Skip to main content

mongreldb_core/
result_cache.rs

1//! Persistent result-cache publication (TODO §2).
2//!
3//! Goal: result-cache inserts must do **no** serialization, encryption, write,
4//! sync, or rename on the query thread. The query thread enqueues a cheap
5//! `PersistableEntry` (Arc-shared rows + scalars) onto a bounded coalescing
6//! pending map; a background worker drains the map and performs the actual
7//! disk write through a [`PersistentCacheIo`] abstraction.
8//!
9//! Layout: frame format + encryption helpers first, then the writer queue,
10//! then the `PersistentCacheIo` trait + production `RealPersistentCacheIo`,
11//! then the test module, then the worker spawn function. Clippy's
12//! `items_after_test_module` lint flags this ordering; we keep it
13//! intentionally because tests sit alongside the writer queue they cover
14//! and the worker lives at the bottom because it depends on the writer.
15//!
16//! Frame format (stable across versions, written in little-endian order):
17//!
18//! ```text
19//! [magic: 4B = b"MLCP"]
20//! [format_version: u16 LE]
21//! [reserved: u16 LE = 0]
22//! [table_id: u64 LE]
23//! [schema_id: u64 LE]
24//! [run_generation: u64 LE]
25//! [cache_key: u64 LE]
26//! [entry_generation: u64 LE]
27//! [payload_len: u32 LE]
28//! [payload: u8 × payload_len]            # bincode(SerializedEntry) or
29//!                                        #   encrypted bincode(...)
30//! [crc32c: u32 LE]                       # over the prefix + payload_len + payload
31//! ```
32//!
33//! A loader rejects frames whose magic, version, table_id, schema_id, or
34//! run_generation does not match the open table, or whose CRC does not match
35//! the stored bytes. Stale `cache_key`s (already invalidated) are also
36//! rejected on reopen by the worker, which compares its queued entry's
37//! per-key generation against the latest clear generation captured on enqueue.
38
39use std::collections::HashMap;
40use std::io::Write;
41use std::path::{Path, PathBuf};
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::mpsc;
44use std::sync::{Arc, Condvar, Mutex};
45use std::time::Duration;
46
47use crc::{Crc, CRC_32_ISCSI};
48
49use crate::encryption::{AesCipher, Cipher};
50use crate::engine::{LookupMetrics, LookupMetricsSnapshot};
51
52/// Magic identifying a persistent-cache frame: "MLCP" (MongreLDb Cache Page).
53pub const FRAME_MAGIC: [u8; 4] = *b"MLCP";
54/// On-disk format version. Bumped on any layout change.
55pub const FRAME_FORMAT_VERSION: u16 = 1;
56/// Reserved key under which the `Clear` op is enqueued. Cannot collide with
57/// any real cache key (`u64::MAX` is reserved as the per-op sentinel).
58pub(crate) const CLEAR_KEY: u64 = u64::MAX;
59/// Length of the fixed prefix before `payload_len`.
60const FRAME_PREFIX_LEN: usize = 4 + 2 + 2 + 8 + 8 + 8 + 8 + 8;
61/// Length of the fixed trailer after `payload`.
62const FRAME_TRAILER_LEN: usize = 4;
63/// Total frame header + checksum = prefix + payload_len + trailer.
64const FRAME_HEADER_LEN: usize = FRAME_PREFIX_LEN + 4;
65const FRAME_FULL_OVERHEAD: usize = FRAME_HEADER_LEN + FRAME_TRAILER_LEN;
66/// AES-GCM nonce length.
67const NONCE_LEN: usize = 12;
68const CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
69
70/// Header of a persisted frame. Read from disk and re-validated before any
71/// payload is decoded.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct PersistedHeader {
74    pub format_version: u16,
75    pub table_id: u64,
76    pub schema_id: u64,
77    pub run_generation: u64,
78    pub cache_key: u64,
79    pub entry_generation: u64,
80    pub payload_len: u32,
81}
82
83/// Decoded frame ready for the table-side deserializer.
84#[derive(Debug, Clone)]
85pub struct PersistedFrame {
86    pub header: PersistedHeader,
87    /// Plaintext or ciphertext payload bytes (decryption is the caller's job).
88    pub payload: Vec<u8>,
89}
90
91/// Encode a `PersistedFrame` into a self-describing, checksummed byte buffer.
92/// The CRC32C is computed over the fixed prefix and the payload bytes.
93pub fn encode_frame(frame: &PersistedFrame) -> Vec<u8> {
94    let mut out = Vec::with_capacity(FRAME_FULL_OVERHEAD + frame.payload.len());
95    out.extend_from_slice(&FRAME_MAGIC);
96    out.extend_from_slice(&frame.header.format_version.to_le_bytes());
97    out.extend_from_slice(&0u16.to_le_bytes()); // reserved
98    out.extend_from_slice(&frame.header.table_id.to_le_bytes());
99    out.extend_from_slice(&frame.header.schema_id.to_le_bytes());
100    out.extend_from_slice(&frame.header.run_generation.to_le_bytes());
101    out.extend_from_slice(&frame.header.cache_key.to_le_bytes());
102    out.extend_from_slice(&frame.header.entry_generation.to_le_bytes());
103    out.extend_from_slice(&frame.header.payload_len.to_le_bytes());
104    out.extend_from_slice(&frame.payload);
105    let crc = CRC32C.checksum(&out[0..FRAME_PREFIX_LEN + 4 + frame.payload.len()]);
106    out.extend_from_slice(&crc.to_le_bytes());
107    out
108}
109
110/// Decode a frame buffer. Returns `None` on any structural or CRC failure.
111pub fn decode_frame(bytes: &[u8]) -> Option<PersistedFrame> {
112    if bytes.len() < FRAME_FULL_OVERHEAD {
113        return None;
114    }
115    // The body we checksum is everything except the trailing 4-byte CRC.
116    let body_len = bytes.len() - FRAME_TRAILER_LEN;
117    let stored_crc = u32::from_le_bytes(bytes[body_len..].try_into().ok()?);
118    let actual_crc = CRC32C.checksum(&bytes[..body_len]);
119    if actual_crc != stored_crc {
120        return None;
121    }
122    let mut p = &bytes[..body_len];
123    let mut magic = [0u8; 4];
124    magic.copy_from_slice(&p[..4]);
125    if magic != FRAME_MAGIC {
126        return None;
127    }
128    p = &p[4..];
129    let format_version = u16::from_le_bytes(p[..2].try_into().ok()?);
130    p = &p[2..];
131    let _reserved = u16::from_le_bytes(p[..2].try_into().ok()?);
132    p = &p[2..];
133    let table_id = u64::from_le_bytes(p[..8].try_into().ok()?);
134    p = &p[8..];
135    let schema_id = u64::from_le_bytes(p[..8].try_into().ok()?);
136    p = &p[8..];
137    let run_generation = u64::from_le_bytes(p[..8].try_into().ok()?);
138    p = &p[8..];
139    let cache_key = u64::from_le_bytes(p[..8].try_into().ok()?);
140    p = &p[8..];
141    let entry_generation = u64::from_le_bytes(p[..8].try_into().ok()?);
142    p = &p[8..];
143    let payload_len = u32::from_le_bytes(p[..4].try_into().ok()?) as usize;
144    p = &p[4..];
145    if p.len() < payload_len {
146        return None;
147    }
148    let payload = p[..payload_len].to_vec();
149    Some(PersistedFrame {
150        header: PersistedHeader {
151            format_version,
152            table_id,
153            schema_id,
154            run_generation,
155            cache_key,
156            entry_generation,
157            payload_len: payload_len as u32,
158        },
159        payload,
160    })
161}
162
163/// Quick header-only peek: returns `Some(PersistedHeader)` if the buffer is
164/// structurally valid and the magic/version match, without verifying the CRC
165/// or copying the payload. The CRC is still verified — a fast reject path
166/// would re-parse the entire buffer, so we simply delegate to [`decode_frame`].
167pub fn read_header_only(bytes: &[u8]) -> Option<PersistedHeader> {
168    decode_frame(bytes).map(|f| f.header)
169}
170
171// ============================================================================
172// Persistent cache identity + shared encode/decode helpers
173// ============================================================================
174
175/// Stable identity bound to a persisted cache frame (REM-002 §18.1). The
176/// loader compares the on-disk frame header against this identity and rejects
177/// the entry on any mismatch. The `logical_generation` is the durable table
178/// epoch (`Table::current_epoch().0`) — never a process-local counter — so a
179/// valid frame from an older logical state cannot be served after a commit
180/// has advanced the epoch. Persistent cache frames are valid only for the
181/// **exact** logical table generation: a frame from a *future* generation
182/// (backup/PITR restore, manual `_rcache` copy, partial filesystem rollback)
183/// can contain rows that do not exist in the restored table and is rejected
184/// with the same `GenerationMismatch` as an older frame.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub struct PersistentCacheIdentity {
187    pub table_id: u64,
188    pub schema_id: u64,
189    pub logical_generation: u64,
190}
191
192/// Context passed to [`encode_persisted_entry`] and used by the worker to
193/// validate the on-disk frame matches what the queue said should be written.
194#[derive(Debug, Clone, Copy)]
195pub struct PersistContext {
196    pub identity: PersistentCacheIdentity,
197    pub key: u64,
198    pub entry_generation: u64,
199}
200
201/// Why persistent publication is unavailable for a result cache. Each variant
202/// carries a stable [`PersistenceDisabledReason::label`] used in query traces
203/// and diagnostics.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum PersistenceDisabledReason {
206    /// The cache has no persistent directory configured.
207    NoDirectory,
208    /// The background worker (or its I/O setup) failed to start.
209    WorkerSpawnFailed,
210    /// The background worker was shut down (e.g. `Database::close`).
211    WorkerShutdown,
212    /// The writer queue was unavailable (e.g. poisoned / not installed).
213    QueueUnavailable,
214}
215
216impl PersistenceDisabledReason {
217    /// Stable lowercase label for metrics, traces, and logs.
218    pub fn label(&self) -> &'static str {
219        match self {
220            Self::NoDirectory => "no_directory",
221            Self::WorkerSpawnFailed => "worker_spawn_failed",
222            Self::WorkerShutdown => "worker_shutdown",
223            Self::QueueUnavailable => "queue_unavailable",
224        }
225    }
226}
227
228impl std::fmt::Display for PersistenceDisabledReason {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.write_str(self.label())
231    }
232}
233
234/// Explicit persistent-publication state of a result cache. Replaces the
235/// implicit `Option<writer>` semantics: the query path either enqueues onto
236/// the background writer or **skips** persistent publication (keeping the
237/// in-memory entry and incrementing the skip metric). There is no
238/// query-thread synchronous write fallback — the persistent cache is
239/// disposable optimization state and its loss degrades to a recompute.
240pub enum PersistentPublicationState {
241    /// Background writer installed; publication is asynchronous.
242    Async(Arc<PersistentResultCacheWriter>),
243    /// Publication unavailable; query-path persists are skipped.
244    Disabled(PersistenceDisabledReason),
245}
246
247impl std::fmt::Debug for PersistentPublicationState {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        match self {
250            Self::Async(_) => f.write_str("PersistentPublicationState::Async(..)"),
251            Self::Disabled(reason) => write!(f, "PersistentPublicationState::Disabled({reason:?})"),
252        }
253    }
254}
255
256/// Error returned by [`encode_persisted_entry`] when the payload cannot be
257/// framed (e.g. encryption failure). The caller treats this as a transient
258/// per-op failure and increments the persist error counter.
259#[derive(Debug)]
260pub struct CachePersistError {
261    pub message: String,
262}
263
264impl std::fmt::Display for CachePersistError {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        f.write_str(&self.message)
267    }
268}
269
270impl std::error::Error for CachePersistError {}
271
272/// Why a persisted frame was rejected by [`decode_persisted_entry`]. The
273/// caller maps each variant to a rejection metric / log line.
274#[derive(Debug, Clone, PartialEq, Eq)]
275#[allow(dead_code)] // some variants are reserved for future strictness
276pub enum CacheLoadRejection {
277    /// File does not start with the MLCP magic — legacy unframed data.
278    LegacyUnframed,
279    /// File is too small to contain a valid header / payload / trailer.
280    Truncated,
281    /// Stored CRC does not match the body bytes.
282    BadCrc,
283    /// Format version is not one we can read.
284    UnsupportedVersion(u16),
285    /// Table identity does not match the open table.
286    TableIdMismatch { expected: u64, found: u64 },
287    /// Schema identity does not match the open table.
288    SchemaIdMismatch { expected: u64, found: u64 },
289    /// Logical generation does not exactly match the open table's current
290    /// epoch (both older and future generations are rejected).
291    GenerationMismatch { expected: u64, found: u64 },
292    /// The cache key embedded in the header does not match the on-disk file
293    /// name (e.g. a file got renamed but its header still references the
294    /// old key — the safest response is to reject the whole entry).
295    KeyMismatch { expected: u64, found: u64 },
296    /// File claimed an encrypted payload but a cipher was not provided.
297    MissingCipher,
298    /// Cipher was provided but the GCM tag check failed (wrong key, bit rot).
299    DecryptionFailed,
300    /// Frame decoded cleanly but the inner payload did not deserialize.
301    PayloadInvalid,
302}
303
304impl std::fmt::Display for CacheLoadRejection {
305    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306        match self {
307            Self::LegacyUnframed => f.write_str("legacy unframed data"),
308            Self::Truncated => f.write_str("truncated frame"),
309            Self::BadCrc => f.write_str("bad crc"),
310            Self::UnsupportedVersion(v) => write!(f, "unsupported format version {v}"),
311            Self::TableIdMismatch { expected, found } => {
312                write!(f, "table id mismatch: expected {expected}, found {found}")
313            }
314            Self::SchemaIdMismatch { expected, found } => {
315                write!(f, "schema id mismatch: expected {expected}, found {found}")
316            }
317            Self::GenerationMismatch { expected, found } => write!(
318                f,
319                "logical generation mismatch: expected {expected}, found {found}"
320            ),
321            Self::KeyMismatch { expected, found } => {
322                write!(f, "cache key mismatch: expected {expected}, found {found}")
323            }
324            Self::MissingCipher => f.write_str("cipher required for encrypted frame"),
325            Self::DecryptionFailed => f.write_str("decryption failed"),
326            Self::PayloadInvalid => f.write_str("payload deserialize failed"),
327        }
328    }
329}
330
331impl std::error::Error for CacheLoadRejection {}
332
333/// Frame an already-bincode-serialized payload using the MLCP layout. Both
334/// the async worker and the synchronous maintenance helper call this — the
335/// on-disk format is unified across both paths (REM-002 §18.2). The payload is
336/// encrypted (or left as plaintext) before framing; the magic / version /
337/// table / schema / generation / key / payload_len / CRC32C layout is the
338/// same for both encrypted and plaintext frames.
339pub fn encode_persisted_entry(
340    context: PersistContext,
341    payload: &[u8],
342    cipher: Option<&AesCipher>,
343) -> Result<Vec<u8>, CachePersistError> {
344    let inner = encrypt_payload(cipher, payload).map_err(|e| CachePersistError {
345        message: format!("encrypt_persisted_entry: {:?}", e),
346    })?;
347    let frame = PersistedFrame {
348        header: PersistedHeader {
349            format_version: FRAME_FORMAT_VERSION,
350            table_id: context.identity.table_id,
351            schema_id: context.identity.schema_id,
352            run_generation: context.identity.logical_generation,
353            cache_key: context.key,
354            entry_generation: context.entry_generation,
355            payload_len: inner.len() as u32,
356        },
357        payload: inner,
358    };
359    Ok(encode_frame(&frame))
360}
361
362/// Decode + validate a frame produced by [`encode_persisted_entry`]. On
363/// rejection, the caller removes the on-disk file (the cache is disposable).
364/// On success, the plaintext payload is returned so the engine can perform
365/// the bincode-specific `SerializedEntry` deserialize.
366pub fn decode_persisted_entry(
367    expected: PersistentCacheIdentity,
368    expected_key: u64,
369    bytes: &[u8],
370    cipher: Option<&AesCipher>,
371) -> Result<Vec<u8>, CacheLoadRejection> {
372    // Legacy unframed files: anything that does not start with MLCP is
373    // treated as legacy data. The safe policy is to delete and recompute
374    // (REM-002 §18.5).
375    if bytes.len() < FRAME_MAGIC.len() || bytes[..FRAME_MAGIC.len()] != FRAME_MAGIC {
376        return Err(CacheLoadRejection::LegacyUnframed);
377    }
378    let frame = decode_frame(bytes).ok_or(CacheLoadRejection::BadCrc)?;
379    if frame.header.format_version != FRAME_FORMAT_VERSION {
380        return Err(CacheLoadRejection::UnsupportedVersion(
381            frame.header.format_version,
382        ));
383    }
384    if frame.header.table_id != expected.table_id {
385        return Err(CacheLoadRejection::TableIdMismatch {
386            expected: expected.table_id,
387            found: frame.header.table_id,
388        });
389    }
390    if frame.header.schema_id != expected.schema_id {
391        return Err(CacheLoadRejection::SchemaIdMismatch {
392            expected: expected.schema_id,
393            found: frame.header.schema_id,
394        });
395    }
396    if frame.header.run_generation != expected.logical_generation {
397        // Exact identity: persistent cache frames are valid only for the
398        // exact logical table generation. An older frame is stale; a future
399        // frame can only appear after a backup/PITR restore, a manual
400        // `_rcache` copy, or a partial filesystem rollback, and can contain
401        // rows that do not exist in the restored table. Both are rejected
402        // (and deleted best-effort by the caller) — accepting a future
403        // generation requires a separately designed restore protocol.
404        return Err(CacheLoadRejection::GenerationMismatch {
405            expected: expected.logical_generation,
406            found: frame.header.run_generation,
407        });
408    }
409    if frame.header.cache_key != expected_key {
410        return Err(CacheLoadRejection::KeyMismatch {
411            expected: expected_key,
412            found: frame.header.cache_key,
413        });
414    }
415    let plaintext = decrypt_payload(cipher, &frame.payload).map_err(|_e| {
416        // The encrypt path reports `IoError`, but on the load path any
417        // encryption error is treated as a key/auth failure (the file is
418        // untrusted). Convert the error to a typed rejection so the loader
419        // can return a clean variant.
420        CacheLoadRejection::DecryptionFailed
421    })?;
422    plaintext.ok_or(CacheLoadRejection::DecryptionFailed)
423}
424
425// ============================================================================
426// Pending-op map (writer queue)
427// ============================================================================
428
429/// A pending operation for one cache key. Later writes supersede earlier
430/// writes; a `Remove` supersedes a `Store`. `Clear` invalidates every older
431/// op under the same generation.
432#[derive(Debug)]
433pub enum PendingCacheOp {
434    Store(PersistableEntry),
435    Remove,
436    /// Clear the on-disk cache entirely. The worker only needs to know this
437    /// is a clear (the actual file deletion is performed by `io.clear()`).
438    Clear,
439}
440
441/// Cheap-to-share body for a queued store. The query thread constructs this
442/// with raw data (Arc-shared rows, columns, footprint) plus a lazy
443/// [`PersistableEntry::payload_factory`]. The factory captures the raw data
444/// and produces the bincode-serialized bytes only when the worker invokes
445/// it — keeping the query thread free of both serialization and I/O.
446pub struct PersistableEntry {
447    pub key: u64,
448    pub table_id: u64,
449    pub schema_id: u64,
450    pub run_generation: u64,
451    pub entry_generation: u64,
452    /// Approximate in-memory bytes; used for queue accounting.
453    pub bytes: usize,
454    /// Lazy bincode payload producer. Invoked exactly once by the worker
455    /// thread; the query thread never executes it. Returns `None` if
456    /// bincode encoding fails.
457    pub payload_factory: Box<dyn FnOnce() -> Option<Vec<u8>> + Send + 'static>,
458}
459
460impl std::fmt::Debug for PersistableEntry {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        f.debug_struct("PersistableEntry")
463            .field("key", &self.key)
464            .field("table_id", &self.table_id)
465            .field("schema_id", &self.schema_id)
466            .field("run_generation", &self.run_generation)
467            .field("entry_generation", &self.entry_generation)
468            .field("bytes", &self.bytes)
469            .finish_non_exhaustive()
470    }
471}
472
473/// State of the pending-operation map. The worker publishes stores only when
474/// the queued op's per-key generation still matches the latest generation seen
475/// for that key — a newer invalidate cannot be silently overwritten.
476#[derive(Debug)]
477pub struct PendingCacheState {
478    pub clear_generation: u64,
479    pub next_key_generation: u64,
480    /// Per-key, monotonically increasing generation. A `Remove` resets a key
481    /// to the same generation as a fresh `Store` would receive, so any prior
482    /// `Store` is observed as stale.
483    pub key_generations: HashMap<u64, u64>,
484    pub operations: HashMap<u64, PendingCacheOp>,
485    /// Per-op byte estimate: sum of `entry.bytes` for `Store`s. Used for
486    /// the `max_pending_bytes` capacity check; checked/saturating on every
487    /// transition.
488    pub approx_bytes: usize,
489}
490
491impl Default for PendingCacheState {
492    fn default() -> Self {
493        Self {
494            clear_generation: 0,
495            next_key_generation: 1,
496            key_generations: HashMap::new(),
497            operations: HashMap::new(),
498            approx_bytes: 0,
499        }
500    }
501}
502
503/// Capacity knobs for the writer. Held behind `Arc` so the worker can read
504/// without taking the queue lock.
505#[derive(Debug, Clone)]
506pub struct WriterLimits {
507    pub max_pending_keys: usize,
508    pub max_pending_bytes: usize,
509}
510
511impl Default for WriterLimits {
512    fn default() -> Self {
513        Self {
514            max_pending_keys: 16_384,
515            max_pending_bytes: 64 * 1024 * 1024,
516        }
517    }
518}
519
520struct Inner {
521    state: PendingCacheState,
522    limits: WriterLimits,
523    shutdown: bool,
524}
525
526/// Summary returned by the worker for one drained op. The writer uses it to
527/// maintain the stale-store / errors / abandoned counters.
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub enum DrainOutcome {
530    StorePublished,
531    StoreStale,
532    StoreErrored,
533    RemoveApplied,
534    RemoveErrored,
535    ClearApplied,
536    ClearErrored,
537    Abandoned,
538}
539
540/// A queued op + the key/generation snapshot the worker must verify before
541/// publishing. The worker re-reads the queue's per-key generation under the
542/// lock and rejects the op if it has been superseded.
543#[derive(Debug)]
544pub struct DrainedOp {
545    pub key: u64,
546    pub op: PendingCacheOp,
547    pub key_generation: u64,
548    pub clear_generation: u64,
549}
550
551/// The PersistentResultCacheWriter. The query thread calls
552/// [`PersistentResultCacheWriter::enqueue_store`] / [`enqueue_remove`] /
553/// `enqueue_clear`; the worker calls [`drain_one`] in a loop and re-validates
554/// the op before publishing.
555pub struct PersistentResultCacheWriter {
556    inner: Mutex<Inner>,
557    cond: Condvar,
558    metrics: LookupMetrics,
559    enqueued_total: AtomicU64,
560    coalesced_total: AtomicU64,
561    dropped_total: AtomicU64,
562    remove_total: AtomicU64,
563    stale_total: AtomicU64,
564    errors_total: AtomicU64,
565    abandoned_total: AtomicU64,
566    queue_depth: AtomicU64,
567    /// Number of I/O operations the worker is currently processing. The
568    /// worker increments this counter before invoking `io.write_atomic` /
569    /// `io.remove` / `io.clear` and decrements after the call returns. A
570    /// drain helper that observes `queue_depth == 0` should also wait for
571    /// this counter to drop to 0 before declaring the writer idle — the
572    /// "queue empty but write still in flight" window is what the
573    /// `flush_persistent_cache(deadline)` test relies on.
574    writes_in_flight: AtomicU64,
575}
576
577impl PersistentResultCacheWriter {
578    pub fn new(metrics: LookupMetrics, limits: WriterLimits) -> Self {
579        Self {
580            inner: Mutex::new(Inner {
581                state: PendingCacheState::default(),
582                limits,
583                shutdown: false,
584            }),
585            cond: Condvar::new(),
586            metrics,
587            enqueued_total: AtomicU64::new(0),
588            coalesced_total: AtomicU64::new(0),
589            dropped_total: AtomicU64::new(0),
590            remove_total: AtomicU64::new(0),
591            stale_total: AtomicU64::new(0),
592            errors_total: AtomicU64::new(0),
593            abandoned_total: AtomicU64::new(0),
594            queue_depth: AtomicU64::new(0),
595            writes_in_flight: AtomicU64::new(0),
596        }
597    }
598
599    /// Enqueue a `Store` op. Coalesces with a previous `Store` for the same
600    /// key (the new entry supersedes the old one — old bytes are subtracted
601    /// from the queue's `approx_bytes` first). Drops the op and increments
602    /// the dropped counter when the queue is full.
603    pub fn enqueue_store(&self, entry: PersistableEntry) {
604        let mut guard = self.inner.lock().expect("writer mutex poisoned");
605        let key = entry.key;
606        let bytes = entry.bytes;
607        // Per spec §9.4: coalescing the SAME key never adds a key. Check
608        // existing membership before applying the key-count cap.
609        let already_present = guard.state.operations.contains_key(&key);
610        let would_grow = !already_present;
611        let key_cap = guard.limits.max_pending_keys;
612        let byte_cap = guard.limits.max_pending_bytes;
613        if (would_grow && guard.state.operations.len() >= key_cap)
614            || guard.state.approx_bytes.saturating_add(bytes) > byte_cap
615        {
616            self.dropped_total.fetch_add(1, Ordering::Relaxed);
617            self.metrics
618                .result_cache_persist_dropped_store_total
619                .fetch_add(1, Ordering::Relaxed);
620            return;
621        }
622        // Per-op decision: a pending Remove wins over a new Store, but the
623        // caller's intent is preserved by leaving the Remove queued. The new
624        // Store is dropped silently (counted as dropped).
625        if let Some(PendingCacheOp::Remove) = guard.state.operations.get(&key) {
626            self.dropped_total.fetch_add(1, Ordering::Relaxed);
627            self.metrics
628                .result_cache_persist_dropped_store_total
629                .fetch_add(1, Ordering::Relaxed);
630            return;
631        }
632        if let Some(PendingCacheOp::Store(prev)) = guard.state.operations.get(&key) {
633            // Store→Store: subtract prev's bytes, count as coalesced.
634            guard.state.approx_bytes = guard.state.approx_bytes.saturating_sub(prev.bytes);
635            self.coalesced_total.fetch_add(1, Ordering::Relaxed);
636            self.metrics
637                .result_cache_persist_coalesced_total
638                .fetch_add(1, Ordering::Relaxed);
639        } else {
640            // Fresh key: register its generation.
641            let gen = guard.state.next_key_generation;
642            guard.state.next_key_generation = guard.state.next_key_generation.wrapping_add(1);
643            guard.state.key_generations.insert(key, gen);
644        }
645        guard
646            .state
647            .operations
648            .insert(key, PendingCacheOp::Store(entry));
649        guard.state.approx_bytes = guard.state.approx_bytes.saturating_add(bytes);
650        let depth = guard.state.operations.len() as u64;
651        drop(guard);
652        self.queue_depth.store(depth, Ordering::Relaxed);
653        self.enqueued_total.fetch_add(1, Ordering::Relaxed);
654        self.metrics
655            .result_cache_persist_enqueued_total
656            .fetch_add(1, Ordering::Relaxed);
657        self.cond.notify_one();
658    }
659
660    /// Enqueue a `Remove` op. Always supersedes any pending `Store` for the
661    /// same key (its bytes are subtracted so the queue can free capacity
662    /// before the worker actually deletes the file).
663    pub fn enqueue_remove(&self, key: u64) {
664        let mut guard = self.inner.lock().expect("writer mutex poisoned");
665        let new_gen = guard.state.next_key_generation;
666        guard.state.next_key_generation = guard.state.next_key_generation.wrapping_add(1);
667        guard.state.key_generations.insert(key, new_gen);
668        // Subtract the bytes of any pending Store this Remove supersedes.
669        if let Some(PendingCacheOp::Store(prev)) = guard.state.operations.get(&key) {
670            guard.state.approx_bytes = guard.state.approx_bytes.saturating_sub(prev.bytes);
671        }
672        guard.state.operations.insert(key, PendingCacheOp::Remove);
673        let depth = guard.state.operations.len() as u64;
674        drop(guard);
675        self.remove_total.fetch_add(1, Ordering::Relaxed);
676        self.metrics
677            .result_cache_persist_remove_total
678            .fetch_add(1, Ordering::Relaxed);
679        self.queue_depth.store(depth, Ordering::Relaxed);
680        self.cond.notify_one();
681    }
682
683    /// Advance the global clear generation and enqueue a `Clear` op so the
684    /// worker performs the actual file deletion. The in-memory pending map is
685    /// also dropped because every existing op is now stale.
686    pub fn enqueue_clear(&self) {
687        let mut guard = self.inner.lock().expect("writer mutex poisoned");
688        guard.state.clear_generation = guard.state.next_key_generation;
689        guard.state.next_key_generation = guard.state.next_key_generation.wrapping_add(1);
690        // Subtract the bytes of every Store before clearing the map.
691        let mut to_sub = 0usize;
692        for entry in guard.state.operations.values() {
693            if let PendingCacheOp::Store(s) = entry {
694                to_sub = to_sub.saturating_add(s.bytes);
695            }
696        }
697        guard.state.approx_bytes = guard.state.approx_bytes.saturating_sub(to_sub);
698        guard.state.operations.clear();
699        guard.state.key_generations.clear();
700        // Insert the Clear op under a reserved key (`u64::MAX`) so the worker
701        // picks it up next. The key space is the cache key space, so this
702        // cannot collide with a real cache key.
703        guard
704            .state
705            .operations
706            .insert(CLEAR_KEY, PendingCacheOp::Clear);
707        let depth = guard.state.operations.len() as u64;
708        drop(guard);
709        self.queue_depth.store(depth, Ordering::Relaxed);
710        self.cond.notify_all();
711    }
712
713    /// Pop the next pending op, waiting on the condvar until one is queued
714    /// or shutdown is requested. Returns `None` only after shutdown AND the
715    /// queue is empty — the worker drains remaining ops before exit.
716    pub fn drain_one(&self) -> Option<DrainedOp> {
717        let mut guard = self.inner.lock().expect("writer mutex poisoned");
718        loop {
719            if let Some(next_key) = guard.state.operations.keys().next().copied() {
720                let op = guard
721                    .state
722                    .operations
723                    .remove(&next_key)
724                    .expect("just observed");
725                let key_gen = guard
726                    .state
727                    .key_generations
728                    .get(&next_key)
729                    .copied()
730                    .unwrap_or(0);
731                // Subtract the queued op's contribution to the byte total.
732                if let PendingCacheOp::Store(s) = &op {
733                    guard.state.approx_bytes = guard.state.approx_bytes.saturating_sub(s.bytes);
734                }
735                // Drop the per-key generation entry — any new op will allocate a
736                // fresh generation on insert. This means a later fresh `Store`
737                // for the same key cannot collide with a stale op.
738                guard.state.key_generations.remove(&next_key);
739                let clear_gen = guard.state.clear_generation;
740                let depth = guard.state.operations.len() as u64;
741                drop(guard);
742                self.queue_depth.store(depth, Ordering::Relaxed);
743                return Some(DrainedOp {
744                    key: next_key,
745                    op,
746                    key_generation: key_gen,
747                    clear_generation: clear_gen,
748                });
749            }
750            if guard.shutdown {
751                return None;
752            }
753            guard = self.cond.wait(guard).expect("condvar wait poisoned");
754        }
755    }
756
757    /// Non-blocking variant: returns `Some(op)` if any op is queued, else
758    /// `None`. Used by `flush_persistent_cache(deadline)` to drain the queue
759    /// under a deadline.
760    pub fn try_drain_one(&self) -> Option<DrainedOp> {
761        let mut guard = self.inner.lock().expect("writer mutex poisoned");
762        let next_key = {
763            let mut iter = guard.state.operations.iter();
764            let (k, _) = iter.next()?;
765            *k
766        };
767        let op = guard
768            .state
769            .operations
770            .remove(&next_key)
771            .expect("just observed");
772        let key_gen = guard
773            .state
774            .key_generations
775            .get(&next_key)
776            .copied()
777            .unwrap_or(0);
778        if let PendingCacheOp::Store(s) = &op {
779            guard.state.approx_bytes = guard.state.approx_bytes.saturating_sub(s.bytes);
780        }
781        guard.state.key_generations.remove(&next_key);
782        let clear_gen = guard.state.clear_generation;
783        let depth = guard.state.operations.len() as u64;
784        drop(guard);
785        self.queue_depth.store(depth, Ordering::Relaxed);
786        Some(DrainedOp {
787            key: next_key,
788            op,
789            key_generation: key_gen,
790            clear_generation: clear_gen,
791        })
792    }
793
794    /// Mark the writer as shutdown. The worker drains remaining ops until
795    /// the queue is empty or the optional deadline expires; anything still
796    /// queued at that point is counted as abandoned.
797    pub fn shutdown(&self) {
798        let mut guard = self.inner.lock().expect("writer mutex poisoned");
799        guard.shutdown = true;
800        drop(guard);
801        self.cond.notify_all();
802    }
803
804    /// Count the ops still queued at the moment of the call. Used to compute
805    /// the abandoned count on shutdown.
806    pub fn pending_count(&self) -> u64 {
807        self.queue_depth.load(Ordering::Relaxed)
808    }
809
810    /// Drain every remaining op synchronously, accumulating their per-op
811    /// byte estimates into `abandoned_bytes` and counting each as abandoned.
812    /// Returns the number of abandoned ops. Used by
813    /// `shutdown_persistent_cache(deadline)` to clear the queue on close.
814    pub fn drain_all_as_abandoned(&self) -> u64 {
815        let mut guard = self.inner.lock().expect("writer mutex poisoned");
816        let mut n = 0u64;
817        let mut bytes = 0usize;
818        for (_, op) in guard.state.operations.drain() {
819            if let PendingCacheOp::Store(s) = op {
820                bytes = bytes.saturating_add(s.bytes);
821            }
822            n += 1;
823        }
824        guard.state.key_generations.clear();
825        guard.state.approx_bytes = 0;
826        drop(guard);
827        if n > 0 {
828            self.abandoned_total.fetch_add(n, Ordering::Relaxed);
829            self.metrics
830                .result_cache_persist_shutdown_abandoned_total
831                .fetch_add(n, Ordering::Relaxed);
832            let _ = bytes; // currently unused; reserved for future accounting
833        }
834        self.queue_depth.store(0, Ordering::Relaxed);
835        self.cond.notify_all();
836        n
837    }
838
839    pub fn record_outcome(&self, outcome: DrainOutcome) {
840        match outcome {
841            DrainOutcome::StorePublished => {}
842            DrainOutcome::RemoveApplied => {}
843            DrainOutcome::ClearApplied => {}
844            DrainOutcome::StoreStale => {
845                self.stale_total.fetch_add(1, Ordering::Relaxed);
846                self.metrics
847                    .result_cache_persist_stale_store_skipped_total
848                    .fetch_add(1, Ordering::Relaxed);
849            }
850            DrainOutcome::StoreErrored
851            | DrainOutcome::RemoveErrored
852            | DrainOutcome::ClearErrored => {
853                self.errors_total.fetch_add(1, Ordering::Relaxed);
854                self.metrics
855                    .result_cache_persist_errors_total
856                    .fetch_add(1, Ordering::Relaxed);
857            }
858            DrainOutcome::Abandoned => {
859                self.abandoned_total.fetch_add(1, Ordering::Relaxed);
860                self.metrics
861                    .result_cache_persist_shutdown_abandoned_total
862                    .fetch_add(1, Ordering::Relaxed);
863            }
864        }
865    }
866
867    pub fn enqueued_total(&self) -> u64 {
868        self.enqueued_total.load(Ordering::Relaxed)
869    }
870    pub fn coalesced_total(&self) -> u64 {
871        self.coalesced_total.load(Ordering::Relaxed)
872    }
873    pub fn dropped_total(&self) -> u64 {
874        self.dropped_total.load(Ordering::Relaxed)
875    }
876    pub fn remove_total(&self) -> u64 {
877        self.remove_total.load(Ordering::Relaxed)
878    }
879    pub fn stale_total(&self) -> u64 {
880        self.stale_total.load(Ordering::Relaxed)
881    }
882    pub fn errors_total(&self) -> u64 {
883        self.errors_total.load(Ordering::Relaxed)
884    }
885    pub fn abandoned_total(&self) -> u64 {
886        self.abandoned_total.load(Ordering::Relaxed)
887    }
888    pub fn queue_depth(&self) -> usize {
889        self.queue_depth.load(Ordering::Relaxed) as usize
890    }
891    /// Number of I/O operations the worker has not yet completed.
892    pub fn writes_in_flight(&self) -> u64 {
893        self.writes_in_flight.load(Ordering::Relaxed)
894    }
895
896    /// Point-in-time copy of the persistent-cache publish counters. Exposed
897    /// so external integration tests can assert on the writer's bookkeeping
898    /// without going through `Table::lookup_metrics_snapshot`.
899    pub fn persist_snapshot(&self) -> LookupMetricsSnapshot {
900        LookupMetricsSnapshot {
901            result_cache_persist_enqueued_total: self.enqueued_total.load(Ordering::Relaxed),
902            result_cache_persist_coalesced_total: self.coalesced_total.load(Ordering::Relaxed),
903            result_cache_persist_dropped_store_total: self.dropped_total.load(Ordering::Relaxed),
904            result_cache_persist_remove_total: self.remove_total.load(Ordering::Relaxed),
905            result_cache_persist_stale_store_skipped_total: self
906                .stale_total
907                .load(Ordering::Relaxed),
908            result_cache_persist_errors_total: self.errors_total.load(Ordering::Relaxed),
909            result_cache_persist_shutdown_abandoned_total: self
910                .abandoned_total
911                .load(Ordering::Relaxed),
912            result_cache_persist_queue_depth: self.queue_depth.load(Ordering::Relaxed),
913            ..LookupMetricsSnapshot::default()
914        }
915    }
916
917    /// Construct a writer with a fresh [`LookupMetrics`] (the canonical
918    /// metrics type is `pub(crate)` and not constructible from external
919    /// integration tests).
920    pub fn for_test(limits: WriterLimits) -> Self {
921        Self::new(LookupMetrics::default(), limits)
922    }
923
924    /// Bump the writer's per-key generation for `key` and return the new
925    /// generation. Used by the cache when it allocates a fresh entry
926    /// generation just before enqueueing, so a subsequent invalidation
927    /// (Remove / Clear) is observed as newer.
928    pub fn bump_persist_generation(&self, key: u64) -> u64 {
929        let mut guard = self.inner.lock().expect("writer mutex poisoned");
930        let gen = guard.state.next_key_generation;
931        guard.state.next_key_generation = guard.state.next_key_generation.wrapping_add(1);
932        guard.state.key_generations.insert(key, gen);
933        gen
934    }
935}
936
937// ============================================================================
938// PersistentCacheIo trait
939// ============================================================================
940
941/// I/O abstraction the worker thread uses to publish and reload persistent
942/// cache frames. Production code uses [`RealPersistentCacheIo`]; tests inject
943/// recording/blocking/failing variants.
944pub trait PersistentCacheIo: Send + Sync {
945    /// Atomically write `frame` for `key` (write to temp + fsync + rename +
946    /// dir-sync). Errors are reported as `IoError`.
947    fn write_atomic(&self, key: u64, frame: &[u8]) -> Result<(), IoError>;
948    /// Remove the persistent frame for `key`, if any. Missing files are OK.
949    fn remove(&self, key: u64) -> Result<(), IoError>;
950    /// Delete every persistent frame in this cache. Used by `clear`.
951    fn clear(&self) -> Result<(), IoError>;
952    /// Read the persistent frame for `key`, if any. Missing files return
953    /// `Ok(None)` so the loader can ignore absent entries.
954    fn load(&self, key: u64) -> Result<Option<Vec<u8>>, IoError>;
955    /// Best-effort: check whether the on-disk frame for `key` exists.
956    fn exists(&self, key: u64) -> bool;
957}
958
959/// Errors raised by [`PersistentCacheIo`]. Stored as opaque strings so the
960/// worker can record them on its metrics without converting every I/O error
961/// into a `MongrelError`.
962#[derive(Debug, Clone)]
963pub struct IoError {
964    pub kind: IoErrorKind,
965    pub message: String,
966}
967
968impl IoError {
969    pub fn new(kind: IoErrorKind, message: impl Into<String>) -> Self {
970        Self {
971            kind,
972            message: message.into(),
973        }
974    }
975}
976
977/// Distinguishes "retryable" from "fatal" I/O errors. Currently a marker;
978/// future changes may add fallback paths.
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
980pub enum IoErrorKind {
981    Other,
982}
983
984/// Real implementation of [`PersistentCacheIo`] writing to a temp directory.
985/// Each call performs temp write + fsync + rename + parent dir sync, which
986/// is the same crash-safe contract that production code already requires.
987pub struct RealPersistentCacheIo {
988    dir: PathBuf,
989}
990
991impl RealPersistentCacheIo {
992    pub fn new(dir: PathBuf) -> std::io::Result<Self> {
993        std::fs::create_dir_all(&dir)?;
994        Ok(Self { dir })
995    }
996    fn final_path(&self, key: u64) -> PathBuf {
997        self.dir.join(format!("{key:016x}.bin"))
998    }
999    fn temp_path(&self, key: u64) -> PathBuf {
1000        self.dir.join(format!("{key:016x}.bin.tmp"))
1001    }
1002}
1003
1004impl PersistentCacheIo for RealPersistentCacheIo {
1005    fn write_atomic(&self, key: u64, frame: &[u8]) -> Result<(), IoError> {
1006        let final_path = self.final_path(key);
1007        let tmp_path = self.temp_path(key);
1008        // Remove any orphan temp file from a prior failed write.
1009        let _ = std::fs::remove_file(&tmp_path);
1010        {
1011            let mut f = std::fs::File::create(&tmp_path).map_err(|e| {
1012                IoError::new(
1013                    IoErrorKind::Other,
1014                    format!("create {}: {e}", tmp_path.display()),
1015                )
1016            })?;
1017            f.write_all(frame)
1018                .map_err(|e| IoError::new(IoErrorKind::Other, format!("write tmp: {e}")))?;
1019            f.flush()
1020                .map_err(|e| IoError::new(IoErrorKind::Other, format!("flush tmp: {e}")))?;
1021            f.sync_all()
1022                .map_err(|e| IoError::new(IoErrorKind::Other, format!("fsync tmp: {e}")))?;
1023        }
1024        std::fs::rename(&tmp_path, &final_path).map_err(|e| {
1025            // Best effort: clean up the temp file before reporting the error.
1026            let _ = std::fs::remove_file(&tmp_path);
1027            IoError::new(
1028                IoErrorKind::Other,
1029                format!(
1030                    "rename {} -> {}: {e}",
1031                    tmp_path.display(),
1032                    final_path.display()
1033                ),
1034            )
1035        })?;
1036        // Durability: fsync the parent dir so the rename is durable.
1037        if let Ok(dir) = std::fs::File::open(&self.dir) {
1038            let _ = dir.sync_all();
1039        }
1040        Ok(())
1041    }
1042    fn remove(&self, key: u64) -> Result<(), IoError> {
1043        let path = self.final_path(key);
1044        match std::fs::remove_file(&path) {
1045            Ok(()) => Ok(()),
1046            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1047            Err(e) => Err(IoError::new(
1048                IoErrorKind::Other,
1049                format!("remove {}: {e}", path.display()),
1050            )),
1051        }
1052    }
1053    fn clear(&self) -> Result<(), IoError> {
1054        let entries = match std::fs::read_dir(&self.dir) {
1055            Ok(e) => e,
1056            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1057            Err(e) => {
1058                return Err(IoError::new(
1059                    IoErrorKind::Other,
1060                    format!("read_dir {}: {e}", self.dir.display()),
1061                ))
1062            }
1063        };
1064        for entry in entries.flatten() {
1065            let path = entry.path();
1066            if path.extension().and_then(|s| s.to_str()) == Some("bin") {
1067                if let Err(e) = std::fs::remove_file(&path) {
1068                    if e.kind() != std::io::ErrorKind::NotFound {
1069                        return Err(IoError::new(
1070                            IoErrorKind::Other,
1071                            format!("remove {}: {e}", path.display()),
1072                        ));
1073                    }
1074                }
1075            }
1076        }
1077        Ok(())
1078    }
1079    fn load(&self, key: u64) -> Result<Option<Vec<u8>>, IoError> {
1080        let path = self.final_path(key);
1081        match std::fs::read(&path) {
1082            Ok(b) => Ok(Some(b)),
1083            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1084            Err(e) => Err(IoError::new(
1085                IoErrorKind::Other,
1086                format!("read {}: {e}", path.display()),
1087            )),
1088        }
1089    }
1090    fn exists(&self, key: u64) -> bool {
1091        self.final_path(key).exists()
1092    }
1093}
1094
1095/// Helper used by the worker to encrypt a payload before it is written.
1096/// Returns the plaintext bytes untouched when no DEK is present.
1097pub fn encrypt_payload(cipher: Option<&AesCipher>, plaintext: &[u8]) -> Result<Vec<u8>, IoError> {
1098    let Some(cipher) = cipher else {
1099        return Ok(plaintext.to_vec());
1100    };
1101    let mut nonce = [0u8; NONCE_LEN];
1102    crate::encryption::fill_random(&mut nonce)
1103        .map_err(|e| IoError::new(IoErrorKind::Other, format!("fill_random nonce: {e}")))?;
1104    let ct = cipher
1105        .encrypt_page(&nonce, plaintext)
1106        .map_err(|e| IoError::new(IoErrorKind::Other, format!("aes encrypt: {e}")))?;
1107    let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
1108    out.extend_from_slice(&nonce);
1109    out.extend_from_slice(&ct);
1110    Ok(out)
1111}
1112
1113/// Inverse of [`encrypt_payload`]: returns `None` on key mismatch or tag
1114/// failure.
1115pub fn decrypt_payload(
1116    cipher: Option<&AesCipher>,
1117    bytes: &[u8],
1118) -> Result<Option<Vec<u8>>, IoError> {
1119    let Some(cipher) = cipher else {
1120        return Ok(Some(bytes.to_vec()));
1121    };
1122    if bytes.len() < NONCE_LEN {
1123        return Ok(None);
1124    }
1125    let nonce: [u8; NONCE_LEN] = bytes[..NONCE_LEN].try_into().expect("checked above");
1126    match cipher.decrypt_page(&nonce, &bytes[NONCE_LEN..]) {
1127        Ok(plaintext) => Ok(Some(plaintext)),
1128        Err(_) => Ok(None),
1129    }
1130}
1131
1132#[cfg(test)]
1133#[allow(clippy::items_after_test_module)]
1134mod tests {
1135    use super::*;
1136
1137    fn entry(key: u64, bytes: usize) -> PersistableEntry {
1138        PersistableEntry {
1139            key,
1140            table_id: 0,
1141            schema_id: 0,
1142            run_generation: 0,
1143            entry_generation: 0,
1144            bytes,
1145            payload_factory: Box::new(|| Some(Vec::new())),
1146        }
1147    }
1148
1149    #[test]
1150    fn frame_round_trip() {
1151        let frame = PersistedFrame {
1152            header: PersistedHeader {
1153                format_version: FRAME_FORMAT_VERSION,
1154                table_id: 7,
1155                schema_id: 9,
1156                run_generation: 11,
1157                cache_key: 13,
1158                entry_generation: 17,
1159                payload_len: 5,
1160            },
1161            payload: b"hello".to_vec(),
1162        };
1163        let encoded = encode_frame(&frame);
1164        let decoded = decode_frame(&encoded).expect("round-trip");
1165        assert_eq!(decoded.header, frame.header);
1166        assert_eq!(decoded.payload, frame.payload);
1167    }
1168
1169    #[test]
1170    fn frame_rejects_bad_crc() {
1171        let frame = PersistedFrame {
1172            header: PersistedHeader {
1173                format_version: FRAME_FORMAT_VERSION,
1174                table_id: 1,
1175                schema_id: 1,
1176                run_generation: 1,
1177                cache_key: 1,
1178                entry_generation: 1,
1179                payload_len: 4,
1180            },
1181            payload: b"test".to_vec(),
1182        };
1183        let mut encoded = encode_frame(&frame);
1184        let last = encoded.len() - 1;
1185        encoded[last] ^= 0xFF;
1186        assert!(decode_frame(&encoded).is_none());
1187    }
1188
1189    #[test]
1190    fn coalescing_replaces_earlier_store() {
1191        let m = LookupMetrics::default();
1192        let w = PersistentResultCacheWriter::new(m, WriterLimits::default());
1193        w.enqueue_store(entry(1, 100));
1194        w.enqueue_store(entry(1, 200));
1195        assert_eq!(w.coalesced_total(), 1);
1196        assert_eq!(w.queue_depth(), 1);
1197    }
1198
1199    #[test]
1200    fn coalescing_does_not_grow_key_count() {
1201        // Even when max_pending_keys == 1, the second enqueue for the same
1202        // key must be coalesced, not dropped.
1203        let m = LookupMetrics::default();
1204        let w = PersistentResultCacheWriter::new(
1205            m,
1206            WriterLimits {
1207                max_pending_keys: 1,
1208                max_pending_bytes: 1024 * 1024,
1209            },
1210        );
1211        w.enqueue_store(entry(7, 100));
1212        w.enqueue_store(entry(7, 200));
1213        assert_eq!(w.coalesced_total(), 1);
1214        assert_eq!(w.dropped_total(), 0);
1215        assert_eq!(w.queue_depth(), 1);
1216    }
1217
1218    #[test]
1219    fn remove_supersedes_pending_store() {
1220        let m = LookupMetrics::default();
1221        let w = PersistentResultCacheWriter::new(m, WriterLimits::default());
1222        w.enqueue_store(entry(1, 100));
1223        w.enqueue_remove(1);
1224        // A subsequent Store against a pending Remove is dropped.
1225        w.enqueue_store(entry(1, 100));
1226        assert_eq!(w.dropped_total(), 1);
1227        assert_eq!(w.queue_depth(), 1);
1228    }
1229
1230    #[test]
1231    fn clear_invalidates_every_queued_op() {
1232        let m = LookupMetrics::default();
1233        let w = PersistentResultCacheWriter::new(m, WriterLimits::default());
1234        w.enqueue_store(entry(1, 100));
1235        w.enqueue_store(entry(2, 100));
1236        w.enqueue_clear();
1237        // The pending Stores are dropped; a single Clear op is left for the
1238        // worker to consume (the worker will issue `io.clear()`).
1239        assert_eq!(w.queue_depth(), 1);
1240        let drained = w.drain_one().expect("drain clear");
1241        assert!(matches!(drained.op, PendingCacheOp::Clear));
1242        assert_eq!(w.queue_depth(), 0);
1243    }
1244
1245    #[test]
1246    fn capacity_overflow_drops_new_key() {
1247        let m = LookupMetrics::default();
1248        let w = PersistentResultCacheWriter::new(
1249            m,
1250            WriterLimits {
1251                max_pending_keys: 1,
1252                max_pending_bytes: 1024,
1253            },
1254        );
1255        w.enqueue_store(entry(1, 100));
1256        w.enqueue_store(entry(2, 100));
1257        assert_eq!(w.dropped_total(), 1);
1258        assert_eq!(w.queue_depth(), 1);
1259    }
1260
1261    #[test]
1262    fn byte_accounting_exact() {
1263        let m = LookupMetrics::default();
1264        let w = PersistentResultCacheWriter::new(
1265            m,
1266            WriterLimits {
1267                max_pending_keys: 64,
1268                max_pending_bytes: 1000,
1269            },
1270        );
1271        w.enqueue_store(entry(1, 400));
1272        w.enqueue_store(entry(2, 400));
1273        // The third enqueue for a new key would push bytes past the cap.
1274        w.enqueue_store(entry(3, 400));
1275        assert_eq!(w.dropped_total(), 1);
1276        assert_eq!(w.queue_depth(), 2);
1277
1278        // Replace store-1 with a 200-byte Store; the queue byte total drops
1279        // from 800 to 600. Now enqueueing a new 400-byte Store fits.
1280        w.enqueue_store(entry(1, 200));
1281        w.enqueue_store(entry(3, 400));
1282        assert_eq!(w.dropped_total(), 1);
1283
1284        // After drain, the remaining entry must keep the queue under cap and
1285        // allow a new 400-byte Store.
1286        let m2 = LookupMetrics::default();
1287        let w2 = PersistentResultCacheWriter::new(
1288            m2,
1289            WriterLimits {
1290                max_pending_keys: 64,
1291                max_pending_bytes: 1000,
1292            },
1293        );
1294        w2.enqueue_store(entry(1, 400));
1295        w2.enqueue_store(entry(2, 400));
1296        // Drain one of the two keys; the remaining entry must keep the
1297        // queue under cap and allow a new 400-byte Store.
1298        let _ = w2.drain_one();
1299        w2.enqueue_store(entry(3, 400));
1300        assert_eq!(w2.dropped_total(), 0);
1301    }
1302
1303    #[test]
1304    fn shutdown_drains_pending_ops() {
1305        let m = LookupMetrics::default();
1306        let w = PersistentResultCacheWriter::new(m, WriterLimits::default());
1307        w.enqueue_store(entry(1, 100));
1308        w.enqueue_store(entry(2, 100));
1309        w.shutdown();
1310        assert!(w.drain_one().is_some());
1311        assert!(w.drain_one().is_some());
1312        assert!(w.drain_one().is_none());
1313    }
1314
1315    #[test]
1316    fn drain_all_as_abandoned_clears_queue() {
1317        let m = LookupMetrics::default();
1318        let w = PersistentResultCacheWriter::new(m, WriterLimits::default());
1319        w.enqueue_store(entry(1, 100));
1320        w.enqueue_store(entry(2, 100));
1321        w.enqueue_store(entry(3, 100));
1322        let n = w.drain_all_as_abandoned();
1323        assert_eq!(n, 3);
1324        assert_eq!(w.queue_depth(), 0);
1325        assert_eq!(w.abandoned_total(), 3);
1326    }
1327
1328    // --------------------------------------------------------------------
1329    // REM-E §9.5: exact logical-generation identity on load.
1330    // --------------------------------------------------------------------
1331
1332    fn generation_context(table_id: u64, schema_id: u64, generation: u64) -> PersistContext {
1333        PersistContext {
1334            identity: PersistentCacheIdentity {
1335                table_id,
1336                schema_id,
1337                logical_generation: generation,
1338            },
1339            key: 7,
1340            entry_generation: 1,
1341        }
1342    }
1343
1344    fn assert_exact_generation_identity(cipher: Option<&AesCipher>) {
1345        let expected = PersistentCacheIdentity {
1346            table_id: 1,
1347            schema_id: 1,
1348            logical_generation: 50,
1349        };
1350        let payload = b"cached-rows-payload";
1351
1352        // generation == expected → accepted.
1353        let bytes =
1354            encode_persisted_entry(generation_context(1, 1, 50), payload, cipher).expect("encode");
1355        let decoded = decode_persisted_entry(expected, 7, &bytes, cipher).expect("equal accepted");
1356        assert_eq!(decoded, payload);
1357
1358        // generation < expected → rejected.
1359        let bytes =
1360            encode_persisted_entry(generation_context(1, 1, 49), payload, cipher).expect("encode");
1361        let err = decode_persisted_entry(expected, 7, &bytes, cipher)
1362            .expect_err("older generation rejected");
1363        assert_eq!(
1364            err,
1365            CacheLoadRejection::GenerationMismatch {
1366                expected: 50,
1367                found: 49
1368            }
1369        );
1370
1371        // generation > expected → rejected (future frame after a restore).
1372        let bytes =
1373            encode_persisted_entry(generation_context(1, 1, 51), payload, cipher).expect("encode");
1374        let err = decode_persisted_entry(expected, 7, &bytes, cipher)
1375            .expect_err("future generation rejected");
1376        assert_eq!(
1377            err,
1378            CacheLoadRejection::GenerationMismatch {
1379                expected: 50,
1380                found: 51
1381            }
1382        );
1383    }
1384
1385    #[test]
1386    fn loader_requires_exact_logical_generation_plaintext() {
1387        assert_exact_generation_identity(None);
1388    }
1389
1390    #[test]
1391    fn loader_requires_exact_logical_generation_encrypted() {
1392        let cipher = AesCipher::new(&[0x42u8; 32]).expect("32-byte key");
1393        assert_exact_generation_identity(Some(&cipher));
1394    }
1395}
1396
1397/// Test-only: read a path under a [`RealPersistentCacheIo`] for assertion
1398/// purposes. The path uses a hex key naming scheme identical to the I/O
1399/// implementation, so the same `load`/`write_atomic` round-trips apply.
1400pub fn real_io_final_path(dir: &Path, key: u64) -> PathBuf {
1401    dir.join(format!("{key:016x}.bin"))
1402}
1403
1404// ============================================================================
1405// Worker
1406// ============================================================================
1407
1408/// Configuration for spawning a [`spawn_persistent_cache_worker`].
1409pub struct WorkerConfig {
1410    /// Writer to drain. Must outlive the worker.
1411    pub writer: Arc<PersistentResultCacheWriter>,
1412    /// I/O backend. The worker uses `write_atomic` / `remove` / `clear` /
1413    /// `load`. Must outlive the worker.
1414    pub io: Arc<dyn PersistentCacheIo>,
1415    /// Optional encryption cipher. When `Some`, the worker encrypts every
1416    /// payload before framing; when `None`, the payload is written in
1417    /// plaintext.
1418    pub cipher: Option<Arc<AesCipher>>,
1419    /// Stale-check guard. The worker re-checks that the queued entry's
1420    /// `(key, key_generation, clear_generation)` is still the latest snapshot
1421    /// before publishing. When the guard reports a stale match, the op is
1422    /// skipped and counted as `StoreStale`.
1423    pub staleness: Arc<dyn StalenessGuard>,
1424    /// Maximum stale-checks to perform per op. Prevents unbounded work on a
1425    /// single contended key.
1426    pub max_staleness_retries: u32,
1427    /// Optional completion signal. When `Some`, the worker sends `()`
1428    /// immediately before exiting. The receiver (held by the engine) uses
1429    /// `recv_timeout(remaining_deadline)` to wait for a bounded shutdown.
1430    /// Send errors (receiver dropped) are ignored.
1431    pub completion: Option<mpsc::Sender<()>>,
1432}
1433
1434/// Hook for the worker to validate that a drained op is still current. The
1435/// production implementation reads the writer's per-key generation under the
1436/// queue lock; tests can use a custom implementation to simulate races.
1437pub trait StalenessGuard: Send + Sync {
1438    /// Return `true` when `(key, key_generation)` is still the latest known
1439    /// generation for `key` AND `clear_generation` matches the current
1440    /// global clear generation.
1441    fn is_current(&self, key: u64, key_generation: u64, clear_generation: u64) -> bool;
1442}
1443
1444/// A [`StalenessGuard`] backed by the writer's own queue. The implementation
1445/// locks the queue, checks the per-key generation and the clear generation,
1446/// and returns whether the op is still the latest. Cheap when uncontended.
1447pub struct WriterStalenessGuard {
1448    writer: Arc<PersistentResultCacheWriter>,
1449}
1450
1451impl WriterStalenessGuard {
1452    pub fn new(writer: Arc<PersistentResultCacheWriter>) -> Self {
1453        Self { writer }
1454    }
1455}
1456
1457impl StalenessGuard for WriterStalenessGuard {
1458    fn is_current(&self, key: u64, key_generation: u64, clear_generation: u64) -> bool {
1459        let guard = self.writer.inner.lock().expect("writer mutex poisoned");
1460        if guard.state.clear_generation != clear_generation {
1461            return false;
1462        }
1463        match guard.state.key_generations.get(&key) {
1464            Some(g) => *g == key_generation,
1465            None => true, // no later op for this key, original is still current
1466        }
1467    }
1468}
1469
1470/// Spawn the persistent-cache publication worker. The worker thread exits
1471/// when the writer is shut down AND the queue is empty.
1472pub fn spawn_persistent_cache_worker(config: WorkerConfig) -> std::thread::JoinHandle<()> {
1473    std::thread::spawn(move || run_persistent_cache_worker(config))
1474}
1475
1476fn run_persistent_cache_worker(config: WorkerConfig) {
1477    let WorkerConfig {
1478        writer,
1479        io,
1480        cipher,
1481        staleness,
1482        max_staleness_retries,
1483        completion,
1484    } = config;
1485    loop {
1486        let drained = match writer.drain_one() {
1487            Some(d) => d,
1488            None => {
1489                // shutdown + queue empty — emit the completion signal so the
1490                // engine's deadline-bounded shutdown can return promptly.
1491                if let Some(tx) = completion {
1492                    let _ = tx.send(());
1493                }
1494                return;
1495            }
1496        };
1497        // Re-validate staleness after the lock is released. We give the
1498        // queue a brief moment to settle, but bounded by `max_staleness_retries`
1499        // to prevent unbounded spinning under heavy contention.
1500        let mut attempts = 0u32;
1501        let is_current = loop {
1502            if staleness.is_current(
1503                drained.key,
1504                drained.key_generation,
1505                drained.clear_generation,
1506            ) {
1507                break true;
1508            }
1509            if attempts >= max_staleness_retries {
1510                break false;
1511            }
1512            attempts += 1;
1513            std::thread::yield_now();
1514        };
1515        if !is_current {
1516            if matches!(drained.op, PendingCacheOp::Store(_)) {
1517                writer.record_outcome(DrainOutcome::StoreStale);
1518            }
1519            // Removes and Clears are non-staleable for the purposes of
1520            // generation mismatch: a newer Remove wins anyway.
1521            continue;
1522        }
1523        // Final staleness check just before the publish point. Closes the
1524        // race where `enqueue_clear` (or a per-key invalidate) advances the
1525        // writer's generation after the top-of-loop check but before the
1526        // I/O. With a locked final check, a generation bump that happens
1527        // between the two checks will either:
1528        //   * happen before the second check — and abort this write, or
1529        //   * happen after the second check — and the write is allowed
1530        //     because the user's intent was captured before the shutdown.
1531        // The I/O itself is uninterruptible; an in-flight `write_atomic`
1532        // either completes or fails.
1533        if !staleness.is_current(
1534            drained.key,
1535            drained.key_generation,
1536            drained.clear_generation,
1537        ) {
1538            if matches!(drained.op, PendingCacheOp::Store(_)) {
1539                writer.record_outcome(DrainOutcome::StoreStale);
1540            }
1541            continue;
1542        }
1543        match drained.op {
1544            PendingCacheOp::Store(entry) => {
1545                writer.writes_in_flight.fetch_add(1, Ordering::Relaxed);
1546                let payload = match (entry.payload_factory)() {
1547                    Some(p) => p,
1548                    None => {
1549                        writer.record_outcome(DrainOutcome::StoreErrored);
1550                        writer.writes_in_flight.fetch_sub(1, Ordering::Relaxed);
1551                        continue;
1552                    }
1553                };
1554                let context = PersistContext {
1555                    identity: PersistentCacheIdentity {
1556                        table_id: entry.table_id,
1557                        schema_id: entry.schema_id,
1558                        logical_generation: entry.run_generation,
1559                    },
1560                    key: entry.key,
1561                    entry_generation: entry.entry_generation,
1562                };
1563                let bytes = match encode_persisted_entry(context, &payload, cipher.as_deref()) {
1564                    Ok(b) => b,
1565                    Err(_) => {
1566                        writer.record_outcome(DrainOutcome::StoreErrored);
1567                        writer.writes_in_flight.fetch_sub(1, Ordering::Relaxed);
1568                        continue;
1569                    }
1570                };
1571                let outcome = match io.write_atomic(entry.key, &bytes) {
1572                    Ok(()) => DrainOutcome::StorePublished,
1573                    Err(_) => DrainOutcome::StoreErrored,
1574                };
1575                writer.record_outcome(outcome);
1576                writer.writes_in_flight.fetch_sub(1, Ordering::Relaxed);
1577            }
1578            PendingCacheOp::Remove => {
1579                writer.writes_in_flight.fetch_add(1, Ordering::Relaxed);
1580                let outcome = match io.remove(drained.key) {
1581                    Ok(()) => DrainOutcome::RemoveApplied,
1582                    Err(_) => DrainOutcome::RemoveErrored,
1583                };
1584                writer.record_outcome(outcome);
1585                writer.writes_in_flight.fetch_sub(1, Ordering::Relaxed);
1586            }
1587            PendingCacheOp::Clear => {
1588                writer.writes_in_flight.fetch_add(1, Ordering::Relaxed);
1589                let outcome = match io.clear() {
1590                    Ok(()) => DrainOutcome::ClearApplied,
1591                    Err(_) => DrainOutcome::ClearErrored,
1592                };
1593                writer.record_outcome(outcome);
1594                writer.writes_in_flight.fetch_sub(1, Ordering::Relaxed);
1595            }
1596        }
1597    }
1598}
1599
1600/// Try to receive a worker completion signal with a bounded timeout. Used by
1601/// `shutdown_persistent_cache(deadline)` so the engine returns even if the
1602/// worker is blocked in filesystem I/O. Returns `true` if the signal was
1603/// received (worker has exited), `false` on timeout.
1604pub fn recv_completion_with_timeout(rx: &mpsc::Receiver<()>, timeout: Duration) -> bool {
1605    matches!(rx.recv_timeout(timeout), Ok(()))
1606}