khive_runtime/audit_batch.rs
1//! ADR-133 Slice 1: the audit-batch seam.
2//!
3//! Incidental audit writes (gate denials, dispatch outcomes, config-lock
4//! rows, git.digest receipts, and pure-observability rows like
5//! `RecallExecuted`) no longer take one writer-task acquisition per row on
6//! the request hot path. Concurrent submissions arriving while a generation
7//! is committing share the *next* generation instead of each taking their
8//! own writer acquisition, so N concurrent producers collapse to one
9//! [`khive_storage::EventStore::append_events_idempotent`] call per
10//! generation rather than N.
11//!
12//! [`AuditBatch`] owns this seam. A lazily-spawned supervisor task drains
13//! pending rows into generations and drives each through the store; the
14//! supervisor's own `JoinHandle` is retained (never discarded) so an
15//! abnormal exit — panic, cancellation, a lost child join, or a driver that
16//! returns `Ok` while state is not terminally consistent — is observed and
17//! converted into a `Failed` transition with all accepted waiters resolved,
18//! per owner ruling R1/R4 (`.khive/OWNER_RULING_adr133_gate.md`).
19
20#[cfg(any(test, feature = "fault-injection"))]
21use std::sync::atomic::Ordering;
22use std::sync::Arc;
23use std::time::Duration;
24
25use parking_lot::Mutex;
26use tokio::sync::oneshot;
27use tokio::task::JoinHandle;
28
29use khive_storage::event::EventAppendDisposition;
30use khive_storage::{Event, EventStore, StorageError, WriterTaskRequestState};
31
32/// Coarse durability classification for an [`AuditProducer`]. See
33/// [`classify`] for the exhaustive mapping.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub(crate) enum AuditProductionClass {
36 /// The dispatch this row audits carries an obligation: the caller-visible
37 /// outcome must not silently diverge from what was durably recorded.
38 DispatchObligation,
39 /// The row is a best-effort observability signal; a non-commit degrades
40 /// gracefully rather than blocking or failing the dispatch it audits.
41 PureObservability,
42}
43
44/// Every call site that can submit a row through [`AuditBatchControl`].
45/// Adding a variant here without extending the crate-private `classify`
46/// function's match is a compile error — there is no wildcard arm.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AuditProducer {
49 /// The gate denied a dispatch; the denial itself is audited.
50 GateDenied,
51 /// The gate backend was unreachable and the dispatch failed closed; the
52 /// refusal is audited best-effort: the typed `GateUnavailable` refusal
53 /// already fails the dispatch, so a lost row degrades diagnostics only.
54 GateUnavailable,
55 /// A pack dispatch returned a successful result.
56 DispatchSucceeded,
57 /// A pack dispatch returned an error result.
58 DispatchFailed,
59 /// A redirected KG read consulted the gate on its effective entity id.
60 EffectiveTargetCheck,
61 /// The gate allowed a verb no pack owns.
62 UnknownVerb,
63 /// The strict `git.digest` success receipt (schema v2).
64 GitDigestReceipt,
65 /// A drained process-lifetime `OnceLock` config-lock row.
66 ConfigLocked,
67 /// A `memory.recall` execution's pure-observability audit row.
68 /// Submitted by the recall handler through its owning registry's batch.
69 RecallExecuted,
70}
71
72/// Classify an [`AuditProducer`] into its [`AuditProductionClass`]. One
73/// exhaustive match, no wildcard arm.
74pub(crate) const fn classify(producer: AuditProducer) -> AuditProductionClass {
75 match producer {
76 AuditProducer::GateDenied
77 | AuditProducer::DispatchSucceeded
78 | AuditProducer::DispatchFailed
79 | AuditProducer::EffectiveTargetCheck
80 | AuditProducer::UnknownVerb
81 | AuditProducer::GitDigestReceipt => AuditProductionClass::DispatchObligation,
82 AuditProducer::ConfigLocked
83 | AuditProducer::RecallExecuted
84 | AuditProducer::GateUnavailable => AuditProductionClass::PureObservability,
85 }
86}
87
88/// Exhaustive terminal reasons an [`AuditBatchControl::submit`],
89/// [`AuditBatchControl::quiesce`], or [`AuditBatchControl::close_and_drain`]
90/// call can resolve to. Never mapped through a wildcard arm anywhere in this
91/// module (R4).
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum AuditTerminalReason {
94 /// `EventStore::preflight_event` rejected the row before it was ever
95 /// enqueued. The row was never counted as submitted.
96 PreflightRejected,
97 /// The batch is `Closing` or `Closed`; new admission is refused.
98 AdmissionClosed,
99 /// `AuditBatchConfig::max_pending_rows` was reached before this row was
100 /// enqueued. The row was never counted as submitted and never shared a
101 /// generation with anyone — safe to retry, and doing so applies the
102 /// obligation at most once.
103 QueueAdmissionExhausted,
104 /// The row was already enqueued (counted in `submitted_rows`) when this
105 /// caller's `AuditBatchConfig::admission_deadline` elapsed waiting for
106 /// its generation's outcome. Unlike [`Self::QueueAdmissionExhausted`],
107 /// the row was not refused: by the moment the deadline fires it may
108 /// still be sitting in `state.pending`, or the driver may have already
109 /// drained it into an in-flight generation — either way it remains
110 /// enqueued and unresolved, and is committed (or terminally failed) by
111 /// the generation driver independently of this caller's timeout, so the
112 /// caller cannot tell from this reason alone whether the row eventually
113 /// committed, or even which of those two states it was in when the
114 /// deadline elapsed. Retrying is only safe for an idempotent caller —
115 /// the prior submission may still land. When this reason degrades an
116 /// admission-degrade-safe read's own audit obligation, it is counted
117 /// separately from [`Self::QueueAdmissionExhausted`] — see
118 /// `pack::audit_admission_unresolved_obligation_count` — precisely
119 /// because a row counted here may still commit, unlike one refused
120 /// before enqueue.
121 AdmissionDeadlineExpired,
122 /// Reached only through `AuditBatch::submit_until_resolved`: the row's
123 /// [`Self::AdmissionDeadlineExpired`] wait had already elapsed, and this
124 /// caller's own `AuditBatchConfig::resolution_deadline` then also
125 /// elapsed still waiting for the row's real generation outcome. The
126 /// caller reaches this only after its domain effect has already
127 /// committed, so the effect is never retried and the row is never
128 /// re-enqueued — but unlike an ordinary commit, the caller now learns
129 /// the effect committed while the audit outcome itself is unresolved.
130 /// Same as [`Self::AdmissionDeadlineExpired`], the row is left exactly
131 /// where the driver holds it (`state.pending`, or already mid-generation)
132 /// for the driver to resolve independently — this reason performs no
133 /// removal. Kept distinct from [`Self::AdmissionDeadlineExpired`] so a
134 /// caller, and diagnostics reading this reason, can tell a merely-slow
135 /// admission wait apart from a resolution wait that gave up entirely.
136 ResolutionDeadlineExpired,
137 /// A row shared this generation's id with a previously stored row whose
138 /// columns or observation projection did not match exactly.
139 IdentityConflict,
140 /// `classify_store_error` judged the store's error non-retryable, and the
141 /// generation stopped on that attempt. Usually that is the first attempt,
142 /// but not necessarily: a generation whose earlier attempts failed
143 /// retryably and whose next one returns a non-retryable error reports
144 /// this reason too, because the attempt that decided the outcome is the
145 /// non-retryable one. Distinct from [`Self::RetryExhausted`], which the
146 /// classifier judged safe to retry on every attempt and which failed
147 /// anyway once they ran out — this reason carries no such hope. Whatever
148 /// the store returned on the deciding attempt, it is not the kind of
149 /// failure `AuditBatchConfig::max_commit_attempts` exists to ride out,
150 /// so a caller or an automated retry policy reading this reason should
151 /// not schedule a bare retry of the same call and should instead treat
152 /// it as a storage fault needing attention.
153 StoreFailure,
154 /// The store returned a `classify_store_error`-retryable error on every
155 /// one of `AuditBatchConfig::max_commit_attempts` attempts for this
156 /// generation, and the last attempt still failed retryable. Kept
157 /// distinct from [`Self::StoreFailure`] — the same way
158 /// [`Self::ResolutionDeadlineExpired`] is kept distinct from
159 /// [`Self::AdmissionDeadlineExpired`] — so a caller, and diagnostics
160 /// reading this reason, can tell a store call the classifier judged
161 /// hopeless apart from one that kept failing a condition (write-queue or
162 /// writer-task pressure, pool or timeout) the classifier judged
163 /// transient. The underlying condition may still be
164 /// transient at the moment attempts run out (a daemon restart, pool
165 /// pressure outlasting the configured backoff), so an operator or an
166 /// automated retry policy sitting above this batch can choose to wait
167 /// longer and try again rather than treating it identically to
168 /// [`Self::StoreFailure`]. This reason changes no tolerance, deadline,
169 /// retry count, or backoff on its own — it only names which of the two
170 /// causes produced the generation's failure.
171 RetryExhausted,
172 /// The configured `EventStore` backend does not implement
173 /// `append_events_idempotent`.
174 IdempotencyUnsupported,
175 /// The generation driver task panicked, or the supervisor awaiting it
176 /// unwound while armed.
177 DriverPanicked,
178 /// The generation driver task was cancelled/aborted, or the supervisor
179 /// awaiting it was dropped mid-await (shutdown abort) while armed.
180 DriverCancelled,
181 /// The child driver's `JoinHandle` was lost — dropped without ever being
182 /// inspected — so its outcome could not be classified.
183 DriverJoinLost,
184 /// The driver returned `Ok` but locked batch state was not proved
185 /// terminally consistent afterward (`in_flight` still set, or the
186 /// generation's rows were never resolved).
187 DriverExitedInconsistent,
188 /// The driver's own bound on a single generation's
189 /// `EventStore::append_events_idempotent()` call
190 /// (`supervisor_loop`'s `driver_append_deadline`) elapsed while that
191 /// call was still in flight. Unlike [`Self::AdmissionDeadlineExpired`]
192 /// and [`Self::ResolutionDeadlineExpired`], which bound only how long a
193 /// *caller* keeps waiting while the row stays wherever the driver holds
194 /// it, this bounds the driver's own hold: every waiter on the abandoned
195 /// generation is resolved with this reason and the generation is
196 /// removed from the driver's in-flight state, so a stalled store call
197 /// cannot pin `pending` at `max_pending_rows` and starve all later
198 /// admission (khive#2331). The underlying append is not cancelled — it
199 /// may not be safely cancellable mid-flight — so it is left to run to
200 /// completion in the background and its eventual result, whatever it
201 /// is, is discarded; no waiter is still listening for it. The caller's
202 /// domain effect (if any already committed before this row was
203 /// enqueued) is never retried, and the row is never re-enqueued.
204 DriverAppendAbandoned,
205 /// Before spawning a generation's child, the driver found
206 /// `AuditBatchConfig::max_abandoned_appends` detached appends already
207 /// outstanding from prior [`Self::DriverAppendAbandoned`] generations
208 /// (khive#2331). The store is treated as wedged: this generation's rows
209 /// are shed without ever attempting an append — no child task is
210 /// spawned, no store call is made, and every waiter resolves with this
211 /// reason immediately, well inside `driver_append_deadline`. Same
212 /// non-retry contract as [`Self::DriverAppendAbandoned`]: any
213 /// already-committed domain effect is not retried and the row is never
214 /// re-enqueued, and pure-observability producers record degradation.
215 /// The driver reattempts an append on the next generation as soon as an
216 /// outstanding append returns (commit or failure) and the count drops
217 /// back below the cap — recovery needs no timer of its own.
218 StoreWedged,
219}
220
221/// One row accepted for batching: the immutable event identity plus the
222/// producer that minted it, used for classification and, on failure,
223/// degradation accounting.
224pub struct PreparedAuditRow {
225 pub event: Event,
226 pub producer: AuditProducer,
227}
228
229/// What [`AuditBatchControl::submit`] resolves to on a non-error outcome.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum AuditCommitOutcome {
232 /// The row was freshly inserted.
233 Committed,
234 /// A prior row with the same identity already matched exactly (internal
235 /// retry replaying the same producer-minted identity).
236 AlreadyPresentIdentical,
237}
238
239/// Production-visible snapshot of [`AuditBatch::health_metrics`]. See there
240/// for field semantics. `khive_db::diagnostics::RuntimeAuditBatchMetrics` carries
241/// these three fields plus `admission_refused_obligations`,
242/// `retry_exhausted_obligations`, and `admission_unresolved_obligations`,
243/// which are sourced from process-wide
244/// counters outside `AuditBatch` rather than from this struct — see
245/// `VerbRegistry::audit_batch_metrics`.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub struct AuditBatchHealthMetrics {
248 pub flush_failures: u64,
249 pub degraded_rows: u64,
250 pub degraded: bool,
251 /// Detached [`AuditTerminalReason::DriverAppendAbandoned`] appends that
252 /// eventually returned a commit, after their generation had already
253 /// resolved every waiter with `DriverAppendAbandoned`. Lets an operator
254 /// see that a store recorded as wedged later drained.
255 pub late_append_commits: u64,
256 /// Detached [`AuditTerminalReason::DriverAppendAbandoned`] appends that
257 /// eventually returned a failure (store error, panic, or cancellation),
258 /// after their generation had already resolved every waiter with
259 /// `DriverAppendAbandoned`.
260 pub late_append_failures: u64,
261}
262
263/// Tunables for the batch seam. Defaults are conservative; every field is
264/// exercised by at least one mechanism test.
265#[derive(Debug, Clone)]
266pub struct AuditBatchConfig {
267 pub max_pending_rows: std::num::NonZeroUsize,
268 pub max_rows_per_generation: std::num::NonZeroUsize,
269 pub max_commit_attempts: std::num::NonZeroU8,
270 pub retry_backoff: Duration,
271 pub admission_deadline: Duration,
272 /// Caps how much longer `AuditBatch::submit_until_resolved` keeps
273 /// waiting on a row's real generation outcome once `admission_deadline`
274 /// has already elapsed on it. Without this bound a generation stuck on a
275 /// stalled `EventStore::append_events_idempotent` call retains the
276 /// completed write's caller, its request slot, and its audit-lane waiter
277 /// forever, exhausting both request and audit capacity (khive#2331).
278 /// Must be at least `admission_deadline` — validated (debug-only) in
279 /// [`AuditBatch::new`]. Defaults to 6x `admission_deadline`.
280 ///
281 /// Also the single source value the driver's own per-generation append
282 /// bound (`supervisor_loop`'s `driver_append_deadline`) is derived from
283 /// — that bound exists so a stalled append cannot keep the driver from
284 /// ever draining `pending` again, which is what actually exhausts
285 /// admission for every later caller, not just the one already waiting
286 /// on the stuck row. See [`AuditTerminalReason::DriverAppendAbandoned`].
287 pub resolution_deadline: Duration,
288 /// Caps how many [`AuditTerminalReason::DriverAppendAbandoned`] appends
289 /// may be outstanding (handed to a detached task, still running) at
290 /// once. Each outstanding append retains up to
291 /// `max_rows_per_generation` events until it finally returns, so the
292 /// retained-buffer bound this places on a wedged store is
293 /// `max_abandoned_appends * max_rows_per_generation` rows — without it,
294 /// a store whose append never returns mints one such task per
295 /// `driver_append_deadline` with no cap, and both the live task count
296 /// and the retained event batches inside them grow until the process
297 /// exits (khive#2331). Once the cap is reached, `supervisor_loop` sheds
298 /// further generations with [`AuditTerminalReason::StoreWedged`] instead
299 /// of attempting another append; it resumes attempting appends as soon
300 /// as an outstanding one returns and the count drops back below the
301 /// cap. Defaults to 4.
302 pub max_abandoned_appends: std::num::NonZeroUsize,
303}
304
305impl Default for AuditBatchConfig {
306 fn default() -> Self {
307 let admission_deadline = Duration::from_secs(5);
308 Self {
309 max_pending_rows: std::num::NonZeroUsize::new(4096).unwrap(),
310 max_rows_per_generation: std::num::NonZeroUsize::new(256).unwrap(),
311 max_commit_attempts: std::num::NonZeroU8::new(3).unwrap(),
312 retry_backoff: Duration::from_millis(20),
313 admission_deadline,
314 resolution_deadline: admission_deadline * 6,
315 max_abandoned_appends: std::num::NonZeroUsize::new(4).unwrap(),
316 }
317 }
318}
319
320#[async_trait::async_trait]
321pub trait AuditBatchControl: Send + Sync {
322 async fn submit(
323 &self,
324 row: PreparedAuditRow,
325 ) -> Result<AuditCommitOutcome, AuditTerminalReason>;
326 async fn quiesce(&self) -> Result<(), AuditTerminalReason>;
327 async fn close_and_drain(&self) -> Result<(), AuditTerminalReason>;
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
331enum Lifecycle {
332 Open,
333 Closing,
334 Closed,
335 Failed(AuditTerminalReason),
336}
337
338struct Waiting {
339 event: Event,
340 producer: AuditProducer,
341 responder: oneshot::Sender<Result<AuditCommitOutcome, AuditTerminalReason>>,
342}
343
344/// A committed/failed generation's accounting, retained for the lifetime of
345/// the process (bounded in practice by process lifetime and generation
346/// volume; this slice makes no attempt to prune history).
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct AuditGenerationSnapshot {
349 pub generation_id: u64,
350 pub submitted_rows: u64,
351 pub committed_rows: u64,
352 pub store_batch_calls: u64,
353 pub terminal_reason: Option<AuditTerminalReason>,
354}
355
356struct State {
357 lifecycle: Lifecycle,
358 pending: Vec<Waiting>,
359 in_flight_generation: Option<u64>,
360 driver_active: bool,
361 next_generation_id: u64,
362 submitted_rows: u64,
363 committed_rows: u64,
364 store_batch_calls: u64,
365 generations: Vec<AuditGenerationSnapshot>,
366 flush_failures: u64,
367 degraded_rows: u64,
368 degraded: bool,
369 /// Detached `DriverAppendAbandoned` appends currently still running,
370 /// bounded by `AuditBatchConfig::max_abandoned_appends`. Incremented
371 /// when `supervisor_loop` abandons a generation and hands its child to a
372 /// detached task; decremented by that task once the child finally
373 /// returns.
374 outstanding_abandoned_appends: usize,
375 late_append_commits: u64,
376 late_append_failures: u64,
377}
378
379impl State {
380 fn new() -> Self {
381 Self {
382 lifecycle: Lifecycle::Open,
383 pending: Vec::new(),
384 in_flight_generation: None,
385 driver_active: false,
386 next_generation_id: 0,
387 submitted_rows: 0,
388 committed_rows: 0,
389 store_batch_calls: 0,
390 generations: Vec::new(),
391 flush_failures: 0,
392 degraded_rows: 0,
393 degraded: false,
394 outstanding_abandoned_appends: 0,
395 late_append_commits: 0,
396 late_append_failures: 0,
397 }
398 }
399
400 fn is_idle(&self) -> bool {
401 self.pending.is_empty() && self.in_flight_generation.is_none() && !self.driver_active
402 }
403}
404
405struct Inner {
406 state: Mutex<State>,
407}
408
409impl Inner {
410 /// Wins once: the first abnormal driver/supervisor exit sets `Failed`
411 /// and drains every accepted waiter (this generation's, plus anything
412 /// still queued for a future one) with the same typed reason. A later
413 /// abnormal path observes the existing terminal state and does not
414 /// double-count (R1).
415 fn fail_driver(&self, reason: AuditTerminalReason, in_flight_waiters: Vec<Waiting>) {
416 let mut state = self.state.lock();
417 let already_failed = matches!(state.lifecycle, Lifecycle::Failed(_));
418 if !already_failed {
419 state.lifecycle = Lifecycle::Failed(reason);
420 }
421 state.driver_active = false;
422 state.in_flight_generation = None;
423 let drained: Vec<Waiting> = std::mem::take(&mut state.pending);
424 if !already_failed {
425 state.flush_failures += 1;
426 state.degraded = true;
427 let generation_id = state.next_generation_id;
428 state.generations.push(AuditGenerationSnapshot {
429 generation_id,
430 submitted_rows: (in_flight_waiters.len() + drained.len()) as u64,
431 committed_rows: 0,
432 store_batch_calls: 0,
433 terminal_reason: Some(reason),
434 });
435 state.next_generation_id += 1;
436 }
437 drop(state);
438 for waiting in in_flight_waiters.into_iter().chain(drained) {
439 record_degradation_if_pure(self, waiting.producer);
440 let _ = waiting.responder.send(Err(reason));
441 }
442 }
443
444 fn record_degradation(&self) {
445 let mut state = self.state.lock();
446 state.degraded_rows += 1;
447 state.degraded = true;
448 }
449}
450
451fn record_degradation_if_pure(inner: &Inner, producer: AuditProducer) {
452 if classify(producer) == AuditProductionClass::PureObservability {
453 inner.record_degradation();
454 }
455}
456
457/// Armed for the lifetime of one generation's supervision. If dropped while
458/// still armed — the supervisor's own frame unwinding (panic) or being
459/// dropped mid-await (cancellation/shutdown abort) — the guard fails the
460/// generation before returning control to whatever tore it down, so the
461/// caller never observes a background-task-count restoration that raced
462/// ahead of the failure broadcast (R1).
463struct SupervisorGuard<'a> {
464 inner: &'a Arc<Inner>,
465 waiting: Option<Vec<Waiting>>,
466}
467
468impl<'a> SupervisorGuard<'a> {
469 fn armed(inner: &'a Arc<Inner>, waiting: Vec<Waiting>) -> Self {
470 Self {
471 inner,
472 waiting: Some(waiting),
473 }
474 }
475
476 /// Clean disarm: the caller has fully classified the outcome and taken
477 /// ownership of the waiters to resolve them itself.
478 fn disarm(mut self) -> Vec<Waiting> {
479 self.waiting.take().unwrap_or_default()
480 }
481
482 /// Explicit failure classification reached without unwinding/cancelling
483 /// this frame (e.g. a `JoinError` was returned normally). Equivalent to
484 /// what `Drop` does when armed, but callable inline.
485 fn fail(mut self, reason: AuditTerminalReason) {
486 if let Some(waiting) = self.waiting.take() {
487 self.inner.fail_driver(reason, waiting);
488 }
489 }
490}
491
492impl Drop for SupervisorGuard<'_> {
493 fn drop(&mut self) {
494 let Some(waiting) = self.waiting.take() else {
495 return;
496 };
497 let reason = if std::thread::panicking() {
498 AuditTerminalReason::DriverPanicked
499 } else {
500 AuditTerminalReason::DriverCancelled
501 };
502 self.inner.fail_driver(reason, waiting);
503 }
504}
505
506enum RetryDecision {
507 Retry,
508 Terminal(AuditTerminalReason),
509}
510
511fn classify_store_error(err: &StorageError) -> RetryDecision {
512 match err.without_sqlite_write_stage() {
513 StorageError::WriteQueueFull { .. } | StorageError::WriterTaskBusy { .. } => {
514 RetryDecision::Retry
515 }
516 StorageError::CapacityUnavailable {
517 phase: khive_storage::CapacityUnavailablePhase::LockContended,
518 ..
519 } => RetryDecision::Retry,
520 // Transient availability conditions, not judgments on the batch. The
521 // events-daemon forwarding lane (ADR-170) reports an unreachable or
522 // stalled daemon as `Pool`/`Timeout`; the direct SQL path reports
523 // acquisition pressure the same way. Both are exactly what the
524 // configured bounded retries exist for — treating them as terminal
525 // would abandon a generation on the first blip of a daemon restart.
526 StorageError::Pool { .. } | StorageError::Timeout { .. } => RetryDecision::Retry,
527 // The writer task wraps any request operation that failed and was rolled
528 // back, whatever the cause, so the wrapper alone does not say whether a
529 // retry can succeed: a missing column fails the same way on every
530 // attempt. Judge the cause it carries. A request left in an unknown
531 // state is replayed regardless, because the append is idempotent.
532 StorageError::WriterTaskRequestFailed {
533 request_state,
534 source,
535 } => match request_state {
536 WriterTaskRequestState::NotStarted | WriterTaskRequestState::TransactionRolledBack => {
537 if is_sqlite_busy_or_locked(source) {
538 RetryDecision::Retry
539 } else {
540 classify_store_error(source)
541 }
542 }
543 WriterTaskRequestState::SideEffectsUnknown => RetryDecision::Retry,
544 },
545 StorageError::WriterTaskTerminated { request_state, .. } => match request_state {
546 WriterTaskRequestState::NotStarted | WriterTaskRequestState::TransactionRolledBack => {
547 RetryDecision::Retry
548 }
549 WriterTaskRequestState::SideEffectsUnknown => RetryDecision::Retry,
550 },
551 StorageError::Unsupported { operation, .. }
552 if operation.as_ref() == "append_events_idempotent" =>
553 {
554 RetryDecision::Terminal(AuditTerminalReason::IdempotencyUnsupported)
555 }
556 _ => RetryDecision::Terminal(AuditTerminalReason::StoreFailure),
557 }
558}
559
560/// Whether a failed writer request's preserved cause is SQLite contention. The
561/// writer keeps the request body's driver error as the cause, and a driver
562/// error is not one of the typed transient variants `classify_store_error`
563/// retries on its own.
564fn is_sqlite_busy_or_locked(err: &StorageError) -> bool {
565 let StorageError::Driver { source, .. } = err.without_sqlite_write_stage() else {
566 return false;
567 };
568 matches!(
569 source
570 .downcast_ref::<rusqlite::Error>()
571 .and_then(|error| error.sqlite_error_code()),
572 Some(rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked)
573 )
574}
575
576enum GenerationResult {
577 Committed(Vec<EventAppendDisposition>),
578 Failed(AuditTerminalReason),
579 /// Fault-injection only: proves the supervisor's post-`Ok` consistency
580 /// check actually runs.
581 #[cfg_attr(not(any(test, feature = "fault-injection")), allow(dead_code))]
582 FakedInconsistent,
583}
584
585/// The driver's own bound on how long `supervisor_loop` waits for one
586/// generation's `run_generation` child task before abandoning it
587/// (khive#2331; see [`AuditTerminalReason::DriverAppendAbandoned`]).
588///
589/// Derived from `resolution_deadline` rather than a second config field, at
590/// 3x it. That margin is load-bearing, not arbitrary: `AuditBatch::new`
591/// already enforces `resolution_deadline >= admission_deadline`, so the
592/// worst-case *caller*-side wait for a `submit_until_resolved` row —
593/// `admission_deadline` then `resolution_deadline` in sequence — is always
594/// `<= 2 * resolution_deadline`. Bounding the driver at `3 *
595/// resolution_deadline` guarantees it can never resolve a still-waiting
596/// caller's row with `DriverAppendAbandoned` ahead of that caller's own,
597/// more specific `ResolutionDeadlineExpired` reason.
598///
599/// This bound alone only stops the driver from holding one stalled
600/// generation forever — a store whose append never returns still mints one
601/// detached append per `driver_append_deadline` with nothing capping how
602/// many run at once. `AuditBatchConfig::max_abandoned_appends` closes that:
603/// once that many detached appends are outstanding, further generations are
604/// shed with [`AuditTerminalReason::StoreWedged`] instead of attempting
605/// another append. Combined, the two bounds guarantee at most
606/// `max_abandoned_appends` live detached appends at any time, at most that
607/// many retained event batches, and a shed generation costs no store work
608/// at all — it resolves inside this function's caller without ever calling
609/// `run_generation`.
610fn driver_append_deadline(config: &AuditBatchConfig) -> Duration {
611 config.resolution_deadline.saturating_mul(3)
612}
613
614async fn run_generation(
615 store: Arc<dyn EventStore>,
616 events: Vec<Event>,
617 config: Arc<AuditBatchConfig>,
618) -> GenerationResult {
619 #[cfg(any(test, feature = "fault-injection"))]
620 if fault::CHILD_PANIC.swap(false, Ordering::SeqCst) {
621 panic!("adr133 fault injection: audit_batch child_panic");
622 }
623 #[cfg(any(test, feature = "fault-injection"))]
624 if fault::INCONSISTENT_EXIT.swap(false, Ordering::SeqCst) {
625 return GenerationResult::FakedInconsistent;
626 }
627
628 let mut attempt: u8 = 0;
629 loop {
630 attempt += 1;
631 match store.append_events_idempotent(events.clone()).await {
632 Ok(result) => return GenerationResult::Committed(result.rows),
633 Err(err) => {
634 let reason = match classify_store_error(&err) {
635 RetryDecision::Retry if attempt < config.max_commit_attempts.get() => {
636 tokio::time::sleep(config.retry_backoff).await;
637 continue;
638 }
639 RetryDecision::Retry => AuditTerminalReason::RetryExhausted,
640 RetryDecision::Terminal(reason) => reason,
641 };
642 tracing::warn!(
643 error = %err,
644 attempts = attempt,
645 ?reason,
646 "audit generation failed; its rows were not committed"
647 );
648 return GenerationResult::Failed(reason);
649 }
650 }
651 }
652}
653
654/// The batch owner. Constructed once per configured `EventStore`; every
655/// dispatch-audit call site routes its row through [`AuditBatch::submit`]
656/// instead of taking its own writer-task acquisition.
657pub struct AuditBatch {
658 inner: Arc<Inner>,
659 store: Arc<dyn EventStore>,
660 config: Arc<AuditBatchConfig>,
661 supervisor: Mutex<Option<JoinHandle<()>>>,
662}
663
664impl AuditBatch {
665 pub fn new(store: Arc<dyn EventStore>, config: AuditBatchConfig) -> Arc<Self> {
666 debug_assert!(
667 config.resolution_deadline >= config.admission_deadline,
668 "resolution_deadline ({:?}) must be at least admission_deadline ({:?})",
669 config.resolution_deadline,
670 config.admission_deadline
671 );
672 Arc::new(Self {
673 inner: Arc::new(Inner {
674 state: Mutex::new(State::new()),
675 }),
676 store,
677 config: Arc::new(config),
678 supervisor: Mutex::new(None),
679 })
680 }
681
682 /// Process-lifetime audit-batch health counters, for the registry/
683 /// runtime owner to feed into `db_diagnostics` (D8's operator surface).
684 /// Unlike `test_internals::AuditBatchSnapshot::metrics_snapshot`,
685 /// which is test-only (the module is cfg-gated and invisible to the
686 /// default doc build, so an intra-doc link cannot resolve), this is
687 /// always available.
688 pub fn health_metrics(&self) -> AuditBatchHealthMetrics {
689 let state = self.inner.state.lock();
690 AuditBatchHealthMetrics {
691 flush_failures: state.flush_failures,
692 degraded_rows: state.degraded_rows,
693 degraded: state.degraded,
694 late_append_commits: state.late_append_commits,
695 late_append_failures: state.late_append_failures,
696 }
697 }
698
699 fn spawn_supervisor_if_idle(&self) {
700 let inner = self.inner.clone();
701 let store = self.store.clone();
702 let config = self.config.clone();
703 let handle = tokio::spawn(async move {
704 supervisor_loop(inner, store, config).await;
705 });
706 *self.supervisor.lock() = Some(handle);
707 }
708
709 /// Enqueue one row and keep waiting for its real generation outcome past
710 /// the ordinary admission wait deadline, up to `resolution_deadline`.
711 ///
712 /// This is the narrow khive#2256 seam for a successful operation whose
713 /// domain effect has already committed. Returning
714 /// [`AuditTerminalReason::AdmissionDeadlineExpired`] there would report a
715 /// false operation failure and invite an unsafe retry while the same
716 /// audit row remains enqueued. Pre-enqueue refusal and genuine terminal
717 /// generation failures still return normally. The post-admission wait is
718 /// itself bounded by `resolution_deadline`
719 /// ([`AuditTerminalReason::ResolutionDeadlineExpired`]) so a stalled
720 /// store cannot retain this caller, its request slot, and its audit-lane
721 /// waiter forever (khive#2331).
722 ///
723 /// The two deadlines therefore mean different things to the caller, and
724 /// callers key on the difference. Admission expiry means the row is
725 /// enqueued and this seam keeps waiting; the generation commits it
726 /// independently, so a dispatch that reaches it reports its committed
727 /// result and counts the row as unresolved. Resolution expiry means the
728 /// caller waited for the real outcome and never received one: the commit
729 /// is unconfirmed, not proven absent. That is returned as a terminal
730 /// reason and the dispatch propagates it as a structured
731 /// committed-outcome error carrying the domain result, with no retryable
732 /// context, for every verb rather than only for the receipt verb. Never
733 /// replay the handler or re-enqueue the row on it; the original driver
734 /// work continues on its own.
735 pub(crate) async fn submit_until_resolved(
736 &self,
737 row: PreparedAuditRow,
738 ) -> Result<AuditCommitOutcome, AuditTerminalReason> {
739 self.submit_with_wait_policy(row, true).await
740 }
741
742 async fn submit_with_wait_policy(
743 &self,
744 row: PreparedAuditRow,
745 wait_until_resolved: bool,
746 ) -> Result<AuditCommitOutcome, AuditTerminalReason> {
747 // Pre-enqueue validation (invariant 3): a malformed row is rejected
748 // before it can share a generation with anyone else's.
749 if self.store.preflight_event(&row.event).is_err() {
750 return Err(AuditTerminalReason::PreflightRejected);
751 }
752
753 let producer = row.producer;
754 let (tx, mut rx) = oneshot::channel();
755 let need_spawn = {
756 let mut state = self.inner.state.lock();
757 match state.lifecycle {
758 Lifecycle::Closed | Lifecycle::Closing => {
759 return Err(AuditTerminalReason::AdmissionClosed)
760 }
761 Lifecycle::Failed(reason) => return Err(reason),
762 Lifecycle::Open => {}
763 }
764 if state.pending.len() >= self.config.max_pending_rows.get() {
765 return Err(AuditTerminalReason::QueueAdmissionExhausted);
766 }
767 state.pending.push(Waiting {
768 event: row.event,
769 producer,
770 responder: tx,
771 });
772 state.submitted_rows += 1;
773 let need_spawn = !state.driver_active;
774 if need_spawn {
775 state.driver_active = true;
776 }
777 need_spawn
778 };
779 if need_spawn {
780 self.spawn_supervisor_if_idle();
781 }
782
783 if wait_until_resolved {
784 match tokio::time::timeout(self.config.admission_deadline, &mut rx).await {
785 Ok(Ok(result)) => return result,
786 Ok(Err(_recv_error)) => return Err(AuditTerminalReason::DriverJoinLost),
787 Err(_elapsed) => tracing::warn!(
788 ?producer,
789 "strict audit obligation remains enqueued after the admission wait deadline; \
790 waiting up to the resolution deadline for its real terminal outcome"
791 ),
792 }
793 return match tokio::time::timeout(self.config.resolution_deadline, rx).await {
794 Ok(Ok(result)) => result,
795 Ok(Err(_recv_error)) => Err(AuditTerminalReason::DriverJoinLost),
796 // Same non-removal contract as the `AdmissionDeadlineExpired`
797 // arm below: the row is left exactly where the driver holds
798 // it. The caller's domain effect already committed by the
799 // time it reached this wait, so it is never retried here;
800 // the audit outcome itself is what remains unresolved.
801 Err(_elapsed) => {
802 tracing::warn!(
803 ?producer,
804 "strict audit obligation remains unresolved after the resolution \
805 deadline; giving up on this caller's wait — the committed effect is \
806 not retried and the row is not re-enqueued"
807 );
808 Err(AuditTerminalReason::ResolutionDeadlineExpired)
809 }
810 };
811 }
812
813 match tokio::time::timeout(self.config.admission_deadline, rx).await {
814 Ok(Ok(result)) => result,
815 Ok(Err(_recv_error)) => Err(AuditTerminalReason::DriverJoinLost),
816 // The row was already pushed onto `state.pending` above (and
817 // `submitted_rows` incremented) before this wait began — this is
818 // a deadline elapsing on an enqueued row, not a queue-full
819 // refusal, so it gets its own terminal reason (khive#2117,
820 // khive#2208). The row is left in place for the driver to drain;
821 // this arm performs no removal.
822 Err(_elapsed) => Err(AuditTerminalReason::AdmissionDeadlineExpired),
823 }
824 }
825}
826
827async fn supervisor_loop(
828 inner: Arc<Inner>,
829 store: Arc<dyn EventStore>,
830 config: Arc<AuditBatchConfig>,
831) {
832 loop {
833 let (waiting, wedged_generation_id) = {
834 let mut state = inner.state.lock();
835 if matches!(state.lifecycle, Lifecycle::Failed(_)) {
836 state.driver_active = false;
837 break;
838 }
839 if state.pending.is_empty() {
840 state.driver_active = false;
841 break;
842 }
843 let take_n = state
844 .pending
845 .len()
846 .min(config.max_rows_per_generation.get());
847 let waiting: Vec<Waiting> = state.pending.drain(..take_n).collect();
848 let generation_id = state.next_generation_id;
849 state.next_generation_id += 1;
850 // khive#2331: a store whose append never returns must not mint
851 // an unbounded number of detached appends. Before committing to
852 // spawning this generation's child, check whether the cap is
853 // already saturated by prior abandoned generations still
854 // running in the background — if so, this generation is shed
855 // below instead of ever calling the store.
856 if state.outstanding_abandoned_appends >= config.max_abandoned_appends.get() {
857 (waiting, Some(generation_id))
858 } else {
859 state.in_flight_generation = Some(generation_id);
860 (waiting, None)
861 }
862 };
863
864 if let Some(generation_id) = wedged_generation_id {
865 tracing::warn!(
866 generation_id,
867 max_abandoned_appends = config.max_abandoned_appends.get(),
868 "audit store treated as wedged: max_abandoned_appends detached appends are \
869 already outstanding; shedding this generation without attempting an append"
870 );
871 let submitted = waiting.len() as u64;
872 {
873 let mut state = inner.state.lock();
874 state.flush_failures += 1;
875 state.generations.push(AuditGenerationSnapshot {
876 generation_id,
877 submitted_rows: submitted,
878 committed_rows: 0,
879 store_batch_calls: 0,
880 terminal_reason: Some(AuditTerminalReason::StoreWedged),
881 });
882 }
883 for w in waiting {
884 record_degradation_if_pure(&inner, w.producer);
885 let _ = w.responder.send(Err(AuditTerminalReason::StoreWedged));
886 }
887 continue;
888 }
889
890 #[cfg(any(test, feature = "fault-injection"))]
891 if fault::SUPERVISOR_PANIC.swap(false, Ordering::SeqCst) {
892 let _guard = SupervisorGuard::armed(&inner, waiting);
893 panic!("adr133 fault injection: audit_batch supervisor_panic");
894 }
895 #[cfg(any(test, feature = "fault-injection"))]
896 if fault::SUPERVISOR_SLEEP_BEFORE_SPAWN.swap(false, Ordering::SeqCst) {
897 let guard = SupervisorGuard::armed(&inner, waiting);
898 tokio::time::sleep(Duration::from_secs(3600)).await;
899 drop(guard);
900 continue;
901 }
902
903 let guard = SupervisorGuard::armed(&inner, waiting);
904 let events: Vec<Event> = guard
905 .waiting
906 .as_ref()
907 .expect("guard freshly armed")
908 .iter()
909 .map(|w| w.event.clone())
910 .collect();
911
912 let mut child: JoinHandle<GenerationResult> =
913 tokio::spawn(run_generation(store.clone(), events, config.clone()));
914
915 #[cfg(any(test, feature = "fault-injection"))]
916 if fault::CHILD_CANCEL.swap(false, Ordering::SeqCst) {
917 child.abort();
918 }
919 #[cfg(any(test, feature = "fault-injection"))]
920 if fault::JOIN_LOST.swap(false, Ordering::SeqCst) {
921 drop(child);
922 guard.fail(AuditTerminalReason::DriverJoinLost);
923 continue;
924 }
925
926 let append_deadline = driver_append_deadline(&config);
927 let join_result = match tokio::time::timeout(append_deadline, &mut child).await {
928 Ok(join_result) => join_result,
929 Err(_elapsed) => {
930 // The append is still in flight and may not be safely
931 // cancellable mid-write (a blocking storage call cannot be
932 // forced to stop), so `child` is not aborted here — it is
933 // handed to a detached reaper that drives it to completion
934 // and discards whatever it eventually returns. Every waiter
935 // on this generation is resolved now, and the driver loops
936 // back to `pending` immediately instead of staying pinned on
937 // this one stalled call, which is what actually starves
938 // later admission (khive#2331).
939 tracing::warn!(
940 ?append_deadline,
941 "audit generation append exceeded the driver's own bound; abandoning \
942 this generation so admission capacity recovers for later callers"
943 );
944 let waiting = guard.disarm();
945 let submitted = waiting.len() as u64;
946 {
947 let mut state = inner.state.lock();
948 state.in_flight_generation = None;
949 state.flush_failures += 1;
950 state.outstanding_abandoned_appends += 1;
951 let generation_id = state.next_generation_id.saturating_sub(1);
952 state.generations.push(AuditGenerationSnapshot {
953 generation_id,
954 submitted_rows: submitted,
955 committed_rows: 0,
956 store_batch_calls: 0,
957 terminal_reason: Some(AuditTerminalReason::DriverAppendAbandoned),
958 });
959 }
960 for w in waiting {
961 record_degradation_if_pure(&inner, w.producer);
962 let _ = w
963 .responder
964 .send(Err(AuditTerminalReason::DriverAppendAbandoned));
965 }
966 let reaper_inner = inner.clone();
967 tokio::spawn(async move {
968 let result = child.await;
969 let mut state = reaper_inner.state.lock();
970 state.outstanding_abandoned_appends =
971 state.outstanding_abandoned_appends.saturating_sub(1);
972 match result {
973 Ok(GenerationResult::Committed(_)) => state.late_append_commits += 1,
974 Ok(GenerationResult::Failed(_) | GenerationResult::FakedInconsistent)
975 | Err(_) => state.late_append_failures += 1,
976 }
977 });
978 continue;
979 }
980 };
981 match join_result {
982 Err(join_err) => {
983 let reason = if join_err.is_panic() {
984 AuditTerminalReason::DriverPanicked
985 } else {
986 AuditTerminalReason::DriverCancelled
987 };
988 guard.fail(reason);
989 }
990 Ok(GenerationResult::FakedInconsistent) => {
991 guard.fail(AuditTerminalReason::DriverExitedInconsistent);
992 }
993 Ok(GenerationResult::Failed(reason)) => {
994 let waiting = guard.disarm();
995 let submitted = waiting.len() as u64;
996 {
997 let mut state = inner.state.lock();
998 state.in_flight_generation = None;
999 state.store_batch_calls += 1;
1000 state.flush_failures += 1;
1001 state.degraded = true;
1002 let generation_id = state.next_generation_id.saturating_sub(1);
1003 state.generations.push(AuditGenerationSnapshot {
1004 generation_id,
1005 submitted_rows: submitted,
1006 committed_rows: 0,
1007 store_batch_calls: 1,
1008 terminal_reason: Some(reason),
1009 });
1010 }
1011 for w in waiting {
1012 record_degradation_if_pure(&inner, w.producer);
1013 let _ = w.responder.send(Err(reason));
1014 }
1015 }
1016 Ok(GenerationResult::Committed(dispositions)) => {
1017 let waiting = guard.disarm();
1018 if dispositions.len() != waiting.len() {
1019 // Defensive: the store must preserve input order/length.
1020 // Treat a mismatch as the driver having exited in a
1021 // state that cannot be reconciled with the accepted
1022 // waiters.
1023 {
1024 let mut state = inner.state.lock();
1025 state.in_flight_generation = None;
1026 }
1027 inner.fail_driver(AuditTerminalReason::DriverExitedInconsistent, waiting);
1028 continue;
1029 }
1030 let mut committed_n = 0u64;
1031 for d in &dispositions {
1032 if !matches!(d, EventAppendDisposition::IdentityConflict) {
1033 committed_n += 1;
1034 }
1035 }
1036 let submitted = dispositions.len() as u64;
1037 {
1038 let mut state = inner.state.lock();
1039 state.in_flight_generation = None;
1040 state.store_batch_calls += 1;
1041 state.committed_rows += committed_n;
1042 let generation_id = state.next_generation_id.saturating_sub(1);
1043 state.generations.push(AuditGenerationSnapshot {
1044 generation_id,
1045 submitted_rows: submitted,
1046 committed_rows: committed_n,
1047 store_batch_calls: 1,
1048 terminal_reason: None,
1049 });
1050 }
1051 for (w, disposition) in waiting.into_iter().zip(dispositions) {
1052 let result = match disposition {
1053 EventAppendDisposition::Inserted => Ok(AuditCommitOutcome::Committed),
1054 EventAppendDisposition::AlreadyPresentIdentical => {
1055 Ok(AuditCommitOutcome::AlreadyPresentIdentical)
1056 }
1057 EventAppendDisposition::IdentityConflict => {
1058 record_degradation_if_pure(&inner, w.producer);
1059 Err(AuditTerminalReason::IdentityConflict)
1060 }
1061 };
1062 let _ = w.responder.send(result);
1063 }
1064 }
1065 }
1066 }
1067}
1068
1069#[async_trait::async_trait]
1070impl AuditBatchControl for AuditBatch {
1071 async fn submit(
1072 &self,
1073 row: PreparedAuditRow,
1074 ) -> Result<AuditCommitOutcome, AuditTerminalReason> {
1075 self.submit_with_wait_policy(row, false).await
1076 }
1077
1078 async fn quiesce(&self) -> Result<(), AuditTerminalReason> {
1079 loop {
1080 let (lifecycle, idle) = {
1081 let state = self.inner.state.lock();
1082 (state.lifecycle.clone(), state.is_idle())
1083 };
1084 match lifecycle {
1085 Lifecycle::Failed(reason) => return Err(reason),
1086 Lifecycle::Closed => return Ok(()),
1087 _ if idle => return Ok(()),
1088 _ => tokio::time::sleep(Duration::from_millis(2)).await,
1089 }
1090 }
1091 }
1092
1093 async fn close_and_drain(&self) -> Result<(), AuditTerminalReason> {
1094 {
1095 let mut state = self.inner.state.lock();
1096 if matches!(state.lifecycle, Lifecycle::Open) {
1097 state.lifecycle = Lifecycle::Closing;
1098 }
1099 }
1100 let result = AuditBatchControl::quiesce(self).await;
1101 {
1102 let mut state = self.inner.state.lock();
1103 if result.is_ok() && matches!(state.lifecycle, Lifecycle::Closing) {
1104 state.lifecycle = Lifecycle::Closed;
1105 }
1106 }
1107 let handle = self.supervisor.lock().take();
1108 if let Some(handle) = handle {
1109 let _ = handle.await;
1110 }
1111 result
1112 }
1113}
1114
1115#[cfg(any(test, feature = "fault-injection"))]
1116mod fault {
1117 use std::sync::atomic::AtomicBool;
1118
1119 pub(super) static CHILD_PANIC: AtomicBool = AtomicBool::new(false);
1120 pub(super) static CHILD_CANCEL: AtomicBool = AtomicBool::new(false);
1121 pub(super) static SUPERVISOR_PANIC: AtomicBool = AtomicBool::new(false);
1122 pub(super) static SUPERVISOR_SLEEP_BEFORE_SPAWN: AtomicBool = AtomicBool::new(false);
1123 pub(super) static JOIN_LOST: AtomicBool = AtomicBool::new(false);
1124 pub(super) static INCONSISTENT_EXIT: AtomicBool = AtomicBool::new(false);
1125}
1126
1127/// Deterministic fault-injection arms consumed by exactly one subsequent
1128/// generation each. Test-only surface, gated identically to the rest of this
1129/// crate's `fault-injection` fixtures (see `crate::operations`).
1130#[cfg(any(test, feature = "fault-injection"))]
1131pub mod fault_injection {
1132 use std::sync::atomic::Ordering;
1133
1134 pub fn arm_child_panic() {
1135 super::fault::CHILD_PANIC.store(true, Ordering::SeqCst);
1136 }
1137 pub fn arm_child_cancel() {
1138 super::fault::CHILD_CANCEL.store(true, Ordering::SeqCst);
1139 }
1140 pub fn arm_supervisor_panic() {
1141 super::fault::SUPERVISOR_PANIC.store(true, Ordering::SeqCst);
1142 }
1143 pub fn arm_supervisor_sleep_before_spawn() {
1144 super::fault::SUPERVISOR_SLEEP_BEFORE_SPAWN.store(true, Ordering::SeqCst);
1145 }
1146 pub fn arm_join_lost() {
1147 super::fault::JOIN_LOST.store(true, Ordering::SeqCst);
1148 }
1149 pub fn arm_inconsistent_exit() {
1150 super::fault::INCONSISTENT_EXIT.store(true, Ordering::SeqCst);
1151 }
1152}
1153
1154// ── Test-attribution surface (R2) ────────────────────────────────────────
1155//
1156// Additive only: production attribution still lands in the public
1157// `db_diagnostics` counters via `metrics_snapshot`-shaped data supplied at
1158// that seam. This surface never substitutes for it.
1159#[cfg(any(test, feature = "test-internals"))]
1160mod test_internals {
1161 use super::*;
1162
1163 /// A point-in-time view of the batch's counters and generation history,
1164 /// used by mechanism tests to compute a delta across a measured
1165 /// operation.
1166 #[derive(Debug, Clone)]
1167 pub struct AuditBatchSnapshot {
1168 pub pending_rows: usize,
1169 pub in_flight_generation: Option<u64>,
1170 pub driver_active: bool,
1171 pub next_generation_id: u64,
1172 pub submitted_rows: u64,
1173 pub committed_rows: u64,
1174 pub store_batch_calls: u64,
1175 pub per_generation: Vec<AuditGenerationSnapshot>,
1176 /// Live count of detached `DriverAppendAbandoned` appends still
1177 /// running, bounded by `AuditBatchConfig::max_abandoned_appends`.
1178 pub outstanding_abandoned_appends: usize,
1179 }
1180
1181 impl AuditBatchSnapshot {
1182 pub fn is_idle(&self) -> bool {
1183 self.pending_rows == 0 && self.in_flight_generation.is_none() && !self.driver_active
1184 }
1185 }
1186
1187 #[derive(Debug, Clone, Copy)]
1188 pub struct AuditBatchMetricsSnapshot {
1189 pub flush_failures: u64,
1190 pub degraded_rows: u64,
1191 pub degraded: bool,
1192 pub late_append_commits: u64,
1193 pub late_append_failures: u64,
1194 }
1195
1196 #[derive(Debug, Clone, PartialEq)]
1197 pub struct AuditBatchDelta {
1198 pub submitted_rows: u64,
1199 pub committed_rows: u64,
1200 pub store_batch_calls: u64,
1201 pub per_generation: Vec<AuditGenerationSnapshot>,
1202 }
1203
1204 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1205 pub enum AuditSnapshotError {
1206 CounterRegressed,
1207 GenerationHistoryRegressed,
1208 }
1209
1210 impl AuditBatch {
1211 pub fn test_snapshot(&self) -> AuditBatchSnapshot {
1212 let state = self.inner.state.lock();
1213 AuditBatchSnapshot {
1214 pending_rows: state.pending.len(),
1215 in_flight_generation: state.in_flight_generation,
1216 driver_active: state.driver_active,
1217 next_generation_id: state.next_generation_id,
1218 submitted_rows: state.submitted_rows,
1219 committed_rows: state.committed_rows,
1220 store_batch_calls: state.store_batch_calls,
1221 per_generation: state.generations.clone(),
1222 outstanding_abandoned_appends: state.outstanding_abandoned_appends,
1223 }
1224 }
1225
1226 pub fn metrics_snapshot(&self) -> AuditBatchMetricsSnapshot {
1227 let state = self.inner.state.lock();
1228 AuditBatchMetricsSnapshot {
1229 flush_failures: state.flush_failures,
1230 degraded_rows: state.degraded_rows,
1231 degraded: state.degraded,
1232 late_append_commits: state.late_append_commits,
1233 late_append_failures: state.late_append_failures,
1234 }
1235 }
1236
1237 /// Abort the currently-retained supervisor `JoinHandle`, if any,
1238 /// simulating a shutdown abort landing mid-generation. Returns
1239 /// whether a handle was found and aborted. Test-only: exercises the
1240 /// R1 supervisor-cancellation path without reaching into a private
1241 /// field from outside this module.
1242 pub fn test_abort_supervisor(&self) -> bool {
1243 let handle = self.supervisor.lock().take();
1244 match handle {
1245 Some(handle) => {
1246 handle.abort();
1247 true
1248 }
1249 None => false,
1250 }
1251 }
1252 }
1253
1254 /// Checked monotonic subtraction. Rejects a regressed counter or a
1255 /// generation history that is not an append-only extension of `before`.
1256 pub fn audit_delta(
1257 before: &AuditBatchSnapshot,
1258 after: &AuditBatchSnapshot,
1259 ) -> Result<AuditBatchDelta, AuditSnapshotError> {
1260 let submitted_rows = after
1261 .submitted_rows
1262 .checked_sub(before.submitted_rows)
1263 .ok_or(AuditSnapshotError::CounterRegressed)?;
1264 let committed_rows = after
1265 .committed_rows
1266 .checked_sub(before.committed_rows)
1267 .ok_or(AuditSnapshotError::CounterRegressed)?;
1268 let store_batch_calls = after
1269 .store_batch_calls
1270 .checked_sub(before.store_batch_calls)
1271 .ok_or(AuditSnapshotError::CounterRegressed)?;
1272 if after.per_generation.len() < before.per_generation.len() {
1273 return Err(AuditSnapshotError::GenerationHistoryRegressed);
1274 }
1275 if after.per_generation[..before.per_generation.len()] != before.per_generation[..] {
1276 return Err(AuditSnapshotError::GenerationHistoryRegressed);
1277 }
1278 let per_generation = after.per_generation[before.per_generation.len()..].to_vec();
1279 Ok(AuditBatchDelta {
1280 submitted_rows,
1281 committed_rows,
1282 store_batch_calls,
1283 per_generation,
1284 })
1285 }
1286
1287 /// Exhaustive, non-wildcard producer classification (see
1288 /// [`super::AuditProducer`] and [`super::classify`], the real
1289 /// crate-private definitions this mirrors one-for-one). The doctest
1290 /// below proves the general property those definitions rely on: a match
1291 /// over a non-`#[non_exhaustive]` enum that omits a variant, with no
1292 /// wildcard arm to silently absorb it, fails to compile rather than
1293 /// passing an incomplete classification.
1294 ///
1295 /// ```compile_fail
1296 /// enum AuditProducer {
1297 /// GateDenied,
1298 /// DispatchSucceeded,
1299 /// DispatchFailed,
1300 /// UnknownVerb,
1301 /// GitDigestReceipt,
1302 /// ConfigLocked,
1303 /// RecallExecuted,
1304 /// }
1305 ///
1306 /// fn describe(p: AuditProducer) -> &'static str {
1307 /// match p {
1308 /// AuditProducer::GateDenied => "obligation",
1309 /// AuditProducer::DispatchSucceeded => "obligation",
1310 /// AuditProducer::DispatchFailed => "obligation",
1311 /// AuditProducer::UnknownVerb => "obligation",
1312 /// AuditProducer::GitDigestReceipt => "obligation",
1313 /// AuditProducer::ConfigLocked => "observability",
1314 /// // RecallExecuted intentionally omitted: a non-exhaustive
1315 /// // match must fail to compile, proving no variant can
1316 /// // silently fall through an absent wildcard arm.
1317 /// }
1318 /// }
1319 /// ```
1320 #[allow(dead_code)]
1321 struct DoctestAnchor;
1322}
1323
1324#[cfg(any(test, feature = "test-internals"))]
1325pub use test_internals::*;