Watch
0
0
Fork
You've already forked hyperhive
0

harness stores: a failed lookup is not no-row, bulk done is one transaction, the reminder cap is checked with the insert

This commit is contained in:
damocles 2026-11-12 12:53:28 +01:00 • committed by mara
commit e3bf401f6e

View file

@ -47,6 +47,18 @@ pub struct Reminder {
pub created_at: DateTime<Utc>,
}
/// What [`Reminders::store_within_cap`] did.
#[derive(Debug, PartialEq, Eq)]
pub enum Stored {
/// Stored, with this row id.
Row(i64),
/// Not stored: this many reminders were already pending.
AtCap {
/// The pending count that met the cap.
pending: u64,
},
}
/// The harness-local reminder store. Cheap to share behind an `Arc`; the
/// inner connection is guarded by a `Mutex` (reminder ops are short sqlite
/// writes, same as `Todos`).
@ -70,8 +82,9 @@ impl Reminders {
})
}
/// Store a new pending reminder, due at `due_at` (unix seconds).
/// Returns the new row id.
/// Store a new pending reminder with no cap, due at `due_at` (unix
/// seconds). Returns the new row id. Tests and fixtures only: the harness
/// stores through [`Self::store_within_cap`].
///
/// # Errors
///
@ -80,6 +93,7 @@ impl Reminders {
/// # Panics
///
/// Panics if the connection mutex is poisoned.
#[cfg(test)]
pub fn store(&self, message: &str, file_path: Option<&str>, due_at: i64) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let now = Utc::now().timestamp();
@ -92,6 +106,45 @@ impl Reminders {
Ok(conn.last_insert_rowid())
}
/// Store a new pending reminder unless `max` or more are already pending,
/// counting and inserting under one lock so two concurrent requests
/// cannot both pass the cap. [`Stored::AtCap`] carries the pending count.
///
/// # Errors
///
/// Propagates a sqlite count or insert failure; a failed count is an
/// error, never "zero pending".
///
/// # Panics
///
/// Panics if the connection mutex is poisoned.
pub fn store_within_cap(
&self,
message: &str,
file_path: Option<&str>,
due_at: i64,
max: u64,
) -> Result<Stored> {
let conn = self.conn.lock().unwrap();
let pending: i64 = conn.query_row(
"SELECT COUNT(*) FROM reminders WHERE sent_at IS NULL",
[],
|row| row.get(0),
)?;
let pending = u64::try_from(pending).unwrap_or(0);
if max > 0 && pending >= max {
return Ok(Stored::AtCap { pending });
}
let now = Utc::now().timestamp();
conn.execute(
"INSERT INTO reminders (message, file_path, due_at, created_at, sent_at) \
VALUES (?1, ?2, ?3, ?4, NULL)",
params![message, file_path, due_at, now],
)?;
crate::otel_turn_metrics::record_reminder("scheduled");
Ok(Stored::Row(conn.last_insert_rowid()))
}
/// Count of currently-pending (undelivered) reminders.
///
/// # Errors
@ -285,6 +338,48 @@ mod tests {
(dir, db)
}
#[test]
fn store_within_cap_stops_at_the_cap_and_a_cap_of_zero_means_none() {
let (_dir, db) = store();
assert!(matches!(
db.store_within_cap("a", None, 10, 2).unwrap(),
Stored::Row(_)
));
assert!(matches!(
db.store_within_cap("b", None, 10, 2).unwrap(),
Stored::Row(_)
));
assert_eq!(
db.store_within_cap("c", None, 10, 2).unwrap(),
Stored::AtCap { pending: 2 }
);
assert!(matches!(
db.store_within_cap("d", None, 10, 0).unwrap(),
Stored::Row(_)
));
assert_eq!(db.count_pending().unwrap(), 3);
}
#[test]
fn concurrent_stores_cannot_pass_the_cap_together() {
let (_dir, db) = store();
let stored = std::thread::scope(|scope| {
let handles: Vec<_> = (0..16)
.map(|i| {
let db = &db;
scope.spawn(move || db.store_within_cap(&format!("r{i}"), None, 10, 5).unwrap())
})
.collect();
handles
.into_iter()
.map(|h| h.join().unwrap())
.filter(|s| matches!(s, Stored::Row(_)))
.count()
});
assert_eq!(stored, 5);
assert_eq!(db.count_pending().unwrap(), 5);
}
#[test]
fn store_and_list_pending() {
let (_dir, s) = store();