Skip to main content

rushdb_storage/
read_view.rs

1//! Transitional fallible, pinned reads. Persistent views own one native redb
2//! transaction per physical shard (merged on read); the memory reference uses
3//! logical MVCC without cloning a root. Legacy callers remain separate until
4//! the LMPG/query API migration.
5
6use std::collections::BTreeMap;
7use std::ops::Bound;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::{Arc, Mutex};
10
11use crate::{
12    prefix_range, redb_backend::MergedViews, resources::physical_shard_for, StorageError, Store,
13};
14use rushdb_types::ids::TenantId;
15
16/// Per-read byte ceilings enforced when copying values out of the store.
17#[derive(Clone, Copy, Debug)]
18pub struct ReadLimits {
19    /// Maximum copied key plus value for a single result.
20    pub max_item_bytes: usize,
21    /// Maximum encoded bytes in one current MVCC history block.
22    pub max_history_block_bytes: usize,
23}
24
25impl Default for ReadLimits {
26    fn default() -> Self {
27        Self {
28            max_item_bytes: 8 * 1024 * 1024,
29            max_history_block_bytes: 16 * 1024 * 1024,
30        }
31    }
32}
33
34impl ReadLimits {
35    pub(crate) fn check_item(
36        &self,
37        key_bytes: usize,
38        value_bytes: usize,
39    ) -> Result<(), StorageError> {
40        if key_bytes
41            .checked_add(value_bytes)
42            .is_none_or(|size| size > self.max_item_bytes)
43        {
44            return Err(StorageError::ResourceExhausted(
45                "read item exceeds max_item_bytes".into(),
46            ));
47        }
48        Ok(())
49    }
50}
51
52/// Shared cancellation flag for one or more protected read views.
53#[derive(Clone, Default, Debug)]
54pub struct ReadCancellation(Arc<AtomicBool>);
55
56impl ReadCancellation {
57    /// Request cancellation of reads sharing this flag.
58    pub fn cancel(&self) {
59        self.0.store(true, Ordering::Release);
60    }
61
62    pub(crate) fn same_as(&self, other: &Self) -> bool {
63        Arc::ptr_eq(&self.0, &other.0)
64    }
65
66    fn check(&self) -> Result<(), StorageError> {
67        if self.0.load(Ordering::Acquire) {
68            Err(StorageError::Cancelled)
69        } else {
70            Ok(())
71        }
72    }
73}
74
75/// One commit index's retained read lease.
76///
77/// `generation` identifies the set of live views sharing this entry. It changes
78/// whenever the entry is re-created after being emptied or expired, so a stale
79/// `ReadView` that drops *after* GC expired its lease cannot decrement a newer,
80/// unrelated pin that happens to sit at the same commit index.
81#[derive(Debug, Clone, Copy)]
82pub struct ReadPin {
83    /// Number of live views currently pinned at this commit index.
84    pub count: usize,
85    /// Wall-clock millis of the most recent pin admission (lease age base).
86    pub lease_ms: u64,
87    /// Identity of this pin generation (see type docs).
88    pub generation: u64,
89}
90
91static PIN_GENERATION: AtomicU64 = AtomicU64::new(1);
92
93fn next_pin_generation() -> u64 {
94    PIN_GENERATION.fetch_add(1, Ordering::Relaxed)
95}
96
97/// Owns all resources needed to read; it can outlive the Store and be shared
98/// with asynchronous providers without self-referential borrowed lifetimes.
99pub struct ReadView {
100    health: Arc<crate::health::StoreHealth>,
101    pins: Arc<Mutex<BTreeMap<u64, ReadPin>>>,
102    cancellations: Arc<Mutex<BTreeMap<u64, Vec<ReadCancellation>>>>,
103    ci: u64,
104    // The store's apply-time deriver, so an overlay over this view can derive
105    // its staged writes. Derived rows of committed state are ordinary
106    // rows of the snapshot.
107    deriver: Option<Arc<dyn crate::Deriver>>,
108    // The store's planner-statistics cache; estimates only.
109    planner_stats: Arc<crate::PlannerStats>,
110    // The store's per-snapshot owner-set cache (READ-2).
111    owner_sets: Arc<crate::owner_sets::OwnerSets>,
112    graphs: Arc<crate::graph_cache::GraphCache>,
113    // Generation of the `ReadPin` entry this view admitted into. Guards Drop
114    // against decrementing a newer pin at the same CI after lease expiry.
115    pin_generation: u64,
116    // Whether the logical cap equals the backend view's current CI. This
117    // remains true across later writes because the backend pins its pages.
118    native_current: bool,
119    // One read view per physical shard, merged on read. A key routes to exactly
120    // one shard, so no in-memory mirror is needed. `None` on a bounded child,
121    // which delegates to its parent.
122    views: Option<MergedViews>,
123    // Must drop after `views`: their cache charge covers the read views too.
124    backends: Vec<Arc<dyn crate::StorageBackend>>,
125    limits: ReadLimits,
126    cancellation: ReadCancellation,
127    // A bounded child keeps the parent's pinned storage transaction alive and
128    // only narrows copying limits. This avoids reopening a later snapshot.
129    parent: Option<Arc<ReadView>>,
130    materialization_limit: usize,
131}
132
133impl Store {
134    /// Open a pinned read view of the latest committed state.
135    ///
136    /// # Errors
137    /// Returns [`StorageError`] if the publisher has failed, the native
138    /// backend read fails, or the default read limits are invalid.
139    pub fn read_view(&self) -> Result<ReadView, StorageError> {
140        self.read_with_limits(None, ReadLimits::default())
141    }
142
143    /// Open a pinned read view restricted to the physical shard that owns
144    /// `tenant`. This avoids opening every shard for tenant-scoped engine
145    /// requests while preserving the same snapshot and lease semantics.
146    ///
147    /// # Errors
148    /// Returns the same errors as [`Store::read_view`].
149    pub fn read_view_for_tenant(&self, tenant: &TenantId) -> Result<ReadView, StorageError> {
150        self.read_with_limits_for_shard(None, ReadLimits::default(), Some(tenant))
151    }
152
153    /// Open a pinned read view of the state at `commit_index`.
154    ///
155    /// **Internal retention/backup mechanism, not a client contract.** There is
156    /// no protocol surface for arbitrary snapshots (see); callers are
157    /// limited to storage internals, offline PITR export, and the transaction
158    /// coordinator's current-snapshot pin. It is `pub` only because those
159    /// callers live in other crates.
160    ///
161    /// # Errors
162    /// Returns [`StorageError`] if the publisher has failed, the native
163    /// backend read fails, or the requested snapshot is in the future or
164    /// expired.
165    #[doc(hidden)]
166    pub fn read_at(&self, commit_index: u64) -> Result<ReadView, StorageError> {
167        self.read_with_limits(Some(commit_index), ReadLimits::default())
168    }
169
170    /// Open a pinned read view with explicit `limits`, optionally at a
171    /// requested commit index.
172    ///
173    /// # Errors
174    /// Returns [`StorageError::InvalidRead`] for zero limits,
175    /// [`StorageError::FutureSnapshot`] when `requested` is newer than the
176    /// visible commit, [`StorageError::SnapshotExpired`] when it predates the
177    /// GC floor, or a backend error if the native view cannot be opened.
178    ///
179    /// Lock poisoning fences the store until reopen.
180    pub fn read_with_limits(
181        &self,
182        requested: Option<u64>,
183        limits: ReadLimits,
184    ) -> Result<ReadView, StorageError> {
185        self.read_with_limits_for_shard(requested, limits, None)
186    }
187
188    fn read_with_limits_for_shard(
189        &self,
190        requested: Option<u64>,
191        limits: ReadLimits,
192        tenant: Option<&TenantId>,
193    ) -> Result<ReadView, StorageError> {
194        if limits.max_item_bytes == 0 || limits.max_history_block_bytes == 0 {
195            return Err(StorageError::InvalidRead(
196                "read byte limits must be positive".into(),
197            ));
198        }
199        // Publication does not need the writer's publish lock: a native read
200        // transaction pins its own physical snapshot, and `committed_version`
201        // is published only after the durable commit. Admission is serialized
202        // with GC floor advancement on `read_pins` alone (see
203        // `retire_before_locked`), so opening a view no longer contends with an
204        // in-flight fsync.
205        self.check_health()?;
206        self.check_publisher()?;
207        // Native direct reads open one transaction per physical shard. A single
208        // shard uses the optimized current-read path; multiple shards merge
209        // their native views per key (the global LSN is unchanged), so reads
210        // stay memory-bounded with no in-memory mirror.
211        let backends: Vec<Arc<dyn crate::StorageBackend>> = match tenant {
212            Some(tenant) if self.resource_config().shard_count > 1 => self
213                .backend(physical_shard_for(
214                    Some(tenant),
215                    self.resource_config().shard_count,
216                ))
217                .into_iter()
218                .collect(),
219            _ if self.resource_config().shard_count == 1 => {
220                self.primary_backend().into_iter().collect()
221            }
222            _ => self.all_backends(),
223        };
224        let has_backends = !backends.is_empty();
225        let mut views: Vec<Box<dyn crate::backend::StorageReadView>> =
226            Vec::with_capacity(backends.len());
227        for backend in &backends {
228            views.push(backend.read_view()?);
229        }
230        let merged = MergedViews::new(views);
231        let (visible, floor) = match (has_backends, tenant) {
232            // A tenant-routed native transaction may lag the global watermark
233            // because another tenant committed on a different shard. The
234            // routed shard is still complete for this tenant, so retain the
235            // store-wide snapshot identity and retention floor.
236            (true, Some(_)) | (false, _) => (self.committed_version(), self.gc_watermark()),
237            (true, None) => (merged.visible_ci()?, merged.history_floor()?),
238        };
239        let ci = requested.unwrap_or(visible);
240        if ci > visible {
241            return Err(StorageError::FutureSnapshot {
242                requested: ci,
243                visible,
244            });
245        }
246        // Serialize the floor check and pin insert with GC floor advancement.
247        // GC stores the new watermark before it reclaims, so any pin inserted
248        // here after the floor moved is rejected; any pin inserted before is
249        // included in GC's clamp.
250        let mut pins = self.health.guard(self.read_pins.lock())?;
251        self.check_publisher()?;
252        let floor = floor.max(self.gc_watermark());
253        if ci < floor {
254            return Err(StorageError::SnapshotExpired {
255                requested: ci,
256                oldest: floor,
257            });
258        }
259        // The pin carries a lease timestamp so GC can expire abandoned readers
260        // by age instead of blocking history forever. Refresh it on every
261        // admission: a long-lived entry with churning readers is still active
262        // and must not be expired out from under them.
263        let lease_ms = crate::wal::now_ms();
264        let cancellation = ReadCancellation::default();
265        self.health
266            .guard(self.read_cancellations.lock())?
267            .entry(ci)
268            .or_default()
269            .push(cancellation.clone());
270        let pin_generation = {
271            let entry = pins.entry(ci).or_insert_with(|| ReadPin {
272                count: 0,
273                lease_ms,
274                generation: next_pin_generation(),
275            });
276            entry.count += 1;
277            entry.lease_ms = lease_ms;
278            entry.generation
279        };
280        let deriver = self.deriver();
281        Ok(ReadView {
282            health: Arc::clone(&self.health),
283            pins: self.read_pins.clone(),
284            cancellations: self.read_cancellations.clone(),
285            ci,
286            deriver,
287            planner_stats: Arc::clone(&self.planner_stats),
288            owner_sets: Arc::clone(&self.owner_sets),
289            graphs: Arc::clone(&self.graphs),
290            pin_generation,
291            native_current: ci == visible,
292            views: Some(merged),
293            backends,
294            limits,
295            cancellation,
296            parent: None,
297            materialization_limit: 64 * 1024 * 1024,
298        })
299    }
300}
301
302impl Drop for ReadView {
303    fn drop(&mut self) {
304        // Bounded children share the root pin through their parent Arc.
305        if self.parent.is_some() {
306            return;
307        }
308        // Release the native pages before admitting physical compaction.
309        self.views.take();
310        self.backends.clear();
311        let mut pins = self.health.cleanup(self.pins.lock());
312        if let Some(entry) = pins.get_mut(&self.ci) {
313            // Only release a pin this view actually admitted. If GC expired our
314            // lease and a new generation was admitted at the same CI, touching
315            // it here would drop a live reader's retention.
316            if entry.generation == self.pin_generation {
317                entry.count = entry.count.saturating_sub(1);
318                if entry.count == 0 {
319                    pins.remove(&self.ci);
320                }
321            }
322        }
323        let mut cancellations = self.health.cleanup(self.cancellations.lock());
324        if let Some(entries) = cancellations.get_mut(&self.ci) {
325            entries.retain(|entry| !entry.same_as(&self.cancellation));
326            if entries.is_empty() {
327                cancellations.remove(&self.ci);
328            }
329        }
330    }
331}
332
333impl ReadView {
334    /// The store's planner-statistics cache. Estimates only; exact
335    /// answers never come from it.
336    #[must_use]
337    pub fn planner_stats_cache(&self) -> &crate::PlannerStats {
338        &self.planner_stats
339    }
340
341    /// The store's owner-set cache, keyed by this view's commit index.
342    #[must_use]
343    pub fn owner_sets(&self) -> &crate::owner_sets::OwnerSets {
344        &self.owner_sets
345    }
346
347    /// The store's resident-adjacency cache, keyed by this view's commit
348    /// index.
349    #[must_use]
350    pub fn graphs(&self) -> &crate::graph_cache::GraphCache {
351        &self.graphs
352    }
353
354    fn storage_view(&self) -> &ReadView {
355        self.parent.as_deref().map_or(self, ReadView::storage_view)
356    }
357
358    #[cfg(test)]
359    pub(crate) fn native_backend_count(&self) -> usize {
360        self.backends.len()
361    }
362
363    /// Process-local Store identity, independent of tenant and commit index.
364    /// Prevents accidentally pairing a view with another engine's catalogs.
365    pub fn belongs_to(&self, store: &Store) -> bool {
366        Arc::ptr_eq(&self.pins, &store.read_pins)
367    }
368
369    /// Commit index this view reads at.
370    #[must_use]
371    pub fn commit_index(&self) -> u64 {
372        self.ci
373    }
374
375    /// The store's apply-time deriver, if one is installed.
376    #[must_use]
377    pub fn deriver(&self) -> Option<&Arc<dyn crate::Deriver>> {
378        self.deriver.as_ref()
379    }
380
381    /// A cloneable handle to this view's cancellation flag.
382    #[must_use]
383    pub fn cancellation(&self) -> ReadCancellation {
384        self.cancellation.clone()
385    }
386
387    /// Create a child view over this exact pinned snapshot with narrower
388    /// per-item and materialized-prefix limits. The child has its own limits,
389    /// while cancellation remains shared with the parent.
390    #[must_use]
391    pub fn with_materialization_limit(self: &Arc<Self>, bytes: usize) -> Arc<ReadView> {
392        Arc::new(ReadView {
393            health: Arc::clone(&self.health),
394            pins: self.pins.clone(),
395            cancellations: self.cancellations.clone(),
396            ci: self.ci,
397            deriver: self.deriver.clone(),
398            planner_stats: Arc::clone(&self.planner_stats),
399            owner_sets: Arc::clone(&self.owner_sets),
400            graphs: Arc::clone(&self.graphs),
401            pin_generation: self.pin_generation,
402            native_current: self.native_current,
403            views: None,
404            backends: Vec::new(),
405            limits: ReadLimits {
406                max_item_bytes: self.limits.max_item_bytes.min(bytes),
407                max_history_block_bytes: self.limits.max_history_block_bytes,
408            },
409            cancellation: self.cancellation.clone(),
410            parent: Some(self.clone()),
411            materialization_limit: self.materialization_limit.min(bytes),
412        })
413    }
414
415    /// Check cancellation between non-storage operations (for example,
416    /// serializing output) without performing an otherwise unnecessary read.
417    ///
418    /// # Errors
419    /// Returns [`StorageError::Cancelled`] if the view has been cancelled.
420    pub fn check_cancelled(&self) -> Result<(), StorageError> {
421        self.health.check()?;
422        self.cancellation.check()
423    }
424
425    /// Read a single key at this view's snapshot.
426    ///
427    /// # Errors
428    /// Returns [`StorageError`] if the view is cancelled, the item exceeds the
429    /// read limit, or the backend read fails.
430    pub fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StorageError> {
431        self.with_value(key, |value| value.map(<[u8]>::to_vec))
432    }
433
434    /// Read a single key at this view's snapshot without copying it: `f`
435    /// sees the stored value bytes in place (`None` when the key is not
436    /// visible) and its result is returned. The same read as [`Self::get`]
437    /// (visibility, limits, cancellation, read accounting) minus the owned
438    /// copy, for callers that only inspect the value, such as scoring a
439    /// stored vector.
440    ///
441    /// `f` runs while the backend holds the read (a redb page guard or the
442    /// in-memory maps' read locks): keep it short and never read the store
443    /// from inside it.
444    ///
445    /// # Errors
446    /// Returns [`StorageError`] if the view is cancelled, the item exceeds the
447    /// read limit, or the backend read fails; `f` is not called then.
448    pub fn with_value<R>(
449        &self,
450        key: &[u8],
451        f: impl FnOnce(Option<&[u8]>) -> R,
452    ) -> Result<R, StorageError> {
453        self.visit_value(key, ScanProjection::KeyValue, self.limits, f)
454    }
455
456    /// Transitional materializing helper for consumers migrating to cursors.
457    /// The local ceiling is not a substitute for shared query admission.
458    ///
459    /// # Errors
460    /// Returns [`StorageError`] if the view is cancelled, the item exceeds the
461    /// read limit, or the materialized result exceeds the 64 MiB ceiling.
462    ///
463    /// # Panics
464    /// Panics if a key/value projection batch unexpectedly yields a row
465    /// without a value.
466    pub fn range_prefix(&self, prefix: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StorageError> {
467        self.materialize_scan(ScanBounds::prefix(prefix))
468    }
469
470    /// Materialize every key/value pair with `start <= key < end`, in key
471    /// order. An empty `end` means unbounded above. Shares the prefix path's
472    /// aggregate materialization ceiling.
473    ///
474    /// # Errors
475    /// Returns [`StorageError`] if the view is cancelled, the item exceeds the
476    /// read limit, or the materialized result exceeds the 64 MiB ceiling.
477    pub fn range(&self, start: &[u8], end: &[u8]) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StorageError> {
478        let bounds = ScanBounds {
479            start: Bound::Included(start.to_vec()),
480            end: if end.is_empty() {
481                Bound::Unbounded
482            } else {
483                Bound::Excluded(end.to_vec())
484            },
485        };
486        self.materialize_scan(bounds)
487    }
488
489    fn materialize_scan(
490        &self,
491        bounds: ScanBounds,
492    ) -> Result<Vec<(Vec<u8>, Vec<u8>)>, StorageError> {
493        let mut cursor = self.scan(bounds, ScanProjection::KeyValue)?;
494        let mut rows = Vec::new();
495        let mut bytes = 0usize;
496        loop {
497            // Keep each fetch within the remaining aggregate cap so a large
498            // prefix cannot transiently materialize beyond the child budget.
499            let remaining = self.materialization_limit.saturating_sub(bytes);
500            let batch = cursor.next_batch(1024, remaining.max(1))?;
501            bytes = bytes.checked_add(batch.bytes).ok_or_else(|| {
502                StorageError::ResourceExhausted("materialized read size overflow".into())
503            })?;
504            if bytes > self.materialization_limit {
505                return Err(StorageError::ResourceExhausted(
506                    "materialized read exceeds configured limit; use a cursor".into(),
507                ));
508            }
509            rows.extend(
510                batch
511                    .rows
512                    .into_iter()
513                    .map(|row| (row.key, row.value.expect("key/value projection"))),
514            );
515            if batch.done {
516                return Ok(rows);
517            }
518        }
519    }
520
521    fn read_value_with_limits(
522        &self,
523        key: &[u8],
524        projection: ScanProjection,
525        limits: ReadLimits,
526    ) -> Result<Option<Vec<u8>>, StorageError> {
527        self.visit_value(key, projection, limits, |value| value.map(<[u8]>::to_vec))
528    }
529
530    fn visit_value<R>(
531        &self,
532        key: &[u8],
533        projection: ScanProjection,
534        limits: ReadLimits,
535        visit: impl FnOnce(Option<&[u8]>) -> R,
536    ) -> Result<R, StorageError> {
537        self.health.check()?;
538        self.cancellation.check()?;
539        limits.check_item(key.len(), 0)?;
540        let storage = self.storage_view();
541        let views = storage
542            .views
543            .as_ref()
544            .expect("root read view owns backend views");
545        // The `current` fast path is only valid when the backend physically
546        // pins the snapshot (redb). The live memory maps do not, so a view must
547        // resolve reads by its `ci` even at the latest commit.
548        let current = self.native_current && views.snapshot_pinned();
549        views.visit_value(key, self.ci, current, limits, projection, visit)
550    }
551
552    fn next_key(
553        &self,
554        lower: &Bound<Vec<u8>>,
555        upper: &Bound<Vec<u8>>,
556    ) -> Result<Option<Vec<u8>>, StorageError> {
557        self.health.check()?;
558        self.cancellation.check()?;
559        let storage = self.storage_view();
560        let views = storage
561            .views
562            .as_ref()
563            .expect("root read view owns backend views");
564        views.next_key(lower, upper, self.limits, self.native_current)
565    }
566
567    /// `ScanCursor`'s v2 native-iterator fast path (B7 follow-up): a real
568    /// backend range cursor over `(lower, upper)` — one seek for the whole
569    /// scan instead of one per row (`next_key` + `read_value_with_limits`,
570    /// still the fallback). `Ok(None)` when this view cannot use the
571    /// current-value fast path (same gate `visit_value`'s
572    /// `current` flag already needs: a non-current/pinned-older snapshot,
573    /// or a backend whose live maps aren't physically pinned pages), or
574    /// when a backend has no such cursor.
575    fn native_range(
576        &self,
577        lower: &Bound<Vec<u8>>,
578        upper: &Bound<Vec<u8>>,
579        projection: ScanProjection,
580    ) -> Result<
581        Option<Box<dyn Iterator<Item = Result<(Vec<u8>, Vec<u8>), StorageError>> + '_>>,
582        StorageError,
583    > {
584        self.health.check()?;
585        self.cancellation.check()?;
586        let storage = self.storage_view();
587        let views = storage
588            .views
589            .as_ref()
590            .expect("root read view owns backend views");
591        // Same gate as `visit_value`'s `current` fast path: valid
592        // only when this view's ci is the backend's live, physically pinned
593        // current state.
594        if !(self.native_current && views.snapshot_pinned()) {
595            return Ok(None);
596        }
597        Ok(views
598            .native_range(lower, upper, self.limits, projection)?
599            .map(|merged| Box::new(merged) as Box<dyn Iterator<Item = _> + '_>))
600    }
601
602    /// Open a forward scan cursor over `bounds` with the given `projection`.
603    ///
604    /// # Errors
605    /// Returns [`StorageError::Cancelled`] if the view is cancelled,
606    /// [`StorageError::InvalidRead`] if the bounds are inverted, or
607    /// [`StorageError::ResourceExhausted`] if a bound key exceeds the read
608    /// limit.
609    pub fn scan(
610        &self,
611        bounds: ScanBounds,
612        projection: ScanProjection,
613    ) -> Result<ScanCursor<'_>, StorageError> {
614        self.health.check()?;
615        self.cancellation.check()?;
616        for bound in [&bounds.start, &bounds.end] {
617            if let Bound::Included(key) | Bound::Excluded(key) = bound {
618                self.limits.check_item(key.len(), 0)?;
619            }
620        }
621        let empty = bounds.is_empty()?;
622        Ok(ScanCursor {
623            view: self,
624            lower: bounds.start,
625            upper: bounds.end,
626            projection,
627            done: empty,
628            native: None,
629            native_checked: false,
630            pending_native: Vec::new(),
631        })
632    }
633}
634
635/// Byte-ordered scan bounds as an inclusive/exclusive pair.
636#[derive(Clone, Debug)]
637pub struct ScanBounds {
638    /// Lower bound of the scan.
639    pub start: Bound<Vec<u8>>,
640    /// Upper bound of the scan.
641    pub end: Bound<Vec<u8>>,
642}
643
644impl ScanBounds {
645    /// Bounds covering every key under `prefix`.
646    #[must_use]
647    pub fn prefix(prefix: &[u8]) -> Self {
648        let (start, end) = prefix_range(prefix);
649        Self {
650            start: Bound::Included(start),
651            end: if end.is_empty() {
652                Bound::Unbounded
653            } else {
654                Bound::Excluded(end)
655            },
656        }
657    }
658
659    fn is_empty(&self) -> Result<bool, StorageError> {
660        use Bound::{Excluded, Included};
661        match (&self.start, &self.end) {
662            (Included(a) | Excluded(a), Included(b) | Excluded(b)) => {
663                if a > b {
664                    return Err(StorageError::InvalidRead("start exceeds end".into()));
665                }
666                Ok(a == b
667                    && (!matches!(self.start, Included(_)) || !matches!(self.end, Included(_))))
668            }
669            _ => Ok(false),
670        }
671    }
672}
673
674/// Which columns a scan should materialize for each row.
675#[derive(Clone, Copy, Debug, PartialEq, Eq)]
676pub enum ScanProjection {
677    /// Return keys only; row values are empty.
678    Keys,
679    /// Return both key and value.
680    KeyValue,
681}
682
683/// One materialized scan row.
684#[derive(Debug, PartialEq, Eq)]
685pub struct ScanRow {
686    /// Row key.
687    pub key: Vec<u8>,
688    /// None for keys-only projection; an empty stored value is Some(vec![]).
689    pub value: Option<Vec<u8>>,
690}
691
692/// One batch of scan rows together with its byte accounting and done flag.
693#[derive(Debug)]
694pub struct ScanBatch {
695    /// Materialized rows in ascending key order.
696    pub rows: Vec<ScanRow>,
697    /// Copied key/value bytes, excluding allocation/container overhead.
698    pub bytes: usize,
699    /// False may require another call to discover exhaustion at a batch edge.
700    pub done: bool,
701}
702
703/// Forward cursor tied to one protected view. No full candidate collection.
704/// v2 (B7 follow-up): when the view's current-value fast path is valid
705/// (see [`ReadView::native_range`]), one real backend cursor serves the
706/// whole scan — built lazily on the first [`Self::next_batch`] call and
707/// held across calls (`native`/`native_checked`). Otherwise falls back to
708/// v1's ordered seek-per-row (`next_key` + `read_value_with_limits`).
709/// Each batch plus at most one bounded lookahead row is allocated. Callers
710/// must release consumed batches; shared outstanding-byte admission is P01.
711pub struct ScanCursor<'view> {
712    view: &'view ReadView,
713    lower: Bound<Vec<u8>>,
714    upper: Bound<Vec<u8>>,
715    projection: ScanProjection,
716    done: bool,
717    /// `None` until the first `next_batch` call decides eligibility; stays
718    /// `None` for the cursor's lifetime when ineligible or unavailable
719    /// (v1 fallback), `Some` once a native cursor is built (v2).
720    native: Option<
721        std::iter::Peekable<
722            Box<dyn Iterator<Item = Result<(Vec<u8>, Vec<u8>), StorageError>> + 'view>,
723        >,
724    >,
725    native_checked: bool,
726    /// Undo buffer for the native path's error contract — see
727    /// `next_batch_native`.
728    pending_native: Vec<ScanRow>,
729}
730
731impl ScanCursor<'_> {
732    /// Fetch up to `row_limit` rows bounded by `byte_limit` copied bytes.
733    ///
734    /// # Errors
735    /// Returns [`StorageError::InvalidRead`] for zero limits,
736    /// [`StorageError::Cancelled`] if the view was cancelled, or
737    /// [`StorageError::ResourceExhausted`] if a single row exceeds
738    /// `byte_limit`.
739    pub fn next_batch(
740        &mut self,
741        row_limit: usize,
742        byte_limit: usize,
743    ) -> Result<ScanBatch, StorageError> {
744        let before = self.lower.clone();
745        let was_done = self.done;
746        let result = self.next_batch_inner(row_limit, byte_limit);
747        if result.is_err() {
748            // No batch was delivered, so none of its rows are consumed.
749            self.lower = before;
750            self.done = was_done;
751        }
752        result
753    }
754
755    fn next_batch_inner(
756        &mut self,
757        row_limit: usize,
758        byte_limit: usize,
759    ) -> Result<ScanBatch, StorageError> {
760        self.view.check_cancelled()?;
761        if row_limit == 0 || byte_limit == 0 {
762            return Err(StorageError::InvalidRead(
763                "batch limits must be positive".into(),
764            ));
765        }
766        if !self.native_checked {
767            self.native = self
768                .view
769                .native_range(&self.lower, &self.upper, self.projection)?
770                .map(std::iter::Iterator::peekable);
771            self.native_checked = true;
772        }
773        if self.native.is_some() {
774            return self.next_batch_native(row_limit, byte_limit);
775        }
776        let mut batch = ScanBatch {
777            rows: Vec::new(),
778            bytes: 0,
779            done: self.done,
780        };
781        while !self.done && batch.rows.len() < row_limit {
782            let Some(key) = self.view.next_key(&self.lower, &self.upper)? else {
783                self.done = true;
784                break;
785            };
786            let remaining = byte_limit - batch.bytes;
787            let mut limits = self.view.limits;
788            let bounded_by_batch = remaining < limits.max_item_bytes;
789            limits.max_item_bytes = limits.max_item_bytes.min(remaining);
790            let Some(value) = (match self
791                .view
792                .read_value_with_limits(&key, self.projection, limits)
793            {
794                Ok(value) => value,
795                Err(_error) if bounded_by_batch && !batch.rows.is_empty() => break,
796                Err(error) => return Err(error),
797            }) else {
798                self.lower = Bound::Excluded(key);
799                continue;
800            };
801            let bytes = key.len() + value.len(); // checked by visit_value
802            if bytes > byte_limit - batch.bytes {
803                if batch.rows.is_empty() {
804                    return Err(StorageError::ResourceExhausted(
805                        "scan row exceeds batch byte limit".into(),
806                    ));
807                }
808                break; // keep continuation before the unconsumed row
809            }
810            self.lower = Bound::Excluded(key.clone());
811            batch.bytes += bytes;
812            batch.rows.push(ScanRow {
813                key,
814                value: (self.projection == ScanProjection::KeyValue).then_some(value),
815            });
816            // An inclusive upper bound can leave equal excluded/included
817            // bounds after the last row; avoid asking a backend to traverse it.
818            if matches!(&self.upper, Bound::Included(end) if batch.rows.last().is_some_and(|row| &row.key == end))
819            {
820                self.done = true;
821            }
822        }
823        batch.done = self.done;
824        Ok(batch)
825    }
826
827    /// v2 fast path: pull rows from the already-built native cursor
828    /// (`self.native`) instead of seeking per row. A row that doesn't fit
829    /// the *remaining* per-batch byte budget is left un-consumed (peeked,
830    /// not advanced) and deferred to the next call — mirroring v1's own
831    /// "oversized row" deferral — because unlike v1's position-based
832    /// `self.lower`, this cursor's position lives inside the iterator
833    /// itself: a row must never be consumed and then discarded by an
834    /// error return, or [`Self::next_batch`]'s rollback-and-retry would
835    /// silently skip it (the iterator can't be "seeked back").
836    fn next_batch_native(
837        &mut self,
838        row_limit: usize,
839        byte_limit: usize,
840    ) -> Result<ScanBatch, StorageError> {
841        let mut batch = ScanBatch {
842            rows: Vec::new(),
843            bytes: 0,
844            done: self.done,
845        };
846        // Rows a *previous* call already consumed from the iterator but
847        // could not deliver (it hit a hard error on a later row and had to
848        // propagate that error — see below) are delivered first. Unlike
849        // v1's position-based retry, this iterator cannot be "seeked
850        // back", so an already-consumed row that isn't returned here would
851        // be lost forever; `pending_native` is the explicit undo buffer
852        // that makes this cursor's error contract match v1's exactly
853        // (a failed batch still delivers everything it actually read on a
854        // later call) despite the underlying iterator being stateful.
855        while !self.pending_native.is_empty() && batch.rows.len() < row_limit {
856            let bytes = self.pending_native[0].key.len()
857                + self.pending_native[0].value.as_ref().map_or(0, Vec::len);
858            if bytes > byte_limit - batch.bytes {
859                if batch.rows.is_empty() {
860                    // Every pending row already passed the absolute
861                    // `max_item_bytes` check when it was first read; the
862                    // only way it can still be too big is a tighter
863                    // `byte_limit` on *this* call. Matches v1's own
864                    // "first row already exceeds this call's budget" case
865                    // exactly — an empty `Ok` here would silently never
866                    // make progress instead of surfacing that.
867                    return Err(StorageError::ResourceExhausted(
868                        "scan row exceeds batch byte limit".into(),
869                    ));
870                }
871                break;
872            }
873            batch.bytes += bytes;
874            batch.rows.push(self.pending_native.remove(0));
875        }
876        if !self.pending_native.is_empty() {
877            batch.done = false;
878            return Ok(batch);
879        }
880        let iter = self
881            .native
882            .as_mut()
883            .expect("next_batch_native only called when native is Some");
884        while !self.done && batch.rows.len() < row_limit {
885            let bytes = match iter.peek() {
886                Some(Ok((key, value))) => key.len() + value.len(),
887                Some(Err(error)) => {
888                    if !batch.rows.is_empty() {
889                        // A hard error (e.g. an absolute per-item size
890                        // violation) on a row *after* some already-consumed
891                        // rows in this same call: stash them so the next
892                        // call delivers them instead of losing them, then
893                        // propagate — matching v1's "this call errors, but
894                        // the rows it already read still arrive later".
895                        self.pending_native = std::mem::take(&mut batch.rows);
896                    }
897                    return Err(error.clone());
898                }
899                None => {
900                    self.done = true;
901                    break;
902                }
903            };
904            let remaining = byte_limit - batch.bytes;
905            if bytes > remaining {
906                if batch.rows.is_empty() {
907                    return Err(StorageError::ResourceExhausted(
908                        "scan row exceeds batch byte limit".into(),
909                    ));
910                }
911                break; // left un-consumed (peeked only) for the next call
912            }
913            let Some(Ok((key, value))) = iter.next() else {
914                unreachable!("peek just confirmed Some(Ok(_))")
915            };
916            batch.bytes += bytes;
917            // Same inclusive-upper-bound edge v1 handles: avoid asking the
918            // backend to traverse past it.
919            let at_upper_edge = matches!(&self.upper, Bound::Included(end) if &key == end);
920            batch.rows.push(ScanRow {
921                key,
922                value: (self.projection == ScanProjection::KeyValue).then_some(value),
923            });
924            if at_upper_edge {
925                self.done = true;
926            }
927        }
928        batch.done = self.done;
929        Ok(batch)
930    }
931}