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