From cc61e9d32d2de56221c9914d0c7c521b7c96cef5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:37:49 -0800 Subject: [PATCH 01/64] perf(sessions): read projection queue heads once per window Avoid repeating ordered scans over permanently parked collision rows for every item in a 32-observation write transaction. Keep strict retry fences, atomic rollback, bounded windows, and restart checkpoints. Refs #4756 --- .../src/observation_projection/rebuild.rs | 82 ++++++++----- .../observation_projection/queue.rs | 113 ++++++++++++++++++ 2 files changed, 162 insertions(+), 33 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index e32499a11b5..5e5bb36b5db 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -225,12 +225,14 @@ pub async fn project_queued_observations( .await .map_err(|error| storage("begin projection window transaction", error))?; let now_micros = tracedecay_contracts::clock::now_micros().0; - let mut items = Vec::new(); - while items.len() < max { - let Some(observation_id) = next_ready_projection_head(&transaction, now_micros).await? - else { - break; - }; + // Read the next ordered window only once. The old query ran a fresh + // MIN(observation_sequence) scan for every projected item. Permanently + // parked collision rows remain in this queue, so every new item re-walked + // that same growing prefix even inside one 32-item write transaction. + // The first not-yet-due row still fences every later row. + let ready = ready_projection_window(&transaction, now_micros, max).await?; + let mut items = Vec::with_capacity(ready.len()); + for observation_id in ready { // Boxed for the same reason as the scalar drain: the collision-guarded // apply subtree overflows the base-opt worker stack when inlined. match Box::pin(project_observation_in_transaction_with_session( @@ -259,47 +261,61 @@ pub async fn project_queued_observations( Ok(ProjectionDrainBatch { items, has_more }) } -/// Ready queue head under the same gate as the runtime's -/// `NextQueuedProjection` read: strict minimum sequence, retry deadline -/// passed, and no active projection rebuild generation. -async fn next_ready_projection_head( +/// The strict sequence head is the first nonterminal queue row, even when +/// that row is not due yet. Read at most one write-window worth of heads +/// through the sequence index, stopping at that first deferred row. This +/// preserves head-of-line retry fences and active-rebuild suppression while +/// avoiding one full queued-prefix probe per observation. +async fn ready_projection_window( conn: &impl QueryExecutor, now_micros: i64, -) -> ProjectionStoreResult> { + max: usize, +) -> ProjectionStoreResult> { + let limit = i64::try_from(max).map_err(|_| { + storage_message( + "read projection queue window", + "projection window exceeds SQLite integer range", + ) + })?; let mut rows = conn .query( - "SELECT observation_id FROM projection_queue - WHERE next_retry_at_micros <= ?2 - AND observation_sequence = ( - SELECT MIN(observation_sequence) FROM projection_queue - WHERE next_retry_at_micros < ?3 - ) + "SELECT observation_id, next_retry_at_micros FROM projection_queue + WHERE next_retry_at_micros < ?1 AND NOT EXISTS ( SELECT 1 FROM observation_projection_rebuilds - WHERE projector_version = ?1 + WHERE projector_version = ?2 ) - LIMIT 1", + ORDER BY observation_sequence + LIMIT ?3", params![ + PROJECTION_TERMINAL_RETRY_MICROS, SESSION_MESSAGE_PROJECTOR_VERSION, - now_micros, - PROJECTION_TERMINAL_RETRY_MICROS + limit ], ) .await - .map_err(|error| storage("read projection queue head", error))?; - let Some(row) = rows + .map_err(|error| storage("read projection queue window", error))?; + let mut ready = Vec::new(); + while let Some(row) = rows .next() .await - .map_err(|error| storage("read projection queue head", error))? - else { - return Ok(None); - }; - let observation_id = row - .get::(0) - .map_err(|error| storage("decode projection queue head", error))?; - Ok(Some( - CanonicalObservationIdV1::new(observation_id).map_err(ProjectionStoreError::Contract)?, - )) + .map_err(|error| storage("read projection queue window", error))? + { + let due = row + .get::(1) + .map_err(|error| storage("decode projection queue deadline", error))?; + if due > now_micros { + break; + } + let observation_id = row + .get::(0) + .map_err(|error| storage("decode projection queue head", error))?; + ready.push( + CanonicalObservationIdV1::new(observation_id) + .map_err(ProjectionStoreError::Contract)?, + ); + } + Ok(ready) } /// Whether a later drain still has work. A row parked on a terminal collision diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 8308e448ad9..4fdf60abea5 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -568,3 +568,116 @@ async fn stale_exact_duplicate_queue_item_is_consumed_before_later_observation() ); assert_eq!(projection_counts(&tmp).await, (1, 2, 2, 1, 0, 0)); } + +#[tokio::test] +async fn batched_projection_resumes_after_restart_without_replaying_parked_or_deferred_heads() { + let tmp = TempDir::new().unwrap(); + let database_path; + let records = (0_u64..5) + .map(|ordinal| { + observation( + "session-windowed-restart", + ordinal * 100, + (ordinal + 1) * 100, + &format!("receipt.windowed.{ordinal}"), + conversational_payload( + &format!("message-windowed-{ordinal}"), + &format!("windowed restart canary {ordinal}"), + ), + ) + }) + .collect::>(); + + { + let runtime = profile_runtime(&tmp).await; + let store = runtime + .observation_store(HostAdmissionScope::Profile) + .unwrap(); + database_path = runtime + .database_path(HostAdmissionScope::Profile) + .unwrap() + .to_path_buf(); + + for (index, record) in records.iter().enumerate() { + let expected = (index > 0) + .then(|| cursor("session-windowed-restart", index as u64 * 100)); + persist(&store, record.clone(), expected).await; + } + store + .project_observation(records[0].observation_id()) + .await + .unwrap(); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 1); + + // A terminal collision remains in the queue behind the committed + // checkpoint, while the next real head is initially ready. + let conn = rusqlite::Connection::open(&database_path).unwrap(); + conn.execute( + "INSERT INTO projection_queue (observation_id, observation_sequence) + VALUES (?1, 1)", + rusqlite::params![records[0].observation_id().as_str()], + ) + .unwrap(); + conn.execute( + "UPDATE projection_queue SET next_retry_at_micros = ?2 + WHERE observation_id = ?1", + rusqlite::params![records[0].observation_id().as_str(), i64::MAX], + ) + .unwrap(); + // A nonterminal future retry at sequence 4 must fence sequence 5 + // even though sequence 5 is already due. + conn.execute( + "UPDATE projection_queue SET next_retry_at_micros = ?2 + WHERE observation_id = ?1", + rusqlite::params![ + records[3].observation_id().as_str(), + i64::MAX - 1 + ], + ) + .unwrap(); + drop(conn); + + let batch = store.project_queued_observations(32).await.unwrap().unwrap(); + assert_eq!(batch.items.len(), 2, "only sequences 2 and 3 are ready"); + assert!(batch.has_more, "the deferred sequence 4 must remain pending"); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 3); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 3); + } + + { + let runtime = profile_runtime(&tmp).await; + let store = runtime + .observation_store(HostAdmissionScope::Profile) + .unwrap(); + let parked = store.project_queued_observations(32).await.unwrap().unwrap(); + assert!(parked.items.is_empty(), "restart must not replay committed work"); + assert!(parked.has_more, "restart must not skip the deferred head"); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 3); + + rusqlite::Connection::open(&database_path) + .unwrap() + .execute( + "UPDATE projection_queue SET next_retry_at_micros = 0 + WHERE observation_id = ?1", + rusqlite::params![records[3].observation_id().as_str()], + ) + .unwrap(); + + let first = store.project_queued_observations(1).await.unwrap().unwrap(); + assert_eq!(first.items.len(), 1, "one-item window must not overrun its bound"); + assert!(first.has_more); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 4); + + let tail = store.project_queued_observations(32).await.unwrap().unwrap(); + assert_eq!(tail.items.len(), 1); + assert!(!tail.has_more, "the terminal collision is not runnable backlog"); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 5); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 5); + assert_eq!(table_count(&tmp, "projection_queue").await, 1); + + let idle = store.project_queued_observations(32).await.unwrap().unwrap(); + assert!(idle.items.is_empty()); + assert!(!idle.has_more); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 5); + } +} From e1874e3bb55f76894c7d27b250a17c13083038e0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:25:36 -0800 Subject: [PATCH 02/64] style(sessions): format queue-head window regression --- .../observation_projection/queue.rs | 84 ++++++++++++++----- 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 4fdf60abea5..f17fc789e8c 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -599,15 +599,18 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de .to_path_buf(); for (index, record) in records.iter().enumerate() { - let expected = (index > 0) - .then(|| cursor("session-windowed-restart", index as u64 * 100)); + let expected = + (index > 0).then(|| cursor("session-windowed-restart", index as u64 * 100)); persist(&store, record.clone(), expected).await; } store .project_observation(records[0].observation_id()) .await .unwrap(); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 1); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 1 + ); // A terminal collision remains in the queue behind the committed // checkpoint, while the next real head is initially ready. @@ -629,18 +632,25 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de conn.execute( "UPDATE projection_queue SET next_retry_at_micros = ?2 WHERE observation_id = ?1", - rusqlite::params![ - records[3].observation_id().as_str(), - i64::MAX - 1 - ], + rusqlite::params![records[3].observation_id().as_str(), i64::MAX - 1], ) .unwrap(); drop(conn); - let batch = store.project_queued_observations(32).await.unwrap().unwrap(); + let batch = store + .project_queued_observations(32) + .await + .unwrap() + .unwrap(); assert_eq!(batch.items.len(), 2, "only sequences 2 and 3 are ready"); - assert!(batch.has_more, "the deferred sequence 4 must remain pending"); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 3); + assert!( + batch.has_more, + "the deferred sequence 4 must remain pending" + ); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 3 + ); assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 3); } @@ -649,10 +659,20 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de let store = runtime .observation_store(HostAdmissionScope::Profile) .unwrap(); - let parked = store.project_queued_observations(32).await.unwrap().unwrap(); - assert!(parked.items.is_empty(), "restart must not replay committed work"); + let parked = store + .project_queued_observations(32) + .await + .unwrap() + .unwrap(); + assert!( + parked.items.is_empty(), + "restart must not replay committed work" + ); assert!(parked.has_more, "restart must not skip the deferred head"); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 3); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 3 + ); rusqlite::Connection::open(&database_path) .unwrap() @@ -664,20 +684,44 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de .unwrap(); let first = store.project_queued_observations(1).await.unwrap().unwrap(); - assert_eq!(first.items.len(), 1, "one-item window must not overrun its bound"); + assert_eq!( + first.items.len(), + 1, + "one-item window must not overrun its bound" + ); assert!(first.has_more); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 4); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 4 + ); - let tail = store.project_queued_observations(32).await.unwrap().unwrap(); + let tail = store + .project_queued_observations(32) + .await + .unwrap() + .unwrap(); assert_eq!(tail.items.len(), 1); - assert!(!tail.has_more, "the terminal collision is not runnable backlog"); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 5); + assert!( + !tail.has_more, + "the terminal collision is not runnable backlog" + ); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 5 + ); assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 5); assert_eq!(table_count(&tmp, "projection_queue").await, 1); - let idle = store.project_queued_observations(32).await.unwrap().unwrap(); + let idle = store + .project_queued_observations(32) + .await + .unwrap() + .unwrap(); assert!(idle.items.is_empty()); assert!(!idle.has_more); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 5); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 5 + ); } } From 7f8147b274c22f23cfa051e867e394c2940c841d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:48:44 -0800 Subject: [PATCH 03/64] perf(sessions): avoid idle projection writer transactions Refs #4756 --- .../src/observation_projection/rebuild.rs | 20 +++++++++ .../observation_projection/queue.rs | 43 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index 5e5bb36b5db..7ef3d74d717 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -220,6 +220,26 @@ pub async fn project_queued_observations( if max == 0 { return Ok(ProjectionDrainBatch::default()); } + // An idle drain needs no writer lease. This constant-work existence probe + // deliberately does not scan for a ready head: large queues of terminal + // collisions must not add a second ordered scan to active write windows. + // The transaction below remains the authority for concurrent admissions + // and enforces ordering after taking the writer lease. + { + let reader = database.read_connection(); + let mut rows = reader + .query("SELECT 1 FROM projection_queue LIMIT 1", ()) + .await + .map_err(|error| storage("probe empty projection queue", error))?; + if rows + .next() + .await + .map_err(|error| storage("probe empty projection queue", error))? + .is_none() + { + return Ok(ProjectionDrainBatch::default()); + } + } let transaction = database .begin_write_transaction("begin projection window transaction") .await diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index f17fc789e8c..546e4eabe60 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -725,3 +725,46 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de ); } } + +#[tokio::test] +async fn empty_projection_drain_does_not_wait_for_an_unrelated_writer() { + let tmp = TempDir::new().unwrap(); + let runtime = profile_runtime(&tmp).await; + let store = runtime + .observation_store(HostAdmissionScope::Profile) + .unwrap(); + let database_path = runtime + .database_path(HostAdmissionScope::Profile) + .unwrap(); + + // A separate connection holds SQLite's writer reservation. An idle + // projection pass must still finish using only the read connection. + let writer = rusqlite::Connection::open(database_path).unwrap(); + writer.execute_batch("BEGIN IMMEDIATE").unwrap(); + let empty = tokio::time::timeout( + std::time::Duration::from_secs(2), + store.project_queued_observations(32), + ) + .await + .expect("an empty projection pass must not acquire the writer") + .unwrap() + .unwrap(); + assert!(empty.items.is_empty()); + assert!(!empty.has_more); + writer.execute_batch("ROLLBACK").unwrap(); + + // Skipping the empty writer transaction must never suppress subsequent + // admissions after the external writer releases its reservation. + let new_record = observation( + "session-empty-drain", + 0, + 100, + "receipt.empty-drain", + conversational_payload("message-empty-drain", "post-idle admission"), + ); + persist(&store, new_record, None).await; + let after = store.project_queued_observations(32).await.unwrap().unwrap(); + assert_eq!(after.items.len(), 1); + assert!(!after.has_more); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 1); +} From d265e31e513a01e5a14cb189f459a1432682a6b9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:57:25 -0800 Subject: [PATCH 04/64] perf(sessions): skip terminal retries during restart re-arm Bound re-arm write batches to actually retryable rows instead of visiting permanently parked collisions as empty batches on every fresh store mount. Add behavioral coverage for interleaved terminal and deferred queue rows, preserved retry history, and zero no-op batches. Refs #4756 --- .../src/observation_projection/rearm_tests.rs | 49 +++++++++++++++++++ .../src/observation_projection/state.rs | 13 +++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 37d8654ebdf..a917d5c37a4 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -189,6 +189,55 @@ async fn rearm_walks_deferred_retries_in_bounded_batches() { ); } +#[asupersync::test] +async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() { + let harness = RegisteredGlobalDbHarness::open("rearm-terminal-collisions").await; + seed_queue(&harness, 9, 3).await; + + // Six permanently parked collisions are interleaved with three deferred + // retries and three already-due rows. Neither parked nor due entries may + // consume a restart re-arm write window. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?1 + WHERE observation_sequence IN (1, 3, 5, 7, 9, 11)", + params![i64::MAX], + ) + .await + .unwrap(), + 6 + ); + transaction.commit().await.unwrap(); + assert_eq!(queue_state(&harness).await, (12, 9, 12)); + + let rearmed = rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .unwrap(); + assert_eq!( + rearmed, + RearmedProjectionRetries { + rows: 3, + batches: 2, + }, + "parked collisions must not consume re-arm write windows" + ); + assert_eq!( + queue_state(&harness).await, + (12, 6, 12), + "only retryable deadlines clear; terminal collisions remain parked" + ); + + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .unwrap(), + RearmedProjectionRetries::default(), + "restarts with only terminal collisions cannot schedule empty write batches" + ); +} + #[asupersync::test] async fn daemon_admission_defers_rearm_to_background_convergence() { let harness = RegisteredGlobalDbHarness::open("rearm-daemon-admission").await; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 14763f15083..809944f7500 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -320,10 +320,17 @@ async fn rearm_projection_retry_batch( &transaction, "SELECT MAX(rowid) FROM ( SELECT rowid FROM projection_queue - WHERE rowid > ?1 AND rowid <= ?2 AND next_retry_at_micros > 0 - ORDER BY rowid LIMIT ?3 + WHERE rowid > ?1 AND rowid <= ?2 + AND next_retry_at_micros > 0 + AND next_retry_at_micros < ?3 + ORDER BY rowid LIMIT ?4 )", - params![after_rowid, last_rowid, batch_rows], + params![ + after_rowid, + last_rowid, + PROJECTION_TERMINAL_RETRY_MICROS, + batch_rows + ], ) .await? else { From 7322c88304ccc53a3c6c1168e2a7850fb9234b0d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:22:26 -0800 Subject: [PATCH 05/64] style(sessions): format projection queue regression --- .../session_suite/observation_projection/queue.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 546e4eabe60..3b74e643b8d 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -733,9 +733,7 @@ async fn empty_projection_drain_does_not_wait_for_an_unrelated_writer() { let store = runtime .observation_store(HostAdmissionScope::Profile) .unwrap(); - let database_path = runtime - .database_path(HostAdmissionScope::Profile) - .unwrap(); + let database_path = runtime.database_path(HostAdmissionScope::Profile).unwrap(); // A separate connection holds SQLite's writer reservation. An idle // projection pass must still finish using only the read connection. @@ -763,7 +761,11 @@ async fn empty_projection_drain_does_not_wait_for_an_unrelated_writer() { conversational_payload("message-empty-drain", "post-idle admission"), ); persist(&store, new_record, None).await; - let after = store.project_queued_observations(32).await.unwrap().unwrap(); + let after = store + .project_queued_observations(32) + .await + .unwrap() + .unwrap(); assert_eq!(after.items.len(), 1); assert!(!after.has_more); assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 1); From 35da211f4a321e4c2c6b1c1933390e0eb144ca32 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:28:23 -0800 Subject: [PATCH 06/64] fix(sessions): avoid idle retry-rearm writer transactions Capture the last retryable deadline through the reader and stop after its bounded writer window, rather than taking an empty writer transaction at startup or after every completed re-arm. Reject nonpositive batch sizes. Exercise registered writer transaction counters, durable daemon remount, later retries, terminal preservation, and invalid limits. Refs #4756 --- .../src/observation_projection/rearm_tests.rs | 89 ++++++++++++++++++- .../src/observation_projection/state.rs | 41 +++++---- 2 files changed, 111 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index a917d5c37a4..6cdae32608e 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -9,7 +9,7 @@ use tracedecay_domain::{ use tracedecay_runtime_core::db::engine::params; use super::{RearmedProjectionRetries, rearm_queued_projection_retries}; -use crate::tests::harness::RegisteredGlobalDbHarness; +use crate::tests::harness::{RegisteredGlobalDbHarness, writer_telemetry}; const FAR_FUTURE_MICROS: i64 = 9_000_000_000_000_000; @@ -162,6 +162,7 @@ async fn rearm_walks_deferred_retries_in_bounded_batches() { let harness = RegisteredGlobalDbHarness::open("rearm-bounded-batches").await; seed_queue(&harness, 9, 3).await; assert_eq!(queue_state(&harness).await, (12, 9, 12)); + let before = writer_telemetry(&harness.registered).transactions; let rearmed = rearm_queued_projection_retries(harness.registered.runtime_database(), 4) .await @@ -175,6 +176,12 @@ async fn rearm_walks_deferred_retries_in_bounded_batches() { }, "nine deferred rows re-arm in ceil(9 / 4) statements, never one queue-sized one" ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!(after.committed_transactions - before.committed_transactions, 3); + assert_eq!( + after.rolled_back_transactions, before.rolled_back_transactions, + "reaching the captured frontier must not open an empty final transaction" + ); assert_eq!( queue_state(&harness).await, (12, 0, 12), @@ -187,6 +194,11 @@ async fn rearm_walks_deferred_retries_in_bounded_batches() { RearmedProjectionRetries::default(), "an armed queue costs no write batches" ); + assert_eq!( + writer_telemetry(&harness.registered).transactions, + after, + "zero reported batches must also mean zero actual writer transactions" + ); } #[asupersync::test] @@ -211,6 +223,7 @@ async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() ); transaction.commit().await.unwrap(); assert_eq!(queue_state(&harness).await, (12, 9, 12)); + let before = writer_telemetry(&harness.registered).transactions; let rearmed = rearm_queued_projection_retries(harness.registered.runtime_database(), 2) .await @@ -223,19 +236,89 @@ async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() }, "parked collisions must not consume re-arm write windows" ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!(after.committed_transactions - before.committed_transactions, 2); + assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); assert_eq!( queue_state(&harness).await, (12, 6, 12), "only retryable deadlines clear; terminal collisions remain parked" ); + // Reopen the durable database through real daemon admission. Do not run + // background convergence before measuring its retry re-arm directly. + let (harness, _convergence) = harness.restart_for_daemon().await; + assert_eq!(queue_state(&harness).await, (12, 6, 12)); + let before = writer_telemetry(&harness.registered).transactions; + for _ in 0..2 { + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .unwrap(), + RearmedProjectionRetries::default(), + "restarts with only terminal collisions cannot schedule empty write batches" + ); + assert_eq!(writer_telemetry(&harness.registered).transactions, before); + } + + // An idle observation must not cache away a subsequently deferred row. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?1 + WHERE observation_sequence = 2", + params![FAR_FUTURE_MICROS], + ) + .await + .unwrap(), + 1 + ); + transaction.commit().await.unwrap(); + let before = writer_telemetry(&harness.registered).transactions; + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .unwrap(), + RearmedProjectionRetries { + rows: 1, + batches: 1, + } + ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!(after.committed_transactions - before.committed_transactions, 1); + assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); + assert_eq!(queue_state(&harness).await, (12, 6, 12)); +} + +#[asupersync::test] +async fn rearm_rejects_nonpositive_windows_before_acquiring_the_writer() { + let harness = RegisteredGlobalDbHarness::open("rearm-invalid-window").await; + seed_queue(&harness, 5, 0).await; + let before = writer_telemetry(&harness.registered).transactions; + for batch_rows in [0, -1] { + assert!( + rearm_queued_projection_retries(harness.registered.runtime_database(), batch_rows) + .await + .is_err(), + "a nonpositive limit must not silently skip or unbound recovery" + ); + assert_eq!(queue_state(&harness).await, (5, 5, 5)); + assert_eq!(writer_telemetry(&harness.registered).transactions, before); + } assert_eq!( rearm_queued_projection_retries(harness.registered.runtime_database(), 2) .await .unwrap(), - RearmedProjectionRetries::default(), - "restarts with only terminal collisions cannot schedule empty write batches" + RearmedProjectionRetries { + rows: 5, + batches: 3, + } ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!(after.committed_transactions - before.committed_transactions, 3); + assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); + assert_eq!(queue_state(&harness).await, (5, 0, 5)); } #[asupersync::test] diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 809944f7500..9639d90526e 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -257,17 +257,16 @@ pub(crate) struct RearmedProjectionRetries { } /// A projection retry deadline paces re-attempts within the store mount that -/// observed the failure. A fresh mount re-arms every queued projection for -/// immediate replay so restart recovery drains commit-before-ack work instead -/// of waiting out a dead process's backoff. Attempt counts and last errors -/// persist, so a projection that fails again resumes its escalating delay from -/// the recorded attempt history. +/// observed the failure. A fresh mount re-arms retryable deferred projections +/// for immediate replay so recovery does not wait out a dead process's backoff. +/// Attempt counts, last errors, and terminal collision deadlines persist. /// -/// The queue grows with session history, so the re-arm walks it in rowid -/// order, `batch_rows` deferred rows per short writer transaction: no single -/// statement or transaction scales with the queue, and foreground writes -/// interleave between batches. Rows queued after the walk starts belong to -/// this mount and are left alone. +/// The reader captures the last retryable row before taking any writer lease. +/// Already-armed and terminal-only queues therefore need no writer transaction. +/// Each write window selects and updates at most `batch_rows` eligible rows +/// under the same writer lease; foreground writes interleave between windows. +/// Scanning past ineligible rows can still scale with the queue. Rows beyond +/// the captured frontier are left for a later recovery pass. #[tracing::instrument( name = "global_db.observation_projection.rearm_retries", level = "trace", @@ -277,20 +276,30 @@ pub(crate) async fn rearm_queued_projection_retries( database: &Database, batch_rows: i64, ) -> ProjectionStoreResult { + if batch_rows <= 0 { + return Err(storage_message( + "rearm queued projection retries", + "projection retry batch size must be positive", + )); + } let mut rearmed = RearmedProjectionRetries::default(); let Some(last_rowid) = query_optional_i64( &database.read_connection(), - "SELECT MAX(rowid) FROM projection_queue", - params![], + "SELECT MAX(rowid) FROM projection_queue + WHERE next_retry_at_micros > 0 AND next_retry_at_micros < ?1", + params![PROJECTION_TERMINAL_RETRY_MICROS], ) .await? else { return Ok(rearmed); }; let mut after_rowid = 0_i64; - while let Some((batch_end, rows)) = - rearm_projection_retry_batch(database, after_rowid, last_rowid, batch_rows).await? - { + while after_rowid < last_rowid { + let Some((batch_end, rows)) = + rearm_projection_retry_batch(database, after_rowid, last_rowid, batch_rows).await? + else { + break; + }; rearmed.rows += rows; rearmed.batches += 1; after_rowid = batch_end; @@ -985,7 +994,7 @@ fn output_authority_batch_sql() -> String { SELECT provenance.projector_version AS projector_version, provenance.output_provider AS output_provider, provenance.output_message_id AS output_message_id, - MAX(provenance.message_created) AS projector_owned, + MAX(message_created) AS projector_owned, COUNT(*) AS owner_count FROM json_each(?2) AS requested CROSS JOIN observation_projection_provenance AS provenance From 5466c9097620d6c5cc867aeb9d1c72cb7569d7f4 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:49:35 -0800 Subject: [PATCH 07/64] perf(sessions): avoid terminal-only projection writer leases Use the queue retry deadline index for an inexpensive nonterminal readiness probe. Parked collisions must not acquire a writer on idle drains; new observations still project after such a probe. Refs #4756 --- .../src/observation_projection/rebuild.rs | 16 +++++--- .../src/observation_projection/schema.rs | 8 +++- .../observation_projection/queue.rs | 38 ++++++++++++++++--- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index 7ef3d74d717..c737f30c6d3 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -220,15 +220,19 @@ pub async fn project_queued_observations( if max == 0 { return Ok(ProjectionDrainBatch::default()); } - // An idle drain needs no writer lease. This constant-work existence probe - // deliberately does not scan for a ready head: large queues of terminal - // collisions must not add a second ordered scan to active write windows. - // The transaction below remains the authority for concurrent admissions - // and enforces ordering after taking the writer lease. + // Terminal-collision rows can remain parked indefinitely. Probe for any + // nonterminal work before taking a writer lease so idle polls never rewrite + // a terminal-only queue. The deadline performance index makes this a + // bounded lookup on converged stores; the writer still verifies ordered + // readiness after concurrent admissions race this read-only hint. { let reader = database.read_connection(); let mut rows = reader - .query("SELECT 1 FROM projection_queue LIMIT 1", ()) + .query( + "SELECT 1 FROM projection_queue + WHERE next_retry_at_micros < ?1 LIMIT 1", + params![PROJECTION_TERMINAL_RETRY_MICROS], + ) .await .map_err(|error| storage("probe empty projection queue", error))?; if rows diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index 849e5972d3b..504cc2fe175 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -313,6 +313,10 @@ const OBSERVATION_PROJECTION_BINDING_TRIGGERS_SQL: &str = /// Historical-data indexes the schema contract requires on the projection /// authority. pub(crate) const OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL: &[&str] = &[ + // Nonterminal queue probes must not scan every permanently parked collision + // on every idle pass. SQLite can also use this for retry frontier reads. + "CREATE INDEX IF NOT EXISTS idx_projection_queue_retry_deadline + ON projection_queue (next_retry_at_micros);", "CREATE INDEX IF NOT EXISTS idx_observation_projection_provenance_output ON observation_projection_provenance (projector_version, output_provider, output_message_id);", @@ -630,7 +634,8 @@ mod tests { drop(open_registered_schema(&path).await.unwrap()); let conn = TestConnection::open(&path); conn.execute_batch( - "DROP INDEX idx_observation_projection_provenance_output; + "DROP INDEX idx_projection_queue_retry_deadline; + DROP INDEX idx_observation_projection_provenance_output; DROP INDEX idx_observation_projection_provenance_global_output; DROP INDEX idx_observation_workflow_facts_query; DROP INDEX idx_observation_workflow_facts_item; @@ -656,6 +661,7 @@ mod tests { .query( "SELECT name FROM sqlite_schema WHERE name IN ( + 'idx_projection_queue_retry_deadline', 'idx_observation_projection_provenance_output', 'idx_observation_projection_provenance_global_output', 'idx_observation_workflow_facts_query', diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 3b74e643b8d..cdf620a2503 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -712,17 +712,45 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 5); assert_eq!(table_count(&tmp, "projection_queue").await, 1); - let idle = store - .project_queued_observations(32) - .await - .unwrap() - .unwrap(); + // A terminal-only queue may survive indefinitely; it must not take + // a writer lease while another service holds the same database writer. + let writer = rusqlite::Connection::open(&database_path).unwrap(); + writer.execute_batch("BEGIN IMMEDIATE").unwrap(); + let idle = tokio::time::timeout( + std::time::Duration::from_secs(2), + store.project_queued_observations(32), + ) + .await + .expect("a terminal-only queue cannot acquire the writer") + .unwrap() + .unwrap(); + writer.execute_batch("ROLLBACK").unwrap(); assert!(idle.items.is_empty()); assert!(!idle.has_more); assert_eq!( store.projection_checkpoint().await.unwrap().last_sequence(), 5 ); + + // The skip is not cached: a later admission still advances the + // durable checkpoint past the parked terminal collision. + persist( + &store, + observation( + "session-windowed-restart", + 500, + 600, + "receipt.windowed.5", + conversational_payload("message-windowed-5", "post-terminal admission"), + ), + Some(cursor("session-windowed-restart", 500)), + ) + .await; + let resumed = store.project_queued_observations(32).await.unwrap().unwrap(); + assert_eq!(resumed.items.len(), 1); + assert!(!resumed.has_more); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 6); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 6); } } From 1c08ac0c0909fd00d28142ad0c72dc6f154c043b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:54:28 -0800 Subject: [PATCH 08/64] perf(sessions): skip deferred-only projection writer polls Use indexed deadline readiness to avoid empty writer transactions while all nonterminal rows await their retry clock. Preserve pending coverage, strict sequence authority, and newly admitted work after the probe. Exercise a persisted terminal + deferred queue across reopening with a concurrent SQLite writer reservation and subsequent durable continuation. Refs #4756 --- .../src/observation_projection/rebuild.rs | 33 +++-- .../observation_projection/queue.rs | 113 ++++++++++++++++++ 2 files changed, 134 insertions(+), 12 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index c737f30c6d3..b29a7b0e989 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -220,28 +220,37 @@ pub async fn project_queued_observations( if max == 0 { return Ok(ProjectionDrainBatch::default()); } - // Terminal-collision rows can remain parked indefinitely. Probe for any - // nonterminal work before taking a writer lease so idle polls never rewrite - // a terminal-only queue. The deadline performance index makes this a - // bounded lookup on converged stores; the writer still verifies ordered - // readiness after concurrent admissions race this read-only hint. + // The deadline index proves when no nonterminal row is due without + // taking a writer lease. A deferred-only queue is pending work, but must + // not burn an empty write transaction on every poll while its retry clock + // runs. When anything is due, the writer remains authoritative for + // sequence order, rebuild fences, and concurrent admissions. { let reader = database.read_connection(); let mut rows = reader .query( - "SELECT 1 FROM projection_queue - WHERE next_retry_at_micros < ?1 LIMIT 1", + "SELECT next_retry_at_micros FROM projection_queue + WHERE next_retry_at_micros < ?1 + ORDER BY next_retry_at_micros LIMIT 1", params![PROJECTION_TERMINAL_RETRY_MICROS], ) .await - .map_err(|error| storage("probe empty projection queue", error))?; - if rows + .map_err(|error| storage("probe ready projection queue", error))?; + let earliest = rows .next() .await - .map_err(|error| storage("probe empty projection queue", error))? - .is_none() - { + .map_err(|error| storage("probe ready projection queue", error))?; + let Some(earliest) = earliest else { return Ok(ProjectionDrainBatch::default()); + }; + let deadline = earliest + .get::(0) + .map_err(|error| storage("decode earliest projection deadline", error))?; + if deadline > tracedecay_contracts::clock::now_micros().0 { + return Ok(ProjectionDrainBatch { + items: Vec::new(), + has_more: true, + }); } } let transaction = database diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index cdf620a2503..007bf854cc6 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -754,6 +754,119 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de } } +#[tokio::test] +async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { + let tmp = TempDir::new().unwrap(); + let database_path; + let first = observation( + "session-deferred-only", + 0, + 100, + "receipt.deferred-only-first", + conversational_payload("message-deferred-only-first", "first deferred-only canary"), + ); + let second = observation( + "session-deferred-only", + 100, + 200, + "receipt.deferred-only-second", + conversational_payload("message-deferred-only-second", "second deferred-only canary"), + ); + { + let runtime = profile_runtime(&tmp).await; + let store = runtime + .observation_store(HostAdmissionScope::Profile) + .unwrap(); + database_path = runtime + .database_path(HostAdmissionScope::Profile) + .unwrap() + .to_path_buf(); + persist(&store, first.clone(), None).await; + persist( + &store, + second.clone(), + Some(cursor("session-deferred-only", 100)), + ) + .await; + store + .project_observation(first.observation_id()) + .await + .unwrap(); + + // Keep a terminal collision and a deferred sequence, with no ready + // work. Both survive the restart; neither should require a writer. + let connection = rusqlite::Connection::open(&database_path).unwrap(); + connection + .execute( + "INSERT INTO projection_queue (observation_id, observation_sequence) + VALUES (?1, 1)", + rusqlite::params![first.observation_id().as_str()], + ) + .unwrap(); + connection + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?2 + WHERE observation_id = ?1", + rusqlite::params![first.observation_id().as_str(), i64::MAX], + ) + .unwrap(); + connection + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?2 + WHERE observation_id = ?1", + rusqlite::params![second.observation_id().as_str(), i64::MAX - 1], + ) + .unwrap(); + } + + let runtime = profile_runtime(&tmp).await; + let store = runtime + .observation_store(HostAdmissionScope::Profile) + .unwrap(); + let writer = rusqlite::Connection::open(&database_path).unwrap(); + writer.execute_batch("BEGIN IMMEDIATE").unwrap(); + for _ in 0..2 { + let deferred = tokio::time::timeout( + std::time::Duration::from_secs(2), + store.project_queued_observations(32), + ) + .await + .expect("deferred-only projection must not acquire the writer") + .unwrap() + .unwrap(); + assert!(deferred.items.is_empty()); + assert!(deferred.has_more, "the deferred head remains pending"); + } + writer.execute_batch("ROLLBACK").unwrap(); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 1 + ); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 1); + + // Skipping the writer must never cache a negative readiness decision. + rusqlite::Connection::open(&database_path) + .unwrap() + .execute( + "UPDATE projection_queue SET next_retry_at_micros = 0 + WHERE observation_id = ?1", + rusqlite::params![second.observation_id().as_str()], + ) + .unwrap(); + let resumed = store + .project_queued_observations(1) + .await + .unwrap() + .unwrap(); + assert_eq!(resumed.items.len(), 1); + assert!(!resumed.has_more, "the remaining row is terminal"); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 2 + ); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 2); +} + #[tokio::test] async fn empty_projection_drain_does_not_wait_for_an_unrelated_writer() { let tmp = TempDir::new().unwrap(); From 6f32ff2f905b7f8cd05d86fda65d9c8d5d42304a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:22:14 -0800 Subject: [PATCH 09/64] perf(sessions): honor deferred sequence head without writer Probe the lowest nonterminal observation sequence, not the earliest retry clock anywhere in the queue. A later due row must not acquire a writer while an earlier deferred row fences projection. Keep the probe and batched reads on a partial sequence index excluding permanently parked collisions. Extend the durable reopen regression to prove a later due row cannot bypass the deferred head under another SQLite writer reservation; then project both observations in bounded one-item windows. Refs #4756 --- .../src/observation_projection/rebuild.rs | 37 +++++++++---------- .../src/observation_projection/schema.rs | 10 ++++- .../observation_projection/queue.rs | 30 +++++++++++++-- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index b29a7b0e989..44fd637f37e 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -220,32 +220,33 @@ pub async fn project_queued_observations( if max == 0 { return Ok(ProjectionDrainBatch::default()); } - // The deadline index proves when no nonterminal row is due without - // taking a writer lease. A deferred-only queue is pending work, but must - // not burn an empty write transaction on every poll while its retry clock - // runs. When anything is due, the writer remains authoritative for - // sequence order, rebuild fences, and concurrent admissions. + // Sequence order, not the earliest deadline anywhere in the queue, + // determines readiness. A deferred head fences even a later due row: + // neither case should take a writer lease just to find no ready work. + // The partial sequence index excludes terminal (i64::MAX) collisions. + // The writer remains authoritative when this check finds a ready head. { let reader = database.read_connection(); let mut rows = reader .query( + // The fixed terminal sentinel matches the partial index predicate. "SELECT next_retry_at_micros FROM projection_queue - WHERE next_retry_at_micros < ?1 - ORDER BY next_retry_at_micros LIMIT 1", - params![PROJECTION_TERMINAL_RETRY_MICROS], + WHERE next_retry_at_micros < 9223372036854775807 + ORDER BY observation_sequence LIMIT 1", + (), ) .await .map_err(|error| storage("probe ready projection queue", error))?; - let earliest = rows + let head = rows .next() .await .map_err(|error| storage("probe ready projection queue", error))?; - let Some(earliest) = earliest else { + let Some(head) = head else { return Ok(ProjectionDrainBatch::default()); }; - let deadline = earliest + let deadline = head .get::(0) - .map_err(|error| storage("decode earliest projection deadline", error))?; + .map_err(|error| storage("decode projection head deadline", error))?; if deadline > tracedecay_contracts::clock::now_micros().0 { return Ok(ProjectionDrainBatch { items: Vec::new(), @@ -313,18 +314,14 @@ async fn ready_projection_window( let mut rows = conn .query( "SELECT observation_id, next_retry_at_micros FROM projection_queue - WHERE next_retry_at_micros < ?1 + WHERE next_retry_at_micros < 9223372036854775807 AND NOT EXISTS ( SELECT 1 FROM observation_projection_rebuilds - WHERE projector_version = ?2 + WHERE projector_version = ?1 ) ORDER BY observation_sequence - LIMIT ?3", - params![ - PROJECTION_TERMINAL_RETRY_MICROS, - SESSION_MESSAGE_PROJECTOR_VERSION, - limit - ], + LIMIT ?2", + params![SESSION_MESSAGE_PROJECTOR_VERSION, limit], ) .await .map_err(|error| storage("read projection queue window", error))?; diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index 504cc2fe175..b60a3df1537 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -313,6 +313,12 @@ const OBSERVATION_PROJECTION_BINDING_TRIGGERS_SQL: &str = /// Historical-data indexes the schema contract requires on the projection /// authority. pub(crate) const OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL: &[&str] = &[ + // The terminal collision sentinel is i64::MAX. Keep this literal + // predicate identical to the ordered queue-head reads so SQLite can + // avoid scanning the parked prefix while honoring sequence fences. + "CREATE INDEX IF NOT EXISTS idx_projection_queue_active_sequence + ON projection_queue (observation_sequence, next_retry_at_micros) + WHERE next_retry_at_micros < 9223372036854775807;", // Nonterminal queue probes must not scan every permanently parked collision // on every idle pass. SQLite can also use this for retry frontier reads. "CREATE INDEX IF NOT EXISTS idx_projection_queue_retry_deadline @@ -634,7 +640,8 @@ mod tests { drop(open_registered_schema(&path).await.unwrap()); let conn = TestConnection::open(&path); conn.execute_batch( - "DROP INDEX idx_projection_queue_retry_deadline; + "DROP INDEX idx_projection_queue_active_sequence; + DROP INDEX idx_projection_queue_retry_deadline; DROP INDEX idx_observation_projection_provenance_output; DROP INDEX idx_observation_projection_provenance_global_output; DROP INDEX idx_observation_workflow_facts_query; @@ -661,6 +668,7 @@ mod tests { .query( "SELECT name FROM sqlite_schema WHERE name IN ( + 'idx_projection_queue_active_sequence', 'idx_projection_queue_retry_deadline', 'idx_observation_projection_provenance_output', 'idx_observation_projection_provenance_global_output', diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 007bf854cc6..072745d50f8 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -772,6 +772,13 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { "receipt.deferred-only-second", conversational_payload("message-deferred-only-second", "second deferred-only canary"), ); + let third = observation( + "session-deferred-only", + 200, + 300, + "receipt.deferred-only-third", + conversational_payload("message-deferred-only-third", "due behind deferred head"), + ); { let runtime = profile_runtime(&tmp).await; let store = runtime @@ -788,13 +795,19 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { Some(cursor("session-deferred-only", 100)), ) .await; + persist( + &store, + third.clone(), + Some(cursor("session-deferred-only", 200)), + ) + .await; store .project_observation(first.observation_id()) .await .unwrap(); - // Keep a terminal collision and a deferred sequence, with no ready - // work. Both survive the restart; neither should require a writer. + // Keep a terminal collision and a deferred sequence. Sequence three + // is due, but must not bypass the deferred sequence two across restart. let connection = rusqlite::Connection::open(&database_path).unwrap(); connection .execute( @@ -858,13 +871,22 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { .await .unwrap() .unwrap(); - assert_eq!(resumed.items.len(), 1); - assert!(!resumed.has_more, "the remaining row is terminal"); + assert_eq!(resumed.items.len(), 1, "the one-item window cannot skip ahead"); + assert!(resumed.has_more, "the later ready sequence still needs projection"); assert_eq!( store.projection_checkpoint().await.unwrap().last_sequence(), 2 ); assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 2); + + let tail = store.project_queued_observations(1).await.unwrap().unwrap(); + assert_eq!(tail.items.len(), 1); + assert!(!tail.has_more, "only the terminal collision remains"); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 3 + ); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 3); } #[tokio::test] From 71195bb7ce10c1afcba7fc4d751903d230bcd323 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:51:46 -0800 Subject: [PATCH 10/64] perf(sessions): avoid writer leases during projection rebuilds Skip ordinary projection while the canonical rebuild fence is active, without taking an idle SQLite writer transaction. Exercise pending status under a competing writer and resumed durable projection after the fence clears. Refs #4756 --- .../src/observation_projection/rebuild.rs | 16 ++++- .../observation_projection/queue.rs | 64 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index 44fd637f37e..f0f933688c2 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -230,10 +230,13 @@ pub async fn project_queued_observations( let mut rows = reader .query( // The fixed terminal sentinel matches the partial index predicate. - "SELECT next_retry_at_micros FROM projection_queue + "SELECT next_retry_at_micros, + EXISTS(SELECT 1 FROM observation_projection_rebuilds + WHERE projector_version = ?1) + FROM projection_queue WHERE next_retry_at_micros < 9223372036854775807 ORDER BY observation_sequence LIMIT 1", - (), + params![SESSION_MESSAGE_PROJECTOR_VERSION], ) .await .map_err(|error| storage("probe ready projection queue", error))?; @@ -247,7 +250,14 @@ pub async fn project_queued_observations( let deadline = head .get::(0) .map_err(|error| storage("decode projection head deadline", error))?; - if deadline > tracedecay_contracts::clock::now_micros().0 { + // An active generation rebuild suppresses ordinary projection. + // Do not acquire a writer only to discover that suppression again; + // the next poll rechecks both the rebuild and the ordered queue head. + let rebuild_active = head + .get::(1) + .map_err(|error| storage("decode projection rebuild gate", error))? + != 0; + if rebuild_active || deadline > tracedecay_contracts::clock::now_micros().0 { return Ok(ProjectionDrainBatch { items: Vec::new(), has_more: true, diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 072745d50f8..f83b629f6db 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -933,3 +933,67 @@ async fn empty_projection_drain_does_not_wait_for_an_unrelated_writer() { assert!(!after.has_more); assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 1); } + +#[tokio::test] +async fn active_rebuild_keeps_projection_pending_without_acquiring_writer() { + let tmp = TempDir::new().unwrap(); + let runtime = profile_runtime(&tmp).await; + let store = runtime + .observation_store(HostAdmissionScope::Profile) + .unwrap(); + let database_path = runtime.database_path(HostAdmissionScope::Profile).unwrap(); + persist( + &store, + observation( + "session-rebuild-gate", + 0, + 100, + "receipt.rebuild-gate", + conversational_payload("message-rebuild-gate", "project after rebuild"), + ), + None, + ) + .await; + + let writer = rusqlite::Connection::open(database_path).unwrap(); + writer + .execute( + "INSERT INTO observation_projection_rebuilds + (projector_version, generation, frontier_sequence, state) + VALUES (?1, 'rebuild-gate-generation', 1, 'building')", + rusqlite::params![tracedecay_store::SESSION_MESSAGE_PROJECTOR_VERSION], + ) + .unwrap(); + + // A live rebuild fences ordinary projection, so an empty drain should + // return pending without waiting for a separate SQLite writer reservation. + writer.execute_batch("BEGIN IMMEDIATE").unwrap(); + for _ in 0..2 { + let deferred = tokio::time::timeout( + std::time::Duration::from_secs(2), + store.project_queued_observations(32), + ) + .await + .expect("an active rebuild must not acquire the projection writer") + .unwrap() + .unwrap(); + assert!(deferred.items.is_empty()); + assert!(deferred.has_more, "the observation remains queued"); + } + writer.execute_batch("ROLLBACK").unwrap(); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 0); + + // Clearing the durable rebuild fence must expose the original queued + // observation on the next poll without a cached negative gate. + writer + .execute( + "DELETE FROM observation_projection_rebuilds WHERE projector_version = ?1", + rusqlite::params![tracedecay_store::SESSION_MESSAGE_PROJECTOR_VERSION], + ) + .unwrap(); + let resumed = store.project_queued_observations(1).await.unwrap().unwrap(); + assert_eq!(resumed.items.len(), 1); + assert!(!resumed.has_more); + assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 1); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 1); +} From 348d92f8b271c11daa2e8864ec2a4a7aedc33ab1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:54:08 -0800 Subject: [PATCH 11/64] style(sessions): apply hosted Bazel rustfmt corrections Apply exact rustfmt_check_2024 hunks to the projection restart regressions without changing behavior or weakening any checks. Refs #4756 --- .../src/observation_projection/rearm_tests.rs | 35 +++++++++++++---- .../observation_projection/queue.rs | 38 +++++++++++++------ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 6cdae32608e..bf75ad69f0c 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -177,7 +177,10 @@ async fn rearm_walks_deferred_retries_in_bounded_batches() { "nine deferred rows re-arm in ceil(9 / 4) statements, never one queue-sized one" ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!(after.committed_transactions - before.committed_transactions, 3); + assert_eq!( + after.committed_transactions - before.committed_transactions, + 3 + ); assert_eq!( after.rolled_back_transactions, before.rolled_back_transactions, "reaching the captured frontier must not open an empty final transaction" @@ -237,8 +240,14 @@ async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() "parked collisions must not consume re-arm write windows" ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!(after.committed_transactions - before.committed_transactions, 2); - assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); + assert_eq!( + after.committed_transactions - before.committed_transactions, + 2 + ); + assert_eq!( + after.rolled_back_transactions, + before.rolled_back_transactions + ); assert_eq!( queue_state(&harness).await, (12, 6, 12), @@ -286,8 +295,14 @@ async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() } ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!(after.committed_transactions - before.committed_transactions, 1); - assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); + assert_eq!( + after.committed_transactions - before.committed_transactions, + 1 + ); + assert_eq!( + after.rolled_back_transactions, + before.rolled_back_transactions + ); assert_eq!(queue_state(&harness).await, (12, 6, 12)); } @@ -316,8 +331,14 @@ async fn rearm_rejects_nonpositive_windows_before_acquiring_the_writer() { } ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!(after.committed_transactions - before.committed_transactions, 3); - assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); + assert_eq!( + after.committed_transactions - before.committed_transactions, + 3 + ); + assert_eq!( + after.rolled_back_transactions, + before.rolled_back_transactions + ); assert_eq!(queue_state(&harness).await, (5, 0, 5)); } diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index f83b629f6db..5671f1d652c 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -746,10 +746,17 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de Some(cursor("session-windowed-restart", 500)), ) .await; - let resumed = store.project_queued_observations(32).await.unwrap().unwrap(); + let resumed = store + .project_queued_observations(32) + .await + .unwrap() + .unwrap(); assert_eq!(resumed.items.len(), 1); assert!(!resumed.has_more); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 6); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 6 + ); assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 6); } } @@ -770,7 +777,10 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { 100, 200, "receipt.deferred-only-second", - conversational_payload("message-deferred-only-second", "second deferred-only canary"), + conversational_payload( + "message-deferred-only-second", + "second deferred-only canary", + ), ); let third = observation( "session-deferred-only", @@ -866,13 +876,16 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { rusqlite::params![second.observation_id().as_str()], ) .unwrap(); - let resumed = store - .project_queued_observations(1) - .await - .unwrap() - .unwrap(); - assert_eq!(resumed.items.len(), 1, "the one-item window cannot skip ahead"); - assert!(resumed.has_more, "the later ready sequence still needs projection"); + let resumed = store.project_queued_observations(1).await.unwrap().unwrap(); + assert_eq!( + resumed.items.len(), + 1, + "the one-item window cannot skip ahead" + ); + assert!( + resumed.has_more, + "the later ready sequence still needs projection" + ); assert_eq!( store.projection_checkpoint().await.unwrap().last_sequence(), 2 @@ -994,6 +1007,9 @@ async fn active_rebuild_keeps_projection_pending_without_acquiring_writer() { let resumed = store.project_queued_observations(1).await.unwrap().unwrap(); assert_eq!(resumed.items.len(), 1); assert!(!resumed.has_more); - assert_eq!(store.projection_checkpoint().await.unwrap().last_sequence(), 1); + assert_eq!( + store.projection_checkpoint().await.unwrap().last_sequence(), + 1 + ); assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 1); } From f7e4a6e7e0a444d44824e46ba7fda4d1704899b8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:15:50 -0800 Subject: [PATCH 12/64] fix(sessions): preserve queue index plans under QPSG Name the terminal collision sentinel in all queue SQL. Read the first nonterminal sequence through the partial index, then apply the dynamic deadline check without a second queue-head scan. Exercise the typed queue reader across deferred heads and restart. Refs #4756 --- .../src/observation_projection/rebuild.rs | 8 ++--- .../src/observation_projection/state.rs | 22 +++++--------- .../src/schema_contract/invariants/repair.rs | 6 ++-- .../src/repository/observation/mod.rs | 29 ++++++++----------- .../observation_projection/queue.rs | 9 ++++++ 5 files changed, 36 insertions(+), 38 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index f0f933688c2..2a41ae90266 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -363,8 +363,8 @@ async fn ready_projection_window( async fn projection_queue_has_items(conn: &impl QueryExecutor) -> ProjectionStoreResult { let mut rows = conn .query( - "SELECT 1 FROM projection_queue WHERE next_retry_at_micros < ?1 LIMIT 1", - params![PROJECTION_TERMINAL_RETRY_MICROS], + "SELECT 1 FROM projection_queue WHERE next_retry_at_micros < 9223372036854775807 LIMIT 1", + (), ) .await .map_err(|error| storage("probe projection queue", error))?; @@ -1112,9 +1112,9 @@ async fn activate_projection_rebuild_transaction( transaction .execute( "DELETE FROM projection_queue - WHERE observation_sequence <= ?1 AND next_retry_at_micros < ?2 + WHERE observation_sequence <= ?1 AND next_retry_at_micros < 9223372036854775807 AND revival_generation IS NULL", - params![job.frontier, PROJECTION_TERMINAL_RETRY_MICROS], + params![job.frontier], ) .await .map_err(|error| storage("consume rebuilt projection queue", error))?; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 9639d90526e..03c85217f6e 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -7,8 +7,7 @@ use tracedecay_domain::{ CanonicalObservationIdV1, DurableObservationV1, ObservationSourceGenerationV1, PayloadDigestV1, }; use tracedecay_store::{ - EDITED_FILES_KEY, ObservationProjection, PROJECTION_TERMINAL_RETRY_MICROS, - ProjectionCheckpoint, ProjectionStoreError, ProjectionStoreResult, + EDITED_FILES_KEY, ObservationProjection, ProjectionCheckpoint, ProjectionStoreError, ProjectionStoreResult, SESSION_MESSAGE_PROJECTOR_VERSION, SPAWNED_SESSIONS_KEY, SessionMessageProjection, SessionMessageRecord, SessionRecord, message_output_digest, }; @@ -286,8 +285,8 @@ pub(crate) async fn rearm_queued_projection_retries( let Some(last_rowid) = query_optional_i64( &database.read_connection(), "SELECT MAX(rowid) FROM projection_queue - WHERE next_retry_at_micros > 0 AND next_retry_at_micros < ?1", - params![PROJECTION_TERMINAL_RETRY_MICROS], + WHERE next_retry_at_micros > 0 AND next_retry_at_micros < 9223372036854775807", + (), ) .await? else { @@ -331,15 +330,10 @@ async fn rearm_projection_retry_batch( SELECT rowid FROM projection_queue WHERE rowid > ?1 AND rowid <= ?2 AND next_retry_at_micros > 0 - AND next_retry_at_micros < ?3 - ORDER BY rowid LIMIT ?4 + AND next_retry_at_micros < 9223372036854775807 + ORDER BY rowid LIMIT ?3 )", - params![ - after_rowid, - last_rowid, - PROJECTION_TERMINAL_RETRY_MICROS, - batch_rows - ], + params![after_rowid, last_rowid, batch_rows], ) .await? else { @@ -354,8 +348,8 @@ async fn rearm_projection_retry_batch( "UPDATE projection_queue SET next_retry_at_micros = 0 WHERE rowid > ?1 AND rowid <= ?2 AND next_retry_at_micros > 0 - AND next_retry_at_micros < ?3", - params![after_rowid, batch_end, PROJECTION_TERMINAL_RETRY_MICROS], + AND next_retry_at_micros < 9223372036854775807", + params![after_rowid, batch_end], ) .await .map_err(|error| storage(OPERATION, error))?; diff --git a/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs b/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs index 0d8ec8f31a2..fe61ba805e9 100644 --- a/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs +++ b/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs @@ -4,7 +4,7 @@ use tracedecay_domain::{ DurableObservationV1, ObservationOrderingDomainV1, ObservationSourceCursorV1, }; use tracedecay_store::observation::ObservationCoverageV1; -use tracedecay_store::{PROJECTION_TERMINAL_RETRY_MICROS, SESSION_MESSAGE_PROJECTOR_VERSION}; +use tracedecay_store::SESSION_MESSAGE_PROJECTOR_VERSION; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; use tracedecay_rusqlite_runtime::repository::observation_cursor_authority::PRUNE_SUPERSEDED_CURSOR_ADVANCES_SQL; @@ -151,9 +151,9 @@ pub(super) async fn repair_projection_frontier( .map_err(audit_read_error)?; conn.execute( "DELETE FROM projection_queue - WHERE observation_sequence <= ?1 AND next_retry_at_micros < ?2 + WHERE observation_sequence <= ?1 AND next_retry_at_micros < 9223372036854775807 AND revival_generation IS NULL", - params![repaired_checkpoint, PROJECTION_TERMINAL_RETRY_MICROS], + params![repaired_checkpoint], ) .await .map_err(audit_read_error)?; diff --git a/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs b/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs index fd152032664..df1c2f8e05f 100644 --- a/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs +++ b/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs @@ -16,7 +16,7 @@ use tracedecay_store::{ AnchoredObservationWrite, CanonicalBodyError, CursorAdvanceLedgerDisagreementV1, CursorAdvanceLedgerIdentityV1, LOAD_CANONICAL_BODY_SQL, ObservationCoverageReason, ObservationCursorAdvance, ObservationReadOperationV1, ObservationReadResultV1, - PROJECTION_TERMINAL_RETRY_MICROS, ProjectionRebuildProgressV1, ProjectionRebuildStateV1, + ProjectionRebuildProgressV1, ProjectionRebuildStateV1, ProjectionSkipReason, SESSION_MESSAGE_PROJECTOR_VERSION, UPSERT_CANONICAL_BODY_SQL, parse_stored_observation, slim_stored_json, }; @@ -333,27 +333,22 @@ impl ObservationExecutor { Ok(ObservationReadResultV1::Replay(observations)) } ObservationReadOperationV1::NextQueuedProjection { now_micros } => { + // Read the first nonterminal head through the stable + // partial index. A deferred head fences later due rows. let observation_id = snapshot .query_row_cached( - "SELECT observation_id FROM projection_queue - WHERE next_retry_at_micros <= ?2 - AND observation_sequence = ( - SELECT MIN(observation_sequence) FROM projection_queue - WHERE next_retry_at_micros < ?3 - ) + "SELECT observation_id, next_retry_at_micros FROM projection_queue + WHERE next_retry_at_micros < 9223372036854775807 AND NOT EXISTS ( - SELECT 1 FROM observation_projection_rebuilds - WHERE projector_version = ?1 - ) - LIMIT 1", - ( - SESSION_MESSAGE_PROJECTOR_VERSION, - now_micros, - PROJECTION_TERMINAL_RETRY_MICROS, - ), - |row| row.get::<_, String>(0), + SELECT 1 FROM observation_projection_rebuilds + WHERE projector_version = ?1 + ) + ORDER BY observation_sequence LIMIT 1", + [SESSION_MESSAGE_PROJECTOR_VERSION], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), ) .optional()? + .and_then(|(id, deadline)| (deadline <= *now_micros).then_some(id)) .map(CanonicalObservationIdV1::new) .transpose() .map_err(invalid)?; diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 5671f1d652c..c8ddff5130c 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -860,6 +860,10 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { assert!(deferred.items.is_empty()); assert!(deferred.has_more, "the deferred head remains pending"); } + assert!( + store.next_queued_observation().await.unwrap().is_none(), + "the later due row must not bypass a deferred sequence head" + ); writer.execute_batch("ROLLBACK").unwrap(); assert_eq!( store.projection_checkpoint().await.unwrap().last_sequence(), @@ -876,6 +880,11 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { rusqlite::params![second.observation_id().as_str()], ) .unwrap(); + assert_eq!( + store.next_queued_observation().await.unwrap(), + Some(second.observation_id()), + "the released sequence head must be the next queued observation" + ); let resumed = store.project_queued_observations(1).await.unwrap().unwrap(); assert_eq!( resumed.items.len(), From aadc35a47a72f07f605698471088a529ff1500cf Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:16:41 -0800 Subject: [PATCH 13/64] style(sessions): format projection index import Refs #4756 --- .../src/observation_projection/state.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 03c85217f6e..e496d6a7936 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -7,9 +7,9 @@ use tracedecay_domain::{ CanonicalObservationIdV1, DurableObservationV1, ObservationSourceGenerationV1, PayloadDigestV1, }; use tracedecay_store::{ - EDITED_FILES_KEY, ObservationProjection, ProjectionCheckpoint, ProjectionStoreError, ProjectionStoreResult, - SESSION_MESSAGE_PROJECTOR_VERSION, SPAWNED_SESSIONS_KEY, SessionMessageProjection, - SessionMessageRecord, SessionRecord, message_output_digest, + EDITED_FILES_KEY, ObservationProjection, ProjectionCheckpoint, ProjectionStoreError, + ProjectionStoreResult, SESSION_MESSAGE_PROJECTOR_VERSION, SPAWNED_SESSIONS_KEY, + SessionMessageProjection, SessionMessageRecord, SessionRecord, message_output_digest, }; use tracedecay_lcm::raw::{message_body_from_record_row, stored_message_record_select_columns}; From c1d1f67b64064d2eac6235e0bd398450843093a3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:38:40 -0800 Subject: [PATCH 14/64] style(sessions): satisfy Bazel rustfmt import ordering Refs #4756 --- .../src/schema_contract/invariants/repair.rs | 2 +- .../src/repository/observation/mod.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs b/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs index fe61ba805e9..1d17a4aeb84 100644 --- a/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs +++ b/crates/tracedecay-global-db/src/schema_contract/invariants/repair.rs @@ -3,8 +3,8 @@ use std::collections::BTreeSet; use tracedecay_domain::{ DurableObservationV1, ObservationOrderingDomainV1, ObservationSourceCursorV1, }; -use tracedecay_store::observation::ObservationCoverageV1; use tracedecay_store::SESSION_MESSAGE_PROJECTOR_VERSION; +use tracedecay_store::observation::ObservationCoverageV1; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; use tracedecay_rusqlite_runtime::repository::observation_cursor_authority::PRUNE_SUPERSEDED_CURSOR_ADVANCES_SQL; diff --git a/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs b/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs index df1c2f8e05f..9dcc1586d54 100644 --- a/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs +++ b/crates/tracedecay-rusqlite-runtime/src/repository/observation/mod.rs @@ -16,9 +16,9 @@ use tracedecay_store::{ AnchoredObservationWrite, CanonicalBodyError, CursorAdvanceLedgerDisagreementV1, CursorAdvanceLedgerIdentityV1, LOAD_CANONICAL_BODY_SQL, ObservationCoverageReason, ObservationCursorAdvance, ObservationReadOperationV1, ObservationReadResultV1, - ProjectionRebuildProgressV1, ProjectionRebuildStateV1, - ProjectionSkipReason, SESSION_MESSAGE_PROJECTOR_VERSION, UPSERT_CANONICAL_BODY_SQL, - parse_stored_observation, slim_stored_json, + ProjectionRebuildProgressV1, ProjectionRebuildStateV1, ProjectionSkipReason, + SESSION_MESSAGE_PROJECTOR_VERSION, UPSERT_CANONICAL_BODY_SQL, parse_stored_observation, + slim_stored_json, }; use crate::operation::StorageOperationError; From 5fa0250cd015f83d372b138365ceb452bccaf250 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:36:05 -0800 Subject: [PATCH 15/64] fix(sessions): clone queued observation id in restart assertion Refs #4756 --- .../tests/session_suite/observation_projection/queue.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index c8ddff5130c..87c88c52601 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -882,7 +882,7 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { .unwrap(); assert_eq!( store.next_queued_observation().await.unwrap(), - Some(second.observation_id()), + Some(second.observation_id().clone()), "the released sequence head must be the next queued observation" ); let resumed = store.project_queued_observations(1).await.unwrap().unwrap(); From b8e1c38624679cf30f6caf82fdb37d9c2b5e2df2 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:24:51 -0800 Subject: [PATCH 16/64] fix(projection): register strict queue index authority --- .../src/observation_projection/schema.rs | 39 +++++++++++++++++++ .../src/schema_contract/definitions.rs | 14 +++++++ .../src/schema_contract/validation.rs | 1 + 3 files changed, 54 insertions(+) diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index b60a3df1537..9e05fd073bc 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -633,6 +633,45 @@ mod tests { ); } + /// Real registered-store admission must accept the two queue indexes while + /// preserving the strict authority inventory and the partial-index contract. + #[asupersync::test] + async fn registered_projection_queue_indexes_match_authority_schema() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("global.db"); + drop(open_registered_schema(&path).await.unwrap()); + // Reopening validates the persisted index inventory, not just creation. + drop(open_registered_schema(&path).await.unwrap()); + + let conn = TestConnection::open(&path); + let mut rows = conn + .query( + "SELECT name, partial FROM pragma_index_list('projection_queue') + WHERE name IN ( + 'idx_projection_queue_active_sequence', + 'idx_projection_queue_retry_deadline' + ) ORDER BY name", + (), + ) + .await + .unwrap(); + let mut indexes = Vec::new(); + while let Some(row) = rows.next().await.unwrap() { + indexes.push(( + row.get::(0).unwrap(), + row.get::(1).unwrap(), + )); + } + assert_eq!( + indexes, + vec![ + ("idx_projection_queue_active_sequence".to_owned(), 1), + ("idx_projection_queue_retry_deadline".to_owned(), 0), + ], + "the registered store must retain both exact queue indexes" + ); + } + #[asupersync::test] async fn performance_indexes_install_outside_schema_transaction() { let temp = TempDir::new().unwrap(); diff --git a/crates/tracedecay-global-db/src/schema_contract/definitions.rs b/crates/tracedecay-global-db/src/schema_contract/definitions.rs index e18d02a9766..c24bba3d2da 100644 --- a/crates/tracedecay-global-db/src/schema_contract/definitions.rs +++ b/crates/tracedecay-global-db/src/schema_contract/definitions.rs @@ -1806,6 +1806,20 @@ pub(super) const INDEXES: &[Index] = &[ origin: "u", columns: &["observation_sequence"], }, + Index { + table: "projection_queue", + name: Some("idx_projection_queue_active_sequence"), + unique: false, + origin: "c", + columns: &["observation_sequence", "next_retry_at_micros"], + }, + Index { + table: "projection_queue", + name: Some("idx_projection_queue_retry_deadline"), + unique: false, + origin: "c", + columns: &["next_retry_at_micros"], + }, Index { table: "observation_projection_provenance", name: Some("idx_observation_projection_provenance_output"), diff --git a/crates/tracedecay-global-db/src/schema_contract/validation.rs b/crates/tracedecay-global-db/src/schema_contract/validation.rs index ae0a81e2d63..37f84315bc3 100644 --- a/crates/tracedecay-global-db/src/schema_contract/validation.rs +++ b/crates/tracedecay-global-db/src/schema_contract/validation.rs @@ -156,6 +156,7 @@ fn index_matches(actual: &ActualIndex, expected: &Index) -> bool { name.eq_ignore_ascii_case("idx_session_temporal_generations_one_active") || name.eq_ignore_ascii_case("idx_session_refresh_operations_one_running") || name.eq_ignore_ascii_case("idx_observations_valid_session_sequence") + || name.eq_ignore_ascii_case("idx_projection_queue_active_sequence") }); if expected .name From 73b742238d1a73b6c2640fb70834ceb322527fa9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:51:08 -0800 Subject: [PATCH 17/64] fix(sessions): validate queued-head partial index predicate Refs #4756. Refuse a wrong same-named partial index before using its ordered-head query plan. Exercise registered-store reopen refusal and repair the schema regression rustfmt gate. --- .../src/observation_projection/schema.rs | 35 ++++++++++++++++--- .../src/schema_contract/pragma.rs | 10 +++++- .../src/schema_contract/validation.rs | 20 +++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index 9e05fd073bc..8606786743e 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -657,10 +657,7 @@ mod tests { .unwrap(); let mut indexes = Vec::new(); while let Some(row) = rows.next().await.unwrap() { - indexes.push(( - row.get::(0).unwrap(), - row.get::(1).unwrap(), - )); + indexes.push((row.get::(0).unwrap(), row.get::(1).unwrap())); } assert_eq!( indexes, @@ -672,6 +669,36 @@ mod tests { ); } + + #[asupersync::test] + async fn registered_projection_queue_rejects_wrong_partial_predicate() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("global.db"); + drop(open_registered_schema(&path).await.unwrap()); + + // IF NOT EXISTS must not make a same-named but incompatible index + // look admitted. The narrower predicate cannot serve a queue-head + // query that includes every nonterminal retry deadline. + let conn = TestConnection::open(&path); + conn.execute_batch( + "DROP INDEX idx_projection_queue_active_sequence; + CREATE INDEX idx_projection_queue_active_sequence + ON projection_queue (observation_sequence, next_retry_at_micros) + WHERE next_retry_at_micros < 0;", + ) + .await + .unwrap(); + drop(conn); + + let Err(error) = open_registered_schema(&path).await else { + panic!("a same-shaped queue index with the wrong predicate must be refused"); + }; + assert!( + error.to_string().contains("projection_queue"), + "schema refusal must identify the incompatible queue index: {error}" + ); + } + #[asupersync::test] async fn performance_indexes_install_outside_schema_transaction() { let temp = TempDir::new().unwrap(); diff --git a/crates/tracedecay-global-db/src/schema_contract/pragma.rs b/crates/tracedecay-global-db/src/schema_contract/pragma.rs index 5de856441fe..765ae764efa 100644 --- a/crates/tracedecay-global-db/src/schema_contract/pragma.rs +++ b/crates/tracedecay-global-db/src/schema_contract/pragma.rs @@ -40,6 +40,7 @@ pub(super) struct ActualIndex { pub(super) unique: bool, pub(super) origin: String, pub(super) partial: bool, + pub(super) definition_sql: Option, pub(super) columns: Vec, } @@ -192,9 +193,12 @@ pub(super) async fn read_table_metadata( JOIN pragma_index_list(requested.table_name) AS p ) SELECT headers.table_name, headers.name, headers.\"unique\", - headers.origin, headers.partial, x.cid, x.name, x.desc, x.coll + headers.origin, headers.partial, x.cid, x.name, x.desc, x.coll, + declared.sql FROM headers JOIN pragma_index_xinfo(headers.name) AS x + LEFT JOIN sqlite_schema AS declared + ON declared.type = 'index' AND declared.name = headers.name WHERE x.key = 1 ORDER BY headers.table_name, headers.seq, x.seqno" ), @@ -225,6 +229,9 @@ pub(super) async fn read_table_metadata( .get::(4) .map_err(|error| global_db_operation_error(OPERATION, error))? != 0; + let definition_sql = row + .get::>(9) + .map_err(|error| global_db_operation_error(OPERATION, error))?; if current .as_ref() .is_some_and(|(current_table, index)| current_table != &table || index.name != name) @@ -249,6 +256,7 @@ pub(super) async fn read_table_metadata( unique, origin, partial, + definition_sql, columns: Vec::new(), }, ) diff --git a/crates/tracedecay-global-db/src/schema_contract/validation.rs b/crates/tracedecay-global-db/src/schema_contract/validation.rs index 37f84315bc3..0cf04f01c6e 100644 --- a/crates/tracedecay-global-db/src/schema_contract/validation.rs +++ b/crates/tracedecay-global-db/src/schema_contract/validation.rs @@ -151,6 +151,24 @@ fn foreign_keys_match(actual: &[ActualForeignKey], contract: &Table) -> bool { }) } +// SQLite's partial-index boolean does not establish which rows the index +// covers. The queue's ordered-head plan depends on this exact terminal bound; +// a same-named index with a narrower predicate is not interchangeable. +fn queue_active_index_predicate_matches(definition_sql: Option<&str>) -> bool { + let Some(sql) = definition_sql else { + return false; + }; + let sql = sql.to_ascii_lowercase(); + let Some((_, predicate)) = sql.rsplit_once("where") else { + return false; + }; + let predicate = predicate + .chars() + .filter(|character| !character.is_ascii_whitespace() && *character != ';') + .collect::(); + predicate == "next_retry_at_micros<9223372036854775807" +} + fn index_matches(actual: &ActualIndex, expected: &Index) -> bool { let expected_partial = expected.name.is_some_and(|name| { name.eq_ignore_ascii_case("idx_session_temporal_generations_one_active") @@ -165,6 +183,8 @@ fn index_matches(actual: &ActualIndex, expected: &Index) -> bool { || !actual.origin.eq_ignore_ascii_case(expected.origin) || actual.partial != expected_partial || actual.columns.len() != expected.columns.len() + || (expected.name == Some("idx_projection_queue_active_sequence") + && !queue_active_index_predicate_matches(actual.definition_sql.as_deref())) { return false; } From 4700f3075d287bf11030065744575a658ffdc635 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:52:07 -0800 Subject: [PATCH 18/64] style(sessions): normalize projection regression spacing Refs #4756. Keep rustfmt-consistent spacing around the new test. --- crates/tracedecay-global-db/src/observation_projection/schema.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index 8606786743e..7e4d8de70b5 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -669,7 +669,6 @@ mod tests { ); } - #[asupersync::test] async fn registered_projection_queue_rejects_wrong_partial_predicate() { let temp = TempDir::new().unwrap(); From ba5f8623333e32aec6637ae426b8384d1f05cb0e Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:20:04 -0800 Subject: [PATCH 19/64] fix(storage): validate the complete active queue index definition --- .../src/observation_projection.rs | 2 +- .../src/observation_projection/schema.rs | 13 +++++++------ .../src/schema_contract/invariants.rs | 2 +- .../src/schema_contract/invariants/triggers.rs | 4 ++-- .../src/schema_contract/mod.rs | 2 +- .../src/schema_contract/validation.rs | 16 +++++++--------- 6 files changed, 19 insertions(+), 20 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection.rs b/crates/tracedecay-global-db/src/observation_projection.rs index 743ec5da540..cffc0be8705 100644 --- a/crates/tracedecay-global-db/src/observation_projection.rs +++ b/crates/tracedecay-global-db/src/observation_projection.rs @@ -18,7 +18,7 @@ pub(super) use apply::{ derive_projection_with_alias, retire_source_record, verify_workflow_effects, }; pub use rebuild::{project_observation, project_queued_observations, rebuild_projection}; -pub(crate) use schema::OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL; +pub(crate) use schema::{ACTIVE_QUEUE_INDEX_SQL, OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL}; pub(super) use schema::{ ensure_observation_projection_performance_indexes, ensure_observation_projection_schema, }; diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index 7e4d8de70b5..2a36e184ad3 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -310,15 +310,16 @@ const OBSERVATION_PROJECTION_SCHEMA_SQL: &str = const OBSERVATION_PROJECTION_BINDING_TRIGGERS_SQL: &str = include_str!("projection_binding_triggers.sql"); +/// Queue-head reads share this partial index's terminal collision bound. +pub(crate) const ACTIVE_QUEUE_INDEX_SQL: &str = + "CREATE INDEX IF NOT EXISTS idx_projection_queue_active_sequence + ON projection_queue (observation_sequence, next_retry_at_micros) + WHERE next_retry_at_micros < 9223372036854775807;"; + /// Historical-data indexes the schema contract requires on the projection /// authority. pub(crate) const OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL: &[&str] = &[ - // The terminal collision sentinel is i64::MAX. Keep this literal - // predicate identical to the ordered queue-head reads so SQLite can - // avoid scanning the parked prefix while honoring sequence fences. - "CREATE INDEX IF NOT EXISTS idx_projection_queue_active_sequence - ON projection_queue (observation_sequence, next_retry_at_micros) - WHERE next_retry_at_micros < 9223372036854775807;", + ACTIVE_QUEUE_INDEX_SQL, // Nonterminal queue probes must not scan every permanently parked collision // on every idle pass. SQLite can also use this for retry frontier reads. "CREATE INDEX IF NOT EXISTS idx_projection_queue_retry_deadline diff --git a/crates/tracedecay-global-db/src/schema_contract/invariants.rs b/crates/tracedecay-global-db/src/schema_contract/invariants.rs index f0b3aa17417..1ef9bb1191b 100644 --- a/crates/tracedecay-global-db/src/schema_contract/invariants.rs +++ b/crates/tracedecay-global-db/src/schema_contract/invariants.rs @@ -9,7 +9,7 @@ type ProjectionPage = (AuditCheckpoint, i64, i64, i64, bool); use tracedecay_store::SESSION_MESSAGE_PROJECTOR_VERSION; use super::super::global_db_operation_error; -use super::normalize_trigger_sql; +use super::normalize_schema_sql; use tracedecay_runtime_core::db::{ Database, DatabaseEngineReadSnapshot, DatabaseWriteTransaction, engine::{Executor, QueryExecutor, params}, diff --git a/crates/tracedecay-global-db/src/schema_contract/invariants/triggers.rs b/crates/tracedecay-global-db/src/schema_contract/invariants/triggers.rs index d2a6a324c41..a7a036e61e5 100644 --- a/crates/tracedecay-global-db/src/schema_contract/invariants/triggers.rs +++ b/crates/tracedecay-global-db/src/schema_contract/invariants/triggers.rs @@ -5,7 +5,7 @@ use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Result}; use super::rows::audit_read_error; -use super::normalize_trigger_sql; +use super::normalize_schema_sql; pub(in crate::schema_contract) struct Trigger { pub(in crate::schema_contract) name: &'static str, @@ -1684,7 +1684,7 @@ pub(in crate::schema_contract) async fn first_invalid_trigger( .get(&trigger.name.to_ascii_lowercase()) .is_some_and(|(table, sql)| { table.eq_ignore_ascii_case(trigger.table) - && normalize_trigger_sql(sql) == normalize_trigger_sql(trigger.create_sql) + && normalize_schema_sql(sql) == normalize_schema_sql(trigger.create_sql) }) })) } diff --git a/crates/tracedecay-global-db/src/schema_contract/mod.rs b/crates/tracedecay-global-db/src/schema_contract/mod.rs index 1aed76024bd..e24b9aecfed 100644 --- a/crates/tracedecay-global-db/src/schema_contract/mod.rs +++ b/crates/tracedecay-global-db/src/schema_contract/mod.rs @@ -161,7 +161,7 @@ pub(crate) fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool { .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_bytes())) } -fn normalize_trigger_sql(sql: &str) -> String { +fn normalize_schema_sql(sql: &str) -> String { sql.trim_end_matches(';') .split_whitespace() .collect::>() diff --git a/crates/tracedecay-global-db/src/schema_contract/validation.rs b/crates/tracedecay-global-db/src/schema_contract/validation.rs index 0cf04f01c6e..a7bded4bed7 100644 --- a/crates/tracedecay-global-db/src/schema_contract/validation.rs +++ b/crates/tracedecay-global-db/src/schema_contract/validation.rs @@ -10,11 +10,14 @@ use tracedecay_rusqlite_runtime::repository::{ GraphPublicationSchemaObjectV1, canonical_graph_publication_schema_inventory, }; +use crate::observation_projection::ACTIVE_QUEUE_INDEX_SQL; + use super::super::{global_db_operation_error, global_db_operation_message}; use super::definitions::{ Column, INDEX_DESCENDING_COLUMNS, INDEX_EXPRESSION_COLUMN, INDEXES, Index, REGISTRY_TABLE_NAMES, TABLES, Table, }; +use super::normalize_schema_sql; use super::pragma::{ ActualColumn, ActualForeignKey, ActualIndex, ActualTableMetadata, read_table_metadata, }; @@ -158,15 +161,10 @@ fn queue_active_index_predicate_matches(definition_sql: Option<&str>) -> bool { let Some(sql) = definition_sql else { return false; }; - let sql = sql.to_ascii_lowercase(); - let Some((_, predicate)) = sql.rsplit_once("where") else { - return false; - }; - let predicate = predicate - .chars() - .filter(|character| !character.is_ascii_whitespace() && *character != ';') - .collect::(); - predicate == "next_retry_at_micros<9223372036854775807" + // sqlite_schema stores CREATE INDEX without IF NOT EXISTS. Compare the + // complete stored definition so SQL comments cannot stand in for a predicate. + let expected = ACTIVE_QUEUE_INDEX_SQL.replacen(" IF NOT EXISTS", "", 1); + normalize_schema_sql(sql) == normalize_schema_sql(&expected) } fn index_matches(actual: &ActualIndex, expected: &Index) -> bool { From 16a426644a41bac24ebfd17a541e6c7c29b34c1b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:52:34 -0800 Subject: [PATCH 20/64] test(automation): bind scheduler fixtures to backend identities Repair three hosted automation_runner_test failures by supplying the configured executable identity required by the production prefilter. Use the same identity for the prefilter and retained runner, preserving manual-schedule, exact-ledger, live-lock and lock-race assertions. Add a production-prefilter regression for unconfigured refusal, deduplication and same-occurrence recovery after binding, with zero backend calls and no effectful terminal. Do not relax backend admission, change timeouts, or alter the graph publication changes. Refs #4532 --- .../tests/automation_runner_test/jobs.rs | 124 ++++++++++++++++-- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay/tests/automation_runner_test/jobs.rs b/crates/tracedecay/tests/automation_runner_test/jobs.rs index 740283d56e8..7342786381b 100644 --- a/crates/tracedecay/tests/automation_runner_test/jobs.rs +++ b/crates/tracedecay/tests/automation_runner_test/jobs.rs @@ -86,6 +86,7 @@ fn enabled_job_config() -> AutomationSettingsV1 { struct ContentBackend { calls: AtomicUsize, content: &'static str, + executable: Option, } impl ContentBackend { @@ -93,9 +94,15 @@ impl ContentBackend { Self { calls: AtomicUsize::new(0), content, + executable: None, } } + fn with_executable(mut self, executable: &Path) -> Self { + self.executable = Some(executable.to_path_buf()); + self + } + fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } @@ -126,7 +133,7 @@ impl AgentTaskBackend for ContentBackend { } fn executable(&self) -> Option<&std::path::Path> { - None + self.executable.as_deref() } } @@ -988,6 +995,10 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let mut job = sample_job("sched-job"); job.schedule = Some("manual".to_string()); let config = enabled_job_config(); + // Reach the schedule gate with a bound identity, not the earlier + // backend-unconfigured refusal. This gate never invokes the backend. + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); // Manual schedule: every scheduler tick of the same occurrence reaches // the prefilter, which records the skip once. @@ -996,7 +1007,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let run = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1019,7 +1030,7 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { let repeated = evaluate_and_record_scheduler_skip( &dashboard_root, &config, - None, + backend.executable(), &job, "sched-occurrence", None, @@ -1031,6 +1042,76 @@ async fn scheduler_trigger_skips_repeat_and_respects_lock_discipline() { repeated.ledger_record, records[0], "an immediate repeat skip must return the exact durable prior row" ); + assert_eq!(backend.calls(), 0); +} + +#[tokio::test] +async fn scheduler_prefilter_rejects_unconfigured_backend_and_recovers_without_effect() { + let temp = tempdir().unwrap(); + let dashboard_root = temp.path().join("dashboard"); + let job = sample_job("backend-recovery"); + let config = enabled_job_config(); + let occurrence = "backend-recovery-occurrence"; + let backend = ContentBackend::new("unused"); + + let first = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("a due job still requires a configured backend"); + assert_eq!(first.report["reason"], json!("backend_unconfigured")); + assert_eq!(first.ledger_record.status, AutomationRunStatus::Skipped); + assert_ne!(first.run_id, occurrence); + let repeated = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(repeated.ledger_record, first.ledger_record); + + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = backend.with_executable(executable.path()); + assert!( + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), + "binding the backend must admit the same due occurrence without cooldown" + ); + assert_eq!(backend.calls(), 0); + assert_eq!( + load_run_records(&dashboard_root, 10).await.unwrap(), + vec![first.ledger_record], + "advisory admission must not append an effectful terminal" + ); + assert!( + tracedecay_automation_runtime::automation::run_ledger::find_run_record_exact_bounded( + &dashboard_root, + occurrence, + ) + .await + .unwrap() + .is_none() + ); } #[tokio::test] @@ -1206,15 +1287,22 @@ async fn scheduler_prefilter_live_lock_wins_over_not_due_summary() { let config = enabled_job_config(); let occurrence = "locked-prefilter-occurrence"; - let prefilter = - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .expect("the live job lock must produce a diagnostic"); + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); + let prefilter = evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .expect("the live job lock must produce a diagnostic"); assert_eq!(prefilter.report["reason"], json!("scheduler_lock_active")); assert_ne!(prefilter.run_id, occurrence); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, @@ -1262,12 +1350,21 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { job.schedule = Some("every 1h".to_owned()); let config = enabled_job_config(); let occurrence = "prefilter-gap-occurrence"; + let executable = tempfile::NamedTempFile::new().unwrap(); + let backend = ContentBackend::new("unused").with_executable(executable.path()); assert!( - evaluate_and_record_scheduler_skip(&dashboard_root, &config, None, &job, occurrence, None) - .await - .unwrap() - .is_none(), + evaluate_and_record_scheduler_skip( + &dashboard_root, + &config, + backend.executable(), + &job, + occurrence, + None, + ) + .await + .unwrap() + .is_none(), "a new interval job is advisory-due before effect admission" ); let _raced_in = @@ -1276,7 +1373,6 @@ async fn retained_scheduler_runner_reacquires_after_due_prefilter() { .unwrap() .expect("the advisory prefilter must release its reservation lock"); - let backend = ContentBackend::new("unused"); let admitted_job = tracedecay_automation_runtime::automation::jobs::admit_job(&job).unwrap(); let retained = run_user_job_with_backend_for_retained_settlement( &dashboard_root, From 935c8c716583de5d1d24e96db1e0aee2c5308d03 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:47:03 -0800 Subject: [PATCH 21/64] perf(sessions): avoid rearming already-due projection retries Only future nonterminal projection deadlines need restart rearm. Leave expired deadlines eligible for ordinary projection without acquiring a writer or rewriting the same rows on each restart. Add a persistent-store restart regression with real writer telemetry, interleaved overdue, deferred, and terminal rows, exact deadlines, and bounded one-row write windows. Refs #4756 --- .../src/observation_projection/rearm_tests.rs | 107 ++++++++++++++++++ .../src/observation_projection/state.rs | 23 ++-- 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index bf75ad69f0c..65653201ac0 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -204,6 +204,113 @@ async fn rearm_walks_deferred_retries_in_bounded_batches() { ); } +#[asupersync::test] +async fn expired_retry_deadlines_survive_restarts_without_rearm_writes() { + let harness = RegisteredGlobalDbHarness::open("rearm-expired-deadlines").await; + seed_queue(&harness, 8, 0).await; + + // These positive deadlines have already elapsed. Normal projection may + // consume them directly; a restart must not rewrite the entire overdue + // queue just to change each deadline to zero. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute("UPDATE projection_queue SET next_retry_at_micros = 1", ()) + .await + .unwrap(), + 8 + ); + transaction.commit().await.unwrap(); + + let (harness, _convergence) = harness.restart_for_daemon().await; + let before = writer_telemetry(&harness.registered).transactions; + for _ in 0..2 { + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .unwrap(), + RearmedProjectionRetries::default(), + "expired retries already admit projection and need no restart writes" + ); + assert_eq!( + writer_telemetry(&harness.registered).transactions, + before, + "an overdue-only queue must not acquire any SQLite writer" + ); + } + assert_eq!(queue_state(&harness).await, (8, 8, 8)); + + // Interleave actual deferred retries and a terminal collision. Each + // bounded transaction must touch just its one deferred row, preserving + // the other deadlines and the retry history. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?1 + WHERE observation_sequence IN (2, 6)", + params![FAR_FUTURE_MICROS], + ) + .await + .unwrap(), + 2 + ); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?1 + WHERE observation_sequence = 4", + params![i64::MAX], + ) + .await + .unwrap(), + 1 + ); + transaction.commit().await.unwrap(); + + let before = writer_telemetry(&harness.registered).transactions; + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 1) + .await + .unwrap(), + RearmedProjectionRetries { + rows: 2, + batches: 2, + } + ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!(after.committed_transactions - before.committed_transactions, 2); + assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); + assert_eq!(queue_state(&harness).await, (8, 6, 8)); + + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT observation_sequence, next_retry_at_micros + FROM projection_queue ORDER BY observation_sequence", + (), + ) + .await + .unwrap(); + for sequence in 1..=8_i64 { + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), sequence); + let deadline = row.get::(1).unwrap(); + assert_eq!( + deadline, + if sequence == 4 { + i64::MAX + } else if sequence == 2 || sequence == 6 { + 0 + } else { + 1 + }, + "rearm must not rewrite expired or terminal deadlines" + ); + } + assert!(rows.next().await.unwrap().is_none()); +} + #[asupersync::test] async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() { let harness = RegisteredGlobalDbHarness::open("rearm-terminal-collisions").await; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index e496d6a7936..2818a059132 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -260,8 +260,9 @@ pub(crate) struct RearmedProjectionRetries { /// for immediate replay so recovery does not wait out a dead process's backoff. /// Attempt counts, last errors, and terminal collision deadlines persist. /// -/// The reader captures the last retryable row before taking any writer lease. -/// Already-armed and terminal-only queues therefore need no writer transaction. +/// The reader captures the last still-deferred row before taking any writer +/// lease. Already-due, already-armed, and terminal-only queues need no writer +/// transaction; expired deadlines remain eligible for ordinary projection. /// Each write window selects and updates at most `batch_rows` eligible rows /// under the same writer lease; foreground writes interleave between windows. /// Scanning past ineligible rows can still scale with the queue. Rows beyond @@ -282,11 +283,12 @@ pub(crate) async fn rearm_queued_projection_retries( )); } let mut rearmed = RearmedProjectionRetries::default(); + let now = tracedecay_contracts::clock::now_micros().0; let Some(last_rowid) = query_optional_i64( &database.read_connection(), "SELECT MAX(rowid) FROM projection_queue - WHERE next_retry_at_micros > 0 AND next_retry_at_micros < 9223372036854775807", - (), + WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807", + params![now], ) .await? else { @@ -295,7 +297,7 @@ pub(crate) async fn rearm_queued_projection_retries( let mut after_rowid = 0_i64; while after_rowid < last_rowid { let Some((batch_end, rows)) = - rearm_projection_retry_batch(database, after_rowid, last_rowid, batch_rows).await? + rearm_projection_retry_batch(database, after_rowid, last_rowid, batch_rows, now).await? else { break; }; @@ -306,7 +308,7 @@ pub(crate) async fn rearm_queued_projection_retries( Ok(rearmed) } -/// Re-arms the next `batch_rows` deferred rows after `after_rowid` in one +/// Re-arms the next `batch_rows` not-yet-due rows after `after_rowid` in one /// writer transaction and returns the last rowid it covered. #[tracing::instrument( name = "global_db.observation_projection.rearm_retries.batch", @@ -318,6 +320,7 @@ async fn rearm_projection_retry_batch( after_rowid: i64, last_rowid: i64, batch_rows: i64, + now_micros: i64, ) -> ProjectionStoreResult> { const OPERATION: &str = "rearm queued projection retries"; let transaction = database @@ -329,11 +332,11 @@ async fn rearm_projection_retry_batch( "SELECT MAX(rowid) FROM ( SELECT rowid FROM projection_queue WHERE rowid > ?1 AND rowid <= ?2 - AND next_retry_at_micros > 0 + AND next_retry_at_micros > ?4 AND next_retry_at_micros < 9223372036854775807 ORDER BY rowid LIMIT ?3 )", - params![after_rowid, last_rowid, batch_rows], + params![after_rowid, last_rowid, batch_rows, now_micros], ) .await? else { @@ -347,9 +350,9 @@ async fn rearm_projection_retry_batch( .execute( "UPDATE projection_queue SET next_retry_at_micros = 0 WHERE rowid > ?1 AND rowid <= ?2 - AND next_retry_at_micros > 0 + AND next_retry_at_micros > ?3 AND next_retry_at_micros < 9223372036854775807", - params![after_rowid, batch_end], + params![after_rowid, batch_end, now_micros], ) .await .map_err(|error| storage(OPERATION, error))?; From ca73d62fd3ec8ff72051297e7327c69db2780894 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:24:04 -0800 Subject: [PATCH 22/64] style(sessions): apply hosted Rust 2024 formatting --- .../src/observation_projection/rearm_tests.rs | 10 ++++++++-- .../src/observation_projection/state.rs | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 65653201ac0..15ca0e4b85d 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -279,8 +279,14 @@ async fn expired_retry_deadlines_survive_restarts_without_rearm_writes() { } ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!(after.committed_transactions - before.committed_transactions, 2); - assert_eq!(after.rolled_back_transactions, before.rolled_back_transactions); + assert_eq!( + after.committed_transactions - before.committed_transactions, + 2 + ); + assert_eq!( + after.rolled_back_transactions, + before.rolled_back_transactions + ); assert_eq!(queue_state(&harness).await, (8, 6, 8)); let snapshot = harness.registered.read_snapshot().await.unwrap(); diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 2818a059132..efd36c4d395 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -297,7 +297,8 @@ pub(crate) async fn rearm_queued_projection_retries( let mut after_rowid = 0_i64; while after_rowid < last_rowid { let Some((batch_end, rows)) = - rearm_projection_retry_batch(database, after_rowid, last_rowid, batch_rows, now).await? + rearm_projection_retry_batch(database, after_rowid, last_rowid, batch_rows, now) + .await? else { break; }; From 35c00f38d1fddac77bed30fd2956fae909777e80 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:57:46 -0800 Subject: [PATCH 23/64] fix(sessions): document stable QPSG retry cutoff plans Keep the terminal sentinel literal in each projection re-arm query, while permitting the independently bound due-time filter. The SQLite plan-stability check supports an explained exception for this combination. This preserves due/terminal filtering and bounded writes without changing the check or relaxing its policy. Refs #4756 --- .../src/observation_projection/state.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index efd36c4d395..2af333ecde8 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -286,6 +286,8 @@ pub(crate) async fn rearm_queued_projection_retries( let now = tracedecay_contracts::clock::now_micros().0; let Some(last_rowid) = query_optional_i64( &database.read_connection(), + // qpsg-exempt: the fixed terminal sentinel anchors index eligibility; + // the dynamic due-time bound further narrows eligible retry rows. "SELECT MAX(rowid) FROM projection_queue WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807", params![now], @@ -330,6 +332,8 @@ async fn rearm_projection_retry_batch( .map_err(|error| storage(OPERATION, error))?; let Some(batch_end) = query_optional_i64( &transaction, + // qpsg-exempt: the fixed terminal sentinel keeps the eligible-row + // partial-index predicate explicit; the due-time bound is additional. "SELECT MAX(rowid) FROM ( SELECT rowid FROM projection_queue WHERE rowid > ?1 AND rowid <= ?2 @@ -349,6 +353,8 @@ async fn rearm_projection_retry_batch( }; let rows = transaction .execute( + // qpsg-exempt: the literal terminal sentinel remains in WHERE; + // the bound deadline excludes already-due rows during restart. "UPDATE projection_queue SET next_retry_at_micros = 0 WHERE rowid > ?1 AND rowid <= ?2 AND next_retry_at_micros > ?3 From 563216873c81d800f8b6e9a5971400613acf7242 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:05:01 -0800 Subject: [PATCH 24/64] perf(sessions): update exact rows from each retry window --- .../src/observation_projection/rearm_tests.rs | 23 ++++++ .../src/observation_projection/state.rs | 76 ++++++++++--------- 2 files changed, 64 insertions(+), 35 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 15ca0e4b85d..bbc133cf93d 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -157,6 +157,29 @@ async fn queue_state(harness: &RegisteredGlobalDbHarness) -> (i64, i64, i64) { ) } +#[asupersync::test] +async fn rearm_writer_work_scales_with_deferred_rows() { + async fn writer_steps(rows: i64) -> u64 { + let harness = RegisteredGlobalDbHarness::open("rearm-dense-queue").await; + seed_queue(&harness, rows, 0).await; + let steps = || writer_telemetry(&harness.registered).sqlite_vm.vm_steps; + let before = steps(); + let rearmed = rearm_queued_projection_retries(harness.registered.runtime_database(), 4) + .await + .unwrap(); + assert_eq!(rearmed.rows, u64::try_from(rows).unwrap()); + steps() - before + } + + let base = writer_steps(32).await; + let grown = writer_steps(256).await; + assert!(base > 0); + assert!( + grown < base * 12, + "an eightfold larger dense queue must not rescan its remainder per window: {base} -> {grown}" + ); +} + #[asupersync::test] async fn rearm_walks_deferred_retries_in_bounded_batches() { let harness = RegisteredGlobalDbHarness::open("rearm-bounded-batches").await; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 2af333ecde8..67cbc9aa404 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -16,7 +16,7 @@ use tracedecay_lcm::raw::{message_body_from_record_row, stored_message_record_se use tracedecay_lcm::retrieval_content::projected_content_hash; use tracedecay_lcm::{LcmError, LcmStorageKind}; use tracedecay_runtime_core::db::Database; -use tracedecay_runtime_core::db::engine::{Executor, IntoParams, QueryExecutor, Row, params}; +use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; use tracedecay_sessions::runtime::shared::durable_project_path_key; #[cfg(windows)] use tracedecay_sessions::runtime::shared::path_identity_key; @@ -284,15 +284,7 @@ pub(crate) async fn rearm_queued_projection_retries( } let mut rearmed = RearmedProjectionRetries::default(); let now = tracedecay_contracts::clock::now_micros().0; - let Some(last_rowid) = query_optional_i64( - &database.read_connection(), - // qpsg-exempt: the fixed terminal sentinel anchors index eligibility; - // the dynamic due-time bound further narrows eligible retry rows. - "SELECT MAX(rowid) FROM projection_queue - WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807", - params![now], - ) - .await? + let Some(last_rowid) = last_deferred_retry_rowid(&database.read_connection(), now).await? else { return Ok(rearmed); }; @@ -330,20 +322,29 @@ async fn rearm_projection_retry_batch( .begin_write_transaction(OPERATION) .await .map_err(|error| storage(OPERATION, error))?; - let Some(batch_end) = query_optional_i64( - &transaction, - // qpsg-exempt: the fixed terminal sentinel keeps the eligible-row - // partial-index predicate explicit; the due-time bound is additional. - "SELECT MAX(rowid) FROM ( - SELECT rowid FROM projection_queue - WHERE rowid > ?1 AND rowid <= ?2 - AND next_retry_at_micros > ?4 - AND next_retry_at_micros < 9223372036854775807 - ORDER BY rowid LIMIT ?3 - )", - params![after_rowid, last_rowid, batch_rows, now_micros], - ) - .await? + let mut candidates = transaction + .query( + // qpsg-exempt: the literal ceiling excludes terminal rows; the independent + // clock bound narrows the forward primary-key window to deferred retries. + "SELECT MAX(rowid), json_group_array(rowid) FROM ( + SELECT rowid FROM projection_queue NOT INDEXED + WHERE rowid > ?1 AND rowid <= ?2 + AND next_retry_at_micros > ?4 + AND next_retry_at_micros < 9223372036854775807 + ORDER BY rowid LIMIT ?3 + )", + params![after_rowid, last_rowid, batch_rows, now_micros], + ) + .await + .map_err(|error| storage(OPERATION, error))?; + let batch = candidates + .next() + .await + .map_err(|error| storage(OPERATION, error))? + .ok_or_else(|| storage_message(OPERATION, "missing retry batch aggregate"))?; + let Some(batch_end) = batch + .get::>(0) + .map_err(|error| storage(OPERATION, error))? else { transaction .rollback() @@ -351,15 +352,14 @@ async fn rearm_projection_retry_batch( .map_err(|error| storage(OPERATION, error))?; return Ok(None); }; + // Membership was determined under this writer lease. Apply the bounded set + // by primary key so a dense deadline index is never rescanned per window. + let rowids: String = batch.get(1).map_err(|error| storage(OPERATION, error))?; let rows = transaction .execute( - // qpsg-exempt: the literal terminal sentinel remains in WHERE; - // the bound deadline excludes already-due rows during restart. "UPDATE projection_queue SET next_retry_at_micros = 0 - WHERE rowid > ?1 AND rowid <= ?2 - AND next_retry_at_micros > ?3 - AND next_retry_at_micros < 9223372036854775807", - params![after_rowid, batch_end, now_micros], + WHERE rowid IN (SELECT value FROM json_each(?1))", + params![rowids], ) .await .map_err(|error| storage(OPERATION, error))?; @@ -370,14 +370,20 @@ async fn rearm_projection_retry_batch( Ok(Some((batch_end, rows))) } -pub(super) async fn query_optional_i64( +async fn last_deferred_retry_rowid( conn: &impl QueryExecutor, - sql: &str, - params: impl IntoParams, + now_micros: i64, ) -> ProjectionStoreResult> { - const OPERATION: &str = "read projection retry rearm window"; + const OPERATION: &str = "read projection retry frontier"; + // The ceiling excludes terminal collisions. The clock excludes already-due rows. + // The deadline index also proves an empty range without walking the whole table. let mut rows = conn - .query(sql, params) + .query( + // qpsg-exempt: the deadline index uses both the clock and the literal terminal ceiling. + "SELECT MAX(rowid) FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline + WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807", + params![now_micros], + ) .await .map_err(|error| storage(OPERATION, error))?; let Some(row) = rows From 7708ab10450531cc6ad840e6bbe86997ad469bc8 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:10:29 -0800 Subject: [PATCH 25/64] test(sessions): measure settled retry writer leases --- .../src/observation_projection/rearm_tests.rs | 47 +++---------------- 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index bbc133cf93d..3c23c876a44 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -199,15 +199,10 @@ async fn rearm_walks_deferred_retries_in_bounded_batches() { }, "nine deferred rows re-arm in ceil(9 / 4) statements, never one queue-sized one" ); + // The exact-SQL writer counts one dispatched command per leased transaction, + // including a rollback. The persisted queue below proves the windows committed. let after = writer_telemetry(&harness.registered).transactions; - assert_eq!( - after.committed_transactions - before.committed_transactions, - 3 - ); - assert_eq!( - after.rolled_back_transactions, before.rolled_back_transactions, - "reaching the captured frontier must not open an empty final transaction" - ); + assert_eq!(after.commands - before.commands, 3); assert_eq!( queue_state(&harness).await, (12, 0, 12), @@ -302,14 +297,7 @@ async fn expired_retry_deadlines_survive_restarts_without_rearm_writes() { } ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!( - after.committed_transactions - before.committed_transactions, - 2 - ); - assert_eq!( - after.rolled_back_transactions, - before.rolled_back_transactions - ); + assert_eq!(after.commands - before.commands, 2); assert_eq!(queue_state(&harness).await, (8, 6, 8)); let snapshot = harness.registered.read_snapshot().await.unwrap(); @@ -376,14 +364,7 @@ async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() "parked collisions must not consume re-arm write windows" ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!( - after.committed_transactions - before.committed_transactions, - 2 - ); - assert_eq!( - after.rolled_back_transactions, - before.rolled_back_transactions - ); + assert_eq!(after.commands - before.commands, 2); assert_eq!( queue_state(&harness).await, (12, 6, 12), @@ -431,14 +412,7 @@ async fn rearm_ignores_parked_terminal_collisions_without_empty_write_batches() } ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!( - after.committed_transactions - before.committed_transactions, - 1 - ); - assert_eq!( - after.rolled_back_transactions, - before.rolled_back_transactions - ); + assert_eq!(after.commands - before.commands, 1); assert_eq!(queue_state(&harness).await, (12, 6, 12)); } @@ -467,14 +441,7 @@ async fn rearm_rejects_nonpositive_windows_before_acquiring_the_writer() { } ); let after = writer_telemetry(&harness.registered).transactions; - assert_eq!( - after.committed_transactions - before.committed_transactions, - 3 - ); - assert_eq!( - after.rolled_back_transactions, - before.rolled_back_transactions - ); + assert_eq!(after.commands - before.commands, 3); assert_eq!(queue_state(&harness).await, (5, 0, 5)); } From e30bcbc0091c2c24f44a02288529b2d0c06f4aad Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:16:26 -0800 Subject: [PATCH 26/64] fix(sessions): preserve qualified projection audit ownership --- crates/tracedecay-global-db/src/observation_projection/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 67cbc9aa404..7df52ae100b 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -1004,7 +1004,7 @@ fn output_authority_batch_sql() -> String { SELECT provenance.projector_version AS projector_version, provenance.output_provider AS output_provider, provenance.output_message_id AS output_message_id, - MAX(message_created) AS projector_owned, + MAX(provenance.message_created) AS projector_owned, COUNT(*) AS owner_count FROM json_each(?2) AS requested CROSS JOIN observation_projection_provenance AS provenance From 84bf09170c8783507f46bd7b137b64a50dc8ad53 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:22:49 -0800 Subject: [PATCH 27/64] test(sessions): distinguish mount-local projection retry deadlines --- .../observation_projection/queue.rs | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs index 87c88c52601..bd51932b777 100644 --- a/crates/tracedecay/tests/session_suite/observation_projection/queue.rs +++ b/crates/tracedecay/tests/session_suite/observation_projection/queue.rs @@ -570,7 +570,7 @@ async fn stale_exact_duplicate_queue_item_is_consumed_before_later_observation() } #[tokio::test] -async fn batched_projection_resumes_after_restart_without_replaying_parked_or_deferred_heads() { +async fn batched_projection_rearms_the_deferred_head_after_the_durable_checkpoint() { let tmp = TempDir::new().unwrap(); let database_path; let records = (0_u64..5) @@ -659,29 +659,17 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de let store = runtime .observation_store(HostAdmissionScope::Profile) .unwrap(); - let parked = store - .project_queued_observations(32) - .await - .unwrap() - .unwrap(); - assert!( - parked.items.is_empty(), - "restart must not replay committed work" + // Fresh admission re-arms the previous mount's retry. Its first ready + // item must be sequence four, after the durable checkpoint. + assert_eq!( + store.next_queued_observation().await.unwrap().as_ref(), + Some(records[3].observation_id()) ); - assert!(parked.has_more, "restart must not skip the deferred head"); assert_eq!( store.projection_checkpoint().await.unwrap().last_sequence(), 3 ); - - rusqlite::Connection::open(&database_path) - .unwrap() - .execute( - "UPDATE projection_queue SET next_retry_at_micros = 0 - WHERE observation_id = ?1", - rusqlite::params![records[3].observation_id().as_str()], - ) - .unwrap(); + assert_eq!(table_count(&tmp, "lcm_raw_messages").await, 3); let first = store.project_queued_observations(1).await.unwrap().unwrap(); assert_eq!( @@ -689,6 +677,10 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de 1, "one-item window must not overrun its bound" ); + assert!(matches!( + first.items[0].outcome, + ProjectionPersistOutcome::Projected(_) + )); assert!(first.has_more); assert_eq!( store.projection_checkpoint().await.unwrap().last_sequence(), @@ -762,7 +754,7 @@ async fn batched_projection_resumes_after_restart_without_replaying_parked_or_de } #[tokio::test] -async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { +async fn reopened_queue_skips_writer_for_a_newly_deferred_head() { let tmp = TempDir::new().unwrap(); let database_path; let first = observation( @@ -816,8 +808,7 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { .await .unwrap(); - // Keep a terminal collision and a deferred sequence. Sequence three - // is due, but must not bypass the deferred sequence two across restart. + // Terminal collisions persist across fresh admission. let connection = rusqlite::Connection::open(&database_path).unwrap(); connection .execute( @@ -833,13 +824,6 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { rusqlite::params![first.observation_id().as_str(), i64::MAX], ) .unwrap(); - connection - .execute( - "UPDATE projection_queue SET next_retry_at_micros = ?2 - WHERE observation_id = ?1", - rusqlite::params![second.observation_id().as_str(), i64::MAX - 1], - ) - .unwrap(); } let runtime = profile_runtime(&tmp).await; @@ -847,6 +831,17 @@ async fn deferred_only_reopened_queue_skips_writer_until_retry_is_due() { .observation_store(HostAdmissionScope::Profile) .unwrap(); let writer = rusqlite::Connection::open(&database_path).unwrap(); + // A retry deferred by this mount fences the already-due third row. + assert_eq!( + writer + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?2 + WHERE observation_id = ?1", + rusqlite::params![second.observation_id().as_str(), i64::MAX - 1], + ) + .unwrap(), + 1 + ); writer.execute_batch("BEGIN IMMEDIATE").unwrap(); for _ in 0..2 { let deferred = tokio::time::timeout( From 1df969b95272ba3cb2cd004c8b074b429a81f0ba Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:52:51 -0800 Subject: [PATCH 28/64] perf(sessions): move sparse retry scans off SQLite writer Refs #4756 Select bounded retry candidates on the read side, then conditionally rearm only unchanged observation identities under a short writer lease. Add registered-store regressions for sparse prefixes and stale retry rows. --- .../src/observation_projection/rearm_tests.rs | 124 ++++++++++++++++++ .../src/observation_projection/state.rs | 117 ++++++++++++----- 2 files changed, 210 insertions(+), 31 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 3c23c876a44..8689df03c4d 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -8,6 +8,9 @@ use tracedecay_domain::{ }; use tracedecay_runtime_core::db::engine::params; +use super::state::{ + apply_rearm_projection_retry_candidates, read_rearm_projection_retry_candidates, +}; use super::{RearmedProjectionRetries, rearm_queued_projection_retries}; use crate::tests::harness::{RegisteredGlobalDbHarness, writer_telemetry}; @@ -445,6 +448,127 @@ async fn rearm_rejects_nonpositive_windows_before_acquiring_the_writer() { assert_eq!(queue_state(&harness).await, (5, 0, 5)); } +#[asupersync::test] +async fn sparse_retry_rearm_writer_work_is_bounded_by_updated_rows() { + async fn writer_steps(prefix: i64, fixture: &'static str) -> u64 { + let harness = RegisteredGlobalDbHarness::open(fixture).await; + seed_queue(&harness, prefix + 4, 0).await; + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = 0 + WHERE observation_sequence <= ?1", + params![prefix], + ) + .await + .unwrap(), + u64::try_from(prefix).unwrap() + ); + transaction.commit().await.unwrap(); + let before = writer_telemetry(&harness.registered); + let rearmed = rearm_queued_projection_retries(harness.registered.runtime_database(), 4) + .await + .unwrap(); + assert_eq!( + rearmed, + RearmedProjectionRetries { + rows: 4, + batches: 1, + } + ); + let after = writer_telemetry(&harness.registered); + assert_eq!(after.transactions.commands - before.transactions.commands, 1); + assert_eq!(queue_state(&harness).await.1, 0); + after.sqlite_vm.vm_steps - before.sqlite_vm.vm_steps + } + + let small = writer_steps(32, "rearm-sparse-small").await; + let large = writer_steps(256, "rearm-sparse-large").await; + assert!(small > 0); + assert!( + large < small * 2, + "eight times as many inert queue rows must not multiply SQLite writer work: {small} -> {large}" + ); +} + +#[asupersync::test] +async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { + let harness = RegisteredGlobalDbHarness::open("rearm-stale-candidates").await; + seed_queue(&harness, 3, 0).await; + let now = 1_000_000_i64; + let (last, candidates) = read_rearm_projection_retry_candidates( + &harness.registered.runtime_database().read_connection(), + 0, + 3, + 3, + now, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(last, 3); + + // The reader's window is now stale: a foreground owner schedules a new + // generation for one row and another row has become due. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET attempt_count = attempt_count + 1 + WHERE observation_sequence = 2", + (), + ) + .await + .unwrap(), + 1 + ); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = 1 + WHERE observation_sequence = 3", + (), + ) + .await + .unwrap(), + 1 + ); + transaction.commit().await.unwrap(); + + assert_eq!( + apply_rearm_projection_retry_candidates( + harness.registered.runtime_database(), + &candidates, + now, + ) + .await + .unwrap(), + 1, + "only the unchanged candidate can be reset" + ); + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT observation_sequence, attempt_count, next_retry_at_micros + FROM projection_queue ORDER BY observation_sequence", + (), + ) + .await + .unwrap(); + for (sequence, attempts, deadline) in [ + (1_i64, 3_i64, 0_i64), + (2, 4, FAR_FUTURE_MICROS), + (3, 3, 1), + ] { + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), sequence); + assert_eq!(row.get::(1).unwrap(), attempts); + assert_eq!(row.get::(2).unwrap(), deadline); + } + assert!(rows.next().await.unwrap().is_none()); +} + #[asupersync::test] async fn daemon_admission_defers_rearm_to_background_convergence() { let harness = RegisteredGlobalDbHarness::open("rearm-daemon-admission").await; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 7df52ae100b..7d4932afcb5 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -263,10 +263,10 @@ pub(crate) struct RearmedProjectionRetries { /// The reader captures the last still-deferred row before taking any writer /// lease. Already-due, already-armed, and terminal-only queues need no writer /// transaction; expired deadlines remain eligible for ordinary projection. -/// Each write window selects and updates at most `batch_rows` eligible rows -/// under the same writer lease; foreground writes interleave between windows. -/// Scanning past ineligible rows can still scale with the queue. Rows beyond -/// the captured frontier are left for a later recovery pass. +/// Each read window selects at most `batch_rows` eligible rows outside +/// the writer lease. A short writer transaction conditionally updates their +/// exact observed identities, so sparse inactive prefixes never hold the +/// writer. Rows beyond the captured frontier are left for later recovery. #[tracing::instrument( name = "global_db.observation_projection.rearm_retries", level = "trace", @@ -297,14 +297,17 @@ pub(crate) async fn rearm_queued_projection_retries( break; }; rearmed.rows += rows; - rearmed.batches += 1; + if rows != 0 { + rearmed.batches += 1; + } after_rowid = batch_end; } Ok(rearmed) } -/// Re-arms the next `batch_rows` not-yet-due rows after `after_rowid` in one -/// writer transaction and returns the last rowid it covered. +/// Select a bounded retry window outside the SQLite writer lease. The +/// sparse tail scan may visit many already-due or terminal rows; only the +/// exact selected identities are looked up while holding the writer. #[tracing::instrument( name = "global_db.observation_projection.rearm_retries.batch", level = "trace", @@ -317,17 +320,42 @@ async fn rearm_projection_retry_batch( batch_rows: i64, now_micros: i64, ) -> ProjectionStoreResult> { - const OPERATION: &str = "rearm queued projection retries"; - let transaction = database - .begin_write_transaction(OPERATION) - .await - .map_err(|error| storage(OPERATION, error))?; - let mut candidates = transaction + let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( + &database.read_connection(), + after_rowid, + last_rowid, + batch_rows, + now_micros, + ) + .await? + else { + return Ok(None); + }; + let rows = apply_rearm_projection_retry_candidates(database, &candidates, now_micros).await?; + Ok(Some((batch_end, rows))) +} + +/// Candidate reads are repeatable within this statement, but not guaranteed +/// current when a later writer lease is acquired. Retain observation identity, +/// retry deadline and attempt generation for a conditional write. +pub(super) async fn read_rearm_projection_retry_candidates( + conn: &impl QueryExecutor, + after_rowid: i64, + last_rowid: i64, + batch_rows: i64, + now_micros: i64, +) -> ProjectionStoreResult> { + const OPERATION: &str = "read projection retry rearm window"; + let mut candidates = conn .query( - // qpsg-exempt: the literal ceiling excludes terminal rows; the independent - // clock bound narrows the forward primary-key window to deferred retries. - "SELECT MAX(rowid), json_group_array(rowid) FROM ( - SELECT rowid FROM projection_queue NOT INDEXED + // qpsg-exempt: the literal terminal ceiling and independent clock + // predicate select a bounded rowid-ordered window on the reader. + "SELECT MAX(rowid), + json_group_array(json_array(rowid, observation_id, + next_retry_at_micros, attempt_count)) + FROM ( + SELECT rowid, observation_id, next_retry_at_micros, attempt_count + FROM projection_queue NOT INDEXED WHERE rowid > ?1 AND rowid <= ?2 AND next_retry_at_micros > ?4 AND next_retry_at_micros < 9223372036854775807 @@ -346,28 +374,55 @@ async fn rearm_projection_retry_batch( .get::>(0) .map_err(|error| storage(OPERATION, error))? else { - transaction - .rollback() - .await - .map_err(|error| storage(OPERATION, error))?; return Ok(None); }; - // Membership was determined under this writer lease. Apply the bounded set - // by primary key so a dense deadline index is never rescanned per window. - let rowids: String = batch.get(1).map_err(|error| storage(OPERATION, error))?; + let identities: String = batch.get(1).map_err(|error| storage(OPERATION, error))?; + Ok(Some((batch_end, identities))) +} + +/// Primary-key lookups make writer work proportional to this bounded page. +/// A newly retried, settled, terminal, or replaced observation cannot be +/// rearmed using a stale reader snapshot. A fully stale page rolls back. +pub(super) async fn apply_rearm_projection_retry_candidates( + database: &Database, + candidates: &str, + now_micros: i64, +) -> ProjectionStoreResult { + const OPERATION: &str = "rearm queued projection retries"; + let transaction = database + .begin_write_transaction(OPERATION) + .await + .map_err(|error| storage(OPERATION, error))?; let rows = transaction .execute( + // qpsg-exempt: the candidate list contains exact rowid identities, + // and the literal terminal bound protects a concurrent park. "UPDATE projection_queue SET next_retry_at_micros = 0 - WHERE rowid IN (SELECT value FROM json_each(?1))", - params![rowids], + WHERE (rowid, observation_id, next_retry_at_micros, attempt_count) IN ( + SELECT json_extract(value, '$[0]'), + json_extract(value, '$[1]'), + json_extract(value, '$[2]'), + json_extract(value, '$[3]') + FROM json_each(?1) + ) + AND next_retry_at_micros > ?2 + AND next_retry_at_micros < 9223372036854775807", + params![candidates, now_micros], ) .await .map_err(|error| storage(OPERATION, error))?; - transaction - .commit() - .await - .map_err(|error| storage(OPERATION, error))?; - Ok(Some((batch_end, rows))) + if rows == 0 { + transaction + .rollback() + .await + .map_err(|error| storage(OPERATION, error))?; + } else { + transaction + .commit() + .await + .map_err(|error| storage(OPERATION, error))?; + } + Ok(rows) } async fn last_deferred_retry_rowid( From fbec98284fd127d366ebffe2bfaaeb397a9cf3e9 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:08:21 -0800 Subject: [PATCH 29/64] test(sessions): cover interrupted retry recovery across restart Refs #4756 Preserve the concurrent sparse-queue source fix. Exercise two committed windows followed by a real SQLite trigger failure, then reopen the registered store and require bounded completion of only the remainder. Reject any update to the committed prefix, assert exact writer lease counts, and require repeated idle recovery to acquire no writer. The native regression requires Bazel CI; local SQL checks are supporting mechanism evidence, not Rust execution or incident-scale acceptance. --- .../src/observation_projection/rearm_tests.rs | 2 + .../rearm_tests/restart.rs | 122 ++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 8689df03c4d..753ec08ced5 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -1,3 +1,5 @@ +mod restart; + use serde_json::Value; use tracedecay_domain::{ CanonicalObservationEnvelopeV1, ComponentVersion, DurableObservationV1, ObservationId, diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs new file mode 100644 index 00000000000..dad7504a6bc --- /dev/null +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs @@ -0,0 +1,122 @@ +use crate::observation_projection::{RearmedProjectionRetries, rearm_queued_projection_retries}; +use crate::tests::harness::{RegisteredGlobalDbHarness, writer_telemetry}; + +use super::{FAR_FUTURE_MICROS, queue_state, seed_queue}; + +async fn retry_deadlines(harness: &RegisteredGlobalDbHarness) -> Vec<(i64, i64)> { + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT observation_sequence, next_retry_at_micros + FROM projection_queue ORDER BY observation_sequence", + (), + ) + .await + .unwrap(); + let mut deadlines = Vec::new(); + while let Some(row) = rows.next().await.unwrap() { + deadlines.push((row.get(0).unwrap(), row.get(1).unwrap())); + } + deadlines +} + +#[asupersync::test] +async fn retry_rearm_resumes_committed_windows_after_failure_and_restart() { + let harness = RegisteredGlobalDbHarness::open("rearm-interrupted-recovery").await; + seed_queue(&harness, 9, 0).await; + + // Two windows commit before the third fails. The failing window must + // roll back as a unit rather than lose its first observation's retry. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "CREATE TRIGGER reject_retry_window + BEFORE UPDATE OF next_retry_at_micros ON projection_queue + WHEN OLD.observation_sequence = 6 AND NEW.next_retry_at_micros = 0 + BEGIN SELECT RAISE(ABORT, 'injected retry window failure'); END", + (), + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + assert!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .is_err(), + "the injected failure must interrupt recovery" + ); + + let interrupted = (1..=9_i64) + .map(|sequence| { + ( + sequence, + if sequence <= 4 { 0 } else { FAR_FUTURE_MICROS }, + ) + }) + .collect::>(); + assert_eq!(retry_deadlines(&harness).await, interrupted); + assert_eq!(queue_state(&harness).await, (9, 5, 9)); + + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute("DROP TRIGGER reject_retry_window", ()) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let (harness, _convergence) = harness.restart_for_daemon().await; + assert_eq!(retry_deadlines(&harness).await, interrupted); + + // Make a replay of the committed prefix fail, even if it would write + // identical values. Row counts alone cannot detect redundant updates. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "CREATE TRIGGER reject_committed_retry_replay + BEFORE UPDATE OF next_retry_at_micros ON projection_queue + WHEN OLD.observation_sequence <= 4 + BEGIN SELECT RAISE(ABORT, 'committed retry prefix replayed'); END", + (), + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + let before = writer_telemetry(&harness.registered).transactions; + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .unwrap(), + RearmedProjectionRetries { + rows: 5, + batches: 3, + }, + "restart must resume only the five remaining rows in bounded windows" + ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!( + after.commands - before.commands, + 3, + "the final productive window must not be followed by an empty writer lease" + ); + assert_eq!(queue_state(&harness).await, (9, 0, 9)); + assert_eq!( + retry_deadlines(&harness).await, + (1..=9_i64).map(|sequence| (sequence, 0)).collect::>() + ); + for _ in 0..2 { + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 2) + .await + .unwrap(), + RearmedProjectionRetries::default() + ); + assert_eq!(writer_telemetry(&harness.registered).transactions, after); + } + + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute("DROP TRIGGER reject_committed_retry_replay", ()) + .await + .unwrap(); + transaction.commit().await.unwrap(); +} From b6d7a9b726e8b9545758f1b9c0ad99ece3588278 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:14:19 -0800 Subject: [PATCH 30/64] fix(sessions): bound retry recovery by observation sequence --- .../src/observation_projection/rearm_tests.rs | 91 +++++++++++++++++-- .../src/observation_projection/state.rs | 58 ++++++++---- 2 files changed, 123 insertions(+), 26 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 753ec08ced5..99acb8cb7f3 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -12,6 +12,7 @@ use tracedecay_runtime_core::db::engine::params; use super::state::{ apply_rearm_projection_retry_candidates, read_rearm_projection_retry_candidates, + rearm_projection_retry_batch, retry_rearm_frontier, }; use super::{RearmedProjectionRetries, rearm_queued_projection_retries}; use crate::tests::harness::{RegisteredGlobalDbHarness, writer_telemetry}; @@ -480,7 +481,10 @@ async fn sparse_retry_rearm_writer_work_is_bounded_by_updated_rows() { } ); let after = writer_telemetry(&harness.registered); - assert_eq!(after.transactions.commands - before.transactions.commands, 1); + assert_eq!( + after.transactions.commands - before.transactions.commands, + 1 + ); assert_eq!(queue_state(&harness).await.1, 0); after.sqlite_vm.vm_steps - before.sqlite_vm.vm_steps } @@ -499,10 +503,17 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { let harness = RegisteredGlobalDbHarness::open("rearm-stale-candidates").await; seed_queue(&harness, 3, 0).await; let now = 1_000_000_i64; + let frontier = retry_rearm_frontier( + &harness.registered.runtime_database().read_connection(), + now, + ) + .await + .unwrap() + .unwrap(); let (last, candidates) = read_rearm_projection_retry_candidates( &harness.registered.runtime_database().read_connection(), 0, - 3, + frontier, 3, now, ) @@ -558,11 +569,9 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { ) .await .unwrap(); - for (sequence, attempts, deadline) in [ - (1_i64, 3_i64, 0_i64), - (2, 4, FAR_FUTURE_MICROS), - (3, 3, 1), - ] { + for (sequence, attempts, deadline) in + [(1_i64, 3_i64, 0_i64), (2, 4, FAR_FUTURE_MICROS), (3, 3, 1)] + { let row = rows.next().await.unwrap().unwrap(); assert_eq!(row.get::(0).unwrap(), sequence); assert_eq!(row.get::(1).unwrap(), attempts); @@ -571,6 +580,74 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { assert!(rows.next().await.unwrap().is_none()); } +#[asupersync::test] +async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { + let harness = RegisteredGlobalDbHarness::open("rearm-reused-queue-tail").await; + seed_queue(&harness, 4, 0).await; + let database = harness.registered.runtime_database(); + let now = 1_000_000; + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "DELETE FROM projection_queue WHERE observation_sequence = 4", + (), + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let frontier = retry_rearm_frontier(&database.read_connection(), now) + .await + .unwrap() + .unwrap(); + assert_eq!( + rearm_projection_retry_batch(database, 0, frontier, 1, now) + .await + .unwrap(), + Some((1, 1)) + ); + + // A foreground pass settles the old tail. SQLite assigns its rowid to a + // newer observation, whose retry belongs to the current mount. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "DELETE FROM projection_queue WHERE observation_sequence = 3", + (), + ) + .await + .unwrap(); + transaction + .execute( + "INSERT INTO projection_queue + (observation_id, observation_sequence, attempt_count, next_retry_at_micros, last_error) + SELECT observation_id, sequence, 1, ?1, 'new retry' + FROM observations WHERE sequence = 4", + params![FAR_FUTURE_MICROS], + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + assert_eq!( + rearm_projection_retry_batch(database, 1, frontier, 4, now) + .await + .unwrap(), + Some((2, 1)), + "only the original retry is inside the captured frontier" + ); + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT rowid, next_retry_at_micros FROM projection_queue + WHERE observation_sequence = 4", + (), + ) + .await + .unwrap(); + let new = rows.next().await.unwrap().unwrap(); + assert_eq!(new.get::(0).unwrap(), 3); + assert_eq!(new.get::(1).unwrap(), FAR_FUTURE_MICROS); +} + #[asupersync::test] async fn daemon_admission_defers_rearm_to_background_convergence() { let harness = RegisteredGlobalDbHarness::open("rearm-daemon-admission").await; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 7d4932afcb5..36f349fb215 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -255,12 +255,18 @@ pub(crate) struct RearmedProjectionRetries { pub(crate) batches: u64, } +#[derive(Clone, Copy, Debug)] +pub(super) struct RetryRearmFrontier { + last_rowid: i64, + last_sequence: i64, +} + /// A projection retry deadline paces re-attempts within the store mount that /// observed the failure. A fresh mount re-arms retryable deferred projections /// for immediate replay so recovery does not wait out a dead process's backoff. /// Attempt counts, last errors, and terminal collision deadlines persist. /// -/// The reader captures the last still-deferred row before taking any writer +/// The reader captures the queue frontier before taking any writer /// lease. Already-due, already-armed, and terminal-only queues need no writer /// transaction; expired deadlines remain eligible for ordinary projection. /// Each read window selects at most `batch_rows` eligible rows outside @@ -284,15 +290,13 @@ pub(crate) async fn rearm_queued_projection_retries( } let mut rearmed = RearmedProjectionRetries::default(); let now = tracedecay_contracts::clock::now_micros().0; - let Some(last_rowid) = last_deferred_retry_rowid(&database.read_connection(), now).await? - else { + let Some(frontier) = retry_rearm_frontier(&database.read_connection(), now).await? else { return Ok(rearmed); }; let mut after_rowid = 0_i64; - while after_rowid < last_rowid { + while after_rowid < frontier.last_rowid { let Some((batch_end, rows)) = - rearm_projection_retry_batch(database, after_rowid, last_rowid, batch_rows, now) - .await? + rearm_projection_retry_batch(database, after_rowid, frontier, batch_rows, now).await? else { break; }; @@ -313,17 +317,17 @@ pub(crate) async fn rearm_queued_projection_retries( level = "trace", skip_all )] -async fn rearm_projection_retry_batch( +pub(super) async fn rearm_projection_retry_batch( database: &Database, after_rowid: i64, - last_rowid: i64, + frontier: RetryRearmFrontier, batch_rows: i64, now_micros: i64, ) -> ProjectionStoreResult> { let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( &database.read_connection(), after_rowid, - last_rowid, + frontier, batch_rows, now_micros, ) @@ -341,7 +345,7 @@ async fn rearm_projection_retry_batch( pub(super) async fn read_rearm_projection_retry_candidates( conn: &impl QueryExecutor, after_rowid: i64, - last_rowid: i64, + frontier: RetryRearmFrontier, batch_rows: i64, now_micros: i64, ) -> ProjectionStoreResult> { @@ -356,12 +360,18 @@ pub(super) async fn read_rearm_projection_retry_candidates( FROM ( SELECT rowid, observation_id, next_retry_at_micros, attempt_count FROM projection_queue NOT INDEXED - WHERE rowid > ?1 AND rowid <= ?2 + WHERE rowid > ?1 AND rowid <= ?2 AND observation_sequence <= ?5 AND next_retry_at_micros > ?4 AND next_retry_at_micros < 9223372036854775807 ORDER BY rowid LIMIT ?3 )", - params![after_rowid, last_rowid, batch_rows, now_micros], + params![ + after_rowid, + frontier.last_rowid, + batch_rows, + now_micros, + frontier.last_sequence + ], ) .await .map_err(|error| storage(OPERATION, error))?; @@ -425,17 +435,19 @@ pub(super) async fn apply_rearm_projection_retry_candidates( Ok(rows) } -async fn last_deferred_retry_rowid( +pub(super) async fn retry_rearm_frontier( conn: &impl QueryExecutor, now_micros: i64, -) -> ProjectionStoreResult> { +) -> ProjectionStoreResult> { const OPERATION: &str = "read projection retry frontier"; - // The ceiling excludes terminal collisions. The clock excludes already-due rows. - // The deadline index also proves an empty range without walking the whole table. + // The deadline index covers the physical scan bound. SQLite can reuse its + // tail rowid after settlement, so also capture the highest canonical queue + // sequence from its unique index in the same snapshot. let mut rows = conn .query( // qpsg-exempt: the deadline index uses both the clock and the literal terminal ceiling. - "SELECT MAX(rowid) FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline + "SELECT MAX(rowid), (SELECT MAX(observation_sequence) FROM projection_queue) + FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807", params![now_micros], ) @@ -448,8 +460,16 @@ async fn last_deferred_retry_rowid( else { return Ok(None); }; - row.get::>(0) - .map_err(|error| storage(OPERATION, error)) + let Some(last_rowid) = row + .get::>(0) + .map_err(|error| storage(OPERATION, error))? + else { + return Ok(None); + }; + Ok(Some(RetryRearmFrontier { + last_rowid, + last_sequence: row.get(1).map_err(|error| storage(OPERATION, error))?, + })) } pub(super) async fn schedule_projection_retry( From c19037ae5f470ca3c1df1ec98c670dfa2019c30a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:15:01 -0800 Subject: [PATCH 31/64] style(sessions): format retry recovery restart assertion --- .../src/observation_projection/rearm_tests/restart.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs index dad7504a6bc..da3972e0a2a 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs @@ -47,12 +47,7 @@ async fn retry_rearm_resumes_committed_windows_after_failure_and_restart() { ); let interrupted = (1..=9_i64) - .map(|sequence| { - ( - sequence, - if sequence <= 4 { 0 } else { FAR_FUTURE_MICROS }, - ) - }) + .map(|sequence| (sequence, if sequence <= 4 { 0 } else { FAR_FUTURE_MICROS })) .collect::>(); assert_eq!(retry_deadlines(&harness).await, interrupted); assert_eq!(queue_state(&harness).await, (9, 5, 9)); @@ -101,7 +96,9 @@ async fn retry_rearm_resumes_committed_windows_after_failure_and_restart() { assert_eq!(queue_state(&harness).await, (9, 0, 9)); assert_eq!( retry_deadlines(&harness).await, - (1..=9_i64).map(|sequence| (sequence, 0)).collect::>() + (1..=9_i64) + .map(|sequence| (sequence, 0)) + .collect::>() ); for _ in 0..2 { assert_eq!( From 46a16e627d47100c8767d6da094ee571d5806ec7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:45:39 -0800 Subject: [PATCH 32/64] fix(sessions): freeze retry eligibility across rearm windows Refs #4756 Use one stable background read snapshot for bounded candidate pages. Without it, a foreground retry scheduled between pages could become eligible for recovery and have its current mount backoff erased. Retain conditional writer-side checks for modified and replaced rows. Add a registered-store regression for concurrent fresh backoff and adapt the queue-rowid reuse regression to the stable snapshot. No thresholds, timeouts, or CI policy are changed. --- .../src/observation_projection/rearm_tests.rs | 84 +++++++++++++++++-- .../src/observation_projection/state.rs | 25 ++++-- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 99acb8cb7f3..3e86b9c4bf3 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -580,6 +580,77 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { assert!(rows.next().await.unwrap().is_none()); } +#[asupersync::test] +async fn retry_rearm_does_not_erase_a_live_retry_between_windows() { + let harness = RegisteredGlobalDbHarness::open("rearm-live-between-windows").await; + seed_queue(&harness, 2, 1).await; + let database = harness.registered.runtime_database(); + let now = 1_000_000_i64; + let read = database + .read_connection() + .convergence_snapshot() + .await + .unwrap(); + let frontier = retry_rearm_frontier(&read, now).await.unwrap().unwrap(); + + // The first old retry is rearmed, leaving a previously due row between + // it and the next old retry in canonical queue order. + assert_eq!( + rearm_projection_retry_batch(database, &read, 0, frontier, 1, now) + .await + .unwrap(), + Some((1, 1)) + ); + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue + SET attempt_count = attempt_count + 1, + next_retry_at_micros = ?1, last_error = 'fresh live retry' + WHERE observation_sequence = 2", + params![FAR_FUTURE_MICROS], + ) + .await + .unwrap(), + 1 + ); + transaction.commit().await.unwrap(); + + // A fresh per-page SELECT would see the new deadline at sequence 2 and + // illegally clear the current mount's backoff. The original snapshot must + // skip it, select the old sequence 3 and update only that row. + assert_eq!( + rearm_projection_retry_batch(database, &read, 1, frontier, 1, now) + .await + .unwrap(), + Some((3, 1)) + ); + drop(read); + + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT observation_sequence, attempt_count, next_retry_at_micros, last_error + FROM projection_queue ORDER BY observation_sequence", + (), + ) + .await + .unwrap(); + for (sequence, attempts, deadline, error) in [ + (1_i64, 3_i64, 0_i64, "recorded failure"), + (2, 4, FAR_FUTURE_MICROS, "fresh live retry"), + (3, 3, 0, "recorded failure"), + ] { + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), sequence); + assert_eq!(row.get::(1).unwrap(), attempts); + assert_eq!(row.get::(2).unwrap(), deadline); + assert_eq!(row.get::(3).unwrap(), error); + } + assert!(rows.next().await.unwrap().is_none()); +} + #[asupersync::test] async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { let harness = RegisteredGlobalDbHarness::open("rearm-reused-queue-tail").await; @@ -595,12 +666,14 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .await .unwrap(); transaction.commit().await.unwrap(); - let frontier = retry_rearm_frontier(&database.read_connection(), now) + let read = database + .read_connection() + .convergence_snapshot() .await - .unwrap() .unwrap(); + let frontier = retry_rearm_frontier(&read, now).await.unwrap().unwrap(); assert_eq!( - rearm_projection_retry_batch(database, 0, frontier, 1, now) + rearm_projection_retry_batch(database, &read, 0, frontier, 1, now) .await .unwrap(), Some((1, 1)) @@ -628,12 +701,13 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .unwrap(); transaction.commit().await.unwrap(); assert_eq!( - rearm_projection_retry_batch(database, 1, frontier, 4, now) + rearm_projection_retry_batch(database, &read, 1, frontier, 4, now) .await .unwrap(), - Some((2, 1)), + Some((3, 1)), "only the original retry is inside the captured frontier" ); + drop(read); let snapshot = harness.registered.read_snapshot().await.unwrap(); let mut rows = snapshot .query( diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 36f349fb215..2b1b833c77a 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -269,10 +269,11 @@ pub(super) struct RetryRearmFrontier { /// The reader captures the queue frontier before taking any writer /// lease. Already-due, already-armed, and terminal-only queues need no writer /// transaction; expired deadlines remain eligible for ordinary projection. -/// Each read window selects at most `batch_rows` eligible rows outside -/// the writer lease. A short writer transaction conditionally updates their -/// exact observed identities, so sparse inactive prefixes never hold the -/// writer. Rows beyond the captured frontier are left for later recovery. +/// A stable read snapshot selects at most `batch_rows` candidates per window +/// without retaining a SQLite writer. It excludes retries created after this +/// mount started recovery, even if their queue rowids predate its frontier. +/// Short writer transactions conditionally update only unchanged identities. +/// Rows beyond the captured frontier are left for later recovery. #[tracing::instrument( name = "global_db.observation_projection.rearm_retries", level = "trace", @@ -290,13 +291,22 @@ pub(crate) async fn rearm_queued_projection_retries( } let mut rearmed = RearmedProjectionRetries::default(); let now = tracedecay_contracts::clock::now_micros().0; - let Some(frontier) = retry_rearm_frontier(&database.read_connection(), now).await? else { + // Freeze the previous mount's retry eligibility before any re-arm write. + // A live reader on each page would accidentally re-arm a *new* retry that + // foreground projection schedules between recovery windows. + let snapshot = database + .read_connection() + .convergence_snapshot() + .await + .map_err(|error| storage("open projection retry recovery snapshot", error))?; + let Some(frontier) = retry_rearm_frontier(&snapshot, now).await? else { return Ok(rearmed); }; let mut after_rowid = 0_i64; while after_rowid < frontier.last_rowid { let Some((batch_end, rows)) = - rearm_projection_retry_batch(database, after_rowid, frontier, batch_rows, now).await? + rearm_projection_retry_batch(database, &snapshot, after_rowid, frontier, batch_rows, now) + .await? else { break; }; @@ -319,13 +329,14 @@ pub(crate) async fn rearm_queued_projection_retries( )] pub(super) async fn rearm_projection_retry_batch( database: &Database, + snapshot: &impl QueryExecutor, after_rowid: i64, frontier: RetryRearmFrontier, batch_rows: i64, now_micros: i64, ) -> ProjectionStoreResult> { let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( - &database.read_connection(), + snapshot, after_rowid, frontier, batch_rows, From 7dd565d441543f86911722941ab1560c7fc5d989 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:22:18 -0800 Subject: [PATCH 33/64] simplify(sessions): use the frozen retry snapshot as the scan bound --- .../src/observation_projection/rearm_tests.rs | 24 ++---- .../src/observation_projection/state.rs | 76 ++++++++----------- 2 files changed, 37 insertions(+), 63 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 3e86b9c4bf3..b5aec03f810 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -503,23 +503,13 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { let harness = RegisteredGlobalDbHarness::open("rearm-stale-candidates").await; seed_queue(&harness, 3, 0).await; let now = 1_000_000_i64; - let frontier = retry_rearm_frontier( - &harness.registered.runtime_database().read_connection(), - now, - ) - .await - .unwrap() - .unwrap(); - let (last, candidates) = read_rearm_projection_retry_candidates( - &harness.registered.runtime_database().read_connection(), - 0, - frontier, - 3, - now, - ) - .await - .unwrap() - .unwrap(); + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let frontier = retry_rearm_frontier(&snapshot, now).await.unwrap().unwrap(); + let (last, candidates) = read_rearm_projection_retry_candidates(&snapshot, 0, frontier, 3, now) + .await + .unwrap() + .unwrap(); + drop(snapshot); assert_eq!(last, 3); // The reader's window is now stale: a foreground owner schedules a new diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 2b1b833c77a..10150b7538d 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -15,8 +15,8 @@ use tracedecay_store::{ use tracedecay_lcm::raw::{message_body_from_record_row, stored_message_record_select_columns}; use tracedecay_lcm::retrieval_content::projected_content_hash; use tracedecay_lcm::{LcmError, LcmStorageKind}; -use tracedecay_runtime_core::db::Database; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; +use tracedecay_runtime_core::db::{Database, DatabaseEngineReadSnapshot}; use tracedecay_sessions::runtime::shared::durable_project_path_key; #[cfg(windows)] use tracedecay_sessions::runtime::shared::path_identity_key; @@ -255,12 +255,6 @@ pub(crate) struct RearmedProjectionRetries { pub(crate) batches: u64, } -#[derive(Clone, Copy, Debug)] -pub(super) struct RetryRearmFrontier { - last_rowid: i64, - last_sequence: i64, -} - /// A projection retry deadline paces re-attempts within the store mount that /// observed the failure. A fresh mount re-arms retryable deferred projections /// for immediate replay so recovery does not wait out a dead process's backoff. @@ -299,14 +293,20 @@ pub(crate) async fn rearm_queued_projection_retries( .convergence_snapshot() .await .map_err(|error| storage("open projection retry recovery snapshot", error))?; - let Some(frontier) = retry_rearm_frontier(&snapshot, now).await? else { + let Some(last_rowid) = retry_rearm_frontier(&snapshot, now).await? else { return Ok(rearmed); }; let mut after_rowid = 0_i64; - while after_rowid < frontier.last_rowid { - let Some((batch_end, rows)) = - rearm_projection_retry_batch(database, &snapshot, after_rowid, frontier, batch_rows, now) - .await? + while after_rowid < last_rowid { + let Some((batch_end, rows)) = rearm_projection_retry_batch( + database, + &snapshot, + after_rowid, + last_rowid, + batch_rows, + now, + ) + .await? else { break; }; @@ -329,16 +329,16 @@ pub(crate) async fn rearm_queued_projection_retries( )] pub(super) async fn rearm_projection_retry_batch( database: &Database, - snapshot: &impl QueryExecutor, + snapshot: &DatabaseEngineReadSnapshot, after_rowid: i64, - frontier: RetryRearmFrontier, + last_rowid: i64, batch_rows: i64, now_micros: i64, ) -> ProjectionStoreResult> { let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( snapshot, after_rowid, - frontier, + last_rowid, batch_rows, now_micros, ) @@ -350,18 +350,18 @@ pub(super) async fn rearm_projection_retry_batch( Ok(Some((batch_end, rows))) } -/// Candidate reads are repeatable within this statement, but not guaranteed -/// current when a later writer lease is acquired. Retain observation identity, -/// retry deadline and attempt generation for a conditional write. +/// Candidate membership is fixed by the recovery snapshot. The foreground +/// writer may already have replaced a selected retry by the time we apply it, +/// so carry the original observation identity, deadline and attempt count. pub(super) async fn read_rearm_projection_retry_candidates( - conn: &impl QueryExecutor, + snapshot: &DatabaseEngineReadSnapshot, after_rowid: i64, - frontier: RetryRearmFrontier, + last_rowid: i64, batch_rows: i64, now_micros: i64, ) -> ProjectionStoreResult> { const OPERATION: &str = "read projection retry rearm window"; - let mut candidates = conn + let mut candidates = snapshot .query( // qpsg-exempt: the literal terminal ceiling and independent clock // predicate select a bounded rowid-ordered window on the reader. @@ -371,18 +371,12 @@ pub(super) async fn read_rearm_projection_retry_candidates( FROM ( SELECT rowid, observation_id, next_retry_at_micros, attempt_count FROM projection_queue NOT INDEXED - WHERE rowid > ?1 AND rowid <= ?2 AND observation_sequence <= ?5 + WHERE rowid > ?1 AND rowid <= ?2 AND next_retry_at_micros > ?4 AND next_retry_at_micros < 9223372036854775807 ORDER BY rowid LIMIT ?3 )", - params![ - after_rowid, - frontier.last_rowid, - batch_rows, - now_micros, - frontier.last_sequence - ], + params![after_rowid, last_rowid, batch_rows, now_micros], ) .await .map_err(|error| storage(OPERATION, error))?; @@ -447,17 +441,15 @@ pub(super) async fn apply_rearm_projection_retry_candidates( } pub(super) async fn retry_rearm_frontier( - conn: &impl QueryExecutor, + snapshot: &DatabaseEngineReadSnapshot, now_micros: i64, -) -> ProjectionStoreResult> { +) -> ProjectionStoreResult> { const OPERATION: &str = "read projection retry frontier"; - // The deadline index covers the physical scan bound. SQLite can reuse its - // tail rowid after settlement, so also capture the highest canonical queue - // sequence from its unique index in the same snapshot. - let mut rows = conn + // The deadline index bounds how far the snapshot reader needs to scan. + let mut rows = snapshot .query( // qpsg-exempt: the deadline index uses both the clock and the literal terminal ceiling. - "SELECT MAX(rowid), (SELECT MAX(observation_sequence) FROM projection_queue) + "SELECT MAX(rowid) FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807", params![now_micros], @@ -471,16 +463,8 @@ pub(super) async fn retry_rearm_frontier( else { return Ok(None); }; - let Some(last_rowid) = row - .get::>(0) - .map_err(|error| storage(OPERATION, error))? - else { - return Ok(None); - }; - Ok(Some(RetryRearmFrontier { - last_rowid, - last_sequence: row.get(1).map_err(|error| storage(OPERATION, error))?, - })) + row.get::>(0) + .map_err(|error| storage(OPERATION, error)) } pub(super) async fn schedule_projection_retry( From ebe773971d95ed889997a7b8ae05b0dfa7b4da26 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:25:13 -0800 Subject: [PATCH 34/64] fix(sessions): release retry snapshot before batched writes Refs #4756 Stage bounded, generation-frozen retry candidates read-only before acquiring SQLite writer leases. This prevents recovery itself pinning the WAL across its write batches, while guarded row updates preserve concurrent retries. Add real registered-store regressions for fresh retries, reused rowids, staging-memory refusal, and an independently checkpointed WAL after writes. Keep issue open pending exact-head Bazel and incident-scale acceptance. --- .../src/observation_projection/rearm_tests.rs | 104 +++++++++++---- .../src/observation_projection/state.rs | 122 ++++++++++-------- 2 files changed, 143 insertions(+), 83 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index b5aec03f810..30e02709010 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -12,7 +12,7 @@ use tracedecay_runtime_core::db::engine::params; use super::state::{ apply_rearm_projection_retry_candidates, read_rearm_projection_retry_candidates, - rearm_projection_retry_batch, retry_rearm_frontier, + retry_rearm_frontier, stage_rearm_projection_retry_candidates, }; use super::{RearmedProjectionRetries, rearm_queued_projection_retries}; use crate::tests::harness::{RegisteredGlobalDbHarness, writer_telemetry}; @@ -576,20 +576,18 @@ async fn retry_rearm_does_not_erase_a_live_retry_between_windows() { seed_queue(&harness, 2, 1).await; let database = harness.registered.runtime_database(); let now = 1_000_000_i64; - let read = database - .read_connection() - .convergence_snapshot() + let staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) .await .unwrap(); - let frontier = retry_rearm_frontier(&read, now).await.unwrap().unwrap(); + assert_eq!(staged.len(), 2); - // The first old retry is rearmed, leaving a previously due row between - // it and the next old retry in canonical queue order. + // The snapshot is released before any writer lease. The original two + // deferred rows are frozen independently of later foreground retries. assert_eq!( - rearm_projection_retry_batch(database, &read, 0, frontier, 1, now) + apply_rearm_projection_retry_candidates(database, &staged[0], now) .await .unwrap(), - Some((1, 1)) + 1 ); let transaction = harness.registered.begin_write_transaction().await.unwrap(); assert_eq!( @@ -608,15 +606,14 @@ async fn retry_rearm_does_not_erase_a_live_retry_between_windows() { transaction.commit().await.unwrap(); // A fresh per-page SELECT would see the new deadline at sequence 2 and - // illegally clear the current mount's backoff. The original snapshot must - // skip it, select the old sequence 3 and update only that row. + // illegally clear this mount's backoff. The staged eligibility excludes + // the new retry even after dropping the WAL reader. assert_eq!( - rearm_projection_retry_batch(database, &read, 1, frontier, 1, now) + apply_rearm_projection_retry_candidates(database, &staged[1], now) .await .unwrap(), - Some((3, 1)) + 1 ); - drop(read); let snapshot = harness.registered.read_snapshot().await.unwrap(); let mut rows = snapshot @@ -656,17 +653,15 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .await .unwrap(); transaction.commit().await.unwrap(); - let read = database - .read_connection() - .convergence_snapshot() + let staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) .await .unwrap(); - let frontier = retry_rearm_frontier(&read, now).await.unwrap().unwrap(); + assert_eq!(staged.len(), 3); assert_eq!( - rearm_projection_retry_batch(database, &read, 0, frontier, 1, now) + apply_rearm_projection_retry_candidates(database, &staged[0], now) .await .unwrap(), - Some((1, 1)) + 1 ); // A foreground pass settles the old tail. SQLite assigns its rowid to a @@ -690,14 +685,16 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .await .unwrap(); transaction.commit().await.unwrap(); - assert_eq!( - rearm_projection_retry_batch(database, &read, 1, frontier, 4, now) + let mut remaining = 0; + for candidates in staged.iter().skip(1) { + remaining += apply_rearm_projection_retry_candidates(database, candidates, now) .await - .unwrap(), - Some((3, 1)), - "only the original retry is inside the captured frontier" + .unwrap(); + } + assert_eq!( + remaining, 1, + "only the unchanged original retry survives rowid reuse" ); - drop(read); let snapshot = harness.registered.read_snapshot().await.unwrap(); let mut rows = snapshot .query( @@ -712,6 +709,61 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { assert_eq!(new.get::(1).unwrap(), FAR_FUTURE_MICROS); } +#[asupersync::test] +async fn staged_retry_recovery_releases_wal_reader_before_writes() { + let harness = RegisteredGlobalDbHarness::open("rearm-release-wal-reader").await; + seed_queue(&harness, 9, 0).await; + let database = harness.registered.runtime_database(); + let staged = stage_rearm_projection_retry_candidates(database, 2, 1_000_000, usize::MAX) + .await + .unwrap(); + assert_eq!(staged.len(), 5); + let mut changed = 0; + for candidates in staged { + changed += apply_rearm_projection_retry_candidates(database, &candidates, 1_000_000) + .await + .unwrap(); + } + assert_eq!(changed, 9); + + // The checkpoint is run through a separate SQLite connection solely on + // this disposable registered-store fixture. A snapshot kept across the + // writer batches would leave a pinned WAL read slot and report busy. + let outside = rusqlite::Connection::open(harness.registered.db_path()).unwrap(); + let (busy, log_frames, checkpointed): (i64, i64, i64) = outside + .query_row("PRAGMA wal_checkpoint(RESTART)", [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + }) + .unwrap(); + assert_eq!(busy, 0, "no rearm snapshot may pin the WAL after staging"); + assert!(log_frames > 0, "the checkpoint must observe actual writer traffic"); + assert_eq!(checkpointed, log_frames); + assert_eq!(queue_state(&harness).await, (9, 0, 9)); +} + +#[asupersync::test] +async fn retry_candidate_stage_refuses_capacity_before_any_writer_lease() { + let harness = RegisteredGlobalDbHarness::open("rearm-stage-memory-bound").await; + seed_queue(&harness, 9, 0).await; + let database = harness.registered.runtime_database(); + let before = writer_telemetry(&harness.registered).transactions; + assert!( + stage_rearm_projection_retry_candidates(database, 2, 1_000_000, 8) + .await + .is_err(), + "an oversized eligibility stage must refuse before modifying retry state" + ); + assert_eq!(writer_telemetry(&harness.registered).transactions, before); + assert_eq!(queue_state(&harness).await, (9, 9, 9)); + + assert_eq!( + rearm_queued_projection_retries(database, 2).await.unwrap(), + RearmedProjectionRetries { rows: 9, batches: 5 }, + "recovery with admitted capacity must remain possible after refusal" + ); + assert_eq!(queue_state(&harness).await, (9, 0, 9)); +} + #[asupersync::test] async fn daemon_admission_defers_rearm_to_background_convergence() { let harness = RegisteredGlobalDbHarness::open("rearm-daemon-admission").await; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 10150b7538d..137b03950b5 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -249,6 +249,10 @@ pub(super) async fn projection_retry_state( /// Queue rows one re-arm transaction touches. pub(crate) const REARM_PROJECTION_RETRY_BATCH_ROWS: i64 = 1_024; +/// Maximum candidate-identity memory staged before rearm starts writing. +/// The bound avoids trading WAL retention for an unbounded heap allocation. +const REARM_CANDIDATE_STAGE_BYTES: usize = 64 * 1024 * 1024; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct RearmedProjectionRetries { pub(crate) rows: u64, @@ -256,18 +260,14 @@ pub(crate) struct RearmedProjectionRetries { } /// A projection retry deadline paces re-attempts within the store mount that -/// observed the failure. A fresh mount re-arms retryable deferred projections -/// for immediate replay so recovery does not wait out a dead process's backoff. -/// Attempt counts, last errors, and terminal collision deadlines persist. +/// observed the failure. A fresh mount re-arms only previously deferred +/// observations. Fresh foreground retries must keep their own deadline. /// -/// The reader captures the queue frontier before taking any writer -/// lease. Already-due, already-armed, and terminal-only queues need no writer -/// transaction; expired deadlines remain eligible for ordinary projection. -/// A stable read snapshot selects at most `batch_rows` candidates per window -/// without retaining a SQLite writer. It excludes retries created after this -/// mount started recovery, even if their queue rowids predate its frontier. -/// Short writer transactions conditionally update only unchanged identities. -/// Rows beyond the captured frontier are left for later recovery. +/// Freeze the exact candidates in one consistent read view *before* taking +/// a writer lease. Keeping a WAL snapshot pinned across write windows would +/// prevent restart checkpoints and risk revocation during large recoveries. +/// Each subsequent short writer transaction conditionally updates unchanged +/// observation identities; an interrupted recovery keeps its committed prefix. #[tracing::instrument( name = "global_db.observation_projection.rearm_retries", level = "trace", @@ -277,77 +277,85 @@ pub(crate) async fn rearm_queued_projection_retries( database: &Database, batch_rows: i64, ) -> ProjectionStoreResult { - if batch_rows <= 0 { + if batch_rows <= 0 || batch_rows > REARM_PROJECTION_RETRY_BATCH_ROWS { return Err(storage_message( "rearm queued projection retries", - "projection retry batch size must be positive", + "projection retry batch size is outside the bounded range", )); } - let mut rearmed = RearmedProjectionRetries::default(); let now = tracedecay_contracts::clock::now_micros().0; - // Freeze the previous mount's retry eligibility before any re-arm write. - // A live reader on each page would accidentally re-arm a *new* retry that - // foreground projection schedules between recovery windows. + let windows = stage_rearm_projection_retry_candidates( + database, + batch_rows, + now, + REARM_CANDIDATE_STAGE_BYTES, + ) + .await?; + let mut rearmed = RearmedProjectionRetries::default(); + for candidates in windows { + let rows = apply_rearm_projection_retry_candidates(database, &candidates, now).await?; + rearmed.rows += rows; + if rows != 0 { + rearmed.batches += 1; + } + } + Ok(rearmed) +} + +/// The snapshot pins the WAL only during read-only candidate discovery and +/// drops before any recovery writes. Scan the old queue in bounded pages +/// without staging the inactive prefix or any observation payload. +/// Refuse over-capacity staging before changing any retry deadline. +#[tracing::instrument( + name = "global_db.observation_projection.stage_retry_rearm", + level = "trace", + skip_all +)] +pub(super) async fn stage_rearm_projection_retry_candidates( + database: &Database, + batch_rows: i64, + now_micros: i64, + max_staged_bytes: usize, +) -> ProjectionStoreResult> { + const OPERATION: &str = "stage queued projection retries"; let snapshot = database .read_connection() .convergence_snapshot() .await .map_err(|error| storage("open projection retry recovery snapshot", error))?; - let Some(last_rowid) = retry_rearm_frontier(&snapshot, now).await? else { - return Ok(rearmed); + let Some(last_rowid) = retry_rearm_frontier(&snapshot, now_micros).await? else { + return Ok(Vec::new()); }; + let mut staged = Vec::new(); + let mut staged_bytes = 0_usize; let mut after_rowid = 0_i64; while after_rowid < last_rowid { - let Some((batch_end, rows)) = rearm_projection_retry_batch( - database, + let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( &snapshot, after_rowid, last_rowid, batch_rows, - now, + now_micros, ) .await? else { break; }; - rearmed.rows += rows; - if rows != 0 { - rearmed.batches += 1; + staged_bytes = staged_bytes + .checked_add(candidates.len()) + .and_then(|bytes| bytes.checked_add(std::mem::size_of::())) + .ok_or_else(|| storage_message(OPERATION, "retry candidate staging size overflow"))?; + if staged_bytes > max_staged_bytes { + return Err(storage_message( + OPERATION, + "retry candidate staging exceeds the bounded memory allowance", + )); } + staged.push(candidates); after_rowid = batch_end; } - Ok(rearmed) -} - -/// Select a bounded retry window outside the SQLite writer lease. The -/// sparse tail scan may visit many already-due or terminal rows; only the -/// exact selected identities are looked up while holding the writer. -#[tracing::instrument( - name = "global_db.observation_projection.rearm_retries.batch", - level = "trace", - skip_all -)] -pub(super) async fn rearm_projection_retry_batch( - database: &Database, - snapshot: &DatabaseEngineReadSnapshot, - after_rowid: i64, - last_rowid: i64, - batch_rows: i64, - now_micros: i64, -) -> ProjectionStoreResult> { - let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( - snapshot, - after_rowid, - last_rowid, - batch_rows, - now_micros, - ) - .await? - else { - return Ok(None); - }; - let rows = apply_rearm_projection_retry_candidates(database, &candidates, now_micros).await?; - Ok(Some((batch_end, rows))) + drop(snapshot); + Ok(staged) } /// Candidate membership is fixed by the recovery snapshot. The foreground From f0b5e5cade6dff8cd037886a07961dbd5ab9c425 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:49:04 -0800 Subject: [PATCH 35/64] fix(sessions): rearm retry backlogs beyond staging memory --- crates/tracedecay-global-db/Cargo.toml | 8 +- .../src/observation_projection/rearm_tests.rs | 107 +++++++++++------- .../src/observation_projection/state.rs | 101 +++++++++++++---- 3 files changed, 150 insertions(+), 66 deletions(-) diff --git a/crates/tracedecay-global-db/Cargo.toml b/crates/tracedecay-global-db/Cargo.toml index b268d61c4c7..ea9419ce893 100644 --- a/crates/tracedecay-global-db/Cargo.toml +++ b/crates/tracedecay-global-db/Cargo.toml @@ -14,7 +14,6 @@ test-helpers = [ "tracedecay-runtime-core/test-helpers", "tracedecay-session-temporal-store/test-helpers", "dep:rusqlite", - "dep:tempfile", ] [dependencies] @@ -44,16 +43,11 @@ tracedecay-private-fs = { path = "../tracedecay-private-fs" } tracedecay-sessions = { path = "../tracedecay-sessions" } tracedecay-store = { path = "../tracedecay-store" } tracedecay-temporal-query = { path = "../tracedecay-temporal-query" } -# Test-only harnesses in this crate build temporary stores. Optional and -# activated by `test-helpers` (the only non-`cfg(test)` consumer is the -# re-exported `tests::` harness); the crate's own `cfg(test)` builds resolve -# it from dev-dependencies, so production builds never link it. -tempfile = { version = "3", optional = true } +tempfile.workspace = true [dev-dependencies] criterion = { workspace = true, features = ["async"] } rusqlite.workspace = true -tempfile.workspace = true # Test-only helper surfaces this crate's test targets reach across crate # boundaries: engine fixtures, session normalizers, and temporal page requests. tracedecay-graph-db = { path = "../tracedecay-graph-db", features = ["test-helpers"] } diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 30e02709010..e19976d6652 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -576,17 +576,20 @@ async fn retry_rearm_does_not_erase_a_live_retry_between_windows() { seed_queue(&harness, 2, 1).await; let database = harness.registered.runtime_database(); let now = 1_000_000_i64; - let staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) + let mut staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) .await .unwrap(); - assert_eq!(staged.len(), 2); // The snapshot is released before any writer lease. The original two // deferred rows are frozen independently of later foreground retries. assert_eq!( - apply_rearm_projection_retry_candidates(database, &staged[0], now) - .await - .unwrap(), + apply_rearm_projection_retry_candidates( + database, + &staged.next().await.unwrap().expect("first frozen window"), + now + ) + .await + .unwrap(), 1 ); let transaction = harness.registered.begin_write_transaction().await.unwrap(); @@ -609,12 +612,17 @@ async fn retry_rearm_does_not_erase_a_live_retry_between_windows() { // illegally clear this mount's backoff. The staged eligibility excludes // the new retry even after dropping the WAL reader. assert_eq!( - apply_rearm_projection_retry_candidates(database, &staged[1], now) - .await - .unwrap(), + apply_rearm_projection_retry_candidates( + database, + &staged.next().await.unwrap().expect("second frozen window"), + now + ) + .await + .unwrap(), 1 ); + assert!(staged.next().await.unwrap().is_none()); let snapshot = harness.registered.read_snapshot().await.unwrap(); let mut rows = snapshot .query( @@ -653,14 +661,17 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .await .unwrap(); transaction.commit().await.unwrap(); - let staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) + let mut staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) .await .unwrap(); - assert_eq!(staged.len(), 3); assert_eq!( - apply_rearm_projection_retry_candidates(database, &staged[0], now) - .await - .unwrap(), + apply_rearm_projection_retry_candidates( + database, + &staged.next().await.unwrap().expect("first frozen window"), + now + ) + .await + .unwrap(), 1 ); @@ -686,8 +697,8 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .unwrap(); transaction.commit().await.unwrap(); let mut remaining = 0; - for candidates in staged.iter().skip(1) { - remaining += apply_rearm_projection_retry_candidates(database, candidates, now) + while let Some(candidates) = staged.next().await.unwrap() { + remaining += apply_rearm_projection_retry_candidates(database, &candidates, now) .await .unwrap(); } @@ -714,21 +725,23 @@ async fn staged_retry_recovery_releases_wal_reader_before_writes() { let harness = RegisteredGlobalDbHarness::open("rearm-release-wal-reader").await; seed_queue(&harness, 9, 0).await; let database = harness.registered.runtime_database(); - let staged = stage_rearm_projection_retry_candidates(database, 2, 1_000_000, usize::MAX) + let mut staged = stage_rearm_projection_retry_candidates(database, 2, 1_000_000, usize::MAX) .await .unwrap(); - assert_eq!(staged.len(), 5); - let mut changed = 0; - for candidates in staged { - changed += apply_rearm_projection_retry_candidates(database, &candidates, 1_000_000) + let first = staged + .next() + .await + .unwrap() + .expect("first frozen retry window"); + assert_eq!( + apply_rearm_projection_retry_candidates(database, &first, 1_000_000) .await - .unwrap(); - } - assert_eq!(changed, 9); + .unwrap(), + 2 + ); - // The checkpoint is run through a separate SQLite connection solely on - // this disposable registered-store fixture. A snapshot kept across the - // writer batches would leave a pinned WAL read slot and report busy. + // Checkpoint while later pages still await their writes. A reader retained + // until the end of the pass would keep this WAL read slot pinned. let outside = rusqlite::Connection::open(harness.registered.db_path()).unwrap(); let (busy, log_frames, checkpointed): (i64, i64, i64) = outside .query_row("PRAGMA wal_checkpoint(RESTART)", [], |row| { @@ -736,31 +749,45 @@ async fn staged_retry_recovery_releases_wal_reader_before_writes() { }) .unwrap(); assert_eq!(busy, 0, "no rearm snapshot may pin the WAL after staging"); - assert!(log_frames > 0, "the checkpoint must observe actual writer traffic"); + assert!( + log_frames > 0, + "the checkpoint must observe actual writer traffic" + ); assert_eq!(checkpointed, log_frames); + let mut remaining = Vec::new(); + while let Some(candidates) = staged.next().await.unwrap() { + remaining.push( + apply_rearm_projection_retry_candidates(database, &candidates, 1_000_000) + .await + .unwrap(), + ); + } + assert_eq!(remaining, [2, 2, 2, 1]); assert_eq!(queue_state(&harness).await, (9, 0, 9)); } #[asupersync::test] -async fn retry_candidate_stage_refuses_capacity_before_any_writer_lease() { +async fn retry_candidate_overflow_rearms_every_window_without_a_writer_during_discovery() { let harness = RegisteredGlobalDbHarness::open("rearm-stage-memory-bound").await; seed_queue(&harness, 9, 0).await; let database = harness.registered.runtime_database(); let before = writer_telemetry(&harness.registered).transactions; - assert!( - stage_rearm_projection_retry_candidates(database, 2, 1_000_000, 8) - .await - .is_err(), - "an oversized eligibility stage must refuse before modifying retry state" - ); + // Type and size of the canonical observation IDs make an eight-byte + // allowance too small even for the shortest window. + let mut staged = stage_rearm_projection_retry_candidates(database, 2, 1_000_000, 8) + .await + .unwrap(); assert_eq!(writer_telemetry(&harness.registered).transactions, before); assert_eq!(queue_state(&harness).await, (9, 9, 9)); - - assert_eq!( - rearm_queued_projection_retries(database, 2).await.unwrap(), - RearmedProjectionRetries { rows: 9, batches: 5 }, - "recovery with admitted capacity must remain possible after refusal" - ); + let mut rows = Vec::new(); + while let Some(window) = staged.next().await.unwrap() { + rows.push( + apply_rearm_projection_retry_candidates(database, &window, 1_000_000) + .await + .unwrap(), + ); + } + assert_eq!(rows, [2, 2, 2, 2, 1]); assert_eq!(queue_state(&harness).await, (9, 0, 9)); } diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 137b03950b5..efc8647077b 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -1,5 +1,9 @@ use std::borrow::Cow; use std::collections::{BTreeSet, HashMap}; + +use asupersync::fs::{BufReader, File, Lines}; +use asupersync::io::AsyncWriteExt; +use asupersync::stream::StreamExt; #[cfg(windows)] use std::path::{Component, Path, Prefix}; @@ -249,8 +253,8 @@ pub(super) async fn projection_retry_state( /// Queue rows one re-arm transaction touches. pub(crate) const REARM_PROJECTION_RETRY_BATCH_ROWS: i64 = 1_024; -/// Maximum candidate-identity memory staged before rearm starts writing. -/// The bound avoids trading WAL retention for an unbounded heap allocation. +/// Keep at most this many bytes of candidate windows in memory. Overflow +/// uses an anonymous file on the registered store's filesystem. const REARM_CANDIDATE_STAGE_BYTES: usize = 64 * 1024 * 1024; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -284,7 +288,7 @@ pub(crate) async fn rearm_queued_projection_retries( )); } let now = tracedecay_contracts::clock::now_micros().0; - let windows = stage_rearm_projection_retry_candidates( + let mut windows = stage_rearm_projection_retry_candidates( database, batch_rows, now, @@ -292,7 +296,7 @@ pub(crate) async fn rearm_queued_projection_retries( ) .await?; let mut rearmed = RearmedProjectionRetries::default(); - for candidates in windows { + while let Some(candidates) = windows.next().await? { let rows = apply_rearm_projection_retry_candidates(database, &candidates, now).await?; rearmed.rows += rows; if rows != 0 { @@ -302,10 +306,33 @@ pub(crate) async fn rearm_queued_projection_retries( Ok(rearmed) } +#[derive(Default)] +pub(super) struct StagedRetryWindows { + memory: std::vec::IntoIter, + overflow: Option>, +} + +impl StagedRetryWindows { + pub(super) async fn next(&mut self) -> ProjectionStoreResult> { + if let Some(page) = self.memory.next() { + return Ok(Some(page)); + } + let Some(lines) = self.overflow.as_mut() else { + return Ok(None); + }; + lines + .next() + .await + .transpose() + .map_err(|error| storage("read staged retry identities", error)) + } +} + /// The snapshot pins the WAL only during read-only candidate discovery and /// drops before any recovery writes. Scan the old queue in bounded pages /// without staging the inactive prefix or any observation payload. -/// Refuse over-capacity staging before changing any retry deadline. +/// Overflow holds only the frozen identity tuples and is discarded on drop, +/// including when discovery, checkpointing or a later writer fails. #[tracing::instrument( name = "global_db.observation_projection.stage_retry_rearm", level = "trace", @@ -316,7 +343,7 @@ pub(super) async fn stage_rearm_projection_retry_candidates( batch_rows: i64, now_micros: i64, max_staged_bytes: usize, -) -> ProjectionStoreResult> { +) -> ProjectionStoreResult { const OPERATION: &str = "stage queued projection retries"; let snapshot = database .read_connection() @@ -324,13 +351,14 @@ pub(super) async fn stage_rearm_projection_retry_candidates( .await .map_err(|error| storage("open projection retry recovery snapshot", error))?; let Some(last_rowid) = retry_rearm_frontier(&snapshot, now_micros).await? else { - return Ok(Vec::new()); + return Ok(StagedRetryWindows::default()); }; let mut staged = Vec::new(); + let mut overflow: Option = None; let mut staged_bytes = 0_usize; let mut after_rowid = 0_i64; while after_rowid < last_rowid { - let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( + let Some((batch_end, mut candidates)) = read_rearm_projection_retry_candidates( &snapshot, after_rowid, last_rowid, @@ -341,21 +369,56 @@ pub(super) async fn stage_rearm_projection_retry_candidates( else { break; }; - staged_bytes = staged_bytes - .checked_add(candidates.len()) - .and_then(|bytes| bytes.checked_add(std::mem::size_of::())) - .ok_or_else(|| storage_message(OPERATION, "retry candidate staging size overflow"))?; - if staged_bytes > max_staged_bytes { - return Err(storage_message( - OPERATION, - "retry candidate staging exceeds the bounded memory allowance", - )); + let page_bytes = candidates + .capacity() + .saturating_add(std::mem::size_of::()); + if overflow.is_none() && page_bytes <= max_staged_bytes - staged_bytes { + staged_bytes += page_bytes; + staged.push(candidates); + } else { + let file = match &mut overflow { + Some(file) => file, + slot @ None => { + let directory = database + .database_path() + .parent() + .ok_or_else(|| { + storage_message( + OPERATION, + "registered database has no containing directory", + ) + })? + .to_path_buf(); + let file = tracedecay_runtime_core::native_runtime::spawn_blocking(move || { + tempfile::tempfile_in(directory) + }) + .await + .map_err(|error| storage_message(OPERATION, error))? + .map_err(|error| storage(OPERATION, error))?; + slot.insert(File::from_std(file)) + } + }; + // json_group_array emits one compact JSON value, escaping embedded newlines. + candidates.push('\n'); + file.write_all(candidates.as_bytes()) + .await + .map_err(|error| storage(OPERATION, error))?; } - staged.push(candidates); after_rowid = batch_end; } drop(snapshot); - Ok(staged) + let overflow = if let Some(mut file) = overflow { + file.rewind() + .await + .map_err(|error| storage(OPERATION, error))?; + Some(BufReader::new(file).lines()) + } else { + None + }; + Ok(StagedRetryWindows { + memory: staged.into_iter(), + overflow, + }) } /// Candidate membership is fixed by the recovery snapshot. The foreground From a144ac660279d1f405d243139ff603ee339842a6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:30:50 -0800 Subject: [PATCH 36/64] fix(sessions): refuse unbounded retry staging disk spill Refs #4756 Keep frozen retry candidates under the existing 64 MiB memory ceiling. Refuse an oversized discovery before any writer transaction rather than spilling arbitrary data beside the live SQLite database and WAL. Remove the production tempfile dependency introduced for disk staging. A registered-store regression forces overflow after the first frozen page, checks that no partial writes occurred, then proves ordinary bounded recovery and idle restart succeed. Rust/Bazel and incident-scale verification remain outstanding. --- crates/tracedecay-global-db/Cargo.toml | 8 +- .../src/observation_projection/rearm_tests.rs | 52 ++++++++---- .../src/observation_projection/state.rs | 79 ++++--------------- 3 files changed, 62 insertions(+), 77 deletions(-) diff --git a/crates/tracedecay-global-db/Cargo.toml b/crates/tracedecay-global-db/Cargo.toml index ea9419ce893..b268d61c4c7 100644 --- a/crates/tracedecay-global-db/Cargo.toml +++ b/crates/tracedecay-global-db/Cargo.toml @@ -14,6 +14,7 @@ test-helpers = [ "tracedecay-runtime-core/test-helpers", "tracedecay-session-temporal-store/test-helpers", "dep:rusqlite", + "dep:tempfile", ] [dependencies] @@ -43,11 +44,16 @@ tracedecay-private-fs = { path = "../tracedecay-private-fs" } tracedecay-sessions = { path = "../tracedecay-sessions" } tracedecay-store = { path = "../tracedecay-store" } tracedecay-temporal-query = { path = "../tracedecay-temporal-query" } -tempfile.workspace = true +# Test-only harnesses in this crate build temporary stores. Optional and +# activated by `test-helpers` (the only non-`cfg(test)` consumer is the +# re-exported `tests::` harness); the crate's own `cfg(test)` builds resolve +# it from dev-dependencies, so production builds never link it. +tempfile = { version = "3", optional = true } [dev-dependencies] criterion = { workspace = true, features = ["async"] } rusqlite.workspace = true +tempfile.workspace = true # Test-only helper surfaces this crate's test targets reach across crate # boundaries: engine fixtures, session normalizers, and temporal page requests. tracedecay-graph-db = { path = "../tracedecay-graph-db", features = ["test-helpers"] } diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index e19976d6652..9518f748f81 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -767,28 +767,52 @@ async fn staged_retry_recovery_releases_wal_reader_before_writes() { } #[asupersync::test] -async fn retry_candidate_overflow_rearms_every_window_without_a_writer_during_discovery() { +async fn retry_candidate_memory_ceiling_refuses_without_writes_and_recovers() { let harness = RegisteredGlobalDbHarness::open("rearm-stage-memory-bound").await; seed_queue(&harness, 9, 0).await; let database = harness.registered.runtime_database(); - let before = writer_telemetry(&harness.registered).transactions; - // Type and size of the canonical observation IDs make an eight-byte - // allowance too small even for the shortest window. - let mut staged = stage_rearm_projection_retry_candidates(database, 2, 1_000_000, 8) + let now = 1_000_000_i64; + + // Permit exactly the first two-row window. The second window must + // refuse rather than spilling its frozen identities to the store disk. + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let frontier = retry_rearm_frontier(&snapshot, now).await.unwrap().unwrap(); + let (end, first) = read_rearm_projection_retry_candidates(&snapshot, 0, frontier, 2, now) .await + .unwrap() .unwrap(); + assert_eq!(end, 2); + let capacity = first.capacity() + std::mem::size_of::(); + drop(snapshot); + + let before = writer_telemetry(&harness.registered).transactions; + assert!( + stage_rearm_projection_retry_candidates(database, 2, now, capacity) + .await + .is_err(), + "a second frozen page must refuse above capacity, never spill to disk", + ); assert_eq!(writer_telemetry(&harness.registered).transactions, before); assert_eq!(queue_state(&harness).await, (9, 9, 9)); - let mut rows = Vec::new(); - while let Some(window) = staged.next().await.unwrap() { - rows.push( - apply_rearm_projection_retry_candidates(database, &window, 1_000_000) - .await - .unwrap(), - ); - } - assert_eq!(rows, [2, 2, 2, 2, 1]); + + // A failed read-only stage must release its WAL snapshot, so the ordinary + // production path can still rearm every deferred row in bounded writers. + assert_eq!( + rearm_queued_projection_retries(database, 2).await.unwrap(), + RearmedProjectionRetries { + rows: 9, + batches: 5, + } + ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!(after.commands - before.commands, 5); assert_eq!(queue_state(&harness).await, (9, 0, 9)); + assert_eq!( + rearm_queued_projection_retries(database, 2).await.unwrap(), + RearmedProjectionRetries::default(), + "a repeat recovery must not reacquire the writer", + ); + assert_eq!(writer_telemetry(&harness.registered).transactions, after); } #[asupersync::test] diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index efc8647077b..833fb672f50 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -1,9 +1,6 @@ use std::borrow::Cow; use std::collections::{BTreeSet, HashMap}; -use asupersync::fs::{BufReader, File, Lines}; -use asupersync::io::AsyncWriteExt; -use asupersync::stream::StreamExt; #[cfg(windows)] use std::path::{Component, Path, Prefix}; @@ -253,8 +250,8 @@ pub(super) async fn projection_retry_state( /// Queue rows one re-arm transaction touches. pub(crate) const REARM_PROJECTION_RETRY_BATCH_ROWS: i64 = 1_024; -/// Keep at most this many bytes of candidate windows in memory. Overflow -/// uses an anonymous file on the registered store's filesystem. +/// Hard ceiling for frozen candidate windows. A larger recovery refuses +/// before acquiring a writer rather than spilling unbounded data beside SQLite. const REARM_CANDIDATE_STAGE_BYTES: usize = 64 * 1024 * 1024; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -309,30 +306,19 @@ pub(crate) async fn rearm_queued_projection_retries( #[derive(Default)] pub(super) struct StagedRetryWindows { memory: std::vec::IntoIter, - overflow: Option>, } impl StagedRetryWindows { pub(super) async fn next(&mut self) -> ProjectionStoreResult> { - if let Some(page) = self.memory.next() { - return Ok(Some(page)); - } - let Some(lines) = self.overflow.as_mut() else { - return Ok(None); - }; - lines - .next() - .await - .transpose() - .map_err(|error| storage("read staged retry identities", error)) + Ok(self.memory.next()) } } /// The snapshot pins the WAL only during read-only candidate discovery and /// drops before any recovery writes. Scan the old queue in bounded pages /// without staging the inactive prefix or any observation payload. -/// Overflow holds only the frozen identity tuples and is discarded on drop, -/// including when discovery, checkpointing or a later writer fails. +/// If frozen identities exceed the memory ceiling, refuse without spilling +/// into the database filesystem or publishing a partially rearmed prefix. #[tracing::instrument( name = "global_db.observation_projection.stage_retry_rearm", level = "trace", @@ -354,11 +340,10 @@ pub(super) async fn stage_rearm_projection_retry_candidates( return Ok(StagedRetryWindows::default()); }; let mut staged = Vec::new(); - let mut overflow: Option = None; let mut staged_bytes = 0_usize; let mut after_rowid = 0_i64; while after_rowid < last_rowid { - let Some((batch_end, mut candidates)) = read_rearm_projection_retry_candidates( + let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( &snapshot, after_rowid, last_rowid, @@ -371,53 +356,23 @@ pub(super) async fn stage_rearm_projection_retry_candidates( }; let page_bytes = candidates .capacity() - .saturating_add(std::mem::size_of::()); - if overflow.is_none() && page_bytes <= max_staged_bytes - staged_bytes { - staged_bytes += page_bytes; - staged.push(candidates); - } else { - let file = match &mut overflow { - Some(file) => file, - slot @ None => { - let directory = database - .database_path() - .parent() - .ok_or_else(|| { - storage_message( - OPERATION, - "registered database has no containing directory", - ) - })? - .to_path_buf(); - let file = tracedecay_runtime_core::native_runtime::spawn_blocking(move || { - tempfile::tempfile_in(directory) - }) - .await - .map_err(|error| storage_message(OPERATION, error))? - .map_err(|error| storage(OPERATION, error))?; - slot.insert(File::from_std(file)) - } - }; - // json_group_array emits one compact JSON value, escaping embedded newlines. - candidates.push('\n'); - file.write_all(candidates.as_bytes()) - .await - .map_err(|error| storage(OPERATION, error))?; + .checked_add(std::mem::size_of::()) + .ok_or_else(|| storage_message(OPERATION, "retry candidate size overflow"))?; + staged_bytes = staged_bytes + .checked_add(page_bytes) + .ok_or_else(|| storage_message(OPERATION, "retry staging size overflow"))?; + if staged_bytes > max_staged_bytes { + return Err(storage_message( + OPERATION, + "frozen retry candidates exceed bounded memory capacity", + )); } + staged.push(candidates); after_rowid = batch_end; } drop(snapshot); - let overflow = if let Some(mut file) = overflow { - file.rewind() - .await - .map_err(|error| storage(OPERATION, error))?; - Some(BufReader::new(file).lines()) - } else { - None - }; Ok(StagedRetryWindows { memory: staged.into_iter(), - overflow, }) } From 09a27629feeca82fdabf50554fc8522ea0dc7456 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:33:19 -0800 Subject: [PATCH 37/64] fix(sessions): rearm earlier mounts one retry page at a time --- .../src/observation/schema.rs | 1 + .../src/observation_projection/rearm_tests.rs | 229 +++++++------ .../src/observation_projection/rebuild.rs | 4 +- .../src/observation_projection/schema.rs | 5 + .../src/observation_projection/state.rs | 303 +++++++++--------- .../src/schema_contract/definitions.rs | 1 + 6 files changed, 288 insertions(+), 255 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation/schema.rs b/crates/tracedecay-global-db/src/observation/schema.rs index 2348a02cd03..b1861b22f9a 100644 --- a/crates/tracedecay-global-db/src/observation/schema.rs +++ b/crates/tracedecay-global-db/src/observation/schema.rs @@ -195,6 +195,7 @@ const OBSERVATION_AUTHORITY_SCHEMA_SQL: &str = "CREATE TABLE IF NOT EXISTS sanit attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0), next_retry_at_micros INTEGER NOT NULL DEFAULT 0 CHECK(next_retry_at_micros >= 0), last_error TEXT, + retry_mount TEXT, revival_generation TEXT, revival_start_offset INTEGER CHECK( (revival_generation IS NULL AND revival_start_offset IS NULL) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 9518f748f81..515bb74f692 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -11,8 +11,7 @@ use tracedecay_domain::{ use tracedecay_runtime_core::db::engine::params; use super::state::{ - apply_rearm_projection_retry_candidates, read_rearm_projection_retry_candidates, - retry_rearm_frontier, stage_rearm_projection_retry_candidates, + RetryRearmWindows, apply_rearm_projection_retry_candidates, schedule_projection_retry, }; use super::{RearmedProjectionRetries, rearm_queued_projection_retries}; use crate::tests::harness::{RegisteredGlobalDbHarness, writer_telemetry}; @@ -501,16 +500,12 @@ async fn sparse_retry_rearm_writer_work_is_bounded_by_updated_rows() { #[asupersync::test] async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { let harness = RegisteredGlobalDbHarness::open("rearm-stale-candidates").await; - seed_queue(&harness, 3, 0).await; + seed_queue(&harness, 4, 0).await; let now = 1_000_000_i64; - let snapshot = harness.registered.read_snapshot().await.unwrap(); - let frontier = retry_rearm_frontier(&snapshot, now).await.unwrap().unwrap(); - let (last, candidates) = read_rearm_projection_retry_candidates(&snapshot, 0, frontier, 3, now) + let mut windows = RetryRearmWindows::new(harness.registered.runtime_database(), 4, now) .await - .unwrap() .unwrap(); - drop(snapshot); - assert_eq!(last, 3); + let candidates = windows.next().await.unwrap().unwrap(); // The reader's window is now stale: a foreground owner schedules a new // generation for one row and another row has become due. @@ -537,6 +532,18 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { .unwrap(), 1 ); + // An identical attempt count and deadline can leave every old CAS + // field unchanged. The foreground mount still owns the new retry. + schedule_projection_retry( + &transaction, + queued_observation(3).0.observation_id(), + 3, + FAR_FUTURE_MICROS, + "new mount", + &windows.mount, + ) + .await + .unwrap(); transaction.commit().await.unwrap(); assert_eq!( @@ -544,6 +551,7 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { harness.registered.runtime_database(), &candidates, now, + &windows.mount, ) .await .unwrap(), @@ -559,9 +567,12 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { ) .await .unwrap(); - for (sequence, attempts, deadline) in - [(1_i64, 3_i64, 0_i64), (2, 4, FAR_FUTURE_MICROS), (3, 3, 1)] - { + for (sequence, attempts, deadline) in [ + (1_i64, 3_i64, 0_i64), + (2, 4, FAR_FUTURE_MICROS), + (3, 3, 1), + (4, 3, FAR_FUTURE_MICROS), + ] { let row = rows.next().await.unwrap().unwrap(); assert_eq!(row.get::(0).unwrap(), sequence); assert_eq!(row.get::(1).unwrap(), attempts); @@ -576,53 +587,47 @@ async fn retry_rearm_does_not_erase_a_live_retry_between_windows() { seed_queue(&harness, 2, 1).await; let database = harness.registered.runtime_database(); let now = 1_000_000_i64; - let mut staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) - .await - .unwrap(); + let mut windows = RetryRearmWindows::new(database, 1, now).await.unwrap(); - // The snapshot is released before any writer lease. The original two - // deferred rows are frozen independently of later foreground retries. + // Apply the first old retry, then schedule a foreground retry between pages. assert_eq!( apply_rearm_projection_retry_candidates( database, - &staged.next().await.unwrap().expect("first frozen window"), - now + &windows.next().await.unwrap().expect("first retry window"), + now, + &windows.mount, ) .await .unwrap(), 1 ); let transaction = harness.registered.begin_write_transaction().await.unwrap(); - assert_eq!( - transaction - .execute( - "UPDATE projection_queue - SET attempt_count = attempt_count + 1, - next_retry_at_micros = ?1, last_error = 'fresh live retry' - WHERE observation_sequence = 2", - params![FAR_FUTURE_MICROS], - ) - .await - .unwrap(), - 1 - ); + schedule_projection_retry( + &transaction, + queued_observation(1).0.observation_id(), + 4, + FAR_FUTURE_MICROS, + "fresh live retry", + &windows.mount, + ) + .await + .unwrap(); transaction.commit().await.unwrap(); - // A fresh per-page SELECT would see the new deadline at sequence 2 and - // illegally clear this mount's backoff. The staged eligibility excludes - // the new retry even after dropping the WAL reader. + // The next reader sees the live retry and leaves its deadline intact. assert_eq!( apply_rearm_projection_retry_candidates( database, - &staged.next().await.unwrap().expect("second frozen window"), - now + &windows.next().await.unwrap().expect("next old retry"), + now, + &windows.mount, ) .await .unwrap(), 1 ); - assert!(staged.next().await.unwrap().is_none()); + assert!(windows.next().await.unwrap().is_none()); let snapshot = harness.registered.read_snapshot().await.unwrap(); let mut rows = snapshot .query( @@ -661,14 +666,13 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .await .unwrap(); transaction.commit().await.unwrap(); - let mut staged = stage_rearm_projection_retry_candidates(database, 1, now, usize::MAX) - .await - .unwrap(); + let mut windows = RetryRearmWindows::new(database, 1, now).await.unwrap(); assert_eq!( apply_rearm_projection_retry_candidates( database, - &staged.next().await.unwrap().expect("first frozen window"), - now + &windows.next().await.unwrap().expect("first retry window"), + now, + &windows.mount, ) .await .unwrap(), @@ -689,18 +693,29 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { .execute( "INSERT INTO projection_queue (observation_id, observation_sequence, attempt_count, next_retry_at_micros, last_error) - SELECT observation_id, sequence, 1, ?1, 'new retry' + SELECT observation_id, sequence, 0, 0, NULL FROM observations WHERE sequence = 4", - params![FAR_FUTURE_MICROS], + (), ) .await .unwrap(); + schedule_projection_retry( + &transaction, + queued_observation(3).0.observation_id(), + 1, + FAR_FUTURE_MICROS, + "new retry", + &windows.mount, + ) + .await + .unwrap(); transaction.commit().await.unwrap(); let mut remaining = 0; - while let Some(candidates) = staged.next().await.unwrap() { - remaining += apply_rearm_projection_retry_candidates(database, &candidates, now) - .await - .unwrap(); + while let Some(candidates) = windows.next().await.unwrap() { + remaining += + apply_rearm_projection_retry_candidates(database, &candidates, now, &windows.mount) + .await + .unwrap(); } assert_eq!( remaining, 1, @@ -721,20 +736,16 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { } #[asupersync::test] -async fn staged_retry_recovery_releases_wal_reader_before_writes() { +async fn retry_recovery_releases_wal_reader_between_pages() { let harness = RegisteredGlobalDbHarness::open("rearm-release-wal-reader").await; seed_queue(&harness, 9, 0).await; let database = harness.registered.runtime_database(); - let mut staged = stage_rearm_projection_retry_candidates(database, 2, 1_000_000, usize::MAX) + let mut windows = RetryRearmWindows::new(database, 2, 1_000_000) .await .unwrap(); - let first = staged - .next() - .await - .unwrap() - .expect("first frozen retry window"); + let first = windows.next().await.unwrap().expect("first retry window"); assert_eq!( - apply_rearm_projection_retry_candidates(database, &first, 1_000_000) + apply_rearm_projection_retry_candidates(database, &first, 1_000_000, &windows.mount) .await .unwrap(), 2 @@ -755,11 +766,16 @@ async fn staged_retry_recovery_releases_wal_reader_before_writes() { ); assert_eq!(checkpointed, log_frames); let mut remaining = Vec::new(); - while let Some(candidates) = staged.next().await.unwrap() { + while let Some(candidates) = windows.next().await.unwrap() { remaining.push( - apply_rearm_projection_retry_candidates(database, &candidates, 1_000_000) - .await - .unwrap(), + apply_rearm_projection_retry_candidates( + database, + &candidates, + 1_000_000, + &windows.mount, + ) + .await + .unwrap(), ); } assert_eq!(remaining, [2, 2, 2, 1]); @@ -767,52 +783,69 @@ async fn staged_retry_recovery_releases_wal_reader_before_writes() { } #[asupersync::test] -async fn retry_candidate_memory_ceiling_refuses_without_writes_and_recovers() { - let harness = RegisteredGlobalDbHarness::open("rearm-stage-memory-bound").await; - seed_queue(&harness, 9, 0).await; +async fn retry_rearm_respects_mount_ownership_across_restart_and_existing_schema() { + let harness = RegisteredGlobalDbHarness::open("rearm-mount-owner").await; + seed_queue(&harness, 2, 0).await; let database = harness.registered.runtime_database(); - let now = 1_000_000_i64; - - // Permit exactly the first two-row window. The second window must - // refuse rather than spilling its frozen identities to the store disk. - let snapshot = harness.registered.read_snapshot().await.unwrap(); - let frontier = retry_rearm_frontier(&snapshot, now).await.unwrap().unwrap(); - let (end, first) = read_rearm_projection_retry_candidates(&snapshot, 0, frontier, 2, now) + let windows = RetryRearmWindows::new(database, 1, 1_000_000) .await - .unwrap() .unwrap(); - assert_eq!(end, 2); - let capacity = first.capacity() + std::mem::size_of::(); - drop(snapshot); - - let before = writer_telemetry(&harness.registered).transactions; - assert!( - stage_rearm_projection_retry_candidates(database, 2, now, capacity) + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + schedule_projection_retry( + &transaction, + queued_observation(1).0.observation_id(), + 4, + FAR_FUTURE_MICROS, + "this mount", + &windows.mount, + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + assert_eq!( + rearm_queued_projection_retries(database, 1) .await - .is_err(), - "a second frozen page must refuse above capacity, never spill to disk", + .unwrap() + .rows, + 1 ); - assert_eq!(writer_telemetry(&harness.registered).transactions, before); - assert_eq!(queue_state(&harness).await, (9, 9, 9)); - - // A failed read-only stage must release its WAL snapshot, so the ordinary - // production path can still rearm every deferred row in bounded writers. assert_eq!( - rearm_queued_projection_retries(database, 2).await.unwrap(), - RearmedProjectionRetries { - rows: 9, - batches: 5, - } + rearm_queued_projection_retries(database, 1) + .await + .unwrap() + .rows, + 0 ); - let after = writer_telemetry(&harness.registered).transactions; - assert_eq!(after.commands - before.commands, 5); - assert_eq!(queue_state(&harness).await, (9, 0, 9)); + let (harness, _convergence) = harness.restart_for_daemon().await; assert_eq!( - rearm_queued_projection_retries(database, 2).await.unwrap(), - RearmedProjectionRetries::default(), - "a repeat recovery must not reacquire the writer", + rearm_queued_projection_retries(harness.registered.runtime_database(), 1) + .await + .unwrap() + .rows, + 1 + ); + + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute("ALTER TABLE projection_queue DROP COLUMN retry_mount", ()) + .await + .unwrap(); + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?1", + params![FAR_FUTURE_MICROS], + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let (harness, _convergence) = harness.restart_for_daemon().await; + assert_eq!( + rearm_queued_projection_retries(harness.registered.runtime_database(), 1) + .await + .unwrap() + .rows, + 2 ); - assert_eq!(writer_telemetry(&harness.registered).transactions, after); } #[asupersync::test] diff --git a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs index 2a41ae90266..5aee6ce5323 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rebuild.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rebuild.rs @@ -25,7 +25,8 @@ use super::state::{ consume_projection_queue_item, decode_observation_row, decode_sequence, projected_stored_message, projection_retry_state, queued_projection, read_checkpoint, read_message, read_observation, read_session, reconcile_session_rows_detailed, - schedule_projection_retry, storage, storage_message, stored_output_digest, write_checkpoint, + retry_mount_identity, schedule_projection_retry, storage, storage_message, + stored_output_digest, write_checkpoint, }; use super::transition::{ MessageTransition, MessageTransitionState, WorkflowFactTarget, WorkflowFactTransition, @@ -404,6 +405,7 @@ async fn persist_projection_retry_on_database( attempt_count, next_retry_at_micros, last_error, + &retry_mount_identity(database)?, ) .await?; transaction diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index 2a36e184ad3..fb5ddde1c5d 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -372,6 +372,11 @@ pub(crate) const OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL: &[&str] = &[ pub(in super::super) async fn ensure_observation_projection_schema( conn: &impl Executor, ) -> Result<(), Error> { + // Stores created before retry ownership was recorded have only prior-mount deadlines. + if !projection_table_column_exists(conn, "projection_queue", "retry_mount").await? { + conn.execute_batch("ALTER TABLE projection_queue ADD COLUMN retry_mount TEXT") + .await?; + } conn.execute_batch(OBSERVATION_PROJECTION_SCHEMA_SQL) .await?; verify_final_projection_shape(conn).await?; diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 833fb672f50..0d43913bd31 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -16,8 +16,8 @@ use tracedecay_store::{ use tracedecay_lcm::raw::{message_body_from_record_row, stored_message_record_select_columns}; use tracedecay_lcm::retrieval_content::projected_content_hash; use tracedecay_lcm::{LcmError, LcmStorageKind}; +use tracedecay_runtime_core::db::Database; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; -use tracedecay_runtime_core::db::{Database, DatabaseEngineReadSnapshot}; use tracedecay_sessions::runtime::shared::durable_project_path_key; #[cfg(windows)] use tracedecay_sessions::runtime::shared::path_identity_key; @@ -250,10 +250,6 @@ pub(super) async fn projection_retry_state( /// Queue rows one re-arm transaction touches. pub(crate) const REARM_PROJECTION_RETRY_BATCH_ROWS: i64 = 1_024; -/// Hard ceiling for frozen candidate windows. A larger recovery refuses -/// before acquiring a writer rather than spilling unbounded data beside SQLite. -const REARM_CANDIDATE_STAGE_BYTES: usize = 64 * 1024 * 1024; - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct RearmedProjectionRetries { pub(crate) rows: u64, @@ -264,11 +260,9 @@ pub(crate) struct RearmedProjectionRetries { /// observed the failure. A fresh mount re-arms only previously deferred /// observations. Fresh foreground retries must keep their own deadline. /// -/// Freeze the exact candidates in one consistent read view *before* taking -/// a writer lease. Keeping a WAL snapshot pinned across write windows would -/// prevent restart checkpoints and risk revocation during large recoveries. -/// Each subsequent short writer transaction conditionally updates unchanged -/// observation identities; an interrupted recovery keeps its committed prefix. +/// A foreground retry carries its mount identity in the same transaction as +/// its deadline. Recovery can therefore release each snapshot before applying +/// its bounded page without clearing a retry scheduled by this mount. #[tracing::instrument( name = "global_db.observation_projection.rearm_retries", level = "trace", @@ -285,16 +279,12 @@ pub(crate) async fn rearm_queued_projection_retries( )); } let now = tracedecay_contracts::clock::now_micros().0; - let mut windows = stage_rearm_projection_retry_candidates( - database, - batch_rows, - now, - REARM_CANDIDATE_STAGE_BYTES, - ) - .await?; + let mut windows = RetryRearmWindows::new(database, batch_rows, now).await?; let mut rearmed = RearmedProjectionRetries::default(); while let Some(candidates) = windows.next().await? { - let rows = apply_rearm_projection_retry_candidates(database, &candidates, now).await?; + let rows = + apply_rearm_projection_retry_candidates(database, &candidates, now, &windows.mount) + .await?; rearmed.rows += rows; if rows != 0 { rearmed.batches += 1; @@ -303,122 +293,146 @@ pub(crate) async fn rearm_queued_projection_retries( Ok(rearmed) } -#[derive(Default)] -pub(super) struct StagedRetryWindows { - memory: std::vec::IntoIter, +/// The write authority distinguishes process lifetimes and the registry epoch +/// distinguishes remounts sharing the same authority. Only the digest is stored. +pub(super) fn retry_mount_identity(database: &Database) -> ProjectionStoreResult { + let authority = database + .write_authority() + .map_err(|error| storage("identify projection retry mount", error))?; + Ok(tracedecay_domain::canonical_text::canonical_framed_sha256( + b"tracedecay.projection-retry.mount", + &[ + authority.token().as_bytes(), + &database + .registered_binding() + .authority_epoch + .get() + .to_le_bytes(), + ], + )) } -impl StagedRetryWindows { - pub(super) async fn next(&mut self) -> ProjectionStoreResult> { - Ok(self.memory.next()) - } +pub(super) struct RetryRearmWindows<'a> { + database: &'a Database, + batch_rows: i64, + pub(super) mount: String, + deadline: i64, + rowid: i64, + last: Option<(i64, i64)>, } -/// The snapshot pins the WAL only during read-only candidate discovery and -/// drops before any recovery writes. Scan the old queue in bounded pages -/// without staging the inactive prefix or any observation payload. -/// If frozen identities exceed the memory ceiling, refuse without spilling -/// into the database filesystem or publishing a partially rearmed prefix. -#[tracing::instrument( - name = "global_db.observation_projection.stage_retry_rearm", - level = "trace", - skip_all -)] -pub(super) async fn stage_rearm_projection_retry_candidates( - database: &Database, - batch_rows: i64, - now_micros: i64, - max_staged_bytes: usize, -) -> ProjectionStoreResult { - const OPERATION: &str = "stage queued projection retries"; - let snapshot = database - .read_connection() - .convergence_snapshot() - .await - .map_err(|error| storage("open projection retry recovery snapshot", error))?; - let Some(last_rowid) = retry_rearm_frontier(&snapshot, now_micros).await? else { - return Ok(StagedRetryWindows::default()); - }; - let mut staged = Vec::new(); - let mut staged_bytes = 0_usize; - let mut after_rowid = 0_i64; - while after_rowid < last_rowid { - let Some((batch_end, candidates)) = read_rearm_projection_retry_candidates( - &snapshot, - after_rowid, - last_rowid, +impl<'a> RetryRearmWindows<'a> { + pub(super) async fn new( + database: &'a Database, + batch_rows: i64, + now: i64, + ) -> ProjectionStoreResult { + const OPERATION: &str = "read projection retry frontier"; + let snapshot = database + .read_connection() + .convergence_snapshot() + .await + .map_err(|error| storage(OPERATION, error))?; + let mut rows = snapshot + .query( + // qpsg-exempt: the deadline ceiling excludes permanent collisions at the end of the index. + "SELECT next_retry_at_micros, rowid FROM projection_queue + INDEXED BY idx_projection_queue_retry_deadline + WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807 + ORDER BY next_retry_at_micros DESC, rowid DESC LIMIT 1", + params![now], + ) + .await + .map_err(|error| storage(OPERATION, error))?; + let last = rows + .next() + .await + .map_err(|error| storage(OPERATION, error))? + .map(|row| Ok((row.get(0)?, row.get(1)?))) + .transpose() + .map_err(|error: tracedecay_runtime_core::db::engine::Error| { + storage(OPERATION, error) + })?; + Ok(Self { + database, batch_rows, - now_micros, - ) - .await? - else { - break; + mount: retry_mount_identity(database)?, + deadline: now, + rowid: i64::MAX, + last, + }) + } + + /// The deadline index includes rowid as its implicit last key. Split the + /// continuation into two index seeks so even many rows at the same + /// deadline do not repeatedly traverse the preceding pages. + pub(super) async fn next(&mut self) -> ProjectionStoreResult> { + const OPERATION: &str = "read projection retry rearm window"; + let Some(end) = self.last else { + return Ok(None); }; - let page_bytes = candidates - .capacity() - .checked_add(std::mem::size_of::()) - .ok_or_else(|| storage_message(OPERATION, "retry candidate size overflow"))?; - staged_bytes = staged_bytes - .checked_add(page_bytes) - .ok_or_else(|| storage_message(OPERATION, "retry staging size overflow"))?; - if staged_bytes > max_staged_bytes { - return Err(storage_message( - OPERATION, - "frozen retry candidates exceed bounded memory capacity", - )); + while (self.deadline, self.rowid) < end { + let snapshot = self + .database + .read_connection() + .convergence_snapshot() + .await + .map_err(|error| storage(OPERATION, error))?; + let mut rows = snapshot + .query( + // qpsg-exempt: both arms seek the retry deadline index, bounded by the captured nonterminal upper key. + "SELECT rowid AS id, next_retry_at_micros AS deadline, + CASE WHEN retry_mount IS NOT ?4 THEN observation_id END, attempt_count + FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline + WHERE next_retry_at_micros = ?1 AND rowid > ?2 + AND (next_retry_at_micros, rowid) <= (?5, ?6) + UNION ALL + SELECT rowid, next_retry_at_micros, + CASE WHEN retry_mount IS NOT ?4 THEN observation_id END, attempt_count + FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline + WHERE next_retry_at_micros > ?1 AND (next_retry_at_micros, rowid) <= (?5, ?6) + ORDER BY deadline, id LIMIT ?3", + params![ + self.deadline, + self.rowid, + self.batch_rows, + self.mount.as_str(), + end.0, + end.1 + ], + ) + .await + .map_err(|error| storage(OPERATION, error))?; + let mut candidates = Vec::new(); + let mut last = None; + while let Some(row) = rows + .next() + .await + .map_err(|error| storage(OPERATION, error))? + { + let rowid: i64 = row.get(0).map_err(|error| storage(OPERATION, error))?; + let deadline: i64 = row.get(1).map_err(|error| storage(OPERATION, error))?; + let observation: Option = + row.get(2).map_err(|error| storage(OPERATION, error))?; + if let Some(observation) = observation { + let attempt: i64 = row.get(3).map_err(|error| storage(OPERATION, error))?; + candidates.push((rowid, observation, deadline, attempt)); + } + last = Some((deadline, rowid)); + } + let Some((deadline, rowid)) = last else { + return Ok(None); + }; + self.deadline = deadline; + self.rowid = rowid; + if !candidates.is_empty() { + return serde_json::to_string(&candidates) + .map(Some) + .map_err(|error| storage(OPERATION, error)); + } } - staged.push(candidates); - after_rowid = batch_end; + Ok(None) } - drop(snapshot); - Ok(StagedRetryWindows { - memory: staged.into_iter(), - }) -} - -/// Candidate membership is fixed by the recovery snapshot. The foreground -/// writer may already have replaced a selected retry by the time we apply it, -/// so carry the original observation identity, deadline and attempt count. -pub(super) async fn read_rearm_projection_retry_candidates( - snapshot: &DatabaseEngineReadSnapshot, - after_rowid: i64, - last_rowid: i64, - batch_rows: i64, - now_micros: i64, -) -> ProjectionStoreResult> { - const OPERATION: &str = "read projection retry rearm window"; - let mut candidates = snapshot - .query( - // qpsg-exempt: the literal terminal ceiling and independent clock - // predicate select a bounded rowid-ordered window on the reader. - "SELECT MAX(rowid), - json_group_array(json_array(rowid, observation_id, - next_retry_at_micros, attempt_count)) - FROM ( - SELECT rowid, observation_id, next_retry_at_micros, attempt_count - FROM projection_queue NOT INDEXED - WHERE rowid > ?1 AND rowid <= ?2 - AND next_retry_at_micros > ?4 - AND next_retry_at_micros < 9223372036854775807 - ORDER BY rowid LIMIT ?3 - )", - params![after_rowid, last_rowid, batch_rows, now_micros], - ) - .await - .map_err(|error| storage(OPERATION, error))?; - let batch = candidates - .next() - .await - .map_err(|error| storage(OPERATION, error))? - .ok_or_else(|| storage_message(OPERATION, "missing retry batch aggregate"))?; - let Some(batch_end) = batch - .get::>(0) - .map_err(|error| storage(OPERATION, error))? - else { - return Ok(None); - }; - let identities: String = batch.get(1).map_err(|error| storage(OPERATION, error))?; - Ok(Some((batch_end, identities))) } /// Primary-key lookups make writer work proportional to this bounded page. @@ -428,6 +442,7 @@ pub(super) async fn apply_rearm_projection_retry_candidates( database: &Database, candidates: &str, now_micros: i64, + mount: &str, ) -> ProjectionStoreResult { const OPERATION: &str = "rearm queued projection retries"; let transaction = database @@ -438,7 +453,7 @@ pub(super) async fn apply_rearm_projection_retry_candidates( .execute( // qpsg-exempt: the candidate list contains exact rowid identities, // and the literal terminal bound protects a concurrent park. - "UPDATE projection_queue SET next_retry_at_micros = 0 + "UPDATE projection_queue SET next_retry_at_micros = 0, retry_mount = NULL WHERE (rowid, observation_id, next_retry_at_micros, attempt_count) IN ( SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]'), @@ -446,9 +461,10 @@ pub(super) async fn apply_rearm_projection_retry_candidates( json_extract(value, '$[3]') FROM json_each(?1) ) + AND retry_mount IS NOT ?3 AND next_retry_at_micros > ?2 AND next_retry_at_micros < 9223372036854775807", - params![candidates, now_micros], + params![candidates, now_micros, mount], ) .await .map_err(|error| storage(OPERATION, error))?; @@ -466,50 +482,25 @@ pub(super) async fn apply_rearm_projection_retry_candidates( Ok(rows) } -pub(super) async fn retry_rearm_frontier( - snapshot: &DatabaseEngineReadSnapshot, - now_micros: i64, -) -> ProjectionStoreResult> { - const OPERATION: &str = "read projection retry frontier"; - // The deadline index bounds how far the snapshot reader needs to scan. - let mut rows = snapshot - .query( - // qpsg-exempt: the deadline index uses both the clock and the literal terminal ceiling. - "SELECT MAX(rowid) - FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline - WHERE next_retry_at_micros > ?1 AND next_retry_at_micros < 9223372036854775807", - params![now_micros], - ) - .await - .map_err(|error| storage(OPERATION, error))?; - let Some(row) = rows - .next() - .await - .map_err(|error| storage(OPERATION, error))? - else { - return Ok(None); - }; - row.get::>(0) - .map_err(|error| storage(OPERATION, error)) -} - pub(super) async fn schedule_projection_retry( conn: &impl Executor, observation_id: &CanonicalObservationIdV1, attempt_count: u32, next_retry_at_micros: i64, last_error: &str, + mount: &str, ) -> ProjectionStoreResult<()> { let updated = conn .execute( "UPDATE projection_queue - SET attempt_count = ?2, next_retry_at_micros = ?3, last_error = ?4 + SET attempt_count = ?2, next_retry_at_micros = ?3, last_error = ?4, retry_mount = ?5 WHERE observation_id = ?1", params![ observation_id.as_str(), i64::from(attempt_count), next_retry_at_micros, last_error, + mount, ], ) .await diff --git a/crates/tracedecay-global-db/src/schema_contract/definitions.rs b/crates/tracedecay-global-db/src/schema_contract/definitions.rs index c24bba3d2da..a1bf2ca7e3f 100644 --- a/crates/tracedecay-global-db/src/schema_contract/definitions.rs +++ b/crates/tracedecay-global-db/src/schema_contract/definitions.rs @@ -501,6 +501,7 @@ pub(super) const TABLES: &[Table] = &[ column("attempt_count", "INTEGER", true, Some("0"), 0), column("next_retry_at_micros", "INTEGER", true, Some("0"), 0), column("last_error", "TEXT", false, None, 0), + column("retry_mount", "TEXT", false, None, 0), column("revival_generation", "TEXT", false, None, 0), column("revival_start_offset", "INTEGER", false, None, 0), ], From 6269188bc4dae0d756bb6d02738293f9b2e4ae8f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:37:53 -0800 Subject: [PATCH 38/64] fix(sessions): refuse incompatible projection retry queues Refs #4756 Do not silently ALTER an existing projection queue when adding retry mount identity. Check the old queue before schema installation and return the canonical typed fresh-store refusal without changing its shape. A registered-store reopen regression removes the new column, requires refusal with reset guidance, and verifies the old column inventory remains unchanged. No production data, release, CI policy, or other branch touched. Bazel Rust verification remains pending. --- .../src/observation_projection/schema.rs | 51 +++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index fb5ddde1c5d..a3faa34ff74 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -372,10 +372,11 @@ pub(crate) const OBSERVATION_PROJECTION_PERFORMANCE_INDEX_SQL: &[&str] = &[ pub(in super::super) async fn ensure_observation_projection_schema( conn: &impl Executor, ) -> Result<(), Error> { - // Stores created before retry ownership was recorded have only prior-mount deadlines. - if !projection_table_column_exists(conn, "projection_queue", "retry_mount").await? { - conn.execute_batch("ALTER TABLE projection_queue ADD COLUMN retry_mount TEXT") - .await?; + // Existing queue shapes are immutable. A previous binary's queue cannot + // be upgraded behind the registered schema contract: refuse before DDL. + let queue_columns = projection_rebuild_column_names(conn, "projection_queue").await?; + if !queue_columns.is_empty() && !queue_columns.iter().any(|column| column == "retry_mount") { + return Err(unsupported_projection_schema("projection_queue")); } conn.execute_batch(OBSERVATION_PROJECTION_SCHEMA_SQL) .await?; @@ -639,6 +640,48 @@ mod tests { ); } + /// Reopening a store with the preceding queue shape must refuse without + /// silently adding retry ownership or changing the original table. + #[asupersync::test] + async fn prior_retry_queue_shape_is_refused_without_schema_mutation() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("global.db"); + drop(open_registered_schema(&path).await.unwrap()); + + let conn = TestConnection::open(&path); + conn.execute_batch("ALTER TABLE projection_queue DROP COLUMN retry_mount") + .await + .unwrap(); + drop(conn); + + let Err(error) = open_registered_schema(&path).await else { + panic!("a prior retry queue must be refused, not upgraded in place"); + }; + let message = error.to_string(); + assert!( + message.contains("projection_queue"), + "the refusal must identify the incompatible queue: {message}" + ); + assert!( + message.contains("create a fresh one"), + "a prior schema must get the reset remedy: {message}" + ); + + let conn = TestConnection::open(&path); + let mut rows = conn + .query( + "SELECT name FROM pragma_table_xinfo('projection_queue') + WHERE name = 'retry_mount'", + (), + ) + .await + .unwrap(); + assert!( + rows.next().await.unwrap().is_none(), + "refused admission must preserve the old queue shape unchanged" + ); + } + /// Real registered-store admission must accept the two queue indexes while /// preserving the strict authority inventory and the partial-index contract. #[asupersync::test] From b7cd1afd0225905db54b1c5478126353fce12841 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:59:55 -0800 Subject: [PATCH 39/64] fix(sessions): preserve the shipped retry queue on recovery --- .../src/observation/schema.rs | 1 - .../src/observation_projection/rearm_tests.rs | 75 +++++++++++++++++-- .../src/observation_projection/schema.rs | 15 ++-- .../src/observation_projection/state.rs | 39 +++++++--- .../src/schema_contract/definitions.rs | 14 +++- 5 files changed, 119 insertions(+), 25 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation/schema.rs b/crates/tracedecay-global-db/src/observation/schema.rs index b1861b22f9a..2348a02cd03 100644 --- a/crates/tracedecay-global-db/src/observation/schema.rs +++ b/crates/tracedecay-global-db/src/observation/schema.rs @@ -195,7 +195,6 @@ const OBSERVATION_AUTHORITY_SCHEMA_SQL: &str = "CREATE TABLE IF NOT EXISTS sanit attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0), next_retry_at_micros INTEGER NOT NULL DEFAULT 0 CHECK(next_retry_at_micros >= 0), last_error TEXT, - retry_mount TEXT, revival_generation TEXT, revival_start_offset INTEGER CHECK( (revival_generation IS NULL AND revival_start_offset IS NULL) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index 515bb74f692..eaec213ef35 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -509,7 +509,12 @@ async fn rearm_reader_snapshot_cannot_clear_a_new_retry_generation() { // The reader's window is now stale: a foreground owner schedules a new // generation for one row and another row has become due. - let transaction = harness.registered.begin_write_transaction().await.unwrap(); + let transaction = harness + .registered + .runtime_database() + .begin_write_transaction("schedule projection retry") + .await + .unwrap(); assert_eq!( transaction .execute( @@ -601,7 +606,12 @@ async fn retry_rearm_does_not_erase_a_live_retry_between_windows() { .unwrap(), 1 ); - let transaction = harness.registered.begin_write_transaction().await.unwrap(); + let transaction = harness + .registered + .runtime_database() + .begin_write_transaction("schedule projection retry") + .await + .unwrap(); schedule_projection_retry( &transaction, queued_observation(1).0.observation_id(), @@ -681,7 +691,12 @@ async fn rearm_leaves_later_observations_with_reused_queue_rowids_deferred() { // A foreground pass settles the old tail. SQLite assigns its rowid to a // newer observation, whose retry belongs to the current mount. - let transaction = harness.registered.begin_write_transaction().await.unwrap(); + let transaction = harness + .registered + .runtime_database() + .begin_write_transaction("schedule projection retry") + .await + .unwrap(); transaction .execute( "DELETE FROM projection_queue WHERE observation_sequence = 3", @@ -790,7 +805,12 @@ async fn retry_rearm_respects_mount_ownership_across_restart_and_existing_schema let windows = RetryRearmWindows::new(database, 1, 1_000_000) .await .unwrap(); - let transaction = harness.registered.begin_write_transaction().await.unwrap(); + let transaction = harness + .registered + .runtime_database() + .begin_write_transaction("schedule projection retry") + .await + .unwrap(); schedule_projection_retry( &transaction, queued_observation(1).0.observation_id(), @@ -827,7 +847,7 @@ async fn retry_rearm_respects_mount_ownership_across_restart_and_existing_schema let transaction = harness.registered.begin_write_transaction().await.unwrap(); transaction - .execute("ALTER TABLE projection_queue DROP COLUMN retry_mount", ()) + .execute("DROP TABLE projection_retry_owners", ()) .await .unwrap(); transaction @@ -848,6 +868,51 @@ async fn retry_rearm_respects_mount_ownership_across_restart_and_existing_schema ); } +#[asupersync::test] +async fn failed_rearm_owner_write_preserves_the_previous_retry() { + let harness = RegisteredGlobalDbHarness::open("rearm-owner-rollback").await; + seed_queue(&harness, 1, 0).await; + let database = harness.registered.runtime_database(); + let windows = RetryRearmWindows::new(database, 1, 1_000_000) + .await + .unwrap(); + let setup = harness.registered.begin_write_transaction().await.unwrap(); + setup + .execute_batch( + "CREATE TRIGGER reject_retry_owner BEFORE INSERT ON projection_retry_owners + BEGIN SELECT RAISE(ABORT, 'retry owner refused'); END;", + ) + .await + .unwrap(); + setup.commit().await.unwrap(); + + { + let transaction = database + .begin_write_transaction("schedule projection retry") + .await + .unwrap(); + let error = schedule_projection_retry( + &transaction, + queued_observation(0).0.observation_id(), + 4, + FAR_FUTURE_MICROS - 1, + "new retry", + &windows.mount, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("retry owner refused")); + } + assert_eq!(queue_state(&harness).await, (1, 1, 1)); + assert_eq!( + rearm_queued_projection_retries(database, 1) + .await + .unwrap() + .rows, + 1 + ); +} + #[asupersync::test] async fn daemon_admission_defers_rearm_to_background_convergence() { let harness = RegisteredGlobalDbHarness::open("rearm-daemon-admission").await; diff --git a/crates/tracedecay-global-db/src/observation_projection/schema.rs b/crates/tracedecay-global-db/src/observation_projection/schema.rs index a3faa34ff74..90c703c22ed 100644 --- a/crates/tracedecay-global-db/src/observation_projection/schema.rs +++ b/crates/tracedecay-global-db/src/observation_projection/schema.rs @@ -19,6 +19,10 @@ const OBSERVATION_PROJECTION_SCHEMA_SQL: &str = FOREIGN KEY(observation_id) REFERENCES observations(observation_id), FOREIGN KEY(receipt_id) REFERENCES sanitization_receipts(receipt_id) ); + CREATE TABLE IF NOT EXISTS projection_retry_owners ( + observation_id TEXT PRIMARY KEY NOT NULL REFERENCES projection_queue(observation_id) ON DELETE CASCADE, + mount TEXT NOT NULL + ) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS observation_projection_checkpoints ( projector_version TEXT PRIMARY KEY, last_sequence INTEGER NOT NULL CHECK(last_sequence >= 0) @@ -375,7 +379,7 @@ pub(in super::super) async fn ensure_observation_projection_schema( // Existing queue shapes are immutable. A previous binary's queue cannot // be upgraded behind the registered schema contract: refuse before DDL. let queue_columns = projection_rebuild_column_names(conn, "projection_queue").await?; - if !queue_columns.is_empty() && !queue_columns.iter().any(|column| column == "retry_mount") { + if !queue_columns.is_empty() && !queue_columns.iter().any(|column| column == "attempt_count") { return Err(unsupported_projection_schema("projection_queue")); } conn.execute_batch(OBSERVATION_PROJECTION_SCHEMA_SQL) @@ -640,16 +644,15 @@ mod tests { ); } - /// Reopening a store with the preceding queue shape must refuse without - /// silently adding retry ownership or changing the original table. + /// A queue missing its retry counter must remain untouched on refusal. #[asupersync::test] - async fn prior_retry_queue_shape_is_refused_without_schema_mutation() { + async fn incompatible_retry_queue_is_refused_without_schema_mutation() { let temp = TempDir::new().unwrap(); let path = temp.path().join("global.db"); drop(open_registered_schema(&path).await.unwrap()); let conn = TestConnection::open(&path); - conn.execute_batch("ALTER TABLE projection_queue DROP COLUMN retry_mount") + conn.execute_batch("ALTER TABLE projection_queue DROP COLUMN attempt_count") .await .unwrap(); drop(conn); @@ -671,7 +674,7 @@ mod tests { let mut rows = conn .query( "SELECT name FROM pragma_table_xinfo('projection_queue') - WHERE name = 'retry_mount'", + WHERE name = 'attempt_count'", (), ) .await diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 0d43913bd31..d2b67e9e1c0 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -16,8 +16,8 @@ use tracedecay_store::{ use tracedecay_lcm::raw::{message_body_from_record_row, stored_message_record_select_columns}; use tracedecay_lcm::retrieval_content::projected_content_hash; use tracedecay_lcm::{LcmError, LcmStorageKind}; -use tracedecay_runtime_core::db::Database; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; +use tracedecay_runtime_core::db::{Database, DatabaseWriteTransaction}; use tracedecay_sessions::runtime::shared::durable_project_path_key; #[cfg(windows)] use tracedecay_sessions::runtime::shared::path_identity_key; @@ -382,13 +382,19 @@ impl<'a> RetryRearmWindows<'a> { .query( // qpsg-exempt: both arms seek the retry deadline index, bounded by the captured nonterminal upper key. "SELECT rowid AS id, next_retry_at_micros AS deadline, - CASE WHEN retry_mount IS NOT ?4 THEN observation_id END, attempt_count + CASE WHEN NOT EXISTS ( + SELECT 1 FROM projection_retry_owners AS owner + WHERE owner.observation_id = projection_queue.observation_id AND owner.mount = ?4 + ) THEN observation_id END, attempt_count FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline WHERE next_retry_at_micros = ?1 AND rowid > ?2 AND (next_retry_at_micros, rowid) <= (?5, ?6) UNION ALL SELECT rowid, next_retry_at_micros, - CASE WHEN retry_mount IS NOT ?4 THEN observation_id END, attempt_count + CASE WHEN NOT EXISTS ( + SELECT 1 FROM projection_retry_owners AS owner + WHERE owner.observation_id = projection_queue.observation_id AND owner.mount = ?4 + ) THEN observation_id END, attempt_count FROM projection_queue INDEXED BY idx_projection_queue_retry_deadline WHERE next_retry_at_micros > ?1 AND (next_retry_at_micros, rowid) <= (?5, ?6) ORDER BY deadline, id LIMIT ?3", @@ -453,7 +459,7 @@ pub(super) async fn apply_rearm_projection_retry_candidates( .execute( // qpsg-exempt: the candidate list contains exact rowid identities, // and the literal terminal bound protects a concurrent park. - "UPDATE projection_queue SET next_retry_at_micros = 0, retry_mount = NULL + "UPDATE projection_queue SET next_retry_at_micros = 0 WHERE (rowid, observation_id, next_retry_at_micros, attempt_count) IN ( SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]'), @@ -461,7 +467,10 @@ pub(super) async fn apply_rearm_projection_retry_candidates( json_extract(value, '$[3]') FROM json_each(?1) ) - AND retry_mount IS NOT ?3 + AND NOT EXISTS ( + SELECT 1 FROM projection_retry_owners AS owner + WHERE owner.observation_id = projection_queue.observation_id AND owner.mount = ?3 + ) AND next_retry_at_micros > ?2 AND next_retry_at_micros < 9223372036854775807", params![candidates, now_micros, mount], @@ -483,7 +492,7 @@ pub(super) async fn apply_rearm_projection_retry_candidates( } pub(super) async fn schedule_projection_retry( - conn: &impl Executor, + conn: &DatabaseWriteTransaction<'_>, observation_id: &CanonicalObservationIdV1, attempt_count: u32, next_retry_at_micros: i64, @@ -493,23 +502,29 @@ pub(super) async fn schedule_projection_retry( let updated = conn .execute( "UPDATE projection_queue - SET attempt_count = ?2, next_retry_at_micros = ?3, last_error = ?4, retry_mount = ?5 + SET attempt_count = ?2, next_retry_at_micros = ?3, last_error = ?4 WHERE observation_id = ?1", params![ observation_id.as_str(), i64::from(attempt_count), next_retry_at_micros, last_error, - mount, ], ) .await .map_err(|error| storage("schedule projection retry", error))?; - if updated == 1 { - Ok(()) - } else { - Err(ProjectionStoreError::NotQueued) + if updated != 1 { + return Err(ProjectionStoreError::NotQueued); } + // Retry scheduling and ownership commit under the same queue writer. + conn.execute( + "INSERT INTO projection_retry_owners (observation_id, mount) VALUES (?1, ?2) + ON CONFLICT (observation_id) DO UPDATE SET mount = excluded.mount", + params![observation_id.as_str(), mount], + ) + .await + .map_err(|error| storage("record projection retry owner", error))?; + Ok(()) } pub(super) async fn consume_projection_queue_item( diff --git a/crates/tracedecay-global-db/src/schema_contract/definitions.rs b/crates/tracedecay-global-db/src/schema_contract/definitions.rs index a1bf2ca7e3f..3a86b0d8083 100644 --- a/crates/tracedecay-global-db/src/schema_contract/definitions.rs +++ b/crates/tracedecay-global-db/src/schema_contract/definitions.rs @@ -501,7 +501,6 @@ pub(super) const TABLES: &[Table] = &[ column("attempt_count", "INTEGER", true, Some("0"), 0), column("next_retry_at_micros", "INTEGER", true, Some("0"), 0), column("last_error", "TEXT", false, None, 0), - column("retry_mount", "TEXT", false, None, 0), column("revival_generation", "TEXT", false, None, 0), column("revival_start_offset", "INTEGER", false, None, 0), ], @@ -512,6 +511,19 @@ pub(super) const TABLES: &[Table] = &[ "NO ACTION" )] ), + table!( + "projection_retry_owners", + [ + column("observation_id", "TEXT", true, None, 1), + column("mount", "TEXT", true, None, 0), + ], + [foreign_key( + "observation_id", + "projection_queue", + "observation_id", + "CASCADE" + )] + ), table!( "observation_projection_provenance", [ From 1c0af1e75af845c09f2d94001bf6760a511300aa Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:53:06 -0800 Subject: [PATCH 40/64] perf(sessions): batch durable source presence reoffers Refs #4756 Replace per-record SQLite writes in source-presence capture with bounded JSON row pages under the original atomic source transaction. Skip identical presence updates without losing latest-offer ordering or source provenance. Add a 257-record registered-store regression for three statement windows, idempotent reoffers, rollback of a failing last page, and latest-wins duplicates. Local SQLite exercised the same upsert and integrity behavior. Exact-head nonzero Bazel Rust verification and incident-scale CPU/write measurements remain necessary. --- .../src/registered_source_rewrite.rs | 35 +++-- .../src/tests/source_rewrite.rs | 129 +++++++++++++++++- 2 files changed, 154 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index c6d0cc566e5..3081d33b736 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -26,6 +26,10 @@ use super::registered::JobExit; /// which made rewrite completion fail deterministically for those sources. const UNOFFERED_RECORD_PAGE_SIZE: i64 = 1_000; +/// One page is small enough for the SQLite JSON virtual table and writer +/// deadline. The entire supplied presence batch still commits atomically. +const SOURCE_PRESENCE_PAGE_ROWS: usize = 128; + /// One record a source layout offered, under the identity admission derived. pub struct ObservationSourcePresenceV1 { pub observation_id: CanonicalObservationIdV1, @@ -94,21 +98,34 @@ impl RegisteredGlobalDb { .await .map_err(|error| storage("begin source presence transaction", error))?; let recorded = async { - for record in records { + for chunk in records.chunks(SOURCE_PRESENCE_PAGE_ROWS) { + let page = chunk + .iter() + .map(|record| { + Ok(( + record.observation_id.as_str(), + record.generation.generation_id().to_string(), + offset(record.start_offset)?, + )) + }) + .collect::>>()?; + let encoded = serde_json::to_string(&page) + .map_err(|error| storage("encode source presence page", error))?; transaction .execute( "INSERT INTO observation_source_presence ( source_key, observation_id, generation, start_offset - ) VALUES (?1, ?2, ?3, ?4) + ) + SELECT ?1, json_extract(value, '$[0]'), + json_extract(value, '$[1]'), json_extract(value, '$[2]') + FROM json_each(?2) AS input + WHERE true ORDER BY CAST(input.key AS INTEGER) ON CONFLICT(source_key, observation_id) DO UPDATE SET generation = excluded.generation, - start_offset = excluded.start_offset", - params![ - key.as_str(), - record.observation_id.as_str(), - record.generation.generation_id().to_string(), - offset(record.start_offset)?, - ], + start_offset = excluded.start_offset + WHERE observation_source_presence.generation IS NOT excluded.generation + OR observation_source_presence.start_offset IS NOT excluded.start_offset", + params![key.as_str(), encoded.as_str()], ) .await .map_err(|error| storage("record source presence", error))?; diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 19672f47d8a..53e35862be4 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -19,7 +19,7 @@ use tracedecay_store::{ProjectionSkipReason, ProjectionStoreError}; use crate::ObservationSourcePresenceV1; use crate::tests::harness::{ HostAdmissionScope, HostAdmissionTestRuntimeV1, RegisteredGlobalDbHarness, - seed_projected_messages, + seed_projected_messages, writer_telemetry, }; /// More unoffered records than one keyset page *and* more than the engine's @@ -320,3 +320,130 @@ async fn rewrite_completion_retires_projected_records_and_rolls_back_a_failed_at "retired records must request their session's temporal rebuild" ); } + +/// Capturing one host page previously dispatched one SQLite statement per +/// record, even for reoffers identical to the already durable generation. +/// The JSON batch must preserve last-offer ordering, exact identity and +/// rollback while reducing writer commands to bounded pages. +#[asupersync::test] +async fn source_presence_pages_are_atomic_and_unchanged_reoffers_do_not_write() { + const RECORDS: usize = 257; + let harness = RegisteredGlobalDbHarness::open("source-presence-batched").await; + let source = ObservationSourceIdentityV1::new( + SessionId::new("session.presence-batched").unwrap(), + ) + .unwrap(); + let scope = ObservationScopeV1::Profile; + let first = ObservationSourceGenerationV1::new(1).unwrap(); + let second = ObservationSourceGenerationV1::new(2).unwrap(); + let offered = (0..RECORDS) + .map(|index| ObservationSourcePresenceV1 { + observation_id: tracedecay_domain::CanonicalObservationIdV1::new(format!( + "sha256:{index:064x}" + )) + .unwrap(), + generation: first, + start_offset: index as u64, + }) + .collect::>(); + let commands = || writer_telemetry(&harness.registered).transactions.commands; + let before = commands(); + harness + .registered + .record_observation_source_presence(&source, &scope, &offered) + .await + .unwrap(); + assert_eq!( + commands() - before, + 3, + "257 real presence records must need three bounded writer statements, not 257" + ); + + // A SQLite trigger makes a redundant UPDATE observable and fatal, even + // if SQLite would have written unchanged column values. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "CREATE TRIGGER forbid_identical_presence_reoffer + BEFORE UPDATE ON observation_source_presence + WHEN NEW.generation = OLD.generation + AND NEW.start_offset = OLD.start_offset + BEGIN SELECT RAISE(ABORT, 'identical presence reoffer'); END", + (), + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let before = commands(); + harness + .registered + .record_observation_source_presence(&source, &scope, &offered) + .await + .unwrap(); + assert_eq!(commands() - before, 3); + + let mut replacement = offered + .iter() + .map(|record| ObservationSourcePresenceV1 { + observation_id: record.observation_id.clone(), + generation: second, + start_offset: record.start_offset, + }) + .collect::>(); + replacement.last_mut().unwrap().start_offset = u64::MAX; + assert!( + harness + .registered + .record_observation_source_presence(&source, &scope, &replacement) + .await + .is_err(), + "a malformed final-page offset must roll back all preceding pages" + ); + + let key = serde_json::to_string(&(source.clone(), scope.clone())).unwrap(); + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT COUNT(*), SUM(generation = '2') + FROM observation_source_presence WHERE source_key = ?1", + params![key.as_str()], + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), RECORDS as i64); + assert_eq!( + row.get::(1).unwrap(), + 0, + "the failing last page must not partially publish any generation changes" + ); + drop(rows); + drop(snapshot); + + replacement.last_mut().unwrap().start_offset = (RECORDS - 1) as u64; + // A duplicate in the final page must keep the last offer's offset rather + // than the first occurrence in an earlier page. + replacement.push(ObservationSourcePresenceV1 { + observation_id: replacement[0].observation_id.clone(), + generation: second, + start_offset: 10_000, + }); + harness + .registered + .record_observation_source_presence(&source, &scope, &replacement) + .await + .unwrap(); + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT generation, start_offset FROM observation_source_presence + WHERE source_key = ?1 AND observation_id = ?2", + params![key.as_str(), replacement[0].observation_id.as_str()], + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), "2"); + assert_eq!(row.get::(1).unwrap(), 10_000); + assert!(rows.next().await.unwrap().is_none()); +} From 49edb8a613dd97e292d531aae9da8853628676de Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:25:19 -0800 Subject: [PATCH 41/64] perf(sessions): avoid rearming retries that matured mid-recovery Refs #4756 Recheck the clock for each recovery reader page and before the guarded writer update. Deadlines already due for projection no longer acquire empty recovery writes during a long historical catch-up. Add registered- store regressions for partially and fully matured pages with real writer transaction counts. --- .../rearm_tests/restart.rs | 96 +++++++++++++++++++ .../src/observation_projection/state.rs | 15 ++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs index da3972e0a2a..e532a5e7fb2 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs @@ -1,5 +1,9 @@ +use crate::observation_projection::state::{ + RetryRearmWindows, apply_rearm_projection_retry_candidates, +}; use crate::observation_projection::{RearmedProjectionRetries, rearm_queued_projection_retries}; use crate::tests::harness::{RegisteredGlobalDbHarness, writer_telemetry}; +use tracedecay_runtime_core::db::engine::params; use super::{FAR_FUTURE_MICROS, queue_state, seed_queue}; @@ -117,3 +121,95 @@ async fn retry_rearm_resumes_committed_windows_after_failure_and_restart() { .unwrap(); transaction.commit().await.unwrap(); } + +/// A recovery can spend a long time between its original clock read and later +/// candidate pages. Deadlines that matured meanwhile must not be rewritten: +/// they are already eligible for ordinary projection without restart rearm. +#[asupersync::test] +async fn retry_rearm_skips_deadlines_that_mature_during_recovery() { + let harness = RegisteredGlobalDbHarness::open("rearm-midwalk-deadlines").await; + seed_queue(&harness, 4, 0).await; + let database = harness.registered.runtime_database(); + + // Model a recovery that started before deadlines at 1_500_000 micros. + // The actual runtime clock is later; this does not rely on sleeping. + let mut windows = RetryRearmWindows::new(database, 4, 1_000_000).await.unwrap(); + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = 1500000 + WHERE observation_sequence IN (1, 2)", + (), + ) + .await + .unwrap(), + 2 + ); + transaction.commit().await.unwrap(); + + let before = writer_telemetry(&harness.registered).transactions; + let candidates = windows + .next() + .await + .unwrap() + .expect("two still-deferred observations remain"); + let selected: Vec<(i64, String, i64, i64)> = serde_json::from_str(&candidates).unwrap(); + assert_eq!(selected.len(), 2, "matured deadlines need no write window"); + assert!(selected.iter().all(|item| item.2 == FAR_FUTURE_MICROS)); + assert_eq!( + apply_rearm_projection_retry_candidates( + database, + &candidates, + tracedecay_contracts::clock::now_micros().0, + &windows.mount, + ) + .await + .unwrap(), + 2 + ); + assert!(windows.next().await.unwrap().is_none()); + assert_eq!( + retry_deadlines(&harness).await, + vec![(1, 1_500_000), (2, 1_500_000), (3, 0), (4, 0)] + ); + let after = writer_telemetry(&harness.registered).transactions; + assert_eq!(after.commands - before.commands, 1); + + // If *all* rows mature after the recovery cursor was established, + // the reader must finish without even acquiring an empty writer lease. + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = ?1 + WHERE observation_sequence IN (3, 4)", + params![FAR_FUTURE_MICROS], + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + let mut expired = RetryRearmWindows::new(database, 4, 1_000_000).await.unwrap(); + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + transaction + .execute( + "UPDATE projection_queue SET next_retry_at_micros = 1500000 + WHERE observation_sequence IN (3, 4)", + (), + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + let before_idle = writer_telemetry(&harness.registered).transactions; + assert!(expired.next().await.unwrap().is_none()); + assert_eq!(writer_telemetry(&harness.registered).transactions, before_idle); + assert_eq!( + rearm_queued_projection_retries(database, 4).await.unwrap(), + RearmedProjectionRetries::default() + ); + assert_eq!(writer_telemetry(&harness.registered).transactions, before_idle); + assert_eq!( + retry_deadlines(&harness).await, + vec![(1, 1_500_000), (2, 1_500_000), (3, 1_500_000), (4, 1_500_000)] + ); +} diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index d2b67e9e1c0..0da299edef1 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -282,9 +282,13 @@ pub(crate) async fn rearm_queued_projection_retries( let mut windows = RetryRearmWindows::new(database, batch_rows, now).await?; let mut rearmed = RearmedProjectionRetries::default(); while let Some(candidates) = windows.next().await? { - let rows = - apply_rearm_projection_retry_candidates(database, &candidates, now, &windows.mount) - .await?; + let rows = apply_rearm_projection_retry_candidates( + database, + &candidates, + tracedecay_contracts::clock::now_micros().0, + &windows.mount, + ) + .await?; rearmed.rows += rows; if rows != 0 { rearmed.batches += 1; @@ -409,6 +413,9 @@ impl<'a> RetryRearmWindows<'a> { ) .await .map_err(|error| storage(OPERATION, error))?; + // Deadlines can become due while a large history recovery runs. + // Projection can already consume them without any rearm write. + let page_now = tracedecay_contracts::clock::now_micros().0; let mut candidates = Vec::new(); let mut last = None; while let Some(row) = rows @@ -420,7 +427,7 @@ impl<'a> RetryRearmWindows<'a> { let deadline: i64 = row.get(1).map_err(|error| storage(OPERATION, error))?; let observation: Option = row.get(2).map_err(|error| storage(OPERATION, error))?; - if let Some(observation) = observation { + if deadline > page_now && let Some(observation) = observation { let attempt: i64 = row.get(3).map_err(|error| storage(OPERATION, error))?; candidates.push((rowid, observation, deadline, attempt)); } From d494ae8edf34be3033aa8178fa585a364c523764 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:22:55 -0800 Subject: [PATCH 42/64] style(sessions): format source and retry recovery regressions --- .../rearm_tests/restart.rs | 25 +++++++++++++++---- .../src/tests/source_rewrite.rs | 7 +++--- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs index e532a5e7fb2..d56aaea2fea 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests/restart.rs @@ -133,7 +133,9 @@ async fn retry_rearm_skips_deadlines_that_mature_during_recovery() { // Model a recovery that started before deadlines at 1_500_000 micros. // The actual runtime clock is later; this does not rely on sleeping. - let mut windows = RetryRearmWindows::new(database, 4, 1_000_000).await.unwrap(); + let mut windows = RetryRearmWindows::new(database, 4, 1_000_000) + .await + .unwrap(); let transaction = harness.registered.begin_write_transaction().await.unwrap(); assert_eq!( transaction @@ -188,7 +190,9 @@ async fn retry_rearm_skips_deadlines_that_mature_during_recovery() { .await .unwrap(); transaction.commit().await.unwrap(); - let mut expired = RetryRearmWindows::new(database, 4, 1_000_000).await.unwrap(); + let mut expired = RetryRearmWindows::new(database, 4, 1_000_000) + .await + .unwrap(); let transaction = harness.registered.begin_write_transaction().await.unwrap(); transaction .execute( @@ -202,14 +206,25 @@ async fn retry_rearm_skips_deadlines_that_mature_during_recovery() { let before_idle = writer_telemetry(&harness.registered).transactions; assert!(expired.next().await.unwrap().is_none()); - assert_eq!(writer_telemetry(&harness.registered).transactions, before_idle); + assert_eq!( + writer_telemetry(&harness.registered).transactions, + before_idle + ); assert_eq!( rearm_queued_projection_retries(database, 4).await.unwrap(), RearmedProjectionRetries::default() ); - assert_eq!(writer_telemetry(&harness.registered).transactions, before_idle); + assert_eq!( + writer_telemetry(&harness.registered).transactions, + before_idle + ); assert_eq!( retry_deadlines(&harness).await, - vec![(1, 1_500_000), (2, 1_500_000), (3, 1_500_000), (4, 1_500_000)] + vec![ + (1, 1_500_000), + (2, 1_500_000), + (3, 1_500_000), + (4, 1_500_000) + ] ); } diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 53e35862be4..10ccbb6f249 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -329,10 +329,9 @@ async fn rewrite_completion_retires_projected_records_and_rolls_back_a_failed_at async fn source_presence_pages_are_atomic_and_unchanged_reoffers_do_not_write() { const RECORDS: usize = 257; let harness = RegisteredGlobalDbHarness::open("source-presence-batched").await; - let source = ObservationSourceIdentityV1::new( - SessionId::new("session.presence-batched").unwrap(), - ) - .unwrap(); + let source = + ObservationSourceIdentityV1::new(SessionId::new("session.presence-batched").unwrap()) + .unwrap(); let scope = ObservationScopeV1::Profile; let first = ObservationSourceGenerationV1::new(1).unwrap(); let second = ObservationSourceGenerationV1::new(2).unwrap(); From 5aa785428bc5e832f699bfc0e14e179260da6b57 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:39:37 -0800 Subject: [PATCH 43/64] test(sessions): measure SQLite statement pages on the writer --- .../src/tests/source_rewrite.rs | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 10ccbb6f249..d0b29b77f31 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -19,7 +19,7 @@ use tracedecay_store::{ProjectionSkipReason, ProjectionStoreError}; use crate::ObservationSourcePresenceV1; use crate::tests::harness::{ HostAdmissionScope, HostAdmissionTestRuntimeV1, RegisteredGlobalDbHarness, - seed_projected_messages, writer_telemetry, + seed_projected_messages, }; /// More unoffered records than one keyset page *and* more than the engine's @@ -345,18 +345,43 @@ async fn source_presence_pages_are_atomic_and_unchanged_reoffers_do_not_write() start_offset: index as u64, }) .collect::>(); - let commands = || writer_telemetry(&harness.registered).transactions.commands; - let before = commands(); + // SQLite accounts for the outer INSERT's rows when its statement ends. + // Subtract the probe's own writes so all rows from one statement share + // a marker, even though their AFTER INSERT trigger fires individually. + let setup = harness.registered.begin_write_transaction().await.unwrap(); + setup + .execute_batch( + "CREATE TABLE presence_page_probe (prior_changes INTEGER NOT NULL); + CREATE TRIGGER observe_presence_page AFTER INSERT ON observation_source_presence + BEGIN + INSERT INTO presence_page_probe VALUES ( + total_changes() - (SELECT COALESCE(MAX(rowid), 0) FROM presence_page_probe) + ); + END;", + ) + .await + .unwrap(); + setup.commit().await.unwrap(); harness .registered .record_observation_source_presence(&source, &scope, &offered) .await .unwrap(); - assert_eq!( - commands() - before, - 3, - "257 real presence records must need three bounded writer statements, not 257" - ); + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT COUNT(*) FROM presence_page_probe GROUP BY prior_changes ORDER BY prior_changes", + (), + ) + .await + .unwrap(); + let mut pages = Vec::new(); + while let Some(row) = rows.next().await.unwrap() { + pages.push(row.get::(0).unwrap()); + } + assert_eq!(pages, [128, 128, 1]); + drop(rows); + drop(snapshot); // A SQLite trigger makes a redundant UPDATE observable and fatal, even // if SQLite would have written unchanged column values. @@ -373,13 +398,11 @@ async fn source_presence_pages_are_atomic_and_unchanged_reoffers_do_not_write() .await .unwrap(); transaction.commit().await.unwrap(); - let before = commands(); harness .registered .record_observation_source_presence(&source, &scope, &offered) .await .unwrap(); - assert_eq!(commands() - before, 3); let mut replacement = offered .iter() From 89bd943319e23de269b5c7e80302ab03f7453185 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:50:46 -0800 Subject: [PATCH 44/64] style(sessions): format the matured retry guard --- .../tracedecay-global-db/src/observation_projection/state.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/state.rs b/crates/tracedecay-global-db/src/observation_projection/state.rs index 0da299edef1..520f7cd5d1f 100644 --- a/crates/tracedecay-global-db/src/observation_projection/state.rs +++ b/crates/tracedecay-global-db/src/observation_projection/state.rs @@ -427,7 +427,9 @@ impl<'a> RetryRearmWindows<'a> { let deadline: i64 = row.get(1).map_err(|error| storage(OPERATION, error))?; let observation: Option = row.get(2).map_err(|error| storage(OPERATION, error))?; - if deadline > page_now && let Some(observation) = observation { + if deadline > page_now + && let Some(observation) = observation + { let attempt: i64 = row.get(3).map_err(|error| storage(OPERATION, error))?; candidates.push((rowid, observation, deadline, attempt)); } From c3ddf7348c77baefb473415b192658a09cafffdd Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:24:43 -0800 Subject: [PATCH 45/64] fix(storage): count prepared reads and writes inside SQL transactions --- .../tracedecay-global-db/src/tests/source_rewrite.rs | 12 +++++++++++- crates/tracedecay-rusqlite-runtime/src/telemetry.rs | 3 +++ .../src/telemetry/reader.rs | 6 ++++++ .../src/telemetry/sqlite_vm.rs | 5 +++++ .../src/telemetry/tests.rs | 2 ++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index d0b29b77f31..cfd3a4ec823 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -19,7 +19,7 @@ use tracedecay_store::{ProjectionSkipReason, ProjectionStoreError}; use crate::ObservationSourcePresenceV1; use crate::tests::harness::{ HostAdmissionScope, HostAdmissionTestRuntimeV1, RegisteredGlobalDbHarness, - seed_projected_messages, + seed_projected_messages, writer_telemetry, }; /// More unoffered records than one keyset page *and* more than the engine's @@ -362,11 +362,19 @@ async fn source_presence_pages_are_atomic_and_unchanged_reoffers_do_not_write() .await .unwrap(); setup.commit().await.unwrap(); + let statements = || writer_telemetry(&harness.registered).sqlite_vm.statements; + // Measure the registered writer's own transaction bookkeeping on this shard. + let before_empty = statements(); + let empty = harness.registered.begin_write_transaction().await.unwrap(); + empty.commit().await.unwrap(); + let transaction_overhead = statements() - before_empty; + let before = statements(); harness .registered .record_observation_source_presence(&source, &scope, &offered) .await .unwrap(); + assert_eq!(statements() - before, transaction_overhead + 3); let snapshot = harness.registered.read_snapshot().await.unwrap(); let mut rows = snapshot .query( @@ -398,11 +406,13 @@ async fn source_presence_pages_are_atomic_and_unchanged_reoffers_do_not_write() .await .unwrap(); transaction.commit().await.unwrap(); + let before = statements(); harness .registered .record_observation_source_presence(&source, &scope, &offered) .await .unwrap(); + assert_eq!(statements() - before, transaction_overhead + 3); let mut replacement = offered .iter() diff --git a/crates/tracedecay-rusqlite-runtime/src/telemetry.rs b/crates/tracedecay-rusqlite-runtime/src/telemetry.rs index ef445b5eb5d..0e54152b167 100644 --- a/crates/tracedecay-rusqlite-runtime/src/telemetry.rs +++ b/crates/tracedecay-rusqlite-runtime/src/telemetry.rs @@ -134,6 +134,8 @@ pub struct WriterTransactionMetrics { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct SqliteVmSnapshot { + /// Completed prepared executions observed by the runtime, including reads. + pub statements: u64, pub fullscan_steps: u64, pub sort_steps: u64, pub vm_steps: u64, @@ -142,6 +144,7 @@ pub struct SqliteVmSnapshot { impl SqliteVmSnapshot { pub(crate) fn saturating_add(self, other: Self) -> Self { Self { + statements: self.statements.saturating_add(other.statements), fullscan_steps: self.fullscan_steps.saturating_add(other.fullscan_steps), sort_steps: self.sort_steps.saturating_add(other.sort_steps), vm_steps: self.vm_steps.saturating_add(other.vm_steps), diff --git a/crates/tracedecay-rusqlite-runtime/src/telemetry/reader.rs b/crates/tracedecay-rusqlite-runtime/src/telemetry/reader.rs index 6d3efba5ebc..a210d1a95cd 100644 --- a/crates/tracedecay-rusqlite-runtime/src/telemetry/reader.rs +++ b/crates/tracedecay-rusqlite-runtime/src/telemetry/reader.rs @@ -16,6 +16,7 @@ pub(crate) struct ReaderAdmissionRecorder { release_events: AtomicU64, wait_micros: AtomicU64, execution_micros: AtomicU64, + statements: AtomicU64, fullscan_steps: AtomicU64, sort_steps: AtomicU64, vm_steps: AtomicU64, @@ -51,6 +52,8 @@ impl ReaderAdmissionRecorder { pub(crate) fn executed(&self, elapsed: Duration, sqlite_vm: SqliteVmSnapshot) { self.execution_micros .fetch_add(duration_micros(elapsed), Ordering::Relaxed); + self.statements + .fetch_add(sqlite_vm.statements, Ordering::Relaxed); self.fullscan_steps .fetch_add(sqlite_vm.fullscan_steps, Ordering::Relaxed); self.sort_steps @@ -69,6 +72,7 @@ impl ReaderAdmissionRecorder { wait_micros: self.wait_micros.load(Ordering::Relaxed), execution_micros: self.execution_micros.load(Ordering::Relaxed), sqlite_vm: SqliteVmSnapshot { + statements: self.statements.load(Ordering::Relaxed), fullscan_steps: self.fullscan_steps.load(Ordering::Relaxed), sort_steps: self.sort_steps.load(Ordering::Relaxed), vm_steps: self.vm_steps.load(Ordering::Relaxed), @@ -91,6 +95,7 @@ mod tests { recorder.executed( Duration::from_micros(11), SqliteVmSnapshot { + statements: 1, fullscan_steps: 2, sort_steps: 1, vm_steps: 9, @@ -104,6 +109,7 @@ mod tests { assert_eq!(snapshot.release_events, 1); assert_eq!(snapshot.wait_micros, 10); assert_eq!(snapshot.execution_micros, 11); + assert_eq!(snapshot.sqlite_vm.statements, 1); assert_eq!(snapshot.sqlite_vm.fullscan_steps, 2); assert_eq!(snapshot.sqlite_vm.sort_steps, 1); assert_eq!(snapshot.sqlite_vm.vm_steps, 9); diff --git a/crates/tracedecay-rusqlite-runtime/src/telemetry/sqlite_vm.rs b/crates/tracedecay-rusqlite-runtime/src/telemetry/sqlite_vm.rs index 5600f0d0890..90fc2641d3c 100644 --- a/crates/tracedecay-rusqlite-runtime/src/telemetry/sqlite_vm.rs +++ b/crates/tracedecay-rusqlite-runtime/src/telemetry/sqlite_vm.rs @@ -10,6 +10,7 @@ use super::SqliteVmSnapshot; thread_local! { static OBSERVED: Cell = const { Cell::new(SqliteVmSnapshot { + statements: 0, fullscan_steps: 0, sort_steps: 0, vm_steps: 0, @@ -18,6 +19,9 @@ thread_local! { pub(crate) fn observe_statement(statement: &Statement<'_>) { let delta = SqliteVmSnapshot { + // SQLITE_STMTSTATUS_RUN counts trigger subprograms as additional runs. + // This boundary observes the caller's prepared statement once. + statements: 1, fullscan_steps: take_status(statement, StatementStatus::FullscanStep), sort_steps: take_status(statement, StatementStatus::Sort), vm_steps: take_status(statement, StatementStatus::VmStep), @@ -64,6 +68,7 @@ mod tests { assert_eq!(count, 2); observe_statement(&statement); let snapshot = take_observed_vm(); + assert_eq!(snapshot.statements, 1); assert!( snapshot.fullscan_steps > 0, "unindexed table scan must increment fullscan" diff --git a/crates/tracedecay-rusqlite-runtime/src/telemetry/tests.rs b/crates/tracedecay-rusqlite-runtime/src/telemetry/tests.rs index 190ff459462..cbdb1de3424 100644 --- a/crates/tracedecay-rusqlite-runtime/src/telemetry/tests.rs +++ b/crates/tracedecay-rusqlite-runtime/src/telemetry/tests.rs @@ -72,6 +72,7 @@ fn transaction_close_records_commands_rows_lock_and_outcomes() { lock_held_micros: 4, transaction_micros: 9, sqlite_vm: SqliteVmSnapshot { + statements: 2, fullscan_steps: 1, sort_steps: 2, vm_steps: 6, @@ -111,6 +112,7 @@ fn transaction_close_records_commands_rows_lock_and_outcomes() { snapshot.transactions.lock_held_micros <= snapshot.transactions.transaction_micros, "lock-held time cannot exceed total transaction time" ); + assert_eq!(snapshot.sqlite_vm.statements, 2); assert_eq!(snapshot.sqlite_vm.fullscan_steps, 1); assert_eq!(snapshot.sqlite_vm.sort_steps, 2); assert_eq!(snapshot.sqlite_vm.vm_steps, 6); From ddbcad6762f8cda6fff4583fafa1f83cacc01c7d Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:33:51 -0800 Subject: [PATCH 46/64] perf(sessions): scope rewrite queue updates to source records Refs #4756 Use the canonical source-presence primary key as the driving side of rewrite revival updates. Avoid an O(all project/profile queue rows) writer scan for each source generation transition. Preserve the exact generation and starting-offset fences, with a registered-store queue-scale regression. The new regression requires nonzero Bazel Rust verification before merge. --- .../src/observation_projection/rearm_tests.rs | 84 +++++++++++++++++++ .../src/registered_source_rewrite.rs | 16 ++-- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index eaec213ef35..a23e3a859a9 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -939,3 +939,87 @@ async fn daemon_admission_defers_rearm_to_background_convergence() { "background convergence re-arms the fresh mount's queue" ); } + +// A rewrite for one source must not visit the entire projection queue while +// holding the SQLite writer. The real queue grows with all imported sources, +// but only records offered by this source may have their revival fence moved. +#[asupersync::test] +async fn source_rewrite_begin_writer_work_is_independent_of_unrelated_queue_rows() { + async fn writer_steps(queue_rows: i64, label: &'static str) -> u64 { + let harness = RegisteredGlobalDbHarness::open(label).await; + seed_queue(&harness, queue_rows, 0).await; + let (observation, _) = queued_observation(0); + let source = observation.source().clone(); + let scope = observation.scope().clone(); + let previous = observation.identity().generation(); + let successor = ObservationSourceGenerationV1::new(2).unwrap(); + + harness + .registered + .record_observation_source_presence( + &source, + &scope, + &[crate::ObservationSourcePresenceV1 { + observation_id: observation.observation_id().clone(), + generation: previous, + start_offset: 0, + }], + ) + .await + .unwrap(); + let transaction = harness.registered.begin_write_transaction().await.unwrap(); + assert_eq!( + transaction + .execute( + "UPDATE projection_queue SET revival_generation = ?1, + revival_start_offset = 0 WHERE observation_id = ?2", + params![ + previous.generation_id().to_string(), + observation.observation_id().as_str() + ], + ) + .await + .unwrap(), + 1 + ); + transaction.commit().await.unwrap(); + + let before = writer_telemetry(&harness.registered).sqlite_vm.vm_steps; + harness + .registered + .begin_observation_source_rewrite(&source, &scope, previous, successor, 1) + .await + .unwrap(); + let after = writer_telemetry(&harness.registered).sqlite_vm.vm_steps; + + let snapshot = harness.registered.read_snapshot().await.unwrap(); + let mut rows = snapshot + .query( + "SELECT observation_id, revival_generation, revival_start_offset + FROM projection_queue WHERE revival_generation IS NOT NULL", + (), + ) + .await + .unwrap(); + let row = rows.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), observation.observation_id().as_str()); + assert_eq!( + row.get::(1).unwrap(), + successor.generation_id().to_string() + ); + assert_eq!(row.get::(2).unwrap(), 0); + assert!( + rows.next().await.unwrap().is_none(), + "unrelated queue rows must retain their original null revival state" + ); + after - before + } + + let small = writer_steps(32, "source-rewrite-sparse-32").await; + let large = writer_steps(256, "source-rewrite-sparse-256").await; + assert!(small > 0); + assert!( + large < small * 2, + "an eightfold larger unrelated queue must not amplify writer work: {small} -> {large}" + ); +} diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 3081d33b736..dfd11051956 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -180,15 +180,13 @@ impl RegisteredGlobalDb { transaction .execute( "UPDATE projection_queue SET revival_generation = ?2 - WHERE EXISTS ( - SELECT 1 FROM observation_source_presence AS presence - WHERE presence.source_key = ?1 - AND presence.observation_id = projection_queue.observation_id - AND presence.start_offset < ?3 - AND presence.generation IN (?4, ?5) - AND presence.generation = projection_queue.revival_generation - AND presence.start_offset = projection_queue.revival_start_offset - )", + FROM observation_source_presence AS presence + WHERE presence.source_key = ?1 + AND presence.observation_id = projection_queue.observation_id + AND presence.start_offset < ?3 + AND presence.generation IN (?4, ?5) + AND presence.generation = projection_queue.revival_generation + AND presence.start_offset = projection_queue.revival_start_offset", params![ key.as_str(), generation.as_str(), From 3e6aa1f5cd6aaefd90de30ec081720aa34659242 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:21:39 -0800 Subject: [PATCH 47/64] style(sessions): format the rewrite queue regression --- .../src/observation_projection/rearm_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index a23e3a859a9..d1c1911d4a7 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -1002,7 +1002,10 @@ async fn source_rewrite_begin_writer_work_is_independent_of_unrelated_queue_rows .await .unwrap(); let row = rows.next().await.unwrap().unwrap(); - assert_eq!(row.get::(0).unwrap(), observation.observation_id().as_str()); + assert_eq!( + row.get::(0).unwrap(), + observation.observation_id().as_str() + ); assert_eq!( row.get::(1).unwrap(), successor.generation_id().to_string() From 81e71e5c4c3e41e59cc65f25faa5b704252b4530 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:50:16 -0800 Subject: [PATCH 48/64] perf(sessions): skip settled source rewrite retirements Refs #4756 When an old source presence row is already disposed and has no accepted queue revival, completing another rewrite used to issue UPDATE/SELECT work for that row even though its projection cannot change. Filter those rows through the existing disposition and queue authorities while preserving live retirements, pending revival cancellation, keyset order, and atomic cleanup. Add a registered-store behavioral regression comparing exact writer command counts with 8 and 64 genuinely retired records. Bazel Rust execution and incident-scale latency remain unverified. --- .../src/registered_source_rewrite.rs | 46 +++++--- .../src/tests/source_rewrite.rs | 109 ++++++++++++++++++ 2 files changed, 142 insertions(+), 13 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index dfd11051956..005cdda7ecf 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -14,7 +14,9 @@ use tracedecay_domain::{ ObservationSourceIdentityV1, }; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, params}; -use tracedecay_store::{ProjectionStoreError, ProjectionStoreResult}; +use tracedecay_store::{ + ProjectionStoreError, ProjectionStoreResult, SESSION_MESSAGE_PROJECTOR_VERSION, +}; use super::RegisteredGlobalDb; use super::observation_projection::retire_source_record; @@ -263,27 +265,45 @@ impl RegisteredGlobalDb { return Err(JobExit::Rollback(0)); } let mut retired = 0_u64; - // Keyset-paginate the unoffered ids instead of materializing the whole - // set in one statement: a single rewritten source can hold more - // unoffered records than the engine's per-query materialization cap, - // and one oversized read wedged every completion attempt forever. - // `retire_source_record` never touches `observation_source_presence`, - // so the ordered id cursor stays stable across pages; the settle - // deletes below remain the authoritative cleanup. + // Paginate only unoffered records that still require a + // retirement or have an accepted revival to cancel. An + // already-disposed record with no pending revival cannot + // produce another output change; invoking retirement for it + // issued redundant UPDATE/SELECT commands under the writer. + // Missing observations are still visited, preserving the + // unoffered keyset coverage test. The final DELETE removes + // every old presence row, including those skipped here. + // Retirement never mutates source presence, so the keyset is + // stable through the atomic transaction. let mut after_observation_id = String::new(); loop { let mut rows = transaction .query( - "SELECT observation_id FROM observation_source_presence - WHERE source_key = ?1 AND generation <> ?2 - AND observation_id > ?3 - ORDER BY observation_id + "SELECT presence.observation_id + FROM observation_source_presence AS presence + WHERE presence.source_key = ?1 AND presence.generation <> ?2 + AND presence.observation_id > ?3 + AND ( + NOT EXISTS ( + SELECT 1 FROM observation_projection_dispositions AS disposition + WHERE disposition.projector_version = ?5 + AND disposition.observation_id = presence.observation_id + ) + OR EXISTS ( + SELECT 1 FROM projection_queue AS queued + WHERE queued.observation_id = presence.observation_id + AND (queued.revival_generation IS NOT NULL + OR queued.revival_start_offset IS NOT NULL) + ) + ) + ORDER BY presence.observation_id LIMIT ?4", params![ key.as_str(), generation.as_str(), after_observation_id.as_str(), - UNOFFERED_RECORD_PAGE_SIZE + UNOFFERED_RECORD_PAGE_SIZE, + SESSION_MESSAGE_PROJECTOR_VERSION ], ) .await diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index cfd3a4ec823..40c19a4d167 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -479,3 +479,112 @@ async fn source_presence_pages_are_atomic_and_unchanged_reoffers_do_not_write() assert_eq!(row.get::(1).unwrap(), 10_000); assert!(rows.next().await.unwrap().is_none()); } + +/// Once retirement has committed, a reoffered source can still carry old +/// presence entries. Repeating their retirement must not perform an UPDATE +/// and receipt read for every already-disposed observation. This exercises +/// real projected records and the canonical disposition/queue authorities. +#[asupersync::test] +async fn rewrite_completion_skips_settled_records_without_revival() { + async fn completed_writer_statements(records: usize) -> u64 { + let directory = TempDir::new().unwrap(); + let runtime = HostAdmissionTestRuntimeV1::profile(directory.path()) + .await + .unwrap(); + let registered = runtime + .registered_database(HostAdmissionScope::Profile) + .expect("registered profile database"); + let seeded = seed_projected_messages(&runtime, 0..records).await; + let source = ObservationSourceIdentityV1::for_provider( + ProviderId::new("codex").unwrap(), + SessionId::new("session.settled-rewrite").unwrap(), + ) + .unwrap(); + let scope = ObservationScopeV1::Profile; + let first = ObservationSourceGenerationV1::new(1).unwrap(); + let second = ObservationSourceGenerationV1::new(2).unwrap(); + let third = ObservationSourceGenerationV1::new(3).unwrap(); + let offered = seeded + .iter() + .map(|(observation, _)| ObservationSourcePresenceV1 { + observation_id: observation.observation_id().clone(), + generation: first, + start_offset: observation.identity().position().start(), + }) + .collect::>(); + registered + .record_observation_source_presence(&source, &scope, &offered) + .await + .unwrap(); + registered + .begin_observation_source_rewrite(&source, &scope, first, second, 0) + .await + .unwrap(); + assert_eq!( + registered + .complete_observation_source_rewrite(&source, &scope, second) + .await + .unwrap(), + records as u64 + ); + + let replay = offered + .iter() + .map(|record| ObservationSourcePresenceV1 { + observation_id: record.observation_id.clone(), + generation: second, + start_offset: record.start_offset, + }) + .collect::>(); + registered + .record_observation_source_presence(&source, &scope, &replay) + .await + .unwrap(); + registered + .begin_observation_source_rewrite(&source, &scope, second, third, 0) + .await + .unwrap(); + + let statements = || writer_telemetry(®istered).sqlite_vm.statements; + let before = statements(); + assert_eq!( + registered + .complete_observation_source_rewrite(&source, &scope, third) + .await + .unwrap(), + 0, + "a previously retired observation must not be retired twice" + ); + let writer_commands = statements() - before; + let snapshot = registered.read_snapshot().await.unwrap(); + let key = serde_json::to_string(&(source.clone(), scope.clone())).unwrap(); + let mut remaining = snapshot + .query( + "SELECT COUNT(*) FROM observation_source_presence WHERE source_key = ?1", + params![key.as_str()], + ) + .await + .unwrap(); + let row = remaining.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), 0); + drop(remaining); + let mut pending = snapshot + .query( + "SELECT COUNT(*) FROM observation_source_rewrites WHERE source_key = ?1", + params![key.as_str()], + ) + .await + .unwrap(); + let row = pending.next().await.unwrap().unwrap(); + assert_eq!(row.get::(0).unwrap(), 0); + writer_commands + } + + let small = completed_writer_statements(8).await; + let large = completed_writer_statements(64).await; + assert!(small > 0); + assert_eq!( + large, small, + "eightfold more settled records must not create per-record writer commands" + ); +} From 6aeabb84fba7df60c1320864beee795a02b34a2b Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:53:42 -0800 Subject: [PATCH 49/64] test(sessions): preserve queued revivals during rewrite cleanup Refs #4756 Extend the settled-source regression with a real queued accepted revival. An already-disposed observation must still have its queued revival cleared, while a large settled prefix must not submit one writer operation per record. Retain the existing atomic cleanup checks. Native Bazel regression has not yet executed. --- .../src/tests/source_rewrite.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 40c19a4d167..f3bb03feed8 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -545,6 +545,25 @@ async fn rewrite_completion_skips_settled_records_without_revival() { .await .unwrap(); + // A settled observation can still have a newly accepted revival. + // The optimization must clear this queued offer before skipping the + // retired disposition; otherwise projection can resurrect old output. + let writer = registered.writer_connection().unwrap(); + writer + .execute( + "INSERT INTO projection_queue ( + observation_id, observation_sequence, revival_generation, revival_start_offset + ) + SELECT observation_id, sequence, '2', 0 + FROM observations WHERE observation_id = ?1 + ON CONFLICT(observation_id) DO UPDATE SET + revival_generation = excluded.revival_generation, + revival_start_offset = excluded.revival_start_offset", + params![seeded[0].0.observation_id().as_str()], + ) + .await + .unwrap(); + let statements = || writer_telemetry(®istered).sqlite_vm.statements; let before = statements(); assert_eq!( @@ -577,6 +596,18 @@ async fn rewrite_completion_skips_settled_records_without_revival() { .unwrap(); let row = pending.next().await.unwrap().unwrap(); assert_eq!(row.get::(0).unwrap(), 0); + drop(pending); + let mut revival = snapshot + .query( + "SELECT revival_generation, revival_start_offset + FROM projection_queue WHERE observation_id = ?1", + params![seeded[0].0.observation_id().as_str()], + ) + .await + .unwrap(); + let row = revival.next().await.unwrap().unwrap(); + assert_eq!(row.get::>(0).unwrap(), None); + assert_eq!(row.get::>(1).unwrap(), None); writer_commands } From 50d28225751fb197e328bfb6cd4181d5110577a7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:35:03 -0800 Subject: [PATCH 50/64] perf(sessions): skip absent source-record retirement work Refs #4756 Skip per-record UPDATE and receipt reads for obsolete source-presence rows with neither a materialized observation nor an accepted queued revival. Keep the existing queued-revival path and atomic source-rewrite settlement; bound writer statements even when a stale source has thousands of presence-only records. Strengthen the registered-store >10k-row regression with writer telemetry; no existing behavioral assertions removed. --- .../src/registered_source_rewrite.rs | 22 +++++++++++++------ .../src/tests/source_rewrite.rs | 15 ++++++++----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 005cdda7ecf..3c9619ff23e 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -270,9 +270,11 @@ impl RegisteredGlobalDb { // already-disposed record with no pending revival cannot // produce another output change; invoking retirement for it // issued redundant UPDATE/SELECT commands under the writer. - // Missing observations are still visited, preserving the - // unoffered keyset coverage test. The final DELETE removes - // every old presence row, including those skipped here. + // Presence-only ghosts without an observation or queued + // revival have nothing to retire: skip their per-row writer + // UPDATE/receipt read. A queued accepted revival remains + // eligible even if the observation is absent. The final + // DELETE still settles every stale presence row. // Retirement never mutates source presence, so the keyset is // stable through the atomic transaction. let mut after_observation_id = String::new(); @@ -284,10 +286,16 @@ impl RegisteredGlobalDb { WHERE presence.source_key = ?1 AND presence.generation <> ?2 AND presence.observation_id > ?3 AND ( - NOT EXISTS ( - SELECT 1 FROM observation_projection_dispositions AS disposition - WHERE disposition.projector_version = ?5 - AND disposition.observation_id = presence.observation_id + ( + EXISTS ( + SELECT 1 FROM observations AS observation + WHERE observation.observation_id = presence.observation_id + ) + AND NOT EXISTS ( + SELECT 1 FROM observation_projection_dispositions AS disposition + WHERE disposition.projector_version = ?5 + AND disposition.observation_id = presence.observation_id + ) ) OR EXISTS ( SELECT 1 FROM projection_queue AS queued diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index f3bb03feed8..dc4db5e98f7 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -60,17 +60,22 @@ async fn rewrite_completion_retires_unoffered_records_beyond_the_query_cap() { .await .unwrap(); - // None of the seeded records are re-offered under the new generation, so - // completion must retire-path all of them. The `observations` table is - // empty, so each retire is a no-op returning false; what matters is that - // the reads stay under the materialization cap and the settle deletes - // still clear the source. + // These are presence-only ghosts: no observations, dispositions, or + // queued revivals exist to retire. The final settle must still delete + // all presence rows, without issuing per-ghost UPDATE/receipt queries + // while holding the SQLite writer's fixed transaction lease. + let before = writer_telemetry(&harness.registered).sqlite_vm.statements; let retired = harness .registered .complete_observation_source_rewrite(&source, &scope, generation) .await .unwrap_or_else(|error| panic!("rewrite completion must not exceed query caps: {error}")); assert_eq!(retired, 0); + let statements = writer_telemetry(&harness.registered).sqlite_vm.statements - before; + assert!( + statements.saturating_mul(16) < UNOFFERED_RECORDS as u64, + "presence-only ghosts must not cause per-record writer commands: {statements} statements for {UNOFFERED_RECORDS} records" + ); let mut remaining = writer .query( From 71256708412f5bf815311ce24b3d2b1d70bb8664 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:00:56 -0800 Subject: [PATCH 51/64] fix(sessions): resume source retirement in bounded writer pages Refs #4756 Commit each 128-record retirement page and delete its stale presence entries in the same writer transaction. An interrupted completion retains the remaining presence rows as its durable continuation frontier. Exercise real projected records, injected provenance failure, registered store reopen, and bounded transaction counts for presence-only history. --- .../src/registered_source_rewrite.rs | 229 ++++++++++-------- .../src/tests/source_rewrite.rs | 104 +++++--- 2 files changed, 188 insertions(+), 145 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 3c9619ff23e..28f840b44c4 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -22,11 +22,9 @@ use super::RegisteredGlobalDb; use super::observation_projection::retire_source_record; use super::registered::JobExit; -/// Unoffered-record reads are keyset-paginated at this page size. The exact- -/// SQL engine refuses a query that materializes more rows than its hard cap, -/// and one long host session can hold more unoffered records than that cap, -/// which made rewrite completion fail deterministically for those sources. -const UNOFFERED_RECORD_PAGE_SIZE: i64 = 1_000; +/// Each committed page settles at most this many source-presence records, +/// releasing the writer lease and making restart continuation durable. +const UNOFFERED_RECORD_PAGE_SIZE: i64 = 128; /// One page is small enough for the SQLite JSON virtual table and writer /// deadline. The entire supplied presence batch still commits atomically. @@ -256,107 +254,126 @@ impl RegisteredGlobalDb { return Ok(0); } drop(snapshot); - self.transact_or_rollback( - |error| storage("run source rewrite completion", error), - async move |transaction| { - if pending_rewrite(&transaction, &key).await?.as_deref() - != Some(generation.as_str()) - { - return Err(JobExit::Rollback(0)); - } - let mut retired = 0_u64; - // Paginate only unoffered records that still require a - // retirement or have an accepted revival to cancel. An - // already-disposed record with no pending revival cannot - // produce another output change; invoking retirement for it - // issued redundant UPDATE/SELECT commands under the writer. - // Presence-only ghosts without an observation or queued - // revival have nothing to retire: skip their per-row writer - // UPDATE/receipt read. A queued accepted revival remains - // eligible even if the observation is absent. The final - // DELETE still settles every stale presence row. - // Retirement never mutates source presence, so the keyset is - // stable through the atomic transaction. - let mut after_observation_id = String::new(); - loop { - let mut rows = transaction - .query( - "SELECT presence.observation_id - FROM observation_source_presence AS presence - WHERE presence.source_key = ?1 AND presence.generation <> ?2 - AND presence.observation_id > ?3 - AND ( - ( - EXISTS ( - SELECT 1 FROM observations AS observation - WHERE observation.observation_id = presence.observation_id - ) - AND NOT EXISTS ( - SELECT 1 FROM observation_projection_dispositions AS disposition - WHERE disposition.projector_version = ?5 - AND disposition.observation_id = presence.observation_id - ) - ) - OR EXISTS ( - SELECT 1 FROM projection_queue AS queued - WHERE queued.observation_id = presence.observation_id - AND (queued.revival_generation IS NOT NULL - OR queued.revival_start_offset IS NOT NULL) - ) - ) - ORDER BY presence.observation_id - LIMIT ?4", - params![ - key.as_str(), - generation.as_str(), - after_observation_id.as_str(), - UNOFFERED_RECORD_PAGE_SIZE, - SESSION_MESSAGE_PROJECTOR_VERSION - ], - ) - .await - .map_err(|error| storage("read unoffered source records", error))?; - let mut page = Vec::new(); - while let Some(row) = rows - .next() - .await - .map_err(|error| storage("read unoffered source records", error))? - { - page.push( - row.get::(0) - .map_err(|error| storage("read unoffered source records", error))?, - ); - } - drop(rows); - if page.is_empty() { - break; - } - let page_len = page.len(); - after_observation_id = page[page_len - 1].clone(); - for observation_id in &page { - let retired_one: Pin< - Box> + Send + '_>, - > = Box::pin(retire_source_record(&transaction, observation_id)); - if retired_one.await? { - retired += 1; + let mut total_retired = 0_u64; + loop { + // The remaining older-generation presence entries are the durable + // continuation frontier. Each page commits its retirements and + // deletes only the entries it has finished processing; a failed + // page rolls back without repeating earlier committed pages. + let key = key.clone(); + let generation = generation.clone(); + let (retired, finished) = self + .transact_or_rollback( + |error| storage("run source rewrite completion page", error), + async move |transaction| { + if pending_rewrite(&transaction, &key).await?.as_deref() + != Some(generation.as_str()) + { + return Err(JobExit::Rollback((0_u64, true))); } - } - if (page_len as i64) < UNOFFERED_RECORD_PAGE_SIZE { - break; - } - } - for sql in [ - "DELETE FROM observation_source_presence WHERE source_key = ?1 AND generation <> ?2", - "DELETE FROM observation_source_rewrites WHERE source_key = ?1 AND generation = ?2", - ] { - transaction - .execute(sql, params![key.as_str(), generation.as_str()]) - .await - .map_err(|error| storage("settle source rewrite", error))?; - } - Ok(retired) - }, - ) - .await + let mut rows = transaction + .query( + "SELECT presence.observation_id, + ( + ( + EXISTS ( + SELECT 1 FROM observations AS observation + WHERE observation.observation_id = presence.observation_id + ) + AND NOT EXISTS ( + SELECT 1 FROM observation_projection_dispositions AS disposition + WHERE disposition.projector_version = ?4 + AND disposition.observation_id = presence.observation_id + ) + ) + OR EXISTS ( + SELECT 1 FROM projection_queue AS queued + WHERE queued.observation_id = presence.observation_id + AND (queued.revival_generation IS NOT NULL + OR queued.revival_start_offset IS NOT NULL) + ) + ) + FROM observation_source_presence AS presence + WHERE presence.source_key = ?1 AND presence.generation <> ?2 + ORDER BY presence.observation_id + LIMIT ?3", + params![ + key.as_str(), + generation.as_str(), + UNOFFERED_RECORD_PAGE_SIZE, + SESSION_MESSAGE_PROJECTOR_VERSION, + ], + ) + .await + .map_err(|error| storage("read unoffered source records", error))?; + let mut page = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|error| storage("read unoffered source records", error))? + { + page.push(( + row.get::(0).map_err(|error| { + storage("read unoffered source records", error) + })?, + row.get::(1).map_err(|error| { + storage("read unoffered source records", error) + })? != 0, + )); + } + drop(rows); + if page.is_empty() { + transaction + .execute( + "DELETE FROM observation_source_rewrites + WHERE source_key = ?1 AND generation = ?2", + params![key.as_str(), generation.as_str()], + ) + .await + .map_err(|error| storage("settle source rewrite", error))?; + return Ok((0, true)); + } + let mut retired = 0_u64; + for (observation_id, needs_retirement) in &page { + if !needs_retirement { + continue; + } + let retired_one: Pin< + Box> + Send + '_>, + > = Box::pin(retire_source_record(&transaction, observation_id)); + if retired_one.await? { + retired += 1; + } + } + let last_id = &page[page.len() - 1].0; + transaction + .execute( + "DELETE FROM observation_source_presence + WHERE source_key = ?1 AND generation <> ?2 + AND observation_id <= ?3", + params![key.as_str(), generation.as_str(), last_id.as_str()], + ) + .await + .map_err(|error| storage("settle source rewrite page", error))?; + let finished = (page.len() as i64) < UNOFFERED_RECORD_PAGE_SIZE; + if finished { + transaction + .execute( + "DELETE FROM observation_source_rewrites + WHERE source_key = ?1 AND generation = ?2", + params![key.as_str(), generation.as_str()], + ) + .await + .map_err(|error| storage("settle source rewrite", error))?; + } + Ok((retired, finished)) + }, + ) + .await?; + total_retired += retired; + if finished { + return Ok(total_retired); + } + } } } diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index dc4db5e98f7..7e3227a788d 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -3,9 +3,8 @@ //! Rewrite completion retires every record the rewritten source no longer //! offers. One long host session can hold more unoffered records than the //! exact-SQL engine's per-query materialization cap, so completion must read -//! them in keyset pages rather than one unbounded statement; the unbounded -//! read failed deterministically for exactly those sources and wedged the -//! host catch-up in a retry loop that never converged. +//! them in bounded writer pages. Each committed page must persist enough +//! progress that a restart does not repeat its already-retired records. use tempfile::TempDir; @@ -24,7 +23,7 @@ use crate::tests::harness::{ /// More unoffered records than one keyset page *and* more than the engine's /// per-query materialization cap, so the test fails against the unbounded -/// read and exercises repeated pagination against the fix. +/// read and exercises repeated, independently committed writer pages. const UNOFFERED_RECORDS: i64 = 10_003; #[asupersync::test] @@ -65,6 +64,7 @@ async fn rewrite_completion_retires_unoffered_records_beyond_the_query_cap() { // all presence rows, without issuing per-ghost UPDATE/receipt queries // while holding the SQLite writer's fixed transaction lease. let before = writer_telemetry(&harness.registered).sqlite_vm.statements; + let before_commits = writer_telemetry(&harness.registered).transactions.committed_transactions; let retired = harness .registered .complete_observation_source_rewrite(&source, &scope, generation) @@ -72,6 +72,13 @@ async fn rewrite_completion_retires_unoffered_records_beyond_the_query_cap() { .unwrap_or_else(|error| panic!("rewrite completion must not exceed query caps: {error}")); assert_eq!(retired, 0); let statements = writer_telemetry(&harness.registered).sqlite_vm.statements - before; + let after = writer_telemetry(&harness.registered); + let commits = after.transactions.committed_transactions - before_commits; + assert_eq!( + commits, + (UNOFFERED_RECORDS as u64).div_ceil(128), + "each bounded page must durably commit before the next page" + ); assert!( statements.saturating_mul(16) < UNOFFERED_RECORDS as u64, "presence-only ghosts must not cause per-record writer commands: {statements} statements for {UNOFFERED_RECORDS} records" @@ -106,24 +113,15 @@ async fn table_count(conn: &impl QueryExecutor, sql: &str) -> i64 { row.get(0).unwrap() } -/// Retiring an unoffered record is real work, not a presence-row cleanup: -/// its provenance and workflow facts are removed, a `source_record_retired` -/// disposition takes their place, and a sole-owned projected output is -/// deleted. A completion that dies mid-transaction must roll every earlier -/// retirement back; the pending rewrite survives, so a retry after the -/// failure clears converges — the retry loop the paged read exists to keep -/// alive would otherwise stay wedged. -/// -/// The fixture plants an inconsistent provenance row (a surviving co-owner -/// that claims an output its durable record does not derive) on the -/// record the keyset order retires last, so the first attempt retires real -/// records across pages and then fails inside -/// `retire_source_record` with `ProvenanceCollision`. Removing the planted -/// row and completing again retires every unoffered record for real. +/// Retiring an unoffered record also removes its projections and workflow +/// facts. A collision on a later page must roll that page back, but earlier +/// page commits must remain durable for the next daemon invocation. +/// Reopening the registered store must resume from those committed pages, +/// preserving the reoffered survivor and retiring each obsolete record once. #[asupersync::test] -async fn rewrite_completion_retires_projected_records_and_rolls_back_a_failed_attempt() { - // More real projected records than one keyset page, so a failed attempt - // leaves proven retirements to roll back on every page. +async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() { + // More real projected records than one page: prior page commits must + // remain durable when the final page's provenance collision aborts. const RECORDS: usize = 1_005; let directory = TempDir::new().unwrap(); @@ -219,6 +217,7 @@ async fn rewrite_completion_retires_projected_records_and_rolls_back_a_failed_at .await .unwrap(); + let before_commits = writer_telemetry(registered).transactions.committed_transactions; let error = registered .complete_observation_source_rewrite(&source, &scope, generation) .await @@ -228,36 +227,59 @@ async fn rewrite_completion_retires_projected_records_and_rolls_back_a_failed_at "colliding output ownership must fail completion, got {error:?}" ); - // Every retirement the attempt already performed must be rolled back: - // provenance and projected outputs restored, no dispositions recorded, - // and the pending rewrite still waiting for a converging retry. - assert_eq!( - table_count( - &writer, - "SELECT COUNT(*) FROM observation_projection_dispositions" - ) - .await, - 0, - "retirements before the collision must roll back with the transaction" + // Earlier committed pages survive the failure; only the page containing + // the collision rolls back. This is the progress a new process must reuse. + let already_retired = table_count( + &writer, + "SELECT COUNT(*) FROM observation_projection_dispositions", + ) + .await; + assert!( + already_retired >= 128 && already_retired < (RECORDS - 1) as i64, + "failure must preserve completed pages, but not the colliding page" ); + let commits = writer_telemetry(registered).transactions.committed_transactions - before_commits; + assert!(commits >= 1, "at least one bounded retirement page must commit"); assert_eq!( table_count( &writer, - "SELECT COUNT(*) FROM observation_projection_provenance" + "SELECT COUNT(*) FROM observation_projection_provenance", ) .await, - i64::try_from(RECORDS).unwrap() + 1, - "rolled-back retirements must restore provenance" + i64::try_from(RECORDS).unwrap() + 1 - already_retired, + "committed pages must remove their provenance" ); assert_eq!( table_count(&writer, "SELECT COUNT(*) FROM lcm_raw_messages").await, - i64::try_from(RECORDS).unwrap(), - "rolled-back retirements must restore projected outputs" + i64::try_from(RECORDS).unwrap() - already_retired, + "committed pages must remove only their projected outputs" ); assert_eq!( table_count(&writer, "SELECT COUNT(*) FROM observation_source_rewrites").await, 1, - "the failed attempt must leave the rewrite pending" + "failed page must leave the rewrite pending for restart" + ); + + // Reopen the registered profile store as a new daemon fixture. Durable + // rewrite state, dispositions and the source-presence page frontier must + // survive rather than replay the already committed retirement work. + drop(writer); + drop(runtime); + let runtime = HostAdmissionTestRuntimeV1::profile(directory.path()) + .await + .unwrap(); + let registered = runtime + .registered_database(HostAdmissionScope::Profile) + .expect("reopened registered profile database"); + let writer = registered.writer_connection().unwrap(); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_projection_dispositions", + ) + .await, + already_retired, + "restart must retain the completed retirement frontier" ); writer @@ -275,7 +297,11 @@ async fn rewrite_completion_retires_projected_records_and_rolls_back_a_failed_at .unwrap_or_else(|error| { panic!("retry after clearing the collision must converge: {error}") }); - assert_eq!(retired, u64::try_from(RECORDS - 1).unwrap()); + assert_eq!( + retired, + u64::try_from(RECORDS - 1).unwrap() - already_retired as u64, + "restart must retire only records that were not already settled" + ); let retired_reason = ProjectionSkipReason::SourceRecordRetired.as_str(); assert_eq!( From 6aab5111cfc391faac263f3e4799a41febcef57a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:32:47 -0800 Subject: [PATCH 52/64] style(sessions): format bounded rewrite regression counters Refs #4756 --- .../src/tests/source_rewrite.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 7e3227a788d..4beb538d4c1 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -64,7 +64,9 @@ async fn rewrite_completion_retires_unoffered_records_beyond_the_query_cap() { // all presence rows, without issuing per-ghost UPDATE/receipt queries // while holding the SQLite writer's fixed transaction lease. let before = writer_telemetry(&harness.registered).sqlite_vm.statements; - let before_commits = writer_telemetry(&harness.registered).transactions.committed_transactions; + let before_commits = writer_telemetry(&harness.registered) + .transactions + .committed_transactions; let retired = harness .registered .complete_observation_source_rewrite(&source, &scope, generation) @@ -217,7 +219,9 @@ async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() .await .unwrap(); - let before_commits = writer_telemetry(registered).transactions.committed_transactions; + let before_commits = writer_telemetry(registered) + .transactions + .committed_transactions; let error = registered .complete_observation_source_rewrite(&source, &scope, generation) .await @@ -238,8 +242,14 @@ async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() already_retired >= 128 && already_retired < (RECORDS - 1) as i64, "failure must preserve completed pages, but not the colliding page" ); - let commits = writer_telemetry(registered).transactions.committed_transactions - before_commits; - assert!(commits >= 1, "at least one bounded retirement page must commit"); + let commits = writer_telemetry(registered) + .transactions + .committed_transactions + - before_commits; + assert!( + commits >= 1, + "at least one bounded retirement page must commit" + ); assert_eq!( table_count( &writer, From 57ff9671d2794b444ad54c6c3a44f17e3b979fe3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:15:29 -0800 Subject: [PATCH 53/64] perf(sessions): seek rewrite pages past settled survivors Refs #4756. Bound the repeated SQLite reads and deletes in large source rewrites while preserving durable per-page progress and an end-of-sweep recheck for concurrent older offers. --- .../src/registered_source_rewrite.rs | 87 +++++++++++------ .../src/tests/source_rewrite.rs | 97 +++++++++++++++++++ 2 files changed, 155 insertions(+), 29 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 28f840b44c4..fab3faeaa70 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -255,6 +255,9 @@ impl RegisteredGlobalDb { } drop(snapshot); let mut total_retired = 0_u64; + // Durably deleted pages survive restart; within this invocation seek + // past the already-visited current-generation survivor prefix. + let mut resume_from = String::new(); loop { // The remaining older-generation presence entries are the durable // continuation frontier. Each page commits its retirements and @@ -262,7 +265,8 @@ impl RegisteredGlobalDb { // page rolls back without repeating earlier committed pages. let key = key.clone(); let generation = generation.clone(); - let (retired, finished) = self + let after_id = resume_from.clone(); + let (retired, finished, next_cursor) = self .transact_or_rollback( |error| storage("run source rewrite completion page", error), async move |transaction| { @@ -295,6 +299,7 @@ impl RegisteredGlobalDb { ) FROM observation_source_presence AS presence WHERE presence.source_key = ?1 AND presence.generation <> ?2 + AND presence.observation_id >= ?5 ORDER BY presence.observation_id LIMIT ?3", params![ @@ -302,6 +307,7 @@ impl RegisteredGlobalDb { generation.as_str(), UNOFFERED_RECORD_PAGE_SIZE, SESSION_MESSAGE_PROJECTOR_VERSION, + after_id.as_str(), ], ) .await @@ -322,17 +328,6 @@ impl RegisteredGlobalDb { )); } drop(rows); - if page.is_empty() { - transaction - .execute( - "DELETE FROM observation_source_rewrites - WHERE source_key = ?1 AND generation = ?2", - params![key.as_str(), generation.as_str()], - ) - .await - .map_err(|error| storage("settle source rewrite", error))?; - return Ok((0, true)); - } let mut retired = 0_u64; for (observation_id, needs_retirement) in &page { if !needs_retirement { @@ -345,28 +340,61 @@ impl RegisteredGlobalDb { retired += 1; } } - let last_id = &page[page.len() - 1].0; - transaction - .execute( - "DELETE FROM observation_source_presence - WHERE source_key = ?1 AND generation <> ?2 - AND observation_id <= ?3", - params![key.as_str(), generation.as_str(), last_id.as_str()], - ) - .await - .map_err(|error| storage("settle source rewrite page", error))?; - let finished = (page.len() as i64) < UNOFFERED_RECORD_PAGE_SIZE; - if finished { + if let Some((last_id, _)) = page.last() { transaction .execute( - "DELETE FROM observation_source_rewrites - WHERE source_key = ?1 AND generation = ?2", - params![key.as_str(), generation.as_str()], + "DELETE FROM observation_source_presence + WHERE source_key = ?1 AND generation <> ?2 + AND observation_id >= ?4 AND observation_id <= ?3", + params![ + key.as_str(), + generation.as_str(), + last_id.as_str(), + after_id.as_str(), + ], ) .await - .map_err(|error| storage("settle source rewrite", error))?; + .map_err(|error| storage("settle source rewrite page", error))?; } - Ok((retired, finished)) + // Another admitted writer can append an older-generation + // key behind this in-memory seek between commits. Recheck + // the entire source before dropping the durable marker. + let last_page = (page.len() as i64) < UNOFFERED_RECORD_PAGE_SIZE; + let finished = if last_page { + let mut remaining = transaction + .query( + "SELECT 1 FROM observation_source_presence + WHERE source_key = ?1 AND generation <> ?2 LIMIT 1", + params![key.as_str(), generation.as_str()], + ) + .await + .map_err(|error| storage("check remaining source records", error))?; + let empty = remaining + .next() + .await + .map_err(|error| storage("check remaining source records", error))? + .is_none(); + drop(remaining); + if empty { + transaction + .execute( + "DELETE FROM observation_source_rewrites + WHERE source_key = ?1 AND generation = ?2", + params![key.as_str(), generation.as_str()], + ) + .await + .map_err(|error| storage("settle source rewrite", error))?; + } + empty + } else { + false + }; + let next_cursor = if last_page { + String::new() + } else { + page.last().map(|(id, _)| id.clone()).unwrap_or_default() + }; + Ok((retired, finished, next_cursor)) }, ) .await?; @@ -374,6 +402,7 @@ impl RegisteredGlobalDb { if finished { return Ok(total_retired); } + resume_from = next_cursor; } } } diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 4beb538d4c1..b7618835d02 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -660,3 +660,100 @@ async fn rewrite_completion_skips_settled_records_without_revival() { "eightfold more settled records must not create per-record writer commands" ); } + +/// With a large current-generation prefix, each older-generation page must +/// seek beyond already checked survivors instead of walking them repeatedly. +async fn source_rewrite_steps_with_survivor_prefix(unoffered: usize) -> u64 { + const SURVIVORS: usize = 4_096; + let harness = RegisteredGlobalDbHarness::open("source-rewrite-seek-prefix").await; + let source = + ObservationSourceIdentityV1::new(SessionId::new("session.seek-prefix").unwrap()).unwrap(); + let scope = ObservationScopeV1::Profile; + let previous = ObservationSourceGenerationV1::new(1).unwrap(); + let generation = ObservationSourceGenerationV1::new(2).unwrap(); + let key = serde_json::to_string(&(source.clone(), scope.clone())).unwrap(); + let old_id = previous.generation_id().to_string(); + let new_id = generation.generation_id().to_string(); + let writer = harness.registered.writer_connection().unwrap(); + for start in (0..(SURVIVORS + unoffered)).step_by(128) { + let end = (start + 128).min(SURVIVORS + unoffered); + let values = (start..end) + .map(|index| { + ( + format!("sha256:{index:06}"), + if index < SURVIVORS { + new_id.as_str() + } else { + old_id.as_str() + }, + ) + }) + .collect::>(); + let encoded = serde_json::to_string(&values).unwrap(); + writer + .execute( + "INSERT INTO observation_source_presence ( + source_key, observation_id, generation, start_offset + ) SELECT ?1, json_extract(value, '$[0]'), + json_extract(value, '$[1]'), 0 FROM json_each(?2)", + params![key.as_str(), encoded.as_str()], + ) + .await + .unwrap(); + } + harness + .registered + .begin_observation_source_rewrite(&source, &scope, previous, generation, 0) + .await + .unwrap(); + let before = writer_telemetry(&harness.registered); + assert_eq!( + harness + .registered + .complete_observation_source_rewrite(&source, &scope, generation) + .await + .unwrap(), + 0, + "presence-only rows have no projected observations to retire" + ); + let after = writer_telemetry(&harness.registered); + assert_eq!( + after.transactions.committed_transactions - before.transactions.committed_transactions, + unoffered.div_ceil(128) as u64, + "each bounded page must commit independently" + ); + assert_eq!( + table_count( + &writer, + &format!( + "SELECT COUNT(*) FROM observation_source_presence WHERE generation = '{old_id}'" + ), + ) + .await, + 0, + "all unoffered older records must be deleted" + ); + assert_eq!( + table_count( + &writer, + &format!( + "SELECT COUNT(*) FROM observation_source_presence WHERE generation = '{new_id}'" + ), + ) + .await, + SURVIVORS as i64, + "the current-generation survivors must remain untouched" + ); + after.sqlite_vm.vm_steps - before.sqlite_vm.vm_steps +} + +#[asupersync::test] +async fn source_rewrite_pages_do_not_rescan_the_same_survivor_prefix() { + let one_page_steps = source_rewrite_steps_with_survivor_prefix(127).await; + let eight_page_steps = source_rewrite_steps_with_survivor_prefix(1_023).await; + assert!( + eight_page_steps < one_page_steps.saturating_mul(3), + "eight retirement pages must seek beyond the 4096 survivor records: \ + one page {one_page_steps} VM steps; eight pages {eight_page_steps}" + ); +} From a169d54dc40c6c18a15d5b8c82e14ed6ba0066dc Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:16:02 -0800 Subject: [PATCH 54/64] fix(sessions): preserve typed rollback result for rewrite seek Refs #4756. Match the three-field transactional completion return on superseded source generations. --- crates/tracedecay-global-db/src/registered_source_rewrite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index fab3faeaa70..bce1c561a42 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -273,7 +273,7 @@ impl RegisteredGlobalDb { if pending_rewrite(&transaction, &key).await?.as_deref() != Some(generation.as_str()) { - return Err(JobExit::Rollback((0_u64, true))); + return Err(JobExit::Rollback((0_u64, true, String::new()))); } let mut rows = transaction .query( From 6a0abf5558db32b50866b6e15e5463d5c2ed30c5 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:17:34 -0800 Subject: [PATCH 55/64] perf(sessions): avoid second scan for single rewrite page Refs #4756. Only a resumed seek needs to recheck earlier source keys before settling; a first-page completion sees the whole source under its SQLite writer lease. --- .../src/registered_source_rewrite.rs | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index bce1c561a42..b374de4fba9 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -361,20 +361,28 @@ impl RegisteredGlobalDb { // the entire source before dropping the durable marker. let last_page = (page.len() as i64) < UNOFFERED_RECORD_PAGE_SIZE; let finished = if last_page { - let mut remaining = transaction - .query( - "SELECT 1 FROM observation_source_presence - WHERE source_key = ?1 AND generation <> ?2 LIMIT 1", - params![key.as_str(), generation.as_str()], - ) - .await - .map_err(|error| storage("check remaining source records", error))?; - let empty = remaining - .next() - .await - .map_err(|error| storage("check remaining source records", error))? - .is_none(); - drop(remaining); + // With no seek this page covered the entire source + // under the writer lock, so no second scan is needed. + // A resumed page must check keys admitted behind it. + let empty = if after_id.is_empty() { + true + } else { + let mut remaining = transaction + .query( + "SELECT 1 FROM observation_source_presence + WHERE source_key = ?1 AND generation <> ?2 LIMIT 1", + params![key.as_str(), generation.as_str()], + ) + .await + .map_err(|error| storage("check remaining source records", error))?; + let empty = remaining + .next() + .await + .map_err(|error| storage("check remaining source records", error))? + .is_none(); + drop(remaining); + empty + }; if empty { transaction .execute( From 54e8f8078c575e29bf9062d560c0a619fb904204 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:01:03 -0800 Subject: [PATCH 56/64] test(sessions): bound rewrite fixture setup and scope writer Refs #4756 Release the first writer handle before reopening the profile database to satisfy Clippy without changing the restart regression. Seed the 10,003 presence-only rows in bounded SQL windows rather than one writer dispatch per row, retaining the over-cap page and statement-count assertions. --- .../src/tests/source_rewrite.rs | 164 ++++++++++-------- 1 file changed, 91 insertions(+), 73 deletions(-) diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index b7618835d02..0fb02fb9f6c 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -37,21 +37,37 @@ async fn rewrite_completion_retires_unoffered_records_beyond_the_query_cap() { let key = serde_json::to_string(&(source.clone(), scope.clone())).unwrap(); let writer = harness.registered.writer_connection().unwrap(); - for index in 0..UNOFFERED_RECORDS { + // Keep the regression's 10,003 ghost records without turning fixture + // preparation into 10,003 serial writer round-trips. + for start in (0..UNOFFERED_RECORDS).step_by(128) { + let end = (start + 128).min(UNOFFERED_RECORDS); writer .execute( - "INSERT INTO observation_source_presence ( - source_key, observation_id, generation, start_offset - ) VALUES (?1, ?2, ?3, 0)", + "WITH RECURSIVE generated(i) AS ( + SELECT ?2 + UNION ALL + SELECT i + 1 FROM generated WHERE i + 1 < ?3 + ) + INSERT INTO observation_source_presence ( + source_key, observation_id, generation, start_offset + ) + SELECT ?1, printf('sha256:unoffered-%06d', i), ?4, 0 + FROM generated", params![ key.as_str(), - format!("sha256:unoffered-{index:06}"), + start, + end, previous.generation_id().to_string() ], ) .await .unwrap(); } + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM observation_source_presence").await, + UNOFFERED_RECORDS, + "fixture must cover more rows than the query materialization cap" + ); harness .registered @@ -198,82 +214,84 @@ async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() // record's output. When the last record retires, the surviving owner is // read back and its stored rendering derives a different message, which // the retire path refuses with `ProvenanceCollision`. - let writer = registered.writer_connection().unwrap(); - writer - .execute( - "INSERT INTO observation_projection_provenance ( - projector_version, observation_id, output_ordinal, receipt_id, - output_provider, output_message_id, output_digest, message_created, - retrieval_anchor_id - ) - SELECT projector_version, observation_id, 1, receipt_id, - output_provider, ?1, output_digest, message_created, + let already_retired = { + let writer = registered.writer_connection().unwrap(); + writer + .execute( + "INSERT INTO observation_projection_provenance ( + projector_version, observation_id, output_ordinal, receipt_id, + output_provider, output_message_id, output_digest, message_created, retrieval_anchor_id - FROM observation_projection_provenance - WHERE observation_id = ?2", - params![ - collided_message_id.as_str(), - survivor.observation_id().as_str() - ], - ) - .await - .unwrap(); + ) + SELECT projector_version, observation_id, 1, receipt_id, + output_provider, ?1, output_digest, message_created, + retrieval_anchor_id + FROM observation_projection_provenance + WHERE observation_id = ?2", + params![ + collided_message_id.as_str(), + survivor.observation_id().as_str() + ], + ) + .await + .unwrap(); - let before_commits = writer_telemetry(registered) - .transactions - .committed_transactions; - let error = registered - .complete_observation_source_rewrite(&source, &scope, generation) - .await - .unwrap_err(); - assert!( - matches!(error, ProjectionStoreError::ProvenanceCollision), - "colliding output ownership must fail completion, got {error:?}" - ); + let before_commits = writer_telemetry(registered) + .transactions + .committed_transactions; + let error = registered + .complete_observation_source_rewrite(&source, &scope, generation) + .await + .unwrap_err(); + assert!( + matches!(error, ProjectionStoreError::ProvenanceCollision), + "colliding output ownership must fail completion, got {error:?}" + ); - // Earlier committed pages survive the failure; only the page containing - // the collision rolls back. This is the progress a new process must reuse. - let already_retired = table_count( - &writer, - "SELECT COUNT(*) FROM observation_projection_dispositions", - ) - .await; - assert!( - already_retired >= 128 && already_retired < (RECORDS - 1) as i64, - "failure must preserve completed pages, but not the colliding page" - ); - let commits = writer_telemetry(registered) - .transactions - .committed_transactions - - before_commits; - assert!( - commits >= 1, - "at least one bounded retirement page must commit" - ); - assert_eq!( - table_count( + // Earlier committed pages survive the failure; only the page containing + // the collision rolls back. This is the progress a new process must reuse. + let already_retired = table_count( &writer, - "SELECT COUNT(*) FROM observation_projection_provenance", + "SELECT COUNT(*) FROM observation_projection_dispositions", ) - .await, - i64::try_from(RECORDS).unwrap() + 1 - already_retired, - "committed pages must remove their provenance" - ); - assert_eq!( - table_count(&writer, "SELECT COUNT(*) FROM lcm_raw_messages").await, - i64::try_from(RECORDS).unwrap() - already_retired, - "committed pages must remove only their projected outputs" - ); - assert_eq!( - table_count(&writer, "SELECT COUNT(*) FROM observation_source_rewrites").await, - 1, - "failed page must leave the rewrite pending for restart" - ); + .await; + assert!( + already_retired >= 128 && already_retired < (RECORDS - 1) as i64, + "failure must preserve completed pages, but not the colliding page" + ); + let commits = writer_telemetry(registered) + .transactions + .committed_transactions + - before_commits; + assert!( + commits >= 1, + "at least one bounded retirement page must commit" + ); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_projection_provenance", + ) + .await, + i64::try_from(RECORDS).unwrap() + 1 - already_retired, + "committed pages must remove their provenance" + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM lcm_raw_messages").await, + i64::try_from(RECORDS).unwrap() - already_retired, + "committed pages must remove only their projected outputs" + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM observation_source_rewrites").await, + 1, + "failed page must leave the rewrite pending for restart" + ); + already_retired + }; // Reopen the registered profile store as a new daemon fixture. Durable // rewrite state, dispositions and the source-presence page frontier must // survive rather than replay the already committed retirement work. - drop(writer); drop(runtime); let runtime = HostAdmissionTestRuntimeV1::profile(directory.path()) .await @@ -605,7 +623,7 @@ async fn rewrite_completion_skips_settled_records_without_revival() { .await .unwrap(); - let statements = || writer_telemetry(®istered).sqlite_vm.statements; + let statements = || writer_telemetry(registered).sqlite_vm.statements; let before = statements(); assert_eq!( registered From 656ae03b6db8516553fa8d5dc8a3bc11aa6b089f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:57:23 -0800 Subject: [PATCH 57/64] perf(sessions): skip repeated retained-prefix rewrite updates Refs #4756 A restart can begin an already pending source generation again. Do not rewrite its unchanged presence rows or accepted queue revivals under a writer lease. Preserve updates for genuinely newer generations and cover both paths with abort-on-no-op registered-store triggers. --- .../src/registered_source_rewrite.rs | 6 +- .../src/tests/source_rewrite.rs | 136 ++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index b374de4fba9..690089d5d00 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -186,7 +186,8 @@ impl RegisteredGlobalDb { AND presence.start_offset < ?3 AND presence.generation IN (?4, ?5) AND presence.generation = projection_queue.revival_generation - AND presence.start_offset = projection_queue.revival_start_offset", + AND presence.start_offset = projection_queue.revival_start_offset + AND presence.generation <> ?2", params![ key.as_str(), generation.as_str(), @@ -201,7 +202,8 @@ impl RegisteredGlobalDb { .execute( "UPDATE observation_source_presence SET generation = ?2 WHERE source_key = ?1 AND start_offset < ?3 - AND generation IN (?4, ?5)", + AND generation IN (?4, ?5) + AND generation <> ?2", params![ key.as_str(), generation.as_str(), diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 0fb02fb9f6c..cec0f885072 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -380,6 +380,142 @@ async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() ); } +/// Beginning the same in-progress layout again must not write retained records +/// whose generation and accepted revival were already advanced. Each trigger +/// makes a redundant physical UPDATE fail, while the next *real* generation +/// transition must still advance both authorities. +#[asupersync::test] +async fn repeated_source_rewrite_begin_preserves_already_advanced_prefix() { + let directory = TempDir::new().unwrap(); + let runtime = HostAdmissionTestRuntimeV1::profile(directory.path()) + .await + .unwrap(); + let registered = runtime + .registered_database(HostAdmissionScope::Profile) + .expect("registered profile database"); + let seeded = seed_projected_messages(&runtime, 0..2).await; + let source = + ObservationSourceIdentityV1::new(SessionId::new("session.repeat-prefix").unwrap()).unwrap(); + let scope = ObservationScopeV1::Profile; + let previous = ObservationSourceGenerationV1::new(1).unwrap(); + let generation = ObservationSourceGenerationV1::new(2).unwrap(); + let next = ObservationSourceGenerationV1::new(3).unwrap(); + let offered = seeded + .iter() + .map(|(observation, _)| ObservationSourcePresenceV1 { + observation_id: observation.observation_id().clone(), + generation: previous, + start_offset: observation.identity().position().start(), + }) + .collect::>(); + registered + .record_observation_source_presence(&source, &scope, &offered) + .await + .unwrap(); + let writer = registered.writer_connection().unwrap(); + writer + .execute( + "INSERT INTO projection_queue ( + observation_id, observation_sequence, revival_generation, revival_start_offset + ) + SELECT observation_id, sequence, ?2, ?3 + FROM observations WHERE observation_id = ?1 + ON CONFLICT(observation_id) DO UPDATE SET + revival_generation = excluded.revival_generation, + revival_start_offset = excluded.revival_start_offset", + params![ + offered[0].observation_id.as_str(), + previous.generation_id().to_string(), + i64::try_from(offered[0].start_offset).unwrap() + ], + ) + .await + .unwrap(); + + let limit = i64::MAX as u64; + registered + .begin_observation_source_rewrite(&source, &scope, previous, generation, limit) + .await + .unwrap(); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_source_presence WHERE generation = '2'" + ) + .await, + 2, + "the first rewrite must advance both retained presence rows" + ); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM projection_queue WHERE revival_generation = '2'" + ) + .await, + 1, + "the first rewrite must advance the queued accepted revival" + ); + + // The second beginning is exactly what an interrupted host re-import + // repeats. Rejecting no-op updates on both tables proves the changed + // predicates actually prevent writer amplification, not just identical + // resulting values. + writer + .execute_batch( + "CREATE TRIGGER reject_unchanged_presence + BEFORE UPDATE OF generation ON observation_source_presence + WHEN NEW.generation IS OLD.generation + BEGIN SELECT RAISE(ABORT, 'repeated retained presence update'); END; + CREATE TRIGGER reject_unchanged_revival + BEFORE UPDATE OF revival_generation ON projection_queue + WHEN NEW.revival_generation IS OLD.revival_generation + BEGIN SELECT RAISE(ABORT, 'repeated accepted revival update'); END;", + ) + .await + .unwrap(); + registered + .begin_observation_source_rewrite(&source, &scope, previous, generation, limit) + .await + .expect("restarting an identical layout must not update retained rows"); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_source_presence WHERE generation = '2'" + ) + .await, + 2 + ); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM projection_queue WHERE revival_generation = '2'" + ) + .await, + 1 + ); + + registered + .begin_observation_source_rewrite(&source, &scope, generation, next, limit) + .await + .expect("a genuinely new rewrite must still advance the retained prefix"); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_source_presence WHERE generation = '3'" + ) + .await, + 2 + ); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM projection_queue WHERE revival_generation = '3'" + ) + .await, + 1 + ); +} + /// Capturing one host page previously dispatched one SQLite statement per /// record, even for reoffers identical to the already durable generation. /// The JSON batch must preserve last-offer ordering, exact identity and From 9573a0333144fb90c928fbf4ba3b3703c4de9810 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:49:24 -0800 Subject: [PATCH 58/64] perf(sessions): avoid unchanged pending rewrite updates Refs #4756 --- .../src/registered_source_rewrite.rs | 4 +++- .../src/tests/source_rewrite.rs | 24 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 690089d5d00..049f264f630 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -221,7 +221,9 @@ impl RegisteredGlobalDb { ) VALUES (?1, ?2, ?3) ON CONFLICT(source_key) DO UPDATE SET generation = excluded.generation, - retained_through = excluded.retained_through", + retained_through = excluded.retained_through + WHERE observation_source_rewrites.generation IS NOT excluded.generation + OR observation_source_rewrites.retained_through IS NOT excluded.retained_through", params![key.as_str(), generation.as_str(), retained_through], ) .await diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index cec0f885072..83ca855019c 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -380,10 +380,10 @@ async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() ); } -/// Beginning the same in-progress layout again must not write retained records -/// whose generation and accepted revival were already advanced. Each trigger -/// makes a redundant physical UPDATE fail, while the next *real* generation -/// transition must still advance both authorities. +/// Beginning the same in-progress layout again must not rewrite retained +/// records, accepted revivals, or the unchanged pending rewrite marker. +/// SQL triggers reject every redundant UPDATE, while a real generation +/// transition must still advance all three authorities. #[asupersync::test] async fn repeated_source_rewrite_begin_preserves_already_advanced_prefix() { let directory = TempDir::new().unwrap(); @@ -469,7 +469,12 @@ async fn repeated_source_rewrite_begin_preserves_already_advanced_prefix() { CREATE TRIGGER reject_unchanged_revival BEFORE UPDATE OF revival_generation ON projection_queue WHEN NEW.revival_generation IS OLD.revival_generation - BEGIN SELECT RAISE(ABORT, 'repeated accepted revival update'); END;", + BEGIN SELECT RAISE(ABORT, 'repeated accepted revival update'); END; + CREATE TRIGGER reject_unchanged_rewrite + BEFORE UPDATE ON observation_source_rewrites + WHEN NEW.generation IS OLD.generation + AND NEW.retained_through IS OLD.retained_through + BEGIN SELECT RAISE(ABORT, 'repeated pending rewrite update'); END;", ) .await .unwrap(); @@ -514,6 +519,15 @@ async fn repeated_source_rewrite_begin_preserves_already_advanced_prefix() { .await, 1 ); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_source_rewrites WHERE generation = '3'" + ) + .await, + 1, + "a new source layout must still advance the pending rewrite" + ); } /// Capturing one host page previously dispatched one SQLite statement per From cd9901017fae7ba57cfb3391415ec0e12b139596 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:21:35 -0800 Subject: [PATCH 59/64] test(sessions): batch retry fixture writes without reducing coverage Refs #4756 Replace four serialized SQL submissions per fixture observation with four statements per ordered 128-row page. Keep typed receipts, observations, source cursors, queue ordering and a single atomic setup transaction. Assert each statement inserted every expected row; preserve all existing recovery cases and their assertions, including writer-work measurements. Independent SQLite differential checks preserve exact rows and rollback. This removes fixture overhead; it does not establish a full Bazel pass or resolution of the reported historical-import incident. --- .../src/observation_projection/rearm_tests.rs | 125 +++++++++--------- 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs index d1c1911d4a7..5fe4ca7a890 100644 --- a/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs +++ b/crates/tracedecay-global-db/src/observation_projection/rearm_tests.rs @@ -77,67 +77,70 @@ fn queued_observation(index: i64) -> (DurableObservationV1, ObservationSourceCur /// eligible, interleaved so batches must skip due rows. async fn seed_queue(harness: &RegisteredGlobalDbHarness, deferred: i64, due: i64) { let transaction = harness.registered.begin_write_transaction().await.unwrap(); - for index in 0..deferred + due { - let (observation, cursor) = queued_observation(index); - let receipt = observation.receipt(); - transaction - .execute( - "INSERT INTO sanitization_receipts - (receipt_id, sanitizer_version, payload_digest, receipt_json) - VALUES (?1, ?2, ?3, ?4)", - params![ - receipt.receipt().receipt_id().as_str(), - receipt.receipt().sanitizer_version().as_str(), - observation.payload_reference().digest().as_str(), - serde_json::to_string(receipt).unwrap() - ], - ) - .await - .unwrap(); - transaction - .execute( - "INSERT INTO observations - (observation_id, payload_digest, receipt_id, observation_json, - committed_cursor_json) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - observation.observation_id().as_str(), - observation.payload_reference().digest().as_str(), - receipt.receipt().receipt_id().as_str(), - serde_json::to_string(&observation).unwrap(), - serde_json::to_string(&cursor).unwrap() - ], - ) - .await - .unwrap(); - transaction - .execute( - "INSERT INTO source_cursors(source_json, scope_json, cursor_json) - VALUES (?1, ?2, ?3)", - params![ - serde_json::to_string(cursor.source()).unwrap(), - serde_json::to_string(cursor.scope()).unwrap(), - serde_json::to_string(&cursor).unwrap() - ], - ) - .await - .unwrap(); - let next_retry = if index % 2 == 1 && index / 2 < due { - 0 - } else { - FAR_FUTURE_MICROS - }; - transaction - .execute( - "INSERT INTO projection_queue - (observation_id, observation_sequence, attempt_count, next_retry_at_micros, - last_error) - SELECT observation_id, sequence, 3, ?2, 'recorded failure' - FROM observations WHERE observation_id = ?1", - params![observation.observation_id().as_str(), next_retry], - ) - .await - .unwrap(); + // Fixture preparation must not dominate the recovery being measured with + // four asynchronous writer round-trips per observation. Keep every typed + // record and the atomic setup, but submit bounded, explicitly ordered pages. + for start in (0..deferred + due).step_by(128) { + let end = (start + 128).min(deferred + due); + let page = (start..end) + .map(|index| { + let (observation, cursor) = queued_observation(index); + let receipt = observation.receipt(); + let next_retry = if index % 2 == 1 && index / 2 < due { + 0 + } else { + FAR_FUTURE_MICROS + }; + serde_json::json!({ + "receipt_id": receipt.receipt().receipt_id().as_str(), + "sanitizer_version": receipt.receipt().sanitizer_version().as_str(), + "payload_digest": observation.payload_reference().digest().as_str(), + "receipt_json": serde_json::to_string(receipt).unwrap(), + "observation_id": observation.observation_id().as_str(), + "observation_json": serde_json::to_string(&observation).unwrap(), + "cursor_json": serde_json::to_string(&cursor).unwrap(), + "source_json": serde_json::to_string(cursor.source()).unwrap(), + "scope_json": serde_json::to_string(cursor.scope()).unwrap(), + "next_retry": next_retry, + }) + }) + .collect::>(); + let expected_rows = u64::try_from(page.len()).unwrap(); + let encoded = serde_json::to_string(&page).unwrap(); + for sql in [ + "INSERT INTO sanitization_receipts + (receipt_id, sanitizer_version, payload_digest, receipt_json) + SELECT json_extract(value, '$.receipt_id'), + json_extract(value, '$.sanitizer_version'), + json_extract(value, '$.payload_digest'), json_extract(value, '$.receipt_json') + FROM json_each(?1) ORDER BY CAST(key AS INTEGER)", + "INSERT INTO observations + (observation_id, payload_digest, receipt_id, observation_json, committed_cursor_json) + SELECT json_extract(value, '$.observation_id'), json_extract(value, '$.payload_digest'), + json_extract(value, '$.receipt_id'), json_extract(value, '$.observation_json'), + json_extract(value, '$.cursor_json') + FROM json_each(?1) ORDER BY CAST(key AS INTEGER)", + "INSERT INTO source_cursors(source_json, scope_json, cursor_json) + SELECT json_extract(value, '$.source_json'), json_extract(value, '$.scope_json'), + json_extract(value, '$.cursor_json') + FROM json_each(?1) ORDER BY CAST(key AS INTEGER)", + "INSERT INTO projection_queue + (observation_id, observation_sequence, attempt_count, next_retry_at_micros, last_error) + SELECT observation.observation_id, observation.sequence, 3, + json_extract(input.value, '$.next_retry'), 'recorded failure' + FROM json_each(?1) AS input CROSS JOIN observations AS observation + WHERE observation.observation_id = json_extract(input.value, '$.observation_id') + ORDER BY CAST(input.key AS INTEGER)", + ] { + assert_eq!( + transaction + .execute(sql, params![encoded.as_str()]) + .await + .unwrap(), + expected_rows, + "every fixture page must preserve all receipt, observation, cursor and queue rows" + ); + } } transaction.commit().await.unwrap(); } From 697428138db805e02511c1f602aceae8c1bc5624 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:25:49 -0800 Subject: [PATCH 60/64] perf(sessions): avoid empty source-rewrite tail transactions Read one bounded lookahead identity so exact 128-row multiples settle their pending rewrite marker in the last productive page. Keep each retirement and delete page capped at 128 rows and recheck behind the keyset cursor after resumed pages. Add real registered-store regressions for 128, 256 and 257 unoffered identities, verifying commit counts, marker retirement and surviving current-generation presence. Refs #4756 --- .../src/registered_source_rewrite.rs | 10 +- .../src/tests/source_rewrite.rs | 105 ++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 049f264f630..1806d1a923f 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -305,7 +305,7 @@ impl RegisteredGlobalDb { WHERE presence.source_key = ?1 AND presence.generation <> ?2 AND presence.observation_id >= ?5 ORDER BY presence.observation_id - LIMIT ?3", + LIMIT (?3 + 1)", params![ key.as_str(), generation.as_str(), @@ -332,6 +332,12 @@ impl RegisteredGlobalDb { )); } drop(rows); + // An extra read-only key establishes whether a full page + // was the final page. Never retire or delete the lookahead. + let has_next_page = page.len() > UNOFFERED_RECORD_PAGE_SIZE as usize; + if has_next_page { + page.pop(); + } let mut retired = 0_u64; for (observation_id, needs_retirement) in &page { if !needs_retirement { @@ -363,7 +369,7 @@ impl RegisteredGlobalDb { // Another admitted writer can append an older-generation // key behind this in-memory seek between commits. Recheck // the entire source before dropping the durable marker. - let last_page = (page.len() as i64) < UNOFFERED_RECORD_PAGE_SIZE; + let last_page = !has_next_page; let finished = if last_page { // With no seek this page covered the entire source // under the writer lock, so no second scan is needed. diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 83ca855019c..c37fa2747e3 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -125,6 +125,111 @@ async fn rewrite_completion_retires_unoffered_records_beyond_the_query_cap() { assert_eq!(rewrites, 0, "completed rewrite row must be removed"); } +/// An exact multiple of the bounded page must retire its final records and +/// clear the pending marker in the same productive writer transaction. A +/// lookahead row may be read, but must never be retired in the previous page. +#[asupersync::test] +async fn rewrite_completion_exact_page_boundaries_need_no_empty_writer() { + for (unoffered, fixture) in [ + (128_i64, "source-rewrite-exact-128"), + (256, "source-rewrite-exact-256"), + (257, "source-rewrite-after-exact-256"), + ] { + let harness = RegisteredGlobalDbHarness::open(fixture).await; + let source = + ObservationSourceIdentityV1::new(SessionId::new("session.exact-pages").unwrap()) + .unwrap(); + let scope = ObservationScopeV1::Profile; + let previous = ObservationSourceGenerationV1::new(1).unwrap(); + let generation = ObservationSourceGenerationV1::new(2).unwrap(); + let key = serde_json::to_string(&(source.clone(), scope.clone())).unwrap(); + let writer = harness.registered.writer_connection().unwrap(); + + writer + .execute( + "INSERT INTO observation_source_presence + (source_key, observation_id, generation, start_offset) + VALUES (?1, 'sha256:current', ?2, 0)", + params![key.as_str(), generation.generation_id().to_string()], + ) + .await + .unwrap(); + for start in (0..unoffered).step_by(128) { + let end = (start + 128).min(unoffered); + assert_eq!( + writer + .execute( + "WITH RECURSIVE generated(i) AS ( + SELECT ?2 + UNION ALL + SELECT i + 1 FROM generated WHERE i + 1 < ?3 + ) + INSERT INTO observation_source_presence + (source_key, observation_id, generation, start_offset) + SELECT ?1, printf('sha256:obsolete-%06d', i), ?4, 0 + FROM generated", + params![ + key.as_str(), + start, + end, + previous.generation_id().to_string(), + ], + ) + .await + .unwrap(), + u64::try_from(end - start).unwrap() + ); + } + + harness + .registered + .begin_observation_source_rewrite(&source, &scope, previous, generation, 0) + .await + .unwrap(); + let before = writer_telemetry(&harness.registered) + .transactions + .committed_transactions; + assert_eq!( + harness + .registered + .complete_observation_source_rewrite(&source, &scope, generation) + .await + .unwrap(), + 0, + "presence-only ghosts must not count as projected retirements" + ); + let commits = writer_telemetry(&harness.registered) + .transactions + .committed_transactions + - before; + assert_eq!( + commits, + u64::try_from(unoffered).unwrap().div_ceil(128), + "{fixture}: no empty writer transaction after an exact full page" + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM observation_source_rewrites").await, + 0, + "the durable pending rewrite marker must be cleared" + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM observation_source_presence").await, + 1, + "every obsolete identity must be retired" + ); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_source_presence + WHERE observation_id = 'sha256:current' AND generation = '2'" + ) + .await, + 1, + "the current-generation survivor must remain" + ); + } +} + async fn table_count(conn: &impl QueryExecutor, sql: &str) -> i64 { let mut rows = conn.query(sql, ()).await.unwrap(); let row = rows.next().await.unwrap().unwrap(); From 010fcce8f87ec35b010815acf774facfcc464cd3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:26:49 -0800 Subject: [PATCH 61/64] fix(sessions): discard source-rewrite lookahead without must-use warning Truncate the staged page to the committed limit rather than ignoring Vec::pop's must-use Option; retain the one-row lookahead invariant. Refs #4756 --- crates/tracedecay-global-db/src/registered_source_rewrite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 1806d1a923f..8f89a74ce8b 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -336,7 +336,7 @@ impl RegisteredGlobalDb { // was the final page. Never retire or delete the lookahead. let has_next_page = page.len() > UNOFFERED_RECORD_PAGE_SIZE as usize; if has_next_page { - page.pop(); + page.truncate(UNOFFERED_RECORD_PAGE_SIZE as usize); } let mut retired = 0_u64; for (observation_id, needs_retirement) in &page { From c47732cfb49897feb68d3faa330b0b5978b15921 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:49:25 -0800 Subject: [PATCH 62/64] perf(sessions): coalesce temporal resets in source retirement pages Refs #4756 Retirement previously upserted the same session-temporal reset once for each projected observation under the bounded SQLite writer lease. Batch retired effect IDs per page and DISTINCT their sessions before the reset, retaining the same transaction, cursor, per-observation provenance checks, and rollback semantics. Add a registered-store trigger regression that fails on the old repeated update and checks all records retire exactly once. SQLite multi-session and trigger SQL reproduced independently. Bazel verification remains required; no incident-scale acceptance is claimed. --- .../src/observation_projection/apply.rs | 3 +- .../src/registered_source_rewrite.rs | 7 ++ .../src/tests/source_rewrite.rs | 97 +++++++++++++++++++ .../src/lib.rs | 2 +- .../src/projection.rs | 2 +- .../src/projection/receipts.rs | 32 ++++++ 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-global-db/src/observation_projection/apply.rs b/crates/tracedecay-global-db/src/observation_projection/apply.rs index 793d9e7076e..63a346c5467 100644 --- a/crates/tracedecay-global-db/src/observation_projection/apply.rs +++ b/crates/tracedecay-global-db/src/observation_projection/apply.rs @@ -1677,7 +1677,8 @@ pub(in super::super) async fn retire_source_record( }; supersede_projected_message(conn, &message).await?; } - request_session_temporal_reset(conn, observation_id).await?; + // Source-rewrite completion coalesces temporal reset requests for the + // retired page after all output ownership has been reconciled. Ok(true) } diff --git a/crates/tracedecay-global-db/src/registered_source_rewrite.rs b/crates/tracedecay-global-db/src/registered_source_rewrite.rs index 8f89a74ce8b..16f6e56c187 100644 --- a/crates/tracedecay-global-db/src/registered_source_rewrite.rs +++ b/crates/tracedecay-global-db/src/registered_source_rewrite.rs @@ -21,6 +21,7 @@ use tracedecay_store::{ use super::RegisteredGlobalDb; use super::observation_projection::retire_source_record; use super::registered::JobExit; +use tracedecay_session_temporal_store::request_session_temporal_resets; /// Each committed page settles at most this many source-presence records, /// releasing the writer lease and making restart continuation durable. @@ -339,6 +340,7 @@ impl RegisteredGlobalDb { page.truncate(UNOFFERED_RECORD_PAGE_SIZE as usize); } let mut retired = 0_u64; + let mut retired_ids = Vec::new(); for (observation_id, needs_retirement) in &page { if !needs_retirement { continue; @@ -348,8 +350,13 @@ impl RegisteredGlobalDb { > = Box::pin(retire_source_record(&transaction, observation_id)); if retired_one.await? { retired += 1; + retired_ids.push(observation_id.as_str()); } } + // One source can yield many effects for the same session. + // Request one temporal reset per affected session in + // this page's transaction, rather than per observation. + request_session_temporal_resets(&transaction, &retired_ids).await?; if let Some((last_id, _)) = page.last() { transaction .execute( diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index c37fa2747e3..e7aacd807b8 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -236,6 +236,103 @@ async fn table_count(conn: &impl QueryExecutor, sql: &str) -> i64 { row.get(0).unwrap() } +/// One source page can retire many effects of the same session. The +/// temporal reset is a session-level invalidation, not a per-record writer +/// upsert; an unchanged second update must be impossible in this page. +#[asupersync::test] +async fn rewrite_completion_coalesces_temporal_reset_for_one_session() { + let directory = TempDir::new().unwrap(); + let runtime = HostAdmissionTestRuntimeV1::profile(directory.path()) + .await + .unwrap(); + let seeded = seed_projected_messages(&runtime, 0..4).await; + let registered = runtime + .registered_database(HostAdmissionScope::Profile) + .expect("registered profile database"); + let source = seeded[0].0.source().clone(); + let scope = ObservationScopeV1::Profile; + let previous = ObservationSourceGenerationV1::new(1).unwrap(); + let generation = ObservationSourceGenerationV1::new(2).unwrap(); + let writer = registered.writer_connection().unwrap(); + + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM session_temporal_observation_effects WHERE output_count > 0" + ) + .await, + 4, + "all projected observations must own materialized temporal effects" + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM session_temporal_resets").await, + 0, + "the fixture must not already have pending reset requests" + ); + + let offered: Vec<_> = seeded + .iter() + .map(|(observation, _)| ObservationSourcePresenceV1 { + observation_id: observation.observation_id().clone(), + generation: previous, + start_offset: observation.identity().position().start(), + }) + .collect(); + registered + .record_observation_source_presence(&source, &scope, &offered) + .await + .unwrap(); + registered + .begin_observation_source_rewrite(&source, &scope, previous, generation, 0) + .await + .unwrap(); + + // The previous per-observation upsert fails on its second update. The + // replacement must coalesce same-session effects but still retire all four. + writer + .execute( + "CREATE TRIGGER reject_redundant_temporal_reset + BEFORE UPDATE ON session_temporal_resets + BEGIN SELECT RAISE(ABORT, 'duplicate temporal reset'); END", + (), + ) + .await + .unwrap(); + + assert_eq!( + registered + .complete_observation_source_rewrite(&source, &scope, generation) + .await + .unwrap(), + 4 + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM session_temporal_resets").await, + 1, + "one session must receive exactly one durable reset request" + ); + assert_eq!( + table_count( + &writer, + "SELECT COUNT(*) FROM observation_projection_dispositions + WHERE reason = 'source_record_retired'" + ) + .await, + 4, + "coalescing must not skip retirement of any projected record" + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM observation_source_presence").await, + 0, + "all retired presence rows must be settled" + ); + assert_eq!( + table_count(&writer, "SELECT COUNT(*) FROM observation_source_rewrites").await, + 0, + "the rewrite marker must be committed with the reset" + ); +} + /// Retiring an unoffered record also removes its projections and workflow /// facts. A collision on a later page must roll that page back, but earlier /// page commits must remain durable for the next daemon invocation. diff --git a/crates/tracedecay-session-temporal-store/src/lib.rs b/crates/tracedecay-session-temporal-store/src/lib.rs index 59fe925f254..5f8f5d27272 100644 --- a/crates/tracedecay-session-temporal-store/src/lib.rs +++ b/crates/tracedecay-session-temporal-store/src/lib.rs @@ -116,7 +116,7 @@ pub use doctor_health::{ pub use projection::{ record_canonical_observation_effect, record_live_collision_preserving_canonical_observation_effect, request_session_temporal_reset, - request_session_temporal_reset_if_materialized, + request_session_temporal_reset_if_materialized, request_session_temporal_resets, }; pub use refresh::{ SessionRefreshBeginBatchItemV1, SessionRefreshBeginBatchOutcomeV1, SessionRefreshBeginPlanV1, diff --git a/crates/tracedecay-session-temporal-store/src/projection.rs b/crates/tracedecay-session-temporal-store/src/projection.rs index 8477f47d373..6981f8b8208 100644 --- a/crates/tracedecay-session-temporal-store/src/projection.rs +++ b/crates/tracedecay-session-temporal-store/src/projection.rs @@ -30,7 +30,7 @@ pub(crate) use receipts::{base_source_frontier, digest_bytes}; pub use receipts::{ record_canonical_observation_effect, record_live_collision_preserving_canonical_observation_effect, request_session_temporal_reset, - request_session_temporal_reset_if_materialized, + request_session_temporal_reset_if_materialized, request_session_temporal_resets, }; const DISCOVER_REFRESH: &str = "discover session temporal refresh"; diff --git a/crates/tracedecay-session-temporal-store/src/projection/receipts.rs b/crates/tracedecay-session-temporal-store/src/projection/receipts.rs index 99d2478555a..39614b51b7e 100644 --- a/crates/tracedecay-session-temporal-store/src/projection/receipts.rs +++ b/crates/tracedecay-session-temporal-store/src/projection/receipts.rs @@ -384,6 +384,38 @@ pub async fn request_session_temporal_reset( Ok(()) } +/// Coalesces temporal resets for a bounded source-rewrite retirement page. +/// The caller atomically commits these requests with retired projections and +/// the durable page frontier. Different sessions retain distinct requests. +#[tracing::instrument( + name = "session_temporal.persist.reset_request_batch", + level = "trace", + skip_all +)] +pub async fn request_session_temporal_resets( + conn: &impl Executor, + observation_ids: &[&str], +) -> ProjectionStoreResult<()> { + if observation_ids.is_empty() { + return Ok(()); + } + let encoded = serde_json::to_string(observation_ids) + .map_err(|error| ProjectionStoreError::storage("encode temporal reset page", error))?; + conn.execute( + "INSERT INTO session_temporal_resets (session_id, requested_at) + SELECT DISTINCT effect.session_id, unixepoch() * 1000000 + FROM json_each(?1) AS retired + JOIN session_temporal_observation_effects AS effect + ON effect.observation_id = retired.value + WHERE effect.output_count > 0 + ON CONFLICT(session_id) DO UPDATE SET requested_at = excluded.requested_at", + params![encoded.as_str()], + ) + .await + .map_err(|error| ProjectionStoreError::storage("request temporal reset page", error))?; + Ok(()) +} + /// [`request_session_temporal_reset`] for an observation whose output was /// retired, limited to a session where a live or pending generation already /// covers it. A refresh past that frontier skips the retired effect on its From 721fdc8a7755eb544911809df1dde85b39ba16e0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:33:52 -0800 Subject: [PATCH 63/64] test(sessions): bound projected restart fixture batches Refs #4756 Seed all 1,005 real projected observations in 128-item admission and projection windows before the late-page source-rewrite collision. Retain the same source cursor chain, durable restart regression, record volume, and assertions. Avoid a single oversized fixture writer transaction. The timeout is not yet attributed to this setup; Bazel CI is required. --- .../tracedecay-global-db/src/tests/source_rewrite.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index e7aacd807b8..6c255797d0a 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -351,7 +351,16 @@ async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() let registered = runtime .registered_database(HostAdmissionScope::Profile) .expect("registered profile database"); - let seeded = seed_projected_messages(&runtime, 0..RECORDS).await; + // Seed the full 1,005-observation production journey in bounded + // admission/projection windows. A single 1,005-item projection writer + // transaction in fixture setup obscures the restart behavior under test + // and can monopolize the writer before the rewrite even starts. + let mut seeded = Vec::with_capacity(RECORDS); + for start in (0..RECORDS).step_by(128) { + let end = (start + 128).min(RECORDS); + seeded.extend(seed_projected_messages(&runtime, start..end).await); + } + assert_eq!(seeded.len(), RECORDS, "all source records must be projected"); let source = ObservationSourceIdentityV1::for_provider( ProviderId::new("codex").unwrap(), From 0099fe6b6ebd3e60118b68be6da9348814b73be7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:20:03 -0800 Subject: [PATCH 64/64] style(sessions): apply pinned formatting to restart regression Refs #4756 Apply the single exact Bazel rustfmt hunk from CI 42736729359. Preserve the nonvacuous 1,005-observation restart test and all assertions. --- crates/tracedecay-global-db/src/tests/source_rewrite.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tracedecay-global-db/src/tests/source_rewrite.rs b/crates/tracedecay-global-db/src/tests/source_rewrite.rs index 6c255797d0a..7fcc465491b 100644 --- a/crates/tracedecay-global-db/src/tests/source_rewrite.rs +++ b/crates/tracedecay-global-db/src/tests/source_rewrite.rs @@ -360,7 +360,11 @@ async fn rewrite_completion_resumes_committed_pages_after_failure_and_restart() let end = (start + 128).min(RECORDS); seeded.extend(seed_projected_messages(&runtime, start..end).await); } - assert_eq!(seeded.len(), RECORDS, "all source records must be projected"); + assert_eq!( + seeded.len(), + RECORDS, + "all source records must be projected" + ); let source = ObservationSourceIdentityV1::for_provider( ProviderId::new("codex").unwrap(),