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:
parent
19357bca2d
commit
e3bf401f6e
1 changed files with 155 additions and 17 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue