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}