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#[path = "writer_task_labels.rs"]
64mod labels;
65
66pub(crate) use labels::{collect_statement_labels, MAX_WRITE_LABELS};
67use labels::{dedup_labels, SubmitLabels};
68
69/// Retry delays after a busy/locked `BEGIN IMMEDIATE` refusal. The operation
70/// closure remains owned by the request until one BEGIN succeeds, so these
71/// are the only retries this path may perform. Persistent contention is
72/// bounded to three total BEGIN attempts and 15 ms of explicit backoff, and
73/// the attempts together share one configured `busy_timeout` acquisition
74/// budget rather than each waiting out a full window of their own.
75const WRITER_BEGIN_RETRY_DELAYS: [Duration; 2] =
76    [Duration::from_millis(5), Duration::from_millis(10)];
77
78/// Closure signature for a write operation executed against the writer
79/// task's dedicated connection.
80///
81/// `conn` is already inside a `BEGIN IMMEDIATE` transaction opened by
82/// `run_writer_task` when this runs. The closure must issue DML (and, in
83/// later slices, named `SAVEPOINT`s) only — never a bare `BEGIN` / `COMMIT`
84/// / `ROLLBACK` — a nested bare `BEGIN IMMEDIATE` would violate SQLite's
85/// nested-transaction rule and return `SQLITE_ERROR: cannot start a
86/// transaction within a transaction` (ADR-067 lines 271-276).
87type WriteOp<R> = Box<dyn FnOnce(&Connection) -> Result<R, StorageError> + Send>;
88
89/// Latest completed writer-task span for one database, decomposed at the
90/// boundaries an operator can act on (#1849). A zero value means that phase
91/// did not run (top-level/early-failure request) or completed below the
92/// microsecond clock resolution; `total_micros` retains the full span.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct WriterStageObservation {
95    pub queue_wait_micros: u64,
96    pub transaction_acquire_micros: u64,
97    pub body_micros: u64,
98    pub commit_micros: u64,
99    pub total_micros: u64,
100    pub queue_depth_at_entry: u64,
101    pub observed_at_unix_ms: u64,
102    /// Distinct labels the drained request ran, in first-seen order,
103    /// deduplicated and bounded at `MAX_WRITE_LABELS`. Resolved only for a
104    /// write that crossed the slow-write threshold, so empty for every write
105    /// below it; also empty when no statement in the request carried a label
106    /// and the submit site had no name in hand — e.g. an opaque closure from
107    /// a caller with no `&'static str` operation.
108    pub labels: Vec<String>,
109    /// Distinct labels dropped past `MAX_WRITE_LABELS`, when the request
110    /// named more; `None` when nothing was dropped.
111    pub labels_truncated: Option<u64>,
112}
113
114static WRITER_STAGE_OBSERVATIONS: OnceLock<
115    Mutex<HashMap<Option<PathBuf>, WriterStageObservation>>,
116> = OnceLock::new();
117
118fn writer_stage_observations() -> &'static Mutex<HashMap<Option<PathBuf>, WriterStageObservation>> {
119    WRITER_STAGE_OBSERVATIONS.get_or_init(|| Mutex::new(HashMap::new()))
120}
121
122fn writer_db_key_from_path(path: Option<&Path>) -> Option<PathBuf> {
123    path.map(Path::to_path_buf)
124}
125
126fn writer_db_key(pool: &ConnectionPool) -> Option<PathBuf> {
127    writer_db_key_from_path(pool.canonical_path())
128}
129
130fn duration_micros(duration: Duration) -> u64 {
131    duration.as_micros().min(u128::from(u64::MAX)) as u64
132}
133
134fn observed_at_unix_ms() -> u64 {
135    std::time::SystemTime::now()
136        .duration_since(std::time::UNIX_EPOCH)
137        .map(|duration| duration.as_millis() as u64)
138        .unwrap_or(0)
139}
140
141/// Pure in-memory read of the most recently completed writer-task span for
142/// this exact backend. It acquires no SQLite connection and performs no I/O.
143pub fn last_writer_stage_observation(pool: &ConnectionPool) -> Option<WriterStageObservation> {
144    writer_stage_observations()
145        .lock()
146        .unwrap_or_else(std::sync::PoisonError::into_inner)
147        .get(&writer_db_key(pool))
148        .cloned()
149}
150
151struct WriteTelemetry {
152    backend_key: Option<PathBuf>,
153    db: String,
154    submitted_at: Instant,
155    queue_depth_at_entry: usize,
156    slow_write_threshold: Option<Duration>,
157    labels: SubmitLabels,
158}
159
160impl WriteTelemetry {
161    fn new(
162        backend_key: Option<PathBuf>,
163        db: String,
164        queue_depth_at_entry: usize,
165        slow_write_threshold: Option<Duration>,
166        labels: SubmitLabels,
167    ) -> Self {
168        Self {
169            backend_key,
170            db,
171            submitted_at: Instant::now(),
172            queue_depth_at_entry,
173            slow_write_threshold,
174            labels,
175        }
176    }
177
178    fn queue_wait(&self) -> Duration {
179        self.submitted_at.elapsed()
180    }
181
182    /// The labels named on this request, bounded to [`MAX_WRITE_LABELS`]
183    /// plus the count of distinct labels dropped. Computed only on the slow
184    /// path, since only [`Self::finish`] calls it: the fast path never turns
185    /// a [`SubmitLabels::Site`] name into an allocation.
186    fn resolve_labels(&self) -> (Vec<String>, Option<u64>) {
187        match &self.labels {
188            SubmitLabels::None => (Vec::new(), None),
189            SubmitLabels::Site(site) => (vec![(*site).to_owned()], None),
190            SubmitLabels::Statements(labels) => {
191                if labels.len() <= MAX_WRITE_LABELS {
192                    (labels.clone(), None)
193                } else {
194                    let dropped = (labels.len() - MAX_WRITE_LABELS) as u64;
195                    (labels[..MAX_WRITE_LABELS].to_vec(), Some(dropped))
196                }
197            }
198        }
199    }
200
201    fn finish(
202        self,
203        queue_wait: Duration,
204        transaction_acquire: Duration,
205        body: Duration,
206        commit: Duration,
207    ) {
208        let total = self.submitted_at.elapsed();
209        let slow = self
210            .slow_write_threshold
211            .is_some_and(|threshold| total >= threshold);
212        // Labels are resolved only for a write that crossed the slow
213        // threshold, so a fast write never turns a site name into an
214        // allocation; its observation carries no labels.
215        let (labels, labels_truncated) = if slow {
216            self.resolve_labels()
217        } else {
218            (Vec::new(), None)
219        };
220        let observation = WriterStageObservation {
221            queue_wait_micros: duration_micros(queue_wait),
222            transaction_acquire_micros: duration_micros(transaction_acquire),
223            body_micros: duration_micros(body),
224            commit_micros: duration_micros(commit),
225            total_micros: duration_micros(total),
226            queue_depth_at_entry: self.queue_depth_at_entry as u64,
227            observed_at_unix_ms: observed_at_unix_ms(),
228            labels,
229            labels_truncated,
230        };
231        writer_stage_observations()
232            .lock()
233            .unwrap_or_else(std::sync::PoisonError::into_inner)
234            .insert(self.backend_key, observation.clone());
235
236        if slow {
237            crate::timeout_sink::emit_slow_write(&self.db, &observation);
238        }
239    }
240}
241
242/// One write request awaiting execution by the writer task.
243///
244/// Carries a typed closure and a typed oneshot reply so that the concrete
245/// return type `R` (e.g. `BatchWriteSummary`) is preserved end to end,
246/// while [`AnyWriteRequest`] lets the drain loop hold heterogeneous
247/// requests in one homogeneous channel.
248///
249/// `top_level` (ADR-067 Component A): when `true`,
250/// the drain loop runs this request's operation WITHOUT wrapping it in a
251/// `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK` — still serialized through the
252/// single writer owner (only one request drains at a time regardless of
253/// this flag), but with the transaction wrap skipped entirely. Exists for
254/// statements SQLite forbids inside any open transaction (e.g. `VACUUM`);
255/// see [`WriterTaskHandle::send_top_level`].
256pub struct WriteRequest<R: Send + 'static> {
257    op: WriteOp<R>,
258    reply: oneshot::Sender<Result<R, StorageError>>,
259    top_level: bool,
260    /// Only the typed checkpoint entry point may bypass disk admission.
261    checkpoint_bypass: bool,
262    vacuum_copy_headroom: bool,
263    telemetry: WriteTelemetry,
264}
265
266mod sealed {
267    /// Restricts [`super::AnyWriteRequest`] to implementations defined in
268    /// this module — only [`super::WriteRequest<R>`] implements it — and
269    /// carries the drain loop's internal terminal-state reporting methods
270    /// without changing the public execution-method signatures.
271    pub trait Sealed {
272        fn execute_and_reply_reporting_terminal(
273            self: Box<Self>,
274            conn: &rusqlite::Connection,
275            tx_span: Option<khive_storage::tx_registry::TxHandle>,
276            queue_wait: std::time::Duration,
277            transaction_acquire: std::time::Duration,
278        ) -> Option<khive_storage::error::WriterTaskRequestState>;
279
280        fn execute_and_reply_top_level_reporting_terminal(
281            self: Box<Self>,
282            conn: &rusqlite::Connection,
283            queue_wait: std::time::Duration,
284        ) -> Option<khive_storage::error::WriterTaskRequestState>;
285
286        fn reply_error_after_begin(
287            self: Box<Self>,
288            err: khive_storage::error::StorageError,
289            queue_wait: std::time::Duration,
290            transaction_acquire: std::time::Duration,
291        );
292    }
293}
294
295/// Type-erased write request the writer task's drain loop can hold in a
296/// homogeneous channel (`mpsc::Sender<Box<dyn AnyWriteRequest + Send>>`),
297/// while each concrete [`WriteRequest<R>`] still carries its own typed
298/// reply. Sealed: only this module may implement it (ADR-067 lines
299/// 210-212).
300pub trait AnyWriteRequest: sealed::Sealed + Send {
301    /// Runs this request's operation against `conn`, commits or rolls back
302    /// the enclosing transaction based on the outcome, and sends the
303    /// (possibly commit-failure-adjusted) result to the request's oneshot
304    /// reply channel.
305    ///
306    /// `conn` must already be inside a successfully-opened `BEGIN IMMEDIATE`
307    /// transaction opened by the caller (`run_writer_task`) — this method
308    /// issues only `COMMIT` / `ROLLBACK`, never `BEGIN`, so `run_writer_task`
309    /// remains the sole issuer of `BEGIN IMMEDIATE` (ADR-067 Component A).
310    /// Callers must use [`Self::reply_error`] instead when the enclosing
311    /// `BEGIN IMMEDIATE` itself failed — this method must not be invoked in
312    /// that case.
313    fn execute_and_reply(self: Box<Self>, conn: &Connection);
314
315    /// Runs this request's operation directly against `conn` — no
316    /// transaction wrap, no `COMMIT`/`ROLLBACK` — and sends the result to
317    /// the request's oneshot reply channel.
318    ///
319    /// Used only for [`Self::is_top_level`] requests: the drain loop calls
320    /// this INSTEAD of `execute_and_reply` for such requests, skipping
321    /// `BEGIN IMMEDIATE` entirely so a statement that must run outside any
322    /// transaction (e.g. `VACUUM`) can still be serialized through the
323    /// single writer owner.
324    fn execute_and_reply_top_level(self: Box<Self>, conn: &Connection);
325
326    /// Replies with `err` without running this request's operation or
327    /// touching `conn`.
328    ///
329    /// Used when the enclosing `BEGIN IMMEDIATE` failed (for example,
330    /// `SQLITE_BUSY` from lock contention with an unmigrated writer path
331    /// still holding the pool's writer mutex — reachable while only
332    /// `entity.rs` is routed through this channel). Running the operation
333    /// anyway would execute its DML against `conn` in autocommit mode,
334    /// landing partial writes for a request the caller is told failed.
335    /// Skipping the operation entirely keeps "the caller got an error" and
336    /// "no rows landed" true together.
337    fn reply_error(self: Box<Self>, err: StorageError);
338
339    /// `true` if the drain loop must run this request via
340    /// [`Self::execute_and_reply_top_level`] (no transaction wrap) instead
341    /// of [`Self::execute_and_reply`] (wrapped in `BEGIN IMMEDIATE`).
342    fn is_top_level(&self) -> bool;
343
344    fn is_checkpoint_bypass(&self) -> bool;
345
346    fn needs_vacuum_headroom(&self) -> bool;
347
348    /// Time from the caller constructing this request (before bounded-channel
349    /// admission) until the writer task dequeued it.
350    fn queue_wait(&self) -> Duration;
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354enum RollbackDisposition {
355    RolledBack,
356    SideEffectsUnknown,
357}
358
359/// Roll back a failed wrapped request and verify that the connection really
360/// returned to autocommit mode before it can serve another request.
361fn rollback_after_failure(conn: &Connection, failure_context: &'static str) -> RollbackDisposition {
362    match conn.execute_batch("ROLLBACK") {
363        Ok(()) if conn.is_autocommit() => RollbackDisposition::RolledBack,
364        Ok(()) => {
365            tracing::error!(
366                failure_context,
367                "writer transaction: ROLLBACK returned success but the connection is still in a \
368                 transaction; request side effects are unknown"
369            );
370            RollbackDisposition::SideEffectsUnknown
371        }
372        Err(rollback_error) => {
373            tracing::error!(
374                error = %rollback_error,
375                failure_context,
376                "writer transaction: rollback after request failure failed; request side effects are \
377                 unknown"
378            );
379            RollbackDisposition::SideEffectsUnknown
380        }
381    }
382}
383
384/// Execute an operation inside an already-open transaction and apply the
385/// shared commit, rollback, panic, and autocommit verification rules.
386pub(crate) fn execute_wrapped_transaction<R, F>(
387    conn: &Connection,
388    commit_operation: &'static str,
389    operation: F,
390) -> (Result<R, StorageError>, Option<WriterTaskRequestState>)
391where
392    F: FnOnce(&Connection) -> Result<R, StorageError>,
393{
394    let profiled = execute_wrapped_transaction_profiled(conn, commit_operation, None, operation);
395    (profiled.result, profiled.terminal_state)
396}
397
398struct ProfiledWrappedTransaction<R> {
399    result: Result<R, StorageError>,
400    terminal_state: Option<WriterTaskRequestState>,
401    body: Duration,
402    commit: Duration,
403}
404
405fn execute_wrapped_transaction_profiled<R, F>(
406    conn: &Connection,
407    commit_operation: &'static str,
408    db: Option<&str>,
409    operation: F,
410) -> ProfiledWrappedTransaction<R>
411where
412    F: FnOnce(&Connection) -> Result<R, StorageError>,
413{
414    let body_started = Instant::now();
415    let operation_outcome = catch_unwind(AssertUnwindSafe(|| {
416        operation(conn).map_err(crate::error::statement_failure)
417    }));
418    let body = body_started.elapsed();
419
420    match operation_outcome {
421        Ok(Ok(value)) => {
422            let commit_started = Instant::now();
423            let commit_outcome = conn.execute_batch("COMMIT");
424            let commit = commit_started.elapsed();
425            match commit_outcome {
426                Ok(()) if conn.is_autocommit() => ProfiledWrappedTransaction {
427                    result: Ok(value),
428                    terminal_state: None,
429                    body,
430                    commit,
431                },
432                Ok(()) => {
433                    tracing::error!(
434                    "writer transaction: COMMIT returned success but the connection is still in \
435                     a transaction; request side effects are unknown"
436                );
437                    let request_state = WriterTaskRequestState::SideEffectsUnknown;
438                    ProfiledWrappedTransaction {
439                        result: Err(writer_task_terminated(request_state)),
440                        terminal_state: Some(request_state),
441                        body,
442                        commit,
443                    }
444                }
445                Err(commit_error) => {
446                    if let Some(db) = db {
447                        crate::timeout_sink::maybe_emit_sqlite_full(db, &commit_error);
448                    }
449                    match rollback_after_failure(conn, "commit failure") {
450                        RollbackDisposition::RolledBack => ProfiledWrappedTransaction {
451                            result: Err(StorageError::WriterTaskRequestFailed {
452                                request_state: WriterTaskRequestState::TransactionRolledBack,
453                                source: Box::new({
454                                    let failure = crate::error::native_write_failure(
455                                        &commit_error,
456                                        khive_storage::error::SqliteWriteStage::Commit,
457                                    );
458                                    let error = StorageError::Pool {
459                                        operation: commit_operation.into(),
460                                        message: commit_error.to_string(),
461                                    };
462                                    match failure {
463                                        Some(failure) => error.with_sqlite_write_failure(failure),
464                                        None => error,
465                                    }
466                                }),
467                            }),
468                            terminal_state: None,
469                            body,
470                            commit,
471                        },
472                        RollbackDisposition::SideEffectsUnknown => {
473                            let request_state = WriterTaskRequestState::SideEffectsUnknown;
474                            ProfiledWrappedTransaction {
475                                result: Err(writer_task_terminated_with_cause(
476                                    request_state,
477                                    &commit_error,
478                                    khive_storage::error::SqliteWriteStage::Commit,
479                                )),
480                                terminal_state: Some(request_state),
481                                body,
482                                commit,
483                            }
484                        }
485                    }
486                }
487            }
488        }
489        Ok(Err(operation_error)) => {
490            match rollback_after_failure(conn, "request operation failure") {
491                RollbackDisposition::RolledBack => ProfiledWrappedTransaction {
492                    result: Err(StorageError::WriterTaskRequestFailed {
493                        request_state: WriterTaskRequestState::TransactionRolledBack,
494                        source: Box::new(operation_error),
495                    }),
496                    terminal_state: None,
497                    body,
498                    commit: Duration::ZERO,
499                },
500                RollbackDisposition::SideEffectsUnknown => {
501                    // SQLite may have already rolled back a failed transaction,
502                    // leaving no transaction for the explicit ROLLBACK.
503                    // Escalate the original cause once; retain native FULL evidence below.
504                    if let Some(db) = db {
505                        crate::timeout_sink::maybe_emit_sqlite_full(db, &operation_error);
506                    }
507                    let request_state = WriterTaskRequestState::SideEffectsUnknown;
508                    ProfiledWrappedTransaction {
509                        result: Err(writer_task_terminated_with_cause(
510                            request_state,
511                            &operation_error,
512                            khive_storage::error::SqliteWriteStage::Statement,
513                        )),
514                        terminal_state: Some(request_state),
515                        body,
516                        commit: Duration::ZERO,
517                    }
518                }
519            }
520        }
521        Err(_panic_payload) => {
522            let request_state = match rollback_after_failure(conn, "request panic") {
523                RollbackDisposition::RolledBack => WriterTaskRequestState::TransactionRolledBack,
524                RollbackDisposition::SideEffectsUnknown => {
525                    WriterTaskRequestState::SideEffectsUnknown
526                }
527            };
528            ProfiledWrappedTransaction {
529                result: Err(writer_task_terminated(request_state)),
530                terminal_state: Some(request_state),
531                body,
532                commit: Duration::ZERO,
533            }
534        }
535    }
536}
537
538impl<R: Send + 'static> sealed::Sealed for WriteRequest<R> {
539    fn execute_and_reply_reporting_terminal(
540        self: Box<Self>,
541        conn: &Connection,
542        tx_span: Option<khive_storage::tx_registry::TxHandle>,
543        queue_wait: Duration,
544        transaction_acquire: Duration,
545    ) -> Option<WriterTaskRequestState> {
546        // Keep the typed reply sender outside the unwind boundary. Calling
547        // `(self.op)(conn)` directly would drop `self.reply` while unwinding,
548        // leaving the active caller with only an untyped RecvError.
549        let WriteRequest {
550            op,
551            reply,
552            telemetry,
553            ..
554        } = *self;
555        let profiled = execute_wrapped_transaction_profiled(
556            conn,
557            "writer_task_commit",
558            Some(&telemetry.db),
559            op,
560        );
561        if let Err(error) = &profiled.result {
562            crate::timeout_sink::maybe_emit_sqlite_full(&telemetry.db, error);
563        }
564        // The reply wake can make the caller immediately observe the
565        // committed/error result. Deregister the transaction span first so
566        // tx_registry continues to mean "currently open SQL transaction",
567        // never "a transaction whose caller has already resumed" (#1790).
568        drop(tx_span);
569        telemetry.finish(
570            queue_wait,
571            transaction_acquire,
572            profiled.body,
573            profiled.commit,
574        );
575        // The receiver may already be gone (caller dropped its future) —
576        // that is not this task's problem to report.
577        let _ = reply.send(profiled.result);
578        profiled.terminal_state
579    }
580
581    fn execute_and_reply_top_level_reporting_terminal(
582        self: Box<Self>,
583        conn: &Connection,
584        queue_wait: Duration,
585    ) -> Option<WriterTaskRequestState> {
586        let WriteRequest {
587            op,
588            reply,
589            telemetry,
590            ..
591        } = *self;
592        let body_started = Instant::now();
593        let outcome = catch_unwind(AssertUnwindSafe(|| {
594            op(conn).map_err(crate::error::statement_failure)
595        }));
596        let body = body_started.elapsed();
597        let telemetry_db = telemetry.db.clone();
598        telemetry.finish(queue_wait, Duration::ZERO, body, Duration::ZERO);
599        match outcome {
600            Ok(outcome) if conn.is_autocommit() => {
601                // No COMMIT/ROLLBACK here: this request explicitly did not
602                // open a transaction, so there is nothing to close.
603                if let Err(error) = &outcome {
604                    crate::timeout_sink::maybe_emit_sqlite_full(&telemetry_db, error);
605                }
606                let _ = reply.send(outcome);
607                None
608            }
609            Ok(outcome) => {
610                tracing::error!(
611                    "writer task: top-level request returned with an open transaction; request \
612                     side effects are unknown"
613                );
614                let request_state = WriterTaskRequestState::SideEffectsUnknown;
615                let error = match outcome {
616                    Err(error) => writer_task_terminated_with_cause(
617                        request_state,
618                        &error,
619                        khive_storage::error::SqliteWriteStage::Statement,
620                    ),
621                    Ok(_) => writer_task_terminated(request_state),
622                };
623                let _ = reply.send(Err(error));
624                Some(request_state)
625            }
626            Err(_panic_payload) => {
627                // Statements completed before a top-level panic may already
628                // have autocommitted. Report the ambiguity; never invent a
629                // rollback for a request that opened no transaction.
630                let request_state = WriterTaskRequestState::SideEffectsUnknown;
631                let _ = reply.send(Err(writer_task_terminated(request_state)));
632                Some(request_state)
633            }
634        }
635    }
636
637    fn reply_error_after_begin(
638        self: Box<Self>,
639        err: StorageError,
640        queue_wait: Duration,
641        transaction_acquire: Duration,
642    ) {
643        let WriteRequest {
644            reply, telemetry, ..
645        } = *self;
646        telemetry.finish(
647            queue_wait,
648            transaction_acquire,
649            Duration::ZERO,
650            Duration::ZERO,
651        );
652        let _ = reply.send(Err(err));
653    }
654}
655
656impl<R: Send + 'static> AnyWriteRequest for WriteRequest<R> {
657    fn execute_and_reply(self: Box<Self>, conn: &Connection) {
658        let queue_wait = self.queue_wait();
659        let _ = sealed::Sealed::execute_and_reply_reporting_terminal(
660            self,
661            conn,
662            None,
663            queue_wait,
664            Duration::ZERO,
665        );
666    }
667
668    fn execute_and_reply_top_level(self: Box<Self>, conn: &Connection) {
669        let queue_wait = self.queue_wait();
670        let _ =
671            sealed::Sealed::execute_and_reply_top_level_reporting_terminal(self, conn, queue_wait);
672    }
673
674    fn reply_error(self: Box<Self>, err: StorageError) {
675        let queue_wait = self.queue_wait();
676        sealed::Sealed::reply_error_after_begin(self, err, queue_wait, Duration::ZERO);
677    }
678
679    fn is_top_level(&self) -> bool {
680        self.top_level
681    }
682
683    fn is_checkpoint_bypass(&self) -> bool {
684        self.checkpoint_bypass
685    }
686
687    fn needs_vacuum_headroom(&self) -> bool {
688        self.vacuum_copy_headroom
689    }
690
691    fn queue_wait(&self) -> Duration {
692        self.telemetry.queue_wait()
693    }
694}
695
696fn writer_task_terminated(request_state: WriterTaskRequestState) -> StorageError {
697    StorageError::writer_task_terminated(request_state)
698}
699
700fn writer_task_terminated_with_cause(
701    request_state: WriterTaskRequestState,
702    error: &(dyn std::error::Error + 'static),
703    stage: khive_storage::error::SqliteWriteStage,
704) -> StorageError {
705    let mut source = Some(error);
706    let mut sqlite_full_codes = None;
707    while let Some(current) = source {
708        if let Some(rusqlite::Error::SqliteFailure(code, _)) =
709            current.downcast_ref::<rusqlite::Error>()
710        {
711            if code.code == rusqlite::ErrorCode::DiskFull {
712                sqlite_full_codes = Some((code.extended_code & 0xff, code.extended_code));
713                break;
714            }
715        }
716        source = current.source();
717    }
718    // The original error was already escalated at the failing boundary.
719    // A source-free pair preserves caller evidence without duplicating that sink event.
720    let terminal = StorageError::WriterTaskTerminated {
721        request_state,
722        sqlite_full_codes,
723    };
724    match crate::error::native_write_failure(error, stage) {
725        Some(mut failure) => {
726            failure.settlement_unknown =
727                request_state == WriterTaskRequestState::SideEffectsUnknown;
728            terminal.with_sqlite_write_failure(failure)
729        }
730        None => terminal,
731    }
732}
733
734fn writer_task_begin_error(error: rusqlite::Error, busy_timeout: Duration) -> StorageError {
735    let failure =
736        crate::error::native_write_failure(&error, khive_storage::error::SqliteWriteStage::Begin);
737    let mapped = if crate::timeout_sink::is_busy_or_locked(&error) {
738        StorageError::WriterTaskBusy {
739            timeout_ms: u64::try_from(busy_timeout.as_millis()).unwrap_or(u64::MAX),
740        }
741    } else {
742        StorageError::Pool {
743            operation: "writer_task_begin".into(),
744            message: error.to_string(),
745        }
746    };
747    match failure {
748        Some(failure) => mapped.with_sqlite_write_failure(failure),
749        None => mapped,
750    }
751}
752
753/// Sender half of the write queue. Cheaply cloneable (wraps an
754/// `mpsc::Sender`) — every migrated store that shares one writer task holds
755/// a clone of this handle.
756#[derive(Clone, Debug)]
757pub struct WriterTaskHandle {
758    tx: mpsc::Sender<Box<dyn AnyWriteRequest + Send>>,
759    /// Exact canonical backend identity used by the in-memory stage registry.
760    /// Kept separate from `db`, whose lossy display form is logging-only.
761    backend_key: Option<PathBuf>,
762    /// This handle's pool's writer-timeout sink identity (`timeout_sink::db_label`),
763    /// captured at spawn so `send_with_timeout`'s `WriteQueueFull` path can
764    /// report a `queue_saturation` sink row (ADR-136 D1 gate 6a) without
765    /// needing a `&ConnectionPool` reference.
766    db: String,
767    /// Slow-write latency bound (`timeout_sink::slow_write_threshold`),
768    /// resolved once at spawn. `None` disables slow-write rows. Captured
769    /// here rather than read per send so the caller path pays no env lookup
770    /// and tests get a deterministic value by setting the override before
771    /// spawning their own writer task.
772    slow_write_threshold: Option<std::time::Duration>,
773    /// Default enqueue-capacity deadline for [`Self::send_bounded`] /
774    /// [`Self::send_top_level_bounded`], captured from
775    /// `PoolConfig::write_admission_deadline_ms` at [`spawn`] (ADR-131
776    /// Decision 2). Bounds ONLY the wait for channel capacity, the same
777    /// boundary `send_with_timeout` already documents — never the reply wait
778    /// after acceptance (#1382).
779    ///
780    /// This is a dedicated admission authority, distinct from
781    /// `PoolConfig::checkout_timeout` (reader/pool checkout). The two used
782    /// to be conflated here before ADR-131 Decision 2 (#1382, #1643).
783    ///
784    /// ADR-131 Decision 2's outer-request-budget clamp ("when the caller's outer
785    /// request deadline leaves less time remaining than the configured
786    /// admission deadline, the admission deadline applied to that operation
787    /// is the remaining outer budget instead") is explicitly DEFERRED: no
788    /// outer request deadline is plumbed to `send_bounded` /
789    /// `send_top_level_bounded`'s call sites as of #1737, so there is no
790    /// budget to clamp against. When an outer deadline reaches this call
791    /// path, clamp here rather than faking a shorter deadline against a
792    /// nonexistent budget.
793    enqueue_timeout: std::time::Duration,
794}
795
796impl WriterTaskHandle {
797    /// Enqueue a write operation and return the oneshot receiver its reply
798    /// will arrive on, once the request has actually been accepted onto the
799    /// channel.
800    ///
801    /// Shared by [`Self::send`] and [`Self::send_with_timeout`] so that a
802    /// caller-supplied deadline (see `send_with_timeout`) can bound ONLY
803    /// this enqueue step — never the reply-wait that follows it. Once this
804    /// returns `Ok`, the request has been accepted by the writer task and
805    /// will either run to completion or receive a typed terminal error if an
806    /// earlier request kills the task before this operation begins. The
807    /// returned receiver must be awaited without a timeout; abandoning it
808    /// here would silently drop the request's eventual result, not cancel the
809    /// request itself.
810    async fn enqueue<R, F>(
811        &self,
812        op: F,
813    ) -> Result<oneshot::Receiver<Result<R, StorageError>>, StorageError>
814    where
815        R: Send + 'static,
816        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
817    {
818        self.enqueue_inner(op, false, false, false, SubmitLabels::None)
819            .await
820    }
821
822    /// Shared enqueue path for both transaction-wrapped ([`Self::enqueue`])
823    /// and top-level ([`Self::send_top_level`]) requests — `top_level`
824    /// controls which [`AnyWriteRequest`] method the drain loop invokes.
825    /// `labels` is what the submit site knows about this request's
826    /// statements; see [`SubmitLabels`].
827    async fn enqueue_inner<R, F>(
828        &self,
829        op: F,
830        top_level: bool,
831        checkpoint_bypass: bool,
832        vacuum_copy_headroom: bool,
833        labels: SubmitLabels,
834    ) -> Result<oneshot::Receiver<Result<R, StorageError>>, StorageError>
835    where
836        R: Send + 'static,
837        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
838    {
839        let (reply_tx, reply_rx) = oneshot::channel();
840        // Normalize here (once per request) so every submit path — including
841        // the public `send_labelled` entry points a caller can hand a raw
842        // list — records distinct labels in first-seen order. Bounding stays
843        // in `resolve_labels`, which runs only on the slow path.
844        let labels = match labels {
845            SubmitLabels::Statements(labels) => {
846                SubmitLabels::Statements(dedup_labels(labels.iter().map(String::as_str)))
847            }
848            other => other,
849        };
850        let telemetry = WriteTelemetry::new(
851            self.backend_key.clone(),
852            self.db.clone(),
853            self.queue_depth(),
854            self.slow_write_threshold,
855            labels,
856        );
857        let request = WriteRequest {
858            op: Box::new(op),
859            reply: reply_tx,
860            top_level,
861            checkpoint_bypass,
862            vacuum_copy_headroom,
863            telemetry,
864        };
865
866        self.tx
867            .send(Box::new(request))
868            .await
869            .map_err(|_| writer_task_terminated(WriterTaskRequestState::NotStarted))?;
870
871        Ok(reply_rx)
872    }
873
874    /// Send a write operation to the writer task and await its typed reply.
875    ///
876    /// Backpressure: this suspends on the channel's `send().await` when the
877    /// bounded queue is full (ADR-067 "Channel capacity and queue-full
878    /// policy") — there is no `try_send` escape hatch. Callers that need a
879    /// deadline on that wait should use [`Self::send_with_timeout`] instead.
880    pub async fn send<R, F>(&self, op: F) -> Result<R, StorageError>
881    where
882        R: Send + 'static,
883        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
884    {
885        let reply_rx = self.enqueue(op).await?;
886        reply_rx
887            .await
888            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
889    }
890
891    /// Like [`Self::send`], but bounds the wait for the bounded channel to
892    /// free capacity with `timeout`.
893    ///
894    /// The timeout applies ONLY to enqueueing the request (the channel
895    /// `send().await` that can suspend on a full queue) — never to waiting
896    /// for the writer task's reply once the request has been accepted.
897    /// `StorageError::WriteQueueFull` means exactly "the bounded channel was
898    /// full and this request was never accepted"; it must never be returned
899    /// for a request that was accepted and is still executing (or already
900    /// committed) by the time `timeout` elapses — that would misreport a
901    /// slow op or a lock wait as a queue-capacity failure, and could tell a
902    /// caller a write failed when it actually landed. ADR-067's queue-full
903    /// policy has no immediate-error `try_send` path — only this caller-side
904    /// deadline on the enqueue step.
905    pub async fn send_with_timeout<R, F>(
906        &self,
907        op: F,
908        timeout: std::time::Duration,
909    ) -> Result<R, StorageError>
910    where
911        R: Send + 'static,
912        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
913    {
914        self.send_with_timeout_labelled(op, timeout, SubmitLabels::None)
915            .await
916    }
917
918    /// Shared body of [`Self::send_with_timeout`] and its labelled variants.
919    /// `labels` is carried onto the request's writer-stage observation and
920    /// `slow_write` row; see [`SubmitLabels`].
921    async fn send_with_timeout_labelled<R, F>(
922        &self,
923        op: F,
924        timeout: std::time::Duration,
925        labels: SubmitLabels,
926    ) -> Result<R, StorageError>
927    where
928        R: Send + 'static,
929        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
930    {
931        let reply_rx = match tokio::time::timeout(
932            timeout,
933            self.enqueue_inner(op, false, false, false, labels),
934        )
935        .await
936        {
937            Ok(Ok(reply_rx)) => reply_rx,
938            Ok(Err(e)) => return Err(e),
939            Err(_elapsed) => {
940                let timeout_ms = timeout.as_millis() as u64;
941                crate::timeout_sink::emit_queue_saturation(&self.db, timeout_ms);
942                return Err(StorageError::WriteQueueFull { timeout_ms });
943            }
944        };
945
946        reply_rx
947            .await
948            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
949    }
950
951    /// Like [`Self::send`], but bounds the enqueue-capacity wait with this
952    /// handle's configured `enqueue_timeout`
953    /// (`PoolConfig::write_admission_deadline_ms` at spawn time, ADR-131
954    /// Decision 2) instead of waiting indefinitely (#1382).
955    ///
956    /// Once the request is accepted onto the channel, the reply wait is
957    /// unbounded by this method, identical to [`Self::send`] — this bounds
958    /// only queue-capacity admission, never SQLite execution or reply
959    /// latency. Callers that need a non-default deadline should use
960    /// [`Self::send_with_timeout`] directly.
961    pub async fn send_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
962    where
963        R: Send + 'static,
964        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
965    {
966        self.send_with_timeout(op, self.enqueue_timeout).await
967    }
968
969    /// Send a write operation that MUST run outside any open transaction
970    /// (e.g. `VACUUM`, which SQLite forbids inside `BEGIN`/`COMMIT`) and
971    /// await its typed reply.
972    ///
973    /// Still serialized through the same single writer owner as
974    /// [`Self::send`] — the request goes through the identical bounded
975    /// channel and drain loop, one request at a time — but the drain loop
976    /// skips the per-request `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK` wrap
977    /// entirely for this request (ADR-067 Component A). The single-writer
978    /// guarantee is preserved; only
979    /// the transaction wrap is skipped.
980    pub async fn send_top_level<R, F>(&self, op: F) -> Result<R, StorageError>
981    where
982        R: Send + 'static,
983        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
984    {
985        let reply_rx = self
986            .enqueue_inner(op, true, false, false, SubmitLabels::None)
987            .await?;
988        reply_rx
989            .await
990            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
991    }
992
993    /// Like [`Self::send_top_level`], but bounds the enqueue-capacity wait
994    /// with this handle's configured `enqueue_timeout`, mirroring
995    /// [`Self::send_bounded`] for top-level (transaction-skipping) requests
996    /// (#1382).
997    pub async fn send_top_level_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
998    where
999        R: Send + 'static,
1000        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
1001    {
1002        self.send_top_level_bounded_inner(op, false, false, SubmitLabels::None)
1003            .await
1004    }
1005
1006    /// Checkpoint-only top-level dispatch. It remains serialized by the
1007    /// writer task but never waits for the capacity lease or floor probe.
1008    pub(crate) async fn send_checkpoint_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
1009    where
1010        R: Send + 'static,
1011        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
1012    {
1013        self.send_top_level_bounded_inner(op, true, false, SubmitLabels::Site("wal_checkpoint"))
1014            .await
1015    }
1016
1017    /// Typed VACUUM dispatch with a copy-sized metadata estimate at dequeue.
1018    pub(crate) async fn send_vacuum_bounded<R, F>(&self, op: F) -> Result<R, StorageError>
1019    where
1020        R: Send + 'static,
1021        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
1022    {
1023        self.send_top_level_bounded_inner(op, false, true, SubmitLabels::Site("vacuum"))
1024            .await
1025    }
1026
1027    async fn send_top_level_bounded_inner<R, F>(
1028        &self,
1029        op: F,
1030        checkpoint_bypass: bool,
1031        vacuum_copy_headroom: bool,
1032        labels: SubmitLabels,
1033    ) -> Result<R, StorageError>
1034    where
1035        R: Send + 'static,
1036        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
1037    {
1038        let reply_rx = match tokio::time::timeout(
1039            self.enqueue_timeout,
1040            self.enqueue_inner(op, true, checkpoint_bypass, vacuum_copy_headroom, labels),
1041        )
1042        .await
1043        {
1044            Ok(Ok(reply_rx)) => reply_rx,
1045            Ok(Err(e)) => return Err(e),
1046            Err(_elapsed) => {
1047                let timeout_ms = self.enqueue_timeout.as_millis() as u64;
1048                crate::timeout_sink::emit_queue_saturation(&self.db, timeout_ms);
1049                return Err(StorageError::WriteQueueFull { timeout_ms });
1050            }
1051        };
1052
1053        reply_rx
1054            .await
1055            .map_err(|_| writer_task_terminated(WriterTaskRequestState::SideEffectsUnknown))?
1056    }
1057
1058    /// Current write-queue backlog depth: requests enqueued but not yet
1059    /// accepted by the writer task's drain loop.
1060    ///
1061    /// Reads `mpsc::Sender::max_capacity() - capacity()`, so it is a
1062    /// point-in-time snapshot racy under concurrent senders/the drain loop
1063    /// draining concurrently — acceptable for a monitoring gauge (the
1064    /// load/perf harness metrics read-surface), never used for any correctness
1065    /// decision.
1066    pub fn queue_depth(&self) -> usize {
1067        self.tx.max_capacity() - self.tx.capacity()
1068    }
1069
1070    /// The bounded channel's configured capacity
1071    /// (`PoolConfig::write_queue_capacity`).
1072    pub fn capacity(&self) -> usize {
1073        self.tx.max_capacity()
1074    }
1075}
1076
1077/// Spawn the write-owner task (ADR-067 Component A) on the current Tokio
1078/// runtime.
1079///
1080/// Opens a dedicated standalone writer connection independent of the pool's
1081/// Mutex-guarded `writer()` connection used by unmigrated paths. That one-time
1082/// infrastructure open is uncounted; every dequeued top-level request or
1083/// successful `BEGIN IMMEDIATE` increments the writer-task acquisition class.
1084/// Returns the cloneable [`WriterTaskHandle`] sender half. The task normally
1085/// runs until every handle clone is dropped and the channel closes; a request
1086/// panic, failed rollback, or poisoned connection puts it into the permanent
1087/// terminal state documented below.
1088///
1089/// # Terminal settlement
1090/// Retiring an unsettled writer, including when its blocking owner unwinds,
1091/// releases the volume lease only after cleanup restored autocommit or the
1092/// connection closed. If neither is possible, path, volume, and errors are
1093/// reported to stderr and tracing and the pool is poisoned: that request
1094/// reports [`SqliteError::WriterSettlementUnknown`] and every later write is
1095/// refused with [`SqliteError::WriterPoisoned`] before it starts.
1096///
1097/// `capacity` bounds the channel (`PoolConfig::write_queue_capacity` /
1098/// `KHIVE_WRITE_QUEUE_CAPACITY`, ADR-067 recommends 256).
1099///
1100/// # Errors
1101/// Must be called from within a Tokio runtime context (calls
1102/// `tokio::spawn`). Returns an error if the pool cannot open a standalone
1103/// writer connection (e.g. an in-memory pool has no standalone-connection
1104/// support). See `crates/khive-db/docs/api/writer-task.md` for the
1105/// migration-slice scope this commits per `BEGIN IMMEDIATE`.
1106pub fn spawn(pool: &ConnectionPool, capacity: usize) -> Result<WriterTaskHandle, SqliteError> {
1107    // The lifetime connection is infrastructure, not one acquisition per
1108    // write. Each dequeued request is counted below at the task's actual
1109    // ownership boundary instead.
1110    let conn = pool.open_standalone_writer_untracked()?;
1111    let acquisition_counters = pool.writer_acquisition_counters();
1112    let write_admission = pool.write_admission();
1113    let busy_timeout = pool.config().busy_timeout;
1114    let origin = pool.origin();
1115    let backend_key = writer_db_key(pool);
1116    let db = crate::timeout_sink::db_label(pool);
1117    let (tx, rx) = mpsc::channel(capacity.max(1));
1118    let join = tokio::spawn(run_writer_task(
1119        conn,
1120        rx,
1121        origin,
1122        db.clone(),
1123        acquisition_counters,
1124        write_admission,
1125        busy_timeout,
1126    ));
1127    // Stored on the pool (not returned) so the handle's clone-and-share
1128    // contract stays untouched; see ConnectionPool::take_writer_task_join
1129    // for who awaits it and why.
1130    pool.set_writer_task_join(join);
1131    Ok(WriterTaskHandle {
1132        tx,
1133        backend_key,
1134        db,
1135        slow_write_threshold: crate::timeout_sink::slow_write_threshold(),
1136        enqueue_timeout: std::time::Duration::from_millis(
1137            pool.config().write_admission_deadline_ms,
1138        ),
1139    })
1140}
1141
1142/// Permanently close admission, then reply to every request that was already
1143/// accepted into the bounded channel without invoking its operation closure.
1144///
1145/// Closing before draining is load-bearing: draining an open receiver would
1146/// wait forever while [`WriterTaskHandle`] clones still exist, while dropping
1147/// the receiver immediately would discard buffered requests and their typed
1148/// reply senders.
1149async fn close_and_fail_queued_requests(rx: &mut mpsc::Receiver<Box<dyn AnyWriteRequest + Send>>) {
1150    rx.close();
1151    while let Some(request) = rx.recv().await {
1152        request.reply_error(writer_task_terminated(WriterTaskRequestState::NotStarted));
1153    }
1154}
1155
1156/// Acquire the request transaction without multiplying its busy-timeout budget.
1157/// The setter is supplied so tests can fail SQLite's timeout update while still
1158/// executing real BEGIN statements; production uses `Connection::busy_timeout`.
1159fn begin_immediate_with_retry(
1160    conn: &Connection,
1161    acquisition_counters: &WriterAcquisitionCounters,
1162    busy_timeout: Duration,
1163    mut set_busy_timeout: impl FnMut(&Connection, Duration) -> rusqlite::Result<()>,
1164) -> (rusqlite::Result<()>, Duration, u32) {
1165    let transaction_acquire_started = Instant::now();
1166    let mut begin_attempt = 1_u32;
1167    let mut retry_delays = WRITER_BEGIN_RETRY_DELAYS.into_iter();
1168    let mut busy_timeout_lowered = false;
1169    let begin_outcome = loop {
1170        match conn.execute_batch("BEGIN IMMEDIATE") {
1171            Ok(()) => break Ok(()),
1172            Err(error) if crate::timeout_sink::is_busy_or_locked(&error) => {
1173                // Every busy/locked refusal is counted here,
1174                // whether or not it goes on to be retried: this
1175                // is the pre-PR meaning of the counter, and the
1176                // outer match below no longer counts the final
1177                // refusal a second time.
1178                acquisition_counters.record_writer_task_begin_busy();
1179                let Some(delay) = retry_delays.next() else {
1180                    break Err(error);
1181                };
1182                // The whole retry sequence shares one
1183                // `busy_timeout` budget: each SQLite attempt is
1184                // itself bounded by `busy_timeout`, so without a
1185                // shrinking budget three attempts could each
1186                // wait out a full window and multiply the
1187                // serialized writer's contention window instead
1188                // of bounding it.
1189                let remaining_budget =
1190                    busy_timeout.saturating_sub(transaction_acquire_started.elapsed());
1191                if remaining_budget.is_zero() {
1192                    break Err(error);
1193                }
1194                if let Err(set_err) = set_busy_timeout(conn, remaining_budget) {
1195                    tracing::warn!(
1196                        error = %set_err,
1197                        "writer task: failed to lower busy_timeout for BEGIN \
1198                         retry; surfacing the original busy refusal"
1199                    );
1200                    // A retry with the old timeout could exceed the shared budget.
1201                    break Err(error);
1202                }
1203                busy_timeout_lowered = true;
1204                // Count only refusals that will actually be retried. A failed
1205                // timeout reduction leaves this refusal visible to the caller.
1206                acquisition_counters.record_writer_task_begin_busy_absorbed();
1207                tracing::debug!(
1208                    attempt = begin_attempt,
1209                    backoff_ms = delay.as_millis() as u64,
1210                    budget_remaining_ms = remaining_budget.as_millis() as u64,
1211                    "writer task: BEGIN IMMEDIATE refused busy; retrying before \
1212                     request execution"
1213                );
1214                std::thread::sleep(delay);
1215                begin_attempt = begin_attempt.saturating_add(1);
1216            }
1217            Err(error) => break Err(error),
1218        }
1219    };
1220    let transaction_acquire = transaction_acquire_started.elapsed();
1221    // Restore the pool-configured busy_timeout only if a retry
1222    // actually lowered it, so the common uncontended request
1223    // never pays for an extra pragma write; the next request
1224    // dequeued on this same connection must still start from
1225    // the full configured budget.
1226    if busy_timeout_lowered {
1227        if let Err(restore_err) = set_busy_timeout(conn, busy_timeout) {
1228            tracing::warn!(
1229                error = %restore_err,
1230                "writer task: failed to restore busy_timeout after a BEGIN retry \
1231                 sequence"
1232            );
1233        }
1234    }
1235    (begin_outcome, transaction_acquire, begin_attempt)
1236}
1237
1238struct BlockingWriterConnection {
1239    conn: Option<Connection>,
1240    admission: Arc<crate::pool::WriteAdmission>,
1241    volume_lease: Option<crate::disk_guard::VolumeLease>,
1242}
1243
1244impl BlockingWriterConnection {
1245    fn new(conn: Connection, admission: Arc<crate::pool::WriteAdmission>) -> Self {
1246        Self {
1247            conn: Some(conn),
1248            admission,
1249            volume_lease: None,
1250        }
1251    }
1252
1253    fn finish(
1254        mut self,
1255        state: Option<WriterTaskRequestState>,
1256    ) -> (Option<Connection>, Option<WriterTaskRequestState>) {
1257        if state.is_some() {
1258            self.retire();
1259            (None, state)
1260        } else {
1261            (self.conn.take(), None)
1262        }
1263    }
1264
1265    fn retire(&mut self) {
1266        if let Some(conn) = self.conn.take() {
1267            // A terminal failure has poisoned the admission; the request that
1268            // retired this writer already carries its terminal state.
1269            let _ = self.admission.close_retired_connection(conn);
1270        }
1271    }
1272}
1273
1274impl Drop for BlockingWriterConnection {
1275    fn drop(&mut self) {
1276        self.retire();
1277    }
1278}
1279
1280impl std::ops::Deref for BlockingWriterConnection {
1281    type Target = Connection;
1282
1283    fn deref(&self) -> &Connection {
1284        self.conn
1285            .as_ref()
1286            .expect("blocking writer owns its connection until settlement")
1287    }
1288}
1289
1290/// Drain loop: the sole caller of `BEGIN IMMEDIATE` for write traffic routed
1291/// through the channel. Busy/locked `BEGIN IMMEDIATE` refusals receive the
1292/// bounded retry above before a final failure replies the request's error via
1293/// [`AnyWriteRequest::reply_error`] without invoking the request's closure.
1294///
1295/// A request-operation panic is contained inside the concrete
1296/// [`WriteRequest<R>`] so its typed reply survives. A panic, failed rollback,
1297/// or otherwise poisoned connection makes the task terminal. The task then
1298/// closes admission, explicitly fails every already-queued request as
1299/// [`WriterTaskRequestState::NotStarted`], and exits permanently. There is no
1300/// supervisor or connection restart; later sends observe the closed channel.
1301/// See
1302/// `crates/khive-db/docs/api/writer-task.md` for the full failure matrix.
1303async fn run_writer_task(
1304    mut conn: Connection,
1305    mut rx: mpsc::Receiver<Box<dyn AnyWriteRequest + Send>>,
1306    origin: khive_storage::tx_registry::TxOrigin,
1307    db: String,
1308    acquisition_counters: Arc<WriterAcquisitionCounters>,
1309    write_admission: Arc<crate::pool::WriteAdmission>,
1310    busy_timeout: Duration,
1311) {
1312    while let Some(request) = rx.recv().await {
1313        // The bounded-channel wait ends at this exact dequeue boundary.
1314        // Sampling inside the blocking closure would misattribute a saturated
1315        // Tokio blocking pool to writer-queue contention (#1849).
1316        let queue_wait = request.queue_wait();
1317        let origin = origin.clone();
1318        let blocking_counters = Arc::clone(&acquisition_counters);
1319        let blocking_admission = Arc::clone(&write_admission);
1320        let blocking_db = db.clone();
1321        let outcome = tokio::task::spawn_blocking(move || {
1322            let mut conn = BlockingWriterConnection::new(conn, Arc::clone(&blocking_admission));
1323            let acquisition_counters = blocking_counters;
1324            // A top-level request deliberately skips BEGIN, so it would
1325            // silently join any transaction leaked by an earlier request.
1326            // Refuse every request before dispatch if the connection is not
1327            // demonstrably clean, then retire the writer task.
1328            if !conn.is_autocommit() {
1329                tracing::error!(
1330                    "writer task: connection is not in autocommit mode before request dispatch; \
1331                     retiring the poisoned writer without running the request"
1332                );
1333                let request_state = WriterTaskRequestState::NotStarted;
1334                request.reply_error(writer_task_terminated(request_state));
1335                return conn.finish(Some(request_state));
1336            }
1337
1338            // The lease precedes SQLite writer acquisition and remains held
1339            // until the request has committed, rolled back, or returned to
1340            // autocommit. Checkpoint requests deliberately skip both steps.
1341            conn.volume_lease = if request.is_checkpoint_bypass() {
1342                None
1343            } else {
1344                match blocking_admission.acquire() {
1345                    Ok(lease) => lease,
1346                    Err(error) => {
1347                        request.reply_error(error.into_storage_error(
1348                            khive_storage::StorageCapability::Sql,
1349                            "writer_task_admission",
1350                        ));
1351                        return conn.finish(None);
1352                    }
1353                }
1354            };
1355
1356            let terminal_state = if request.is_top_level() {
1357                if !request.is_checkpoint_bypass() {
1358                    let admission = if request.needs_vacuum_headroom() {
1359                        blocking_admission.check_for_vacuum()
1360                    } else {
1361                        blocking_admission.check()
1362                    };
1363                    if let Err(error) = admission {
1364                        request.reply_error(error.into_storage_error(
1365                            khive_storage::StorageCapability::Sql,
1366                            "writer_task_admission",
1367                        ));
1368                        return conn.finish(None);
1369                    }
1370                }
1371                // ADR-067 Component A:
1372                // no BEGIN IMMEDIATE for this request — some statements
1373                // (e.g. VACUUM) are rejected by SQLite inside any open
1374                // transaction. Still runs on this task's dedicated
1375                // connection and still serialized one-request-at-a-time by
1376                // this same drain loop, so the single-writer guarantee
1377                // holds; only the transaction wrap is skipped.
1378                acquisition_counters.record_writer_task_acquisition();
1379                sealed::Sealed::execute_and_reply_top_level_reporting_terminal(
1380                    request, &conn, queue_wait,
1381                )
1382            } else {
1383                let tx_span = khive_storage::tx_registry::register_scoped(
1384                    Some("writer_task_tx".to_string()),
1385                    origin,
1386                );
1387                let (begin_outcome, transaction_acquire, begin_attempt) =
1388                    begin_immediate_with_retry(
1389                        &conn,
1390                        &acquisition_counters,
1391                        busy_timeout,
1392                        Connection::busy_timeout,
1393                    );
1394                match begin_outcome {
1395                    Ok(()) => {
1396                        if let Err(error) = blocking_admission.check() {
1397                            let request_state =
1398                                match rollback_after_failure(&conn, "capacity admission") {
1399                                    RollbackDisposition::RolledBack => None,
1400                                    RollbackDisposition::SideEffectsUnknown => {
1401                                        Some(WriterTaskRequestState::SideEffectsUnknown)
1402                                    }
1403                                };
1404                            drop(tx_span);
1405                            let error = if let Some(state) = request_state {
1406                                writer_task_terminated(state)
1407                            } else {
1408                                error.into_storage_error(
1409                                    khive_storage::StorageCapability::Sql,
1410                                    "writer_task_admission",
1411                                )
1412                            };
1413                            sealed::Sealed::reply_error_after_begin(
1414                                request,
1415                                error,
1416                                queue_wait,
1417                                transaction_acquire,
1418                            );
1419                            return conn.finish(request_state);
1420                        }
1421                        acquisition_counters.record_writer_task_acquisition();
1422                        sealed::Sealed::execute_and_reply_reporting_terminal(
1423                            request,
1424                            &conn,
1425                            Some(tx_span),
1426                            queue_wait,
1427                            transaction_acquire,
1428                        )
1429                    }
1430                    Err(e) => {
1431                        // Do NOT run the request's operation: `conn` never
1432                        // entered a transaction, so executing the op's DML
1433                        // here would run in autocommit mode and land partial
1434                        // writes for a request the caller is about to be told
1435                        // failed.
1436                        crate::timeout_sink::maybe_emit_sqlite_full(&blocking_db, &e);
1437                        tracing::warn!(
1438                            error = %e,
1439                            attempts = begin_attempt,
1440                            "writer task: BEGIN IMMEDIATE failed; replying an \
1441                             error without running the request's operation"
1442                        );
1443                        // Although BEGIN never opened a SQL transaction, the
1444                        // scoped attempt is observable in tx_registry. Drop
1445                        // it before waking the caller for the same reply-side
1446                        // lifecycle guarantee as successful requests (#1790).
1447                        drop(tx_span);
1448                        // A busy/locked refusal was already counted inside
1449                        // the retry loop above, including this final one —
1450                        // classifying and counting it again here would
1451                        // double it. Only a non-busy BEGIN failure (which
1452                        // never enters the retry loop's busy arm) still
1453                        // needs its counter recorded at this seam.
1454                        let begin_error = writer_task_begin_error(e, busy_timeout);
1455                        if !matches!(
1456                            begin_error.without_sqlite_write_stage(),
1457                            StorageError::WriterTaskBusy { .. }
1458                        ) {
1459                            acquisition_counters.record_writer_task_begin_error();
1460                        }
1461                        sealed::Sealed::reply_error_after_begin(
1462                            request,
1463                            begin_error,
1464                            queue_wait,
1465                            transaction_acquire,
1466                        );
1467                        None
1468                    }
1469                }
1470            };
1471            conn.finish(terminal_state)
1472        })
1473        .await;
1474
1475        match outcome {
1476            Ok((Some(returned_conn), None)) => conn = returned_conn,
1477            Ok((_returned_conn, Some(request_state))) => {
1478                acquisition_counters.record_writer_task_request_failure();
1479                if request_state == WriterTaskRequestState::SideEffectsUnknown {
1480                    acquisition_counters.record_writer_task_side_effects_unknown();
1481                }
1482                tracing::error!(
1483                    request_state = %request_state,
1484                    "writer task reached a terminal request or connection state; closing and \
1485                     failing the queue without restarting"
1486                );
1487                crate::timeout_sink::emit_writer_task_retirement(
1488                    &db,
1489                    &format!("terminal request state: {request_state}"),
1490                );
1491                close_and_fail_queued_requests(&mut rx).await;
1492                return;
1493            }
1494            Ok((None, None)) => unreachable!("a reusable writer returns its connection"),
1495            Err(join_err) => {
1496                acquisition_counters.record_writer_task_request_failure();
1497                tracing::error!(
1498                    error = %join_err,
1499                    "writer task blocking closure failed outside the request \
1500                     panic boundary; closing and failing the queue without restarting"
1501                );
1502                crate::timeout_sink::emit_writer_task_retirement(
1503                    &db,
1504                    &format!("blocking closure join failure: {join_err}"),
1505                );
1506                close_and_fail_queued_requests(&mut rx).await;
1507                return;
1508            }
1509        }
1510    }
1511}
1512
1513#[cfg(test)]
1514#[path = "writer_task_tests.rs"]
1515mod tests;
1516
1517#[cfg(test)]
1518#[path = "writer_task_lease_close_tests.rs"]
1519mod volume_lease_close_tests;
1520
1521#[cfg(test)]
1522#[path = "writer_task_slow_write_label_tests.rs"]
1523mod slow_write_label_tests;
1524
1525#[cfg(all(test, any(unix, windows)))]
1526#[path = "writer_task_identity_tests.rs"]
1527mod identity_tests;