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}