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
17use rusqlite::Connection;
18use tokio::sync::{mpsc, oneshot};
19
20use khive_storage::error::StorageError;
21
22use crate::error::SqliteError;
23use crate::pool::ConnectionPool;
24
25/// Closure signature for a write operation executed against the writer
26/// task's dedicated connection.
27///
28/// `conn` is already inside a `BEGIN IMMEDIATE` transaction opened by
29/// `run_writer_task` when this runs. The closure must issue DML (and, in
30/// later slices, named `SAVEPOINT`s) only — never a bare `BEGIN` / `COMMIT`
31/// / `ROLLBACK` — a nested bare `BEGIN IMMEDIATE` would violate SQLite's
32/// nested-transaction rule and return `SQLITE_ERROR: cannot start a
33/// transaction within a transaction` (ADR-067 lines 271-276).
34type WriteOp<R> = Box<dyn FnOnce(&Connection) -> Result<R, StorageError> + Send>;
35
36/// One write request awaiting execution by the writer task.
37///
38/// Carries a typed closure and a typed oneshot reply so that the concrete
39/// return type `R` (e.g. `BatchWriteSummary`) is preserved end to end,
40/// while [`AnyWriteRequest`] lets the drain loop hold heterogeneous
41/// requests in one homogeneous channel.
42///
43/// `top_level` (ADR-067 Component A): when `true`,
44/// the drain loop runs this request's operation WITHOUT wrapping it in a
45/// `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK` — still serialized through the
46/// single writer owner (only one request drains at a time regardless of
47/// this flag), but with the transaction wrap skipped entirely. Exists for
48/// statements SQLite forbids inside any open transaction (e.g. `VACUUM`);
49/// see [`WriterTaskHandle::send_top_level`].
50pub struct WriteRequest<R: Send + 'static> {
51    op: WriteOp<R>,
52    reply: oneshot::Sender<Result<R, StorageError>>,
53    top_level: bool,
54}
55
56mod sealed {
57    /// Restricts [`super::AnyWriteRequest`] to implementations defined in
58    /// this module — only [`super::WriteRequest<R>`] implements it.
59    pub trait Sealed {}
60}
61
62impl<R: Send + 'static> sealed::Sealed for WriteRequest<R> {}
63
64/// Type-erased write request the writer task's drain loop can hold in a
65/// homogeneous channel (`mpsc::Sender<Box<dyn AnyWriteRequest + Send>>`),
66/// while each concrete [`WriteRequest<R>`] still carries its own typed
67/// reply. Sealed: only this module may implement it (ADR-067 lines
68/// 210-212).
69pub trait AnyWriteRequest: sealed::Sealed + Send {
70    /// Runs this request's operation against `conn`, commits or rolls back
71    /// the enclosing transaction based on the outcome, and sends the
72    /// (possibly commit-failure-adjusted) result to the request's oneshot
73    /// reply channel.
74    ///
75    /// `conn` must already be inside a successfully-opened `BEGIN IMMEDIATE`
76    /// transaction opened by the caller (`run_writer_task`) — this method
77    /// issues only `COMMIT` / `ROLLBACK`, never `BEGIN`, so `run_writer_task`
78    /// remains the sole issuer of `BEGIN IMMEDIATE` (ADR-067 Component A).
79    /// Callers must use [`Self::reply_error`] instead when the enclosing
80    /// `BEGIN IMMEDIATE` itself failed — this method must not be invoked in
81    /// that case.
82    fn execute_and_reply(self: Box<Self>, conn: &Connection);
83
84    /// Runs this request's operation directly against `conn` — no
85    /// transaction wrap, no `COMMIT`/`ROLLBACK` — and sends the result to
86    /// the request's oneshot reply channel.
87    ///
88    /// Used only for [`Self::is_top_level`] requests: the drain loop calls
89    /// this INSTEAD of `execute_and_reply` for such requests, skipping
90    /// `BEGIN IMMEDIATE` entirely so a statement that must run outside any
91    /// transaction (e.g. `VACUUM`) can still be serialized through the
92    /// single writer owner.
93    fn execute_and_reply_top_level(self: Box<Self>, conn: &Connection);
94
95    /// Replies with `err` without running this request's operation or
96    /// touching `conn`.
97    ///
98    /// Used when the enclosing `BEGIN IMMEDIATE` failed (for example,
99    /// `SQLITE_BUSY` from lock contention with an unmigrated writer path
100    /// still holding the pool's writer mutex — reachable while only
101    /// `entity.rs` is routed through this channel). Running the operation
102    /// anyway would execute its DML against `conn` in autocommit mode,
103    /// landing partial writes for a request the caller is told failed.
104    /// Skipping the operation entirely keeps "the caller got an error" and
105    /// "no rows landed" true together.
106    fn reply_error(self: Box<Self>, err: StorageError);
107
108    /// `true` if the drain loop must run this request via
109    /// [`Self::execute_and_reply_top_level`] (no transaction wrap) instead
110    /// of [`Self::execute_and_reply`] (wrapped in `BEGIN IMMEDIATE`).
111    fn is_top_level(&self) -> bool;
112}
113
114impl<R: Send + 'static> AnyWriteRequest for WriteRequest<R> {
115    fn execute_and_reply(self: Box<Self>, conn: &Connection) {
116        let outcome = (self.op)(conn);
117        let final_result = match outcome {
118            Ok(value) => match conn.execute_batch("COMMIT") {
119                Ok(()) => Ok(value),
120                Err(e) => {
121                    let _ = conn.execute_batch("ROLLBACK");
122                    Err(StorageError::Pool {
123                        operation: "writer_task_commit".into(),
124                        message: e.to_string(),
125                    })
126                }
127            },
128            Err(e) => {
129                let _ = conn.execute_batch("ROLLBACK");
130                Err(e)
131            }
132        };
133        // The receiver may already be gone (caller dropped its future) —
134        // that is not this task's problem to report; it just moves on.
135        let _ = self.reply.send(final_result);
136    }
137
138    fn execute_and_reply_top_level(self: Box<Self>, conn: &Connection) {
139        let outcome = (self.op)(conn);
140        // No COMMIT/ROLLBACK here: this request explicitly did not open a
141        // transaction, so there is nothing for this method to close.
142        let _ = self.reply.send(outcome);
143    }
144
145    fn reply_error(self: Box<Self>, err: StorageError) {
146        // Same "receiver may already be gone" reasoning as above — send and
147        // move on regardless of outcome.
148        let _ = self.reply.send(Err(err));
149    }
150
151    fn is_top_level(&self) -> bool {
152        self.top_level
153    }
154}
155
156/// Sender half of the write queue. Cheaply cloneable (wraps an
157/// `mpsc::Sender`) — every migrated store that shares one writer task holds
158/// a clone of this handle.
159#[derive(Clone, Debug)]
160pub struct WriterTaskHandle {
161    tx: mpsc::Sender<Box<dyn AnyWriteRequest + Send>>,
162}
163
164impl WriterTaskHandle {
165    /// Enqueue a write operation and return the oneshot receiver its reply
166    /// will arrive on, once the request has actually been accepted onto the
167    /// channel.
168    ///
169    /// Shared by [`Self::send`] and [`Self::send_with_timeout`] so that a
170    /// caller-supplied deadline (see `send_with_timeout`) can bound ONLY
171    /// this enqueue step — never the reply-wait that follows it. Once this
172    /// returns `Ok`, the request has been accepted by the writer task and
173    /// will run to completion; the returned receiver must be awaited without
174    /// a timeout; abandoning it here would silently drop the request's
175    /// eventual result, not cancel the request itself.
176    async fn enqueue<R, F>(
177        &self,
178        op: F,
179    ) -> Result<oneshot::Receiver<Result<R, StorageError>>, StorageError>
180    where
181        R: Send + 'static,
182        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
183    {
184        self.enqueue_inner(op, false).await
185    }
186
187    /// Shared enqueue path for both transaction-wrapped ([`Self::enqueue`])
188    /// and top-level ([`Self::send_top_level`]) requests — `top_level`
189    /// controls which [`AnyWriteRequest`] method the drain loop invokes.
190    async fn enqueue_inner<R, F>(
191        &self,
192        op: F,
193        top_level: bool,
194    ) -> Result<oneshot::Receiver<Result<R, StorageError>>, StorageError>
195    where
196        R: Send + 'static,
197        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
198    {
199        let (reply_tx, reply_rx) = oneshot::channel();
200        let request = WriteRequest {
201            op: Box::new(op),
202            reply: reply_tx,
203            top_level,
204        };
205
206        self.tx
207            .send(Box::new(request))
208            .await
209            .map_err(|_| StorageError::Internal("writer task channel closed".to_string()))?;
210
211        Ok(reply_rx)
212    }
213
214    /// Send a write operation to the writer task and await its typed reply.
215    ///
216    /// Backpressure: this suspends on the channel's `send().await` when the
217    /// bounded queue is full (ADR-067 "Channel capacity and queue-full
218    /// policy") — there is no `try_send` escape hatch. Callers that need a
219    /// deadline on that wait should use [`Self::send_with_timeout`] instead.
220    pub async fn send<R, F>(&self, op: F) -> Result<R, StorageError>
221    where
222        R: Send + 'static,
223        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
224    {
225        let reply_rx = self.enqueue(op).await?;
226        reply_rx.await.map_err(|_| {
227            StorageError::Internal("writer task dropped before replying".to_string())
228        })?
229    }
230
231    /// Like [`Self::send`], but bounds the wait for the bounded channel to
232    /// free capacity with `timeout`.
233    ///
234    /// The timeout applies ONLY to enqueueing the request (the channel
235    /// `send().await` that can suspend on a full queue) — never to waiting
236    /// for the writer task's reply once the request has been accepted.
237    /// `StorageError::WriteQueueFull` means exactly "the bounded channel was
238    /// full and this request was never accepted"; it must never be returned
239    /// for a request that was accepted and is still executing (or already
240    /// committed) by the time `timeout` elapses — that would misreport a
241    /// slow op or a lock wait as a queue-capacity failure, and could tell a
242    /// caller a write failed when it actually landed. ADR-067's queue-full
243    /// policy has no immediate-error `try_send` path — only this caller-side
244    /// deadline on the enqueue step.
245    pub async fn send_with_timeout<R, F>(
246        &self,
247        op: F,
248        timeout: std::time::Duration,
249    ) -> Result<R, StorageError>
250    where
251        R: Send + 'static,
252        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
253    {
254        let reply_rx = match tokio::time::timeout(timeout, self.enqueue(op)).await {
255            Ok(Ok(reply_rx)) => reply_rx,
256            Ok(Err(e)) => return Err(e),
257            Err(_elapsed) => {
258                return Err(StorageError::WriteQueueFull {
259                    timeout_ms: timeout.as_millis() as u64,
260                })
261            }
262        };
263
264        reply_rx.await.map_err(|_| {
265            StorageError::Internal("writer task dropped before replying".to_string())
266        })?
267    }
268
269    /// Send a write operation that MUST run outside any open transaction
270    /// (e.g. `VACUUM`, which SQLite forbids inside `BEGIN`/`COMMIT`) and
271    /// await its typed reply.
272    ///
273    /// Still serialized through the same single writer owner as
274    /// [`Self::send`] — the request goes through the identical bounded
275    /// channel and drain loop, one request at a time — but the drain loop
276    /// skips the per-request `BEGIN IMMEDIATE`/`COMMIT`/`ROLLBACK` wrap
277    /// entirely for this request (ADR-067 Component A). The single-writer
278    /// guarantee is preserved; only
279    /// the transaction wrap is skipped.
280    pub async fn send_top_level<R, F>(&self, op: F) -> Result<R, StorageError>
281    where
282        R: Send + 'static,
283        F: FnOnce(&Connection) -> Result<R, StorageError> + Send + 'static,
284    {
285        let reply_rx = self.enqueue_inner(op, true).await?;
286        reply_rx.await.map_err(|_| {
287            StorageError::Internal("writer task dropped before replying".to_string())
288        })?
289    }
290
291    /// Current write-queue backlog depth: requests enqueued but not yet
292    /// accepted by the writer task's drain loop.
293    ///
294    /// Reads `mpsc::Sender::max_capacity() - capacity()`, so it is a
295    /// point-in-time snapshot racy under concurrent senders/the drain loop
296    /// draining concurrently — acceptable for a monitoring gauge (the
297    /// load/perf harness metrics read-surface), never used for any correctness
298    /// decision.
299    pub fn queue_depth(&self) -> usize {
300        self.tx.max_capacity() - self.tx.capacity()
301    }
302
303    /// The bounded channel's configured capacity
304    /// (`PoolConfig::write_queue_capacity`).
305    pub fn capacity(&self) -> usize {
306        self.tx.max_capacity()
307    }
308}
309
310/// Spawn the write-owner task (ADR-067 Component A) on the current Tokio
311/// runtime.
312///
313/// Opens a dedicated standalone writer connection
314/// ([`ConnectionPool::open_standalone_writer`]), independent of the pool's
315/// Mutex-guarded `writer()` connection used by unmigrated paths. Returns the
316/// cloneable [`WriterTaskHandle`] sender half; the task runs until every
317/// handle clone is dropped and the channel closes.
318///
319/// `capacity` bounds the channel (`PoolConfig::write_queue_capacity` /
320/// `KHIVE_WRITE_QUEUE_CAPACITY`, ADR-067 recommends 256).
321///
322/// # Errors
323/// Must be called from within a Tokio runtime context (calls
324/// `tokio::spawn`). Returns an error if the pool cannot open a standalone
325/// writer connection (e.g. an in-memory pool has no standalone-connection
326/// support). See `crates/khive-db/docs/api/writer-task.md` for the
327/// migration-slice scope this commits per `BEGIN IMMEDIATE`.
328pub fn spawn(pool: &ConnectionPool, capacity: usize) -> Result<WriterTaskHandle, SqliteError> {
329    let conn = pool.open_standalone_writer()?;
330    let origin = pool.origin();
331    let (tx, rx) = mpsc::channel(capacity.max(1));
332    tokio::spawn(run_writer_task(conn, rx, origin));
333    Ok(WriterTaskHandle { tx })
334}
335
336/// Drain loop: the sole caller of `BEGIN IMMEDIATE` for write traffic routed
337/// through the channel. A `BEGIN IMMEDIATE` failure replies the request's
338/// error via [`AnyWriteRequest::reply_error`] without invoking the
339/// request's closure; no retry — the connection tries fresh next request.
340/// Exits when every [`WriterTaskHandle`] clone drops and the channel closes,
341/// or the blocking closure panics — either way `rx` drops with it, which is
342/// what turns subsequent `send` calls into `StorageError::Internal`. See
343/// `crates/khive-db/docs/api/writer-task.md` for the ADR-067 failure-mode
344/// table this implements.
345async fn run_writer_task(
346    mut conn: Connection,
347    mut rx: mpsc::Receiver<Box<dyn AnyWriteRequest + Send>>,
348    origin: khive_storage::tx_registry::TxOrigin,
349) {
350    while let Some(request) = rx.recv().await {
351        let origin = origin.clone();
352        let outcome = tokio::task::spawn_blocking(move || {
353            if request.is_top_level() {
354                // ADR-067 Component A:
355                // no BEGIN IMMEDIATE for this request — some statements
356                // (e.g. VACUUM) are rejected by SQLite inside any open
357                // transaction. Still runs on this task's dedicated
358                // connection and still serialized one-request-at-a-time by
359                // this same drain loop, so the single-writer guarantee
360                // holds; only the transaction wrap is skipped.
361                request.execute_and_reply_top_level(&conn);
362                return conn;
363            }
364            let _tx_handle = khive_storage::tx_registry::register_scoped(
365                Some("writer_task_tx".to_string()),
366                origin,
367            );
368            match conn.execute_batch("BEGIN IMMEDIATE") {
369                Ok(()) => request.execute_and_reply(&conn),
370                Err(e) => {
371                    // Do NOT run the request's operation: `conn` never
372                    // entered a transaction, so executing the op's DML here
373                    // would run in autocommit mode and land partial writes
374                    // for a request the caller is about to be told failed.
375                    tracing::warn!(
376                        error = %e,
377                        "writer task: BEGIN IMMEDIATE failed; replying an \
378                         error without running the request's operation"
379                    );
380                    request.reply_error(StorageError::Pool {
381                        operation: "writer_task_begin".into(),
382                        message: e.to_string(),
383                    });
384                }
385            }
386            conn
387        })
388        .await;
389
390        match outcome {
391            Ok(returned_conn) => conn = returned_conn,
392            Err(join_err) => {
393                tracing::error!(
394                    error = %join_err,
395                    "writer task blocking closure panicked; writer task is exiting"
396                );
397                return;
398            }
399        }
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::pool::PoolConfig;
407    use serial_test::serial;
408    use std::sync::atomic::{AtomicBool, Ordering};
409    use std::sync::Arc;
410    use std::time::Duration;
411
412    fn file_pool(path: &std::path::Path) -> ConnectionPool {
413        let cfg = PoolConfig {
414            path: Some(path.to_path_buf()),
415            ..PoolConfig::default()
416        };
417        ConnectionPool::new(cfg).expect("pool open")
418    }
419
420    // `#[serial(tx_registry)]`: `run_writer_task` registers a `writer_task_tx`
421    // handle in the process-wide `tx_registry` singleton for the life of each
422    // `BEGIN IMMEDIATE`. Tests that observe the registry (the checkpoint
423    // `tx_age_sweep_*` group) read `tx_registry::oldest()`; an un-serialized
424    // spawning test here would leak a longer-lived `writer_task_tx` into that
425    // read and make the sweep name the wrong transaction. Share the key.
426    #[tokio::test]
427    #[serial(tx_registry)]
428    async fn begin_immediate_failure_replies_error_without_running_op() {
429        // Real lock contention, not a simulation: hold the database-level
430        // write lock from the pool's own writer connection (the unmigrated
431        // path this fix is guarding against) so the writer task's dedicated
432        // connection genuinely fails `BEGIN IMMEDIATE` with `SQLITE_BUSY`
433        // after a short `busy_timeout`.
434        let dir = tempfile::tempdir().unwrap();
435        let path = dir.path().join("writer_task_begin_failure.db");
436        let cfg = PoolConfig {
437            path: Some(path.clone()),
438            busy_timeout: Duration::from_millis(150),
439            ..PoolConfig::default()
440        };
441        let pool = ConnectionPool::new(cfg).unwrap();
442        {
443            let writer = pool.try_writer().unwrap();
444            writer
445                .conn()
446                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
447                .unwrap();
448        }
449
450        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
451
452        let lock_holder = pool.try_writer().unwrap();
453        lock_holder.conn().execute_batch("BEGIN IMMEDIATE").unwrap();
454
455        let op_ran = Arc::new(AtomicBool::new(false));
456        let op_ran_clone = Arc::clone(&op_ran);
457        let result = handle
458            .send(move |conn| {
459                op_ran_clone.store(true, Ordering::SeqCst);
460                conn.execute("INSERT INTO t (id, v) VALUES (99, 'should-not-land')", [])
461                    .map_err(|e| StorageError::Pool {
462                        operation: "test_insert".into(),
463                        message: e.to_string(),
464                    })
465            })
466            .await;
467
468        assert!(
469            matches!(
470                &result,
471                Err(StorageError::Pool { operation, .. }) if operation == "writer_task_begin"
472            ),
473            "expected a writer_task_begin Pool error on BEGIN IMMEDIATE \
474             failure, got {result:?}"
475        );
476        assert!(
477            !op_ran.load(Ordering::SeqCst),
478            "the request's operation closure must never run when BEGIN \
479             IMMEDIATE fails — running it would land a partial write in \
480             autocommit mode for a request the caller is told failed"
481        );
482
483        // Release the contended lock, then verify no row landed from the
484        // failed request.
485        lock_holder.conn().execute_batch("ROLLBACK").unwrap();
486        drop(lock_holder);
487
488        let reader = pool.reader().expect("reader");
489        let count: i64 = reader
490            .conn()
491            .query_row("SELECT COUNT(*) FROM t WHERE id = 99", [], |row| row.get(0))
492            .unwrap();
493        assert_eq!(
494            count, 0,
495            "no row must have landed from the request whose BEGIN IMMEDIATE failed"
496        );
497    }
498
499    // `#[serial(tx_registry)]`: shares the key with the checkpoint
500    // `tx_age_sweep_*` tests — see the note on
501    // `begin_immediate_failure_replies_error_without_running_op`.
502    #[tokio::test]
503    #[serial(tx_registry)]
504    async fn writer_task_executes_op_and_commits() {
505        let dir = tempfile::tempdir().unwrap();
506        let path = dir.path().join("writer_task_commit.db");
507        let pool = file_pool(&path);
508        {
509            let writer = pool.try_writer().unwrap();
510            writer
511                .conn()
512                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
513                .unwrap();
514        }
515
516        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
517
518        let affected = handle
519            .send(|conn| {
520                conn.execute("INSERT INTO t (id, v) VALUES (1, 'hello')", [])
521                    .map_err(|e| StorageError::Pool {
522                        operation: "test_insert".into(),
523                        message: e.to_string(),
524                    })
525            })
526            .await
527            .expect("op should succeed");
528        assert_eq!(affected, 1);
529
530        // Verify the write actually committed to the shared file — read it
531        // back via a fresh pooled reader connection, not the writer task's
532        // own connection.
533        let reader = pool.reader().expect("reader");
534        let v: String = reader
535            .conn()
536            .query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))
537            .expect("row must be committed and visible to a reader");
538        assert_eq!(v, "hello");
539    }
540
541    #[test]
542    fn spawn_fails_on_in_memory_pool() {
543        // In-memory pools have no standalone-connection support
544        // (`ConnectionPool::open_standalone_writer`) — `spawn` must surface
545        // that as an error rather than panicking. Deliberately a plain
546        // `#[test]` (no Tokio runtime): `spawn` fails before it ever reaches
547        // `tokio::spawn`, so no runtime is required for this path.
548        let cfg = PoolConfig {
549            path: None,
550            ..PoolConfig::default()
551        };
552        let pool = ConnectionPool::new(cfg).unwrap();
553        let result = spawn(&pool, 8);
554        assert!(
555            result.is_err(),
556            "in-memory pools must reject spawn, not panic"
557        );
558    }
559
560    #[tokio::test]
561    async fn full_channel_applies_backpressure_not_immediate_error() {
562        // Build the channel directly (bypassing `spawn`/`run_writer_task`)
563        // so nothing ever drains it — deterministic control over "the
564        // channel is full" instead of racing a real writer task's
565        // processing speed.
566        let (tx, _rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(1);
567        let handle = WriterTaskHandle { tx };
568
569        // First send fills the sole channel slot. Its reply never arrives
570        // since nothing drains `_rx`, so run it in the background.
571        let first = tokio::spawn({
572            let handle = handle.clone();
573            async move {
574                let _ = handle.send(|_conn| Ok::<(), StorageError>(())).await;
575            }
576        });
577
578        // Give the first send a moment to occupy the channel slot.
579        tokio::time::sleep(Duration::from_millis(20)).await;
580
581        // Second send must block (backpressure), not fail immediately: a
582        // short timeout should elapse rather than resolve.
583        let second = tokio::time::timeout(
584            Duration::from_millis(100),
585            handle.send(|_conn| Ok::<(), StorageError>(())),
586        )
587        .await;
588
589        assert!(
590            second.is_err(),
591            "a full channel must apply backpressure (send suspends) rather \
592             than erroring immediately — no try_send escape hatch per ADR-067"
593        );
594
595        first.abort();
596    }
597
598    #[tokio::test]
599    async fn send_with_timeout_maps_full_channel_to_write_queue_full() {
600        let (tx, _rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(1);
601        let handle = WriterTaskHandle { tx };
602
603        let first = tokio::spawn({
604            let handle = handle.clone();
605            async move {
606                let _ = handle.send(|_conn| Ok::<(), StorageError>(())).await;
607            }
608        });
609        tokio::time::sleep(Duration::from_millis(20)).await;
610
611        let result = handle
612            .send_with_timeout(
613                |_conn| Ok::<(), StorageError>(()),
614                Duration::from_millis(50),
615            )
616            .await;
617
618        match result {
619            Err(StorageError::WriteQueueFull { timeout_ms }) => assert_eq!(timeout_ms, 50),
620            other => panic!("expected WriteQueueFull, got {other:?}"),
621        }
622
623        first.abort();
624    }
625
626    // `#[serial(tx_registry)]`: this test deliberately keeps a request (and
627    // thus its `writer_task_tx` registry handle) alive past a timeout, so it is
628    // the worst polluter of the checkpoint `tx_age_sweep_*` reads if left
629    // un-serialized. Shares the key — see the note on
630    // `begin_immediate_failure_replies_error_without_running_op`.
631    #[tokio::test]
632    #[serial(tx_registry)]
633    async fn send_with_timeout_returns_op_result_when_op_outlives_the_timeout() {
634        // `send_with_timeout`'s timeout must bound ONLY the enqueue step —
635        // never the reply-wait. An accepted request (channel not full) must
636        // run to completion and report its REAL result even when that takes
637        // longer than `timeout`; before this fix, wrapping the whole
638        // send-plus-reply-wait in one timeout would misreport this as
639        // `WriteQueueFull` despite the write actually landing.
640        let dir = tempfile::tempdir().unwrap();
641        let path = dir.path().join("writer_task_slow_op.db");
642        let pool = file_pool(&path);
643        {
644            let writer = pool.try_writer().unwrap();
645            writer
646                .conn()
647                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
648                .unwrap();
649        }
650
651        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
652
653        let result = handle
654            .send_with_timeout(
655                |conn| {
656                    // Deliberately slower than the timeout below: proves the
657                    // reply-wait itself is never bounded by `timeout`.
658                    std::thread::sleep(Duration::from_millis(150));
659                    conn.execute("INSERT INTO t (id, v) VALUES (1, 'slow')", [])
660                        .map_err(|e| StorageError::Pool {
661                            operation: "test_insert".into(),
662                            message: e.to_string(),
663                        })
664                },
665                Duration::from_millis(20),
666            )
667            .await;
668
669        let affected = result.expect(
670            "an accepted request must return its real result even when the \
671             op takes longer than the enqueue timeout, not WriteQueueFull",
672        );
673        assert_eq!(affected, 1);
674
675        // The slow op's write must have actually committed, not just been
676        // reported as successful.
677        let reader = pool.reader().expect("reader");
678        let v: String = reader
679            .conn()
680            .query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))
681            .expect("the slow op's write must have committed");
682        assert_eq!(v, "slow");
683    }
684
685    #[tokio::test]
686    async fn dropped_receiver_maps_send_to_internal_error() {
687        // Simulates the writer task having stopped/panicked: its `rx` is
688        // gone, so `tx.send()` must fail rather than hang.
689        let (tx, rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(4);
690        drop(rx);
691
692        let handle = WriterTaskHandle { tx };
693        let result = handle.send(|_conn| Ok::<(), StorageError>(())).await;
694
695        match result {
696            Err(StorageError::Internal(_)) => {}
697            other => panic!("expected Internal error on a closed channel, got {other:?}"),
698        }
699    }
700}