Skip to main content

khive_db/
writer_task.rs

1//! Single-writer task and bounded write queue (ADR-067 Component A).
2//!
3//! `WriterTask` (via `spawn` and the drain loop `run_writer_task`) owns a
4//! dedicated standalone writer `rusqlite::Connection` and is the only code
5//! path that issues `BEGIN IMMEDIATE` for write traffic routed through the
6//! channel it drains. Callers reach it exclusively through a
7//! [`WriterTaskHandle`], sending a typed closure and awaiting a typed
8//! oneshot reply so each store method's natural return type (e.g.
9//! `BatchWriteSummary`) survives the trip through the type-erased channel
10//! unmodified — a flat `Result<u64, StorageError>` reply would conflate
11//! `affected`/`failed` into one count and drop `first_error`.
12//!
13//! See `crates/khive-db/docs/api/writer-task.md` for migration-slice scope
14//! (which write paths currently route through this vs. the legacy
15//! pool-mutex path) and the ADR-067 component breakdown.
16//!
17//! ## ADR-136 D1 gate 5: writer classification
18//!
19//! Every connection that issues writes against a khive-db file falls into
20//! exactly one of the rows below. Request-path store writes, runtime
21//! merge/symmetric-edge transactions,
22//! and `khive_storage::SqlAccess` (`SqlBridge::writer`/`atomic_unit`) are subject
23//! to `PoolConfig::write_queue_enabled` / `write_routing_strict` routing.
24//! Everything else here is an
25//! explicit, intentional exemption — not an implicit gap left over from an
26//! incomplete migration.
27//!
28//! | Writer | Connection | Request-path write | Routing |
29//! | --- | --- | --- | --- |
30//! | Request-path writes (`create`, `update`, batch upserts, …) | `WriterTaskHandle` (queue-first) or a standalone/pool-mutex connection on degrade | Yes | Queue-first (ADR-136 D1 gate 1); strict routing fails closed on degrade |
31//! | Startup / schema migrations (`migrations::run_migrations`, `apply_schema_plan`) | The pool's own writer-mutex connection (`ConnectionPool::new`, before the `WriterTask` is ever spawned) | No | Exempt — runs once at boot, before any queue handle exists to route through |
32//! | Checkpointing (`checkpoint::CheckpointConnection`) | Dedicated standalone connection, opened once at task startup (ADR-091 Amendment 5, #1652) | No | Exempt by design — the whole point of that fix was moving checkpoint I/O OFF the pool writer's admission path; routing it back through the write queue would reintroduce the contention it removed |
33//! | Recovery (`walpin` beacon/sidecar bookkeeping) | None — file-level bookkeeping (PID beacons, heartbeats) alongside the database, not a SQL connection against it | No | N/A — never acquires a database writer connection |
34//! | Top-level maintenance (`VACUUM` via `execute_script_top_level`/`WriterTaskHandle::send_top_level`) | `WriterTaskHandle`, skipping the per-request `BEGIN IMMEDIATE`/`COMMIT` wrap only | Yes | Routed through the SAME single writer owner as every other queued request — only the transaction wrap is skipped, never the queue |
35//!
36//! Store constructors may run outside Tokio and temporarily cache no handle;
37//! every store request-path write refreshes through
38//! `ConnectionPool::writer_task_for_write` at execution time. Strict routing
39//! refuses a missing handle, while the explicit compatibility fallback emits
40//! its store-specific violation at the actual direct-writer seam. Runtime
41//! entity/note merges and symmetric edge updates use the same routing policy
42//! through a closed operation adapter. The strict default itself remains gated
43//! by ADR-135 F2 / ADR-136 D2 evidence.
44//!
45//! A `direct_route_violation` sink row (`timeout_sink::emit_direct_route_violation`,
46//! ADR-136 D1 gate 6c) is emitted ONLY for the first row's degrade path — a
47//! request-path write that bypassed an enabled queue. The other four
48//! rows are exempt by design and never emit that row.
49
50use rusqlite::Connection;
51use std::collections::HashMap;
52use std::panic::{catch_unwind, AssertUnwindSafe};
53use std::path::{Path, PathBuf};
54use std::sync::{Arc, Mutex, OnceLock};
55use std::time::{Duration, Instant};
56use tokio::sync::{mpsc, oneshot};
57
58use khive_storage::error::{StorageError, WriterTaskRequestState};
59
60use crate::error::SqliteError;
61use crate::pool::{ConnectionPool, WriterAcquisitionCounters};
62
63/// Retry delays after a busy/locked `BEGIN IMMEDIATE` refusal. The operation
64/// closure remains owned by the request until one BEGIN succeeds, so these
65/// are the only retries this path may perform. Persistent contention is
66/// bounded to three total BEGIN attempts and 15 ms of explicit backoff, and
67/// the attempts together share one configured `busy_timeout` acquisition
68/// budget rather than each waiting out a full window of their own.
69const WRITER_BEGIN_RETRY_DELAYS: [Duration; 2] =
70    [Duration::from_millis(5), Duration::from_millis(10)];
71
72/// Closure signature for a write operation executed against the writer
73/// task's dedicated connection.
74///
75/// `conn` is already inside a `BEGIN IMMEDIATE` transaction opened by
76/// `run_writer_task` when this runs. The closure must issue DML (and, in
77/// later slices, named `SAVEPOINT`s) only — never a bare `BEGIN` / `COMMIT`
78/// / `ROLLBACK` — a nested bare `BEGIN IMMEDIATE` would violate SQLite's
79/// nested-transaction rule and return `SQLITE_ERROR: cannot start a
80/// transaction within a transaction` (ADR-067 lines 271-276).
81type WriteOp<R> = Box<dyn FnOnce(&Connection) -> Result<R, StorageError> + Send>;
82
83/// Latest completed writer-task span for one database, decomposed at the
84/// boundaries an operator can act on (#1849). A zero value means that phase
85/// did not run (top-level/early-failure request) or completed below the
86/// microsecond clock resolution; `total_micros` retains the full span.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct WriterStageObservation {
89    pub queue_wait_micros: u64,
90    pub transaction_acquire_micros: u64,
91    pub body_micros: u64,
92    pub commit_micros: u64,
93    pub total_micros: u64,
94    pub queue_depth_at_entry: u64,
95    pub observed_at_unix_ms: u64,
96}
97
98static WRITER_STAGE_OBSERVATIONS: OnceLock<
99    Mutex<HashMap<Option<PathBuf>, WriterStageObservation>>,
100> = OnceLock::new();
101
102fn writer_stage_observations() -> &'static Mutex<HashMap<Option<PathBuf>, WriterStageObservation>> {
103    WRITER_STAGE_OBSERVATIONS.get_or_init(|| Mutex::new(HashMap::new()))
104}
105
106fn writer_db_key_from_path(path: Option<&Path>) -> Option<PathBuf> {
107    path.map(Path::to_path_buf)
108}
109
110fn writer_db_key(pool: &ConnectionPool) -> Option<PathBuf> {
111    writer_db_key_from_path(pool.canonical_path())
112}
113
114fn duration_micros(duration: Duration) -> u64 {
115    duration.as_micros().min(u128::from(u64::MAX)) as u64
116}
117
118fn observed_at_unix_ms() -> u64 {
119    std::time::SystemTime::now()
120        .duration_since(std::time::UNIX_EPOCH)
121        .map(|duration| duration.as_millis() as u64)
122        .unwrap_or(0)
123}
124
125/// Pure in-memory read of the most recently completed writer-task span for
126/// this exact backend. It acquires no SQLite connection and performs no I/O.
127pub fn last_writer_stage_observation(pool: &ConnectionPool) -> Option<WriterStageObservation> {
128    writer_stage_observations()
129        .lock()
130        .unwrap_or_else(std::sync::PoisonError::into_inner)
131        .get(&writer_db_key(pool))
132        .cloned()
133}
134
135struct WriteTelemetry {
136    backend_key: Option<PathBuf>,
137    db: String,
138    submitted_at: Instant,
139    queue_depth_at_entry: usize,
140    slow_write_threshold: Option<Duration>,
141}
142
143impl WriteTelemetry {
144    fn new(
145        backend_key: Option<PathBuf>,
146        db: String,
147        queue_depth_at_entry: usize,
148        slow_write_threshold: Option<Duration>,
149    ) -> Self {
150        Self {
151            backend_key,
152            db,
153            submitted_at: Instant::now(),
154            queue_depth_at_entry,
155            slow_write_threshold,
156        }
157    }
158
159    fn queue_wait(&self) -> Duration {
160        self.submitted_at.elapsed()
161    }
162
163    fn finish(
164        self,
165        queue_wait: Duration,
166        transaction_acquire: Duration,
167        body: Duration,
168        commit: Duration,
169    ) {
170        let total = self.submitted_at.elapsed();
171        let observation = WriterStageObservation {
172            queue_wait_micros: duration_micros(queue_wait),
173            transaction_acquire_micros: duration_micros(transaction_acquire),
174            body_micros: duration_micros(body),
175            commit_micros: duration_micros(commit),
176            total_micros: duration_micros(total),
177            queue_depth_at_entry: self.queue_depth_at_entry as u64,
178            observed_at_unix_ms: observed_at_unix_ms(),
179        };
180        writer_stage_observations()
181            .lock()
182            .unwrap_or_else(std::sync::PoisonError::into_inner)
183            .insert(self.backend_key, observation.clone());
184
185        if self
186            .slow_write_threshold
187            .is_some_and(|threshold| total >= threshold)
188        {
189            crate::timeout_sink::emit_slow_write(&self.db, &observation);
190        }
191    }
192}
193
194/// One write request awaiting execution by the writer task.
195///
196/// Carries a typed closure and a typed oneshot reply so that the concrete
197/// return type `R` (e.g. `BatchWriteSummary`) is preserved end to end,
198/// while [`AnyWriteRequest`] lets the drain loop hold heterogeneous
199/// requests in one homogeneous channel.
200///
201/// `top_level` (ADR-067 Component A): when `true`,
202/// the drain loop runs this request's operation WITHOUT wrapping it in a
203/// `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK` — still serialized through the
204/// single writer owner (only one request drains at a time regardless of
205/// this flag), but with the transaction wrap skipped entirely. Exists for
206/// statements SQLite forbids inside any open transaction (e.g. `VACUUM`);
207/// see [`WriterTaskHandle::send_top_level`].
208pub struct WriteRequest<R: Send + 'static> {
209    op: WriteOp<R>,
210    reply: oneshot::Sender<Result<R, StorageError>>,
211    top_level: bool,
212    /// Only the typed checkpoint entry point may bypass disk admission.
213    checkpoint_bypass: bool,
214    vacuum_copy_headroom: bool,
215    telemetry: WriteTelemetry,
216}
217
218mod sealed {
219    /// Restricts [`super::AnyWriteRequest`] to implementations defined in
220    /// this module — only [`super::WriteRequest<R>`] implements it — and
221    /// carries the drain loop's internal terminal-state reporting methods
222    /// without changing the public execution-method signatures.
223    pub trait Sealed {
224        fn execute_and_reply_reporting_terminal(
225            self: Box<Self>,
226            conn: &rusqlite::Connection,
227            tx_span: Option<khive_storage::tx_registry::TxHandle>,
228            queue_wait: std::time::Duration,
229            transaction_acquire: std::time::Duration,
230        ) -> Option<khive_storage::error::WriterTaskRequestState>;
231
232        fn execute_and_reply_top_level_reporting_terminal(
233            self: Box<Self>,
234            conn: &rusqlite::Connection,
235            queue_wait: std::time::Duration,
236        ) -> Option<khive_storage::error::WriterTaskRequestState>;
237
238        fn reply_error_after_begin(
239            self: Box<Self>,
240            err: khive_storage::error::StorageError,
241            queue_wait: std::time::Duration,
242            transaction_acquire: std::time::Duration,
243        );
244    }
245}
246
247/// Type-erased write request the writer task's drain loop can hold in a
248/// homogeneous channel (`mpsc::Sender<Box<dyn AnyWriteRequest + Send>>`),
249/// while each concrete [`WriteRequest<R>`] still carries its own typed
250/// reply. Sealed: only this module may implement it (ADR-067 lines
251/// 210-212).
252pub trait AnyWriteRequest: sealed::Sealed + Send {
253    /// Runs this request's operation against `conn`, commits or rolls back
254    /// the enclosing transaction based on the outcome, and sends the
255    /// (possibly commit-failure-adjusted) result to the request's oneshot
256    /// reply channel.
257    ///
258    /// `conn` must already be inside a successfully-opened `BEGIN IMMEDIATE`
259    /// transaction opened by the caller (`run_writer_task`) — this method
260    /// issues only `COMMIT` / `ROLLBACK`, never `BEGIN`, so `run_writer_task`
261    /// remains the sole issuer of `BEGIN IMMEDIATE` (ADR-067 Component A).
262    /// Callers must use [`Self::reply_error`] instead when the enclosing
263    /// `BEGIN IMMEDIATE` itself failed — this method must not be invoked in
264    /// that case.
265    fn execute_and_reply(self: Box<Self>, conn: &Connection);
266
267    /// Runs this request's operation directly against `conn` — no
268    /// transaction wrap, no `COMMIT`/`ROLLBACK` — and sends the result to
269    /// the request's oneshot reply channel.
270    ///
271    /// Used only for [`Self::is_top_level`] requests: the drain loop calls
272    /// this INSTEAD of `execute_and_reply` for such requests, skipping
273    /// `BEGIN IMMEDIATE` entirely so a statement that must run outside any
274    /// transaction (e.g. `VACUUM`) can still be serialized through the
275    /// single writer owner.
276    fn execute_and_reply_top_level(self: Box<Self>, conn: &Connection);
277
278    /// Replies with `err` without running this request's operation or
279    /// touching `conn`.
280    ///
281    /// Used when the enclosing `BEGIN IMMEDIATE` failed (for example,
282    /// `SQLITE_BUSY` from lock contention with an unmigrated writer path
283    /// still holding the pool's writer mutex — reachable while only
284    /// `entity.rs` is routed through this channel). Running the operation
285    /// anyway would execute its DML against `conn` in autocommit mode,
286    /// landing partial writes for a request the caller is told failed.
287    /// Skipping the operation entirely keeps "the caller got an error" and
288    /// "no rows landed" true together.
289    fn reply_error(self: Box<Self>, err: StorageError);
290
291    /// `true` if the drain loop must run this request via
292    /// [`Self::execute_and_reply_top_level`] (no transaction wrap) instead
293    /// of [`Self::execute_and_reply`] (wrapped in `BEGIN IMMEDIATE`).
294    fn is_top_level(&self) -> bool;
295
296    fn is_checkpoint_bypass(&self) -> bool;
297
298    fn needs_vacuum_headroom(&self) -> bool;
299
300    /// Time from the caller constructing this request (before bounded-channel
301    /// admission) until the writer task dequeued it.
302    fn queue_wait(&self) -> Duration;
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306enum RollbackDisposition {
307    RolledBack,
308    SideEffectsUnknown,
309}
310
311/// Roll back a failed wrapped request and verify that the connection really
312/// returned to autocommit mode before it can serve another request.
313fn rollback_after_failure(conn: &Connection, failure_context: &'static str) -> RollbackDisposition {
314    match conn.execute_batch("ROLLBACK") {
315        Ok(()) if conn.is_autocommit() => RollbackDisposition::RolledBack,
316        Ok(()) => {
317            tracing::error!(
318                failure_context,
319                "writer transaction: ROLLBACK returned success but the connection is still in a \
320                 transaction; request side effects are unknown"
321            );
322            RollbackDisposition::SideEffectsUnknown
323        }
324        Err(rollback_error) => {
325            tracing::error!(
326                error = %rollback_error,
327                failure_context,
328                "writer transaction: rollback after request failure failed; request side effects are \
329                 unknown"
330            );
331            RollbackDisposition::SideEffectsUnknown
332        }
333    }
334}
335
336/// Execute an operation inside an already-open transaction and apply the
337/// shared commit, rollback, panic, and autocommit verification rules.
338pub(crate) fn execute_wrapped_transaction<R, F>(
339    conn: &Connection,
340    commit_operation: &'static str,
341    operation: F,
342) -> (Result<R, StorageError>, Option<WriterTaskRequestState>)
343where
344    F: FnOnce(&Connection) -> Result<R, StorageError>,
345{
346    let profiled = execute_wrapped_transaction_profiled(conn, commit_operation, None, operation);
347    (profiled.result, profiled.terminal_state)
348}
349
350struct ProfiledWrappedTransaction<R> {
351    result: Result<R, StorageError>,
352    terminal_state: Option<WriterTaskRequestState>,
353    body: Duration,
354    commit: Duration,
355}
356
357fn execute_wrapped_transaction_profiled<R, F>(
358    conn: &Connection,
359    commit_operation: &'static str,
360    db: Option<&str>,
361    operation: F,
362) -> ProfiledWrappedTransaction<R>
363where
364    F: FnOnce(&Connection) -> Result<R, StorageError>,
365{
366    let body_started = Instant::now();
367    let operation_outcome = catch_unwind(AssertUnwindSafe(|| operation(conn)));
368    let body = body_started.elapsed();
369
370    match operation_outcome {
371        Ok(Ok(value)) => {
372            let commit_started = Instant::now();
373            let commit_outcome = conn.execute_batch("COMMIT");
374            let commit = commit_started.elapsed();
375            match commit_outcome {
376                Ok(()) if conn.is_autocommit() => ProfiledWrappedTransaction {
377                    result: Ok(value),
378                    terminal_state: None,
379                    body,
380                    commit,
381                },
382                Ok(()) => {
383                    tracing::error!(
384                    "writer transaction: COMMIT returned success but the connection is still in \
385                     a transaction; request side effects are unknown"
386                );
387                    let request_state = WriterTaskRequestState::SideEffectsUnknown;
388                    ProfiledWrappedTransaction {
389                        result: Err(writer_task_terminated(request_state)),
390                        terminal_state: Some(request_state),
391                        body,
392                        commit,
393                    }
394                }
395                Err(commit_error) => {
396                    if let Some(db) = db {
397                        crate::timeout_sink::maybe_emit_sqlite_full(db, &commit_error);
398                    }
399                    match rollback_after_failure(conn, "commit failure") {
400                        RollbackDisposition::RolledBack => ProfiledWrappedTransaction {
401                            result: Err(StorageError::WriterTaskRequestFailed {
402                                request_state: WriterTaskRequestState::TransactionRolledBack,
403                                source: Box::new(StorageError::Pool {
404                                    operation: commit_operation.into(),
405                                    message: commit_error.to_string(),
406                                }),
407                            }),
408                            terminal_state: None,
409                            body,
410                            commit,
411                        },
412                        RollbackDisposition::SideEffectsUnknown => {
413                            let request_state = WriterTaskRequestState::SideEffectsUnknown;
414                            ProfiledWrappedTransaction {
415                                result: Err(writer_task_terminated(request_state)),
416                                terminal_state: Some(request_state),
417                                body,
418                                commit,
419                            }
420                        }
421                    }
422                }
423            }
424        }
425        Ok(Err(operation_error)) => {
426            match rollback_after_failure(conn, "request operation failure") {
427                RollbackDisposition::RolledBack => ProfiledWrappedTransaction {
428                    result: Err(StorageError::WriterTaskRequestFailed {
429                        request_state: WriterTaskRequestState::TransactionRolledBack,
430                        source: Box::new(operation_error),
431                    }),
432                    terminal_state: None,
433                    body,
434                    commit: Duration::ZERO,
435                },
436                RollbackDisposition::SideEffectsUnknown => {
437                    // SQLite may have already rolled back a failed transaction,
438                    // leaving no cause on the terminal error returned to callers.
439                    // Escalate SQLITE_FULL while the original error is still here.
440                    if let Some(db) = db {
441                        crate::timeout_sink::maybe_emit_sqlite_full(db, &operation_error);
442                    }
443                    let request_state = WriterTaskRequestState::SideEffectsUnknown;
444                    ProfiledWrappedTransaction {
445                        result: Err(writer_task_terminated(request_state)),
446                        terminal_state: Some(request_state),
447                        body,
448                        commit: Duration::ZERO,
449                    }
450                }
451            }
452        }
453        Err(_panic_payload) => {
454            let request_state = match rollback_after_failure(conn, "request panic") {
455                RollbackDisposition::RolledBack => WriterTaskRequestState::TransactionRolledBack,
456                RollbackDisposition::SideEffectsUnknown => {
457                    WriterTaskRequestState::SideEffectsUnknown
458                }
459            };
460            ProfiledWrappedTransaction {
461                result: Err(writer_task_terminated(request_state)),
462                terminal_state: Some(request_state),
463                body,
464                commit: Duration::ZERO,
465            }
466        }
467    }
468}
469
470impl<R: Send + 'static> sealed::Sealed for WriteRequest<R> {
471    fn execute_and_reply_reporting_terminal(
472        self: Box<Self>,
473        conn: &Connection,
474        tx_span: Option<khive_storage::tx_registry::TxHandle>,
475        queue_wait: Duration,
476        transaction_acquire: Duration,
477    ) -> Option<WriterTaskRequestState> {
478        // Keep the typed reply sender outside the unwind boundary. Calling
479        // `(self.op)(conn)` directly would drop `self.reply` while unwinding,
480        // leaving the active caller with only an untyped RecvError.
481        let WriteRequest {
482            op,
483            reply,
484            telemetry,
485            ..
486        } = *self;
487        let profiled = execute_wrapped_transaction_profiled(
488            conn,
489            "writer_task_commit",
490            Some(&telemetry.db),
491            op,
492        );
493        if let Err(error) = &profiled.result {
494            crate::timeout_sink::maybe_emit_sqlite_full(&telemetry.db, error);
495        }
496        // The reply wake can make the caller immediately observe the
497        // committed/error result. Deregister the transaction span first so
498        // tx_registry continues to mean "currently open SQL transaction",
499        // never "a transaction whose caller has already resumed" (#1790).
500        drop(tx_span);
501        telemetry.finish(
502            queue_wait,
503            transaction_acquire,
504            profiled.body,
505            profiled.commit,
506        );
507        // The receiver may already be gone (caller dropped its future) —
508        // that is not this task's problem to report.
509        let _ = reply.send(profiled.result);
510        profiled.terminal_state
511    }
512
513    fn execute_and_reply_top_level_reporting_terminal(
514        self: Box<Self>,
515        conn: &Connection,
516        queue_wait: Duration,
517    ) -> Option<WriterTaskRequestState> {
518        let WriteRequest {
519            op,
520            reply,
521            telemetry,
522            ..
523        } = *self;
524        let body_started = Instant::now();
525        let outcome = catch_unwind(AssertUnwindSafe(|| op(conn)));
526        let body = body_started.elapsed();
527        let telemetry_db = telemetry.db.clone();
528        telemetry.finish(queue_wait, Duration::ZERO, body, Duration::ZERO);
529        match outcome {
530            Ok(outcome) if conn.is_autocommit() => {
531                // No COMMIT/ROLLBACK here: this request explicitly did not
532                // open a transaction, so there is nothing to close.
533                if let Err(error) = &outcome {
534                    crate::timeout_sink::maybe_emit_sqlite_full(&telemetry_db, error);
535                }
536                let _ = reply.send(outcome);
537                None
538            }
539            Ok(_outcome) => {
540                tracing::error!(
541                    "writer task: top-level request returned with an open transaction; request \
542                     side effects are unknown"
543                );
544                let request_state = WriterTaskRequestState::SideEffectsUnknown;
545                let _ = reply.send(Err(writer_task_terminated(request_state)));
546                Some(request_state)
547            }
548            Err(_panic_payload) => {
549                // Statements completed before a top-level panic may already
550                // have autocommitted. Report the ambiguity; never invent a
551                // rollback for a request that opened no transaction.
552                let request_state = WriterTaskRequestState::SideEffectsUnknown;
553                let _ = reply.send(Err(writer_task_terminated(request_state)));
554                Some(request_state)
555            }
556        }
557    }
558
559    fn reply_error_after_begin(
560        self: Box<Self>,
561        err: StorageError,
562        queue_wait: Duration,
563        transaction_acquire: Duration,
564    ) {
565        let WriteRequest {
566            reply, telemetry, ..
567        } = *self;
568        telemetry.finish(
569            queue_wait,
570            transaction_acquire,
571            Duration::ZERO,
572            Duration::ZERO,
573        );
574        let _ = reply.send(Err(err));
575    }
576}
577
578impl<R: Send + 'static> AnyWriteRequest for WriteRequest<R> {
579    fn execute_and_reply(self: Box<Self>, conn: &Connection) {
580        let queue_wait = self.queue_wait();
581        let _ = sealed::Sealed::execute_and_reply_reporting_terminal(
582            self,
583            conn,
584            None,
585            queue_wait,
586            Duration::ZERO,
587        );
588    }
589
590    fn execute_and_reply_top_level(self: Box<Self>, conn: &Connection) {
591        let queue_wait = self.queue_wait();
592        let _ =
593            sealed::Sealed::execute_and_reply_top_level_reporting_terminal(self, conn, queue_wait);
594    }
595
596    fn reply_error(self: Box<Self>, err: StorageError) {
597        let queue_wait = self.queue_wait();
598        sealed::Sealed::reply_error_after_begin(self, err, queue_wait, Duration::ZERO);
599    }
600
601    fn is_top_level(&self) -> bool {
602        self.top_level
603    }
604
605    fn is_checkpoint_bypass(&self) -> bool {
606        self.checkpoint_bypass
607    }
608
609    fn needs_vacuum_headroom(&self) -> bool {
610        self.vacuum_copy_headroom
611    }
612
613    fn queue_wait(&self) -> Duration {
614        self.telemetry.queue_wait()
615    }
616}
617
618fn writer_task_terminated(request_state: WriterTaskRequestState) -> StorageError {
619    StorageError::WriterTaskTerminated { request_state }
620}
621
622fn writer_task_begin_error(error: rusqlite::Error, busy_timeout: Duration) -> StorageError {
623    if crate::timeout_sink::is_busy_or_locked(&error) {
624        StorageError::WriterTaskBusy {
625            timeout_ms: u64::try_from(busy_timeout.as_millis()).unwrap_or(u64::MAX),
626        }
627    } else {
628        StorageError::Pool {
629            operation: "writer_task_begin".into(),
630            message: error.to_string(),
631        }
632    }
633}
634
635/// Sender half of the write queue. Cheaply cloneable (wraps an
636/// `mpsc::Sender`) — every migrated store that shares one writer task holds
637/// a clone of this handle.
638#[derive(Clone, Debug)]
639pub struct WriterTaskHandle {
640    tx: mpsc::Sender<Box<dyn AnyWriteRequest + Send>>,
641    /// Exact canonical backend identity used by the in-memory stage registry.
642    /// Kept separate from `db`, whose lossy display form is logging-only.
643    backend_key: Option<PathBuf>,
644    /// This handle's pool's writer-timeout sink identity (`timeout_sink::db_label`),
645    /// captured at spawn so `send_with_timeout`'s `WriteQueueFull` path can
646    /// report a `queue_saturation` sink row (ADR-136 D1 gate 6a) without
647    /// needing a `&ConnectionPool` reference.
648    db: String,
649    /// Slow-write latency bound (`timeout_sink::slow_write_threshold`),
650    /// resolved once at spawn. `None` disables slow-write rows. Captured
651    /// here rather than read per send so the caller path pays no env lookup
652    /// and tests get a deterministic value by setting the override before
653    /// spawning their own writer task.
654    slow_write_threshold: Option<std::time::Duration>,
655    /// Default enqueue-capacity deadline for [`Self::send_bounded`] /
656    /// [`Self::send_top_level_bounded`], captured from
657    /// `PoolConfig::write_admission_deadline_ms` at [`spawn`] (ADR-131
658    /// Decision 2). Bounds ONLY the wait for channel capacity, the same
659    /// boundary `send_with_timeout` already documents — never the reply wait
660    /// after acceptance (#1382).
661    ///
662    /// This is a dedicated admission authority, distinct from
663    /// `PoolConfig::checkout_timeout` (reader/pool checkout). The two used
664    /// to be conflated here before ADR-131 Decision 2 (#1382, #1643).
665    ///
666    /// ADR-131 Decision 2's outer-request-budget clamp ("when the caller's outer
667    /// request deadline leaves less time remaining than the configured
668    /// admission deadline, the admission deadline applied to that operation
669    /// is the remaining outer budget instead") is explicitly DEFERRED: no
670    /// outer request deadline is plumbed to `send_bounded` /
671    /// `send_top_level_bounded`'s call sites as of #1737, so there is no
672    /// budget to clamp against. When an outer deadline reaches this call
673    /// path, clamp here rather than faking a shorter deadline against a
674    /// nonexistent budget.
675    enqueue_timeout: std::time::Duration,
676}
677
678impl WriterTaskHandle {
679    /// Enqueue a write operation and return the oneshot receiver its reply
680    /// will arrive on, once the request has actually been accepted onto the
681    /// channel.
682    ///
683    /// Shared by [`Self::send`] and [`Self::send_with_timeout`] so that a
684    /// caller-supplied deadline (see `send_with_timeout`) can bound ONLY
685    /// this enqueue step — never the reply-wait that follows it. Once this
686    /// returns `Ok`, the request has been accepted by the writer task and
687    /// will either run to completion or receive a typed terminal error if an
688    /// earlier request kills the task before this operation begins. The
689    /// returned receiver must be awaited without a timeout; abandoning it
690    /// here would silently drop the request's eventual result, not cancel the
691    /// request itself.
692    async fn enqueue<R, F>(
693        &self,
694        op: F,
695    ) -> Result<oneshot::Receiver<Result<R, StorageError>>, StorageError>
696    where
697        R: Send + 'static,
698        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
699    {
700        self.enqueue_inner(op, false, false, false).await
701    }
702
703    /// Shared enqueue path for both transaction-wrapped ([`Self::enqueue`])
704    /// and top-level ([`Self::send_top_level`]) requests — `top_level`
705    /// controls which [`AnyWriteRequest`] method the drain loop invokes.
706    async fn enqueue_inner<R, F>(
707        &self,
708        op: F,
709        top_level: bool,
710        checkpoint_bypass: bool,
711        vacuum_copy_headroom: bool,
712    ) -> Result<oneshot::Receiver<Result<R, StorageError>>, StorageError>
713    where
714        R: Send + 'static,
715        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
716    {
717        let (reply_tx, reply_rx) = oneshot::channel();
718        let telemetry = WriteTelemetry::new(
719            self.backend_key.clone(),
720            self.db.clone(),
721            self.queue_depth(),
722            self.slow_write_threshold,
723        );
724        let request = WriteRequest {
725            op: Box::new(op),
726            reply: reply_tx,
727            top_level,
728            checkpoint_bypass,
729            vacuum_copy_headroom,
730            telemetry,
731        };
732
733        self.tx
734            .send(Box::new(request))
735            .await
736            .map_err(|_| writer_task_terminated(WriterTaskRequestState::NotStarted))?;
737
738        Ok(reply_rx)
739    }
740
741    /// Send a write operation to the writer task and await its typed reply.
742    ///
743    /// Backpressure: this suspends on the channel's `send().await` when the
744    /// bounded queue is full (ADR-067 "Channel capacity and queue-full
745    /// policy") — there is no `try_send` escape hatch. Callers that need a
746    /// deadline on that wait should use [`Self::send_with_timeout`] instead.
747    pub async fn send<R, F>(&self, op: F) -> Result<R, StorageError>
748    where
749        R: Send + 'static,
750        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
751    {
752        let reply_rx = self.enqueue(op).await?;
753        reply_rx
754            .await
755            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
756    }
757
758    /// Like [`Self::send`], but bounds the wait for the bounded channel to
759    /// free capacity with `timeout`.
760    ///
761    /// The timeout applies ONLY to enqueueing the request (the channel
762    /// `send().await` that can suspend on a full queue) — never to waiting
763    /// for the writer task's reply once the request has been accepted.
764    /// `StorageError::WriteQueueFull` means exactly "the bounded channel was
765    /// full and this request was never accepted"; it must never be returned
766    /// for a request that was accepted and is still executing (or already
767    /// committed) by the time `timeout` elapses — that would misreport a
768    /// slow op or a lock wait as a queue-capacity failure, and could tell a
769    /// caller a write failed when it actually landed. ADR-067's queue-full
770    /// policy has no immediate-error `try_send` path — only this caller-side
771    /// deadline on the enqueue step.
772    pub async fn send_with_timeout<R, F>(
773        &self,
774        op: F,
775        timeout: std::time::Duration,
776    ) -> Result<R, StorageError>
777    where
778        R: Send + 'static,
779        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
780    {
781        let reply_rx = match tokio::time::timeout(timeout, self.enqueue(op)).await {
782            Ok(Ok(reply_rx)) => reply_rx,
783            Ok(Err(e)) => return Err(e),
784            Err(_elapsed) => {
785                let timeout_ms = timeout.as_millis() as u64;
786                crate::timeout_sink::emit_queue_saturation(&self.db, timeout_ms);
787                return Err(StorageError::WriteQueueFull { timeout_ms });
788            }
789        };
790
791        reply_rx
792            .await
793            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
794    }
795
796    /// Like [`Self::send`], but bounds the enqueue-capacity wait with this
797    /// handle's configured `enqueue_timeout`
798    /// (`PoolConfig::write_admission_deadline_ms` at spawn time, ADR-131
799    /// Decision 2) instead of waiting indefinitely (#1382).
800    ///
801    /// Once the request is accepted onto the channel, the reply wait is
802    /// unbounded by this method, identical to [`Self::send`] — this bounds
803    /// only queue-capacity admission, never SQLite execution or reply
804    /// latency. Callers that need a non-default deadline should use
805    /// [`Self::send_with_timeout`] directly.
806    pub async fn send_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
807    where
808        R: Send + 'static,
809        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
810    {
811        self.send_with_timeout(op, self.enqueue_timeout).await
812    }
813
814    /// Send a write operation that MUST run outside any open transaction
815    /// (e.g. `VACUUM`, which SQLite forbids inside `BEGIN`/`COMMIT`) and
816    /// await its typed reply.
817    ///
818    /// Still serialized through the same single writer owner as
819    /// [`Self::send`] — the request goes through the identical bounded
820    /// channel and drain loop, one request at a time — but the drain loop
821    /// skips the per-request `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK` wrap
822    /// entirely for this request (ADR-067 Component A). The single-writer
823    /// guarantee is preserved; only
824    /// the transaction wrap is skipped.
825    pub async fn send_top_level<R, F>(&self, op: F) -> Result<R, StorageError>
826    where
827        R: Send + 'static,
828        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
829    {
830        let reply_rx = self.enqueue_inner(op, true, false, false).await?;
831        reply_rx
832            .await
833            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
834    }
835
836    /// Like [`Self::send_top_level`], but bounds the enqueue-capacity wait
837    /// with this handle's configured `enqueue_timeout`, mirroring
838    /// [`Self::send_bounded`] for top-level (transaction-skipping) requests
839    /// (#1382).
840    pub async fn send_top_level_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
841    where
842        R: Send + 'static,
843        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
844    {
845        self.send_top_level_bounded_inner(op, false, false).await
846    }
847
848    /// Checkpoint-only top-level dispatch. It remains serialized by the
849    /// writer task but never waits for the capacity lease or floor probe.
850    pub(crate) async fn send_checkpoint_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
851    where
852        R: Send + 'static,
853        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
854    {
855        self.send_top_level_bounded_inner(op, true, false).await
856    }
857
858    /// Typed VACUUM dispatch with a copy-sized metadata estimate at dequeue.
859    pub(crate) async fn send_vacuum_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
860    where
861        R: Send + 'static,
862        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
863    {
864        self.send_top_level_bounded_inner(op, false, true).await
865    }
866
867    async fn send_top_level_bounded_inner<R, F>(
868        &self,
869        op: F,
870        checkpoint_bypass: bool,
871        vacuum_copy_headroom: bool,
872    ) -> Result<R, StorageError>
873    where
874        R: Send + 'static,
875        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
876    {
877        let reply_rx = match tokio::time::timeout(
878            self.enqueue_timeout,
879            self.enqueue_inner(op, true, checkpoint_bypass, vacuum_copy_headroom),
880        )
881        .await
882        {
883            Ok(Ok(reply_rx)) => reply_rx,
884            Ok(Err(e)) => return Err(e),
885            Err(_elapsed) => {
886                let timeout_ms = self.enqueue_timeout.as_millis() as u64;
887                crate::timeout_sink::emit_queue_saturation(&self.db, timeout_ms);
888                return Err(StorageError::WriteQueueFull { timeout_ms });
889            }
890        };
891
892        reply_rx
893            .await
894            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
895    }
896
897    /// Current write-queue backlog depth: requests enqueued but not yet
898    /// accepted by the writer task's drain loop.
899    ///
900    /// Reads `mpsc::Sender::max_capacity() - capacity()`, so it is a
901    /// point-in-time snapshot racy under concurrent senders/the drain loop
902    /// draining concurrently — acceptable for a monitoring gauge (the
903    /// load/perf harness metrics read-surface), never used for any correctness
904    /// decision.
905    pub fn queue_depth(&self) -> usize {
906        self.tx.max_capacity() - self.tx.capacity()
907    }
908
909    /// The bounded channel's configured capacity
910    /// (`PoolConfig::write_queue_capacity`).
911    pub fn capacity(&self) -> usize {
912        self.tx.max_capacity()
913    }
914}
915
916/// Spawn the write-owner task (ADR-067 Component A) on the current Tokio
917/// runtime.
918///
919/// Opens a dedicated standalone writer connection independent of the pool's
920/// Mutex-guarded `writer()` connection used by unmigrated paths. That one-time
921/// infrastructure open is uncounted; every dequeued top-level request or
922/// successful `BEGIN IMMEDIATE` increments the writer-task acquisition class.
923/// Returns the cloneable [`WriterTaskHandle`] sender half. The task normally
924/// runs until every handle clone is dropped and the channel closes; a request
925/// panic, failed rollback, or poisoned connection puts it into the permanent
926/// terminal state documented below.
927///
928/// # Terminal settlement
929/// Retiring an unsettled writer, including when its blocking owner unwinds,
930/// releases the volume lease only after cleanup restored autocommit or the
931/// connection closed. If neither is possible, path, volume, and errors are
932/// reported to stderr and tracing and the pool is poisoned: that request
933/// reports [`SqliteError::WriterSettlementUnknown`] and every later write is
934/// refused with [`SqliteError::WriterPoisoned`] before it starts.
935///
936/// `capacity` bounds the channel (`PoolConfig::write_queue_capacity` /
937/// `KHIVE_WRITE_QUEUE_CAPACITY`, ADR-067 recommends 256).
938///
939/// # Errors
940/// Must be called from within a Tokio runtime context (calls
941/// `tokio::spawn`). Returns an error if the pool cannot open a standalone
942/// writer connection (e.g. an in-memory pool has no standalone-connection
943/// support). See `crates/khive-db/docs/api/writer-task.md` for the
944/// migration-slice scope this commits per `BEGIN IMMEDIATE`.
945pub fn spawn(pool: &ConnectionPool, capacity: usize) -> Result<WriterTaskHandle, SqliteError> {
946    // The lifetime connection is infrastructure, not one acquisition per
947    // write. Each dequeued request is counted below at the task's actual
948    // ownership boundary instead.
949    let conn = pool.open_standalone_writer_untracked()?;
950    let acquisition_counters = pool.writer_acquisition_counters();
951    let write_admission = pool.write_admission();
952    let busy_timeout = pool.config().busy_timeout;
953    let origin = pool.origin();
954    let backend_key = writer_db_key(pool);
955    let db = crate::timeout_sink::db_label(pool);
956    let (tx, rx) = mpsc::channel(capacity.max(1));
957    let join = tokio::spawn(run_writer_task(
958        conn,
959        rx,
960        origin,
961        db.clone(),
962        acquisition_counters,
963        write_admission,
964        busy_timeout,
965    ));
966    // Stored on the pool (not returned) so the handle's clone-and-share
967    // contract stays untouched; see ConnectionPool::take_writer_task_join
968    // for who awaits it and why.
969    pool.set_writer_task_join(join);
970    Ok(WriterTaskHandle {
971        tx,
972        backend_key,
973        db,
974        slow_write_threshold: crate::timeout_sink::slow_write_threshold(),
975        enqueue_timeout: std::time::Duration::from_millis(
976            pool.config().write_admission_deadline_ms,
977        ),
978    })
979}
980
981/// Permanently close admission, then reply to every request that was already
982/// accepted into the bounded channel without invoking its operation closure.
983///
984/// Closing before draining is load-bearing: draining an open receiver would
985/// wait forever while [`WriterTaskHandle`] clones still exist, while dropping
986/// the receiver immediately would discard buffered requests and their typed
987/// reply senders.
988async fn close_and_fail_queued_requests(rx: &mut mpsc::Receiver<Box<dyn AnyWriteRequest + Send>>) {
989    rx.close();
990    while let Some(request) = rx.recv().await {
991        request.reply_error(writer_task_terminated(WriterTaskRequestState::NotStarted));
992    }
993}
994
995/// Acquire the request transaction without multiplying its busy-timeout budget.
996/// The setter is supplied so tests can fail SQLite's timeout update while still
997/// executing real BEGIN statements; production uses `Connection::busy_timeout`.
998fn begin_immediate_with_retry(
999    conn: &Connection,
1000    acquisition_counters: &WriterAcquisitionCounters,
1001    busy_timeout: Duration,
1002    mut set_busy_timeout: impl FnMut(&Connection, Duration) -> rusqlite::Result<()>,
1003) -> (rusqlite::Result<()>, Duration, u32) {
1004    let transaction_acquire_started = Instant::now();
1005    let mut begin_attempt = 1_u32;
1006    let mut retry_delays = WRITER_BEGIN_RETRY_DELAYS.into_iter();
1007    let mut busy_timeout_lowered = false;
1008    let begin_outcome = loop {
1009        match conn.execute_batch("BEGIN IMMEDIATE") {
1010            Ok(()) => break Ok(()),
1011            Err(error) if crate::timeout_sink::is_busy_or_locked(&error) => {
1012                // Every busy/locked refusal is counted here,
1013                // whether or not it goes on to be retried: this
1014                // is the pre-PR meaning of the counter, and the
1015                // outer match below no longer counts the final
1016                // refusal a second time.
1017                acquisition_counters.record_writer_task_begin_busy();
1018                let Some(delay) = retry_delays.next() else {
1019                    break Err(error);
1020                };
1021                // The whole retry sequence shares one
1022                // `busy_timeout` budget: each SQLite attempt is
1023                // itself bounded by `busy_timeout`, so without a
1024                // shrinking budget three attempts could each
1025                // wait out a full window and multiply the
1026                // serialized writer's contention window instead
1027                // of bounding it.
1028                let remaining_budget =
1029                    busy_timeout.saturating_sub(transaction_acquire_started.elapsed());
1030                if remaining_budget.is_zero() {
1031                    break Err(error);
1032                }
1033                if let Err(set_err) = set_busy_timeout(conn, remaining_budget) {
1034                    tracing::warn!(
1035                        error = %set_err,
1036                        "writer task: failed to lower busy_timeout for BEGIN \
1037                         retry; surfacing the original busy refusal"
1038                    );
1039                    // A retry with the old timeout could exceed the shared budget.
1040                    break Err(error);
1041                }
1042                busy_timeout_lowered = true;
1043                // Count only refusals that will actually be retried. A failed
1044                // timeout reduction leaves this refusal visible to the caller.
1045                acquisition_counters.record_writer_task_begin_busy_absorbed();
1046                tracing::debug!(
1047                    attempt = begin_attempt,
1048                    backoff_ms = delay.as_millis() as u64,
1049                    budget_remaining_ms = remaining_budget.as_millis() as u64,
1050                    "writer task: BEGIN IMMEDIATE refused busy; retrying before \
1051                     request execution"
1052                );
1053                std::thread::sleep(delay);
1054                begin_attempt = begin_attempt.saturating_add(1);
1055            }
1056            Err(error) => break Err(error),
1057        }
1058    };
1059    let transaction_acquire = transaction_acquire_started.elapsed();
1060    // Restore the pool-configured busy_timeout only if a retry
1061    // actually lowered it, so the common uncontended request
1062    // never pays for an extra pragma write; the next request
1063    // dequeued on this same connection must still start from
1064    // the full configured budget.
1065    if busy_timeout_lowered {
1066        if let Err(restore_err) = set_busy_timeout(conn, busy_timeout) {
1067            tracing::warn!(
1068                error = %restore_err,
1069                "writer task: failed to restore busy_timeout after a BEGIN retry \
1070                 sequence"
1071            );
1072        }
1073    }
1074    (begin_outcome, transaction_acquire, begin_attempt)
1075}
1076
1077struct BlockingWriterConnection {
1078    conn: Option<Connection>,
1079    admission: Arc<crate::pool::WriteAdmission>,
1080    volume_lease: Option<crate::disk_guard::VolumeLease>,
1081}
1082
1083impl BlockingWriterConnection {
1084    fn new(conn: Connection, admission: Arc<crate::pool::WriteAdmission>) -> Self {
1085        Self {
1086            conn: Some(conn),
1087            admission,
1088            volume_lease: None,
1089        }
1090    }
1091
1092    fn finish(
1093        mut self,
1094        state: Option<WriterTaskRequestState>,
1095    ) -> (Option<Connection>, Option<WriterTaskRequestState>) {
1096        if state.is_some() {
1097            self.retire();
1098            (None, state)
1099        } else {
1100            (self.conn.take(), None)
1101        }
1102    }
1103
1104    fn retire(&mut self) {
1105        if let Some(conn) = self.conn.take() {
1106            // A terminal failure has poisoned the admission; the request that
1107            // retired this writer already carries its terminal state.
1108            let _ = self.admission.close_retired_connection(conn);
1109        }
1110    }
1111}
1112
1113impl Drop for BlockingWriterConnection {
1114    fn drop(&mut self) {
1115        self.retire();
1116    }
1117}
1118
1119impl std::ops::Deref for BlockingWriterConnection {
1120    type Target = Connection;
1121
1122    fn deref(&self) -> &Connection {
1123        self.conn
1124            .as_ref()
1125            .expect("blocking writer owns its connection until settlement")
1126    }
1127}
1128
1129/// Drain loop: the sole caller of `BEGIN IMMEDIATE` for write traffic routed
1130/// through the channel. Busy/locked `BEGIN IMMEDIATE` refusals receive the
1131/// bounded retry above before a final failure replies the request's error via
1132/// [`AnyWriteRequest::reply_error`] without invoking the request's closure.
1133///
1134/// A request-operation panic is contained inside the concrete
1135/// [`WriteRequest<R>`] so its typed reply survives. A panic, failed rollback,
1136/// or otherwise poisoned connection makes the task terminal. The task then
1137/// closes admission, explicitly fails every already-queued request as
1138/// [`WriterTaskRequestState::NotStarted`], and exits permanently. There is no
1139/// supervisor or connection restart; later sends observe the closed channel.
1140/// See
1141/// `crates/khive-db/docs/api/writer-task.md` for the full failure matrix.
1142async fn run_writer_task(
1143    mut conn: Connection,
1144    mut rx: mpsc::Receiver<Box<dyn AnyWriteRequest + Send>>,
1145    origin: khive_storage::tx_registry::TxOrigin,
1146    db: String,
1147    acquisition_counters: Arc<WriterAcquisitionCounters>,
1148    write_admission: Arc<crate::pool::WriteAdmission>,
1149    busy_timeout: Duration,
1150) {
1151    while let Some(request) = rx.recv().await {
1152        // The bounded-channel wait ends at this exact dequeue boundary.
1153        // Sampling inside the blocking closure would misattribute a saturated
1154        // Tokio blocking pool to writer-queue contention (#1849).
1155        let queue_wait = request.queue_wait();
1156        let origin = origin.clone();
1157        let blocking_counters = Arc::clone(&acquisition_counters);
1158        let blocking_admission = Arc::clone(&write_admission);
1159        let blocking_db = db.clone();
1160        let outcome = tokio::task::spawn_blocking(move || {
1161            let mut conn = BlockingWriterConnection::new(conn, Arc::clone(&blocking_admission));
1162            let acquisition_counters = blocking_counters;
1163            // A top-level request deliberately skips BEGIN, so it would
1164            // silently join any transaction leaked by an earlier request.
1165            // Refuse every request before dispatch if the connection is not
1166            // demonstrably clean, then retire the writer task.
1167            if !conn.is_autocommit() {
1168                tracing::error!(
1169                    "writer task: connection is not in autocommit mode before request dispatch; \
1170                     retiring the poisoned writer without running the request"
1171                );
1172                let request_state = WriterTaskRequestState::NotStarted;
1173                request.reply_error(writer_task_terminated(request_state));
1174                return conn.finish(Some(request_state));
1175            }
1176
1177            // The lease precedes SQLite writer acquisition and remains held
1178            // until the request has committed, rolled back, or returned to
1179            // autocommit. Checkpoint requests deliberately skip both steps.
1180            conn.volume_lease = if request.is_checkpoint_bypass() {
1181                None
1182            } else {
1183                match blocking_admission.acquire() {
1184                    Ok(lease) => lease,
1185                    Err(error) => {
1186                        request.reply_error(error.into_storage_error(
1187                            khive_storage::StorageCapability::Sql,
1188                            "writer_task_admission",
1189                        ));
1190                        return conn.finish(None);
1191                    }
1192                }
1193            };
1194
1195            let terminal_state = if request.is_top_level() {
1196                if !request.is_checkpoint_bypass() {
1197                    let admission = if request.needs_vacuum_headroom() {
1198                        blocking_admission.check_for_vacuum()
1199                    } else {
1200                        blocking_admission.check()
1201                    };
1202                    if let Err(error) = admission {
1203                        request.reply_error(error.into_storage_error(
1204                            khive_storage::StorageCapability::Sql,
1205                            "writer_task_admission",
1206                        ));
1207                        return conn.finish(None);
1208                    }
1209                }
1210                // ADR-067 Component A:
1211                // no BEGIN IMMEDIATE for this request — some statements
1212                // (e.g. VACUUM) are rejected by SQLite inside any open
1213                // transaction. Still runs on this task's dedicated
1214                // connection and still serialized one-request-at-a-time by
1215                // this same drain loop, so the single-writer guarantee
1216                // holds; only the transaction wrap is skipped.
1217                acquisition_counters.record_writer_task_acquisition();
1218                sealed::Sealed::execute_and_reply_top_level_reporting_terminal(
1219                    request, &conn, queue_wait,
1220                )
1221            } else {
1222                let tx_span = khive_storage::tx_registry::register_scoped(
1223                    Some("writer_task_tx".to_string()),
1224                    origin,
1225                );
1226                let (begin_outcome, transaction_acquire, begin_attempt) =
1227                    begin_immediate_with_retry(
1228                        &conn,
1229                        &acquisition_counters,
1230                        busy_timeout,
1231                        Connection::busy_timeout,
1232                    );
1233                match begin_outcome {
1234                    Ok(()) => {
1235                        if let Err(error) = blocking_admission.check() {
1236                            let request_state =
1237                                match rollback_after_failure(&conn, "capacity admission") {
1238                                    RollbackDisposition::RolledBack => None,
1239                                    RollbackDisposition::SideEffectsUnknown => {
1240                                        Some(WriterTaskRequestState::SideEffectsUnknown)
1241                                    }
1242                                };
1243                            drop(tx_span);
1244                            let error = if let Some(state) = request_state {
1245                                writer_task_terminated(state)
1246                            } else {
1247                                error.into_storage_error(
1248                                    khive_storage::StorageCapability::Sql,
1249                                    "writer_task_admission",
1250                                )
1251                            };
1252                            sealed::Sealed::reply_error_after_begin(
1253                                request,
1254                                error,
1255                                queue_wait,
1256                                transaction_acquire,
1257                            );
1258                            return conn.finish(request_state);
1259                        }
1260                        acquisition_counters.record_writer_task_acquisition();
1261                        sealed::Sealed::execute_and_reply_reporting_terminal(
1262                            request,
1263                            &conn,
1264                            Some(tx_span),
1265                            queue_wait,
1266                            transaction_acquire,
1267                        )
1268                    }
1269                    Err(e) => {
1270                        // Do NOT run the request's operation: `conn` never
1271                        // entered a transaction, so executing the op's DML
1272                        // here would run in autocommit mode and land partial
1273                        // writes for a request the caller is about to be told
1274                        // failed.
1275                        crate::timeout_sink::maybe_emit_sqlite_full(&blocking_db, &e);
1276                        tracing::warn!(
1277                            error = %e,
1278                            attempts = begin_attempt,
1279                            "writer task: BEGIN IMMEDIATE failed; replying an \
1280                             error without running the request's operation"
1281                        );
1282                        // Although BEGIN never opened a SQL transaction, the
1283                        // scoped attempt is observable in tx_registry. Drop
1284                        // it before waking the caller for the same reply-side
1285                        // lifecycle guarantee as successful requests (#1790).
1286                        drop(tx_span);
1287                        // A busy/locked refusal was already counted inside
1288                        // the retry loop above, including this final one —
1289                        // classifying and counting it again here would
1290                        // double it. Only a non-busy BEGIN failure (which
1291                        // never enters the retry loop's busy arm) still
1292                        // needs its counter recorded at this seam.
1293                        let begin_error = writer_task_begin_error(e, busy_timeout);
1294                        if !matches!(&begin_error, StorageError::WriterTaskBusy { .. }) {
1295                            acquisition_counters.record_writer_task_begin_error();
1296                        }
1297                        sealed::Sealed::reply_error_after_begin(
1298                            request,
1299                            begin_error,
1300                            queue_wait,
1301                            transaction_acquire,
1302                        );
1303                        None
1304                    }
1305                }
1306            };
1307            conn.finish(terminal_state)
1308        })
1309        .await;
1310
1311        match outcome {
1312            Ok((Some(returned_conn), None)) => conn = returned_conn,
1313            Ok((_returned_conn, Some(request_state))) => {
1314                acquisition_counters.record_writer_task_request_failure();
1315                if request_state == WriterTaskRequestState::SideEffectsUnknown {
1316                    acquisition_counters.record_writer_task_side_effects_unknown();
1317                }
1318                tracing::error!(
1319                    request_state = %request_state,
1320                    "writer task reached a terminal request or connection state; closing and \
1321                     failing the queue without restarting"
1322                );
1323                crate::timeout_sink::emit_writer_task_retirement(
1324                    &db,
1325                    &format!("terminal request state: {request_state}"),
1326                );
1327                close_and_fail_queued_requests(&mut rx).await;
1328                return;
1329            }
1330            Ok((None, None)) => unreachable!("a reusable writer returns its connection"),
1331            Err(join_err) => {
1332                acquisition_counters.record_writer_task_request_failure();
1333                tracing::error!(
1334                    error = %join_err,
1335                    "writer task blocking closure failed outside the request \
1336                     panic boundary; closing and failing the queue without restarting"
1337                );
1338                crate::timeout_sink::emit_writer_task_retirement(
1339                    &db,
1340                    &format!("blocking closure join failure: {join_err}"),
1341                );
1342                close_and_fail_queued_requests(&mut rx).await;
1343                return;
1344            }
1345        }
1346    }
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352    use crate::pool::PoolConfig;
1353    use rusqlite::hooks::{AuthAction, AuthContext, Authorization, TransactionOperation};
1354    use serial_test::serial;
1355
1356    #[test]
1357    fn begin_error_classification_is_code_based_and_narrow() {
1358        for code in [rusqlite::ffi::SQLITE_BUSY, rusqlite::ffi::SQLITE_LOCKED] {
1359            let error = rusqlite::Error::SqliteFailure(
1360                rusqlite::ffi::Error::new(code),
1361                Some("rendered text is irrelevant".to_string()),
1362            );
1363            assert!(matches!(
1364                writer_task_begin_error(error, Duration::from_millis(175)),
1365                StorageError::WriterTaskBusy { timeout_ms: 175 }
1366            ));
1367        }
1368
1369        let structural = rusqlite::Error::SqliteFailure(
1370            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
1371            Some("database is locked".to_string()),
1372        );
1373        assert!(matches!(
1374            writer_task_begin_error(structural, Duration::from_millis(175)),
1375            StorageError::Pool { ref operation, .. } if operation == "writer_task_begin"
1376        ));
1377    }
1378    use std::future::Future;
1379    use std::pin::Pin;
1380    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1381    use std::sync::mpsc as std_mpsc;
1382    use std::sync::{Arc, Mutex};
1383    use std::task::{Context, Poll, Wake, Waker};
1384    use std::time::Duration;
1385
1386    fn file_pool(path: &std::path::Path) -> ConnectionPool {
1387        let cfg = PoolConfig {
1388            path: Some(path.to_path_buf()),
1389            ..PoolConfig::for_test()
1390        };
1391        ConnectionPool::new(cfg).expect("pool open")
1392    }
1393
1394    fn deny_commit_and_rollback(ctx: AuthContext<'_>) -> Authorization {
1395        match ctx.action {
1396            // SQLite reports COMMIT as the non-exhaustive Unknown transaction
1397            // operation in rusqlite 0.40; ROLLBACK has its own variant.
1398            AuthAction::Transaction {
1399                operation: TransactionOperation::Unknown | TransactionOperation::Rollback,
1400            } => Authorization::Deny,
1401            _ => Authorization::Allow,
1402        }
1403    }
1404
1405    fn deny_commit(ctx: AuthContext<'_>) -> Authorization {
1406        match ctx.action {
1407            AuthAction::Transaction {
1408                operation: TransactionOperation::Unknown,
1409            } => Authorization::Deny,
1410            _ => Authorization::Allow,
1411        }
1412    }
1413
1414    fn deny_rollback(ctx: AuthContext<'_>) -> Authorization {
1415        match ctx.action {
1416            AuthAction::Transaction {
1417                operation: TransactionOperation::Rollback,
1418            } => Authorization::Deny,
1419            _ => Authorization::Allow,
1420        }
1421    }
1422
1423    fn assert_writer_task_terminal_state<T: std::fmt::Debug>(
1424        result: Result<T, StorageError>,
1425        expected: WriterTaskRequestState,
1426    ) {
1427        match result {
1428            Err(StorageError::WriterTaskTerminated { request_state }) => {
1429                assert_eq!(request_state, expected)
1430            }
1431            other => panic!("expected WriterTaskTerminated({expected:?}), got {other:?}"),
1432        }
1433    }
1434
1435    struct ParkedWake {
1436        entered: std_mpsc::SyncSender<()>,
1437        release: Mutex<std_mpsc::Receiver<()>>,
1438    }
1439
1440    impl Wake for ParkedWake {
1441        fn wake(self: Arc<Self>) {
1442            self.entered
1443                .send(())
1444                .expect("reply sender must rendezvous with the test");
1445            self.release
1446                .lock()
1447                .unwrap_or_else(|poisoned| poisoned.into_inner())
1448                .recv()
1449                .expect("test must release the parked reply sender");
1450        }
1451    }
1452
1453    fn arm_parked_wake<F: Future>(
1454        mut future: Pin<&mut F>,
1455    ) -> (std_mpsc::Receiver<()>, std_mpsc::Sender<()>) {
1456        let (entered_tx, entered_rx) = std_mpsc::sync_channel(0);
1457        let (release_tx, release_rx) = std_mpsc::channel();
1458        let waker = Waker::from(Arc::new(ParkedWake {
1459            entered: entered_tx,
1460            release: Mutex::new(release_rx),
1461        }));
1462        let mut context = Context::from_waker(&waker);
1463        assert!(
1464            matches!(future.as_mut().poll(&mut context), Poll::Pending),
1465            "writer send must remain pending until its operation replies"
1466        );
1467        (entered_rx, release_tx)
1468    }
1469
1470    fn poll_ready<F: Future>(mut future: Pin<&mut F>) -> F::Output {
1471        let mut context = Context::from_waker(Waker::noop());
1472        match future.as_mut().poll(&mut context) {
1473            Poll::Ready(output) => output,
1474            Poll::Pending => panic!("reply wake must make the writer send ready"),
1475        }
1476    }
1477
1478    fn database_tx_view(pool: &ConnectionPool) -> khive_storage::tx_registry::TxOriginFilter {
1479        match pool.origin() {
1480            khive_storage::tx_registry::TxOrigin::Database(identity) => {
1481                khive_storage::tx_registry::TxOriginFilter::Secondary(identity)
1482            }
1483            other => panic!("expected a file-backed database origin, got {other:?}"),
1484        }
1485    }
1486
1487    async fn wait_for_writer_span_to_close(view: &khive_storage::tx_registry::TxOriginFilter) {
1488        tokio::time::timeout(Duration::from_secs(5), async {
1489            while khive_storage::tx_registry::any_open_labeled(view, "writer_task_tx") {
1490                tokio::task::yield_now().await;
1491            }
1492        })
1493        .await
1494        .expect("writer task transaction span must eventually close");
1495    }
1496
1497    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1498    #[serial(tx_registry)]
1499    async fn writer_task_connection_maintains_rfc3339_expression_indexes() {
1500        let dir = tempfile::tempdir().unwrap();
1501        let path = dir.path().join("writer_task_rfc3339_expression_index.db");
1502        let pool = file_pool(&path);
1503        {
1504            let writer = pool.writer().expect("pooled writer");
1505            writer
1506                .conn()
1507                .execute_batch(
1508                    "CREATE TABLE deadlines(id INTEGER PRIMARY KEY, due TEXT);
1509                     CREATE INDEX idx_deadlines_strict \
1510                         ON deadlines(ifnull(khive_rfc3339_strict_key(due), x''));",
1511                )
1512                .expect("pooled writer registers the key function");
1513        }
1514        let handle = spawn(&pool, 8).expect("writer task spawn");
1515
1516        let inserted = handle
1517            .send(|conn| {
1518                conn.execute(
1519                    "INSERT INTO deadlines(id, due) VALUES (1, '2026-01-01T00:00:00Z')",
1520                    [],
1521                )
1522                .map_err(|error| StorageError::Pool {
1523                    operation: "test_insert".into(),
1524                    message: error.to_string(),
1525                })
1526            })
1527            .await
1528            .expect("the writer task's connection maintains the expression index");
1529        assert_eq!(inserted, 1);
1530    }
1531
1532    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1533    #[serial(tx_registry)]
1534    async fn successful_send_reply_waits_for_writer_tx_deregistration() {
1535        let dir = tempfile::tempdir().unwrap();
1536        let path = dir.path().join("writer_task_success_reply_lifecycle.db");
1537        let pool = file_pool(&path);
1538        let view = database_tx_view(&pool);
1539        let handle = spawn(&pool, 8).expect("writer task spawn");
1540        let (op_started_tx, op_started_rx) = std_mpsc::sync_channel(0);
1541        let (op_release_tx, op_release_rx) = std_mpsc::channel();
1542
1543        let send = handle.send(move |_conn| {
1544            op_started_tx
1545                .send(())
1546                .expect("operation must rendezvous with the test");
1547            op_release_rx
1548                .recv()
1549                .expect("test must release the operation");
1550            Ok::<_, StorageError>(())
1551        });
1552        tokio::pin!(send);
1553        let (reply_entered_rx, reply_release_tx) = arm_parked_wake(send.as_mut());
1554
1555        op_started_rx
1556            .recv_timeout(Duration::from_secs(5))
1557            .expect("writer operation must start");
1558        op_release_tx.send(()).expect("release writer operation");
1559        reply_entered_rx
1560            .recv_timeout(Duration::from_secs(5))
1561            .expect("reply sender must wake the waiting caller");
1562
1563        let reply = poll_ready(send.as_mut());
1564        let span_was_open_at_reply =
1565            khive_storage::tx_registry::any_open_labeled(&view, "writer_task_tx");
1566
1567        reply_release_tx
1568            .send(())
1569            .expect("release parked reply sender");
1570        wait_for_writer_span_to_close(&view).await;
1571
1572        reply.expect("committed operation reply");
1573        assert!(
1574            !span_was_open_at_reply,
1575            "a successful caller reply must not become observable while its committed writer_task_tx span remains registered"
1576        );
1577    }
1578
1579    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1580    #[serial(tx_registry)]
1581    async fn begin_failure_reply_waits_for_writer_tx_deregistration() {
1582        let dir = tempfile::tempdir().unwrap();
1583        let path = dir.path().join("writer_task_begin_reply_lifecycle.db");
1584        // A lock directory of its own keeps this deliberate writer hold off the
1585        // volume lease the other tests in this process share.
1586        let cfg = PoolConfig {
1587            path: Some(path.clone()),
1588            volume_lock_dir: Some(dir.path().join("volume-locks")),
1589            busy_timeout: Duration::from_millis(150),
1590            ..PoolConfig::for_test()
1591        };
1592        let pool = ConnectionPool::new(cfg).unwrap();
1593        let view = database_tx_view(&pool);
1594        let handle = spawn(&pool, 8).expect("writer task spawn");
1595        // An independent SQLite writer does not hold khive's cooperative
1596        // volume lease, so this reaches the intended BEGIN-busy branch.
1597        let lock_holder = rusqlite::Connection::open(&path).expect("external writer");
1598        lock_holder
1599            .execute_batch("BEGIN IMMEDIATE")
1600            .expect("hold database write lock");
1601        let op_ran = Arc::new(AtomicBool::new(false));
1602        let op_ran_in_request = Arc::clone(&op_ran);
1603
1604        let send = handle.send(move |_conn| {
1605            op_ran_in_request.store(true, Ordering::SeqCst);
1606            Ok::<_, StorageError>(())
1607        });
1608        tokio::pin!(send);
1609        let (reply_entered_rx, reply_release_tx) = arm_parked_wake(send.as_mut());
1610        reply_entered_rx
1611            .recv_timeout(Duration::from_secs(5))
1612            .expect("BEGIN failure must wake the waiting caller");
1613
1614        let reply = poll_ready(send.as_mut());
1615        let span_was_open_at_reply =
1616            khive_storage::tx_registry::any_open_labeled(&view, "writer_task_tx");
1617
1618        reply_release_tx
1619            .send(())
1620            .expect("release parked reply sender");
1621        wait_for_writer_span_to_close(&view).await;
1622        lock_holder
1623            .execute_batch("ROLLBACK")
1624            .expect("release database write lock");
1625
1626        assert!(
1627            matches!(
1628                &reply,
1629                Err(StorageError::WriterTaskBusy { timeout_ms }) if *timeout_ms == 150
1630            ),
1631            "expected typed retryable writer-task contention, got {reply:?}"
1632        );
1633        assert!(!op_ran.load(Ordering::SeqCst));
1634        assert!(
1635            !span_was_open_at_reply,
1636            "a BEGIN-failure caller reply must not become observable while its writer_task_tx span remains registered"
1637        );
1638    }
1639
1640    // `#[serial(tx_registry)]`: `run_writer_task` registers a `writer_task_tx`
1641    // handle in the process-wide `tx_registry` singleton for the life of each
1642    // `BEGIN IMMEDIATE`. Tests that observe the registry (the checkpoint
1643    // `tx_age_sweep_*` group) read `tx_registry::oldest()`; an un-serialized
1644    // spawning test here would leak a longer-lived `writer_task_tx` into that
1645    // read and make the sweep name the wrong transaction. Share the key.
1646    #[tokio::test]
1647    #[serial(tx_registry)]
1648    async fn begin_immediate_failure_replies_error_without_running_op() {
1649        // Real lock contention, not a simulation: an independent SQLite
1650        // connection holds the database write lock without taking khive's
1651        // cooperative volume lease. The writer task therefore reaches its
1652        // dedicated connection's `BEGIN IMMEDIATE` and receives SQLITE_BUSY.
1653        let dir = tempfile::tempdir().unwrap();
1654        let path = dir.path().join("writer_task_begin_failure.db");
1655        // A lock directory of its own keeps this deliberate writer hold off the
1656        // volume lease the other tests in this process share.
1657        let cfg = PoolConfig {
1658            path: Some(path.clone()),
1659            volume_lock_dir: Some(dir.path().join("volume-locks")),
1660            busy_timeout: Duration::from_millis(150),
1661            ..PoolConfig::for_test()
1662        };
1663        let pool = ConnectionPool::new(cfg).unwrap();
1664        {
1665            let writer = pool.try_writer().unwrap();
1666            writer
1667                .conn()
1668                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
1669                .unwrap();
1670        }
1671
1672        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
1673
1674        let lock_holder = rusqlite::Connection::open(&path).unwrap();
1675        lock_holder.execute_batch("BEGIN IMMEDIATE").unwrap();
1676
1677        let op_ran = Arc::new(AtomicBool::new(false));
1678        let op_ran_clone = Arc::clone(&op_ran);
1679        let result = handle
1680            .send(move |conn| {
1681                op_ran_clone.store(true, Ordering::SeqCst);
1682                conn.execute("INSERT INTO t (id, v) VALUES (99, 'should-not-land')", [])
1683                    .map_err(|e| StorageError::Pool {
1684                        operation: "test_insert".into(),
1685                        message: e.to_string(),
1686                    })
1687            })
1688            .await;
1689
1690        assert!(
1691            matches!(
1692                &result,
1693                Err(StorageError::WriterTaskBusy { timeout_ms }) if *timeout_ms == 150
1694            ),
1695            "expected a typed retryable error on contended BEGIN IMMEDIATE, got {result:?}"
1696        );
1697        assert!(
1698            !op_ran.load(Ordering::SeqCst),
1699            "the request's operation closure must never run when BEGIN \
1700             IMMEDIATE fails — running it would land a partial write in \
1701             autocommit mode for a request the caller is told failed"
1702        );
1703
1704        // Release the contended lock, then verify no row landed from the
1705        // failed request.
1706        lock_holder.execute_batch("ROLLBACK").unwrap();
1707        drop(lock_holder);
1708
1709        handle
1710            .send(|conn| {
1711                conn.execute("INSERT INTO t (id, v) VALUES (100, 'next-request')", [])
1712                    .map_err(|e| StorageError::Pool {
1713                        operation: "test_insert_after_busy".into(),
1714                        message: e.to_string(),
1715                    })
1716            })
1717            .await
1718            .expect("transient contention must not retire the writer task");
1719
1720        let reader = pool.reader().expect("reader");
1721        let count: i64 = reader
1722            .conn()
1723            .query_row("SELECT COUNT(*) FROM t WHERE id IN (99, 100)", [], |row| {
1724                row.get(0)
1725            })
1726            .unwrap();
1727        assert_eq!(
1728            count, 1,
1729            "the failed request must not land, while the next request commits on the same task"
1730        );
1731    }
1732
1733    // `#[serial(tx_registry)]`: same rationale as
1734    // `begin_immediate_failure_replies_error_without_running_op`.
1735    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1736    #[serial(tx_registry)]
1737    async fn transient_begin_contention_clears_within_budget_and_op_runs_once() {
1738        // Hold a real SQLite write lock for a fixed, short delay — well
1739        // inside the configured busy_timeout — so SQLite's own internal
1740        // busy-handler wait absorbs the contention transparently within a
1741        // single `BEGIN IMMEDIATE` call. This is the common case the
1742        // shared retry budget (exercised by
1743        // `begin_retry_budget_makes_exactly_one_attempt_under_sustained_contention`
1744        // below) must not regress: recovery inside one busy_timeout window
1745        // should never touch the Rust-level retry loop or its counters.
1746        let dir = tempfile::tempdir().unwrap();
1747        let path = dir.path().join("writer_task_begin_transient_contention.db");
1748        // A lock directory of its own keeps this deliberate writer hold off the
1749        // volume lease the other tests in this process share.
1750        let pool = ConnectionPool::new(PoolConfig {
1751            path: Some(path.clone()),
1752            volume_lock_dir: Some(dir.path().join("volume-locks")),
1753            busy_timeout: Duration::from_millis(500),
1754            ..PoolConfig::for_test()
1755        })
1756        .unwrap();
1757        {
1758            let writer = pool.try_writer().unwrap();
1759            writer
1760                .conn()
1761                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY)")
1762                .unwrap();
1763        }
1764        let handle = spawn(&pool, 8).expect("writer task spawn");
1765        // A second khive writer would contend on the cooperative volume lease
1766        // before SQLite. This fixture needs an independent SQLite writer so it
1767        // exercises the intended BEGIN busy-handler path.
1768        let lock_holder = Connection::open(&path).unwrap();
1769        lock_holder.execute_batch("BEGIN IMMEDIATE").unwrap();
1770
1771        let op_runs = Arc::new(AtomicUsize::new(0));
1772        let op_runs_in_request = Arc::clone(&op_runs);
1773        let send_future = handle.send(move |conn| {
1774            op_runs_in_request.fetch_add(1, Ordering::SeqCst);
1775            conn.execute("INSERT INTO t (id) VALUES (1)", [])
1776                .map_err(|error| StorageError::Pool {
1777                    operation: "test_insert_after_transient_contention".into(),
1778                    message: error.to_string(),
1779                })
1780        });
1781        let release_future = async {
1782            tokio::time::sleep(Duration::from_millis(50)).await;
1783            lock_holder.execute_batch("ROLLBACK").unwrap();
1784        };
1785        let (result, ()) = tokio::join!(send_future, release_future);
1786
1787        assert_eq!(
1788            result.expect("BEGIN IMMEDIATE succeeds once the transient lock clears"),
1789            1
1790        );
1791        assert_eq!(
1792            op_runs.load(Ordering::SeqCst),
1793            1,
1794            "the FnOnce request closure must execute exactly once"
1795        );
1796
1797        let settled = pool.writer_acquisition_snapshot();
1798        assert_eq!(
1799            settled.writer_task_begin_busy, 0,
1800            "contention absorbed inside SQLite's own busy_timeout wait must never \
1801             surface as a Rust-level refusal"
1802        );
1803        assert_eq!(settled.writer_task_begin_busy_absorbed, 0);
1804        let reader = pool.reader().unwrap();
1805        let rows: i64 = reader
1806            .conn()
1807            .query_row("SELECT COUNT(*) FROM t", [], |row| row.get(0))
1808            .unwrap();
1809        assert_eq!(rows, 1, "exactly one closure execution commits one row");
1810    }
1811
1812    include!("writer_task_admission_retry_tests.rs");
1813
1814    // `#[serial(tx_registry)]`: shares the key with the checkpoint
1815    // `tx_age_sweep_*` tests — see the note on
1816    // `begin_immediate_failure_replies_error_without_running_op`.
1817    #[tokio::test]
1818    #[serial(tx_registry)]
1819    async fn writer_task_executes_op_and_commits() {
1820        let dir = tempfile::tempdir().unwrap();
1821        let path = dir.path().join("writer_task_commit.db");
1822        let pool = file_pool(&path);
1823        {
1824            let writer = pool.try_writer().unwrap();
1825            writer
1826                .conn()
1827                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
1828                .unwrap();
1829        }
1830
1831        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
1832
1833        let affected = handle
1834            .send(|conn| {
1835                conn.execute("INSERT INTO t (id, v) VALUES (1, 'hello')", [])
1836                    .map_err(|e| StorageError::Pool {
1837                        operation: "test_insert".into(),
1838                        message: e.to_string(),
1839                    })
1840            })
1841            .await
1842            .expect("op should succeed");
1843        assert_eq!(affected, 1);
1844
1845        // Verify the write actually committed to the shared file — read it
1846        // back via a fresh pooled reader connection, not the writer task's
1847        // own connection.
1848        let reader = pool.reader().expect("reader");
1849        let v: String = reader
1850            .conn()
1851            .query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))
1852            .expect("row must be committed and visible to a reader");
1853        assert_eq!(v, "hello");
1854
1855        let counters = pool.writer_acquisition_snapshot();
1856        assert_eq!(counters.acquisitions, 2);
1857        assert_eq!(counters.pooled_acquisitions, 1);
1858        assert_eq!(counters.standalone_acquisitions, 0);
1859        assert_eq!(counters.writer_task_acquisitions, 1);
1860        assert_eq!(counters.timeouts, 0);
1861    }
1862
1863    #[tokio::test]
1864    async fn writer_task_connection_follows_checkpoint_ownership_claim() {
1865        let dir = tempfile::tempdir().unwrap();
1866        let path = dir.path().join("writer_task_autocheckpoint.db");
1867        let pool = file_pool(&path);
1868        // The pool-registered handle (not a directly spawned side task):
1869        // `propagate_checkpoint_claim_to_writer_task` reaches exactly this
1870        // task's connection.
1871        let handle = pool
1872            .writer_task_handle()
1873            .expect("writer task should spawn")
1874            .expect("file-backed pool resolves the write queue on");
1875
1876        let read_pages = |handle: &WriterTaskHandle| {
1877            let handle = handle.clone();
1878            async move {
1879                handle
1880                    .send_top_level(|conn| {
1881                        conn.pragma_query_value(None, "wal_autocheckpoint", |row| {
1882                            row.get::<_, u32>(0)
1883                        })
1884                        .map_err(|e| StorageError::Pool {
1885                            operation: "test_wal_autocheckpoint".into(),
1886                            message: e.to_string(),
1887                        })
1888                    })
1889                    .await
1890                    .expect("query writer-task connection pragma")
1891            }
1892        };
1893
1894        // Spawned before any claim: the task's long-lived connection keeps
1895        // the bounded fallback.
1896        assert_eq!(
1897            read_pages(&handle).await,
1898            crate::pool::FALLBACK_WAL_AUTOCHECKPOINT_PAGES
1899        );
1900
1901        // After the claim propagates, the same connection is flipped to the
1902        // dedicated-owner setting without reopening.
1903        pool.claim_checkpoint_ownership().expect("claim ownership");
1904        pool.propagate_checkpoint_claim_to_writer_task()
1905            .await
1906            .expect("propagate claim to the running writer task");
1907        assert_eq!(read_pages(&handle).await, 0);
1908    }
1909
1910    #[test]
1911    fn spawn_fails_on_in_memory_pool() {
1912        // In-memory pools have no standalone-connection support
1913        // (the infrastructure-only standalone open) — `spawn` must surface
1914        // that as an error rather than panicking. Deliberately a plain
1915        // `#[test]` (no Tokio runtime): `spawn` fails before it ever reaches
1916        // `tokio::spawn`, so no runtime is required for this path.
1917        let cfg = PoolConfig {
1918            path: None,
1919            ..PoolConfig::default()
1920        };
1921        let pool = ConnectionPool::new(cfg).unwrap();
1922        let result = spawn(&pool, 8);
1923        assert!(
1924            result.is_err(),
1925            "in-memory pools must reject spawn, not panic"
1926        );
1927    }
1928
1929    include!("writer_task_queue_capacity_tests.rs");
1930
1931    #[tokio::test]
1932    #[serial(tx_registry)]
1933    async fn operation_failure_with_successful_rollback_reports_finality_once_and_continues() {
1934        let dir = tempfile::tempdir().unwrap();
1935        let path = dir.path().join("writer_task_operation_rollback.db");
1936        let pool = file_pool(&path);
1937        {
1938            let writer = pool.try_writer().unwrap();
1939            writer
1940                .conn()
1941                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
1942                .unwrap();
1943        }
1944        let handle = spawn(&pool, 8).expect("writer task spawn");
1945
1946        let executions = Arc::new(AtomicUsize::new(0));
1947        let executions_in_op = Arc::clone(&executions);
1948        let original_error = handle
1949            .send(move |conn| -> Result<(), StorageError> {
1950                executions_in_op.fetch_add(1, Ordering::SeqCst);
1951                conn.execute("INSERT INTO t (id, v) VALUES (1, 'rolled-back')", [])
1952                    .map_err(|e| StorageError::Pool {
1953                        operation: "test_operation_error_insert".into(),
1954                        message: e.to_string(),
1955                    })?;
1956                Err(StorageError::Internal(
1957                    "intentional operation failure".into(),
1958                ))
1959            })
1960            .await;
1961        match &original_error {
1962            Err(StorageError::WriterTaskRequestFailed {
1963                request_state: WriterTaskRequestState::TransactionRolledBack,
1964                source,
1965            }) => assert!(
1966                matches!(source.as_ref(), StorageError::Internal(message)
1967                    if message == "intentional operation failure"),
1968                "the proven-rollback wrapper must retain the typed operation error: {source:?}"
1969            ),
1970            other => panic!(
1971                "a confirmed rollback must carry TransactionRolledBack and preserve the operation error, got {other:?}"
1972            ),
1973        }
1974        assert_eq!(
1975            executions.load(Ordering::SeqCst),
1976            1,
1977            "finality propagation must not replay the request closure"
1978        );
1979
1980        let affected = handle
1981            .send(|conn| {
1982                conn.execute("INSERT INTO t (id, v) VALUES (2, 'committed')", [])
1983                    .map_err(|e| StorageError::Pool {
1984                        operation: "test_operation_error_followup_insert".into(),
1985                        message: e.to_string(),
1986                    })
1987            })
1988            .await
1989            .expect("the writer must continue after a confirmed rollback");
1990        assert_eq!(affected, 1);
1991        assert_eq!(
1992            executions.load(Ordering::SeqCst),
1993            1,
1994            "serving a follow-up request must not replay the rolled-back closure"
1995        );
1996
1997        let reader = pool.reader().expect("reader");
1998        let rolled_back: i64 = reader
1999            .conn()
2000            .query_row("SELECT COUNT(*) FROM t WHERE id = 1", [], |row| row.get(0))
2001            .unwrap();
2002        let committed: i64 = reader
2003            .conn()
2004            .query_row("SELECT COUNT(*) FROM t WHERE id = 2", [], |row| row.get(0))
2005            .unwrap();
2006        assert_eq!(rolled_back, 0);
2007        assert_eq!(committed, 1);
2008    }
2009
2010    #[tokio::test]
2011    #[serial(tx_registry)]
2012    async fn commit_failure_with_successful_rollback_reports_finality_once_and_continues() {
2013        let dir = tempfile::tempdir().unwrap();
2014        let path = dir.path().join("writer_task_commit_rollback.db");
2015        let pool = file_pool(&path);
2016        {
2017            let writer = pool.try_writer().unwrap();
2018            writer
2019                .conn()
2020                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
2021                .unwrap();
2022        }
2023        let handle = spawn(&pool, 8).expect("writer task spawn");
2024
2025        let executions = Arc::new(AtomicUsize::new(0));
2026        let executions_in_op = Arc::clone(&executions);
2027        let commit_error = handle
2028            .send(move |conn| -> Result<usize, StorageError> {
2029                executions_in_op.fetch_add(1, Ordering::SeqCst);
2030                let affected = conn
2031                    .execute("INSERT INTO t (id, v) VALUES (1, 'rolled-back')", [])
2032                    .map_err(|e| StorageError::Pool {
2033                        operation: "test_commit_error_insert".into(),
2034                        message: e.to_string(),
2035                    })?;
2036                conn.authorizer(Some(deny_commit))
2037                    .map_err(|e| StorageError::Pool {
2038                        operation: "test_install_authorizer".into(),
2039                        message: e.to_string(),
2040                    })?;
2041                Ok(affected)
2042            })
2043            .await;
2044        match &commit_error {
2045            Err(StorageError::WriterTaskRequestFailed {
2046                request_state: WriterTaskRequestState::TransactionRolledBack,
2047                source,
2048            }) => assert!(
2049                matches!(source.as_ref(), StorageError::Pool { operation, .. }
2050                    if operation == "writer_task_commit"),
2051                "the proven-rollback wrapper must retain the typed COMMIT error: {source:?}"
2052            ),
2053            other => panic!(
2054                "a confirmed rollback must carry TransactionRolledBack and preserve the COMMIT error, got {other:?}"
2055            ),
2056        }
2057        assert!(
2058            commit_error
2059                .as_ref()
2060                .expect_err("COMMIT must be denied")
2061                .is_retryable(),
2062            "the existing retryable commit-error contract must remain unchanged after a \
2063             confirmed rollback"
2064        );
2065        assert_eq!(
2066            executions.load(Ordering::SeqCst),
2067            1,
2068            "finality propagation must not replay the request closure"
2069        );
2070
2071        let affected = handle
2072            .send(|conn| {
2073                conn.authorizer(None::<fn(AuthContext<'_>) -> Authorization>)
2074                    .map_err(|e| StorageError::Pool {
2075                        operation: "test_remove_authorizer".into(),
2076                        message: e.to_string(),
2077                    })?;
2078                conn.execute("INSERT INTO t (id, v) VALUES (2, 'committed')", [])
2079                    .map_err(|e| StorageError::Pool {
2080                        operation: "test_commit_error_followup_insert".into(),
2081                        message: e.to_string(),
2082                    })
2083            })
2084            .await
2085            .expect("the writer must continue after the failed COMMIT is rolled back");
2086        assert_eq!(affected, 1);
2087        assert_eq!(
2088            executions.load(Ordering::SeqCst),
2089            1,
2090            "serving a follow-up request must not replay the rolled-back closure"
2091        );
2092
2093        let reader = pool.reader().expect("reader");
2094        let rolled_back: i64 = reader
2095            .conn()
2096            .query_row("SELECT COUNT(*) FROM t WHERE id = 1", [], |row| row.get(0))
2097            .unwrap();
2098        let committed: i64 = reader
2099            .conn()
2100            .query_row("SELECT COUNT(*) FROM t WHERE id = 2", [], |row| row.get(0))
2101            .unwrap();
2102        assert_eq!(rolled_back, 0);
2103        assert_eq!(committed, 1);
2104    }
2105
2106    #[test]
2107    fn top_level_request_returning_with_open_transaction_reports_side_effects_unknown() {
2108        let conn = Connection::open_in_memory().expect("in-memory connection");
2109        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY)")
2110            .unwrap();
2111        let (reply_tx, mut reply_rx) = oneshot::channel();
2112        let request = WriteRequest {
2113            op: Box::new(|conn| -> Result<usize, StorageError> {
2114                conn.execute_batch("BEGIN IMMEDIATE")
2115                    .map_err(|e| StorageError::Pool {
2116                        operation: "test_top_level_begin".into(),
2117                        message: e.to_string(),
2118                    })?;
2119                conn.execute("INSERT INTO t (id) VALUES (1)", [])
2120                    .map_err(|e| StorageError::Pool {
2121                        operation: "test_top_level_insert".into(),
2122                        message: e.to_string(),
2123                    })
2124            }),
2125            reply: reply_tx,
2126            top_level: true,
2127            checkpoint_bypass: false,
2128            vacuum_copy_headroom: false,
2129            telemetry: WriteTelemetry::new(None, "test".to_string(), 0, None),
2130        };
2131
2132        let terminal_state = sealed::Sealed::execute_and_reply_top_level_reporting_terminal(
2133            Box::new(request),
2134            &conn,
2135            Duration::ZERO,
2136        );
2137        assert_eq!(
2138            terminal_state,
2139            Some(WriterTaskRequestState::SideEffectsUnknown)
2140        );
2141        let reply = reply_rx
2142            .try_recv()
2143            .expect("active request must receive a typed terminal reply");
2144        assert_writer_task_terminal_state(reply, WriterTaskRequestState::SideEffectsUnknown);
2145        assert!(
2146            !conn.is_autocommit(),
2147            "the fixture must prove the post-request autocommit check observed an open transaction"
2148        );
2149    }
2150
2151    #[test]
2152    fn commit_failure_with_failed_rollback_reports_side_effects_unknown() {
2153        let conn = Connection::open_in_memory().expect("in-memory connection");
2154        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY); BEGIN IMMEDIATE")
2155            .unwrap();
2156        let executions = Arc::new(AtomicUsize::new(0));
2157        let executions_in_op = Arc::clone(&executions);
2158        let (reply_tx, mut reply_rx) = oneshot::channel();
2159        let request = WriteRequest {
2160            op: Box::new(move |conn| -> Result<usize, StorageError> {
2161                executions_in_op.fetch_add(1, Ordering::SeqCst);
2162                let affected = conn
2163                    .execute("INSERT INTO t (id) VALUES (1)", [])
2164                    .map_err(|e| StorageError::Pool {
2165                        operation: "test_insert_before_commit_failure".into(),
2166                        message: e.to_string(),
2167                    })?;
2168                conn.authorizer(Some(deny_commit_and_rollback))
2169                    .map_err(|e| StorageError::Pool {
2170                        operation: "test_install_authorizer".into(),
2171                        message: e.to_string(),
2172                    })?;
2173                Ok(affected)
2174            }),
2175            reply: reply_tx,
2176            top_level: false,
2177            checkpoint_bypass: false,
2178            vacuum_copy_headroom: false,
2179            telemetry: WriteTelemetry::new(None, "test".to_string(), 0, None),
2180        };
2181
2182        let terminal_state = sealed::Sealed::execute_and_reply_reporting_terminal(
2183            Box::new(request),
2184            &conn,
2185            None,
2186            Duration::ZERO,
2187            Duration::ZERO,
2188        );
2189        assert_eq!(
2190            terminal_state,
2191            Some(WriterTaskRequestState::SideEffectsUnknown)
2192        );
2193        let reply = reply_rx
2194            .try_recv()
2195            .expect("active request must receive a typed terminal reply");
2196        assert_writer_task_terminal_state(reply, WriterTaskRequestState::SideEffectsUnknown);
2197        assert_eq!(executions.load(Ordering::SeqCst), 1);
2198        assert!(
2199            !conn.is_autocommit(),
2200            "the denied COMMIT and ROLLBACK must leave the test connection poisoned"
2201        );
2202    }
2203
2204    #[tokio::test]
2205    #[serial(tx_registry)]
2206    async fn poisoned_connection_retires_before_queued_top_level_request() {
2207        let dir = tempfile::tempdir().unwrap();
2208        let path = dir.path().join("writer_task_rollback_poison.db");
2209        let pool = file_pool(&path);
2210        {
2211            let writer = pool.try_writer().unwrap();
2212            writer
2213                .conn()
2214                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
2215                .unwrap();
2216        }
2217        let handle = spawn(&pool, 8).expect("writer task spawn");
2218        let (started_tx, started_rx) = oneshot::channel::<()>();
2219        let (release_tx, release_rx) = std_mpsc::channel::<()>();
2220
2221        let active = tokio::spawn({
2222            let handle = handle.clone();
2223            async move {
2224                handle
2225                    .send(move |conn| -> Result<usize, StorageError> {
2226                        let affected = conn
2227                            .execute("INSERT INTO t (id, v) VALUES (1, 'active')", [])
2228                            .map_err(|e| StorageError::Pool {
2229                                operation: "test_active_insert".into(),
2230                                message: e.to_string(),
2231                            })?;
2232                        conn.authorizer(Some(deny_commit_and_rollback))
2233                            .map_err(|e| StorageError::Pool {
2234                                operation: "test_install_authorizer".into(),
2235                                message: e.to_string(),
2236                            })?;
2237                        let _ = started_tx.send(());
2238                        release_rx.recv().expect("test must release active op");
2239                        Ok(affected)
2240                    })
2241                    .await
2242            }
2243        });
2244
2245        tokio::time::timeout(Duration::from_secs(5), started_rx)
2246            .await
2247            .expect("active request did not start")
2248            .expect("active request dropped its start signal");
2249
2250        let queued_ran = Arc::new(AtomicBool::new(false));
2251        let queued_ran_in_op = Arc::clone(&queued_ran);
2252        let queued_top_level = handle
2253            .enqueue_inner(
2254                move |conn| {
2255                    queued_ran_in_op.store(true, Ordering::SeqCst);
2256                    conn.execute("INSERT INTO t (id, v) VALUES (2, 'queued')", [])
2257                        .map_err(|e| StorageError::Pool {
2258                            operation: "test_queued_top_level_insert".into(),
2259                            message: e.to_string(),
2260                        })
2261                },
2262                true,
2263                false,
2264                false,
2265            )
2266            .await
2267            .expect("top-level request must queue behind active request");
2268        release_tx.send(()).expect("release active op");
2269
2270        let active_result = tokio::time::timeout(Duration::from_secs(5), active)
2271            .await
2272            .expect("active caller hung after rollback failure")
2273            .expect("active caller task join");
2274        assert_writer_task_terminal_state(
2275            active_result,
2276            WriterTaskRequestState::SideEffectsUnknown,
2277        );
2278
2279        let queued_result = tokio::time::timeout(Duration::from_secs(5), queued_top_level)
2280            .await
2281            .expect("queued top-level caller hung after terminal failure")
2282            .expect("terminal drain must preserve queued typed reply");
2283        assert_writer_task_terminal_state(queued_result, WriterTaskRequestState::NotStarted);
2284        assert!(
2285            !queued_ran.load(Ordering::SeqCst),
2286            "a top-level request must never run on the poisoned connection"
2287        );
2288
2289        let future_ran = Arc::new(AtomicBool::new(false));
2290        let future_ran_in_op = Arc::clone(&future_ran);
2291        let future_result = handle
2292            .send_top_level(move |_conn| {
2293                future_ran_in_op.store(true, Ordering::SeqCst);
2294                Ok::<(), StorageError>(())
2295            })
2296            .await;
2297        assert_writer_task_terminal_state(future_result, WriterTaskRequestState::NotStarted);
2298        assert!(!future_ran.load(Ordering::SeqCst));
2299    }
2300
2301    #[test]
2302    fn operation_failure_with_failed_rollback_reports_side_effects_unknown() {
2303        let conn = Connection::open_in_memory().expect("in-memory connection");
2304        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY); BEGIN IMMEDIATE")
2305            .unwrap();
2306        let executions = Arc::new(AtomicUsize::new(0));
2307        let executions_in_op = Arc::clone(&executions);
2308        let (reply_tx, mut reply_rx) = oneshot::channel();
2309        let request = WriteRequest {
2310            op: Box::new(move |conn| -> Result<(), StorageError> {
2311                executions_in_op.fetch_add(1, Ordering::SeqCst);
2312                conn.authorizer(Some(deny_rollback))
2313                    .map_err(|e| StorageError::Pool {
2314                        operation: "test_install_authorizer".into(),
2315                        message: e.to_string(),
2316                    })?;
2317                Err(StorageError::Internal(
2318                    "intentional operation failure before denied rollback".into(),
2319                ))
2320            }),
2321            reply: reply_tx,
2322            top_level: false,
2323            checkpoint_bypass: false,
2324            vacuum_copy_headroom: false,
2325            telemetry: WriteTelemetry::new(None, "test".to_string(), 0, None),
2326        };
2327
2328        let terminal_state = sealed::Sealed::execute_and_reply_reporting_terminal(
2329            Box::new(request),
2330            &conn,
2331            None,
2332            Duration::ZERO,
2333            Duration::ZERO,
2334        );
2335        assert_eq!(
2336            terminal_state,
2337            Some(WriterTaskRequestState::SideEffectsUnknown)
2338        );
2339        let reply = reply_rx
2340            .try_recv()
2341            .expect("active request must receive a typed terminal reply");
2342        assert_writer_task_terminal_state(reply, WriterTaskRequestState::SideEffectsUnknown);
2343        assert_eq!(executions.load(Ordering::SeqCst), 1);
2344        assert!(
2345            !conn.is_autocommit(),
2346            "the denied ROLLBACK must leave the test connection poisoned"
2347        );
2348    }
2349
2350    #[test]
2351    fn wrapped_panic_with_failed_rollback_reports_side_effects_unknown() {
2352        let conn = Connection::open_in_memory().expect("in-memory connection");
2353        conn.execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY); BEGIN IMMEDIATE")
2354            .unwrap();
2355        let (reply_tx, mut reply_rx) = oneshot::channel();
2356        let request = WriteRequest {
2357            op: Box::new(|conn| -> Result<(), StorageError> {
2358                // Deliberately violate WriteOp's no-COMMIT contract to create
2359                // the otherwise rare but reachable rollback-failure state.
2360                // The row commits before the panic, so reporting a successful
2361                // rollback here would be dangerously false.
2362                conn.execute_batch("INSERT INTO t (id) VALUES (1); COMMIT")
2363                    .map_err(|e| StorageError::Pool {
2364                        operation: "test_force_rollback_failure".into(),
2365                        message: e.to_string(),
2366                    })?;
2367                panic!("intentional panic after illicit commit");
2368            }),
2369            reply: reply_tx,
2370            top_level: false,
2371            checkpoint_bypass: false,
2372            vacuum_copy_headroom: false,
2373            telemetry: WriteTelemetry::new(None, "test".to_string(), 0, None),
2374        };
2375
2376        let terminal_state = sealed::Sealed::execute_and_reply_reporting_terminal(
2377            Box::new(request),
2378            &conn,
2379            None,
2380            Duration::ZERO,
2381            Duration::ZERO,
2382        );
2383        assert_eq!(
2384            terminal_state,
2385            Some(WriterTaskRequestState::SideEffectsUnknown)
2386        );
2387        let reply = reply_rx
2388            .try_recv()
2389            .expect("active request must receive a typed terminal reply");
2390        assert_writer_task_terminal_state(reply, WriterTaskRequestState::SideEffectsUnknown);
2391
2392        let count: i64 = conn
2393            .query_row("SELECT COUNT(*) FROM t", [], |row| row.get(0))
2394            .unwrap();
2395        assert_eq!(
2396            count, 1,
2397            "the fixture's committed side effect proves why the state must be unknown"
2398        );
2399    }
2400
2401    // `#[serial(tx_registry)]`: the active request holds a registered
2402    // `writer_task_tx` until its panic is caught and rolled back. Share the
2403    // process-wide registry key with the other writer-task transaction tests.
2404    #[tokio::test]
2405    #[serial(tx_registry)]
2406    async fn wrapped_panic_rolls_back_and_terminally_fails_queue() {
2407        let dir = tempfile::tempdir().unwrap();
2408        let path = dir.path().join("writer_task_wrapped_panic.db");
2409        let cfg = PoolConfig {
2410            path: Some(path),
2411            write_queue_enabled: Some(true),
2412            write_queue_capacity: 8,
2413            ..PoolConfig::for_test()
2414        };
2415        let pool = ConnectionPool::new(cfg).unwrap();
2416        {
2417            let writer = pool.try_writer().unwrap();
2418            writer
2419                .conn()
2420                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
2421                .unwrap();
2422        }
2423
2424        // Use the pool-owned OnceLock path rather than `spawn` directly so
2425        // the test also proves a terminal task is never silently replaced.
2426        let handle = pool
2427            .writer_task_handle()
2428            .expect("writer task lookup")
2429            .expect("file-backed queued pool must spawn its writer task");
2430        assert_eq!(pool.writer_task_spawn_count(), 1);
2431
2432        let (started_tx, started_rx) = oneshot::channel::<()>();
2433        let (release_tx, release_rx) = std_mpsc::channel::<()>();
2434        let active = tokio::spawn({
2435            let handle = handle.clone();
2436            async move {
2437                handle
2438                    .send(move |conn| -> Result<usize, StorageError> {
2439                        conn.execute("INSERT INTO t (id, v) VALUES (1, 'active')", [])
2440                            .map_err(|e| StorageError::Pool {
2441                                operation: "test_active_insert".into(),
2442                                message: e.to_string(),
2443                            })?;
2444                        let _ = started_tx.send(());
2445                        release_rx.recv().expect("test must release active op");
2446                        panic!("intentional wrapped writer request panic");
2447                    })
2448                    .await
2449            }
2450        });
2451
2452        tokio::time::timeout(Duration::from_secs(5), started_rx)
2453            .await
2454            .expect("active request did not start")
2455            .expect("active request dropped its start signal");
2456
2457        let queued_one_ran = Arc::new(AtomicBool::new(false));
2458        let queued_one_ran_in_op = Arc::clone(&queued_one_ran);
2459        let queued_one = handle
2460            .enqueue(move |conn| {
2461                queued_one_ran_in_op.store(true, Ordering::SeqCst);
2462                conn.execute("INSERT INTO t (id, v) VALUES (2, 'queued-one')", [])
2463                    .map_err(|e| StorageError::Pool {
2464                        operation: "test_queued_one_insert".into(),
2465                        message: e.to_string(),
2466                    })
2467            })
2468            .await
2469            .expect("first queued request must be accepted");
2470
2471        let queued_two_ran = Arc::new(AtomicBool::new(false));
2472        let queued_two_ran_in_op = Arc::clone(&queued_two_ran);
2473        let queued_two = handle
2474            .enqueue(move |_conn| {
2475                queued_two_ran_in_op.store(true, Ordering::SeqCst);
2476                Ok::<String, StorageError>("queued-two-ran".to_string())
2477            })
2478            .await
2479            .expect("second queued request must be accepted");
2480
2481        assert_eq!(
2482            handle.queue_depth(),
2483            2,
2484            "both heterogeneous requests must be buffered behind the active op"
2485        );
2486        release_tx.send(()).expect("release active op");
2487
2488        let active_result = tokio::time::timeout(Duration::from_secs(5), active)
2489            .await
2490            .expect("active caller hung after panic")
2491            .expect("active caller task join");
2492        assert_writer_task_terminal_state(
2493            active_result,
2494            WriterTaskRequestState::TransactionRolledBack,
2495        );
2496
2497        let queued_one_result = tokio::time::timeout(Duration::from_secs(5), queued_one)
2498            .await
2499            .expect("first queued caller hung after terminal failure")
2500            .expect("terminal drain must preserve first typed reply");
2501        assert_writer_task_terminal_state(queued_one_result, WriterTaskRequestState::NotStarted);
2502
2503        let queued_two_result = tokio::time::timeout(Duration::from_secs(5), queued_two)
2504            .await
2505            .expect("second queued caller hung after terminal failure")
2506            .expect("terminal drain must preserve second typed reply");
2507        assert_writer_task_terminal_state(queued_two_result, WriterTaskRequestState::NotStarted);
2508        assert!(!queued_one_ran.load(Ordering::SeqCst));
2509        assert!(!queued_two_ran.load(Ordering::SeqCst));
2510
2511        let future_ran = Arc::new(AtomicBool::new(false));
2512        let future_ran_in_op = Arc::clone(&future_ran);
2513        let future_result = handle
2514            .send(move |_conn| {
2515                future_ran_in_op.store(true, Ordering::SeqCst);
2516                Ok::<(), StorageError>(())
2517            })
2518            .await;
2519        assert_writer_task_terminal_state(future_result, WriterTaskRequestState::NotStarted);
2520        assert!(!future_ran.load(Ordering::SeqCst));
2521
2522        let cached_after_failure = pool
2523            .writer_task_handle()
2524            .expect("cached writer task lookup")
2525            .expect("pool retains its terminal handle");
2526        assert_eq!(
2527            pool.writer_task_spawn_count(),
2528            1,
2529            "a terminal writer task must not be restarted behind callers' backs"
2530        );
2531        let cached_result = cached_after_failure
2532            .send(|_conn| Ok::<(), StorageError>(()))
2533            .await;
2534        assert_writer_task_terminal_state(cached_result, WriterTaskRequestState::NotStarted);
2535
2536        let reader = pool.reader().expect("reader");
2537        let count: i64 = reader
2538            .conn()
2539            .query_row("SELECT COUNT(*) FROM t", [], |row| row.get(0))
2540            .unwrap();
2541        assert_eq!(
2542            count, 0,
2543            "the active transaction must be rolled back and queued ops must never run"
2544        );
2545    }
2546
2547    #[tokio::test]
2548    async fn top_level_panic_reports_unknown_and_fails_queue_without_running_it() {
2549        let dir = tempfile::tempdir().unwrap();
2550        let path = dir.path().join("writer_task_top_level_panic.db");
2551        let pool = file_pool(&path);
2552        {
2553            let writer = pool.try_writer().unwrap();
2554            writer
2555                .conn()
2556                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
2557                .unwrap();
2558        }
2559        let handle = spawn(&pool, 8).expect("writer task spawn");
2560
2561        let (started_tx, started_rx) = oneshot::channel::<()>();
2562        let (release_tx, release_rx) = std_mpsc::channel::<()>();
2563        let active = tokio::spawn({
2564            let handle = handle.clone();
2565            async move {
2566                handle
2567                    .send_top_level(move |conn| -> Result<usize, StorageError> {
2568                        conn.execute("INSERT INTO t (id, v) VALUES (10, 'autocommitted')", [])
2569                            .map_err(|e| StorageError::Pool {
2570                                operation: "test_top_level_insert".into(),
2571                                message: e.to_string(),
2572                            })?;
2573                        let _ = started_tx.send(());
2574                        release_rx.recv().expect("test must release top-level op");
2575                        panic!("intentional top-level writer request panic");
2576                    })
2577                    .await
2578            }
2579        });
2580
2581        tokio::time::timeout(Duration::from_secs(5), started_rx)
2582            .await
2583            .expect("top-level request did not start")
2584            .expect("top-level request dropped its start signal");
2585
2586        let queued_ran = Arc::new(AtomicBool::new(false));
2587        let queued_ran_in_op = Arc::clone(&queued_ran);
2588        let queued = handle
2589            .enqueue(move |conn| {
2590                queued_ran_in_op.store(true, Ordering::SeqCst);
2591                conn.execute("INSERT INTO t (id, v) VALUES (11, 'queued')", [])
2592                    .map_err(|e| StorageError::Pool {
2593                        operation: "test_top_level_queued_insert".into(),
2594                        message: e.to_string(),
2595                    })
2596            })
2597            .await
2598            .expect("queued request must be accepted");
2599        assert_eq!(handle.queue_depth(), 1);
2600        release_tx.send(()).expect("release top-level op");
2601
2602        let active_result = tokio::time::timeout(Duration::from_secs(5), active)
2603            .await
2604            .expect("top-level caller hung after panic")
2605            .expect("top-level caller task join");
2606        assert_writer_task_terminal_state(
2607            active_result,
2608            WriterTaskRequestState::SideEffectsUnknown,
2609        );
2610
2611        let queued_result = tokio::time::timeout(Duration::from_secs(5), queued)
2612            .await
2613            .expect("queued caller hung after top-level panic")
2614            .expect("terminal drain must preserve queued typed reply");
2615        assert_writer_task_terminal_state(queued_result, WriterTaskRequestState::NotStarted);
2616        assert!(!queued_ran.load(Ordering::SeqCst));
2617
2618        let reader = pool.reader().expect("reader");
2619        let active_count: i64 = reader
2620            .conn()
2621            .query_row("SELECT COUNT(*) FROM t WHERE id = 10", [], |row| row.get(0))
2622            .unwrap();
2623        let queued_count: i64 = reader
2624            .conn()
2625            .query_row("SELECT COUNT(*) FROM t WHERE id = 11", [], |row| row.get(0))
2626            .unwrap();
2627        assert_eq!(
2628            active_count, 1,
2629            "the completed top-level statement autocommits before the panic"
2630        );
2631        assert_eq!(queued_count, 0, "the queued request must never run");
2632    }
2633
2634    #[tokio::test]
2635    async fn closed_receiver_rejects_all_send_surfaces_as_not_started() {
2636        // Simulates the writer task having terminated: its `rx` is gone, so
2637        // every send surface must fail deterministically without running the
2638        // supplied operation.
2639        let (tx, rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(4);
2640        drop(rx);
2641
2642        let handle = WriterTaskHandle {
2643            tx,
2644            backend_key: None,
2645            db: "test".to_string(),
2646            slow_write_threshold: None,
2647            enqueue_timeout: Duration::from_secs(5),
2648        };
2649        let send_result = handle.send(|_conn| Ok::<(), StorageError>(())).await;
2650        assert_writer_task_terminal_state(send_result, WriterTaskRequestState::NotStarted);
2651
2652        let timed_result = handle
2653            .send_with_timeout(|_conn| Ok::<(), StorageError>(()), Duration::from_secs(1))
2654            .await;
2655        assert_writer_task_terminal_state(timed_result, WriterTaskRequestState::NotStarted);
2656
2657        let top_level_result = handle
2658            .send_top_level(|_conn| Ok::<(), StorageError>(()))
2659            .await;
2660        assert_writer_task_terminal_state(top_level_result, WriterTaskRequestState::NotStarted);
2661    }
2662
2663    #[tokio::test]
2664    async fn accepted_request_lost_reply_is_side_effects_unknown() {
2665        // A request can be accepted before an unexpected receiver/task loss.
2666        // The handle cannot prove whether its closure ran, so the oneshot
2667        // fallback must conservatively report SideEffectsUnknown.
2668        let (tx, mut rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(1);
2669        let handle = WriterTaskHandle {
2670            tx,
2671            backend_key: None,
2672            db: "test".to_string(),
2673            slow_write_threshold: None,
2674            enqueue_timeout: Duration::from_secs(5),
2675        };
2676        let request_ran = Arc::new(AtomicBool::new(false));
2677        let request_ran_in_op = Arc::clone(&request_ran);
2678
2679        let dropper = tokio::spawn(async move {
2680            let request = rx.recv().await.expect("request must be accepted");
2681            drop(request);
2682        });
2683        let result = tokio::time::timeout(
2684            Duration::from_secs(5),
2685            handle.send(move |_conn| {
2686                request_ran_in_op.store(true, Ordering::SeqCst);
2687                Ok::<(), StorageError>(())
2688            }),
2689        )
2690        .await
2691        .expect("caller hung after accepted request was dropped");
2692        dropper.await.expect("dropper task join");
2693
2694        assert_writer_task_terminal_state(result, WriterTaskRequestState::SideEffectsUnknown);
2695        assert!(!request_ran.load(Ordering::SeqCst));
2696    }
2697
2698    /// #1849: writer telemetry uses the same canonical OS-path identity as
2699    /// the pool. A lossy display string would merge these distinct backends.
2700    #[cfg(unix)]
2701    #[test]
2702    fn writer_stage_backend_key_preserves_non_utf8_path_bytes() {
2703        use std::ffi::OsString;
2704        use std::os::unix::ffi::OsStringExt;
2705
2706        let path_a =
2707            std::path::PathBuf::from(OsString::from_vec(b"/tmp/khive-writer-\x80.db".to_vec()));
2708        let path_b =
2709            std::path::PathBuf::from(OsString::from_vec(b"/tmp/khive-writer-\x81.db".to_vec()));
2710        assert_eq!(
2711            path_a.display().to_string(),
2712            path_b.display().to_string(),
2713            "fixture must reproduce the lossy display-label collision"
2714        );
2715        assert_ne!(
2716            writer_db_key_from_path(Some(&path_a)),
2717            writer_db_key_from_path(Some(&path_b)),
2718            "backend keys must retain the canonical path's exact OS bytes"
2719        );
2720    }
2721
2722    /// #1849: a deliberately slow transaction body with no queue backlog or
2723    /// lock contention must be attributed to `body`, not flattened into one
2724    /// opaque send-to-reply duration.
2725    #[tokio::test]
2726    async fn writer_stage_sample_attributes_a_slow_body() {
2727        let dir = tempfile::tempdir().unwrap();
2728        let path = dir.path().join("writer_stage_sample.db");
2729        // A lock directory of its own keeps this deliberate writer hold off the
2730        // volume lease the other tests in this process share.
2731        let pool = ConnectionPool::new(PoolConfig {
2732            path: Some(path.clone()),
2733            volume_lock_dir: Some(dir.path().join("volume-locks")),
2734            ..PoolConfig::for_test()
2735        })
2736        .expect("pool open");
2737        {
2738            let writer = pool.try_writer().unwrap();
2739            writer
2740                .conn()
2741                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY)")
2742                .unwrap();
2743        }
2744        let handle = spawn(&pool, 8).unwrap();
2745
2746        // The delay must dwarf a COMMIT on a slow disk (an fsync of a few
2747        // hundred milliseconds has been observed on shared runners), or the
2748        // commit-stage comparison below reads the disk instead of the body.
2749        handle
2750            .send(|conn| {
2751                std::thread::sleep(Duration::from_millis(400));
2752                conn.execute("INSERT INTO t VALUES (1)", [])
2753                    .map_err(|error| StorageError::Pool {
2754                        operation: "writer_stage_sample".into(),
2755                        message: error.to_string(),
2756                    })
2757            })
2758            .await
2759            .unwrap();
2760
2761        let sample = last_writer_stage_observation(&pool).expect("writer stage sample");
2762        assert!(
2763            sample.body_micros >= 350_000,
2764            "the synthetic delay must land in the body stage: {sample:?}"
2765        );
2766        assert!(
2767            sample.body_micros > sample.queue_wait_micros,
2768            "fast queueing must not receive the body's delay: {sample:?}"
2769        );
2770        assert!(
2771            sample.body_micros > sample.transaction_acquire_micros,
2772            "an uncontended BEGIN must not receive the body's delay: {sample:?}"
2773        );
2774        assert!(
2775            sample.body_micros > sample.commit_micros,
2776            "a fast COMMIT must not receive the body's delay: {sample:?}"
2777        );
2778        assert!(sample.observed_at_unix_ms > 0);
2779    }
2780
2781    /// #1849: bounded-channel queue wait ends when the drain loop receives
2782    /// the request. Saturation in Tokio's blocking pool happens after that
2783    /// boundary and must remain in `total - named_stages`, never be relabeled
2784    /// as writer-queue contention.
2785    #[test]
2786    fn writer_queue_wait_excludes_blocking_pool_scheduling_delay() {
2787        let runtime = tokio::runtime::Builder::new_multi_thread()
2788            .worker_threads(1)
2789            .max_blocking_threads(1)
2790            .enable_all()
2791            .build()
2792            .expect("test runtime");
2793
2794        runtime.block_on(async {
2795            let dir = tempfile::tempdir().unwrap();
2796            let path = dir.path().join("writer_dequeue_boundary.db");
2797            let pool = file_pool(&path);
2798            let handle = spawn(&pool, 8).unwrap();
2799
2800            let (blocker_started_tx, blocker_started_rx) = std_mpsc::sync_channel(0);
2801            let (release_blocker_tx, release_blocker_rx) = std_mpsc::channel();
2802            let blocker = tokio::task::spawn_blocking(move || {
2803                blocker_started_tx.send(()).unwrap();
2804                release_blocker_rx.recv().unwrap();
2805            });
2806            blocker_started_rx
2807                .recv_timeout(Duration::from_secs(1))
2808                .expect("sole blocking worker must be occupied");
2809
2810            let reply = handle
2811                .enqueue(|_conn| Ok::<(), StorageError>(()))
2812                .await
2813                .expect("request must enter the bounded writer channel");
2814            let dequeue_deadline = Instant::now() + Duration::from_secs(1);
2815            while handle.queue_depth() != 0 {
2816                assert!(
2817                    Instant::now() < dequeue_deadline,
2818                    "writer drain never dequeued the accepted request"
2819                );
2820                tokio::task::yield_now().await;
2821            }
2822
2823            let scheduling_delay = Duration::from_millis(150);
2824            tokio::time::sleep(scheduling_delay).await;
2825            release_blocker_tx.send(()).unwrap();
2826            blocker.await.unwrap();
2827            reply.await.unwrap().unwrap();
2828
2829            let sample = last_writer_stage_observation(&pool).expect("writer stage sample");
2830            assert!(
2831                sample.total_micros.saturating_sub(sample.queue_wait_micros) >= 100_000,
2832                "the post-dequeue blocking-pool delay must not inflate queue_wait: {sample:?}"
2833            );
2834        });
2835    }
2836
2837    // `#[serial(tx_registry)]`: the active request holds a registered
2838    // `writer_task_tx` before its terminal failure, sharing the process-wide
2839    // registry key with the other writer-task transaction tests.
2840    #[tokio::test]
2841    #[serial(tx_registry)]
2842    async fn writer_task_failure_counters_are_acquisition_site_exact() {
2843        // Case 1: a request that reaches the seam, panics, and rolls back
2844        // cleanly must move `writer_task_request_failures` by exactly one —
2845        // never `writer_task_side_effects_unknown` — and the requests that
2846        // never dequeued into the seam (failed only because the queue closed
2847        // behind them) must move neither counter.
2848        {
2849            let dir = tempfile::tempdir().unwrap();
2850            let path = dir.path().join("writer_task_failure_counters_rollback.db");
2851            let pool = file_pool(&path);
2852            let handle = spawn(&pool, 8).expect("writer task should spawn");
2853
2854            let before = pool.writer_acquisition_snapshot();
2855            assert_eq!(before.writer_task_request_failures, 0);
2856            assert_eq!(before.writer_task_side_effects_unknown, 0);
2857
2858            let (started_tx, started_rx) = oneshot::channel::<()>();
2859            let (release_tx, release_rx) = std_mpsc::channel::<()>();
2860            let active = tokio::spawn({
2861                let handle = handle.clone();
2862                async move {
2863                    handle
2864                        .send(move |_conn| -> Result<(), StorageError> {
2865                            let _ = started_tx.send(());
2866                            release_rx.recv().expect("test must release active op");
2867                            panic!("intentional rollback-clean panic for counter test");
2868                        })
2869                        .await
2870                }
2871            });
2872            tokio::time::timeout(Duration::from_secs(5), started_rx)
2873                .await
2874                .expect("active request did not start")
2875                .expect("active request dropped its start signal");
2876
2877            let queued = handle
2878                .enqueue(|_conn| Ok::<(), StorageError>(()))
2879                .await
2880                .expect("second request must queue behind the active one");
2881
2882            release_tx.send(()).expect("release active op");
2883            let active_result = tokio::time::timeout(Duration::from_secs(5), active)
2884                .await
2885                .expect("active caller hung after panic")
2886                .expect("active caller task join");
2887            assert_writer_task_terminal_state(
2888                active_result,
2889                WriterTaskRequestState::TransactionRolledBack,
2890            );
2891
2892            let queued_result = queued.await.expect("terminal drain must reply");
2893            assert_writer_task_terminal_state(queued_result, WriterTaskRequestState::NotStarted);
2894
2895            let after = pool.writer_acquisition_snapshot();
2896            assert_eq!(
2897                after.writer_task_request_failures, 1,
2898                "only the request that actually reached the seam counts, not the ones \
2899                 failed by the queue-close drain"
2900            );
2901            assert_eq!(
2902                after.writer_task_side_effects_unknown, 0,
2903                "a clean rollback must not be counted as an unknown-side-effects outcome"
2904            );
2905        }
2906
2907        // Case 2: a request whose rollback is itself refused (denied by an
2908        // installed authorizer) terminates `SideEffectsUnknown` and must move
2909        // both counters by exactly one.
2910        {
2911            let dir = tempfile::tempdir().unwrap();
2912            let path = dir.path().join("writer_task_failure_counters_unknown.db");
2913            let pool = file_pool(&path);
2914            let handle = spawn(&pool, 8).expect("writer task should spawn");
2915
2916            let before = pool.writer_acquisition_snapshot();
2917
2918            let active_result = handle
2919                .send(|conn| -> Result<(), StorageError> {
2920                    conn.authorizer(Some(deny_rollback))
2921                        .map_err(|e| StorageError::Pool {
2922                            operation: "test_install_authorizer".into(),
2923                            message: e.to_string(),
2924                        })?;
2925                    Err(StorageError::Internal(
2926                        "intentional operation failure before denied rollback".into(),
2927                    ))
2928                })
2929                .await;
2930            assert_writer_task_terminal_state(
2931                active_result,
2932                WriterTaskRequestState::SideEffectsUnknown,
2933            );
2934
2935            // The reply above races the counter increment: `send` observes
2936            // its reply before `run_writer_task` finishes its own await and
2937            // updates the pool counters. A trailing request against the same
2938            // (now-terminal) handle can only resolve once that update and the
2939            // subsequent queue-close drain have both happened, so awaiting it
2940            // orders the snapshot below strictly after the increment.
2941            let sentinel_result = handle.send(|_conn| Ok::<(), StorageError>(())).await;
2942            assert_writer_task_terminal_state(sentinel_result, WriterTaskRequestState::NotStarted);
2943
2944            let after = pool.writer_acquisition_snapshot();
2945            assert_eq!(
2946                after.writer_task_request_failures - before.writer_task_request_failures,
2947                1
2948            );
2949            assert_eq!(
2950                after.writer_task_side_effects_unknown - before.writer_task_side_effects_unknown,
2951                1
2952            );
2953        }
2954    }
2955}
2956
2957#[cfg(test)]
2958#[path = "writer_task_lease_close_tests.rs"]
2959mod volume_lease_close_tests;
2960
2961#[cfg(all(test, any(unix, windows)))]
2962#[path = "writer_task_identity_tests.rs"]
2963mod identity_tests;