decdn_node/channel_store.rs
1//! Disk-backed `PoolStateStore` for the node runtime.
2//!
3//! Implements [`PoolStateStore`] against a set of `redb` database files under
4//! `<data_dir>`, one file per durable write family:
5//!
6//! - `lanes.redb` — seller lane frontier (`lane_state_v1`). Each record also
7//! carries its lane's owner-signed capability material (`owner_sig`), so the
8//! redeemer's registration payload lands in the same durable write as the
9//! frontier it backs. The file also holds the deployment stamp
10//! (`deployment_v1`).
11//! - `settle.redb` — the seller and buyer pending-settle sets.
12//! - `checkpoint.redb` — the settlement watcher's scan checkpoints.
13//! - `buyer.redb` — the buyer pool state and its owner index.
14//!
15//! Each file has its own `redb` write-transaction slot, so a commit in one
16//! family never waits on an unrelated commit in another: the periodic lane
17//! flush, a settlement checkpoint, and a buyer top-up all proceed on
18//! independent writer slots. Families are
19//! never written in one transaction — `redb` forbids a transaction spanning two
20//! `Database` handles — so a crash between two family commits can leave them at
21//! different watermarks, which every family already tolerates (each records and
22//! recovers on its own terms).
23//!
24//! The seller-side state is bound to one `PaymentPool` deployment
25//! ([`Deployment`]). Pool ids repeat across deployments, so `open()` drops the
26//! lane rows, pending settles and watcher checkpoints a different deployment
27//! wrote before it reads any of them. The buyer pool table carries its own
28//! per-row deployment tag (the same chain id and contract address) and is not
29//! touched.
30//!
31//! The lane table is buffered in memory: `open()`
32//! hydrates the working set from disk, `record`/`forget` mutate that working
33//! set only, and an explicit `flush()` call writes every dirty lane and
34//! applies every tombstone in one fsynced commit (redb's default
35//! [`redb::Durability::Immediate`], set explicitly here so a future redb
36//! default change doesn't silently weaken the durability guarantee). A crash
37//! between two flushes loses the unflushed lane advances; the caller decides
38//! the flush cadence.
39//!
40//! The **owner-signed capability material** (`owner_sig`) is a field of the lane
41//! record, not a side table. It is written by the same `record`/`flush` path as
42//! the frontier, in the same fsynced transaction, so it can never be durable-out-
43//! of-step with the frontier it backs: whenever a lane's frontier is on disk, its
44//! `owner_sig` is too, and a `forget` deletes both in one row removal. A crash
45//! between two flushes loses the lane's unflushed advances AND its `owner_sig`
46//! together — never one without the other — and both recover the same way: the
47//! frontier is superseded by the signer's next, higher voucher, and the material
48//! is re-supplied because the client re-sends its capability on the next request
49//! and intake re-writes it onto the lane (#1906).
50//!
51//! See [`decdn_incentive::store`] for the trait contract and
52//! [ADR 003 §Off-chain voucher state persistence] for the protocol rule
53//! this implements: a node MUST advance `(last_amount, last_bytes_delivered)`
54//! for the lane before it continues delivery, and mirror the advance to disk on
55//! a background timer. Without the on-disk mirror a restart re-opens the lane at
56//! amount zero and a client can replay a previously-accepted voucher for a
57//! second byte delivery (issue #527).
58//!
59//! The record persists the latest voucher's signature (so the seller
60//! redemption path can submit it to the on-chain `PaymentPool.redeem` after a
61//! restart without forfeiting the claim), the capability's spending cap, its
62//! expiry (so the node stops serving once the grant lapses), the owner's
63//! signature over the capability (`owner_sig`, the `ownerSig` the redeemer
64//! submits to register the signer on-chain), and the lane's paid cumulative
65//! (`paid_cumulative`, the on-chain redeemed watermark the redeemer subtracts
66//! from owed — persisting it stops a restart from re-submitting already-redeemed
67//! lanes, #2052). All of these are plain fields of the one `StoredLaneState`
68//! record — there is a single on-disk format and no older shape to decode.
69//!
70//! The seller table above is defined here. The **buyer** pool table (#744) is
71//! not: its record codec and every one of its operations live in
72//! [`decdn_incentive::buyer_pool_table`], shared with the client's
73//! `RedbBuyerPoolStore` (#1246). This file contributes only the buyer table's
74//! wiring — the node holds it in its own `buyer.redb`, so a buyer top-up
75//! commits on a writer slot separate from the seller lane flush.
76//!
77//! [ADR 003 §Off-chain voucher state persistence]: ../../../adr/003-payments.md
78
79use std::collections::HashSet;
80use std::path::{Path, PathBuf};
81
82use crossbeam_queue::SegQueue;
83use dashmap::DashMap;
84use dashmap::mapref::entry::Entry;
85
86use alloy::primitives::{Address, B256, U256};
87use decdn_common::identity;
88/// The `PaymentPool` deployment the store's seller-side state is bound to.
89pub use decdn_incentive::Deployment;
90use decdn_incentive::buyer_pool_table::BuyerPoolTable;
91use decdn_incentive::store::{
92 CheckpointKey, KeyedCheckpointStore, PendingSettle, PendingSettleStore, PoolStateStore,
93 StoreError,
94};
95use decdn_incentive::{
96 AdvanceOutcome, BuyerLaneProgress, BuyerLoad, BuyerPoolState, BuyerPoolStore, DepositOutcome,
97 LaneChain, LaneKey, LaneState, PoolId,
98};
99use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition};
100use serde::{Deserialize, Serialize};
101
102/// File name of the seller lane redb database within `data_dir`.
103const LANES_DB_FILE: &str = decdn_common::data_dir::NODE_LANES_DB_FILE;
104
105/// File name of the pending-settle redb database (seller and buyer sets).
106const SETTLE_DB_FILE: &str = decdn_common::data_dir::NODE_SETTLE_DB_FILE;
107
108/// File name of the settlement-watcher checkpoint redb database.
109const CHECKPOINT_DB_FILE: &str = decdn_common::data_dir::NODE_CHECKPOINT_DB_FILE;
110
111/// File name of the buyer pool redb database (buyer state + owner index).
112///
113/// Shared with the CLI through [`decdn_common::data_dir`] so both sides name
114/// the same file: an operator tool that wants this store has to know it is not
115/// the client's
116/// [`CLIENT_BUYER_DB_FILE`](decdn_common::data_dir::CLIENT_BUYER_DB_FILE).
117const BUYER_DB_FILE: &str = decdn_common::data_dir::NODE_BUYER_DB_FILE;
118
119/// Byte width of a [`LaneKey`] on disk: `pool_id ‖ signer ‖ provider` =
120/// `32 + 20 + 20`.
121const LANE_KEY_LEN: usize = 72;
122
123/// On-disk file mode (`0o600` — owner-only read+write). Defense-in-depth: the
124/// containing `data_dir` is already enforced to `0o700` by
125/// [`decdn_common::identity::ensure_data_dir`], so other local users cannot
126/// reach the file via path traversal, but we still tighten the file mode in
127/// case the directory ACL is widened out-of-band.
128#[cfg(unix)]
129const DB_FILE_MODE: u32 = 0o600;
130
131/// `schema_version` this binary writes and decodes. A record carrying a higher
132/// value causes `load_all` to refuse to start (see
133/// [`StoreError::UnsupportedSchema`]) — a cheap forward tripwire so a store a
134/// newer binary wrote is rejected loudly rather than silently mis-decoded. It
135/// is not a migration hook: there is one on-disk format and no older shape to
136/// read.
137const SUPPORTED_SCHEMA_VERSION: u32 = 1;
138
139/// Sanity ceiling on trailing bytes per lane record. Trailing bytes past the
140/// known fields are tolerated (a newer writer's additive field is a no-op to
141/// us — see [`StoredLaneState`]), but a `remainder.len()` above this threshold
142/// is logged as a warning so an honest schema-skew incident or malicious
143/// padding attempt is observable in operator logs without re-introducing the
144/// strict-decoding regression issue #527's reviewers warned against.
145const SANE_TRAILER_MAX_BYTES: usize = 256;
146
147/// redb table holding the per-lane voucher state.
148///
149/// Key: the [`LaneKey`] encoding `pool_id ‖ signer ‖ provider` (`[u8; 72]`).
150/// Value: postcard-encoded [`StoredLaneState`] (variable length).
151const LANE_TABLE: TableDefinition<'_, &[u8; LANE_KEY_LEN], &[u8]> =
152 TableDefinition::new("lane_state_v1");
153
154/// redb table holding the pending-settle set (#327): pools this node closed
155/// on-chain that await the grace window to elapse before their accrued claim
156/// finalizes. Lives in `settle.redb`.
157///
158/// Key: raw `PoolId` bytes (`[u8; 32]`).
159/// Value: the grace-window deadline (Unix seconds). A fixed-width native `u64`
160/// value needs no postcard envelope (unlike [`LANE_TABLE`]), so there is no
161/// schema-version trailer to evolve here.
162const PENDING_SETTLE_TABLE: TableDefinition<'_, &[u8; 32], u64> =
163 TableDefinition::new("pending_settle_v1");
164
165/// redb table holding pools this node closed as the **buyer** (#988) — the
166/// unilateral close of an unreachable provider's idle pool — that await the
167/// grace window. Same shape as [`PENDING_SETTLE_TABLE`] (key: `PoolId` bytes,
168/// value: deadline) but a SEPARATE table so the buyer settle sweep and the
169/// seller settle sweep never settle each other's closes. Lives in `settle.redb`.
170const BUYER_PENDING_SETTLE_TABLE: TableDefinition<'_, &[u8; 32], u64> =
171 TableDefinition::new("buyer_pending_settle_v1");
172
173/// redb table holding each on-chain watcher's scan checkpoint (#751, keyed in
174/// #1092/#1108): the last block scanned per [`CheckpointKey`], so a bring-up
175/// backfill resumes across restarts and covers events landing while the node was
176/// down. Lives in `checkpoint.redb`. One `&str` key per
177/// watcher (the [`CheckpointKey::as_str`] literals); a fixed-width native `u64`
178/// value needs no postcard envelope.
179const WATCHER_CHECKPOINT_TABLE: TableDefinition<'_, &str, u64> =
180 TableDefinition::new("watcher_checkpoint_v1");
181
182/// redb table holding the store's deployment stamp: the [`Deployment`] every
183/// row in [`LANE_TABLE`], both pending-settle tables, and
184/// [`WATCHER_CHECKPOINT_TABLE`] was written against. Lives in `lanes.redb`, so
185/// a restamp and the lane-table drop it forces commit in one transaction.
186///
187/// One row, key [`DEPLOYMENT_KEY`], value [`Deployment::to_bytes`]:
188/// `chain_id (8 bytes, big-endian) ‖ payment_pool (20 bytes)`. A fixed-width
189/// value needs no postcard envelope. A stamp that does not decode is
190/// [`StoreError::Corrupt`]: the stamp decides which rows survive the open, so a
191/// stamp this binary cannot read must stop bring-up.
192const DEPLOYMENT_TABLE: TableDefinition<'_, &str, &[u8]> = TableDefinition::new("deployment_v1");
193
194/// The one key of [`DEPLOYMENT_TABLE`].
195const DEPLOYMENT_KEY: &str = "payment_pool";
196
197/// Encode a [`LaneKey`] into its `[u8; 72]` table key: `pool_id ‖ signer ‖
198/// provider`.
199fn lane_key_bytes(key: &LaneKey) -> [u8; LANE_KEY_LEN] {
200 let mut out = [0u8; LANE_KEY_LEN];
201 out[..32].copy_from_slice(key.pool_id.as_slice());
202 out[32..52].copy_from_slice(key.signer.as_slice());
203 out[52..].copy_from_slice(key.provider.as_slice());
204 out
205}
206
207/// Decode a `[u8; 72]` table key back into its [`LaneKey`] parts
208/// `(pool_id, signer, provider)`. Fixed-width slices, so the array indexing is
209/// total.
210fn lane_key_parts(bytes: &[u8; LANE_KEY_LEN]) -> (B256, Address, Address) {
211 let mut pool = [0u8; 32];
212 pool.copy_from_slice(&bytes[..32]);
213 let mut signer = [0u8; 20];
214 signer.copy_from_slice(&bytes[32..52]);
215 let mut provider = [0u8; 20];
216 provider.copy_from_slice(&bytes[52..]);
217 (
218 B256::from(pool),
219 Address::from(signer),
220 Address::from(provider),
221 )
222}
223
224/// On-disk record — the single, complete lane-state format. The numeric
225/// balance fields use fixed-size big-endian byte arrays instead of
226/// variable-length integers so the encoded value width is stable across
227/// postcard versions and identical to the on-chain representation, making
228/// manual inspection straightforward.
229///
230/// The lane's identity (`pool_id`, `signer`, `provider`) is NOT stored in the
231/// value — it is the table key ([`lane_key_bytes`]) — so a value carries only
232/// the mutable watermark plus the capability's `cap`/`expiry` and the owner's
233/// signature over that capability (`owner_sig`).
234///
235/// `schema_version` is a forward tripwire only (see [`SUPPORTED_SCHEMA_VERSION`]):
236/// `decode_record` uses [`postcard::take_from_bytes`], which tolerates trailing
237/// bytes, so a record a newer binary wrote with an appended field still decodes
238/// its known prefix here rather than erroring on the extra bytes.
239///
240/// `signature` and `owner_sig` are each a length-prefixed `Vec<u8>` on disk; the
241/// in-memory [`LaneState`] carries the stronger `Option<[u8; 65]>`, and the
242/// narrowing (empty → `None`, 65 → `Some`, anything else → corrupt) lives in
243/// [`StoredLaneState::into_state`]. `owner_sig` is the owner's EIP-712 signature
244/// over the lane's `Capability` — the `ownerSig` the redeemer submits as a
245/// `PaymentPool.redeemMany` `CapabilityReg`. It is a field of the lane record,
246/// not a side table, so it is committed in the same fsynced transaction as the
247/// voucher frontier it backs and can never be durable-out-of-step with it.
248///
249/// `registered_until` is a required field after `expiry`: the observed on-chain
250/// capability expiry for this lane's signer. Adding it is a breaking on-disk
251/// change — a record written before it fails to decode here (the trailing `u64`
252/// is absent, so `take_from_bytes` hits `DeserializeUnexpectedEnd`), it is not
253/// silently defaulted. That break is deliberate and unversioned: deCDN is
254/// pre-launch with no deployed store to stay compatible with, so
255/// [`SUPPORTED_SCHEMA_VERSION`] does not bump.
256#[derive(Debug, Serialize, Deserialize)]
257struct StoredLaneState {
258 schema_version: u32,
259 last_amount: [u8; 32],
260 last_bytes_delivered: [u8; 32],
261 signature: Vec<u8>,
262 cap: [u8; 32],
263 expiry: u64,
264 registered_until: u64,
265 /// The lane's live hash-chain epoch, flattened (ADR 003 §Off-chain voucher
266 /// state persistence). `tip` is the preimage **bytes** at `verified_index`,
267 /// not just the depth: only the payer can produce a value at a given depth,
268 /// so a node that kept the index alone would hold an unprovable claim after
269 /// a restart — in exactly the abandonment case the chain exists to cover.
270 ///
271 /// Chain state is **frontier**, so the #1672 durability split covers it
272 /// unchanged: losing it on a crash forfeits at most the chunks metered
273 /// since the lane's last signature, which is the node's own un-signed tail
274 /// and the safe direction to lose.
275 chain_root: [u8; 32],
276 chunk_price: [u8; 32],
277 verified_index: u8,
278 tip: [u8; 32],
279 /// The owner's EIP-712 signature over the lane's `Capability` (`r‖s‖v`),
280 /// length-prefixed on disk and narrowed to the in-memory `Option<[u8; 65]>`
281 /// (empty → `None`, 65 → `Some`, anything else → corrupt) in
282 /// [`StoredLaneState::into_state`]. The seller redeemer submits it as a
283 /// `PaymentPool.redeemMany` `CapabilityReg` `ownerSig` to register the signer
284 /// on-chain (ADR 003 §Capability delegation). A required field like
285 /// `registered_until`: adding it is a breaking on-disk change — a record
286 /// written before it fails to decode (the trailing length-prefix is absent,
287 /// so `take_from_bytes` hits `DeserializeUnexpectedEnd`), it is not silently
288 /// defaulted. That break is deliberate and unversioned: deCDN is pre-launch
289 /// with no deployed store to stay compatible with.
290 owner_sig: Vec<u8>,
291 /// The lane's paid cumulative — the on-chain `newPaidCumulative` of the most
292 /// recent `PoolRedeemed` this node observed for it — as a fixed-width
293 /// big-endian `[u8; 32]`, like `last_amount`/`cap`. The seller redeemer
294 /// subtracts it from the lane's owed value to get what it still has to cash;
295 /// persisting it here (in the same fsynced row as the frontier) is what stops
296 /// a restarted node from forgetting redemptions behind its log-poller cursor
297 /// and re-submitting already-redeemed lanes for silent on-chain no-ops
298 /// (#2052). A required field like `registered_until`/`owner_sig`: adding it is
299 /// a breaking on-disk change (a record written before it fails to decode), not
300 /// silently defaulted — deliberate and unversioned, as deCDN is pre-launch.
301 paid_cumulative: [u8; 32],
302}
303
304impl From<&LaneState> for StoredLaneState {
305 fn from(state: &LaneState) -> Self {
306 Self {
307 schema_version: SUPPORTED_SCHEMA_VERSION,
308 last_amount: state.last_amount().to_be_bytes(),
309 last_bytes_delivered: state.last_bytes_delivered().to_be_bytes(),
310 signature: state.last_signature().map_or_else(Vec::new, |s| s.to_vec()),
311 cap: state.cap.to_be_bytes(),
312 expiry: state.expiry,
313 registered_until: state.registered_until,
314 owner_sig: state.owner_sig.map_or_else(Vec::new, |s| s.to_vec()),
315 paid_cumulative: state.paid_cumulative.to_be_bytes(),
316 chain_root: state.chain().chain_root.into(),
317 chunk_price: state.chain().chunk_price.to_be_bytes(),
318 verified_index: state.chain().verified_index,
319 tip: state.chain().tip.into(),
320 }
321 }
322}
323
324/// Narrow an on-disk length-prefixed signature to the in-memory
325/// `Option<[u8; 65]>`: empty → `None`, exactly 65 bytes → `Some`, any other
326/// length → [`StoreError::Corrupt`] (a malformed record we must not silently
327/// submit to the on-chain `PaymentPool.redeemMany`).
328fn decode_signature(
329 bytes: &[u8],
330 pool_id: B256,
331 what: &str,
332) -> Result<Option<[u8; 65]>, StoreError> {
333 if bytes.is_empty() {
334 return Ok(None);
335 }
336 let len = bytes.len();
337 Ok(Some(<[u8; 65]>::try_from(bytes).map_err(|_| {
338 StoreError::Corrupt {
339 pool_id: Some(pool_id),
340 detail: format!("stored {what} signature is {len} bytes, expected 0 or 65"),
341 }
342 })?))
343}
344
345impl StoredLaneState {
346 /// Reconstruct the in-memory [`LaneState`] via its hydration constructor
347 /// (the trusted cross-crate writer, #527/#751), given the lane identity
348 /// recovered from the table key. The on-disk `signature` is a
349 /// length-prefixed `Vec<u8>`; it is narrowed to the in-memory
350 /// `Option<[u8; 65]>` here: empty → `None`, exactly 65 bytes → `Some`, any
351 /// other length → [`StoreError::Corrupt`] (a malformed record we must not
352 /// silently submit to the on-chain `PaymentPool.redeem`).
353 fn into_state(
354 self,
355 pool_id: B256,
356 signer: Address,
357 provider: Address,
358 ) -> Result<LaneState, StoreError> {
359 if self.schema_version > SUPPORTED_SCHEMA_VERSION {
360 return Err(StoreError::UnsupportedSchema {
361 found: self.schema_version,
362 supported: SUPPORTED_SCHEMA_VERSION,
363 });
364 }
365 let last_signature = decode_signature(&self.signature, pool_id, "voucher")?;
366 let owner_sig = decode_signature(&self.owner_sig, pool_id, "owner capability")?;
367 let mut state = LaneState::hydrate(
368 pool_id,
369 signer,
370 provider,
371 U256::from_be_bytes(self.cap),
372 self.expiry,
373 U256::from_be_bytes(self.last_amount),
374 U256::from_be_bytes(self.last_bytes_delivered),
375 last_signature,
376 LaneChain {
377 chain_root: B256::from(self.chain_root),
378 chunk_price: U256::from_be_bytes(self.chunk_price),
379 verified_index: self.verified_index,
380 tip: B256::from(self.tip),
381 },
382 );
383 state.registered_until = self.registered_until;
384 state.owner_sig = owner_sig;
385 state.paid_cumulative = U256::from_be_bytes(self.paid_cumulative);
386 Ok(state)
387 }
388}
389
390/// One lane's slot in the in-memory working set.
391///
392/// `Live` holds the authoritative frontier. `Tombstoned` marks a `forget`-ten
393/// lane whose on-disk row is not yet deleted — it stays in the map (rather than
394/// being removed) so `flush` learns to delete the row, and so a `record` that
395/// resurrects the lane in the same window is decided under the entry's shard
396/// lock rather than racing a separate tombstone set. Reads treat `Tombstoned`
397/// as absent.
398///
399/// The `Live` variant carries a full [`LaneState`] inline — no `Box`. That is
400/// the point: a stored lane clones out with a memcpy on every `record`/`get`,
401/// the hot serve path, and boxing would trade that for a heap indirection on
402/// exactly the frequent variant to shrink the rare `Tombstoned` one. Tombstones
403/// are transient (the next flush reaps them), so the size skew never
404/// accumulates.
405#[derive(Debug, Clone)]
406#[allow(clippy::large_enum_variant)]
407enum LaneSlot {
408 Live(LaneState),
409 Tombstoned,
410}
411
412/// `redb`-backed persistent implementation of [`PoolStateStore`].
413///
414/// Construct via [`PersistentPoolStateStore::open`]. One `redb::Database` per
415/// write family is owned for the lifetime of this value; drop closes every
416/// handle. The lane table is buffered in memory: `record`/`forget` mutate the
417/// working set only, and a caller must call [`PoolStateStore::flush`] to commit
418/// it to disk. The store is thread-safe — each redb file serialises its own
419/// writes via single-writer transactions, families commit on independent writer
420/// slots, and reads are MVCC.
421///
422/// The working set is a [`DashMap`] rather than one mutex-guarded map, so a
423/// `record` on the paid-delivery path locks only its own lane's shard — no lane
424/// serialises on another, and settlement's `load_all`/`get` do not contend
425/// with `record` (issue #1792 item 1). Each entry's shard lock is the per-lane
426/// critical section that keeps a `record`/`forget` race decidable.
427#[derive(Debug)]
428pub struct PersistentPoolStateStore {
429 /// Seller lane frontier (`lanes.redb`). The owner-signed capability material
430 /// each lane's redeemer needs rides the lane record itself (`owner_sig`), so
431 /// the periodic flush commits it in the same transaction as the frontier.
432 lanes_db: Database,
433 /// Seller + buyer pending-settle sets (`settle.redb`).
434 settle_db: Database,
435 /// Settlement-watcher scan checkpoints (`checkpoint.redb`).
436 checkpoint_db: Database,
437 /// Buyer pool state + owner index (`buyer.redb`).
438 buyer_db: Database,
439 /// Path of the lane store file (`lanes.redb`); returned by [`Self::path`].
440 path: PathBuf,
441 /// The per-lane working set, hydrated from disk at `open()`.
442 lanes: DashMap<LaneKey, LaneSlot>,
443 /// Lock-free work-list of lanes changed since the last flush.
444 /// `record`/`forget`/`set_registered_until` push their key; `flush` drains
445 /// it and re-reads each slot from `lanes` (the source of truth), so a
446 /// duplicate or a since-superseded key is harmless. Draining the queue —
447 /// rather than clearing a shared dirty set in place — is what bounds the
448 /// durable watermark's lag to one flush interval across a concurrent
449 /// `record` (ADR 003 §Off-chain voucher state persistence): a `record` that
450 /// lands after a key is drained pushes it afresh and is captured next flush.
451 dirty: SegQueue<LaneKey>,
452}
453
454impl PersistentPoolStateStore {
455 /// Open (or create) the lane-state store under `data_dir`.
456 ///
457 /// `data_dir` is validated through
458 /// [`decdn_common::identity::ensure_data_dir`] before any redb operation,
459 /// which enforces `0o700` on the directory and rejects insecure modes.
460 /// The store file is then chmod'd to `0o600` after creation as
461 /// defense-in-depth.
462 ///
463 /// # Errors
464 ///
465 /// - [`StoreError::Backend`] when the `data_dir` security check fails.
466 /// - [`StoreError::Backend`] when `redb::Database::create` refuses the file.
467 /// - [`StoreError::Corrupt`] when the file exists but is zero-length —
468 /// `redb` would otherwise treat that as "create a fresh database" and
469 /// silently reopen the issue #527 replay window.
470 /// - [`StoreError::PermissionTighten`] when the post-create chmod fails.
471 /// - [`StoreError::Io`] for any other filesystem error while stat-ing.
472 ///
473 /// When this returns `Err`, the caller MUST abort node bring-up —
474 /// starting with a clean store silently forfeits the issue #527 guarantee.
475 ///
476 /// `deployment` is the `PaymentPool` the node is configured against. The
477 /// store binds to it before it hydrates any lane: a store stamped with
478 /// another deployment drops its lane rows, pending settles and watcher
479 /// checkpoints, and a store with no stamp takes this one and keeps its rows.
480 pub fn open(data_dir: &Path, deployment: Deployment) -> Result<Self, StoreError> {
481 Self::open_with(data_dir, deployment, Self::tighten_permissions)
482 }
483
484 /// Testable form of [`Self::open`] that takes an injectable chmod
485 /// function. Non-test callers use [`Self::open`]; tests inject a closure
486 /// that simulates chmod failure to exercise the cleanup-branch asymmetry
487 /// (the security-critical fix for #527 follow-up review).
488 ///
489 /// `pub(crate)` deliberately — exposing this beyond the crate would let an
490 /// external caller pass a no-op `chmod_fn` and silently weaken the
491 /// file-mode hardening.
492 pub(crate) fn open_with<F>(
493 data_dir: &Path,
494 deployment: Deployment,
495 chmod_fn: F,
496 ) -> Result<Self, StoreError>
497 where
498 F: Fn(&Path) -> Result<(), StoreError>,
499 {
500 identity::ensure_data_dir(data_dir).map_err(|err| {
501 StoreError::Backend(format!(
502 "data_dir {} failed security check: {err:#}",
503 data_dir.display()
504 ))
505 })?;
506
507 // One hardened redb file per write family. `lanes.redb` opens first so a
508 // corrupt or empty lane store — the file that guards the issue #527
509 // voucher-replay window — is the one that aborts bring-up, and every
510 // integration test that fault-injects that window names this file.
511 let path = data_dir.join(LANES_DB_FILE);
512 let lanes_db = Self::open_hardened_db(&path, &chmod_fn)?;
513 let settle_db = Self::open_hardened_db(&data_dir.join(SETTLE_DB_FILE), &chmod_fn)?;
514 let checkpoint_db = Self::open_hardened_db(&data_dir.join(CHECKPOINT_DB_FILE), &chmod_fn)?;
515 let buyer_db = Self::open_hardened_db(&data_dir.join(BUYER_DB_FILE), &chmod_fn)?;
516
517 Self::bind_deployment(&lanes_db, &settle_db, &checkpoint_db, deployment)?;
518 let lanes = Self::hydrate_lanes(&lanes_db)?;
519 Ok(Self {
520 lanes_db,
521 settle_db,
522 checkpoint_db,
523 buyer_db,
524 path,
525 lanes,
526 dirty: SegQueue::new(),
527 })
528 }
529
530 /// Open (or create) one hardened redb file at `path`, applying the empty-file
531 /// guard, TOCTOU-safe create, and `0o600` tightening every lane-store family
532 /// file gets. Each family lives in its own file so its commits take an
533 /// independent `redb` writer slot.
534 ///
535 /// Rejects a zero-length file: `redb::Database::create` treats both "file
536 /// does not exist" and "file exists but is empty" as "create a fresh
537 /// database" — so a `truncate -s 0` (or a filesystem rollback that nukes
538 /// content but preserves the inode) would start with an empty store. For
539 /// `lanes.redb` that silently reopens the issue #527 voucher-replay window;
540 /// for the other families it silently re-grants budget the lost rows bounded
541 /// (a dropped pending-settle entry forgets an in-flight redemption).
542 ///
543 /// On a chmod failure the cleanup depends on whether the file pre-existed
544 /// this call: a freshly-created file is removed so the next start sees a
545 /// clean state; a pre-existing file (real payment state on disk) is
546 /// preserved, because a transient chmod failure on a read-only mount or NFS
547 /// must not delete live state. The TOCTOU window between the stat and
548 /// `Database::create` is closed via `OpenOptions::create_new`.
549 fn open_hardened_db<F>(path: &Path, chmod_fn: &F) -> Result<Database, StoreError>
550 where
551 F: Fn(&Path) -> Result<(), StoreError>,
552 {
553 let file_existed_before_open = match std::fs::metadata(path) {
554 Ok(meta) if meta.len() == 0 => {
555 return Err(StoreError::Corrupt {
556 pool_id: None,
557 detail: format!(
558 "redb store file at {} is empty (length 0). \
559 This is either a manual truncation or a filesystem rollback, \
560 either of which silently discards durable payment state \
561 (for lanes.redb this re-opens the issue #527 voucher-replay window). \
562 Restore from backup, or delete the file deliberately to start fresh \
563 (forfeiting prior history).",
564 path.display()
565 ),
566 });
567 }
568 Ok(_) => true,
569 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
570 match std::fs::OpenOptions::new()
571 .write(true)
572 .create_new(true)
573 .open(path)
574 {
575 Ok(file) => {
576 // Drop the handle immediately; redb opens its own.
577 drop(file);
578 false
579 }
580 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => true,
581 Err(err) => return Err(StoreError::Io(err)),
582 }
583 }
584 Err(err) => return Err(StoreError::Io(err)),
585 };
586
587 let db = Database::create(path).map_err(|err| {
588 StoreError::Backend(format!(
589 "failed to open redb store file at {}: {err}. \
590 Removing the file forfeits its durability guard \
591 — restore from backup or investigate the corruption.",
592 path.display()
593 ))
594 })?;
595
596 if let Err(chmod_err) = chmod_fn(path) {
597 // `drop(db)` is load-bearing on Windows: NTFS holds a mandatory
598 // exclusive lock on the file handle, so `remove_file` below would
599 // error with sharing-violation if the handle outlives.
600 drop(db);
601 Self::handle_chmod_failure(path, &chmod_err, file_existed_before_open);
602 return Err(chmod_err);
603 }
604
605 Ok(db)
606 }
607
608 /// Bind the store to `deployment` before any row is read.
609 ///
610 /// - The stamp names `deployment`: nothing changes. The check runs in a READ
611 /// transaction, so a boot against the same deployment takes no write and
612 /// the #527 file-stability guarantee holds.
613 /// - No stamp: the first open against this store claims it for
614 /// `deployment`. Existing rows stay — they are presumed written by the
615 /// deployment the node was already configured against, before the stamp
616 /// existed. The presumption fails for a node that was repointed at a
617 /// redeploy BEFORE the stamp existed: it claims the colliding rows, and
618 /// the operator clears them by hand (see the CHANGELOG entry).
619 /// - The stamp names another deployment: every row it wrote is dropped —
620 /// the lane table, both pending-settle sets, and the watcher checkpoints —
621 /// and the stamp is rewritten. Those rows name pool ids that repeat on the
622 /// new deployment. Kept, a returning buyer's new pool would resolve to the
623 /// old lane: its frontier rejects the buyer's first voucher, and the
624 /// redeemer submits the old deployment's signatures to the new contract.
625 /// The dropped lanes' unredeemed vouchers are forfeit.
626 ///
627 /// The settle and checkpoint files clear first. The lane table clears in the
628 /// same `lanes.redb` transaction that writes the new stamp, so a crash at any
629 /// point leaves either the old stamp (the next boot repeats the clear) or the
630 /// new stamp with every lane row gone. That guarantee assumes the next boot
631 /// keeps the NEW config: a crash after the settle/checkpoint clears followed
632 /// by a config revert to the OLD deployment matches the surviving stamp and
633 /// keeps the lanes, but that deployment's pending settles and checkpoints are
634 /// already gone (safe today — a lost checkpoint only widens a rescan — but a
635 /// future pending-settle consumer inherits this one-directional invariant).
636 ///
637 /// # Errors
638 /// [`StoreError::Corrupt`] for a malformed stamp; [`StoreError::Backend`]
639 /// when a transaction fails.
640 fn bind_deployment(
641 lanes_db: &Database,
642 settle_db: &Database,
643 checkpoint_db: &Database,
644 deployment: Deployment,
645 ) -> Result<(), StoreError> {
646 let read_txn = lanes_db
647 .begin_read()
648 .map_err(|err| StoreError::Backend(format!("begin_read (deployment): {err}")))?;
649 let stamp = match read_txn.open_table(DEPLOYMENT_TABLE) {
650 Ok(table) => table
651 .get(DEPLOYMENT_KEY)
652 .map_err(|err| StoreError::Backend(format!("get (deployment): {err}")))?
653 .map(|guard| Deployment::from_bytes(guard.value()))
654 .transpose()?,
655 Err(redb::TableError::TableDoesNotExist(_)) => None,
656 Err(err) => {
657 return Err(StoreError::Backend(format!(
658 "open_table (deployment): {err}"
659 )));
660 }
661 };
662 let foreign = match stamp {
663 Some(stamp) if stamp == deployment => return Ok(()),
664 None => None,
665 Some(foreign) => Some(foreign),
666 };
667 if let Some(foreign) = foreign {
668 Self::log_forfeited_lanes(&read_txn, foreign, deployment);
669 }
670 drop(read_txn);
671 if foreign.is_some() {
672 Self::delete_tables(settle_db, "settle", |txn| {
673 txn.delete_table(PENDING_SETTLE_TABLE)?;
674 txn.delete_table(BUYER_PENDING_SETTLE_TABLE)?;
675 Ok(())
676 })?;
677 Self::delete_tables(checkpoint_db, "checkpoint", |txn| {
678 txn.delete_table(WATCHER_CHECKPOINT_TABLE)?;
679 Ok(())
680 })?;
681 }
682
683 let mut txn = lanes_db
684 .begin_write()
685 .map_err(|err| StoreError::Backend(format!("begin_write (deployment): {err}")))?;
686 txn.set_durability(Durability::Immediate)
687 .map_err(|err| StoreError::Backend(format!("set_durability (deployment): {err}")))?;
688 if foreign.is_some() {
689 txn.delete_table(LANE_TABLE)
690 .map_err(|err| StoreError::Backend(format!("delete_table (lanes): {err}")))?;
691 }
692 {
693 let mut table = txn
694 .open_table(DEPLOYMENT_TABLE)
695 .map_err(|err| StoreError::Backend(format!("open_table (deployment): {err}")))?;
696 table
697 .insert(DEPLOYMENT_KEY, deployment.to_bytes().as_slice())
698 .map_err(|err| StoreError::Backend(format!("insert (deployment): {err}")))?;
699 }
700 txn.commit()
701 .map_err(|err| StoreError::Backend(format!("commit (deployment): {err}")))
702 }
703
704 /// Walk the lane table once before the deployment rebind drops it: each
705 /// per-lane WARN is the LAST RECORD of that lane's unredeemed claim on the
706 /// old deployment (mirroring the buyer-side `drop_foreign_row` convention
707 /// of naming the recovery target), and the walk sums the forfeited value
708 /// for the summary WARN. The forfeited value is the lane's full claim —
709 /// [`LaneState::owed`], the signed cumulative plus the chain-verified
710 /// frontier — minus its paid watermark, the same figure the redeemer
711 /// collects; counting the signed amount alone would understate a lane by
712 /// up to one whole chain.
713 ///
714 /// This walk is observational: the rows are about to be deleted, so
715 /// nothing it hits may abort THIS deployment's bring-up. A row that fails
716 /// to decode is WARN-logged by its key parts, counted in `dropped_lanes`
717 /// AND in the summary's `undecodable_lanes` (its value cannot enter the
718 /// forfeited sum, so that field marks the sum as a lower bound), and a
719 /// table or iterator error degrades to one WARN with the audit marked
720 /// unknown — the `delete_table` that follows is the arbiter of real
721 /// corruption. An absent lane table logs a zero-lane summary.
722 fn log_forfeited_lanes(
723 read_txn: &redb::ReadTransaction,
724 foreign: Deployment,
725 configured: Deployment,
726 ) {
727 let mut dropped_lanes: u64 = 0;
728 let mut undecodable_lanes: u64 = 0;
729 let mut forfeited = U256::ZERO;
730 let mut audit_err: Option<String> = None;
731 match read_txn.open_table(LANE_TABLE) {
732 Ok(table) => match table.iter() {
733 Ok(iter) => {
734 for entry in iter {
735 let (key_guard, value_guard) = match entry {
736 Ok(pair) => pair,
737 Err(err) => {
738 audit_err = Some(format!("lanes iter entry: {err}"));
739 break;
740 }
741 };
742 let key_bytes: [u8; LANE_KEY_LEN] = *key_guard.value();
743 dropped_lanes = dropped_lanes.saturating_add(1);
744 match Self::log_one_forfeited_lane(&key_bytes, value_guard.value(), foreign)
745 {
746 Some(unredeemed) => {
747 forfeited = forfeited.saturating_add(unredeemed);
748 }
749 None => {
750 undecodable_lanes = undecodable_lanes.saturating_add(1);
751 }
752 }
753 }
754 }
755 Err(err) => audit_err = Some(format!("lanes iter: {err}")),
756 },
757 Err(redb::TableError::TableDoesNotExist(_)) => {}
758 Err(err) => audit_err = Some(format!("open_table (lanes): {err}")),
759 }
760 if let Some(error) = audit_err {
761 tracing::warn!(
762 error,
763 "could not enumerate the seller lanes for the forfeit audit; proceeding \
764 with the deployment rebind's drop, forfeited value unknown"
765 );
766 }
767 tracing::warn!(
768 foreign_chain_id = foreign.chain_id,
769 foreign_payment_pool = %foreign.payment_pool,
770 configured_chain_id = configured.chain_id,
771 configured_payment_pool = %configured.payment_pool,
772 dropped_lanes,
773 undecodable_lanes,
774 forfeited_micro_usdc = %forfeited,
775 "dropping seller lane state, pending settles and watcher checkpoints written \
776 against another PaymentPool deployment; pool ids repeat across deployments, \
777 and the dropped lanes' unredeemed vouchers are forfeit"
778 );
779 }
780
781 /// WARN one lane the rebind is about to drop — the last record of its
782 /// claim — and return its forfeited value, or `None` for a row that fails
783 /// to decode (WARN-logged by its key parts; its value cannot be reported).
784 fn log_one_forfeited_lane(
785 key_bytes: &[u8; LANE_KEY_LEN],
786 value_bytes: &[u8],
787 foreign: Deployment,
788 ) -> Option<U256> {
789 match decode_record(key_bytes, value_bytes) {
790 Ok(state) => {
791 let unredeemed = forfeited_value(&state);
792 tracing::warn!(
793 pool_id = %state.pool_id,
794 signer = %state.signer,
795 provider = %state.provider,
796 owed = %state.owed(),
797 last_amount = %state.last_amount(),
798 paid_cumulative = %state.paid_cumulative,
799 unredeemed_micro_usdc = %unredeemed,
800 foreign_chain_id = foreign.chain_id,
801 foreign_payment_pool = %foreign.payment_pool,
802 "dropping this seller lane with the deployment rebind; \
803 this line is the last record of its unredeemed claim \
804 on the old deployment"
805 );
806 Some(unredeemed)
807 }
808 Err(err) => {
809 let (pool_id, signer, provider) = lane_key_parts(key_bytes);
810 tracing::warn!(
811 %pool_id,
812 %signer,
813 %provider,
814 error = %err,
815 foreign_chain_id = foreign.chain_id,
816 foreign_payment_pool = %foreign.payment_pool,
817 "dropping an undecodable seller lane with the \
818 deployment rebind; its value cannot be reported"
819 );
820 None
821 }
822 }
823 }
824
825 /// Delete tables from `db` in one fsynced write transaction. `what` names
826 /// the file in error messages.
827 fn delete_tables(
828 db: &Database,
829 what: &str,
830 delete: impl FnOnce(&redb::WriteTransaction) -> Result<(), redb::TableError>,
831 ) -> Result<(), StoreError> {
832 let mut txn = db
833 .begin_write()
834 .map_err(|err| StoreError::Backend(format!("begin_write ({what} clear): {err}")))?;
835 txn.set_durability(Durability::Immediate)
836 .map_err(|err| StoreError::Backend(format!("set_durability ({what} clear): {err}")))?;
837 delete(&txn)
838 .map_err(|err| StoreError::Backend(format!("delete_table ({what} clear): {err}")))?;
839 txn.commit()
840 .map_err(|err| StoreError::Backend(format!("commit ({what} clear): {err}")))
841 }
842
843 /// Read the whole lane table into the in-memory working set at open. A
844 /// corrupt or forward-schema record aborts startup (running past it would
845 /// reopen the issue #527 voucher-replay window). Every hydrated lane enters
846 /// as [`LaneSlot::Live`].
847 fn hydrate_lanes(db: &Database) -> Result<DashMap<LaneKey, LaneSlot>, StoreError> {
848 let read_txn = db
849 .begin_read()
850 .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
851 let table = match read_txn.open_table(LANE_TABLE) {
852 Ok(t) => t,
853 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(DashMap::new()),
854 Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
855 };
856 let out = DashMap::new();
857 let iter = table
858 .iter()
859 .map_err(|err| StoreError::Backend(format!("table iter: {err}")))?;
860 for entry in iter {
861 let (key_guard, value_guard) =
862 entry.map_err(|err| StoreError::Backend(format!("iter entry: {err}")))?;
863 let key_bytes: [u8; LANE_KEY_LEN] = *key_guard.value();
864 let state = decode_record(&key_bytes, value_guard.value())?;
865 out.insert(state.key(), LaneSlot::Live(state));
866 }
867 Ok(out)
868 }
869
870 /// Operator-facing logging for the chmod-failure cleanup branch. Extracted
871 /// so the security policy ("preserve pre-existing, remove fresh") lives in
872 /// one place; both branches emit `tracing::error!` because both refuse to
873 /// bring the node up and the operator needs an error-level signal.
874 fn handle_chmod_failure(path: &Path, chmod_err: &StoreError, file_existed_before_open: bool) {
875 let observed_mode = Self::observed_mode_string(path);
876 if file_existed_before_open {
877 tracing::error!(
878 path = %path.display(),
879 observed_mode = %observed_mode,
880 event = "lane_store_chmod_fail_preserve",
881 %chmod_err,
882 "chmod failed on pre-existing lane state store; refusing to delete (would reopen issue #527 replay window). \
883 Investigate the permission error and chmod the file to 0o600 manually before retry.",
884 );
885 return;
886 }
887
888 // Fresh file path: we definitively created this file ourselves via
889 // `OpenOptions::create_new` upstream, so removing it cannot destroy
890 // anyone else's voucher state.
891 if let Err(remove_err) = std::fs::remove_file(path) {
892 tracing::error!(
893 %remove_err,
894 path = %path.display(),
895 observed_mode = %observed_mode,
896 event = "lane_store_chmod_fail_remove_failed",
897 %chmod_err,
898 "chmod failed on freshly-created lane state store, and removal also failed; \
899 file persists with current mode. chmod 0o600 manually before next start.",
900 );
901 }
902 }
903
904 /// Best-effort stringified file mode for operator log lines. Returns
905 /// `"unknown"` on stat failure or non-Unix targets — the field is purely
906 /// informational, so a missing value is preferable to a panic.
907 fn observed_mode_string(path: &Path) -> String {
908 #[cfg(unix)]
909 {
910 use std::os::unix::fs::PermissionsExt;
911 match std::fs::metadata(path) {
912 Ok(meta) => format!("{:o}", meta.permissions().mode() & 0o777),
913 Err(_) => "unknown".into(),
914 }
915 }
916 #[cfg(not(unix))]
917 {
918 let _ = path;
919 "n/a (non-unix)".into()
920 }
921 }
922
923 /// Tighten the on-disk file mode to `0o600` after open. **Idempotent**: if
924 /// the file is already at the target mode, the syscall is skipped so a
925 /// read-only mount (EROFS) where the mode is correct from a prior boot does
926 /// not brick subsequent startups.
927 ///
928 /// No-op on non-Unix targets (Windows ACLs are controlled at directory
929 /// level, per the comment on [`DB_FILE_MODE`]).
930 #[cfg(unix)]
931 fn tighten_permissions(path: &Path) -> Result<(), StoreError> {
932 use std::os::unix::fs::PermissionsExt;
933 let meta = std::fs::metadata(path).map_err(|source| StoreError::PermissionTighten {
934 path: path.to_path_buf(),
935 source,
936 })?;
937 if meta.permissions().mode() & 0o777 == DB_FILE_MODE {
938 return Ok(());
939 }
940 let perms = std::fs::Permissions::from_mode(DB_FILE_MODE);
941 std::fs::set_permissions(path, perms).map_err(|source| StoreError::PermissionTighten {
942 path: path.to_path_buf(),
943 source,
944 })?;
945 Ok(())
946 }
947
948 #[cfg(not(unix))]
949 fn tighten_permissions(_path: &Path) -> Result<(), StoreError> {
950 tracing::debug!(
951 "lane state store: file-mode tightening skipped on non-unix; relying on data_dir ACL",
952 );
953 Ok(())
954 }
955
956 /// Filesystem path of the underlying database file. Useful for log messages
957 /// and operator runbooks.
958 #[must_use]
959 pub fn path(&self) -> &Path {
960 &self.path
961 }
962}
963
964/// The value the deployment rebind forfeits for one dropped lane: the lane's
965/// full claim — [`LaneState::owed`], the signed cumulative plus the
966/// chain-verified frontier — minus its on-chain paid watermark, saturating.
967/// This is the figure the redeemer collects; counting `last_amount` alone
968/// would understate a lane by up to one whole chain, and a chain-extended
969/// redemption can leave `paid_cumulative` above `last_amount`.
970fn forfeited_value(state: &LaneState) -> U256 {
971 state.owed().saturating_sub(state.paid_cumulative)
972}
973
974/// Decode one stored record (table value) into a [`LaneState`], given its table
975/// key (the lane encoding). Shared by `load_all` and `get`.
976///
977/// `take_from_bytes` instead of `from_bytes` so a newer writer's additive
978/// fields (trailing bytes to this reader) decode cleanly — see
979/// [`StoredLaneState`]'s doc and the version tripwire in `into_state`.
980fn decode_record(
981 key_bytes: &[u8; LANE_KEY_LEN],
982 value_bytes: &[u8],
983) -> Result<LaneState, StoreError> {
984 let (pool_id, signer, provider) = lane_key_parts(key_bytes);
985 let (stored, remainder): (StoredLaneState, &[u8]) = postcard::take_from_bytes(value_bytes)
986 .map_err(|err| StoreError::Corrupt {
987 pool_id: Some(pool_id),
988 detail: format!("postcard decode failed: {err}"),
989 })?;
990 // Trailing-byte tolerance is bounded: a malicious writer could pad megabytes
991 // onto every record and silently inflate every read. Log (don't fail) when
992 // the trailer beyond the known fields exceeds a small sanity ceiling.
993 if remainder.len() > SANE_TRAILER_MAX_BYTES {
994 tracing::warn!(
995 %pool_id,
996 remainder = remainder.len(),
997 limit = SANE_TRAILER_MAX_BYTES,
998 event = "lane_store_excess_trailer",
999 "lane state record has unusually large trailing bytes; possible malicious padding or large-additive-field schema skew",
1000 );
1001 }
1002 stored.into_state(pool_id, signer, provider)
1003}
1004
1005impl PoolStateStore for PersistentPoolStateStore {
1006 /// Every persisted lane, from the in-memory working set hydrated at `open()`.
1007 /// Tombstoned slots (a `forget`-ten lane not yet flushed) read as absent.
1008 fn load_all(&self) -> Result<Vec<LaneState>, StoreError> {
1009 Ok(self
1010 .lanes
1011 .iter()
1012 .filter_map(|entry| match entry.value() {
1013 LaneSlot::Live(state) => Some(state.clone()),
1014 LaneSlot::Tombstoned => None,
1015 })
1016 .collect())
1017 }
1018
1019 fn get(&self, key: LaneKey) -> Result<Option<LaneState>, StoreError> {
1020 Ok(self.lanes.get(&key).and_then(|entry| match entry.value() {
1021 LaneSlot::Live(state) => Some(state.clone()),
1022 LaneSlot::Tombstoned => None,
1023 }))
1024 }
1025
1026 /// Advance the in-memory lane state and mark it dirty. Durability is the
1027 /// background flush's job (ADR 003 §Off-chain voucher state persistence).
1028 /// Locks only this lane's [`DashMap`] shard; concurrent `record`s on other
1029 /// lanes proceed in parallel.
1030 fn record(&self, state: &LaneState) -> Result<(), StoreError> {
1031 let key = state.key();
1032 let mut next = state.clone();
1033 match self.lanes.entry(key) {
1034 Entry::Occupied(mut occ) => {
1035 if let LaneSlot::Live(existing) = occ.get() {
1036 next.registered_until = next.registered_until.max(existing.registered_until);
1037 // Never let a record that omits the owner-signed material drop
1038 // a grant the lane already holds: like `registered_until`,
1039 // `owner_sig` is trusted-writer state set once and kept, and
1040 // losing it would strand the redeemer for a lane whose frontier
1041 // still advances (#1906).
1042 if next.owner_sig.is_none() {
1043 next.owner_sig = existing.owner_sig;
1044 }
1045 // Same rule for the paid watermark: the voucher-record path
1046 // carries it from the live in-memory lane, which never learns
1047 // the redeemed cumulative (that rides the `PoolRedeemed`
1048 // watcher via `set_paid_cumulative`). Keep the persisted value
1049 // so a frontier advance cannot regress it to zero (#2052).
1050 next.paid_cumulative = next.paid_cumulative.max(existing.paid_cumulative);
1051 }
1052 occ.insert(LaneSlot::Live(next));
1053 }
1054 Entry::Vacant(vac) => {
1055 vac.insert(LaneSlot::Live(next));
1056 }
1057 }
1058 self.dirty.push(key);
1059 Ok(())
1060 }
1061
1062 /// Raise this lane's observed on-chain registration expiry, monotonically —
1063 /// touches ONLY `registered_until`, never the replay-critical `last_*`
1064 /// tuple, so it cannot race a concurrent voucher `record` into a lost
1065 /// update. A no-op for a lane with no live record. Buffered like `record`;
1066 /// durability is the next `flush`'s job.
1067 fn set_registered_until(&self, key: LaneKey, registered_until: u64) -> Result<(), StoreError> {
1068 let Some(mut slot) = self.lanes.get_mut(&key) else {
1069 return Ok(());
1070 };
1071 let LaneSlot::Live(state) = slot.value_mut() else {
1072 return Ok(());
1073 };
1074 if registered_until > state.registered_until {
1075 state.registered_until = registered_until;
1076 self.dirty.push(key);
1077 }
1078 Ok(())
1079 }
1080
1081 /// Raise this lane's paid cumulative, monotonically — touches ONLY
1082 /// `paid_cumulative`, never the replay-critical `last_*` tuple, so a
1083 /// `PoolRedeemed` write cannot race a concurrent voucher `record` into a lost
1084 /// update. A no-op for a lane with no live record (a redemption observed after
1085 /// the lane was forgotten has nothing to advance). Buffered like `record`;
1086 /// durability is the next `flush`'s job (#2052).
1087 fn set_paid_cumulative(&self, key: LaneKey, paid_cumulative: U256) -> Result<(), StoreError> {
1088 let Some(mut slot) = self.lanes.get_mut(&key) else {
1089 return Ok(());
1090 };
1091 let LaneSlot::Live(state) = slot.value_mut() else {
1092 return Ok(());
1093 };
1094 if paid_cumulative > state.paid_cumulative {
1095 state.paid_cumulative = paid_cumulative;
1096 self.dirty.push(key);
1097 }
1098 Ok(())
1099 }
1100
1101 /// Tombstone the lane so reads treat it as absent and the next flush deletes
1102 /// its on-disk row. Idempotent. The slot stays in the map (as a tombstone)
1103 /// so a concurrent `record` resurrecting the lane is decided under the same
1104 /// shard lock rather than racing a separate set. The lane's owner-signed
1105 /// capability material (`owner_sig`) is part of the record, so it goes with
1106 /// the lane in the same delete.
1107 fn forget(&self, key: LaneKey) -> Result<(), StoreError> {
1108 self.lanes.insert(key, LaneSlot::Tombstoned);
1109 self.dirty.push(key);
1110 Ok(())
1111 }
1112
1113 /// Write every dirty lane and apply every tombstone in ONE fsynced redb
1114 /// transaction. Each lane record carries its own owner-signed capability
1115 /// material, so nothing is written separately. Idempotent — a no-op when
1116 /// clean.
1117 ///
1118 /// Double-buffered: the dirty work-list is drained and each lane's slot is
1119 /// cloned out (a cheap memcpy — [`LaneState`] holds no heap fields), then
1120 /// encoded, all off the fsync's critical path. The ~1–10 ms fsynced commit
1121 /// holds no lane shard lock, so a concurrent `record` (a single-shard map
1122 /// insert) never waits on disk I/O.
1123 ///
1124 /// A `record` that lands after its key is drained pushes the key afresh and
1125 /// is captured by the next flush; the store only needs a monotonic floor,
1126 /// and the frontier is documented "safe to lose" on a crash (ADR 003
1127 /// §Off-chain voucher state persistence), so a slightly older value now and a
1128 /// newer one one cadence later is correct.
1129 fn flush(&self) -> Result<(), StoreError> {
1130 // Drain the work-list, deduplicating: a lane touched N times since the
1131 // last flush sits in the queue N times, but we re-read its slot once.
1132 let mut drained: HashSet<LaneKey> = HashSet::new();
1133 while let Some(key) = self.dirty.pop() {
1134 drained.insert(key);
1135 }
1136 if drained.is_empty() {
1137 return Ok(());
1138 }
1139
1140 let mut writes: Vec<(LaneKey, Vec<u8>)> = Vec::new();
1141 let mut tombstones: Vec<LaneKey> = Vec::new();
1142 for key in &drained {
1143 // Clone the slot out under the shard lock, then release it before
1144 // encoding so a concurrent `record` on this lane never waits on the
1145 // postcard encode.
1146 let slot = self.lanes.get(key).map(|entry| entry.value().clone());
1147 match slot {
1148 Some(LaneSlot::Live(state)) => {
1149 match postcard::to_allocvec(&StoredLaneState::from(&state)) {
1150 Ok(encoded) => writes.push((*key, encoded)),
1151 Err(err) => {
1152 // Nothing has been committed yet, but the drain
1153 // already emptied these keys out of the work-list.
1154 // Re-push every drained key so the next flush retries
1155 // them — an early return here without the re-push
1156 // would leave the un-encoded lanes permanently
1157 // "clean" and never reach disk.
1158 for key in &drained {
1159 self.dirty.push(*key);
1160 }
1161 return Err(StoreError::Codec(format!(
1162 "postcard encode failed: {err}"
1163 )));
1164 }
1165 }
1166 }
1167 Some(LaneSlot::Tombstoned) => tombstones.push(*key),
1168 // Drained key with no slot: a `forget` re-pushed this key after a
1169 // flush reaped its tombstone via `remove_if`, so the row is
1170 // already gone. Nothing to write or delete — skip it.
1171 None => {}
1172 }
1173 }
1174 let mut snapshot = FlushSnapshot { writes, tombstones };
1175 // Every batch reaches redb in ascending key order; see
1176 // [`FlushSnapshot::sort_by_table_key`].
1177 snapshot.sort_by_table_key();
1178
1179 // Fsynced commit with NO lane shard lock held.
1180 if let Err(err) = self.commit_snapshot(&snapshot) {
1181 // The commit failed after the work-list was drained. Re-push the
1182 // snapshotted keys so the next flush retries them (the background
1183 // flusher logs "retrying next tick"). A concurrent `record`/`forget`
1184 // that superseded a key in the meantime keeps its newer slot; the
1185 // re-pushed key just re-reads whatever `lanes` now holds.
1186 for (key, _) in &snapshot.writes {
1187 self.dirty.push(*key);
1188 }
1189 for key in &snapshot.tombstones {
1190 self.dirty.push(*key);
1191 }
1192 tracing::warn!(
1193 dirty_lanes = snapshot.writes.len() + snapshot.tombstones.len(),
1194 error = %err,
1195 "lane store commit failed; every drained key is re-queued for the next flush"
1196 );
1197 return Err(err);
1198 }
1199
1200 // Commit succeeded: reap tombstoned slots whose row is now gone. Guard
1201 // with `remove_if` so a `record` that resurrected the lane to `Live`
1202 // after the snapshot keeps its slot (and its freshly-pushed dirty mark).
1203 for key in &snapshot.tombstones {
1204 self.lanes
1205 .remove_if(key, |_, slot| matches!(slot, LaneSlot::Tombstoned));
1206 }
1207 Ok(())
1208 }
1209}
1210
1211/// Encoded dirty writes and tombstone keys resolved from the drained work-list,
1212/// so [`PersistentPoolStateStore::flush`] can run its fsynced commit with no
1213/// shard lock held. Each lane record already carries its own owner-signed
1214/// capability material, so there is no separate capability batch.
1215struct FlushSnapshot {
1216 writes: Vec<(LaneKey, Vec<u8>)>,
1217 tombstones: Vec<LaneKey>,
1218}
1219
1220impl FlushSnapshot {
1221 /// Order both batches by their [`lane_key_bytes`] table key, which is redb's
1222 /// own key order — redb compares a `&[u8; N]` key lexicographically over the
1223 /// encoding. Ascending keys hit redb's append fast path, which fills a leaf
1224 /// page before opening the next instead of leaving each page part-full at a
1225 /// random split point. Measured over 512 lanes at this key and value size,
1226 /// the table occupies about 30% fewer leaf pages (47 against 65-68).
1227 ///
1228 /// The gain lands on keys appended past the end of the table — new lanes, and
1229 /// the cold-load case — because an overwrite of a hydrated lane replaces in
1230 /// place whatever order it arrives in. Steady-state flushes are dominated by
1231 /// overwrites, so treat this as a bound on how far the table sprawls over its
1232 /// lifetime rather than a saving on every flush.
1233 ///
1234 /// Sorting the encoded key rather than an `Ord` on [`LaneKey`] keeps the
1235 /// sort key and the redb key the same value, so a change to the key layout
1236 /// cannot make the two disagree. `writes` and `tombstones` are disjoint —
1237 /// each drained key resolves to exactly one of a live slot or a tombstone —
1238 /// so the insert run and the remove run never meet on one key, and sorting
1239 /// both leaves the whole flush deterministic even though the work-list drains
1240 /// through a `HashSet` whose iteration order carries no meaning.
1241 fn sort_by_table_key(&mut self) {
1242 self.writes
1243 .sort_by_cached_key(|(lane, _)| lane_key_bytes(lane));
1244 self.tombstones.sort_by_cached_key(lane_key_bytes);
1245 }
1246}
1247
1248impl PersistentPoolStateStore {
1249 /// Apply a [`FlushSnapshot`] in ONE fsynced redb transaction. Holds no
1250 /// shard lock — every value was already encoded during the snapshot. All
1251 /// batches arrive in ascending table-key order, so the insert loops append
1252 /// rightward through the B-trees.
1253 fn commit_snapshot(&self, snapshot: &FlushSnapshot) -> Result<(), StoreError> {
1254 let mut write_txn = self
1255 .lanes_db
1256 .begin_write()
1257 .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1258 write_txn
1259 .set_durability(Durability::Immediate)
1260 .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1261 {
1262 let mut table = write_txn
1263 .open_table(LANE_TABLE)
1264 .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1265 for (key, encoded) in &snapshot.writes {
1266 let key_bytes = lane_key_bytes(key);
1267 table
1268 .insert(&key_bytes, encoded.as_slice())
1269 .map_err(|err| StoreError::Backend(format!("insert: {err}")))?;
1270 }
1271 for key in &snapshot.tombstones {
1272 let key_bytes = lane_key_bytes(key);
1273 table
1274 .remove(&key_bytes)
1275 .map_err(|err| StoreError::Backend(format!("remove: {err}")))?;
1276 }
1277 }
1278 write_txn
1279 .commit()
1280 .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1281 Ok(())
1282 }
1283}
1284
1285// ---------------------------------------------------------------------------
1286// Buyer-side store (#744)
1287//
1288// The record codec and every table operation live in
1289// `decdn_incentive::buyer_pool_table` — the same code the client's
1290// `RedbBuyerPoolStore` runs (#1246). This file keeps only the wiring: which
1291// file the table lives in (`buyer.redb`, its own per-family file, so a buyer
1292// commit never waits on the seller lane, pending-settle or watcher-checkpoint
1293// writer slot) and which `TableDefinition` names it.
1294// ---------------------------------------------------------------------------
1295
1296/// Test/e2e-only seam on the concrete store. Kept here rather than on the
1297/// handle because the buyer reconciliation + mixed-reclaim e2e (#763) holds a
1298/// `PersistentPoolStateStore`.
1299#[cfg(any(test, feature = "anvil-e2e"))]
1300impl PersistentPoolStateStore {
1301 /// Test/e2e-only: write raw value bytes under a buyer pool key, bypassing
1302 /// the postcard encoder, to simulate a row left undecodable by a binary
1303 /// downgrade.
1304 ///
1305 /// # Errors
1306 ///
1307 /// [`StoreError::Backend`] if the write or durable commit fails.
1308 pub fn insert_raw_buyer_record(&self, pool_id: PoolId, bytes: &[u8]) -> Result<(), StoreError> {
1309 BuyerPoolTable::new(&self.buyer_db).insert_raw(pool_id, bytes)
1310 }
1311}
1312
1313/// [`BuyerPoolStore`] adapter over the shared [`PersistentPoolStateStore`].
1314///
1315/// Holds an `Arc` to the same store the seller path uses. The seller's
1316/// `lane_state_v1` table and the buyer pool table sit in separate redb files
1317/// (`lanes.redb` and `buyer.redb`) behind that one handle, so neither family's
1318/// commit waits on the other's writer slot. Hand this to the buyer service as
1319/// `Arc<dyn BuyerPoolStore>`.
1320#[derive(Debug, Clone)]
1321pub struct BuyerPoolStoreHandle {
1322 inner: std::sync::Arc<PersistentPoolStateStore>,
1323}
1324
1325impl BuyerPoolStoreHandle {
1326 /// Wrap a shared persistent store as a buyer-pool store.
1327 #[must_use]
1328 pub const fn new(inner: std::sync::Arc<PersistentPoolStateStore>) -> Self {
1329 Self { inner }
1330 }
1331
1332 /// View this store's `buyer.redb` as the buyer pool table.
1333 ///
1334 /// Not `const` — it derefs the `Arc`, which const fns cannot do.
1335 fn table(&self) -> BuyerPoolTable<'_> {
1336 BuyerPoolTable::new(&self.inner.buyer_db)
1337 }
1338}
1339
1340/// Every method delegates to [`decdn_incentive::buyer_pool_table`]; this newtype
1341/// contributes the `buyer.redb` wiring, not the logic.
1342impl BuyerPoolStore for BuyerPoolStoreHandle {
1343 fn load_all(&self) -> Result<BuyerLoad, StoreError> {
1344 self.table().load_all()
1345 }
1346
1347 fn record(&self, state: &BuyerPoolState) -> Result<(), StoreError> {
1348 self.table().record(state)
1349 }
1350
1351 fn forget(&self, owner: Address) -> Result<(), StoreError> {
1352 self.table().forget(owner)
1353 }
1354
1355 fn forget_if_pool(&self, owner: Address, pool_id: PoolId) -> Result<bool, StoreError> {
1356 self.table().forget_if_pool(owner, pool_id)
1357 }
1358
1359 fn get_by_pool_id(&self, pool_id: PoolId) -> Result<Option<BuyerPoolState>, StoreError> {
1360 self.table().get_by_pool_id(pool_id)
1361 }
1362
1363 fn get_by_owner(&self, owner: Address) -> Result<Option<BuyerPoolState>, StoreError> {
1364 self.table().get_by_owner(owner)
1365 }
1366
1367 fn advance_progress(
1368 &self,
1369 owner: Address,
1370 pool_id: PoolId,
1371 lane: LaneKey,
1372 bytes: U256,
1373 amount: U256,
1374 ) -> Result<AdvanceOutcome, StoreError> {
1375 self.table()
1376 .advance_progress(owner, pool_id, lane, bytes, amount)
1377 }
1378
1379 fn rebase_progress(
1380 &self,
1381 owner: Address,
1382 pool_id: PoolId,
1383 lane: LaneKey,
1384 anchor: BuyerLaneProgress,
1385 totals: BuyerLaneProgress,
1386 ) -> Result<AdvanceOutcome, StoreError> {
1387 self.table()
1388 .rebase_progress(owner, pool_id, lane, anchor, totals)
1389 }
1390
1391 fn seed_progress(
1392 &self,
1393 owner: Address,
1394 pool_id: PoolId,
1395 lane: LaneKey,
1396 bytes: U256,
1397 amount: U256,
1398 ) -> Result<AdvanceOutcome, StoreError> {
1399 self.table()
1400 .seed_progress(owner, pool_id, lane, bytes, amount)
1401 }
1402
1403 fn add_deposit(
1404 &self,
1405 owner: Address,
1406 pool_id: PoolId,
1407 additional: U256,
1408 ) -> Result<DepositOutcome, StoreError> {
1409 self.table().add_deposit(owner, pool_id, additional)
1410 }
1411}
1412
1413impl PersistentPoolStateStore {
1414 /// Insert/overwrite a pending-settle entry in `table` (fsync-on-commit). The
1415 /// `PENDING_SETTLE_TABLE` and `BUYER_PENDING_SETTLE_TABLE` share this body so
1416 /// the seller and buyer pending sets stay byte-for-byte consistent.
1417 fn pending_record_in(
1418 &self,
1419 table_def: TableDefinition<'_, &[u8; 32], u64>,
1420 entry: &PendingSettle,
1421 ) -> Result<(), StoreError> {
1422 let key: [u8; 32] = entry.pool_id.into();
1423 let mut write_txn = self
1424 .settle_db
1425 .begin_write()
1426 .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1427 // Force fsync-on-commit, same durability discipline as the other tables: the
1428 // pool is already closing on-chain by the time an entry is written here,
1429 // so a post-close crash that lost it would strand the settlement
1430 // obligation — the very gap this fsync guards.
1431 write_txn
1432 .set_durability(Durability::Immediate)
1433 .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1434 {
1435 let mut table = write_txn
1436 .open_table(table_def)
1437 .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1438 table
1439 .insert(&key, entry.settle_after)
1440 .map_err(|err| StoreError::Backend(format!("insert: {err}")))?;
1441 }
1442 write_txn
1443 .commit()
1444 .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1445 Ok(())
1446 }
1447
1448 /// Load every pending-settle entry from `table` (an absent table is the
1449 /// empty set, not an error — first-boot tolerance).
1450 fn pending_load_from(
1451 &self,
1452 table_def: TableDefinition<'_, &[u8; 32], u64>,
1453 ) -> Result<Vec<PendingSettle>, StoreError> {
1454 let read_txn = self
1455 .settle_db
1456 .begin_read()
1457 .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
1458 let table = match read_txn.open_table(table_def) {
1459 Ok(t) => t,
1460 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
1461 Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
1462 };
1463 let mut out = Vec::new();
1464 let iter = table
1465 .iter()
1466 .map_err(|err| StoreError::Backend(format!("table iter: {err}")))?;
1467 for entry in iter {
1468 let (key_guard, value_guard) =
1469 entry.map_err(|err| StoreError::Backend(format!("iter entry: {err}")))?;
1470 out.push(PendingSettle {
1471 pool_id: B256::from(*key_guard.value()),
1472 settle_after: value_guard.value(),
1473 });
1474 }
1475 Ok(out)
1476 }
1477
1478 /// Remove a pending-settle entry from `table` (a no-op against a
1479 /// never-written table, which must not be created as a side effect).
1480 fn pending_forget_in(
1481 &self,
1482 table_def: TableDefinition<'_, &[u8; 32], u64>,
1483 pool_id: B256,
1484 ) -> Result<(), StoreError> {
1485 let key: [u8; 32] = pool_id.into();
1486 {
1487 let read_txn = self
1488 .settle_db
1489 .begin_read()
1490 .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
1491 match read_txn.open_table(table_def) {
1492 Ok(_) => {}
1493 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(()),
1494 Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
1495 }
1496 }
1497
1498 let mut write_txn = self
1499 .settle_db
1500 .begin_write()
1501 .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1502 write_txn
1503 .set_durability(Durability::Immediate)
1504 .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1505 {
1506 let mut table = write_txn
1507 .open_table(table_def)
1508 .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1509 table
1510 .remove(&key)
1511 .map_err(|err| StoreError::Backend(format!("remove: {err}")))?;
1512 }
1513 write_txn
1514 .commit()
1515 .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1516 Ok(())
1517 }
1518}
1519
1520impl PendingSettleStore for PersistentPoolStateStore {
1521 fn record_pending(&self, entry: &PendingSettle) -> Result<(), StoreError> {
1522 self.pending_record_in(PENDING_SETTLE_TABLE, entry)
1523 }
1524
1525 fn load_pending(&self) -> Result<Vec<PendingSettle>, StoreError> {
1526 self.pending_load_from(PENDING_SETTLE_TABLE)
1527 }
1528
1529 fn forget_pending(&self, pool_id: B256) -> Result<(), StoreError> {
1530 self.pending_forget_in(PENDING_SETTLE_TABLE, pool_id)
1531 }
1532}
1533
1534/// [`PendingSettleStore`] over the buyer's `buyer_pending_settle_v1` table,
1535/// isolated from the seller's `pending_settle_v1` table so the two settle
1536/// sweeps never finalize each other's closes (#988). Wraps the same shared
1537/// [`PersistentPoolStateStore`] (one redb file, one handle); hand this to the
1538/// buyer service as `Arc<dyn PendingSettleStore>`.
1539#[derive(Debug, Clone)]
1540pub struct BuyerPendingSettleStoreHandle {
1541 inner: std::sync::Arc<PersistentPoolStateStore>,
1542}
1543
1544impl BuyerPendingSettleStoreHandle {
1545 /// Wrap a shared persistent store as the buyer pending-settle store.
1546 #[must_use]
1547 pub const fn new(inner: std::sync::Arc<PersistentPoolStateStore>) -> Self {
1548 Self { inner }
1549 }
1550}
1551
1552impl PendingSettleStore for BuyerPendingSettleStoreHandle {
1553 fn record_pending(&self, entry: &PendingSettle) -> Result<(), StoreError> {
1554 self.inner
1555 .pending_record_in(BUYER_PENDING_SETTLE_TABLE, entry)
1556 }
1557
1558 fn load_pending(&self) -> Result<Vec<PendingSettle>, StoreError> {
1559 self.inner.pending_load_from(BUYER_PENDING_SETTLE_TABLE)
1560 }
1561
1562 fn forget_pending(&self, pool_id: B256) -> Result<(), StoreError> {
1563 self.inner
1564 .pending_forget_in(BUYER_PENDING_SETTLE_TABLE, pool_id)
1565 }
1566}
1567
1568impl KeyedCheckpointStore for PersistentPoolStateStore {
1569 fn load_checkpoint(&self, key: CheckpointKey) -> Result<Option<u64>, StoreError> {
1570 let read_txn = self
1571 .checkpoint_db
1572 .begin_read()
1573 .map_err(|err| StoreError::Backend(format!("begin_read: {err}")))?;
1574 // A never-written checkpoint table means "no prior scan" — first boot.
1575 let table = match read_txn.open_table(WATCHER_CHECKPOINT_TABLE) {
1576 Ok(t) => t,
1577 Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
1578 Err(err) => return Err(StoreError::Backend(format!("open_table: {err}"))),
1579 };
1580 let value = table
1581 .get(key.as_str())
1582 .map_err(|err| StoreError::Backend(format!("get: {err}")))?;
1583 Ok(value.map(|g| g.value()))
1584 }
1585
1586 fn record_checkpoint(&self, key: CheckpointKey, block: u64) -> Result<(), StoreError> {
1587 let mut write_txn = self
1588 .checkpoint_db
1589 .begin_write()
1590 .map_err(|err| StoreError::Backend(format!("begin_write: {err}")))?;
1591 // Force fsync-on-commit, same durability discipline as the other tables.
1592 write_txn
1593 .set_durability(Durability::Immediate)
1594 .map_err(|err| StoreError::Backend(format!("set_durability: {err}")))?;
1595 {
1596 let mut table = write_txn
1597 .open_table(WATCHER_CHECKPOINT_TABLE)
1598 .map_err(|err| StoreError::Backend(format!("open_table: {err}")))?;
1599 table
1600 .insert(key.as_str(), block)
1601 .map_err(|err| StoreError::Backend(format!("insert: {err}")))?;
1602 }
1603 write_txn
1604 .commit()
1605 .map_err(|err| StoreError::Backend(format!("commit (fsync): {err}")))?;
1606 Ok(())
1607 }
1608}
1609
1610#[cfg(test)]
1611mod tests;