fix(calendar): fed slots stay fed across a restart

The reminder source skipped an already-fed slot only while the slot
sat in the dispatcher's pending inbox; a claw restart drained that
inbox, and every future lead of the same occurrence re-fed — a
reminder fired two or three times per calendar event. The source
keeps its own fed-slot ledger in the calendar store now (keyed by
calendar, occurrence, and lead), records each slot right after a
successful ingest, and prunes rows whose occurrence is an hour past
every lead, so a pruned row can never resurrect a reminder. The
store-backed source's ledger survives a restart; the static-list
constructor keeps an in-memory one for the process lifetime, said so
in the docs that previously claimed dedup was someone else's problem.
Store failures surface loudly (a silently lost row is a re-fired
reminder); the prune warns and continues. Offline tests cover the
once-only rule, the restart via a tempdir reopen, and the prune.
This commit is contained in:
vhaudiquet committed 2026-11-05 19:06:41 +01:00
1 parent dad99c7664
commit 5a8be19b4c
5 files changed
+371 -40

No files matched your search

@@ -1,15 +1,50 @@
//! The calendar table: agent-managed calendars in SQLite, the same
//! The calendar store: agent-managed calendars in SQLite, the same
//! database file the dispatcher's store uses — the pattern the claw's
//! watch table established. The config stopped carrying calendars:
//! the table is the one truth, and the source reads it fresh on every
//! detect pass, so a row added over `/v1/calendars` (or a chat tool)
//! is live without a restart.
//! is live without a restart. The same store owns the reminder
//! source's fed-slot ledger: the durable half of the reminder dedup,
//! so a slot does not re-fire once its turn has drained the inbox.
use crate::reminder::CalendarWatch;
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use std::path::Path;
use std::sync::Mutex;
/// How long past its last possible due moment a fed slot's row stays
/// in the ledger. A slot only fires while its occurrence is still
/// future, so the prune rule reads `start + lead + this margin`, and
/// the margin covers a clock stepped backwards or a stalled pass
/// landing after the prune. The rows are tiny; the margin errs long.
const FED_SLOT_GRACE_SECS: i64 = 60 * 60;
/// Seconds in a minute, the unit the ledger's `lead` column carries.
const SECS_PER_MINUTE: i64 = 60;
/// The tables this store owns, created idempotently at open. The
/// dispatcher's store shares this database file, so these land beside
/// its tables, never over them.
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS calendars (
name TEXT PRIMARY KEY,
spec TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS fed_slots (
calendar TEXT NOT NULL,
occurrence TEXT NOT NULL,
lead INTEGER NOT NULL,
starts_at INTEGER NOT NULL,
fed_at INTEGER NOT NULL,
PRIMARY KEY (calendar, occurrence, lead)
);";
/// Runs the store's idempotent migration over one connection.
fn migrate(conn: &Connection) -> Result<(), std::io::Error> {
conn.execute_batch(SCHEMA).map_err(std::io::Error::other)
}
/// An open handle on the calendar table. Its own rusqlite connection
/// to the shared database file — WAL and a busy timeout, so the
/// dispatcher's connection and this one interleave through SQLite's
@@ -28,30 +63,17 @@ impl CalendarStore {
let conn = Connection::open(path).map_err(std::io::Error::other)?;
conn.busy_timeout(std::time::Duration::from_millis(5_000))
.map_err(std::io::Error::other)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS calendars (
name TEXT PRIMARY KEY,
spec TEXT NOT NULL,
created_at INTEGER NOT NULL
);",
)
.map_err(std::io::Error::other)?;
migrate(&conn)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
/// An in-memory table; tests use this.
/// An in-memory store; tests and the static-list source's ledger
/// use this.
pub fn in_memory() -> Result<Self, std::io::Error> {
let conn = Connection::open_in_memory().map_err(std::io::Error::other)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS calendars (
name TEXT PRIMARY KEY,
spec TEXT NOT NULL,
created_at INTEGER NOT NULL
);",
)
.map_err(std::io::Error::other)?;
migrate(&conn)?;
Ok(Self {
conn: Mutex::new(conn),
})
@@ -121,6 +143,92 @@ impl CalendarStore {
.map(|spec| serde_json::from_str(spec).map_err(std::io::Error::other))
.collect()
}
/// Whether this slot has already fed. The dispatcher dedups a fed
/// id only while the slot sits in its inbox; this ledger is what
/// keeps the next tick from re-ingesting a slot whose turn has
/// drained.
pub(crate) fn slot_is_fed(
&self,
calendar: &str,
occurrence: &str,
lead: u64,
) -> Result<bool, std::io::Error> {
// rusqlite binds no unsigned ints; the column is INTEGER.
let lead = i64::try_from(lead).map_err(std::io::Error::other)?;
let conn = self
.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let fed: Option<()> = conn
.query_row(
"SELECT 1 FROM fed_slots
WHERE calendar = ?1 AND occurrence = ?2 AND lead = ?3",
rusqlite::params![calendar, occurrence, lead],
|_| Ok(()),
)
.optional()
.map_err(std::io::Error::other)?;
Ok(fed.is_some())
}
/// Records a slot as fed; `false` when the ledger already held it.
/// The dedup id stays `{calendar}:{occurrence}:{lead}` downstream;
/// this table is the source's own memory of what it has fed.
pub(crate) fn mark_slot_fed(
&self,
slot: &FedSlot,
fed_at: i64,
) -> Result<bool, std::io::Error> {
// rusqlite binds no unsigned ints; the column is INTEGER.
let lead = i64::try_from(slot.lead).map_err(std::io::Error::other)?;
let conn = self
.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let inserted = conn
.execute(
"INSERT INTO fed_slots (calendar, occurrence, lead, starts_at, fed_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(calendar, occurrence, lead) DO NOTHING",
rusqlite::params![slot.calendar, slot.occurrence, lead, slot.starts_at, fed_at],
)
.map_err(std::io::Error::other)?;
Ok(inserted == 1)
}
/// Deletes the ledger's spent rows: slots whose occurrence start,
/// plus the lead, plus [`FED_SLOT_GRACE_SECS`] is already past
/// `now`. Such a slot can never be due again — a started event is
/// never fed — so pruning keeps the table bounded without ever
/// resurrecting a reminder. Returns how many rows went.
pub(crate) fn prune_fed_slots(&self, now: i64) -> Result<usize, std::io::Error> {
let conn = self
.conn
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
conn.execute(
"DELETE FROM fed_slots WHERE starts_at + lead * ?1 + ?2 < ?3",
rusqlite::params![SECS_PER_MINUTE, FED_SLOT_GRACE_SECS, now],
)
.map_err(std::io::Error::other)
}
}
/// One reminder slot the source has fed, as the ledger holds it: the
/// dedup key (calendar, occurrence, lead) plus the times the prune
/// rule needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FedSlot {
/// The calendar's name, as the dedup id spells it.
pub(crate) calendar: String,
/// The occurrence's stable vocabulary id.
pub(crate) occurrence: String,
/// The slot's lead, in minutes.
pub(crate) lead: u64,
/// When the occurrence starts, epoch seconds: what the prune rule
/// measures the slot's lifetime against.
pub(crate) starts_at: i64,
}
#[cfg(test)]
@@ -128,6 +236,9 @@ mod tests {
use super::*;
use crate::CalendarKind;
/// 2026-10-20T00:00:00Z, in seconds.
const NOW_SECS: i64 = 1_792_444_800;
fn ics(name: &str) -> CalendarWatch {
serde_json::from_str(&format!(
"{{\"name\": \"{name}\", \"kind\": \"ics\", \"url\": \"https://example.net/f.ics\"}}"
@@ -163,4 +274,79 @@ mod tests {
let err = store.add(&no_url).expect_err("an urlless ics is refused");
assert!(err.to_string().contains("url"), "got: {err}");
}
#[test]
fn a_fed_slot_is_recorded_once_per_key() {
let store = CalendarStore::in_memory().expect("store opens");
let slot = FedSlot {
calendar: "home".to_owned(),
occurrence: "ics:standup@1792445400".to_owned(),
lead: 60,
starts_at: NOW_SECS + 15 * SECS_PER_MINUTE,
};
assert!(
store.mark_slot_fed(&slot, NOW_SECS).expect("mark lands"),
"the first insert is new"
);
assert!(
!store.mark_slot_fed(&slot, NOW_SECS).expect("mark again"),
"the key dedups: the row is never rewritten"
);
assert!(
store
.slot_is_fed("home", "ics:standup@1792445400", 60)
.expect("read lands"),
);
assert!(
!store
.slot_is_fed("home", "ics:standup@1792445400", 15)
.expect("read lands"),
"another lead is its own slot"
);
assert!(
!store
.slot_is_fed("work", "ics:standup@1792445400", 60)
.expect("read lands"),
"another calendar is its own slot"
);
}
#[test]
fn the_ledger_prunes_slots_whose_occurrence_is_long_past() {
let store = CalendarStore::in_memory().expect("store opens");
// Fed two days ago, for an occurrence that started shortly
// after: every lead's moment is long gone.
let spent = FedSlot {
calendar: "home".to_owned(),
occurrence: "ics:old@1792272600".to_owned(),
lead: 15,
starts_at: NOW_SECS - 47 * 3600,
};
store
.mark_slot_fed(&spent, NOW_SECS - 47 * 3600)
.expect("mark lands");
// Still ahead of itself: the slot's last due moment is
// `starts_at + lead + grace`, and that is in the future.
let live = FedSlot {
calendar: "home".to_owned(),
occurrence: "ics:soon@1792483200".to_owned(),
lead: 15,
starts_at: NOW_SECS + 3600,
};
store.mark_slot_fed(&live, NOW_SECS).expect("mark lands");
let pruned = store.prune_fed_slots(NOW_SECS).expect("prune lands");
assert_eq!(pruned, 1, "the spent row goes, and only it");
assert!(
!store
.slot_is_fed("home", "ics:old@1792272600", 15)
.expect("read lands"),
);
assert!(
store
.slot_is_fed("home", "ics:soon@1792483200", 15)
.expect("read lands"),
"a slot that can still be due survives the prune"
);
}
}