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