Skip to main content

spiral/scandal/
types.rs

1//! Exact table scan requests and results. The scandal contract defines the wire types; this
2//! module adds their behavior and the runtime's own scan options.
3
4use std::ops::Range;
5
6use serde::{Deserialize, Serialize};
7use spiral_contract::safelong::SafeUnsignedLong;
8use uuid::Uuid;
9
10pub use super::api::{
11    FilterClause, FilterSupport, FragmentReceipt, PlannedSplit, PreparedWriteReceipt, RowRange,
12    Scan, ScanInfo, ScanMetricsSnapshot, ScanPlan, Split, SplitTake, StorageCounters, StorageScope,
13    TableVersion, WindowCounters,
14};
15use super::emit::Emit;
16use super::error::ScandalError;
17use super::expression::{Expression, NamedExpression};
18use super::profile::ExecutionOptions;
19
20/// The most positions one `TakeRows` request takes.
21pub const MAX_TAKE_POSITIONS: usize = 65_536;
22/// The most splits one `TakeRows` request takes from.
23pub const MAX_TAKES: usize = 256;
24
25impl Default for StorageCounters {
26    fn default() -> Self {
27        Self {
28            scope: StorageScope::Complete,
29            bytes: SafeUnsignedLong(0),
30            requests: SafeUnsignedLong(0),
31            read_time_ns: SafeUnsignedLong(0),
32        }
33    }
34}
35
36impl Default for WindowCounters {
37    fn default() -> Self {
38        Self {
39            visited: SafeUnsignedLong(0),
40            pruned_statistics: SafeUnsignedLong(0),
41        }
42    }
43}
44
45impl Default for ScanMetricsSnapshot {
46    fn default() -> Self {
47        Self {
48            storage: None,
49            windows: None,
50            fragments_considered: SafeUnsignedLong(0),
51            fragments_pruned: SafeUnsignedLong(0),
52            files_considered: SafeUnsignedLong(0),
53            files_pruned: SafeUnsignedLong(0),
54            output_rows: SafeUnsignedLong(0),
55        }
56    }
57}
58
59impl ScanMetricsSnapshot {
60    /// Add another part of the same execution, such as one split's reads, to these counters.
61    pub fn add(&mut self, other: &Self) {
62        self.storage = match (self.storage.take(), other.storage.clone()) {
63            (Some(mut storage), Some(other)) => {
64                storage.bytes.0 += other.bytes.0;
65                storage.requests.0 += other.requests.0;
66                storage.read_time_ns.0 += other.read_time_ns.0;
67                Some(storage)
68            }
69            (storage, other) => storage.or(other),
70        };
71        self.windows = match (self.windows.take(), other.windows.clone()) {
72            (Some(mut windows), Some(other)) => {
73                windows.visited.0 += other.visited.0;
74                windows.pruned_statistics.0 += other.pruned_statistics.0;
75                Some(windows)
76            }
77            (windows, other) => windows.or(other),
78        };
79        self.fragments_considered.0 += other.fragments_considered.0;
80        self.fragments_pruned.0 += other.fragments_pruned.0;
81        self.files_considered.0 += other.files_considered.0;
82        self.files_pruned.0 += other.files_pruned.0;
83        self.output_rows.0 += other.output_rows.0;
84    }
85}
86
87/// Exact scan options. A split token always names the same table version.
88#[derive(Clone, Debug, Default, Deserialize, Serialize)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct ScanOptions {
91    /// Opt-in execution observations, independent of storage counters.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub execution: Option<ExecutionOptions>,
94    /// Collect native execution counters. Disabled by default.
95    #[serde(default, skip_serializing_if = "is_false")]
96    pub collect_metrics: bool,
97    /// Allow unordered execution lanes to share scan work dynamically.
98    /// Otherwise read in stored order through one lane.
99    #[serde(default)]
100    pub partitioned: bool,
101    /// Maximum execution lanes (1–256), independent of the fragment count.
102    /// Defaults to four for unordered scans. Limits and explicit splits use one lane.
103    pub max_streams: Option<usize>,
104    /// Top-level columns, or all columns when omitted.
105    pub columns: Option<Vec<String>>,
106    /// Named row-local output expressions. Mutually exclusive with `columns`.
107    pub projection: Option<Vec<NamedExpression>>,
108    /// Exact predicate; unsupported expressions fail before streaming starts.
109    pub filter: Option<Expression>,
110    /// Maximum returned rows, in stored order.
111    #[serde(default, with = "super::safelong::option_u64")]
112    pub limit: Option<u64>,
113    /// Maximum rows in each delivered batch.
114    pub batch_rows: Option<usize>,
115    /// Opaque split descriptor tied to the requested immutable version.
116    pub split_token: Option<String>,
117    /// Half-open stored positions in this exact version, before filtering or deletion removal.
118    /// Positions concatenate fragments in stored order and are not stable across rewrites.
119    /// Incompatible with split tokens; execution is ordered.
120    pub row_range: Option<ScanRowRange>,
121    /// A top-k or aggregates of the output rows in place of the rows. A scan with an emit
122    /// returns one merged result, reading up to `max_streams` splits at once, and takes no
123    /// `limit`.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub emit: Option<Emit>,
126    /// Add a last output column of this name holding each row's stable row ID, a `u64` that
127    /// survives rewrites and compaction. A top-k can order by this column. Aggregates cannot
128    /// return it.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub row_id_column: Option<String>,
131    /// Keep only rows whose row IDs fall in these half-open ranges. Planning prunes files
132    /// whose row IDs cannot match, and reads enforce the ranges exactly.
133    #[serde(
134        default,
135        skip_serializing_if = "Option::is_none",
136        with = "super::safelong::option_ranges"
137    )]
138    pub row_ids: Option<Vec<Range<u64>>>,
139}
140
141/// Half-open stored positions within one immutable Table version.
142#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
143#[serde(rename_all = "camelCase", deny_unknown_fields)]
144pub struct ScanRowRange {
145    /// Inclusive stored position, before filtering and deletion removal.
146    #[serde(with = "super::safelong::u64")]
147    pub start: u64,
148    /// Exclusive stored position.
149    #[serde(with = "super::safelong::u64")]
150    pub end: u64,
151}
152
153impl ScanRowRange {
154    /// Reject a reversed range; empty ranges are valid.
155    pub fn validate(self) -> Result<(), ScandalError> {
156        if self.start > self.end {
157            return Err(ScandalError::InvalidArgument {
158                message: "rowRange start exceeds end".into(),
159            });
160        }
161        Ok(())
162    }
163}
164
165fn is_false(value: &bool) -> bool {
166    !value
167}
168
169impl ScanOptions {
170    /// Validate the concurrency budget and return the effective execution lane count.
171    pub fn stream_count(&self) -> Result<usize, ScandalError> {
172        if let Some(execution) = &self.execution {
173            execution.validate()?;
174        }
175        if let Some(ranges) = &self.row_ids
176            && ranges.iter().any(|range| range.start > range.end)
177        {
178            return Err(ScandalError::InvalidArgument {
179                message: "a rowIds range starts after its end".into(),
180            });
181        }
182        if let Some(range) = self.row_range {
183            range.validate()?;
184            if self.split_token.is_some() {
185                return Err(ScandalError::InvalidArgument {
186                    message: "rowRange and splitToken are mutually exclusive".into(),
187                });
188            }
189        }
190        let count = self.max_streams.unwrap_or(4);
191        if !(1..=256).contains(&count) {
192            return Err(ScandalError::InvalidArgument {
193                message: "maxStreams must be between 1 and 256".into(),
194            });
195        }
196        Ok(
197            if self.partitioned
198                && self.limit.is_none()
199                && self.split_token.is_none()
200                && self.row_range.is_none()
201            {
202                count
203            } else {
204                1
205            },
206        )
207    }
208}
209
210/// Independently readable split of one immutable table version.
211#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct TableSplit {
214    /// Descriptor for scheduling; does not grant access to data.
215    pub token: String,
216    #[serde(default, with = "super::safelong::option_u64")]
217    pub estimated_bytes: Option<u64>,
218    #[serde(default, with = "super::safelong::option_u64")]
219    pub estimated_rows: Option<u64>,
220}
221
222impl ScanInfo {
223    /// Reject predicates that cannot be enforced exactly before any rows are read.
224    pub fn require_exact_filter(&self) -> Result<(), ScandalError> {
225        require_exact_filter(&self.filter_clauses)
226    }
227}
228
229/// Reject predicate clauses that require residual evaluation outside the scan.
230pub fn require_exact_filter(clauses: &[FilterClause]) -> Result<(), ScandalError> {
231    if let Some(clause) = clauses
232        .iter()
233        .find(|clause| clause.support != FilterSupport::Exact)
234    {
235        return Err(ScandalError::InvalidArgument {
236            message: clause
237                .reason
238                .clone()
239                .unwrap_or_else(|| "scan predicate cannot be enforced exactly".to_owned()),
240        });
241    }
242    Ok(())
243}
244
245impl Scan {
246    /// Read every column of `table` without a filter or limit.
247    pub fn new(table: TableVersion) -> Self {
248        Self {
249            table,
250            columns: None,
251            projection: None,
252            filter: None,
253            limit: None,
254            batch_rows: None,
255            collect_metrics: None,
256            execution: None,
257            emit: None,
258            row_range: None,
259            row_id_column: None,
260            row_ids: None,
261        }
262    }
263
264    /// Whether the scan reports execution counters.
265    pub fn collects_metrics(&self) -> bool {
266        self.collect_metrics.unwrap_or(false)
267    }
268}
269
270impl RowRange {
271    /// Stored rows `start..end`.
272    pub fn new(rows: Range<u64>) -> Self {
273        Self {
274            start: SafeUnsignedLong(rows.start),
275            end: SafeUnsignedLong(rows.end),
276        }
277    }
278
279    /// These stored rows as a range.
280    pub fn range(&self) -> Range<u64> {
281        self.start.0..self.end.0
282    }
283}
284
285/// Registered immutable table data awaiting Catalog staging and publication.
286#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct TableWriteResult {
289    /// Resource whose storage namespace contains the written data.
290    pub resource_id: Uuid,
291    /// Exact immutable version registered with the resource service.
292    pub version_id: Uuid,
293}
294
295#[cfg(test)]
296mod tests {
297    use super::{ScanOptions, ScanRowRange, TableSplit};
298
299    #[test]
300    fn default_options_and_lossless_split_estimates_round_trip() {
301        let options: ScanOptions = serde_json::from_str("{}").unwrap();
302        assert!(!options.partitioned);
303        assert!(!options.collect_metrics);
304        assert!(
305            serde_json::to_value(&options)
306                .unwrap()
307                .get("collectMetrics")
308                .is_none()
309        );
310        assert_eq!(options.stream_count().unwrap(), 1);
311        let mut parallel = ScanOptions {
312            partitioned: true,
313            max_streams: Some(3),
314            ..Default::default()
315        };
316        assert_eq!(parallel.stream_count().unwrap(), 3);
317        parallel.limit = Some(1);
318        assert_eq!(parallel.stream_count().unwrap(), 1);
319        parallel.max_streams = Some(0);
320        assert!(parallel.stream_count().is_err());
321        parallel.max_streams = Some(257);
322        assert!(parallel.stream_count().is_err());
323        assert!(options.filter.is_none());
324        let split = TableSplit {
325            token: "exact".to_owned(),
326            estimated_rows: Some(u64::MAX),
327            estimated_bytes: Some(12),
328        };
329        let json = serde_json::to_value(split).unwrap();
330        assert_eq!(json["estimatedRows"], u64::MAX.to_string());
331        assert_eq!(json["estimatedBytes"], 12);
332        let decoded: TableSplit = serde_json::from_value(json).unwrap();
333        assert_eq!(decoded.estimated_rows, Some(u64::MAX));
334    }
335    #[test]
336    fn row_ranges_encode_losslessly_and_force_ordered_execution() {
337        let options = ScanOptions {
338            partitioned: true,
339            max_streams: Some(4),
340            row_range: Some(ScanRowRange {
341                start: 3,
342                end: u64::MAX,
343            }),
344            ..Default::default()
345        };
346        let json = serde_json::to_value(&options).unwrap();
347        assert_eq!(
348            json["rowRange"],
349            serde_json::json!({"start": 3, "end": "18446744073709551615"})
350        );
351        let decoded: ScanOptions = serde_json::from_value(json).unwrap();
352        assert_eq!(decoded.row_range, options.row_range);
353        assert_eq!(decoded.stream_count().unwrap(), 1);
354        assert!(
355            ScanOptions {
356                split_token: Some("split".into()),
357                ..options.clone()
358            }
359            .stream_count()
360            .is_err()
361        );
362        assert!(
363            ScanOptions {
364                row_range: Some(ScanRowRange { start: 4, end: 3 }),
365                ..options
366            }
367            .stream_count()
368            .is_err()
369        );
370    }
371}