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:
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user