//! The notify/listen mechanism's acceptance tests //! (`sqlite-engine-notify-listen`): commit-atomic auto-commit //! notifies (payload crosses the notify table, no size limit), //! watcher-fanout wakes through the bridged receiver, the close arms //! (watcher death ⇒ `None` terminal; `Closed` vs idle in //! `try_recv`/`recv_timeout`), unsubscribe-on-drop, entry-point //! validation, and the `PayloadTooLarge` never-produced pin (ADR-016 //! §5). use std::path::PathBuf; use std::time::Duration; use alkstore::{Error, Store, WakeReceiver}; use serde_json::json; use crate::store::{open, open_store}; fn temp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!( "alkstore-notify-{tag}-{}-{:?}", std::process::id(), std::thread::current().id() )); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } fn cleanup(dir: &PathBuf) { let _ = std::fs::remove_dir_all(dir); } fn temp_path(tag: &str) -> PathBuf { temp_dir(tag).join("store.db") } /// Wait for a wake with a bounded deadline — no tight-race assertions /// (the suite's stability posture). async fn must_recv(receiver: &mut dyn WakeReceiver, tag: &str) -> alkstore::Wake { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { match receiver.try_recv() { Ok(Some(wake)) => return wake, Ok(None) => {} Err(e) => panic!("{tag}: receiver errored instead of idling: {e}"), } assert!( tokio::time::Instant::now() < deadline, "{tag}: wake never arrived" ); tokio::time::sleep(Duration::from_millis(10)).await; } } /// Wait until the predicate holds with a bounded deadline. async fn until bool>(tag: &str, f: F) { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while !f() { assert!( tokio::time::Instant::now() < deadline, "{tag}: condition never held" ); tokio::time::sleep(Duration::from_millis(10)).await; } } /// Acceptance: `notify` delivers commit-atomic auto-commit notifies — /// the payload crosses the notify table (a committed row is readable /// through a raw reader), the notify is a single auto-commit /// transaction (no partial state possible), and the channel rides the /// row. #[tokio::test(flavor = "multi_thread")] async fn notify_delivers_commit_atomic_through_the_notify_table() { let dir = temp_dir("auto-commit"); let path = temp_path("auto-commit"); let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); store .notify("orders", json!({"x": 1})) .await .expect("auto-commit notify succeeds"); let conn = rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) .unwrap(); let (channel, payload): (String, String) = conn .query_row( "SELECT channel, payload FROM __alkstore_notifications ORDER BY id DESC LIMIT 1", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(channel, "orders", "the channel is the row's channel"); assert_eq!( payload, json!({"x": 1}).to_string(), "the payload is the serde_json serialization (ADR-020 §4's byte form)" ); store.close(); cleanup(&dir); } /// Acceptance: no payload limit on SQLite — this engine never produces /// `PayloadTooLarge` at any size (ADR-016 §5's occurrence-asymmetry /// pin). A multi-megabyte payload notifies successfully. #[tokio::test(flavor = "multi_thread")] async fn notify_never_produces_payload_too_large() { let dir = temp_dir("no-limit"); let store = open_store(temp_path("no-limit").to_str().unwrap(), Default::default()).unwrap(); let big = "x".repeat(2_000_000); store .notify("big", json!({"blob": big})) .await .expect("SQLite notify has no payload limit (ADR-016 §5)"); store.close(); cleanup(&dir); } /// Acceptance: an auto-commit op's *error* arm releases the writer /// slot — the lease-out-both-arms property (ADR-007's seam posture): /// after a notify whose statement fails at the storage layer, the slot /// is free again and a subsequent writer op succeeds without parking. #[tokio::test(flavor = "multi_thread")] async fn failed_notify_releases_the_writer_slot() { let dir = temp_dir("err-arm-release"); let path = temp_path("err-arm-release"); let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); let side = rusqlite::Connection::open(&path).unwrap(); side.execute_batch( "CREATE TRIGGER block_notify BEFORE INSERT ON __alkstore_notifications BEGIN SELECT RAISE(ABORT, 'notify insert blocked'); END", ) .unwrap(); let err = store.notify("orders", json!({"x": 1})).await.unwrap_err(); let source = match &err { Error::Database(source) => source.to_string(), other => panic!("the induced failure surfaces as Database, got {other:?}"), }; assert!( source.contains("notify insert blocked"), "the source chain carries the trigger's message, got: {source}" ); side.execute_batch("DROP TRIGGER block_notify;").unwrap(); drop(side); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let landed = tokio::time::timeout_at(deadline, store.notify("orders", json!({"x": 2}))) .await .expect( "the failed op must release the writer slot — the slot is free again, \ never parked", ); landed.expect("the notify lands after a failed op released the slot"); store.close(); cleanup(&dir); } /// Acceptance: `listen` returns a working receiver — wakes arrive /// after commits by other connections, and `Wake { channel }` carries /// the channel name only. The honest posture: the watcher fans out on /// any commit, so the wake is pinned to *arrival*, never exclusivity /// (best-effort hints, ADR-006). #[tokio::test(flavor = "multi_thread")] async fn listen_wakes_arrive_after_commits() { let dir = temp_dir("wake-arrives"); let path = temp_path("wake-arrives"); let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); let mut receiver = store.listen("orders").await.unwrap(); assert_eq!( receiver.try_recv().unwrap(), None, "a fresh receiver idles, it is not closed" ); // A commit from another connection (a fresh store handle over the // same db — the cross-connection probe). let path2 = path.clone(); let other = tokio::spawn(async move { let s = open(path2.to_str().unwrap(), Default::default()).unwrap(); let mut tx = s.begin_tx().await.unwrap(); tx.enqueue_tx("q", Default::default(), json!({})) .await .unwrap(); tx.commit().await.unwrap(); }); other.await.unwrap(); let wake = must_recv(&mut *receiver, "wake-arrives").await; assert_eq!( wake.channel, "orders", "the wake carries the listened channel" ); store.close(); cleanup(&dir); } /// Acceptance: wakes carry the channel only — never payloads or ids /// (ADR-008 §3; a notify's payload crosses the notify table, the wake /// that follows it does not surface the payload). #[tokio::test(flavor = "multi_thread")] async fn wakes_carry_the_channel_only() { let dir = temp_dir("channel-only"); let store = open_store( temp_path("channel-only").to_str().unwrap(), Default::default(), ) .unwrap(); let mut receiver = store.listen("c").await.unwrap(); store .notify("c", json!({"secret": [1, 2, 3]})) .await .unwrap(); let wake = must_recv(&mut *receiver, "channel-only").await; assert_eq!(wake.channel, "c"); store.close(); cleanup(&dir); } /// Acceptance: watcher death closes the receiver terminally — /// `recv() -> None`. Driven through the substrate's death-signal path: /// store close (the ordinary death — the death-guard clears every /// subscriber feed) closes the bridged receiver; it never reopens. #[tokio::test(flavor = "multi_thread")] async fn watcher_death_closes_the_receiver_terminally() { let dir = temp_dir("death-close"); let store = open_store( temp_path("death-close").to_str().unwrap(), Default::default(), ) .unwrap(); let mut receiver = store.listen("orders").await.unwrap(); // Force the substrate's death signal: close the store (the // death-guard clears every subscriber feed — the bridge thread // exits, the receiver sees the disconnect). store.close(); let none = receiver.recv().await; assert!( none.is_none(), "watcher death must close the receiver: got {none:?}" ); // Terminal: try_recv reports Closed, not idle (ADR-008 §3's // distinguishable states). assert!( matches!(receiver.try_recv(), Err(Error::Closed)), "after close, try_recv is Err(Closed) — not idle" ); // Terminal means forever: a second recv is still None. assert!( receiver.recv().await.is_none(), "a closed receiver never reopens" ); cleanup(&dir); } /// Acceptance: `try_recv` distinguishes closed from idle pre-close — /// `Ok(None)` with no wake pending (idle), `Some` when one arrived, /// `Err(Closed)` only after the source closed (the pinned arms). #[tokio::test(flavor = "multi_thread")] async fn try_recv_distinguishes_idle_from_closed() { let dir = temp_dir("try-recv-arms"); let store = open_store( temp_path("try-recv-arms").to_str().unwrap(), Default::default(), ) .unwrap(); let mut receiver = store.listen("c").await.unwrap(); assert_eq!(receiver.try_recv().unwrap(), None, "idle is Ok(None)"); store.notify("c", json!({"n": 1})).await.unwrap(); let wake = must_recv(&mut *receiver, "try-recv-arms").await; assert_eq!(wake.channel, "c"); // Drained again — idle, not closed. assert_eq!( receiver.try_recv().unwrap(), None, "drained is idle, not closed" ); store.close(); assert!( matches!(receiver.try_recv(), Err(Error::Closed)), "post-close try_recv is Err(Closed)" ); cleanup(&dir); } /// Acceptance: `recv_timeout`'s arms match the pinned shapes — timeout /// expiry without a wake is `Ok(None)` (the documented idle arm, never /// a timeout signal); a wake in the window arrives as `Ok(Some)`; the /// source's death is `Err(Closed)`. #[tokio::test(flavor = "multi_thread")] async fn recv_timeout_matches_the_pinned_arms() { let dir = temp_dir("recv-timeout"); let store = open_store( temp_path("recv-timeout").to_str().unwrap(), Default::default(), ) .unwrap(); let mut receiver = store.listen("c").await.unwrap(); // Expiry without a wake: Ok(None) — never Err (ADR-008 §3's pinned // arm: the Err arm carries Database or Closed, never a timeout). let idle = receiver .recv_timeout(Duration::from_millis(100)) .await .unwrap(); assert_eq!(idle, None, "timeout expiry is the documented Ok(None) arm"); // A wake inside the window arrives as Ok(Some). store.notify("c", json!({"n": 1})).await.unwrap(); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let wake = loop { match receiver .recv_timeout(Duration::from_millis(100)) .await .unwrap() { Some(w) => break w, None => { assert!( tokio::time::Instant::now() < deadline, "the wake never arrived within recv_timeout windows" ); } } }; assert_eq!(wake.channel, "c"); // Death: Err(Closed). store.close(); let err = receiver.recv_timeout(Duration::from_millis(100)).await; assert!( matches!(err, Err(Error::Closed)), "post-close recv_timeout is Err(Closed), got {err:?}" ); cleanup(&dir); } /// Acceptance: receiver drop unsubscribes — no leaked subscriptions; /// `subscriber_count` returns to baseline (the substrate prunes /// dropped subscribers; the eager unsubscribe makes it immediate). #[tokio::test(flavor = "multi_thread")] async fn receiver_drop_unsubscribes_without_leaks() { let dir = temp_dir("unsub"); let store = open_store(temp_path("unsub").to_str().unwrap(), Default::default()).unwrap(); let baseline = store.watcher.subscriber_count(); assert_eq!(baseline, 0, "no subscriptions before any listen"); { let receiver = store.listen("c").await.unwrap(); drop(receiver); } // The receiver's Drop unsubscribed eagerly; the bridge thread may // still be exiting, but the substrate-side subscriber is gone. until("unsub", || store.watcher.subscriber_count() == baseline).await; assert_eq!( store.watcher.subscriber_count(), baseline, "receiver drop must not leak subscriptions" ); store.close(); cleanup(&dir); } /// Acceptance: dropping the store closes receivers through the same /// path (Drop delegates to close) — the death arms hold there too. #[tokio::test(flavor = "multi_thread")] async fn dropping_the_store_closes_receivers() { let dir = temp_dir("drop-close"); let mut receiver; { let store = open_store( temp_path("drop-close").to_str().unwrap(), Default::default(), ) .unwrap(); receiver = store.listen("c").await.unwrap(); drop(store); } assert!(receiver.recv().await.is_none(), "drop closes subscribers"); assert!(matches!(receiver.try_recv(), Err(Error::Closed))); cleanup(&dir); } /// Acceptance: channel validation on both entry points — empty / /// whitespace-only rejected with `InvalidName`, reserved-prefix names /// rejected with `ReservedName`, before any round trip (ADR-008 §4; /// the validation failures leave no notify row, hold no subscription). #[tokio::test(flavor = "multi_thread")] async fn entry_points_validate_channels() { let dir = temp_dir("validation"); let path = temp_path("validation"); let store = open_store(path.to_str().unwrap(), Default::default()).unwrap(); for channel in [ "", " ", "__alkstore_listener_reconnected__", "__alkstore_x", ] { let err = match store.notify(channel, json!({})).await { Err(e) => e, Ok(()) => panic!("notify({channel:?}) must reject by validation"), }; match err { Error::InvalidName { name } => assert_eq!(channel, name), Error::ReservedName { name } => assert_eq!(channel, name), other => panic!("notify({channel:?}) must reject by validation, got {other}"), } let err = match store.listen(channel).await { Err(e) => e, Ok(_) => panic!("listen({channel:?}) must reject by validation"), }; match err { Error::InvalidName { name } => assert_eq!(channel, name), Error::ReservedName { name } => assert_eq!(channel, name), other => panic!("listen({channel:?}) must reject by validation, got {other}"), } } // The rejects were total: no notify row was written and no // subscription was held. let conn = rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) .unwrap(); let rows: i64 = conn .query_row("SELECT COUNT(*) FROM __alkstore_notifications", [], |r| { r.get(0) }) .unwrap(); assert_eq!(rows, 0, "rejected notifies never touched the table"); assert_eq!( store.watcher.subscriber_count(), 0, "rejected listens never subscribed" ); store.close(); cleanup(&dir); } /// Acceptance: multiple listeners on one channel all fan out /// independently; an unrelated channel's listen also wakes (the /// honest overtrigger posture — pinned as hint-arrival, not /// exclusivity, per the task's note). #[tokio::test(flavor = "multi_thread")] async fn multiple_listeners_fan_out() { let dir = temp_dir("fanout"); let store = open_store(temp_path("fanout").to_str().unwrap(), Default::default()).unwrap(); let mut a = store.listen("a").await.unwrap(); let mut b = store.listen("a").await.unwrap(); let mut other_ch = store.listen("zzz").await.unwrap(); store.notify("a", json!({})).await.unwrap(); assert_eq!(must_recv(&mut *a, "fanout-a").await.channel, "a"); assert_eq!(must_recv(&mut *b, "fanout-b").await.channel, "a"); // Overtrigger on purpose: any commit wakes every subscriber; the // other channel's receiver carries its own channel name in the // wake (hints are coalesced, channel-correct, not exclusive). let wake = must_recv(&mut *other_ch, "fanout-other").await; assert_eq!(wake.channel, "zzz"); store.close(); cleanup(&dir); } /// Acceptance: notifies on a closed store fail closed with /// `Error::Database` (the engine-wide closed-store posture — /// `begin_tx` yields the same shape); listens on a closed store do /// too. #[tokio::test(flavor = "multi_thread")] async fn closed_store_fails_notify_and_listen() { let dir = temp_dir("closed-store"); let store = open_store( temp_path("closed-store").to_str().unwrap(), Default::default(), ) .unwrap(); store.close(); let err = store.notify("c", json!({})).await.unwrap_err(); assert!( matches!(err, Error::Database(_)), "notify on a closed store fails closed, got {err}" ); let err = match store.listen("c").await { Err(e) => e, Ok(_) => panic!("listen on a closed store must fail closed"), }; assert!( matches!(err, Error::Database(_)), "listen on a closed store fails closed, got {err}" ); cleanup(&dir); } /// Acceptance: `notify_tx` wakes listeners at commit — the tx half /// rides the same wake plumbing (in-tx notifies are invisible until /// commit; the commit wakes the listener; rollback wakes nothing). #[tokio::test(flavor = "multi_thread")] async fn tx_notify_wakes_at_commit_and_rollback_wakes_nothing() { let dir = temp_dir("tx-wake"); let store = open_store(temp_path("tx-wake").to_str().unwrap(), Default::default()).unwrap(); let mut receiver = store.listen("orders").await.unwrap(); let mut tx = store.begin_tx().await.unwrap(); tx.notify_tx("orders", json!({"x": 1})).await.unwrap(); // In-tx: the wake is not yet deliverable — nothing committed. tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!( receiver.try_recv().unwrap(), None, "an uncommitted notify must not wake" ); tx.commit().await.unwrap(); let wake = must_recv(&mut *receiver, "tx-wake").await; assert_eq!(wake.channel, "orders"); // Rollback: the notify row is dropped with the tx — no wake fires // (a rollback does not bump data_version; the substrate pins // commit-only wakes). let mut tx = store.begin_tx().await.unwrap(); tx.notify_tx("orders", json!({"x": 2})).await.unwrap(); drop(tx); tokio::time::sleep(Duration::from_millis(200)).await; // The watcher may legitimately emit a delayed wake for the *first* // commit if it hadn't drained; drain what's there and require only // that no wake arrives *after* a quiet settle (coalescing is the // contract's shape, not a defect). while receiver.try_recv().unwrap().is_some() {} tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!( receiver.try_recv().unwrap(), None, "a rolled-back notify must not wake" ); store.close(); cleanup(&dir); }