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}