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}