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}