Skip to main content

nodedb_cluster/distributed_graph/
pattern_match.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Distributed pattern matching — cross-shard scatter-gather for MATCH.
4//!
5//! When a MATCH pattern encounters a ghost edge (destination on another shard),
6//! the partial binding row is sent to the target shard for continuation.
7//! The target shard resumes pattern expansion from the ghost destination node.
8//!
9//! Protocol:
10//! 1. Coordinator broadcasts MATCH query to all shards.
11//! 2. Each shard executes the pattern locally on its CSR.
12//! 3. When a triple crosses a shard boundary (ghost edge):
13//!    - The shard packages the partial binding row + remaining pattern triples.
14//!    - Sends a `PatternContinuation` to the target shard.
15//! 4. Target shard resumes execution with the partial bindings.
16//! 5. Results from all shards are merged by the coordinator.
17//! 6. Iterate until no new continuations are pending.
18
19use std::collections::HashMap;
20
21use serde::{Deserialize, Serialize};
22
23/// A partial MATCH result that needs continuation on another shard.
24///
25/// Contains the current variable bindings and the index of the next
26/// triple to execute in the pattern chain. The target shard resumes
27/// from `next_triple_idx` using the provided bindings.
28#[derive(
29    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
30)]
31pub struct PatternContinuation {
32    /// Target shard that should continue execution.
33    pub target_shard: u32,
34    /// Source shard that generated this continuation.
35    pub source_shard: u32,
36    /// Current variable bindings (node_name → value).
37    pub bindings: HashMap<String, String>,
38    /// Index of the next triple to execute in the chain.
39    pub next_triple_idx: usize,
40    /// The ghost node name that the target shard should start from.
41    pub start_node: String,
42    /// The binding variable name for the start node.
43    pub start_binding: String,
44}
45
46/// Coordinator response from a shard for distributed MATCH.
47#[derive(
48    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
49)]
50pub struct ShardMatchResult {
51    /// Shard that produced these results.
52    pub shard_id: u32,
53    /// Completed binding rows (fully matched patterns).
54    pub completed_rows: Vec<HashMap<String, String>>,
55    /// Partial rows that need continuation on other shards.
56    pub continuations: Vec<PatternContinuation>,
57}
58
59/// Coordinator state for distributed MATCH execution.
60///
61/// Tracks pending continuations and completed results across rounds
62/// of scatter-gather until all continuations are resolved.
63#[derive(Debug)]
64pub struct DistributedMatchCoordinator {
65    /// Completed result rows from all shards.
66    pub completed: Vec<HashMap<String, String>>,
67    /// Pending continuations grouped by target shard.
68    pub pending: HashMap<u32, Vec<PatternContinuation>>,
69    /// Round counter (for debugging / max-round termination).
70    pub round: u32,
71    /// Maximum rounds before forced termination (prevent infinite loops).
72    pub max_rounds: u32,
73}
74
75/// Raw routing-resolved fields for [`PatternContinuation::from_resolved`].
76pub struct ResolvedContinuationArgs {
77    pub target_shard: u32,
78    pub source_shard: u32,
79    pub bindings: HashMap<String, String>,
80    pub next_triple_idx: usize,
81    pub start_node: String,
82    pub start_binding: String,
83}
84
85impl PatternContinuation {
86    /// Construct a continuation from its raw routing-resolved fields.
87    ///
88    /// The Control Plane resolves `target_shard` (the owning vShard of the
89    /// frontier node) and `source_shard` (the vShard that emitted the frontier
90    /// entry) via its routing table, then assembles the continuation here. The
91    /// routing classification itself stays in the CP layer — `nodedb-cluster`
92    /// has no access to the routing table — but the field assembly lives with
93    /// the type so the shape cannot drift from the struct definition.
94    pub fn from_resolved(args: ResolvedContinuationArgs) -> Self {
95        let ResolvedContinuationArgs {
96            target_shard,
97            source_shard,
98            bindings,
99            next_triple_idx,
100            start_node,
101            start_binding,
102        } = args;
103        Self {
104            target_shard,
105            source_shard,
106            bindings,
107            next_triple_idx,
108            start_node,
109            start_binding,
110        }
111    }
112}
113
114impl DistributedMatchCoordinator {
115    pub fn new(max_rounds: u32) -> Self {
116        Self {
117            completed: Vec::new(),
118            pending: HashMap::new(),
119            round: 0,
120            max_rounds,
121        }
122    }
123
124    /// Ingest results from a shard.
125    pub fn add_shard_result(&mut self, result: ShardMatchResult) {
126        self.completed.extend(result.completed_rows);
127        for cont in result.continuations {
128            self.pending
129                .entry(cont.target_shard)
130                .or_default()
131                .push(cont);
132        }
133    }
134
135    /// Check if there are pending continuations to dispatch.
136    pub fn has_pending(&self) -> bool {
137        !self.pending.is_empty()
138    }
139
140    /// Take all pending continuations for a target shard.
141    pub fn take_pending(&mut self, shard_id: u32) -> Vec<PatternContinuation> {
142        self.pending.remove(&shard_id).unwrap_or_default()
143    }
144
145    /// Take all pending continuations, grouped by target shard.
146    pub fn take_all_pending(&mut self) -> HashMap<u32, Vec<PatternContinuation>> {
147        std::mem::take(&mut self.pending)
148    }
149
150    /// Advance to next round. Returns `false` if max rounds reached.
151    pub fn advance(&mut self) -> bool {
152        self.round += 1;
153        self.round < self.max_rounds
154    }
155
156    /// Total completed rows.
157    pub fn result_count(&self) -> usize {
158        self.completed.len()
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn pattern_continuation_serde() {
168        let cont = PatternContinuation {
169            target_shard: 2,
170            source_shard: 0,
171            bindings: [("a".into(), "alice".into())].into_iter().collect(),
172            next_triple_idx: 1,
173            start_node: "bob".into(),
174            start_binding: "b".into(),
175        };
176        let bytes = zerompk::to_msgpack_vec(&cont).unwrap();
177        let decoded: PatternContinuation = zerompk::from_msgpack(&bytes).unwrap();
178        assert_eq!(decoded.target_shard, 2);
179        assert_eq!(decoded.start_node, "bob");
180        assert_eq!(decoded.bindings["a"], "alice");
181    }
182
183    #[test]
184    fn shard_match_result_serde() {
185        let result = ShardMatchResult {
186            shard_id: 1,
187            completed_rows: vec![
188                [("a".into(), "alice".into()), ("b".into(), "bob".into())]
189                    .into_iter()
190                    .collect(),
191            ],
192            continuations: vec![],
193        };
194        let bytes = zerompk::to_msgpack_vec(&result).unwrap();
195        let decoded: ShardMatchResult = zerompk::from_msgpack(&bytes).unwrap();
196        assert_eq!(decoded.completed_rows.len(), 1);
197    }
198
199    #[test]
200    fn coordinator_collects_results() {
201        let mut coord = DistributedMatchCoordinator::new(10);
202
203        coord.add_shard_result(ShardMatchResult {
204            shard_id: 0,
205            completed_rows: vec![[("a".into(), "alice".into())].into_iter().collect()],
206            continuations: vec![PatternContinuation {
207                target_shard: 1,
208                source_shard: 0,
209                bindings: [("a".into(), "alice".into())].into_iter().collect(),
210                next_triple_idx: 1,
211                start_node: "bob".into(),
212                start_binding: "b".into(),
213            }],
214        });
215
216        assert_eq!(coord.result_count(), 1);
217        assert!(coord.has_pending());
218        assert_eq!(coord.take_pending(1).len(), 1);
219        assert!(!coord.has_pending());
220    }
221
222    #[test]
223    fn coordinator_multi_round() {
224        let mut coord = DistributedMatchCoordinator::new(5);
225
226        // Round 1: shard 0 produces 2 completed + 1 continuation.
227        coord.add_shard_result(ShardMatchResult {
228            shard_id: 0,
229            completed_rows: vec![
230                [("x".into(), "1".into())].into_iter().collect(),
231                [("x".into(), "2".into())].into_iter().collect(),
232            ],
233            continuations: vec![PatternContinuation {
234                target_shard: 1,
235                source_shard: 0,
236                bindings: HashMap::new(),
237                next_triple_idx: 0,
238                start_node: "n".into(),
239                start_binding: "a".into(),
240            }],
241        });
242
243        assert!(coord.advance()); // Round 1 → 2.
244
245        // Round 2: shard 1 completes the continuation.
246        let pending = coord.take_all_pending();
247        assert_eq!(pending.len(), 1);
248        assert_eq!(pending[&1].len(), 1);
249
250        coord.add_shard_result(ShardMatchResult {
251            shard_id: 1,
252            completed_rows: vec![[("x".into(), "3".into())].into_iter().collect()],
253            continuations: vec![],
254        });
255
256        assert!(!coord.has_pending());
257        assert_eq!(coord.result_count(), 3);
258    }
259
260    #[test]
261    fn coordinator_max_rounds() {
262        let mut coord = DistributedMatchCoordinator::new(2);
263        assert!(coord.advance()); // round 1
264        assert!(!coord.advance()); // round 2 = max
265    }
266
267    #[test]
268    fn coordinator_no_pending_initially() {
269        let coord = DistributedMatchCoordinator::new(10);
270        assert!(!coord.has_pending());
271        assert_eq!(coord.result_count(), 0);
272    }
273}