Skip to main content

nodedb_graph/
traversal_options.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-query graph traversal configuration.
4//!
5//! Adaptive fan-out uses a two-tier limit with optional graceful
6//! degradation instead of a hard kill.
7
8/// Largest accepted value for any graph-DSL depth parameter
9/// (`DEPTH`, `MAX_DEPTH`, `EXPANSION_DEPTH`).
10///
11/// Enforced at every ingress (pgwire, native protocol) and at the
12/// engine boundary as defence-in-depth so a single statement cannot
13/// saturate `cross_core_bfs`, `csr.shortest_path`, or the subgraph
14/// materializer with an unbounded fan-out per hop.
15pub const MAX_GRAPH_TRAVERSAL_DEPTH: usize = 64;
16
17use serde::{Deserialize, Serialize};
18
19/// Per-query graph traversal configuration.
20///
21/// Controls fan-out limits, partial result handling, and visited node caps
22/// for scatter-gather graph queries across shards.
23#[derive(
24    Debug,
25    Clone,
26    PartialEq,
27    Eq,
28    Serialize,
29    Deserialize,
30    zerompk::ToMessagePack,
31    zerompk::FromMessagePack,
32)]
33pub struct GraphTraversalOptions {
34    /// Soft warning threshold (shards per hop).
35    ///
36    /// When the number of shards reached in a single hop exceeds this value,
37    /// a fan-out warning is emitted but execution continues.
38    /// Default: 12
39    pub fan_out_soft: u16,
40
41    /// Hard limit (shards per hop).
42    ///
43    /// Maximum number of shards that can be queried in a single hop.
44    /// If exceeded and `fan_out_partial` is false, returns FAN_OUT_EXCEEDED error.
45    /// Default: 16
46    pub fan_out_hard: u16,
47
48    /// If true, return partial results instead of FAN_OUT_EXCEEDED error.
49    ///
50    /// When the hard limit is exceeded, instead of failing with FAN_OUT_EXCEEDED,
51    /// this flag allows the response to be marked as truncated with partial results.
52    /// Default: false
53    pub fan_out_partial: bool,
54
55    /// Cap on total visited nodes across all shards.
56    ///
57    /// Once this limit is reached, no further node exploration occurs.
58    /// Default: 100_000
59    pub max_visited: usize,
60}
61
62impl Default for GraphTraversalOptions {
63    fn default() -> Self {
64        Self {
65            fan_out_soft: 12,
66            fan_out_hard: 16,
67            fan_out_partial: false,
68            max_visited: 100_000,
69        }
70    }
71}
72
73impl GraphTraversalOptions {
74    /// Create a new `GraphTraversalOptions` with default values.
75    pub fn new() -> Self {
76        Self::default()
77    }
78}
79
80/// Response metadata for scatter-gather graph query results.
81///
82/// Tracks how many shards were reached, skipped, and whether results are
83/// complete or truncated due to adaptive fan-out limits.
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct GraphResponseMeta {
86    /// Number of shards that were queried and returned results.
87    pub shards_reached: u16,
88
89    /// Number of shards that were skipped due to fan-out limits.
90    pub shards_skipped: u16,
91
92    /// Whether results are incomplete (true) or complete (false).
93    pub truncated: bool,
94
95    /// Fan-out warning message if soft limit was exceeded.
96    ///
97    /// Format: "X/Y" where X is shards_reached and Y is fan_out_hard.
98    /// None if no warning.
99    pub fan_out_warning: Option<String>,
100
101    /// Whether results gathered beyond the soft limit are approximate.
102    ///
103    /// Set to true when shards_reached > fan_out_soft.
104    pub approximate: bool,
105}
106
107impl GraphResponseMeta {
108    /// Check if this response has no warnings or truncation.
109    ///
110    /// Returns true if:
111    /// - No fan-out warning
112    /// - Not truncated
113    /// - Not approximate
114    pub fn is_clean(&self) -> bool {
115        self.fan_out_warning.is_none() && !self.truncated && !self.approximate
116    }
117
118    /// Create response metadata with a fan-out warning.
119    ///
120    /// Indicates that the soft limit was exceeded but execution continued.
121    /// Creates a warning string like "12/16" showing reached vs hard limit.
122    pub fn with_warning(shards_reached: u16, shards_skipped: u16, fan_out_hard: u16) -> Self {
123        Self {
124            shards_reached,
125            shards_skipped,
126            truncated: false,
127            fan_out_warning: Some(format!("{}/{}", shards_reached, fan_out_hard)),
128            approximate: true,
129        }
130    }
131
132    /// Create response metadata for truncated results.
133    ///
134    /// Indicates that results were incomplete due to fan-out limits.
135    pub fn with_truncation(shards_reached: u16, shards_skipped: u16) -> Self {
136        Self {
137            shards_reached,
138            shards_skipped,
139            truncated: true,
140            fan_out_warning: None,
141            approximate: true,
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use sonic_rs;
150
151    #[test]
152    fn default_options_have_expected_values() {
153        let opts = GraphTraversalOptions::default();
154        assert_eq!(opts.fan_out_soft, 12);
155        assert_eq!(opts.fan_out_hard, 16);
156        assert!(!opts.fan_out_partial);
157        assert_eq!(opts.max_visited, 100_000);
158    }
159
160    #[test]
161    fn new_returns_defaults() {
162        let opts = GraphTraversalOptions::new();
163        assert_eq!(opts, GraphTraversalOptions::default());
164    }
165
166    #[test]
167    fn default_meta_is_clean() {
168        let meta = GraphResponseMeta::default();
169        assert!(meta.is_clean());
170        assert_eq!(meta.shards_reached, 0);
171        assert_eq!(meta.shards_skipped, 0);
172        assert!(!meta.truncated);
173        assert!(meta.fan_out_warning.is_none());
174        assert!(!meta.approximate);
175    }
176
177    #[test]
178    fn with_warning_generates_correct_string() {
179        let meta = GraphResponseMeta::with_warning(12, 4, 16);
180        assert_eq!(meta.shards_reached, 12);
181        assert_eq!(meta.shards_skipped, 4);
182        assert!(!meta.truncated);
183        assert_eq!(meta.fan_out_warning, Some("12/16".to_string()));
184        assert!(meta.approximate);
185    }
186
187    #[test]
188    fn with_truncation_sets_flags() {
189        let meta = GraphResponseMeta::with_truncation(10, 6);
190        assert_eq!(meta.shards_reached, 10);
191        assert_eq!(meta.shards_skipped, 6);
192        assert!(meta.truncated);
193        assert!(meta.fan_out_warning.is_none());
194        assert!(meta.approximate);
195    }
196
197    #[test]
198    fn with_warning_is_not_clean() {
199        let meta = GraphResponseMeta::with_warning(12, 4, 16);
200        assert!(!meta.is_clean());
201    }
202
203    #[test]
204    fn with_truncation_is_not_clean() {
205        let meta = GraphResponseMeta::with_truncation(10, 6);
206        assert!(!meta.is_clean());
207    }
208
209    #[test]
210    fn serialization_roundtrip() {
211        let opts = GraphTraversalOptions {
212            fan_out_soft: 8,
213            fan_out_hard: 12,
214            fan_out_partial: true,
215            max_visited: 50_000,
216        };
217        let json = sonic_rs::to_string(&opts).unwrap();
218        let deserialized: GraphTraversalOptions = sonic_rs::from_str(&json).unwrap();
219        assert_eq!(opts.fan_out_soft, deserialized.fan_out_soft);
220        assert_eq!(opts.fan_out_hard, deserialized.fan_out_hard);
221        assert_eq!(opts.fan_out_partial, deserialized.fan_out_partial);
222        assert_eq!(opts.max_visited, deserialized.max_visited);
223    }
224
225    #[test]
226    fn meta_serialization_roundtrip() {
227        let meta = GraphResponseMeta::with_warning(15, 1, 16);
228        let json = sonic_rs::to_string(&meta).unwrap();
229        let deserialized: GraphResponseMeta = sonic_rs::from_str(&json).unwrap();
230        assert_eq!(meta.shards_reached, deserialized.shards_reached);
231        assert_eq!(meta.shards_skipped, deserialized.shards_skipped);
232        assert_eq!(meta.truncated, deserialized.truncated);
233        assert_eq!(meta.fan_out_warning, deserialized.fan_out_warning);
234        assert_eq!(meta.approximate, deserialized.approximate);
235    }
236}