1use std::collections::BTreeSet;
17use std::fmt;
18
19use rusqlite::types::Type;
20use rusqlite::{Connection, OptionalExtension, Row, Transaction, TransactionBehavior, params};
21
22use crate::db::StoreError;
23use crate::domain::trigger::{Effect, Trigger, TriggerKind, TriggerState};
24use crate::ids::TRIGGER_ID_PREFIX;
25use crate::work_state::local::LocalSqlite;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct NewTrigger<'a> {
31 pub id: &'a str,
32 pub kind: TriggerKind,
33 pub ticket_id: Option<&'a str>,
34 pub project_id: Option<&'a str>,
35 pub eligible_at_ms: Option<i64>,
36 pub interval_ms: Option<i64>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct QueuedTrigger {
43 pub id: String,
44 pub kind: TriggerKind,
45 pub ticket_id: Option<String>,
46 pub project_id: Option<String>,
47 pub eligible_at_ms: Option<i64>,
48 pub interval_ms: Option<i64>,
49}
50
51impl From<&QueuedTrigger> for Trigger {
52 fn from(queued: &QueuedTrigger) -> Self {
53 Self {
54 state: TriggerState::Queued,
55 kind: queued.kind,
56 eligible_at_ms: queued.eligible_at_ms,
57 interval_ms: queued.interval_ms,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Duplicates {
67 Reuse,
70 Allow,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct EnqueueRequest<'a> {
78 pub kind: TriggerKind,
79 pub ticket_id: Option<&'a str>,
80 pub project_id: Option<&'a str>,
81 pub eligible_at_ms: Option<i64>,
82 pub interval_ms: Option<i64>,
83 pub filters: &'a [String],
84 pub duplicates: Duplicates,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Enqueued {
89 pub id: String,
90 pub reused: bool,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub(crate) struct DeletionPlan {
99 pub(crate) doomed: BTreeSet<String>,
101 pub(crate) unpinned: Vec<String>,
104}
105
106pub(crate) fn enqueue(
116 transaction: &Transaction<'_>,
117 request: &EnqueueRequest<'_>,
118 now_ms: i64,
119) -> Result<Enqueued, StoreError> {
120 let existing = match (request.duplicates, request.ticket_id) {
121 (Duplicates::Reuse, Some(ticket_id)) => {
122 queued_of_kind(transaction, ticket_id, request.kind)?
123 }
124 _ => None,
125 };
126 let (id, reused) = match existing {
127 Some(id) => {
128 if let Some(eligible_at_ms) = request.eligible_at_ms {
131 reschedule(transaction, &id, eligible_at_ms, now_ms)?;
132 }
133 (id, true)
134 }
135 None => {
136 let id = format!("{TRIGGER_ID_PREFIX}{}", reserve_ordinal(transaction)?);
137 insert(
138 transaction,
139 &NewTrigger {
140 id: &id,
141 kind: request.kind,
142 ticket_id: request.ticket_id,
143 project_id: request.project_id,
144 eligible_at_ms: request.eligible_at_ms,
145 interval_ms: request.interval_ms,
146 },
147 now_ms,
148 )?;
149 (id, false)
150 }
151 };
152 for ticket_id in request.filters {
153 insert_filter(transaction, &id, ticket_id)?;
154 }
155 Ok(Enqueued { id, reused })
156}
157
158pub(crate) fn insert(
159 connection: &Connection,
160 trigger: &NewTrigger<'_>,
161 now_ms: i64,
162) -> Result<(), StoreError> {
163 connection.execute(
164 "INSERT INTO triggers
165 (id, kind, state, ticket_id, project_id, eligible_at_ms, interval_ms,
166 created_at_ms, updated_at_ms)
167 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
168 params![
169 trigger.id,
170 trigger.kind.as_str(),
171 TriggerState::Queued.as_str(),
172 trigger.ticket_id,
173 trigger.project_id,
174 trigger.eligible_at_ms,
175 trigger.interval_ms,
176 now_ms,
177 ],
178 )?;
179 Ok(())
180}
181
182pub(crate) fn insert_filter(
183 connection: &Connection,
184 trigger_id: &str,
185 ticket_id: &str,
186) -> Result<(), StoreError> {
187 connection.execute(
188 "INSERT OR IGNORE INTO trigger_filters (trigger_id, ticket_id) VALUES (?1, ?2)",
189 params![trigger_id, ticket_id],
190 )?;
191 Ok(())
192}
193
194pub(crate) fn queued_of_kind(
197 connection: &Connection,
198 ticket_id: &str,
199 kind: TriggerKind,
200) -> Result<Option<String>, StoreError> {
201 connection
202 .query_row(
203 "SELECT id FROM triggers
204 WHERE ticket_id = ?1 AND kind = ?2 AND state = 'queued'
205 ORDER BY created_at_ms LIMIT 1",
206 params![ticket_id, kind.as_str()],
207 |row| row.get(0),
208 )
209 .optional()
210 .map_err(StoreError::from)
211}
212
213pub(crate) fn reschedule(
216 connection: &Connection,
217 trigger_id: &str,
218 eligible_at_ms: i64,
219 now_ms: i64,
220) -> Result<(), StoreError> {
221 connection.execute(
222 "UPDATE triggers
223 SET eligible_at_ms = ?2, updated_at_ms = ?3
224 WHERE id = ?1 AND state = 'queued'",
225 params![trigger_id, eligible_at_ms, now_ms],
226 )?;
227 Ok(())
228}
229
230pub(crate) fn consume(
237 connection: &Connection,
238 trigger_id: &str,
239 effects: &[Effect],
240 now_ms: i64,
241) -> Result<(), StoreError> {
242 for effect in effects {
243 let changed = match effect {
244 Effect::Rearm { eligible_at_ms } => connection.execute(
245 "UPDATE triggers
246 SET eligible_at_ms = ?2, updated_at_ms = ?3
247 WHERE id = ?1 AND state = 'queued' AND kind = 'every'",
248 params![trigger_id, eligible_at_ms, now_ms],
249 )?,
250 Effect::Complete => connection.execute(
251 "UPDATE triggers SET state = 'completed', updated_at_ms = ?2
252 WHERE id = ?1 AND state = 'queued' AND kind != 'every'",
253 params![trigger_id, now_ms],
254 )?,
255 Effect::Fault(_) | Effect::Requeue { .. } => {
258 return Err(StoreError::TriggerNotQueued {
259 trigger_id: trigger_id.into(),
260 });
261 }
262 };
263 if changed != 1 {
264 return Err(StoreError::TriggerNotQueued {
265 trigger_id: trigger_id.into(),
266 });
267 }
268 }
269 Ok(())
270}
271
272pub(crate) fn requeue(
275 connection: &Connection,
276 trigger_id: &str,
277 eligible_at_ms: i64,
278 now_ms: i64,
279) -> Result<usize, StoreError> {
280 connection
281 .execute(
282 "UPDATE triggers
283 SET state = 'queued', eligible_at_ms = ?2, updated_at_ms = ?3
284 WHERE id = ?1",
285 params![trigger_id, eligible_at_ms, now_ms],
286 )
287 .map_err(StoreError::from)
288}
289
290pub(crate) fn complete_for_ticket(
302 connection: &Connection,
303 ticket_id: &str,
304 now_ms: i64,
305) -> Result<usize, StoreError> {
306 connection
307 .execute(
308 "UPDATE triggers SET state = 'completed', updated_at_ms = ?2
309 WHERE ticket_id = ?1 AND state = 'queued'",
310 params![ticket_id, now_ms],
311 )
312 .map_err(StoreError::from)
313}
314
315pub(crate) fn facts(
318 connection: &Connection,
319 trigger_id: &str,
320) -> Result<Option<Trigger>, StoreError> {
321 let row: Option<(String, String, Option<i64>, Option<i64>)> = connection
322 .query_row(
323 "SELECT state, kind, eligible_at_ms, interval_ms FROM triggers WHERE id = ?1",
324 params![trigger_id],
325 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
326 )
327 .optional()?;
328 let Some((state, kind, eligible_at_ms, interval_ms)) = row else {
329 return Ok(None);
330 };
331 let state = TriggerState::parse(&state).ok_or_else(|| {
332 StoreError::from(rusqlite::Error::FromSqlConversionFailure(
333 0,
334 Type::Text,
335 Box::new(UnknownTriggerKind(state)),
336 ))
337 })?;
338 let kind = TriggerKind::parse(&kind).ok_or_else(|| {
339 StoreError::from(rusqlite::Error::FromSqlConversionFailure(
340 1,
341 Type::Text,
342 Box::new(UnknownTriggerKind(kind)),
343 ))
344 })?;
345 Ok(Some(Trigger {
346 state,
347 kind,
348 eligible_at_ms,
349 interval_ms,
350 }))
351}
352
353pub(crate) fn queued(connection: &Connection) -> Result<Vec<QueuedTrigger>, StoreError> {
356 let mut statement = connection.prepare(
357 "SELECT id, kind, ticket_id, project_id, eligible_at_ms, interval_ms
358 FROM triggers WHERE state = 'queued'
359 ORDER BY created_at_ms, id",
360 )?;
361 let triggers = statement
362 .query_map([], queued_trigger)?
363 .collect::<Result<Vec<_>, _>>()?;
364 Ok(triggers)
365}
366
367pub(crate) fn dispatchable(
369 connection: &Connection,
370 now_ms: i64,
371) -> Result<Vec<QueuedTrigger>, StoreError> {
372 let mut statement = connection.prepare(&format!(
373 "SELECT id, kind, ticket_id, project_id, eligible_at_ms, interval_ms
374 FROM triggers
375 WHERE {}
376 ORDER BY created_at_ms, id",
377 due_predicate("", "?1")
378 ))?;
379 let triggers = statement
380 .query_map(params![now_ms], queued_trigger)?
381 .collect::<Result<Vec<_>, _>>()?;
382 Ok(triggers)
383}
384
385pub(crate) fn next_eligible_at_ms(
388 connection: &Connection,
389 now_ms: i64,
390) -> Result<Option<i64>, StoreError> {
391 connection
392 .query_row(
393 "SELECT MIN(eligible_at_ms) FROM triggers
394 WHERE state = 'queued' AND eligible_at_ms > ?1",
395 params![now_ms],
396 |row| row.get(0),
397 )
398 .map_err(StoreError::from)
399}
400
401pub(crate) fn claimable_on(
405 connection: &Connection,
406 ticket_id: &str,
407 now_ms: i64,
408) -> Result<Option<QueuedTrigger>, StoreError> {
409 connection
410 .query_row(
411 &format!(
412 "SELECT tr.id, tr.kind, tr.ticket_id, tr.project_id, tr.eligible_at_ms,
413 tr.interval_ms
414 FROM triggers tr
415 JOIN tickets t ON t.id = ?1
416 WHERE {due}
417 AND {targets}
418 AND {passes_filters}
419 ORDER BY tr.created_at_ms, tr.id
420 LIMIT 1",
421 due = due_predicate("tr.", "?2"),
422 targets = TARGETS_TICKET,
423 passes_filters = passes_filters("tr.id"),
424 ),
425 params![ticket_id, now_ms],
426 queued_trigger,
427 )
428 .optional()
429 .map_err(StoreError::from)
430}
431
432pub(crate) fn for_release(
437 connection: &Connection,
438 ticket_id: &str,
439) -> Result<Option<String>, StoreError> {
440 connection
441 .query_row(
442 &format!(
443 "SELECT tr.id
444 FROM triggers tr
445 JOIN tickets t ON t.id = ?1
446 WHERE (tr.state = 'completed' OR (tr.state = 'queued' AND tr.kind = 'every'))
447 AND {targets}
448 AND {passes_filters}
449 ORDER BY tr.updated_at_ms DESC, tr.created_at_ms, tr.id
450 LIMIT 1",
451 targets = TARGETS_TICKET,
452 passes_filters = passes_filters("tr.id"),
453 ),
454 params![ticket_id],
455 |row| row.get(0),
456 )
457 .optional()
458 .map_err(StoreError::from)
459}
460
461pub(crate) fn deletion_plan(
464 connection: &Connection,
465 stale_tickets: &[String],
466 stale_projects: &[String],
467) -> Result<DeletionPlan, StoreError> {
468 let mut plan = DeletionPlan::default();
469 for ticket_id in stale_tickets {
473 let mut statement = connection.prepare("SELECT id FROM triggers WHERE ticket_id = ?1")?;
474 plan.doomed.extend(
475 statement
476 .query_map(params![ticket_id], |row| row.get::<_, String>(0))?
477 .collect::<Result<Vec<_>, _>>()?,
478 );
479 }
480 for project_id in stale_projects {
484 let mut statement =
485 connection.prepare("SELECT id, state FROM triggers WHERE project_id = ?1")?;
486 let triggers = statement
487 .query_map(params![project_id], |row| {
488 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
489 })?
490 .collect::<Result<Vec<_>, _>>()?;
491 for (trigger_id, state) in triggers {
492 if state == TriggerState::Queued.as_str() {
493 plan.doomed.insert(trigger_id);
494 } else {
495 plan.unpinned.push(trigger_id);
496 }
497 }
498 }
499 Ok(plan)
500}
501
502pub(crate) fn apply_deletion(
505 connection: &Connection,
506 plan: &DeletionPlan,
507) -> Result<usize, StoreError> {
508 for trigger_id in &plan.unpinned {
509 connection.execute(
510 "UPDATE triggers SET project_id = NULL WHERE id = ?1",
511 params![trigger_id],
512 )?;
513 }
514 let mut deleted = 0;
515 for trigger_id in &plan.doomed {
516 deleted += connection.execute(
517 "DELETE FROM trigger_filters WHERE trigger_id = ?1",
518 params![trigger_id],
519 )?;
520 deleted += connection.execute("DELETE FROM triggers WHERE id = ?1", params![trigger_id])?;
521 }
522 Ok(deleted)
523}
524
525pub(crate) fn delete_filters_for_ticket(
529 connection: &Connection,
530 ticket_id: &str,
531) -> Result<usize, StoreError> {
532 connection
533 .execute(
534 "DELETE FROM trigger_filters WHERE ticket_id = ?1",
535 params![ticket_id],
536 )
537 .map_err(StoreError::from)
538}
539
540fn reserve_ordinal(connection: &Connection) -> Result<i64, StoreError> {
544 let reserved: i64 = connection.query_row(
545 "SELECT next_ordinal FROM id_counters WHERE kind = 'trigger'",
546 [],
547 |row| row.get(0),
548 )?;
549 let existing: i64 = connection.query_row(
552 "SELECT COALESCE(MAX(CAST(SUBSTR(id, 3) AS INTEGER)), 0) + 1 FROM triggers",
553 [],
554 |row| row.get(0),
555 )?;
556 let ordinal = reserved.max(existing);
557 connection.execute(
558 "UPDATE id_counters SET next_ordinal = ?1 WHERE kind = 'trigger'",
559 params![ordinal + 1],
560 )?;
561 Ok(ordinal)
562}
563
564fn due_predicate(alias: &str, now: &str) -> String {
569 format!(
570 "{alias}state = 'queued' \
571 AND ({alias}kind IN ('immediate', 'auto') OR {alias}eligible_at_ms <= {now})"
572 )
573}
574
575const TARGETS_TICKET: &str = "(tr.ticket_id = t.id
578 OR (tr.ticket_id IS NULL
579 AND (tr.project_id IS NULL OR tr.project_id = t.project_id)))";
580
581pub(crate) fn passes_filters(trigger: &str) -> String {
589 format!(
590 "(NOT EXISTS (SELECT 1 FROM trigger_filters f
591 WHERE f.trigger_id = {trigger})
592 OR EXISTS (SELECT 1 FROM trigger_filters f
593 WHERE f.trigger_id = {trigger} AND f.ticket_id = t.id))"
594 )
595}
596
597#[derive(Debug)]
598struct UnknownTriggerKind(String);
599
600impl fmt::Display for UnknownTriggerKind {
601 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
602 write!(formatter, "unknown trigger kind `{}`", self.0)
603 }
604}
605
606impl std::error::Error for UnknownTriggerKind {}
607
608fn queued_trigger(row: &Row<'_>) -> rusqlite::Result<QueuedTrigger> {
609 let raw: String = row.get(1)?;
610 let kind = TriggerKind::parse(&raw).ok_or_else(|| {
611 rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(UnknownTriggerKind(raw)))
612 })?;
613 Ok(QueuedTrigger {
614 id: row.get(0)?,
615 kind,
616 ticket_id: row.get(2)?,
617 project_id: row.get(3)?,
618 eligible_at_ms: row.get(4)?,
619 interval_ms: row.get(5)?,
620 })
621}
622
623impl LocalSqlite {
624 pub(crate) fn enqueue_trigger(
628 &self,
629 request: &EnqueueRequest<'_>,
630 now_ms: i64,
631 ) -> Result<Enqueued, StoreError> {
632 let db = self.db();
633 let mut connection = db.lock();
634 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
635 let enqueued = enqueue(&transaction, request, now_ms)?;
636 transaction.commit()?;
637 Ok(enqueued)
638 }
639
640 pub fn insert_trigger(&self, trigger: &NewTrigger<'_>, now_ms: i64) -> Result<(), StoreError> {
645 let db = self.db();
646 let connection = db.lock();
647 insert(&connection, trigger, now_ms)
648 }
649
650 pub fn insert_trigger_filter(
652 &self,
653 trigger_id: &str,
654 ticket_id: &str,
655 ) -> Result<(), StoreError> {
656 let db = self.db();
657 let connection = db.lock();
658 insert_filter(&connection, trigger_id, ticket_id)
659 }
660
661 pub fn queued_triggers(&self) -> Result<Vec<QueuedTrigger>, StoreError> {
662 let db = self.db();
663 let connection = db.lock();
664 queued(&connection)
665 }
666
667 pub fn dispatchable_triggers(&self, now_ms: i64) -> Result<Vec<QueuedTrigger>, StoreError> {
668 let db = self.db();
669 let connection = db.lock();
670 dispatchable(&connection, now_ms)
671 }
672
673 pub fn has_claimable_trigger(&self, ticket_id: &str, now_ms: i64) -> Result<bool, StoreError> {
677 let db = self.db();
678 let connection = db.lock();
679 Ok(claimable_on(&connection, ticket_id, now_ms)?.is_some())
680 }
681
682 pub fn next_trigger_eligible_at_ms(&self, now_ms: i64) -> Result<Option<i64>, StoreError> {
683 let db = self.db();
684 let connection = db.lock();
685 next_eligible_at_ms(&connection, now_ms)
686 }
687
688 pub fn complete_merged_ticket_triggers(
698 &self,
699 now_ms: i64,
700 ) -> Result<Vec<(String, String)>, StoreError> {
701 let db = self.db();
702 let mut connection = db.lock();
703 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
704 let stranded = {
705 let mut statement = transaction.prepare(
706 "SELECT tr.id, tr.ticket_id
707 FROM triggers tr
708 JOIN tickets t ON t.id = tr.ticket_id
709 WHERE tr.state = 'queued' AND t.state = 'merged'
710 ORDER BY tr.created_at_ms, tr.id",
711 )?;
712 statement
713 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
714 .collect::<Result<Vec<(String, String)>, _>>()?
715 };
716 if stranded.is_empty() {
717 return Ok(Vec::new());
718 }
719 let tickets: BTreeSet<&str> = stranded
722 .iter()
723 .map(|(_, ticket_id)| ticket_id.as_str())
724 .collect();
725 for ticket_id in tickets {
726 complete_for_ticket(&transaction, ticket_id, now_ms)?;
727 }
728 transaction.commit()?;
729 Ok(stranded)
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use tempfile::tempdir;
736
737 use super::*;
738 use crate::db::Db;
739 use crate::domain::ticket::TicketState;
740 use crate::domain::trigger::{Event, Fault, step};
741
742 fn seeded(path: &std::path::Path) -> LocalSqlite {
743 let store = LocalSqlite::from_db(Db::open(path, 1_000).unwrap());
744 store
745 .insert_local_project("default", "projects/default.md", "Default", 1_000)
746 .unwrap();
747 for ticket in ["T1", "T2", "T3"] {
748 store
749 .insert_local_ticket(
750 ticket,
751 "default",
752 &format!("tickets/{ticket}.md"),
753 ticket,
754 &[],
755 &format!("sloop/{ticket}"),
756 Some("claude"),
757 None,
758 None,
759 "default",
760 TicketState::Ready,
761 1_000,
762 )
763 .unwrap();
764 }
765 store
766 }
767
768 fn request<'a>(kind: TriggerKind, filters: &'a [String]) -> EnqueueRequest<'a> {
769 EnqueueRequest {
770 kind,
771 ticket_id: None,
772 project_id: None,
773 eligible_at_ms: None,
774 interval_ms: None,
775 filters,
776 duplicates: Duplicates::Allow,
777 }
778 }
779
780 fn due_ness_matrix() -> Vec<(String, Trigger)> {
783 let mut rows = Vec::new();
784 let mut ordinal = 0;
785 for state in [
786 TriggerState::Queued,
787 TriggerState::Completed,
788 TriggerState::Cancelled,
789 ] {
790 for kind in [
791 TriggerKind::Immediate,
792 TriggerKind::Auto,
793 TriggerKind::At,
794 TriggerKind::Every,
795 TriggerKind::Overnight,
796 ] {
797 for eligible_at_ms in [None, Some(1_999), Some(2_000), Some(2_001)] {
798 ordinal += 1;
799 rows.push((
800 format!("TR{ordinal}"),
801 Trigger {
802 state,
803 kind,
804 eligible_at_ms,
805 interval_ms: Some(60_000),
806 },
807 ));
808 }
809 }
810 }
811 rows
812 }
813
814 #[test]
819 fn the_sql_due_predicate_and_the_domain_function_agree() {
820 let directory = tempdir().unwrap();
821 let store = seeded(&directory.path().join("sloop.db"));
822 let matrix = due_ness_matrix();
823 {
824 let db = store.db();
825 let connection = db.lock();
826 for (id, trigger) in &matrix {
827 insert(
828 &connection,
829 &NewTrigger {
830 id,
831 kind: trigger.kind,
832 ticket_id: None,
833 project_id: None,
834 eligible_at_ms: trigger.eligible_at_ms,
835 interval_ms: trigger.interval_ms,
836 },
837 1_000,
838 )
839 .unwrap();
840 connection
841 .execute(
842 "UPDATE triggers SET state = ?2 WHERE id = ?1",
843 params![id, trigger.state.as_str()],
844 )
845 .unwrap();
846 }
847 }
848
849 for now_ms in [0, 1_999, 2_000, 2_001, i64::MAX] {
850 let from_sql: BTreeSet<String> = store
851 .dispatchable_triggers(now_ms)
852 .unwrap()
853 .into_iter()
854 .map(|trigger| trigger.id)
855 .collect();
856 let from_domain: BTreeSet<String> = matrix
857 .iter()
858 .filter(|(_, trigger)| trigger.is_due(now_ms))
859 .map(|(id, _)| id.clone())
860 .collect();
861 assert_eq!(from_sql, from_domain, "disagreement at now_ms = {now_ms}");
862 }
863 assert!(!store.dispatchable_triggers(2_000).unwrap().is_empty());
866 }
867
868 #[test]
871 fn the_claim_path_lookup_shares_the_dispatch_scan_due_ness() {
872 let directory = tempdir().unwrap();
873 let store = seeded(&directory.path().join("sloop.db"));
874 for (ordinal, kind) in [TriggerKind::Immediate, TriggerKind::At, TriggerKind::Every]
875 .into_iter()
876 .enumerate()
877 {
878 store
879 .insert_trigger(
880 &NewTrigger {
881 id: &format!("TR{}", ordinal + 1),
882 kind,
883 ticket_id: Some("T1"),
884 project_id: None,
885 eligible_at_ms: Some(2_000),
886 interval_ms: Some(60_000),
887 },
888 1_000,
889 )
890 .unwrap();
891 }
892
893 for now_ms in [1_999, 2_000] {
894 let scanned = store
895 .dispatchable_triggers(now_ms)
896 .unwrap()
897 .into_iter()
898 .next()
899 .map(|trigger| trigger.id);
900 let db = store.db();
901 let connection = db.lock();
902 let claimable = claimable_on(&connection, "T1", now_ms)
903 .unwrap()
904 .map(|trigger| trigger.id);
905 assert_eq!(scanned, claimable, "at now_ms = {now_ms}");
906 }
907 }
908
909 #[test]
913 fn a_failure_after_the_row_insert_leaves_no_trigger_at_all() {
914 let directory = tempdir().unwrap();
915 let store = seeded(&directory.path().join("sloop.db"));
916 let filters = ["T2".to_owned(), "GONE".to_owned()];
919 let error = store
920 .enqueue_trigger(&request(TriggerKind::Immediate, &filters), 2_000)
921 .expect_err("a filter naming a missing ticket must be rejected");
922 assert!(
923 matches!(error, StoreError::Sqlite(_)),
924 "unexpected error: {error}"
925 );
926
927 assert!(
928 store.queued_triggers().unwrap().is_empty(),
929 "the trigger row outlived its filters, so it is now unrestricted"
930 );
931 let db = store.db();
932 let connection = db.lock();
933 let filter_rows: i64 = connection
934 .query_row("SELECT COUNT(*) FROM trigger_filters", [], |row| row.get(0))
935 .unwrap();
936 assert_eq!(filter_rows, 0);
937 }
938
939 #[test]
940 fn enqueue_writes_the_row_and_every_filter_together() {
941 let directory = tempdir().unwrap();
942 let store = seeded(&directory.path().join("sloop.db"));
943 let filters = ["T2".to_owned(), "T3".to_owned()];
944 let enqueued = store
945 .enqueue_trigger(&request(TriggerKind::Immediate, &filters), 2_000)
946 .unwrap();
947 assert!(!enqueued.reused);
948
949 let queued = store.queued_triggers().unwrap();
950 assert_eq!(queued.len(), 1);
951 assert_eq!(queued[0].id, enqueued.id);
952 assert_eq!(queued[0].kind, TriggerKind::Immediate);
953 assert_eq!(
955 store
956 .select_ready_ticket(None, &enqueued.id, 2_000)
957 .unwrap()
958 .as_deref(),
959 Some("T2")
960 );
961 }
962
963 #[test]
964 fn reuse_absorbs_a_repost_while_allow_mints_a_second_trigger() {
965 let directory = tempdir().unwrap();
966 let store = seeded(&directory.path().join("sloop.db"));
967 let reposted = EnqueueRequest {
968 ticket_id: Some("T1"),
969 duplicates: Duplicates::Reuse,
970 ..request(TriggerKind::Auto, &[])
971 };
972 let first = store.enqueue_trigger(&reposted, 2_000).unwrap();
973 let second = store.enqueue_trigger(&reposted, 3_000).unwrap();
974 assert_eq!(first.id, second.id);
975 assert!(second.reused);
976 assert_eq!(store.queued_triggers().unwrap().len(), 1);
977
978 let scheduled = EnqueueRequest {
980 eligible_at_ms: Some(9_000),
981 ..EnqueueRequest {
982 ticket_id: Some("T1"),
983 duplicates: Duplicates::Reuse,
984 ..request(TriggerKind::At, &[])
985 }
986 };
987 let third = store.enqueue_trigger(&scheduled, 3_000).unwrap();
988 assert_ne!(third.id, first.id);
989 assert_eq!(store.queued_triggers().unwrap().len(), 2);
990
991 let retimed = EnqueueRequest {
993 eligible_at_ms: Some(11_000),
994 ..scheduled.clone()
995 };
996 let fourth = store.enqueue_trigger(&retimed, 4_000).unwrap();
997 assert_eq!(fourth.id, third.id);
998 let requeued = store.queued_triggers().unwrap();
999 assert_eq!(requeued.len(), 2);
1000 assert_eq!(
1001 requeued
1002 .iter()
1003 .find(|trigger| trigger.id == third.id)
1004 .unwrap()
1005 .eligible_at_ms,
1006 Some(11_000)
1007 );
1008
1009 let run = EnqueueRequest {
1011 ticket_id: Some("T1"),
1012 ..request(TriggerKind::Immediate, &[])
1013 };
1014 let fifth = store.enqueue_trigger(&run, 5_000).unwrap();
1015 let sixth = store.enqueue_trigger(&run, 5_000).unwrap();
1016 assert_ne!(fifth.id, sixth.id);
1017 assert_eq!(store.queued_triggers().unwrap().len(), 4);
1018 }
1019
1020 #[test]
1021 fn ids_never_collide_with_a_trigger_the_counter_has_forgotten() {
1022 let directory = tempdir().unwrap();
1023 let store = seeded(&directory.path().join("sloop.db"));
1024 store
1025 .insert_trigger(
1026 &NewTrigger {
1027 id: "TR7",
1028 kind: TriggerKind::Immediate,
1029 ticket_id: Some("T1"),
1030 project_id: None,
1031 eligible_at_ms: None,
1032 interval_ms: None,
1033 },
1034 1_000,
1035 )
1036 .unwrap();
1037 let enqueued = store
1038 .enqueue_trigger(&request(TriggerKind::Immediate, &[]), 2_000)
1039 .unwrap();
1040 assert_eq!(enqueued.id, "TR8");
1041 }
1042
1043 #[test]
1047 fn consume_persists_the_transition_the_domain_chose() {
1048 let directory = tempdir().unwrap();
1049 let store = seeded(&directory.path().join("sloop.db"));
1050 let recurring = store
1051 .enqueue_trigger(
1052 &EnqueueRequest {
1053 eligible_at_ms: Some(2_000),
1054 interval_ms: Some(60_000),
1055 ..request(TriggerKind::Every, &[])
1056 },
1057 1_000,
1058 )
1059 .unwrap();
1060 let once = store
1061 .enqueue_trigger(&request(TriggerKind::Immediate, &[]), 1_000)
1062 .unwrap();
1063
1064 let db = store.db();
1065 let connection = db.lock();
1066 for (id, expected) in [
1067 (
1068 &recurring.id,
1069 vec![Effect::Rearm {
1070 eligible_at_ms: 62_000,
1071 }],
1072 ),
1073 (&once.id, vec![Effect::Complete]),
1074 ] {
1075 let row = queued(&connection)
1076 .unwrap()
1077 .into_iter()
1078 .find(|queued| &queued.id == id)
1079 .expect("trigger is queued");
1080 let mut trigger = Trigger::from(&row);
1081 let effects = step(&mut trigger, Event::Fired, 2_000);
1082 assert_eq!(effects, expected);
1083 consume(&connection, id, &effects, 2_000).unwrap();
1084 }
1085 assert_eq!(
1086 queued(&connection).unwrap().len(),
1087 1,
1088 "the recurring trigger stays queued and the one-shot one does not"
1089 );
1090 assert!(matches!(
1097 consume(&connection, &once.id, &[Effect::Complete], 2_000),
1098 Err(StoreError::TriggerNotQueued { .. })
1099 ));
1100 }
1101
1102 #[test]
1103 fn a_faulting_effect_is_never_written() {
1104 let directory = tempdir().unwrap();
1105 let store = seeded(&directory.path().join("sloop.db"));
1106 let enqueued = store
1107 .enqueue_trigger(
1108 &EnqueueRequest {
1109 eligible_at_ms: Some(2_000),
1110 interval_ms: None,
1111 ..request(TriggerKind::Every, &[])
1112 },
1113 1_000,
1114 )
1115 .unwrap();
1116 let db = store.db();
1117 let connection = db.lock();
1118 let mut trigger = Trigger::from(&queued(&connection).unwrap()[0]);
1119 let effects = step(&mut trigger, Event::Fired, 2_000);
1120 assert_eq!(effects, [Effect::Fault(Fault::InvalidCadence)]);
1121 assert!(matches!(
1122 consume(&connection, &enqueued.id, &effects, 2_000),
1123 Err(StoreError::TriggerNotQueued { .. })
1124 ));
1125 assert_eq!(queued(&connection).unwrap().len(), 1);
1126 }
1127}