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}