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