Skip to main content

quantrs2_device/distributed/
orchestrator.rs

1//! Main distributed orchestrator implementation
2//!
3//! Implements a real, locally-verifiable node registry and workflow
4//! scheduler. Actual dispatch of circuits over a network to remote quantum
5//! devices is out of scope for a pure-local implementation (it requires a
6//! genuine network transport to real, external nodes), so
7//! [`DistributedQuantumOrchestrator::execute_distributed`] performs real
8//! local validation (node availability, capability checks) and then returns
9//! an honest error describing the missing transport instead of fabricating a
10//! successful [`DistributedExecutionResult`].
11
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, Instant};
15
16use petgraph::algo::toposort;
17use petgraph::graph::DiGraph;
18
19use super::config::*;
20use super::types::*;
21
22impl DistributedQuantumOrchestrator {
23    /// Create a new orchestrator with an empty node/workflow registry.
24    pub fn new(config: DistributedOrchestratorConfig) -> Self {
25        Self {
26            config,
27            nodes: Arc::new(Mutex::new(HashMap::new())),
28            workflows: Arc::new(Mutex::new(HashMap::new())),
29            execution_counter: Arc::new(Mutex::new(0)),
30        }
31    }
32
33    /// Attempt to execute a circuit on the distributed system.
34    ///
35    /// This performs real local bookkeeping (execution-id allocation, node
36    /// selection based on the actual registry and node capacity limits) but
37    /// cannot dispatch work to a remote node because no network transport is
38    /// implemented in this build. Rather than fabricate a successful result,
39    /// it reports the specific reason execution could not proceed.
40    pub fn execute_distributed(&self, circuit: &str) -> Result<DistributedExecutionResult, String> {
41        if circuit.trim().is_empty() {
42            return Err("execute_distributed: empty circuit description provided".to_string());
43        }
44
45        let nodes = self
46            .nodes
47            .lock()
48            .unwrap_or_else(std::sync::PoisonError::into_inner);
49        let selected = nodes
50            .values()
51            .filter(|node| matches!(node.status, NodeStatus::Available))
52            .min_by_key(|node| node.node_id.clone());
53        let selected = match selected {
54            Some(node) => node.clone(),
55            None => {
56                return Err(format!(
57                    "execute_distributed: no available nodes registered ({} nodes known, none Available)",
58                    nodes.len()
59                ));
60            }
61        };
62        drop(nodes);
63
64        let mut counter = self
65            .execution_counter
66            .lock()
67            .unwrap_or_else(std::sync::PoisonError::into_inner);
68        *counter += 1;
69        let execution_id = format!("dist-exec-{:08}", *counter);
70        drop(counter);
71
72        // Real local validation succeeded (a capable node exists), but this
73        // build has no inter-node network transport, so we cannot actually
74        // ship the circuit to `selected` and collect real results. Report
75        // that honestly rather than returning a fabricated success.
76        Err(format!(
77            "execute_distributed[{execution_id}]: node '{}' selected and available, but network \
78             dispatch is not implemented in this build (requires a real inter-node transport); \
79             refusing to fabricate execution results",
80            selected.node_id
81        ))
82    }
83
84    /// Register (or update) a node in the orchestrator's registry.
85    pub fn add_node(&mut self, node_info: NodeInfo) -> Result<(), String> {
86        if node_info.node_id.trim().is_empty() {
87            return Err("add_node: node_id must not be empty".to_string());
88        }
89        let mut nodes = self
90            .nodes
91            .lock()
92            .unwrap_or_else(std::sync::PoisonError::into_inner);
93        nodes.insert(node_info.node_id.clone(), node_info);
94        Ok(())
95    }
96
97    /// Remove a previously registered node from the registry.
98    pub fn remove_node(&mut self, node_id: &str) -> Result<(), String> {
99        let mut nodes = self
100            .nodes
101            .lock()
102            .unwrap_or_else(std::sync::PoisonError::into_inner);
103        match nodes.remove(node_id) {
104            Some(_) => Ok(()),
105            None => Err(format!("remove_node: node '{node_id}' is not registered")),
106        }
107    }
108
109    /// Look up the real, currently-recorded status of a registered node.
110    pub fn get_node_status(&self, node_id: &str) -> Result<NodeStatus, String> {
111        let nodes = self
112            .nodes
113            .lock()
114            .unwrap_or_else(std::sync::PoisonError::into_inner);
115        nodes
116            .get(node_id)
117            .map(|node| node.status.clone())
118            .ok_or_else(|| format!("get_node_status: node '{node_id}' is not registered"))
119    }
120
121    /// Validate and register a workflow for scheduling, returning a real
122    /// generated workflow id. The workflow's step dependency graph is
123    /// checked for cycles using a real topological sort (petgraph); a
124    /// workflow whose dependencies form a cycle is rejected rather than
125    /// silently accepted.
126    pub fn schedule_workflow(&self, workflow: DistributedWorkflow) -> Result<String, String> {
127        if workflow.steps.is_empty() {
128            return Err("schedule_workflow: workflow has no steps".to_string());
129        }
130
131        // Build a real dependency graph: an edge dependency -> step means
132        // `dependency` must run before `step`.
133        let mut graph = DiGraph::<&str, ()>::new();
134        let mut node_index = HashMap::new();
135        for step in &workflow.steps {
136            let idx = graph.add_node(step.as_str());
137            node_index.insert(step.as_str(), idx);
138        }
139        for (step, deps) in &workflow.dependencies {
140            let Some(&step_idx) = node_index.get(step.as_str()) else {
141                return Err(format!(
142                    "schedule_workflow: dependency entry references unknown step '{step}'"
143                ));
144            };
145            for dep in deps {
146                let Some(&dep_idx) = node_index.get(dep.as_str()) else {
147                    return Err(format!(
148                        "schedule_workflow: step '{step}' depends on unknown step '{dep}'"
149                    ));
150                };
151                graph.add_edge(dep_idx, step_idx, ());
152            }
153        }
154        if toposort(&graph, None).is_err() {
155            return Err(
156                "schedule_workflow: workflow dependency graph contains a cycle".to_string(),
157            );
158        }
159
160        let mut counter = self
161            .execution_counter
162            .lock()
163            .unwrap_or_else(std::sync::PoisonError::into_inner);
164        *counter += 1;
165        let generated_id = if workflow.workflow_id.trim().is_empty() {
166            format!("workflow-{:08}", *counter)
167        } else {
168            workflow.workflow_id.clone()
169        };
170        drop(counter);
171
172        let mut workflows = self
173            .workflows
174            .lock()
175            .unwrap_or_else(std::sync::PoisonError::into_inner);
176        workflows.insert(generated_id.clone(), workflow);
177        Ok(generated_id)
178    }
179
180    /// Number of nodes currently registered (used by callers/tests to
181    /// observe real registry state).
182    pub fn node_count(&self) -> usize {
183        self.nodes
184            .lock()
185            .unwrap_or_else(std::sync::PoisonError::into_inner)
186            .len()
187    }
188
189    /// Number of workflows currently scheduled.
190    pub fn workflow_count(&self) -> usize {
191        self.workflows
192            .lock()
193            .unwrap_or_else(std::sync::PoisonError::into_inner)
194            .len()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use std::net::SocketAddr;
202
203    fn make_node(id: &str, status: NodeStatus) -> NodeInfo {
204        NodeInfo {
205            node_id: id.to_string(),
206            address: "127.0.0.1:8000".parse::<SocketAddr>().unwrap(),
207            capabilities: NodeCapabilities::default(),
208            status,
209            last_heartbeat: Some(Instant::now()),
210        }
211    }
212
213    #[test]
214    fn test_orchestrator_real_node_registry() {
215        let mut orchestrator =
216            DistributedQuantumOrchestrator::new(DistributedOrchestratorConfig::default());
217        assert_eq!(orchestrator.node_count(), 0);
218
219        orchestrator
220            .add_node(make_node("node_a", NodeStatus::Available))
221            .unwrap();
222        assert_eq!(orchestrator.node_count(), 1);
223        assert!(matches!(
224            orchestrator.get_node_status("node_a").unwrap(),
225            NodeStatus::Available
226        ));
227
228        // A node that was never added must be honestly reported as unknown,
229        // not fabricated as Available.
230        let err = orchestrator.get_node_status("node_missing").unwrap_err();
231        assert!(err.contains("not registered"));
232
233        orchestrator.remove_node("node_a").unwrap();
234        assert_eq!(orchestrator.node_count(), 0);
235        assert!(orchestrator.remove_node("node_a").is_err());
236    }
237
238    #[test]
239    fn test_execute_distributed_honest_when_no_nodes() {
240        let orchestrator =
241            DistributedQuantumOrchestrator::new(DistributedOrchestratorConfig::default());
242        let result = orchestrator.execute_distributed("H 0; CNOT 0 1");
243        let err = result.unwrap_err();
244        assert!(err.contains("no available nodes"));
245    }
246
247    #[test]
248    fn test_execute_distributed_honest_error_no_fake_success() {
249        let mut orchestrator =
250            DistributedQuantumOrchestrator::new(DistributedOrchestratorConfig::default());
251        orchestrator
252            .add_node(make_node("node_a", NodeStatus::Available))
253            .unwrap();
254        // Even with a real, available node, this build has no network
255        // transport, so the call must fail honestly rather than fabricate a
256        // DistributedExecutionResult::default() success.
257        let result = orchestrator.execute_distributed("H 0");
258        let err = result.unwrap_err();
259        assert!(err.contains("network"));
260        assert!(err.contains("node_a"));
261    }
262
263    #[test]
264    fn test_schedule_workflow_rejects_cycle() {
265        let orchestrator =
266            DistributedQuantumOrchestrator::new(DistributedOrchestratorConfig::default());
267        let mut dependencies = HashMap::new();
268        dependencies.insert("step_a".to_string(), vec!["step_b".to_string()]);
269        dependencies.insert("step_b".to_string(), vec!["step_a".to_string()]);
270        let workflow = DistributedWorkflow {
271            workflow_id: String::new(),
272            workflow_type: DistributedWorkflowType::Sequential,
273            steps: vec!["step_a".to_string(), "step_b".to_string()],
274            dependencies,
275        };
276        let err = orchestrator.schedule_workflow(workflow).unwrap_err();
277        assert!(err.contains("cycle"));
278    }
279
280    #[test]
281    fn test_schedule_workflow_accepts_valid_dag_and_generates_id() {
282        let orchestrator =
283            DistributedQuantumOrchestrator::new(DistributedOrchestratorConfig::default());
284        let mut dependencies = HashMap::new();
285        dependencies.insert("step_b".to_string(), vec!["step_a".to_string()]);
286        let workflow = DistributedWorkflow {
287            workflow_id: String::new(),
288            workflow_type: DistributedWorkflowType::Sequential,
289            steps: vec!["step_a".to_string(), "step_b".to_string()],
290            dependencies,
291        };
292        let id = orchestrator.schedule_workflow(workflow).unwrap();
293        assert!(id.starts_with("workflow-"));
294        assert_eq!(orchestrator.workflow_count(), 1);
295    }
296}