feat(archive): prune observer frames past the retention window

Add the opportunistic prune worker for the local archive DB: it deletes
kind-24200 observer-frame scope rows whose archived_at is older than
observer_retention_days, cascading orphan GC to both derived indexes, and
keeps every other kind forever.

Triggers are startup + post-commit, detached from the archive write and
collapsed by an in-process single-flight flag plus a >=24h
archive_meta.prune_last_success_at gate into at most one scan per day.
Candidate scope keys are materialized and fixed per batch transaction; the
candidate-scoped GC cascades observer_channel_index (which the whole-scan GC
does not cover) and agent_metric_index. The observer-index insert becomes a
conditional INSERT ... SELECT so a stale backfill callback cannot resurrect
an index row for a pruned event. The prune-candidate SQL constant is colocated
with the worker so the EXPLAIN-shape test pins the shipped query.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-21 14:42:10 -04:00
parent 7da8f9abf3
commit 15e0fdf280
9 changed files with 897 additions and 61 deletions

View File

@@ -30,6 +30,7 @@
//! durability.
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use rusqlite::Connection;
use tokio::sync::{OnceCell, RwLock};
@@ -65,6 +66,11 @@ pub struct ArchiveDb {
/// Maintenance lock. Ordinary connections hold the read guard for their
/// whole lifetime; the Phase-4 conversion holds the write guard.
maintenance: RwLock<()>,
/// In-process single-flight flag for the opportunistic prune worker
/// (observer-frame retention). A `compare_exchange` admits exactly one
/// prune at a time in this process; a trigger arriving while a prune runs
/// is dropped, not queued. See [`super::prune`].
prune_running: AtomicBool,
/// Test-only path/hook overrides; always `None` in production.
#[cfg(test)]
test_seam: Option<TestSeam>,
@@ -154,6 +160,27 @@ impl ArchiveDb {
.await
.map_err(|e| format!("archive db task failed: {e}"))?
}
/// Try to claim the prune single-flight flag. Returns `Some(guard)` for the
/// one caller that transitions the flag `false -> true`; the guard clears it
/// on drop (including on early return or panic). A caller that arrives while
/// a prune is already running gets `None` and skips its pass.
pub(super) fn try_prune_guard(&self) -> Option<PruneGuard<'_>> {
self.prune_running
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
.then_some(PruneGuard(&self.prune_running))
}
}
/// RAII guard that clears the prune single-flight flag when a prune finishes.
/// Held across the blocking prune dispatch.
pub(super) struct PruneGuard<'a>(&'a AtomicBool);
impl Drop for PruneGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
#[cfg(test)]
@@ -164,6 +191,7 @@ impl ArchiveDb {
Self {
init: OnceCell::new(),
maintenance: RwLock::new(()),
prune_running: AtomicBool::new(false),
test_seam: Some(TestSeam {
path,
on_init: None,
@@ -178,6 +206,7 @@ impl ArchiveDb {
Self {
init: OnceCell::new(),
maintenance: RwLock::new(()),
prune_running: AtomicBool::new(false),
test_seam: Some(TestSeam {
path,
on_init: Some(hook),

View File

@@ -21,6 +21,7 @@ mod agent_usage;
mod archive_db;
mod metric_store;
mod pipeline;
mod prune;
pub mod retention;
pub mod store;
mod store_migrations;
@@ -186,7 +187,7 @@ pub(crate) async fn archive_candidates(
};
let commit_identity_pk = identity_pk.clone();
let commit_relay_url = relay_url.clone();
state
let result = state
.archive_db
.with_conn(move |conn| {
commit_archive(
@@ -200,7 +201,19 @@ pub(crate) async fn archive_candidates(
conn,
)
})
.await
.await?;
// Post-commit prune trigger, detached from this write. The prune's own
// single-flight flag and ≥24h gate collapse the frequent per-batch triggers
// into at most one real prune per day; a prune failure is swallowed inside
// the spawned task and never affects this archive result. Requires the app
// handle (absent in headless tests that drive `archive_candidates`
// directly), so a missing handle simply skips the trigger.
if let Some(app) = state.app_handle.lock().ok().and_then(|g| g.clone()) {
prune::trigger_prune(app);
}
Ok(result)
}
/// Validate an ephemeral observer frame (kind 24200) against ALL local rules.

View File

@@ -0,0 +1,310 @@
//! Observer-frame retention prune worker (v4).
//!
//! Deletes archived kind-24200 observer-frame rows older than the configured
//! window (`observer_retention_days`, from `archive_meta`). Every other
//! archived kind — NIP-AM metrics (44200) and any custom subscription kind — is
//! kept indefinitely and never touched here (Will's v4 ruling, 2026-08-19).
//!
//! # Scheduling
//!
//! No cron. The worker is triggered opportunistically — once shortly after
//! archive sync starts, and again after each archive commit — and gated two
//! ways so those frequent triggers collapse into at most one real prune per
//! day:
//!
//! 1. **In-process single-flight** ([`super::ArchiveDb::try_prune_guard`]): a
//! `compare_exchange` on an `AtomicBool` admits exactly one prune at a time
//! in this process; a trigger that arrives while a prune is running is
//! dropped, not queued. This is a single-process desktop app, so an
//! in-process flag is sufficient — no lease or fenced token (the v3 design's
//! fenced prune lease was cut in v4).
//! 2. **≥24h timestamp gate** (`archive_meta.prune_last_success_at`): a prune
//! that finds the last success within 24h returns immediately. The timestamp
//! is written after every clean pass (even one that deleted nothing), so a
//! burst of post-commit triggers costs one meta read apiece, not a scan.
//!
//! The gate is database-global, so a due pass does global work: it enumerates a
//! fixed worklist of every archived `(identity_pubkey, relay_url)` partition and
//! prunes each under the same cutoff, stamping success only after the whole
//! worklist completes. It never scopes to the identity/relay that happened to be
//! active when the trigger fired, so an inactive historical partition is not
//! starved and a partition failure leaves the gate unstamped for a retry.
//!
//! A prune failure is fully detached from the archive commit that triggered it:
//! [`trigger_prune`] spawns onto the async runtime and swallows the result, so a
//! prune error can never fail a user's archive write.
//!
//! # Delete discipline
//!
//! Candidate scope-row primary keys are materialized inside each batch
//! transaction and fixed for that transaction (Thufir's binding condition #4):
//! the batch deletes exactly those rows, then runs a **candidate-scoped** orphan
//! GC over only the event ids it just unscoped — never a widened whole-table
//! anti-join (`store::gc_orphaned_events` stays a rare repair pass, not the
//! normal path) and never a post-delete re-scan to discover new orphans. The GC
//! cascades BOTH derived indexes, `observer_channel_index` (the one the
//! whole-scan GC does not cover) and `agent_metric_index`, so no index row
//! outlives the canonical event it was derived from.
use rusqlite::{params, Connection};
use tauri::{AppHandle, Manager};
use crate::app_state::AppState;
use super::retention;
/// Minimum spacing between successful prune passes. Frequent triggers below
/// this interval no-op against the `prune_last_success_at` gate.
const PRUNE_MIN_INTERVAL_SECS: i64 = 24 * 60 * 60;
/// Seconds per day, for the `archived_at` cutoff arithmetic. `days` is bounded
/// to `retention::MAX_RETENTION_DAYS` (36 500), so `days * SECS_PER_DAY` cannot
/// overflow `i64`.
const SECS_PER_DAY: i64 = 24 * 60 * 60;
/// Scope-row primary keys deleted per batch transaction. Large enough that the
/// first prune over a multi-million-row archive completes in a bounded number
/// of transactions, small enough that each transaction's write lock is brief.
const PRUNE_BATCH_SIZE: i64 = 2_000;
/// The prune-candidate scan: the single global observer window. Filters scope
/// rows on `(identity_pubkey, relay_url, archived_at < cutoff)` and joins
/// `archived_events` for `kind = 24200`, selecting the scope-row primary key so
/// the batch `DELETE` can materialize its candidates. It constrains neither
/// `scope_type` nor `scope_value` — the reason the M4 scope-age index leads
/// with `archived_at` immediately after the identity/relay equality keys.
///
/// This is the production query. The EXPLAIN-shape test in `prune_tests.rs`
/// runs `EXPLAIN QUERY PLAN` against this exact constant so the covering-index
/// access path and the shipped query can never drift (Thufir's carry-forward
/// note — the constant previously lived only in the test file).
pub(super) const PRUNE_CANDIDATE_SQL: &str = "
SELECT s.id, s.scope_type, s.scope_value
FROM archived_event_scopes s
JOIN archived_events e
ON e.identity_pubkey = s.identity_pubkey
AND e.relay_url = s.relay_url
AND e.id = s.id
WHERE s.identity_pubkey = ?1
AND s.relay_url = ?2
AND s.archived_at < ?3
AND e.kind = 24200
LIMIT ?4
";
/// The fixed prune worklist: every archived `(identity_pubkey, relay_url)`
/// partition holding scope rows. Enumerated once at the start of a due pass so
/// the global gate covers the whole archive rather than just the partition that
/// happened to be active when the trigger fired. A partition with no kind-24200
/// scopes in range simply prunes nothing.
pub(super) const PRUNE_PARTITIONS_SQL: &str = "
SELECT DISTINCT identity_pubkey, relay_url FROM archived_event_scopes
";
/// Spawn a detached prune pass keyed off the app handle. Called post-commit and
/// at archive-sync startup. The prune runs on the async runtime and its result
/// is swallowed, so a prune error never propagates into the archive write that
/// triggered it.
pub fn trigger_prune(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
maybe_prune(&state).await;
});
}
/// Run one prune pass if the single-flight flag admits it. The pass is archive
/// global — it prunes every stored partition, not just the active
/// identity/relay — so it dispatches the gate check + worklist prune to the
/// blocking pool via the gated archive adapter without resolving the active
/// identity. Errors are logged, not returned: every caller is fire-and-forget.
pub async fn maybe_prune(state: &AppState) {
// Single-flight: admit exactly one prune at a time. A trigger arriving
// while a prune runs is dropped — the next post-commit trigger will pick up
// any rows this pass could not reach.
let Some(_guard) = state.archive_db.try_prune_guard() else {
return;
};
let now = super::now_secs();
if let Err(error) = state
.archive_db
.with_conn(move |conn| run_prune_if_due(conn, now))
.await
{
eprintln!("buzz-desktop: archive prune failed: {error}");
}
}
/// Enforce the ≥24h gate, then prune every partition and stamp the global
/// success timestamp.
///
/// The gate (`archive_meta.prune_last_success_at`) is database-global, so the
/// work it gates must be too: a due pass enumerates a fixed worklist of every
/// archived `(identity_pubkey, relay_url)` partition ([`PRUNE_PARTITIONS_SQL`])
/// and prunes each under the same cutoff. The timestamp is stamped only after
/// the entire worklist succeeds — including a pass that deleted nothing — so a
/// burst of triggers is bounded to a single scan per day, and a mid-worklist
/// failure leaves the gate unstamped for the idempotent pass to retry.
fn run_prune_if_due(conn: &Connection, now: i64) -> Result<(), String> {
if let Some(last) = retention::get_prune_last_success_at(conn)? {
if now - last < PRUNE_MIN_INTERVAL_SECS {
return Ok(());
}
}
let days = retention::get_observer_retention_days(conn)?;
let cutoff = now - days * SECS_PER_DAY;
let mut stmt = conn
.prepare(PRUNE_PARTITIONS_SQL)
.map_err(|e| format!("prune: prepare partition scan: {e}"))?;
let partitions: Vec<(String, String)> = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| format!("prune: query partitions: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("prune: read partition row: {e}"))?;
drop(stmt);
for (identity_pk, relay_url) in &partitions {
prune_observer_frames(conn, identity_pk, relay_url, cutoff)?;
}
retention::set_prune_last_success_at(conn, now)?;
Ok(())
}
/// Delete every observer-frame (kind 24200) scope row whose `archived_at` is
/// before `cutoff`, in bounded batches, cascading the orphan GC to both derived
/// indexes for the events those deletes leave unscoped.
///
/// Each batch is one transaction: materialize the candidate scope keys, delete
/// them, then GC only those candidate events. The candidate set is fixed per
/// transaction (binding condition #4). The loop ends when a batch returns fewer
/// candidates than the batch size — i.e. the last, partial batch.
pub(super) fn prune_observer_frames(
conn: &Connection,
identity_pk: &str,
relay_url: &str,
cutoff: i64,
) -> Result<usize, String> {
let mut total_events_deleted = 0usize;
loop {
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("prune: begin batch transaction: {e}"))?;
// Materialize the candidate scope rows in-tx and fix them for this
// transaction — (id, scope_type, scope_value); identity/relay are the
// fixed ?1/?2 params.
let mut stmt = tx
.prepare(PRUNE_CANDIDATE_SQL)
.map_err(|e| format!("prune: prepare candidate scan: {e}"))?;
let candidates: Vec<(String, String, String)> = stmt
.query_map(
params![identity_pk, relay_url, cutoff, PRUNE_BATCH_SIZE],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
},
)
.map_err(|e| format!("prune: query candidates: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("prune: read candidate row: {e}"))?;
drop(stmt);
if candidates.is_empty() {
// Nothing left in range; the empty read did no writes, so rolling
// back is the honest close (commit would be equally harmless).
drop(candidates);
let _ = tx.rollback();
break;
}
let batch_len = candidates.len();
// 1. Delete the exact candidate scope rows by full primary key.
{
let mut del_scope = tx
.prepare(
"DELETE FROM archived_event_scopes
WHERE identity_pubkey = ?1 AND relay_url = ?2
AND id = ?3 AND scope_type = ?4 AND scope_value = ?5",
)
.map_err(|e| format!("prune: prepare scope delete: {e}"))?;
for (id, scope_type, scope_value) in &candidates {
del_scope
.execute(params![identity_pk, relay_url, id, scope_type, scope_value])
.map_err(|e| format!("prune: delete scope row: {e}"))?;
}
}
// 2. Candidate-scoped orphan GC. Only the distinct event ids just
// unscoped are checked — no widened anti-join, no post-delete
// re-scan for other orphans. An event still holding another scope
// row survives; one left with zero scope rows is deleted, and its
// derived-index rows in BOTH indexes are cascaded away in the same
// transaction so neither can outlive the canonical event.
{
let mut del_event = tx
.prepare(
"DELETE FROM archived_events
WHERE identity_pubkey = ?1 AND relay_url = ?2 AND id = ?3
AND NOT EXISTS (
SELECT 1 FROM archived_event_scopes
WHERE identity_pubkey = ?1 AND relay_url = ?2 AND id = ?3
)",
)
.map_err(|e| format!("prune: prepare event GC: {e}"))?;
let mut del_observer = tx
.prepare(
"DELETE FROM observer_channel_index
WHERE identity_pubkey = ?1 AND relay_url = ?2 AND id = ?3",
)
.map_err(|e| format!("prune: prepare observer-index cascade: {e}"))?;
let mut del_metric = tx
.prepare(
"DELETE FROM agent_metric_index
WHERE identity_pubkey = ?1 AND relay_url = ?2 AND id = ?3",
)
.map_err(|e| format!("prune: prepare metric-index cascade: {e}"))?;
let mut seen = std::collections::HashSet::new();
for (id, _, _) in &candidates {
if !seen.insert(id.as_str()) {
continue;
}
let orphaned = del_event
.execute(params![identity_pk, relay_url, id])
.map_err(|e| format!("prune: gc orphaned event: {e}"))?;
if orphaned > 0 {
total_events_deleted += orphaned;
del_observer
.execute(params![identity_pk, relay_url, id])
.map_err(|e| format!("prune: cascade observer index: {e}"))?;
del_metric
.execute(params![identity_pk, relay_url, id])
.map_err(|e| format!("prune: cascade metric index: {e}"))?;
}
}
}
tx.commit()
.map_err(|e| format!("prune: commit batch: {e}"))?;
if (batch_len as i64) < PRUNE_BATCH_SIZE {
break;
}
}
Ok(total_events_deleted)
}
#[cfg(test)]
#[path = "prune_tests.rs"]
mod prune_tests;

View File

@@ -0,0 +1,465 @@
//! Behavior tests for the observer-retention prune worker.
//!
//! `#[path]`-included from `prune.rs`. `super::*` brings the prune API into
//! scope; `super::super::{store, retention}` reach the neighbouring store
//! mutators and the retention accessors. These drive the synchronous prune core
//! (`prune_observer_frames`, `run_prune_if_due`) directly against a temp-file DB
//! — the async single-flight/trigger wiring needs a Tauri `AppState` and is
//! exercised by the live lane, not here.
use super::super::{retention, store};
use super::*;
use tempfile::NamedTempFile;
const ID: &str = "idpk";
const RELAY: &str = "wss://r";
const OWNER: &str = "owner_p";
/// Open a fresh archive DB (full schema + every migration incl. M4).
fn fresh(db: &NamedTempFile) -> Connection {
store::open_archive_db(db.path()).expect("open_archive_db must succeed")
}
/// Insert an observer frame (kind 24200) with a matching owner_p scope row at a
/// given `archived_at`, plus its `observer_channel_index` status row — the full
/// shape the ingest path writes for one frame.
fn seed_observer(conn: &Connection, id: &str, archived_at: i64) {
store::upsert_archived_event(conn, ID, RELAY, id, 24200, "agent", 1000, "{}", archived_at)
.unwrap();
store::upsert_event_scope(conn, ID, RELAY, id, "owner_p", OWNER, archived_at).unwrap();
store::upsert_observer_channel_index(conn, ID, RELAY, id, Some("ch"), 1000).unwrap();
}
fn count(conn: &Connection, table: &str) -> i64 {
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
.unwrap()
}
fn event_exists(conn: &Connection, id: &str) -> bool {
conn.query_row(
"SELECT COUNT(*) FROM archived_events WHERE id = ?1",
params![id],
|r| r.get::<_, i64>(0),
)
.unwrap()
> 0
}
// ── Cutoff correctness ─────────────────────────────────────────────────────────
#[test]
fn prune_deletes_only_frames_older_than_cutoff() {
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer(&conn, "old", 100);
seed_observer(&conn, "recent", 1_000);
// Cutoff 500: "old" (100 < 500) goes, "recent" (1000) stays.
let deleted = prune_observer_frames(&conn, ID, RELAY, 500).unwrap();
assert_eq!(deleted, 1, "only the pre-cutoff frame's event is deleted");
assert!(!event_exists(&conn, "old"));
assert!(event_exists(&conn, "recent"));
// Derived index rows cascaded for the deleted event only.
assert_eq!(count(&conn, "observer_channel_index"), 1);
}
#[test]
fn prune_uses_archived_at_not_event_created_at() {
// A late-arriving OLD event (small created_at, but archived_at is recent)
// must be KEPT — age is "kept locally for N days", the scope-row archived_at.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
// created_at is fixed at 1000 in seed_observer; vary archived_at.
seed_observer(&conn, "late-old", 9_000); // archived recently
seed_observer(&conn, "genuinely-old", 100); // archived long ago
let deleted = prune_observer_frames(&conn, ID, RELAY, 5_000).unwrap();
assert_eq!(deleted, 1);
assert!(
event_exists(&conn, "late-old"),
"a late-arriving old event archived recently must be kept (archived_at basis)"
);
assert!(!event_exists(&conn, "genuinely-old"));
}
#[test]
fn prune_keeps_future_dated_frames() {
// A future-dated archived_at is never before any realistic cutoff, so it is
// never pruned — the archived_at basis is immune to a bogus-future stamp.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer(&conn, "future", i64::MAX / 2);
let deleted = prune_observer_frames(&conn, ID, RELAY, 10_000).unwrap();
assert_eq!(deleted, 0);
assert!(event_exists(&conn, "future"));
}
#[test]
fn prune_never_touches_non_observer_kinds() {
// A kind-44200 metric row with an ancient archived_at must survive: the
// candidate scan filters `e.kind = 24200`, so metrics are kept forever.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
store::upsert_archived_event(&conn, ID, RELAY, "metric", 44200, "agent", 1, "{}", 1).unwrap();
store::upsert_event_scope(&conn, ID, RELAY, "metric", "owner_p", OWNER, 1).unwrap();
seed_observer(&conn, "obs", 1);
let deleted = prune_observer_frames(&conn, ID, RELAY, 5_000).unwrap();
assert_eq!(deleted, 1, "only the observer frame is deleted");
assert!(event_exists(&conn, "metric"), "kind 44200 is kept forever");
assert!(!event_exists(&conn, "obs"));
}
// ── Multi-scope events ─────────────────────────────────────────────────────────
#[test]
fn prune_keeps_event_still_held_by_another_scope() {
// An observer frame re-scoped later under a second scope: the OLD owner_p
// scope row (archived long ago) is a prune candidate, but the RECENT
// channel_h scope row is not. Deleting only the old scope row must NOT
// delete the event — it still has a within-window scope proof.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
store::upsert_archived_event(&conn, ID, RELAY, "multi", 24200, "agent", 1000, "{}", 100)
.unwrap();
store::upsert_event_scope(&conn, ID, RELAY, "multi", "owner_p", OWNER, 100).unwrap();
store::upsert_event_scope(&conn, ID, RELAY, "multi", "channel_h", "chan", 1000).unwrap();
let deleted = prune_observer_frames(&conn, ID, RELAY, 500).unwrap();
assert_eq!(
deleted, 0,
"event retains a within-window channel_h scope, so no event delete"
);
assert!(event_exists(&conn, "multi"));
// The pre-cutoff owner_p scope row was still removed; the recent one survives.
assert_eq!(count(&conn, "archived_event_scopes"), 1);
}
// ── Orphan cascade ───────────────────────────────────────────────────────────
#[test]
fn prune_cascades_both_derived_indexes() {
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
// Observer frame with an observer_channel_index row.
seed_observer(&conn, "obs", 100);
// A kind-24200 frame that ALSO has a stale agent_metric_index row planted on
// its id (defensive: the cascade must clear both indexes for a pruned id).
let metric_row = super::super::metric_store::AgentMetricIndexRow::from_payload(
"{}", "obs", "agent", 1000, 100,
);
super::super::metric_store::insert_metric_index_row(&conn, ID, RELAY, &metric_row).unwrap();
assert_eq!(count(&conn, "agent_metric_index"), 1);
prune_observer_frames(&conn, ID, RELAY, 500).unwrap();
assert_eq!(
count(&conn, "observer_channel_index"),
0,
"observer_channel_index must be cascaded (the whole-scan GC does not cover it)"
);
assert_eq!(
count(&conn, "agent_metric_index"),
0,
"agent_metric_index must be cascaded in the same transaction"
);
}
// ── Batching ───────────────────────────────────────────────────────────────────
#[test]
fn prune_clears_more_than_one_batch() {
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
let n = super::PRUNE_BATCH_SIZE + 250; // spills into a second batch
for i in 0..n {
seed_observer(&conn, &format!("e{i}"), 100);
}
let deleted = prune_observer_frames(&conn, ID, RELAY, 500).unwrap();
assert_eq!(
deleted as i64, n,
"every old frame across batches is deleted"
);
assert_eq!(count(&conn, "archived_events"), 0);
assert_eq!(count(&conn, "archived_event_scopes"), 0);
assert_eq!(count(&conn, "observer_channel_index"), 0);
}
// ── 24h gate ─────────────────────────────────────────────────────────────────
#[test]
fn run_prune_if_due_skips_within_24h_window() {
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer(&conn, "old", 100);
retention::set_observer_retention_days(&conn, 1).unwrap();
let now = 10_000_000;
// Last success 1 hour ago → gated, no prune.
retention::set_prune_last_success_at(&conn, now - 3_600).unwrap();
run_prune_if_due(&conn, now).unwrap();
assert!(
event_exists(&conn, "old"),
"prune within 24h must be skipped"
);
assert_eq!(
retention::get_prune_last_success_at(&conn).unwrap(),
Some(now - 3_600),
"the gated pass must not advance the timestamp"
);
}
#[test]
fn run_prune_if_due_runs_after_24h_and_stamps_timestamp() {
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer(&conn, "old", 100);
retention::set_observer_retention_days(&conn, 1).unwrap();
let now = 10_000_000;
// Last success 25 hours ago → eligible.
retention::set_prune_last_success_at(&conn, now - 25 * 3_600).unwrap();
run_prune_if_due(&conn, now).unwrap();
assert!(
!event_exists(&conn, "old"),
"eligible prune deletes stale frames"
);
assert_eq!(
retention::get_prune_last_success_at(&conn).unwrap(),
Some(now),
"a clean pass stamps the success timestamp"
);
}
#[test]
fn run_prune_if_due_runs_on_never_pruned_db() {
// No prune_last_success_at row → treated as "never pruned" → eligible.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer(&conn, "old", 100);
retention::set_observer_retention_days(&conn, 1).unwrap();
assert_eq!(retention::get_prune_last_success_at(&conn).unwrap(), None);
run_prune_if_due(&conn, 10_000_000).unwrap();
assert!(!event_exists(&conn, "old"));
assert!(retention::get_prune_last_success_at(&conn)
.unwrap()
.is_some());
}
#[test]
fn run_prune_if_due_stamps_even_when_nothing_deleted() {
// A clean pass that deletes nothing still advances the gate, so a burst of
// post-commit triggers is bounded to one scan per day.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer(&conn, "recent", 9_999_999);
retention::set_observer_retention_days(&conn, 1).unwrap();
let now = 10_000_000;
run_prune_if_due(&conn, now).unwrap();
assert!(
event_exists(&conn, "recent"),
"nothing is old enough to prune"
);
assert_eq!(
retention::get_prune_last_success_at(&conn).unwrap(),
Some(now),
"an empty-but-clean pass still stamps the gate"
);
}
// ── Global gate covers every partition (not just the active one) ────────────────
/// Seed an observer frame in an explicit `(identity, relay)` partition. The
/// partition-scoped analogue of `seed_observer`, used to prove one due pass
/// prunes every stored partition — not just the active identity/relay.
fn seed_observer_in(conn: &Connection, id_pk: &str, relay: &str, id: &str, archived_at: i64) {
store::upsert_archived_event(
conn,
id_pk,
relay,
id,
24200,
"agent",
1000,
"{}",
archived_at,
)
.unwrap();
store::upsert_event_scope(conn, id_pk, relay, id, "owner_p", OWNER, archived_at).unwrap();
store::upsert_observer_channel_index(conn, id_pk, relay, id, Some("ch"), 1000).unwrap();
}
#[test]
fn run_prune_if_due_prunes_every_partition_before_stamping() {
// The gate is database-global, so one due pass must prune ALL stored
// partitions — including an inactive/historical one that no active sync is
// ever scoped to — before stamping success. Two partitions, both with an
// expired frame; neither is "the active pair" the pass resolves (it no
// longer resolves one).
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer_in(&conn, "idA", "wss://a", "old_a", 100);
seed_observer_in(&conn, "idB", "wss://b", "old_b", 100);
retention::set_observer_retention_days(&conn, 1).unwrap();
run_prune_if_due(&conn, 10_000_000).unwrap();
assert!(
!event_exists(&conn, "old_a"),
"partition A's expired frame must be pruned"
);
assert!(
!event_exists(&conn, "old_b"),
"partition B's expired frame must be pruned in the same pass"
);
assert_eq!(count(&conn, "archived_events"), 0);
assert_eq!(count(&conn, "observer_channel_index"), 0);
assert!(retention::get_prune_last_success_at(&conn)
.unwrap()
.is_some());
}
#[test]
fn run_prune_if_due_leaves_gate_unstamped_when_a_partition_fails() {
// A mid-worklist failure must leave the gate unstamped so the idempotent
// pass retries the remainder. Force the failure by dropping the metric index
// (the last cascade stage), so whichever partition is pruned first errors
// before the worklist completes.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer_in(&conn, "idA", "wss://a", "old_a", 100);
seed_observer_in(&conn, "idB", "wss://b", "old_b", 100);
retention::set_observer_retention_days(&conn, 1).unwrap();
conn.execute_batch("DROP TABLE agent_metric_index").unwrap();
let result = run_prune_if_due(&conn, 10_000_000);
assert!(
result.is_err(),
"a failing partition must surface the error"
);
assert_eq!(
retention::get_prune_last_success_at(&conn).unwrap(),
None,
"a partial worklist must not stamp success — the pass retries"
);
}
// ── Stale-backfill orphan race (conditional observer-index insert) ──────────────
#[test]
fn observer_index_insert_is_skipped_when_event_is_gone() {
// The stale-backfill race: the TS driver snapshots an unindexed id, the
// frame is pruned, then the driver calls back to index it. The conditional
// INSERT ... SELECT must NOT resurrect an index row for the absent event.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
// No archived_events row for "gone" — simulate it already pruned away.
store::upsert_observer_channel_index(&conn, ID, RELAY, "gone", Some("ch"), 1000).unwrap();
assert_eq!(
count(&conn, "observer_channel_index"),
0,
"no index row may be created for an event that no longer exists"
);
}
#[test]
fn observer_index_insert_succeeds_when_event_present() {
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
store::upsert_archived_event(&conn, ID, RELAY, "here", 24200, "agent", 1000, "{}", 100)
.unwrap();
store::upsert_observer_channel_index(&conn, ID, RELAY, "here", Some("ch"), 1000).unwrap();
assert_eq!(count(&conn, "observer_channel_index"), 1);
}
// ── First-archive ON CONFLICT DO NOTHING pin ────────────────────────────────────
#[test]
fn observer_index_insert_is_idempotent_on_conflict() {
// Re-indexing an already-indexed frame is a no-op (first write wins), even
// now that the insert is gated on the canonical event.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
store::upsert_archived_event(&conn, ID, RELAY, "e", 24200, "agent", 1000, "{}", 100).unwrap();
store::upsert_observer_channel_index(&conn, ID, RELAY, "e", Some("first"), 1000).unwrap();
store::upsert_observer_channel_index(&conn, ID, RELAY, "e", Some("second"), 2000).unwrap();
let ch: Option<String> = conn
.query_row(
"SELECT channel_id FROM observer_channel_index WHERE id = 'e'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
ch.as_deref(),
Some("first"),
"first write wins on PK conflict"
);
}
// ── Batch atomicity (forced error between delete stages) ────────────────────────
#[test]
fn prune_batch_rolls_back_fully_when_a_cascade_fails() {
// The delete stages run in one transaction: scope delete → event GC →
// observer-index cascade → metric-index cascade. If a later stage errors,
// the whole batch must roll back so no event is left with orphaned index
// rows (or, conversely, an index row orphaned by a half-applied delete).
// Force the failure by removing agent_metric_index, so the metric cascade —
// the last stage — errors after the earlier deletes have run in-tx.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
seed_observer(&conn, "old", 100);
conn.execute_batch("DROP TABLE agent_metric_index").unwrap();
let result = prune_observer_frames(&conn, ID, RELAY, 500);
assert!(result.is_err(), "the failed metric cascade must surface");
// Every earlier delete in the batch is rolled back: the frame, its scope
// row, and its observer index row all survive intact.
assert!(
event_exists(&conn, "old"),
"a mid-batch cascade failure must roll back the event delete"
);
assert_eq!(count(&conn, "archived_event_scopes"), 1);
assert_eq!(count(&conn, "observer_channel_index"), 1);
}
// ── EXPLAIN-shape against the real worker SQL ───────────────────────────────────
#[test]
fn prune_candidate_scan_seeks_archived_at_through_the_index() {
// Pinned against the SHIPPED PRUNE_CANDIDATE_SQL constant (the same one the
// worker prepares), so the covering-index access path and the production
// query cannot drift.
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db);
let plan: Vec<String> = conn
.prepare(&format!("EXPLAIN QUERY PLAN {PRUNE_CANDIDATE_SQL}"))
.unwrap()
.query_map(params![ID, RELAY, 0_i64, PRUNE_BATCH_SIZE], |r| {
r.get::<_, String>(3)
})
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let scope_step = plan
.iter()
.find(|d| d.contains(retention::SCOPE_AGE_INDEX_NAME))
.unwrap_or_else(|| {
panic!(
"prune scan must use {}; plan was {plan:?}",
retention::SCOPE_AGE_INDEX_NAME
)
});
assert!(
scope_step.contains("archived_at<?"),
"scope step must range-seek archived_at, got: {scope_step}"
);
assert!(
!plan.iter().any(|d| d.contains("USE TEMP B-TREE")),
"age-first index must remove the ORDER-BY temp b-tree; plan was {plan:?}"
);
}

View File

@@ -31,6 +31,13 @@ pub const DEFAULT_OBSERVER_RETENTION_DAYS: i64 = 30;
/// admitting any realistic user choice.
pub const MAX_RETENTION_DAYS: i64 = 36_500;
/// `archive_meta` key holding the unix-seconds timestamp of the last successful
/// prune pass, stored as its decimal text. The prune worker reads this to
/// enforce its ≥24h gate and writes it after a clean pass (see
/// [`super::prune`]). Kept here beside the retention window because
/// `retention.rs` owns every `archive_meta` accessor.
pub const PRUNE_LAST_SUCCESS_AT_KEY: &str = "prune_last_success_at";
// ── Schema (created by migration M4, not the base SCHEMA) ─────────────────────
/// `archive_meta`. Created inside M4 under `BEGIN IMMEDIATE` (see
@@ -148,6 +155,42 @@ pub fn set_observer_retention_days(conn: &Connection, days: i64) -> Result<(), S
Ok(())
}
// ── Prune timestamp (archive_meta accessor) ─────────────────────────────────────
/// Read the unix-seconds timestamp of the last successful prune pass, or `None`
/// when no prune has ever completed (the seeded DB has no such row). A `None`
/// means "never pruned", which the ≥24h gate treats as immediately eligible.
pub fn get_prune_last_success_at(conn: &Connection) -> Result<Option<i64>, String> {
let raw: Option<String> = conn
.query_row(
"SELECT value FROM archive_meta WHERE key = ?1",
params![PRUNE_LAST_SUCCESS_AT_KEY],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("read prune last success at: {e}"))?;
match raw {
Some(s) => s
.parse::<i64>()
.map(Some)
.map_err(|e| format!("prune last success at not an integer ({s:?}): {e}")),
None => Ok(None),
}
}
/// Record `at` (unix seconds) as the timestamp of the most recent successful
/// prune pass. A single idempotent upsert, atomic under autocommit.
pub fn set_prune_last_success_at(conn: &Connection, at: i64) -> Result<(), String> {
conn.execute(
"INSERT INTO archive_meta (key, value) VALUES (?1, ?2)
ON CONFLICT (key) DO UPDATE SET value = excluded.value",
params![PRUNE_LAST_SUCCESS_AT_KEY, at.to_string()],
)
.map_err(|e| format!("set prune last success at: {e}"))?;
Ok(())
}
// ── Size accounting ─────────────────────────────────────────────────────────────
/// Collect physical (file) and logical (page) size figures for the archive DB.

View File

@@ -383,58 +383,3 @@ fn test_m4_two_conn_first_open_race_neither_times_out_and_marks_once() {
DEFAULT_OBSERVER_RETENTION_DAYS
);
}
// ── Prune-candidate access path (index shape) ──────────────────────────────────
/// The Phase-2 prune-candidate scan (per `PLANS/ARCHIVE_RETENTION_PLAN.md`
/// line 40): the single global observer window filters `identity + relay +
/// archived_at < cutoff`, joins `archived_events` for `kind = 24200`, and
/// selects the scope-row PK so the bounded `DELETE` can materialize candidates.
/// The scope side constrains neither `scope_type` nor `scope_value`.
const PRUNE_CANDIDATE_SQL: &str = "
SELECT s.id, s.scope_type, s.scope_value
FROM archived_event_scopes s
JOIN archived_events e
ON e.identity_pubkey = s.identity_pubkey
AND e.relay_url = s.relay_url
AND e.id = s.id
WHERE s.identity_pubkey = ?1
AND s.relay_url = ?2
AND s.archived_at < ?3
AND e.kind = 24200
LIMIT 1000
";
/// The scope-age index must let the real Phase-2 prune query range-seek
/// `archived_at` directly rather than fall back to a bare identity/relay seek
/// plus a temp b-tree. The index is this PR's deliverable, so its access path
/// is pinned here even though the prune worker lands in Phase 2.
#[test]
fn test_prune_candidate_scan_seeks_archived_at_through_the_index() {
let db = NamedTempFile::new().unwrap();
let conn = fresh(&db); // runs M4, creating the scope-age index
let plan: Vec<String> = conn
.prepare(&format!("EXPLAIN QUERY PLAN {PRUNE_CANDIDATE_SQL}"))
.unwrap()
.query_map(params![ID, RELAY, 0_i64], |r| r.get::<_, String>(3))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let scope_step = plan
.iter()
.find(|d| d.contains(SCOPE_AGE_INDEX_NAME))
.unwrap_or_else(|| panic!("prune scan must use {SCOPE_AGE_INDEX_NAME}; plan was {plan:?}"));
// The planner range-seeks archived_at through the index (equality on the
// two leading identity/relay keys, then the age bound) — not a bare
// identity/relay seek that would leave age to a scan / temp b-tree.
assert!(
scope_step.contains("archived_at<?"),
"scope step must range-seek archived_at, got: {scope_step}"
);
assert!(
!plan.iter().any(|d| d.contains("USE TEMP B-TREE")),
"age-first index must remove the ORDER-BY temp b-tree; plan was {plan:?}"
);
}

View File

@@ -697,9 +697,21 @@ pub fn read_archived_events(
/// Will's (a) ruling (2026-07-08), while ensuring the row exists so a re-run
/// is a no-op.
///
/// **Conditional on the canonical event.** The insert is an `INSERT ... SELECT`
/// gated on the matching `archived_events` row still existing, so a derived
/// index row can never be created for an event that is absent — the
/// stale-backfill orphan race. The TS backfill driver reads a snapshot of
/// unindexed rows, decrypts each asynchronously, then calls back here; a prune
/// pass (or a concurrent GC) can delete the underlying observer frame in that
/// window, and an unconditional insert would resurrect an index row pointing at
/// a deleted event — an orphan the prune's candidate-scoped GC already ran past.
/// Gating the insert on `EXISTS(archived_events)` closes that window at the
/// write itself rather than relying on a later repair pass.
///
/// Idempotent: if the (identity, relay, id) PK already exists the row is left
/// unchanged (INSERT OR IGNORE). Called both at ingest time (new frames) and
/// from the one-shot backfill for existing archived rows.
/// unchanged (`ON CONFLICT DO NOTHING`). Called both at ingest time (new
/// frames, where the canonical row was just inserted in the same transaction)
/// and from the one-shot backfill for existing archived rows.
pub fn upsert_observer_channel_index(
conn: &Connection,
identity_pubkey: &str,
@@ -709,9 +721,14 @@ pub fn upsert_observer_channel_index(
created_at: i64,
) -> Result<(), String> {
conn.execute(
"INSERT OR IGNORE INTO observer_channel_index
"INSERT INTO observer_channel_index
(identity_pubkey, relay_url, id, channel_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
SELECT ?1, ?2, ?3, ?4, ?5
WHERE EXISTS (
SELECT 1 FROM archived_events
WHERE identity_pubkey = ?1 AND relay_url = ?2 AND id = ?3
)
ON CONFLICT (identity_pubkey, relay_url, id) DO NOTHING",
params![identity_pubkey, relay_url, event_id, channel_id, created_at],
)
.map_err(|e| format!("failed to upsert observer_channel_index: {e}"))?;

View File

@@ -744,6 +744,9 @@ fn test_upsert_observer_channel_index_non_null_is_idempotent() {
let pk = "owner";
let relay = "wss://r";
// The insert is gated on the canonical event existing (stale-backfill
// orphan race fix), so seed it first.
upsert_archived_event(&conn, pk, relay, "ev1", 24200, "agent", 1000, "{}", 0).unwrap();
upsert_observer_channel_index(&conn, pk, relay, "ev1", Some("ch-abc"), 1000).unwrap();
// Second call with same PK → INSERT OR IGNORE, no error, row unchanged.
upsert_observer_channel_index(&conn, pk, relay, "ev1", Some("ch-abc"), 2000).unwrap();
@@ -775,6 +778,8 @@ fn test_upsert_observer_channel_index_null_channel_id_is_idempotent() {
let relay = "wss://r";
// Write a NULL channel_id status row (unscoped / decrypt-failed frame).
// Gated on the canonical event existing, so seed it first.
upsert_archived_event(&conn, pk, relay, "ev-null", 24200, "agent", 500, "{}", 0).unwrap();
upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 500).unwrap();
// Re-run → no-op.
upsert_observer_channel_index(&conn, pk, relay, "ev-null", None, 600).unwrap();

View File

@@ -594,6 +594,15 @@ pub async fn start_archive_sync(
run_sync(&io, reload, events, cancel).await;
session.set_subscriptions(Vec::new()).await;
});
// Startup prune trigger: one opportunistic pass shortly after sync begins,
// so a launch that never archives anything (idle agents) still prunes stale
// observer frames once its ≥24h gate allows. The pass is archive-global — it
// enumerates every stored partition, not the identity/relay this sync is
// for — so hooking it here rather than at unconditional startup costs only
// latency to the first pass, never coverage. Detached and gated exactly like
// the post-commit trigger; a prune failure never affects sync startup.
super::prune::trigger_prune(app);
Ok(())
}