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;