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        direction: ScanDirection,
557    ) -> Result<Option<Vec<u8>>, StorageError> {
558        self.health.check()?;
559        self.cancellation.check()?;
560        let storage = self.storage_view();
561        let views = storage
562            .views
563            .as_ref()
564            .expect("root read view owns backend views");
565        views.next_key(lower, upper, self.limits, self.native_current, direction)
566    }
567
568    /// The largest key within `bounds` that holds a value in this view, or
569    /// `None`: one reverse seek per key passed over (a key whose only
570    /// versions are outside this snapshot is skipped).
571    ///
572    /// # Errors
573    /// Returns [`StorageError::Cancelled`] if the view is cancelled,
574    /// [`StorageError::InvalidRead`] if the bounds are inverted, or a
575    /// storage read error.
576    pub fn last_key(&self, bounds: ScanBounds) -> Result<Option<Vec<u8>>, StorageError> {
577        if bounds.is_empty()? {
578            return Ok(None);
579        }
580        let mut upper = bounds.end;
581        while let Some(key) = self.next_key(&bounds.start, &upper, ScanDirection::Reverse)? {
582            if self
583                .read_value_with_limits(&key, ScanProjection::Keys, self.limits)?
584                .is_some()
585            {
586                return Ok(Some(key));
587            }
588            upper = Bound::Excluded(key);
589        }
590        Ok(None)
591    }
592
593    /// `ScanCursor`'s v2 native-iterator fast path (B7 follow-up): a real
594    /// backend range cursor over `(lower, upper)` — one seek for the whole
595    /// scan instead of one per row (`next_key` + `read_value_with_limits`,
596    /// still the fallback). `Ok(None)` when this view cannot use the
597    /// current-value fast path (same gate `visit_value`'s
598    /// `current` flag already needs: a non-current/pinned-older snapshot,
599    /// or a backend whose live maps aren't physically pinned pages), or
600    /// when a backend has no such cursor.
601    fn native_range(
602        &self,
603        lower: &Bound<Vec<u8>>,
604        upper: &Bound<Vec<u8>>,
605        projection: ScanProjection,
606    ) -> Result<
607        Option<Box<dyn Iterator<Item = Result<(Vec<u8>, Vec<u8>), StorageError>> + '_>>,
608        StorageError,
609    > {
610        self.health.check()?;
611        self.cancellation.check()?;
612        let storage = self.storage_view();
613        let views = storage
614            .views
615            .as_ref()
616            .expect("root read view owns backend views");
617        // Same gate as `visit_value`'s `current` fast path: valid
618        // only when this view's ci is the backend's live, physically pinned
619        // current state.
620        if !(self.native_current && views.snapshot_pinned()) {
621            return Ok(None);
622        }
623        Ok(views
624            .native_range(lower, upper, self.limits, projection)?
625            .map(|merged| Box::new(merged) as Box<dyn Iterator<Item = _> + '_>))
626    }
627
628    /// Open a forward scan cursor over `bounds` with the given `projection`.
629    ///
630    /// # Errors
631    /// Returns [`StorageError::Cancelled`] if the view is cancelled,
632    /// [`StorageError::InvalidRead`] if the bounds are inverted, or
633    /// [`StorageError::ResourceExhausted`] if a bound key exceeds the read
634    /// limit.
635    pub fn scan(
636        &self,
637        bounds: ScanBounds,
638        projection: ScanProjection,
639    ) -> Result<ScanCursor<'_>, StorageError> {
640        self.health.check()?;
641        self.cancellation.check()?;
642        for bound in [&bounds.start, &bounds.end] {
643            if let Bound::Included(key) | Bound::Excluded(key) = bound {
644                self.limits.check_item(key.len(), 0)?;
645            }
646        }
647        let empty = bounds.is_empty()?;
648        Ok(ScanCursor {
649            view: self,
650            lower: bounds.start,
651            upper: bounds.end,
652            projection,
653            done: empty,
654            native: None,
655            native_checked: false,
656            pending_native: Vec::new(),
657        })
658    }
659}
660
661/// Byte-ordered scan bounds as an inclusive/exclusive pair.
662#[derive(Clone, Debug)]
663pub struct ScanBounds {
664    /// Lower bound of the scan.
665    pub start: Bound<Vec<u8>>,
666    /// Upper bound of the scan.
667    pub end: Bound<Vec<u8>>,
668}
669
670impl ScanBounds {
671    /// Bounds covering every key under `prefix`.
672    #[must_use]
673    pub fn prefix(prefix: &[u8]) -> Self {
674        let (start, end) = prefix_range(prefix);
675        Self {
676            start: Bound::Included(start),
677            end: if end.is_empty() {
678                Bound::Unbounded
679            } else {
680                Bound::Excluded(end)
681            },
682        }
683    }
684
685    fn is_empty(&self) -> Result<bool, StorageError> {
686        use Bound::{Excluded, Included};
687        match (&self.start, &self.end) {
688            (Included(a) | Excluded(a), Included(b) | Excluded(b)) => {
689                if a > b {
690                    return Err(StorageError::InvalidRead("start exceeds end".into()));
691                }
692                Ok(a == b
693                    && (!matches!(self.start, Included(_)) || !matches!(self.end, Included(_))))
694            }
695            _ => Ok(false),
696        }
697    }
698}
699
700/// Which end of a key range a seek starts from.
701#[derive(Clone, Copy, Debug, PartialEq, Eq)]
702pub enum ScanDirection {
703    /// Ascending: the smallest key first.
704    Forward,
705    /// Descending: the largest key first.
706    Reverse,
707}
708
709impl ScanDirection {
710    /// The bound a seek in this direction starts from.
711    pub(crate) fn from<'b>(
712        self,
713        lower: &'b Bound<Vec<u8>>,
714        upper: &'b Bound<Vec<u8>>,
715    ) -> &'b Bound<Vec<u8>> {
716        match self {
717            Self::Forward => lower,
718            Self::Reverse => upper,
719        }
720    }
721
722    /// Whichever of `a` and `b` a seek in this direction meets first.
723    pub(crate) fn first<T: Ord>(self, a: Option<T>, b: Option<T>) -> Option<T> {
724        match (a, b) {
725            (Some(a), Some(b)) => Some(match self {
726                Self::Forward => a.min(b),
727                Self::Reverse => a.max(b),
728            }),
729            (a, b) => a.or(b),
730        }
731    }
732}
733
734/// Which columns a scan should materialize for each row.
735#[derive(Clone, Copy, Debug, PartialEq, Eq)]
736pub enum ScanProjection {
737    /// Return keys only; row values are empty.
738    Keys,
739    /// Return both key and value.
740    KeyValue,
741}
742
743/// One materialized scan row.
744#[derive(Debug, PartialEq, Eq)]
745pub struct ScanRow {
746    /// Row key.
747    pub key: Vec<u8>,
748    /// None for keys-only projection; an empty stored value is Some(vec![]).
749    pub value: Option<Vec<u8>>,
750}
751
752/// One batch of scan rows together with its byte accounting and done flag.
753#[derive(Debug)]
754pub struct ScanBatch {
755    /// Materialized rows in ascending key order.
756    pub rows: Vec<ScanRow>,
757    /// Copied key/value bytes, excluding allocation/container overhead.
758    pub bytes: usize,
759    /// False may require another call to discover exhaustion at a batch edge.
760    pub done: bool,
761}
762
763/// Forward cursor tied to one protected view. No full candidate collection.
764/// v2 (B7 follow-up): when the view's current-value fast path is valid
765/// (see [`ReadView::native_range`]), one real backend cursor serves the
766/// whole scan — built lazily on the first [`Self::next_batch`] call and
767/// held across calls (`native`/`native_checked`). Otherwise falls back to
768/// v1's ordered seek-per-row (`next_key` + `read_value_with_limits`).
769/// Each batch plus at most one bounded lookahead row is allocated. Callers
770/// must release consumed batches; shared outstanding-byte admission is P01.
771pub struct ScanCursor<'view> {
772    view: &'view ReadView,
773    lower: Bound<Vec<u8>>,
774    upper: Bound<Vec<u8>>,
775    projection: ScanProjection,
776    done: bool,
777    /// `None` until the first `next_batch` call decides eligibility; stays
778    /// `None` for the cursor's lifetime when ineligible or unavailable
779    /// (v1 fallback), `Some` once a native cursor is built (v2).
780    native: Option<
781        std::iter::Peekable<
782            Box<dyn Iterator<Item = Result<(Vec<u8>, Vec<u8>), StorageError>> + 'view>,
783        >,
784    >,
785    native_checked: bool,
786    /// Undo buffer for the native path's error contract — see
787    /// `next_batch_native`.
788    pending_native: Vec<ScanRow>,
789}
790
791impl ScanCursor<'_> {
792    /// Fetch up to `row_limit` rows bounded by `byte_limit` copied bytes.
793    ///
794    /// # Errors
795    /// Returns [`StorageError::InvalidRead`] for zero limits,
796    /// [`StorageError::Cancelled`] if the view was cancelled, or
797    /// [`StorageError::ResourceExhausted`] if a single row exceeds
798    /// `byte_limit`.
799    pub fn next_batch(
800        &mut self,
801        row_limit: usize,
802        byte_limit: usize,
803    ) -> Result<ScanBatch, StorageError> {
804        let before = self.lower.clone();
805        let was_done = self.done;
806        let result = self.next_batch_inner(row_limit, byte_limit);
807        if result.is_err() {
808            // No batch was delivered, so none of its rows are consumed.
809            self.lower = before;
810            self.done = was_done;
811        }
812        result
813    }
814
815    fn next_batch_inner(
816        &mut self,
817        row_limit: usize,
818        byte_limit: usize,
819    ) -> Result<ScanBatch, StorageError> {
820        self.view.check_cancelled()?;
821        if row_limit == 0 || byte_limit == 0 {
822            return Err(StorageError::InvalidRead(
823                "batch limits must be positive".into(),
824            ));
825        }
826        if !self.native_checked {
827            self.native = self
828                .view
829                .native_range(&self.lower, &self.upper, self.projection)?
830                .map(std::iter::Iterator::peekable);
831            self.native_checked = true;
832        }
833        if self.native.is_some() {
834            return self.next_batch_native(row_limit, byte_limit);
835        }
836        let mut batch = ScanBatch {
837            rows: Vec::new(),
838            bytes: 0,
839            done: self.done,
840        };
841        while !self.done && batch.rows.len() < row_limit {
842            let Some(key) = self
843                .view
844                .next_key(&self.lower, &self.upper, ScanDirection::Forward)?
845            else {
846                self.done = true;
847                break;
848            };
849            let remaining = byte_limit - batch.bytes;
850            let mut limits = self.view.limits;
851            let bounded_by_batch = remaining < limits.max_item_bytes;
852            limits.max_item_bytes = limits.max_item_bytes.min(remaining);
853            let Some(value) = (match self
854                .view
855                .read_value_with_limits(&key, self.projection, limits)
856            {
857                Ok(value) => value,
858                Err(_error) if bounded_by_batch && !batch.rows.is_empty() => break,
859                Err(error) => return Err(error),
860            }) else {
861                self.lower = Bound::Excluded(key);
862                continue;
863            };
864            let bytes = key.len() + value.len(); // checked by visit_value
865            if bytes > byte_limit - batch.bytes {
866                if batch.rows.is_empty() {
867                    return Err(StorageError::ResourceExhausted(
868                        "scan row exceeds batch byte limit".into(),
869                    ));
870                }
871                break; // keep continuation before the unconsumed row
872            }
873            self.lower = Bound::Excluded(key.clone());
874            batch.bytes += bytes;
875            batch.rows.push(ScanRow {
876                key,
877                value: (self.projection == ScanProjection::KeyValue).then_some(value),
878            });
879            // An inclusive upper bound can leave equal excluded/included
880            // bounds after the last row; avoid asking a backend to traverse it.
881            if matches!(&self.upper, Bound::Included(end) if batch.rows.last().is_some_and(|row| &row.key == end))
882            {
883                self.done = true;
884            }
885        }
886        batch.done = self.done;
887        Ok(batch)
888    }
889
890    /// v2 fast path: pull rows from the already-built native cursor
891    /// (`self.native`) instead of seeking per row. A row that doesn't fit
892    /// the *remaining* per-batch byte budget is left un-consumed (peeked,
893    /// not advanced) and deferred to the next call — mirroring v1's own
894    /// "oversized row" deferral — because unlike v1's position-based
895    /// `self.lower`, this cursor's position lives inside the iterator
896    /// itself: a row must never be consumed and then discarded by an
897    /// error return, or [`Self::next_batch`]'s rollback-and-retry would
898    /// silently skip it (the iterator can't be "seeked back").
899    fn next_batch_native(
900        &mut self,
901        row_limit: usize,
902        byte_limit: usize,
903    ) -> Result<ScanBatch, StorageError> {
904        let mut batch = ScanBatch {
905            rows: Vec::new(),
906            bytes: 0,
907            done: self.done,
908        };
909        // Rows a *previous* call already consumed from the iterator but
910        // could not deliver (it hit a hard error on a later row and had to
911        // propagate that error — see below) are delivered first. Unlike
912        // v1's position-based retry, this iterator cannot be "seeked
913        // back", so an already-consumed row that isn't returned here would
914        // be lost forever; `pending_native` is the explicit undo buffer
915        // that makes this cursor's error contract match v1's exactly
916        // (a failed batch still delivers everything it actually read on a
917        // later call) despite the underlying iterator being stateful.
918        while !self.pending_native.is_empty() && batch.rows.len() < row_limit {
919            let bytes = self.pending_native[0].key.len()
920                + self.pending_native[0].value.as_ref().map_or(0, Vec::len);
921            if bytes > byte_limit - batch.bytes {
922                if batch.rows.is_empty() {
923                    // Every pending row already passed the absolute
924                    // `max_item_bytes` check when it was first read; the
925                    // only way it can still be too big is a tighter
926                    // `byte_limit` on *this* call. Matches v1's own
927                    // "first row already exceeds this call's budget" case
928                    // exactly — an empty `Ok` here would silently never
929                    // make progress instead of surfacing that.
930                    return Err(StorageError::ResourceExhausted(
931                        "scan row exceeds batch byte limit".into(),
932                    ));
933                }
934                break;
935            }
936            batch.bytes += bytes;
937            batch.rows.push(self.pending_native.remove(0));
938        }
939        if !self.pending_native.is_empty() {
940            batch.done = false;
941            return Ok(batch);
942        }
943        let iter = self
944            .native
945            .as_mut()
946            .expect("next_batch_native only called when native is Some");
947        while !self.done && batch.rows.len() < row_limit {
948            let bytes = match iter.peek() {
949                Some(Ok((key, value))) => key.len() + value.len(),
950                Some(Err(error)) => {
951                    if !batch.rows.is_empty() {
952                        // A hard error (e.g. an absolute per-item size
953                        // violation) on a row *after* some already-consumed
954                        // rows in this same call: stash them so the next
955                        // call delivers them instead of losing them, then
956                        // propagate — matching v1's "this call errors, but
957                        // the rows it already read still arrive later".
958                        self.pending_native = std::mem::take(&mut batch.rows);
959                    }
960                    return Err(error.clone());
961                }
962                None => {
963                    self.done = true;
964                    break;
965                }
966            };
967            let remaining = byte_limit - batch.bytes;
968            if bytes > remaining {
969                if batch.rows.is_empty() {
970                    return Err(StorageError::ResourceExhausted(
971                        "scan row exceeds batch byte limit".into(),
972                    ));
973                }
974                break; // left un-consumed (peeked only) for the next call
975            }
976            let Some(Ok((key, value))) = iter.next() else {
977                unreachable!("peek just confirmed Some(Ok(_))")
978            };
979            batch.bytes += bytes;
980            // Same inclusive-upper-bound edge v1 handles: avoid asking the
981            // backend to traverse past it.
982            let at_upper_edge = matches!(&self.upper, Bound::Included(end) if &key == end);
983            batch.rows.push(ScanRow {
984                key,
985                value: (self.projection == ScanProjection::KeyValue).then_some(value),
986            });
987            if at_upper_edge {
988                self.done = true;
989            }
990        }
991        batch.done = self.done;
992        Ok(batch)
993    }
994}