Skip to main content

alopex_sql/storage/
range_read.rs

1//! Fenced, range-bounded table scans for distributed-read workers.
2//!
3//! Local SQL retains its historical table-prefix scan entry points.  A remote
4//! worker must instead carry a [`StorageRangeConstraint`] and a transaction
5//! opened at the identical cluster-issued [`ReadAtPoint`].
6
7use std::fmt;
8
9use alopex_core::{EncodedRowKeyRange, ReadAtPoint, RowKeyRange};
10
11use crate::catalog::TableMetadata;
12
13use super::error::Result;
14use super::{KeyEncoder, SqlValue, StorageError, TableScanIterator};
15
16/// The catalog and storage identities pinned for one range-worker scan.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct RangeReadSnapshot {
19    read_at: ReadAtPoint,
20    schema_manifest_id: String,
21}
22
23impl RangeReadSnapshot {
24    /// Creates the immutable catalog/schema/index fence for a range read.
25    pub fn new(
26        read_at: ReadAtPoint,
27        schema_manifest_id: impl Into<String>,
28    ) -> std::result::Result<Self, StorageRangeConstraintError> {
29        let schema_manifest_id = schema_manifest_id.into();
30        if schema_manifest_id.trim().is_empty() {
31            return Err(StorageRangeConstraintError::MissingSchemaManifest);
32        }
33        Ok(Self {
34            read_at,
35            schema_manifest_id,
36        })
37    }
38
39    /// Returns the cluster-issued read point backing this snapshot.
40    pub const fn read_at(&self) -> ReadAtPoint {
41        self.read_at
42    }
43
44    /// Returns the immutable schema-manifest identity.
45    pub fn schema_manifest_id(&self) -> &str {
46        &self.schema_manifest_id
47    }
48
49    /// Returns the pinned committed catalog version.
50    pub const fn catalog_version(&self) -> u64 {
51        self.read_at.metadata_version
52    }
53
54    /// Returns the pinned schema epoch.
55    pub const fn schema_epoch(&self) -> u64 {
56        self.read_at.schema_epoch
57    }
58
59    /// Returns the pinned index epoch.
60    pub const fn index_epoch(&self) -> u64 {
61        self.read_at.index_epoch
62    }
63}
64
65/// Mandatory storage bounds and immutable fence for one physical range.
66///
67/// The primary-key interval is always represented as concrete half-open KV
68/// bounds.  Even a full logical table uses the canonical table-prefix end,
69/// never `scan_prefix`.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct StorageRangeConstraint {
72    range_id: String,
73    generation: u64,
74    row_key_range: RowKeyRange,
75    encoded_bounds: EncodedRowKeyRange,
76    snapshot: RangeReadSnapshot,
77}
78
79impl StorageRangeConstraint {
80    /// Constructs a validated, range-bound worker constraint.
81    pub fn new(
82        range_id: impl Into<String>,
83        generation: u64,
84        row_key_range: RowKeyRange,
85        snapshot: RangeReadSnapshot,
86    ) -> std::result::Result<Self, StorageRangeConstraintError> {
87        let range_id = range_id.into();
88        if range_id.trim().is_empty() {
89            return Err(StorageRangeConstraintError::MissingRangeIdentity);
90        }
91        Ok(Self {
92            range_id,
93            generation,
94            encoded_bounds: row_key_range.encoded_bounds(),
95            row_key_range,
96            snapshot,
97        })
98    }
99
100    /// Returns the physical range identity selected by the route planner.
101    pub fn range_id(&self) -> &str {
102        &self.range_id
103    }
104
105    /// Returns the immutable range-generation fence.
106    pub const fn generation(&self) -> u64 {
107        self.generation
108    }
109
110    /// Returns the sole table whose rows may be emitted.
111    pub const fn table_id(&self) -> u32 {
112        self.row_key_range.table_id()
113    }
114
115    /// Returns the canonical primary-key interval.
116    pub const fn row_key_range(&self) -> RowKeyRange {
117        self.row_key_range
118    }
119
120    /// Returns concrete KV scan bounds `[lower_inclusive, upper_exclusive)`.
121    pub fn encoded_bounds(&self) -> (&[u8], &[u8]) {
122        (
123            &self.encoded_bounds.lower_inclusive,
124            &self.encoded_bounds.upper_exclusive,
125        )
126    }
127
128    /// Returns the immutable catalog/schema/index snapshot fence.
129    pub fn snapshot(&self) -> &RangeReadSnapshot {
130        &self.snapshot
131    }
132
133    /// Validates that a supplied SQL table matches the planned physical range.
134    pub fn validate_table(&self, table_meta: &TableMetadata) -> Result<()> {
135        if table_meta.table_id != self.table_id() {
136            return Err(StorageError::CorruptedData {
137                reason: format!(
138                    "range {} is fenced for table {}, not table {}",
139                    self.range_id,
140                    self.table_id(),
141                    table_meta.table_id
142                ),
143            });
144        }
145        Ok(())
146    }
147
148    /// Validates that the storage transaction is pinned at this exact fence.
149    pub fn validate_read_at(&self, actual: Option<ReadAtPoint>) -> Result<()> {
150        match actual {
151            Some(actual) if actual == self.snapshot.read_at() => Ok(()),
152            Some(actual) => Err(StorageError::CorruptedData {
153                reason: format!(
154                    "range {} read-at fence mismatch: expected {:?}, got {:?}",
155                    self.range_id,
156                    self.snapshot.read_at(),
157                    actual
158                ),
159            }),
160            None => Err(StorageError::CorruptedData {
161                reason: format!(
162                    "range {} requires a transaction opened at its read-at fence",
163                    self.range_id
164                ),
165            }),
166        }
167    }
168
169    /// Rechecks a canonical primary key after a secondary-index lookup.
170    pub fn contains_primary_key(&self, encoded_primary_key: &[u8]) -> Result<bool> {
171        KeyEncoder::primary_key_is_in_range(encoded_primary_key, self.row_key_range)
172    }
173
174    /// Rechecks every secondary-index candidate against the primary-key range.
175    ///
176    /// Index sort order is not a substitute for physical row bounds.  A
177    /// candidate outside the range is omitted before its row can be fetched or
178    /// emitted.
179    pub fn recheck_secondary_index_hits<I>(&self, row_ids: I) -> Result<Vec<u64>>
180    where
181        I: IntoIterator<Item = u64>,
182    {
183        row_ids
184            .into_iter()
185            .filter_map(|row_id| {
186                match self.contains_primary_key(&KeyEncoder::row_key(self.table_id(), row_id)) {
187                    Ok(true) => Some(Ok(row_id)),
188                    Ok(false) => None,
189                    Err(error) => Some(Err(error)),
190                }
191            })
192            .collect()
193    }
194}
195
196/// A constrained table iterator which refuses a row that escapes its range.
197pub struct RangeBoundedScanIterator<'a> {
198    inner: TableScanIterator<'a>,
199    constraint: StorageRangeConstraint,
200}
201
202impl<'a> RangeBoundedScanIterator<'a> {
203    /// Wraps a storage scan with a mandatory primary-key recheck.
204    pub fn new(inner: TableScanIterator<'a>, constraint: StorageRangeConstraint) -> Self {
205        Self { inner, constraint }
206    }
207}
208
209impl Iterator for RangeBoundedScanIterator<'_> {
210    type Item = Result<(u64, Vec<SqlValue>)>;
211
212    fn next(&mut self) -> Option<Self::Item> {
213        self.inner.next().map(|entry| {
214            let (row_id, values) = entry?;
215            let key = KeyEncoder::row_key(self.constraint.table_id(), row_id);
216            if self.constraint.contains_primary_key(&key)? {
217                Ok((row_id, values))
218            } else {
219                Err(StorageError::CorruptedData {
220                    reason: format!(
221                        "storage scan returned row {} outside fenced range {}",
222                        row_id,
223                        self.constraint.range_id()
224                    ),
225                })
226            }
227        })
228    }
229}
230
231/// Validation failure while constructing a mandatory worker constraint.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum StorageRangeConstraintError {
234    /// A range worker cannot operate without a stable route identity.
235    MissingRangeIdentity,
236    /// A range worker cannot operate without the pinned schema manifest.
237    MissingSchemaManifest,
238}
239
240impl fmt::Display for StorageRangeConstraintError {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::MissingRangeIdentity => f.write_str("range constraint has no range identity"),
244            Self::MissingSchemaManifest => {
245                f.write_str("range constraint has no schema-manifest identity")
246            }
247        }
248    }
249}
250
251impl std::error::Error for StorageRangeConstraintError {}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::storage::{RowCodec, TableScanIterator};
257    use alopex_core::CanonicalRowKey;
258
259    fn constraint() -> StorageRangeConstraint {
260        let snapshot =
261            RangeReadSnapshot::new(ReadAtPoint::new(7, 11, 13, 17), "schema-13").unwrap();
262        StorageRangeConstraint::new(
263            "range-a",
264            3,
265            RowKeyRange::new(7, Some(10), Some(20)).unwrap(),
266            snapshot,
267        )
268        .unwrap()
269    }
270
271    #[test]
272    fn constraint_uses_concrete_canonical_bounds_not_a_prefix_scan() {
273        let constraint = constraint();
274        let (lower, upper) = constraint.encoded_bounds();
275
276        assert_eq!(lower, KeyEncoder::row_key(7, 10));
277        assert_eq!(upper, KeyEncoder::row_key(7, 20));
278        assert_ne!(lower, KeyEncoder::table_prefix(7));
279    }
280
281    #[test]
282    fn secondary_index_candidates_are_rechecked_against_primary_bounds() {
283        let constraint = constraint();
284
285        assert_eq!(
286            constraint
287                .recheck_secondary_index_hits([9, 10, 19, 20])
288                .unwrap(),
289            vec![10, 19]
290        );
291    }
292
293    #[test]
294    fn scan_wrapper_never_emits_an_out_of_range_primary_row() {
295        let entries = vec![(
296            KeyEncoder::row_key(7, 20),
297            RowCodec::encode(&[SqlValue::Integer(20)]),
298        )];
299        let table_scan = TableScanIterator::new(Box::new(entries.into_iter()), 7);
300        let mut scan = RangeBoundedScanIterator::new(table_scan, constraint());
301
302        assert!(matches!(
303            scan.next(),
304            Some(Err(StorageError::CorruptedData { .. }))
305        ));
306    }
307
308    #[test]
309    fn constraint_requires_route_and_schema_identities() {
310        let point = ReadAtPoint::new(1, 2, 3, 4);
311        assert_eq!(
312            RangeReadSnapshot::new(point, ""),
313            Err(StorageRangeConstraintError::MissingSchemaManifest)
314        );
315        let snapshot = RangeReadSnapshot::new(point, "schema-3").unwrap();
316        assert_eq!(
317            StorageRangeConstraint::new("", 1, RowKeyRange::full_table(7), snapshot),
318            Err(StorageRangeConstraintError::MissingRangeIdentity)
319        );
320    }
321
322    #[test]
323    fn constraint_rejects_a_different_read_at_fence() {
324        let constraint = constraint();
325        assert!(
326            constraint
327                .validate_read_at(Some(ReadAtPoint::new(7, 11, 13, 18)))
328                .is_err()
329        );
330        assert!(constraint.validate_read_at(None).is_err());
331        assert!(
332            constraint
333                .validate_read_at(Some(ReadAtPoint::new(7, 11, 13, 17)))
334                .is_ok()
335        );
336    }
337
338    #[test]
339    fn primary_key_recheck_rejects_malformed_keys() {
340        let constraint = constraint();
341        assert!(matches!(
342            constraint.contains_primary_key(&[0x01]),
343            Err(StorageError::InvalidKeyFormat)
344        ));
345        assert!(
346            !constraint
347                .contains_primary_key(&CanonicalRowKey::new(8, 19).encode())
348                .unwrap()
349        );
350    }
351}