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 (tx, rx) = mpsc::channel(capacity.max(1));
331    tokio::spawn(run_writer_task(conn, rx));
332    Ok(WriterTaskHandle { tx })
333}
334
335/// Drain loop: the sole caller of `BEGIN IMMEDIATE` for write traffic routed
336/// through the channel. A `BEGIN IMMEDIATE` failure replies the request's
337/// error via [`AnyWriteRequest::reply_error`] without invoking the
338/// request's closure; no retry — the connection tries fresh next request.
339/// Exits when every [`WriterTaskHandle`] clone drops and the channel closes,
340/// or the blocking closure panics — either way `rx` drops with it, which is
341/// what turns subsequent `send` calls into `StorageError::Internal`. See
342/// `crates/khive-db/docs/api/writer-task.md` for the ADR-067 failure-mode
343/// table this implements.
344async fn run_writer_task(
345    mut conn: Connection,
346    mut rx: mpsc::Receiver<Box<dyn AnyWriteRequest + Send>>,
347) {
348    while let Some(request) = rx.recv().await {
349        let outcome = tokio::task::spawn_blocking(move || {
350            if request.is_top_level() {
351                // ADR-067 Component A:
352                // no BEGIN IMMEDIATE for this request — some statements
353                // (e.g. VACUUM) are rejected by SQLite inside any open
354                // transaction. Still runs on this task's dedicated
355                // connection and still serialized one-request-at-a-time by
356                // this same drain loop, so the single-writer guarantee
357                // holds; only the transaction wrap is skipped.
358                request.execute_and_reply_top_level(&conn);
359                return conn;
360            }
361            let _tx_handle =
362                khive_storage::tx_registry::register(Some("writer_task_tx".to_string()));
363            match conn.execute_batch("BEGIN IMMEDIATE") {
364                Ok(()) => request.execute_and_reply(&conn),
365                Err(e) => {
366                    // Do NOT run the request's operation: `conn` never
367                    // entered a transaction, so executing the op's DML here
368                    // would run in autocommit mode and land partial writes
369                    // for a request the caller is about to be told failed.
370                    tracing::warn!(
371                        error = %e,
372                        "writer task: BEGIN IMMEDIATE failed; replying an \
373                         error without running the request's operation"
374                    );
375                    request.reply_error(StorageError::Pool {
376                        operation: "writer_task_begin".into(),
377                        message: e.to_string(),
378                    });
379                }
380            }
381            conn
382        })
383        .await;
384
385        match outcome {
386            Ok(returned_conn) => conn = returned_conn,
387            Err(join_err) => {
388                tracing::error!(
389                    error = %join_err,
390                    "writer task blocking closure panicked; writer task is exiting"
391                );
392                return;
393            }
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::pool::PoolConfig;
402    use serial_test::serial;
403    use std::sync::atomic::{AtomicBool, Ordering};
404    use std::sync::Arc;
405    use std::time::Duration;
406
407    fn file_pool(path: &std::path::Path) -> ConnectionPool {
408        let cfg = PoolConfig {
409            path: Some(path.to_path_buf()),
410            ..PoolConfig::default()
411        };
412        ConnectionPool::new(cfg).expect("pool open")
413    }
414
415    // `#[serial(tx_registry)]`: `run_writer_task` registers a `writer_task_tx`
416    // handle in the process-wide `tx_registry` singleton for the life of each
417    // `BEGIN IMMEDIATE`. Tests that observe the registry (the checkpoint
418    // `tx_age_sweep_*` group) read `tx_registry::oldest()`; an un-serialized
419    // spawning test here would leak a longer-lived `writer_task_tx` into that
420    // read and make the sweep name the wrong transaction. Share the key.
421    #[tokio::test]
422    #[serial(tx_registry)]
423    async fn begin_immediate_failure_replies_error_without_running_op() {
424        // Real lock contention, not a simulation: hold the database-level
425        // write lock from the pool's own writer connection (the unmigrated
426        // path this fix is guarding against) so the writer task's dedicated
427        // connection genuinely fails `BEGIN IMMEDIATE` with `SQLITE_BUSY`
428        // after a short `busy_timeout`.
429        let dir = tempfile::tempdir().unwrap();
430        let path = dir.path().join("writer_task_begin_failure.db");
431        let cfg = PoolConfig {
432            path: Some(path.clone()),
433            busy_timeout: Duration::from_millis(150),
434            ..PoolConfig::default()
435        };
436        let pool = ConnectionPool::new(cfg).unwrap();
437        {
438            let writer = pool.try_writer().unwrap();
439            writer
440                .conn()
441                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
442                .unwrap();
443        }
444
445        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
446
447        let lock_holder = pool.try_writer().unwrap();
448        lock_holder.conn().execute_batch("BEGIN IMMEDIATE").unwrap();
449
450        let op_ran = Arc::new(AtomicBool::new(false));
451        let op_ran_clone = Arc::clone(&op_ran);
452        let result = handle
453            .send(move |conn| {
454                op_ran_clone.store(true, Ordering::SeqCst);
455                conn.execute("INSERT INTO t (id, v) VALUES (99, 'should-not-land')", [])
456                    .map_err(|e| StorageError::Pool {
457                        operation: "test_insert".into(),
458                        message: e.to_string(),
459                    })
460            })
461            .await;
462
463        assert!(
464            matches!(
465                &result,
466                Err(StorageError::Pool { operation, .. }) if operation == "writer_task_begin"
467            ),
468            "expected a writer_task_begin Pool error on BEGIN IMMEDIATE \
469             failure, got {result:?}"
470        );
471        assert!(
472            !op_ran.load(Ordering::SeqCst),
473            "the request's operation closure must never run when BEGIN \
474             IMMEDIATE fails — running it would land a partial write in \
475             autocommit mode for a request the caller is told failed"
476        );
477
478        // Release the contended lock, then verify no row landed from the
479        // failed request.
480        lock_holder.conn().execute_batch("ROLLBACK").unwrap();
481        drop(lock_holder);
482
483        let reader = pool.reader().expect("reader");
484        let count: i64 = reader
485            .conn()
486            .query_row("SELECT COUNT(*) FROM t WHERE id = 99", [], |row| row.get(0))
487            .unwrap();
488        assert_eq!(
489            count, 0,
490            "no row must have landed from the request whose BEGIN IMMEDIATE failed"
491        );
492    }
493
494    // `#[serial(tx_registry)]`: shares the key with the checkpoint
495    // `tx_age_sweep_*` tests — see the note on
496    // `begin_immediate_failure_replies_error_without_running_op`.
497    #[tokio::test]
498    #[serial(tx_registry)]
499    async fn writer_task_executes_op_and_commits() {
500        let dir = tempfile::tempdir().unwrap();
501        let path = dir.path().join("writer_task_commit.db");
502        let pool = file_pool(&path);
503        {
504            let writer = pool.try_writer().unwrap();
505            writer
506                .conn()
507                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
508                .unwrap();
509        }
510
511        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
512
513        let affected = handle
514            .send(|conn| {
515                conn.execute("INSERT INTO t (id, v) VALUES (1, 'hello')", [])
516                    .map_err(|e| StorageError::Pool {
517                        operation: "test_insert".into(),
518                        message: e.to_string(),
519                    })
520            })
521            .await
522            .expect("op should succeed");
523        assert_eq!(affected, 1);
524
525        // Verify the write actually committed to the shared file — read it
526        // back via a fresh pooled reader connection, not the writer task's
527        // own connection.
528        let reader = pool.reader().expect("reader");
529        let v: String = reader
530            .conn()
531            .query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))
532            .expect("row must be committed and visible to a reader");
533        assert_eq!(v, "hello");
534    }
535
536    #[test]
537    fn spawn_fails_on_in_memory_pool() {
538        // In-memory pools have no standalone-connection support
539        // (`ConnectionPool::open_standalone_writer`) — `spawn` must surface
540        // that as an error rather than panicking. Deliberately a plain
541        // `#[test]` (no Tokio runtime): `spawn` fails before it ever reaches
542        // `tokio::spawn`, so no runtime is required for this path.
543        let cfg = PoolConfig {
544            path: None,
545            ..PoolConfig::default()
546        };
547        let pool = ConnectionPool::new(cfg).unwrap();
548        let result = spawn(&pool, 8);
549        assert!(
550            result.is_err(),
551            "in-memory pools must reject spawn, not panic"
552        );
553    }
554
555    #[tokio::test]
556    async fn full_channel_applies_backpressure_not_immediate_error() {
557        // Build the channel directly (bypassing `spawn`/`run_writer_task`)
558        // so nothing ever drains it — deterministic control over "the
559        // channel is full" instead of racing a real writer task's
560        // processing speed.
561        let (tx, _rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(1);
562        let handle = WriterTaskHandle { tx };
563
564        // First send fills the sole channel slot. Its reply never arrives
565        // since nothing drains `_rx`, so run it in the background.
566        let first = tokio::spawn({
567            let handle = handle.clone();
568            async move {
569                let _ = handle.send(|_conn| Ok::<(), StorageError>(())).await;
570            }
571        });
572
573        // Give the first send a moment to occupy the channel slot.
574        tokio::time::sleep(Duration::from_millis(20)).await;
575
576        // Second send must block (backpressure), not fail immediately: a
577        // short timeout should elapse rather than resolve.
578        let second = tokio::time::timeout(
579            Duration::from_millis(100),
580            handle.send(|_conn| Ok::<(), StorageError>(())),
581        )
582        .await;
583
584        assert!(
585            second.is_err(),
586            "a full channel must apply backpressure (send suspends) rather \
587             than erroring immediately — no try_send escape hatch per ADR-067"
588        );
589
590        first.abort();
591    }
592
593    #[tokio::test]
594    async fn send_with_timeout_maps_full_channel_to_write_queue_full() {
595        let (tx, _rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(1);
596        let handle = WriterTaskHandle { tx };
597
598        let first = tokio::spawn({
599            let handle = handle.clone();
600            async move {
601                let _ = handle.send(|_conn| Ok::<(), StorageError>(())).await;
602            }
603        });
604        tokio::time::sleep(Duration::from_millis(20)).await;
605
606        let result = handle
607            .send_with_timeout(
608                |_conn| Ok::<(), StorageError>(()),
609                Duration::from_millis(50),
610            )
611            .await;
612
613        match result {
614            Err(StorageError::WriteQueueFull { timeout_ms }) => assert_eq!(timeout_ms, 50),
615            other => panic!("expected WriteQueueFull, got {other:?}"),
616        }
617
618        first.abort();
619    }
620
621    // `#[serial(tx_registry)]`: this test deliberately keeps a request (and
622    // thus its `writer_task_tx` registry handle) alive past a timeout, so it is
623    // the worst polluter of the checkpoint `tx_age_sweep_*` reads if left
624    // un-serialized. Shares the key — see the note on
625    // `begin_immediate_failure_replies_error_without_running_op`.
626    #[tokio::test]
627    #[serial(tx_registry)]
628    async fn send_with_timeout_returns_op_result_when_op_outlives_the_timeout() {
629        // `send_with_timeout`'s timeout must bound ONLY the enqueue step —
630        // never the reply-wait. An accepted request (channel not full) must
631        // run to completion and report its REAL result even when that takes
632        // longer than `timeout`; before this fix, wrapping the whole
633        // send-plus-reply-wait in one timeout would misreport this as
634        // `WriteQueueFull` despite the write actually landing.
635        let dir = tempfile::tempdir().unwrap();
636        let path = dir.path().join("writer_task_slow_op.db");
637        let pool = file_pool(&path);
638        {
639            let writer = pool.try_writer().unwrap();
640            writer
641                .conn()
642                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
643                .unwrap();
644        }
645
646        let handle = spawn(&pool, 8).expect("writer task should spawn on a file-backed pool");
647
648        let result = handle
649            .send_with_timeout(
650                |conn| {
651                    // Deliberately slower than the timeout below: proves the
652                    // reply-wait itself is never bounded by `timeout`.
653                    std::thread::sleep(Duration::from_millis(150));
654                    conn.execute("INSERT INTO t (id, v) VALUES (1, 'slow')", [])
655                        .map_err(|e| StorageError::Pool {
656                            operation: "test_insert".into(),
657                            message: e.to_string(),
658                        })
659                },
660                Duration::from_millis(20),
661            )
662            .await;
663
664        let affected = result.expect(
665            "an accepted request must return its real result even when the \
666             op takes longer than the enqueue timeout, not WriteQueueFull",
667        );
668        assert_eq!(affected, 1);
669
670        // The slow op's write must have actually committed, not just been
671        // reported as successful.
672        let reader = pool.reader().expect("reader");
673        let v: String = reader
674            .conn()
675            .query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))
676            .expect("the slow op's write must have committed");
677        assert_eq!(v, "slow");
678    }
679
680    #[tokio::test]
681    async fn dropped_receiver_maps_send_to_internal_error() {
682        // Simulates the writer task having stopped/panicked: its `rx` is
683        // gone, so `tx.send()` must fail rather than hang.
684        let (tx, rx) = mpsc::channel::<Box<dyn AnyWriteRequest + Send>>(4);
685        drop(rx);
686
687        let handle = WriterTaskHandle { tx };
688        let result = handle.send(|_conn| Ok::<(), StorageError>(())).await;
689
690        match result {
691            Err(StorageError::Internal(_)) => {}
692            other => panic!("expected Internal error on a closed channel, got {other:?}"),
693        }
694    }
695}