decdn_node/receipt_log.rs
1//! Append-only download-receipt log for the node runtime (issue #248).
2//!
3//! A node enqueues one structured [`DownloadReceipt`] per served-and-paid blob
4//! at the voucher-acceptance point (see
5//! [`crate::handlers::client::ClientHandler`]); the disk write happens off the
6//! delivery path in a background writer (see the Seam note below, #803). The
7//! log gives the operator an on-disk, off-chain record of which blobs were
8//! delivered and paid for — enabling revenue audit, cross-restart double-spend
9//! triage, and dispute evidence without relying solely on on-chain state.
10//!
11//! # Seam
12//!
13//! There are two layers. [`ReceiptLog`] is the *disk* boundary — a small trait
14//! (mirroring the trait+impl pattern of
15//! [`decdn_incentive::store::PoolStateStore`] / [`crate::channel_store`])
16//! whose runtime impl is the disk-backed [`JsonlReceiptLog`], appending one JSON
17//! object per line to `<data_dir>/download_receipts.jsonl` (JSON Lines).
18//! [`ReceiptSink`] is the *hot-path* boundary: the paid-delivery path does not
19//! call [`ReceiptLog::append`] directly — it enqueues through the non-blocking
20//! [`ReceiptSink::record`], and a single background [`spawn_receipt_writer`]
21//! task owns the `JsonlReceiptLog` and performs the actual `append` off the
22//! delivery path (#803), so a slow or full disk can never back-pressure
23//! delivery. Both seams take an in-memory fake in unit/loopback tests.
24//!
25//! # Durability
26//!
27//! Each [`ReceiptLog::append`] writes one line straight to the file — one
28//! `write_all`, which lands the bytes in the OS page cache — and does **not**
29//! fsync per record. The receipt log is an *audit* artifact, not the
30//! voucher-replay guard. That guard is the lane watermark in
31//! [`crate::channel_store::PersistentPoolStateStore`], which
32//! advances **in memory** the moment a voucher or preimage is accepted, reaches
33//! disk on the runtime's periodic lane flush
34//! (`payment.voucher_commit_interval_ms`, 5 s by default), and is flushed again
35//! before every on-chain redemption.
36//!
37//! Neither side fsyncs on the delivery path, so after a crash the two tails
38//! **can** disagree, in either direction, and neither direction is guaranteed.
39//! The audit log runs ahead when the background writer has already written a
40//! line whose lane advance is still buffered — a restart then finds a receipt
41//! whose watermark the lane store no longer holds. It runs behind when the
42//! crash catches receipts still queued in [`RECEIPT_LOG_CAPACITY`] (that queue
43//! is process memory and dies with the process, and only a graceful shutdown
44//! drains it), or when a hard crash drops page-cache lines the 5 s lane flush
45//! had already fsynced past.
46//!
47//! Both directions are safe. Ahead costs revenue, not correctness: the node
48//! forfeits at most one flush interval of *frontier* (value it has not yet
49//! redeemed), which ADR 003 §Off-chain voucher state persistence accepts.
50//! Behind is a gap in the audit record and nothing more.
51//!
52//! Replay protection rests on the *redeemed* watermark instead, which the
53//! pre-redemption flush floors. That floor holds wherever the node can afford
54//! to wait for it: the periodic sweep and the redeem-hint path skip their
55//! submit when the flush fails, while the shutdown sweep redeems anyway,
56//! because forfeiting the claim across the stop is worse than a bounded
57//! re-serve risk (see [`crate::payment_settlement`]). Skipping the
58//! per-line fsync keeps the cost off the hot delivery path (ADR 003).
59//!
60//! The append is also crash-atomic at the line level only in the usual POSIX
61//! sense (a torn final line is possible after a hard crash); JSONL readers MUST
62//! tolerate a truncated trailing line.
63//!
64//! # Rotation (issue #802)
65//!
66//! Left unbounded the log grows for the entire process lifetime and across
67//! restarts (`O_APPEND`), eventually exhausting `data_dir`. [`JsonlReceiptLog`]
68//! therefore takes a [`RotationPolicy`] (`max_file_bytes`, `retained_files`):
69//! once the live file would exceed `max_file_bytes` it is rotated to a numbered
70//! backup (`download_receipts.jsonl.1`, `.2`, …, oldest = highest), a fresh
71//! live file is opened, and any backup beyond `retained_files` is deleted. This
72//! bounds `data_dir` to roughly `(retained_files + 1) * max_file_bytes`.
73//! `retained_files == 0` keeps no backups — the live file is truncated in place
74//! on rotation. Rotation is **best-effort**: a rename/open failure mid-rotation
75//! never leaves the log without a writable handle and never aborts paid
76//! delivery (the background writer treats an `append` error as non-fatal, and
77//! delivery is decoupled from it by the bounded queue, #803), it only trims or
78//! skips a backup that cycle.
79
80use std::fs::{File, OpenOptions};
81use std::io::Write;
82use std::path::{Path, PathBuf};
83use std::sync::{Arc, Mutex};
84
85use alloy::primitives::U256;
86use iroh_blobs::Hash;
87use serde::{Deserialize, Serialize};
88use tokio::sync::mpsc;
89use tokio_util::sync::CancellationToken;
90
91use crate::metrics::Metrics;
92use crate::stop_handle::StopHandle;
93
94/// File name of the receipt log within `data_dir`. Canonical name lives in
95/// `decdn_common` so the daemon writer and the `config validate` summary can't
96/// disagree on where receipts land (#964).
97const RECEIPT_LOG_FILE: &str = decdn_common::config::RECEIPT_LOG_FILE;
98
99/// On-disk file mode (`0o600` — owner-only read+write). Same defense-in-depth
100/// rationale as [`crate::channel_store`]: `data_dir` is already `0o700`, but the
101/// receipt log can carry per-client delivery metadata, so the file mode is
102/// tightened independently in case the directory ACL is widened out-of-band.
103#[cfg(unix)]
104const RECEIPT_LOG_FILE_MODE: u32 = 0o600;
105
106/// One durable record of a served-and-paid blob (issue #248).
107///
108/// The on-disk shape. The paid-delivery path captures a [`RawReceipt`] at the
109/// voucher-acceptance point and the background writer renders it into this
110/// value (`RawReceipt::render`, #1792 item 2), so every receipt still
111/// corresponds to a cumulative payment the node verified and committed — the
112/// rendering just happens off the hot path. All identifier fields are lower-hex
113/// strings (no `0x` prefix) so the log is self-describing and greppable without
114/// a schema:
115///
116/// - `hash` — BLAKE3 content hash of the delivered blob (raw 32 bytes, hex).
117/// - `client_node_id` — iroh `NodeId` (ed25519 public key) of the paying peer
118/// (raw 32 bytes, hex).
119/// - `voucher_amount` — the accepted voucher's cumulative amount (its sole
120/// ordering key; there is no nonce), as a decimal string. A decimal `uint256`
121/// (not hex) so an auditor reads the same value the on-chain `PaymentPool` and
122/// operator dashboards show; it is reconstructed from the 32-byte big-endian
123/// wire amount.
124///
125/// `size` is the byte count covered by *this* payment proof (the newly
126/// delivered, now-paid bytes), and `timestamp_secs` is the node's wall-clock
127/// Unix time (seconds) read where the receipt is rendered — the background
128/// writer stamps it as it dequeues, which lags the acceptance point only by the
129/// queue-drain latency (#1792 item 2).
130///
131/// # Invariants
132///
133/// The fields are **private** and the only public constructor is [`new`], which
134/// renders the hex and decimal-`uint256` strings from typed inputs. This makes
135/// the invariants type-owned: a receipt cannot be built field-wise, and serde's
136/// derived `Deserialize` cannot synthesize one with a malformed `hash` /
137/// `client_node_id` / `voucher_amount` from outside the crate (the fields stay
138/// private to readers). Accessor methods expose the rendered values read-only.
139///
140/// Field-wise construction from outside the crate does not compile (the fields
141/// are private), so the validating [`new`] is the only public path in:
142///
143/// ```compile_fail
144/// use decdn_node::receipt_log::DownloadReceipt;
145/// // ERROR: fields `hash`, `size`, ... are private — invariant cannot be bypassed.
146/// let _ = DownloadReceipt {
147/// hash: "not-a-hash".to_string(),
148/// size: 1,
149/// client_node_id: "nope".to_string(),
150/// voucher_amount: "0xdeadbeef".to_string(),
151/// timestamp: 0,
152/// };
153/// ```
154///
155/// [`new`]: DownloadReceipt::new
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct DownloadReceipt {
158 /// BLAKE3 content hash of the delivered blob, lower-hex (64 chars).
159 hash: String,
160 /// Bytes covered by this payment proof (the newly paid delivery).
161 size: u64,
162 /// iroh `NodeId` of the paying client, lower-hex (64 chars).
163 client_node_id: String,
164 /// Accepted voucher cumulative amount, decimal `uint256` string.
165 voucher_amount: String,
166 /// Node wall-clock Unix time (seconds), stamped by the background writer as
167 /// it renders the receipt (#1792 item 2) — the acceptance point plus the
168 /// queue-drain latency. The wire key stays `timestamp` (preserved via
169 /// `#[serde(rename)]`) so the on-disk JSONL format is byte-identical to the
170 /// original; the Rust field name carries the unit explicitly.
171 #[serde(rename = "timestamp")]
172 timestamp_secs: u64,
173}
174
175impl DownloadReceipt {
176 /// Build a receipt from the typed delivery-path values, rendering every
177 /// invariant inside the constructor:
178 ///
179 /// - `hash` is the [`iroh_blobs::Hash`] of the delivered blob — a distinct
180 /// type from `client_node_id_bytes`, so the two 32-byte identifiers cannot
181 /// be transposed at a call site. Rendered to lower-hex (no `0x`).
182 /// - `client_node_id_bytes` is the raw 32-byte iroh node id, lower-hex.
183 /// - `voucher_amount` is the accepted voucher's cumulative `uint256` amount;
184 /// the decimal string is rendered here so the "decimal uint256" invariant
185 /// is owned by the type, not the caller.
186 #[must_use]
187 pub fn new(
188 hash: &Hash,
189 size: u64,
190 client_node_id_bytes: &[u8; 32],
191 voucher_amount: U256,
192 timestamp_secs: u64,
193 ) -> Self {
194 Self {
195 hash: hex_lower(hash.as_bytes()),
196 size,
197 client_node_id: hex_lower(client_node_id_bytes),
198 voucher_amount: voucher_amount.to_string(),
199 timestamp_secs,
200 }
201 }
202
203 /// BLAKE3 content hash of the delivered blob, lower-hex (64 chars, no `0x`).
204 #[must_use]
205 pub fn hash(&self) -> &str {
206 &self.hash
207 }
208
209 /// Bytes covered by this payment proof (the newly paid delivery).
210 #[must_use]
211 pub const fn size(&self) -> u64 {
212 self.size
213 }
214
215 /// iroh `NodeId` of the paying client, lower-hex (64 chars, no `0x`).
216 #[must_use]
217 pub fn client_node_id(&self) -> &str {
218 &self.client_node_id
219 }
220
221 /// Accepted voucher cumulative amount, decimal `uint256` string.
222 #[must_use]
223 pub fn voucher_amount(&self) -> &str {
224 &self.voucher_amount
225 }
226
227 /// Node wall-clock Unix time (seconds) at voucher acceptance.
228 #[must_use]
229 pub const fn timestamp_secs(&self) -> u64 {
230 self.timestamp_secs
231 }
232}
233
234/// One receipt as the paid-delivery hot path produces it: raw typed
235/// identifiers, no rendering and no clock read.
236///
237/// This is what [`ReceiptSink::record`] takes and what rides the writer queue,
238/// so enqueuing a receipt on the delivery path is a handful of `Copy` field
239/// moves — no hex encoding, no `U256` decimal formatting, and no
240/// `SystemTime::now()` (issue #1792 item 2). The background writer
241/// ([`spawn_receipt_writer`]) turns it into the on-disk [`DownloadReceipt`] via
242/// `RawReceipt::render`, which is where the two 64-char hex renders, the
243/// decimal-`uint256` render, and the acceptance timestamp are applied — off the
244/// hot path, in the same task that already owns the disk write (#803).
245#[derive(Debug, Clone, Copy)]
246pub struct RawReceipt {
247 /// BLAKE3 content hash of the delivered blob.
248 hash: Hash,
249 /// Bytes covered by this payment proof (the newly paid delivery).
250 size: u64,
251 /// Raw 32-byte iroh `NodeId` (ed25519 public key) of the paying peer.
252 client_node_id: [u8; 32],
253 /// Accepted voucher cumulative amount (its sole ordering key; no nonce).
254 voucher_amount: U256,
255}
256
257impl RawReceipt {
258 /// Capture the typed delivery-path values with no rendering. Cheap enough to
259 /// build inline on the paid-delivery path — every field is `Copy`.
260 #[must_use]
261 pub const fn new(
262 hash: Hash,
263 size: u64,
264 client_node_id: [u8; 32],
265 voucher_amount: U256,
266 ) -> Self {
267 Self {
268 hash,
269 size,
270 client_node_id,
271 voucher_amount,
272 }
273 }
274
275 /// Render this raw receipt into its on-disk [`DownloadReceipt`] form,
276 /// stamping `timestamp_secs`. This is where the hex and decimal-`uint256`
277 /// rendering happens; callers run it off the delivery path (the background
278 /// writer, or the inline test/loopback sink), so the hot path never pays for
279 /// it. `timestamp_secs` is read where `render` is called — for the
280 /// background writer that is the moment the receipt is dequeued, which lags
281 /// acceptance only by the queue-drain latency and preserves FIFO order.
282 #[must_use]
283 fn render(self, timestamp_secs: u64) -> DownloadReceipt {
284 DownloadReceipt::new(
285 &self.hash,
286 self.size,
287 &self.client_node_id,
288 self.voucher_amount,
289 timestamp_secs,
290 )
291 }
292}
293
294/// Lower-hex encode a 32-byte array without a `0x` prefix. Delegates to
295/// `alloy::primitives::hex::encode` (the `const-hex` re-export already pulled in
296/// via the `alloy` dependency), which emits lowercase with no `0x` prefix —
297/// byte-identical to the prior hand-rolled `{:02x}` loop, and locked against
298/// drift by the `serialized_line_is_wire_stable` test.
299fn hex_lower(bytes: &[u8; 32]) -> String {
300 alloy::primitives::hex::encode(bytes)
301}
302
303/// Append-only sink for [`DownloadReceipt`]s.
304///
305/// The trait exists so the voucher-accept path can be exercised against an
306/// in-memory fake in unit tests; the runtime uses [`JsonlReceiptLog`].
307/// Implementations MUST be cheap enough to call inline on the delivery path
308/// (one buffered line write + flush — no fsync; see the module durability note).
309pub trait ReceiptLog: Send + Sync {
310 /// Durably-enough append one receipt (write + flush, no fsync).
311 ///
312 /// # Errors
313 ///
314 /// Returns the underlying [`std::io::Error`] if serialization or the write
315 /// fails. The caller treats a receipt-log failure as non-fatal (the payment
316 /// already advanced the lane watermark) and logs it.
317 fn append(&self, receipt: &DownloadReceipt) -> std::io::Result<()>;
318}
319
320/// Size-based rotation policy for [`JsonlReceiptLog`] (issue #802).
321///
322/// `max_file_bytes` is the size at which the live file is rotated;
323/// `retained_files` is how many numbered backups (`.1`..=`.N`) to keep (`0`
324/// truncates the live file in place instead of keeping backups). A
325/// `max_file_bytes` of `0` disables rotation entirely (the live file grows
326/// unbounded) — a degenerate value the engine accepts but production never
327/// produces.
328///
329/// The engine deliberately accepts any values (the rotation guards in
330/// [`JsonlReceiptLog::append`] cope with a sub-line cap, and tests use tiny
331/// caps to exercise rotation deterministically). Production policies are built
332/// *only* from a [`decdn_common::config::ResolvedReceipts`] via the [`From`]
333/// impl below, and that config type is range-validated by
334/// `decdn_common::config::resolve_config` — so every live policy is within the
335/// operator-facing safe bounds (`[MIN_RECEIPT_MAX_FILE_BYTES,
336/// MAX_RECEIPT_MAX_FILE_BYTES]`, `retained_files <= MAX_RECEIPT_RETAINED_FILES`).
337#[derive(Debug, Clone, Copy)]
338pub struct RotationPolicy {
339 max_file_bytes: u64,
340 retained_files: u32,
341}
342
343impl RotationPolicy {
344 /// Build a policy from raw, *unvalidated* limits. Test-only: production
345 /// constructs policies from validated config through the [`From`] impl, so
346 /// this wider entry point (which accepts out-of-range and degenerate caps)
347 /// is not exposed outside `#[cfg(test)]`.
348 #[cfg(test)]
349 #[must_use]
350 pub(crate) const fn new(max_file_bytes: u64, retained_files: u32) -> Self {
351 Self {
352 max_file_bytes,
353 retained_files,
354 }
355 }
356}
357
358impl From<&decdn_common::config::ResolvedReceipts> for RotationPolicy {
359 /// The sole production constructor. `ResolvedReceipts` is range-validated by
360 /// the config resolver, so the policy is always within the operator-facing
361 /// safe bounds — the validation guarantee carries across the type boundary
362 /// instead of being re-opened by a raw constructor.
363 fn from(r: &decdn_common::config::ResolvedReceipts) -> Self {
364 Self {
365 max_file_bytes: r.max_file_bytes,
366 retained_files: r.retained_files,
367 }
368 }
369}
370
371/// Mutex-guarded writer state: the live append handle plus the in-memory byte
372/// count used to decide rotation without an extra `stat` per append.
373#[derive(Debug)]
374struct LogState {
375 file: File,
376 written: u64,
377}
378
379/// JSON-Lines, append-only [`ReceiptLog`] backed by a single rotating file
380/// under `data_dir`.
381///
382/// One [`File`] handle is held open for the process lifetime behind a [`Mutex`]
383/// so concurrent delivery streams cannot interleave partial lines. The handle
384/// is opened in append mode (`O_APPEND`), so even across the (already
385/// serialized) writes each `write_all` lands at the current end of file.
386/// Rotation (issue #802) swaps that handle under the same lock; see the
387/// module-level rotation note.
388#[derive(Debug)]
389pub struct JsonlReceiptLog {
390 state: Mutex<LogState>,
391 path: PathBuf,
392 policy: RotationPolicy,
393}
394
395impl JsonlReceiptLog {
396 /// Open (or create) the receipt log under `data_dir` with rotation `policy`.
397 ///
398 /// The file is opened in append mode and, on Unix, created with mode
399 /// `0o600`; an existing file's mode is tightened to `0o600` as
400 /// defense-in-depth (idempotent — skipped when already correct, so a
401 /// read-only mount with the right mode does not brick startup). The
402 /// in-memory rotation counter is seeded from the existing file length so a
403 /// log that is already near the cap rotates on the next append rather than
404 /// only after a full cap's worth of fresh writes.
405 ///
406 /// Unlike [`crate::channel_store::PersistentPoolStateStore::open`], a
407 /// failure here is **not** required to abort node bring-up: the receipt log
408 /// is an audit artifact, not the #527 replay guard. The runtime decides the
409 /// fatality (it currently logs and continues without the audit log).
410 ///
411 /// # Errors
412 ///
413 /// Returns a [`std::io::Error`] if the file cannot be opened/created, its
414 /// mode cannot be tightened, or its current length cannot be read.
415 pub fn open(data_dir: &Path, policy: RotationPolicy) -> std::io::Result<Self> {
416 let path = data_dir.join(RECEIPT_LOG_FILE);
417 let file = open_append(&path)?;
418 let written = file.metadata()?.len();
419 Ok(Self {
420 state: Mutex::new(LogState { file, written }),
421 path,
422 policy,
423 })
424 }
425
426 /// Filesystem path of the underlying (live) log file. Useful for operator
427 /// log lines and runbooks.
428 #[must_use]
429 pub fn path(&self) -> &Path {
430 &self.path
431 }
432
433 /// Path of the `n`-th rotated backup (`<live>.n`).
434 fn backup_path(&self, n: u32) -> PathBuf {
435 let mut raw = self.path.clone().into_os_string();
436 raw.push(format!(".{n}"));
437 PathBuf::from(raw)
438 }
439
440 /// Rotate the live file, preserving a usable append handle in `state`.
441 ///
442 /// Best-effort: every step that can fail leaves `state.file` pointing at a
443 /// writable canonical-path handle so the caller's next `append` still
444 /// succeeds. Any backup numbered above `retained_files` is pruned first (so
445 /// lowering the retention count reclaims stale backups). With
446 /// `retained_files == 0` the live file is then truncated in place;
447 /// otherwise the oldest retained backup is dropped, the chain is shifted up,
448 /// the live file becomes `.1`, and a fresh live file is opened. If opening
449 /// the fresh file fails, `.1` is renamed back so the original handle keeps
450 /// writing to the canonical path.
451 fn rotate(&self, state: &mut LogState) -> std::io::Result<()> {
452 let retained = self.policy.retained_files;
453 // Prune any backups numbered above the retention count first. This
454 // bounds disk even after an operator *lowers* `retained_files` (or sets
455 // it to 0) between runs, when stale higher-numbered backups (e.g. `.10`)
456 // from a prior, larger setting would otherwise linger forever.
457 self.prune_backups_above(retained);
458
459 if retained == 0 {
460 state.file.set_len(0)?;
461 state.written = 0;
462 return Ok(());
463 }
464
465 // A missing live path means an earlier rotation moved it to `.1` and
466 // could not move it back: `state.file` already writes into `.1`. The
467 // shift below would then push that inode up the chain on every retry
468 // until it fell off the end, so re-create the live file instead — that
469 // completes the earlier rotation.
470 if !self.path.exists() {
471 state.file = open_append(&self.path)?;
472 state.written = 0;
473 return Ok(());
474 }
475
476 // Drop the oldest retained backup (NotFound is fine before the chain
477 // fills) to make room for the shift below.
478 match std::fs::remove_file(self.backup_path(retained)) {
479 Ok(()) => {}
480 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
481 Err(e) => return Err(e),
482 }
483 // Shift `.i -> .(i+1)` from the top down so no rename clobbers a file
484 // that still needs moving. A missing intermediate backup is a benign gap.
485 for i in (1..retained).rev() {
486 match std::fs::rename(self.backup_path(i), self.backup_path(i + 1)) {
487 Ok(()) => {}
488 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
489 Err(e) => return Err(e),
490 }
491 }
492 // Move the live file to `.1`. The open handle in `state.file` follows the
493 // inode, so writes through it would now land in `.1` until we swap it.
494 std::fs::rename(&self.path, self.backup_path(1))?;
495 match open_append(&self.path) {
496 Ok(fresh) => {
497 state.file = fresh;
498 state.written = 0;
499 Ok(())
500 }
501 Err(open_err) => {
502 // Restore the canonical path so the still-open `state.file`
503 // handle keeps appending to a correctly-named live log.
504 if let Err(error) = std::fs::rename(self.backup_path(1), &self.path) {
505 tracing::warn!(
506 path = %self.path.display(),
507 %error,
508 "download receipt log: restoring the live path after a failed rotation \
509 failed; receipts append to the `.1` backup until the next rotation \
510 re-creates the live file"
511 );
512 }
513 Err(open_err)
514 }
515 }
516 }
517
518 /// Best-effort removal of every backup numbered strictly above `retained`.
519 /// Backups are written densely (`.1`..`.k`), so we delete upward from
520 /// `retained + 1` and stop at the first gap. This is disk-hygiene, not a
521 /// correctness gate, and must never abort an append — so it never returns an
522 /// error. It does, however, distinguish the two stop conditions: a missing
523 /// index ends the dense run silently, but a *real* error (permissions, busy,
524 /// I/O) is not the end of the run — higher-numbered backups may still exist
525 /// and would leak past the `(retained_files + 1) * max_file_bytes` disk
526 /// bound. Scanning past a gap could run unbounded, so we still stop, but a
527 /// real error is logged so the leak is observable rather than silent.
528 fn prune_backups_above(&self, retained: u32) {
529 let mut n = retained.saturating_add(1);
530 loop {
531 match std::fs::remove_file(self.backup_path(n)) {
532 Ok(()) => {}
533 // The dense `.1`..`.k` run ends at the first missing index.
534 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
535 Err(e) => {
536 tracing::warn!(
537 error = %e,
538 event = "download_receipt_prune_failed",
539 path = %self.backup_path(n).display(),
540 "could not prune stale receipt-log backup; backups above \
541 it may exceed receipts.retained_files until removed manually",
542 );
543 return;
544 }
545 }
546 match n.checked_add(1) {
547 Some(next) => n = next,
548 None => return,
549 }
550 }
551 }
552}
553
554/// Open (or create) an append-mode handle at `path`, applying the `0o600` mode
555/// on Unix and tightening an existing file's mode. Shared by `open` and the
556/// post-rotation reopen so both honour the same permissions invariant.
557fn open_append(path: &Path) -> std::io::Result<File> {
558 let mut opts = OpenOptions::new();
559 opts.create(true).append(true);
560 #[cfg(unix)]
561 {
562 use std::os::unix::fs::OpenOptionsExt as _;
563 opts.mode(RECEIPT_LOG_FILE_MODE);
564 }
565 let file = opts.open(path)?;
566 #[cfg(unix)]
567 tighten_permissions(path)?;
568 Ok(file)
569}
570
571/// Tighten the on-disk file mode to `0o600`. Idempotent: skips the syscall when
572/// the mode already matches so a read-only mount with the correct mode (e.g.
573/// from a prior boot) does not fail startup. Mirrors
574/// [`crate::channel_store::PersistentPoolStateStore`]'s tightening.
575#[cfg(unix)]
576fn tighten_permissions(path: &Path) -> std::io::Result<()> {
577 use std::os::unix::fs::PermissionsExt as _;
578 let meta = std::fs::metadata(path)?;
579 if meta.permissions().mode() & 0o777 == RECEIPT_LOG_FILE_MODE {
580 return Ok(());
581 }
582 std::fs::set_permissions(path, std::fs::Permissions::from_mode(RECEIPT_LOG_FILE_MODE))
583}
584
585impl ReceiptLog for JsonlReceiptLog {
586 fn append(&self, receipt: &DownloadReceipt) -> std::io::Result<()> {
587 // Serialize to a single line first, so a serialization failure never
588 // writes a partial record. `serde_json::to_string` cannot embed a
589 // newline in these scalar fields, keeping one-receipt-per-line.
590 let mut line = serde_json::to_string(receipt).map_err(std::io::Error::other)?;
591 line.push('\n');
592 let line_len = u64::try_from(line.len()).unwrap_or(u64::MAX);
593 let mut guard = self
594 .state
595 .lock()
596 .map_err(|_| std::io::Error::other("receipt log mutex poisoned"))?;
597 // Rotate before writing when this line would push the live file past the
598 // cap, but only once the file already holds a record — so a single line
599 // larger than the cap still writes (after one rotation) instead of
600 // looping. Rotation is best-effort: if it fails, `state.file` is still a
601 // writable handle, so we log and fall through to append the receipt to
602 // the current file (temporarily exceeding the cap) rather than dropping
603 // an audit record. The next append retries the rotation.
604 if self.policy.max_file_bytes > 0
605 && guard.written > 0
606 && guard.written.saturating_add(line_len) > self.policy.max_file_bytes
607 && let Err(e) = self.rotate(&mut guard)
608 {
609 tracing::warn!(
610 error = %e,
611 event = "download_receipt_rotation_failed",
612 path = %self.path.display(),
613 "receipt log rotation failed; appending to the current file \
614 (it may exceed receipts.max_file_bytes until the next rotation)",
615 );
616 }
617 match guard
618 .file
619 .write_all(line.as_bytes())
620 // `File::flush` is a no-op: `write_all` above is what hands the
621 // bytes to the OS. Kept so the call reads as a complete write
622 // sequence if the sink ever grows a buffered writer. No fsync — see
623 // the module-level durability note.
624 .and_then(|()| guard.file.flush())
625 {
626 Ok(()) => {
627 guard.written = guard.written.saturating_add(line_len);
628 Ok(())
629 }
630 Err(e) => {
631 // A partial write can land bytes before erroring, so the
632 // in-memory counter may now disagree with the file. Resync from
633 // metadata (best-effort) so a later rotation isn't decided on a
634 // stale count.
635 if let Ok(meta) = guard.file.metadata() {
636 guard.written = meta.len();
637 }
638 Err(e)
639 }
640 }
641 }
642}
643
644/// A [`ReceiptLog`] that drops every receipt. Used as the runtime fallback when
645/// [`JsonlReceiptLog::open`] fails at bring-up: the audit log is best-effort, so
646/// the node serves paid delivery without it rather than refusing to start (the
647/// replay guard lives in the separate lane store, which the periodic flush
648/// mirrors to disk). The operator gets one `error!` at startup naming the open
649/// failure.
650#[derive(Debug, Default)]
651pub struct NoopReceiptLog;
652
653impl ReceiptLog for NoopReceiptLog {
654 fn append(&self, _receipt: &DownloadReceipt) -> std::io::Result<()> {
655 Ok(())
656 }
657}
658
659/// Non-blocking enqueue boundary for [`RawReceipt`]s on the paid-delivery
660/// hot path (#803).
661///
662/// The voucher-accept path records a receipt through this seam as each voucher
663/// is accepted (acceptance is implicit — delivery simply continues), so the
664/// implementation MUST NOT block on disk I/O: a backed-up sink drops the
665/// receipt (best-effort, audit-only) rather than stall the payment, which
666/// already advanced the lane watermark. The runtime uses [`ChannelReceiptSink`]
667/// (hands off to the background [`spawn_receipt_writer`] task); tests use a
668/// synchronous fake behind the `test-support` `DirectReceiptSink`.
669///
670/// The seam carries a [`RawReceipt`], not the rendered [`DownloadReceipt`]: the
671/// hex/decimal rendering and the timestamp are applied downstream, off the hot
672/// path (#1792 item 2).
673pub trait ReceiptSink: Send + Sync {
674 /// Best-effort, non-blocking record of one receipt. Never blocks the caller
675 /// on disk and never fails delivery — a sink that cannot accept the receipt
676 /// drops it.
677 fn record(&self, receipt: RawReceipt);
678}
679
680/// Capacity of the receipt-writer queue. Receipts are roughly one per voucher
681/// interval (~1 MiB served), so this absorbs a large burst of voucher
682/// acceptances while a disk stall (a slow or full `data_dir`, the realistic
683/// end-state of #802) is worked off, without ever back-pressuring paid
684/// delivery. On overflow the *audit* receipt is dropped (counted via
685/// [`Metrics::receipt_write_dropped`]) rather than the *payment* stalling — the
686/// payment already advanced the lane watermark. Rotation (#802) bounds the log
687/// on disk; this bounds it in memory.
688pub const RECEIPT_LOG_CAPACITY: usize = 1024;
689
690/// Production [`ReceiptSink`]: enqueues to the background writer over a bounded
691/// channel. Mirrors the redeem-hint sink (#751): a `Full` channel drops the
692/// receipt and counts it (`decdn_receipt_writes_dropped_total`); a `Closed`
693/// channel — the writer stopped during shutdown — is silently ignored (the tail
694/// drain has already run, or is bounded by the shutdown deadline).
695#[derive(Clone)]
696pub struct ChannelReceiptSink {
697 tx: mpsc::Sender<RawReceipt>,
698 metrics: Arc<Metrics>,
699}
700
701impl std::fmt::Debug for ChannelReceiptSink {
702 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703 f.debug_struct("ChannelReceiptSink")
704 .field("capacity", &self.tx.max_capacity())
705 .finish_non_exhaustive()
706 }
707}
708
709impl ReceiptSink for ChannelReceiptSink {
710 fn record(&self, receipt: RawReceipt) {
711 match self.tx.try_send(receipt) {
712 // Enqueued — the background writer will append it.
713 Ok(()) => {}
714 // Queue saturated: drop the audit receipt and count it.
715 Err(mpsc::error::TrySendError::Full(_)) => self.metrics.receipt_write_dropped(),
716 // Writer gone. Expected only during shutdown (after the router drains
717 // and the writer's token is cancelled), so it is left uncounted to
718 // avoid false alerting — mirroring the redeem-hint `Closed` rationale
719 // (#751). A `debug!` still leaves a trace, so a writer that died
720 // unexpectedly (e.g. panicked) — which would otherwise drop every
721 // subsequent receipt with no signal — is at least observable.
722 Err(mpsc::error::TrySendError::Closed(_)) => {
723 tracing::debug!(
724 event = "download_receipt_sink_closed",
725 "receipt dropped: writer gone (expected only during shutdown)"
726 );
727 }
728 }
729 }
730}
731
732/// Synchronous [`ReceiptSink`] that appends inline to a [`ReceiptLog`],
733/// swallowing the write error. Test/loopback support only: lets the unit and
734/// `client_loopback` tests keep a deterministic in-memory [`ReceiptLog`] fake
735/// behind the sink seam the handler depends on, without standing up the
736/// background writer or polling for an async drain. Production never uses it —
737/// the runtime always routes through [`spawn_receipt_writer`] so disk I/O cannot
738/// block paid delivery.
739///
740/// It deliberately *violates* the [`ReceiptSink`] non-blocking contract (it
741/// appends inline on the caller's thread), so it must never be wired onto the
742/// paid-delivery path. The `test-support` feature gate keeps it out of every
743/// production build: it compiles only for this crate's own tests and for the
744/// cross-crate integration tests in `tests/`, which cannot see `#[cfg(test)]`
745/// items. The field is private and the type is `#[doc(hidden)]` so it does not
746/// read as a production knob.
747#[cfg(any(test, feature = "test-support"))]
748#[doc(hidden)]
749pub struct DirectReceiptSink(Arc<dyn ReceiptLog>);
750
751#[cfg(any(test, feature = "test-support"))]
752impl DirectReceiptSink {
753 /// Wrap a synchronous [`ReceiptLog`] as an inline-appending sink. Test and
754 /// loopback use only — see the type docs; never wire this onto the
755 /// paid-delivery path.
756 #[must_use]
757 pub fn new(log: Arc<dyn ReceiptLog>) -> Self {
758 Self(log)
759 }
760}
761
762#[cfg(any(test, feature = "test-support"))]
763impl std::fmt::Debug for DirectReceiptSink {
764 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765 f.debug_struct("DirectReceiptSink").finish_non_exhaustive()
766 }
767}
768
769#[cfg(any(test, feature = "test-support"))]
770impl ReceiptSink for DirectReceiptSink {
771 fn record(&self, receipt: RawReceipt) {
772 // Render inline (this fake appends on the caller's thread anyway) with a
773 // timestamp read here, mirroring where the background writer reads it.
774 let rendered = receipt.render(crate::payment_settlement::unix_now());
775 // Audit-only and non-fatal, matching the background writer's handling.
776 let _ = self.0.append(&rendered);
777 }
778}
779
780/// Spawn the single background task that owns the on-disk [`ReceiptLog`] and
781/// drains the receipt queue, returning the [`ReceiptSink`] the delivery path
782/// enqueues through plus the [`StopHandle`] that stops the task at graceful
783/// shutdown.
784///
785/// Decouples the audit write from the paid-delivery hot path (#803): the
786/// voucher-accept path does only a non-blocking [`ReceiptSink::record`] (a
787/// bounded `try_send`) as each voucher is accepted, while this task performs
788/// the actual `append` on the blocking pool. A slow or full disk can only fill
789/// the queue (and drop audit records, counted) — it can never delay delivery
790/// or serialize delivery on the receipt-log mutex. The writer is the *only*
791/// caller of [`ReceiptLog::append`], so the log's internal mutex sees no
792/// cross-stream contention.
793///
794/// On its stop signal ([`StopHandle::cancel`], fired after the router has
795/// drained, so no further receipts are produced) the task flushes whatever is
796/// already enqueued and exits; complete [`StopHandle::shutdown`] within the
797/// shutdown deadline to preserve the audit tail. The `StopHandle` owns the stop
798/// token beside the task handle, so the join cannot hang on sinks that outlive
799/// shutdown — [`crate::handlers::client::ClientHandler`] holds one for the
800/// whole runtime.
801pub fn spawn_receipt_writer(
802 log: Arc<dyn ReceiptLog>,
803 metrics: Arc<Metrics>,
804) -> (Arc<dyn ReceiptSink>, StopHandle) {
805 let (tx, rx) = mpsc::channel::<RawReceipt>(RECEIPT_LOG_CAPACITY);
806 let writer_metrics = Arc::clone(&metrics);
807 let handle =
808 StopHandle::spawn(move |shutdown| receipt_writer_loop(rx, log, writer_metrics, shutdown));
809 (Arc::new(ChannelReceiptSink { tx, metrics }), handle)
810}
811
812/// Drain loop for the background receipt writer (see [`spawn_receipt_writer`]).
813/// Appends each receipt FIFO until the queue closes or `shutdown` fires, then
814/// flushes the already-enqueued tail before returning.
815async fn receipt_writer_loop(
816 mut rx: mpsc::Receiver<RawReceipt>,
817 log: Arc<dyn ReceiptLog>,
818 metrics: Arc<Metrics>,
819 shutdown: CancellationToken,
820) {
821 loop {
822 tokio::select! {
823 biased;
824 maybe = rx.recv() => match maybe {
825 Some(receipt) => append_one(&log, receipt, &metrics).await,
826 // All sinks dropped — nothing more can be enqueued.
827 None => break,
828 },
829 () = shutdown.cancelled() => break,
830 }
831 }
832 // Tail-drain: write every receipt already enqueued before exiting, so a
833 // graceful shutdown does not drop the audit tail the way a crash does.
834 // `try_recv` yields `Empty` once
835 // the buffer is drained (and `Disconnected` if the senders are gone); either
836 // ends the drain.
837 while let Ok(receipt) = rx.try_recv() {
838 append_one(&log, receipt, &metrics).await;
839 }
840 tracing::debug!("download-receipt writer drained and stopped");
841}
842
843/// Render one raw receipt and append it on the blocking pool, logging a
844/// non-fatal failure.
845///
846/// The delivery task only enqueued a [`RawReceipt`]; the hex/decimal rendering
847/// happens here (#1792 item 2), off the paid-delivery path. The timestamp is
848/// read at dequeue — so it lags the acceptance point only by the queue-drain
849/// latency and stays in FIFO order — while the render itself runs *inside* the
850/// [`tokio::task::spawn_blocking`] closure, so no formatting or allocation
851/// touches a runtime worker thread either.
852///
853/// The blocking pool is also where the synchronous `write_all`/`flush` (and any
854/// disk stall under a full or slow `data_dir`) runs, never on a runtime worker.
855/// The single writer awaits each append before the next, preserving receipt
856/// order. An append error is non-fatal — the payment already advanced the lane
857/// watermark — so it is logged at `warn`, counted into
858/// `decdn_receipt_write_failures_total`, and the loop continues.
859async fn append_one(log: &Arc<dyn ReceiptLog>, raw: RawReceipt, metrics: &Metrics) {
860 // Stamp at dequeue so the timestamp reflects acceptance order; the render
861 // that consumes it runs on the blocking pool below.
862 let stamped_at = crate::payment_settlement::unix_now();
863 let log = Arc::clone(log);
864 let join = tokio::task::spawn_blocking(move || {
865 let receipt = raw.render(stamped_at);
866 let res = log.append(&receipt);
867 (res, receipt)
868 })
869 .await;
870 // A `JoinError` means the blocking append panicked (`spawn_blocking` tasks
871 // are not cancellable, so a panic is the only way here). The receipt was
872 // moved into the panicked closure, so its fields are gone — but the loss
873 // still gets a structured line, distinct from the normal write-failure path
874 // below, rather than relying on the default panic hook's unstructured stderr.
875 let (res, receipt) = match join {
876 Ok(pair) => pair,
877 Err(join_err) => {
878 metrics.receipt_write_failure();
879 tracing::warn!(
880 error = %join_err,
881 event = "download_receipt_writer_join_error",
882 "download-receipt append task panicked; one audit receipt lost \
883 (payment already committed, audit log only)"
884 );
885 return;
886 }
887 };
888 if let Err(e) = res {
889 metrics.receipt_write_failure();
890 tracing::warn!(
891 hash = receipt.hash(),
892 client_node_id = receipt.client_node_id(),
893 voucher_amount = receipt.voucher_amount(),
894 error = %e,
895 event = "download_receipt_write_failed",
896 "failed to append download receipt; payment already committed (audit log only)"
897 );
898 }
899}
900
901#[cfg(test)]
902mod tests;