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 {
129 reschedule(transaction, &id, eligible_at_ms, now_ms)?;
130 }
131 (id, true)
132 }
133 None => {
134 let id = format!("{TRIGGER_ID_PREFIX}{}", reserve_ordinal(transaction)?);
135 insert(
136 transaction,
137 &NewTrigger {
138 id: &id,
139 kind: request.kind,
140 ticket_id: request.ticket_id,
141 project_id: request.project_id,
142 eligible_at_ms: request.eligible_at_ms,
143 interval_ms: request.interval_ms,
144 },
145 now_ms,
146 )?;
147 (id, false)
148 }
149 };
150 for ticket_id in request.filters {
151 insert_filter(transaction, &id, ticket_id)?;
152 }
153 Ok(Enqueued { id, reused })
154}
155
156pub(crate) fn insert(
157 connection: &Connection,
158 trigger: &NewTrigger<'_>,
159 now_ms: i64,
160) -> Result<(), StoreError> {
161 connection.execute(
162 "INSERT INTO triggers
163 (id, kind, state, ticket_id, project_id, eligible_at_ms, interval_ms,
164 created_at_ms, updated_at_ms)
165 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
166 params![
167 trigger.id,
168 trigger.kind.as_str(),
169 TriggerState::Queued.as_str(),
170 trigger.ticket_id,
171 trigger.project_id,
172 trigger.eligible_at_ms,
173 trigger.interval_ms,
174 now_ms,
175 ],
176 )?;
177 Ok(())
178}
179
180pub(crate) fn insert_filter(
181 connection: &Connection,
182 trigger_id: &str,
183 ticket_id: &str,
184) -> Result<(), StoreError> {
185 connection.execute(
186 "INSERT OR IGNORE INTO trigger_filters (trigger_id, ticket_id) VALUES (?1, ?2)",
187 params![trigger_id, ticket_id],
188 )?;
189 Ok(())
190}
191
192pub(crate) fn queued_of_kind(
195 connection: &Connection,
196 ticket_id: &str,
197 kind: TriggerKind,
198) -> Result<Option<String>, StoreError> {
199 connection
200 .query_row(
201 "SELECT id FROM triggers
202 WHERE ticket_id = ?1 AND kind = ?2 AND state = 'queued'
203 ORDER BY created_at_ms LIMIT 1",
204 params![ticket_id, kind.as_str()],
205 |row| row.get(0),
206 )
207 .optional()
208 .map_err(StoreError::from)
209}
210
211pub(crate) fn reschedule(
214 connection: &Connection,
215 trigger_id: &str,
216 eligible_at_ms: i64,
217 now_ms: i64,
218) -> Result<(), StoreError> {
219 connection.execute(
220 "UPDATE triggers
221 SET eligible_at_ms = ?2, updated_at_ms = ?3
222 WHERE id = ?1 AND state = 'queued'",
223 params![trigger_id, eligible_at_ms, now_ms],
224 )?;
225 Ok(())
226}
227
228pub(crate) fn consume(
235 connection: &Connection,
236 trigger_id: &str,
237 effects: &[Effect],
238 now_ms: i64,
239) -> Result<(), StoreError> {
240 for effect in effects {
241 let changed = match effect {
242 Effect::Rearm { eligible_at_ms } => connection.execute(
243 "UPDATE triggers
244 SET eligible_at_ms = ?2, updated_at_ms = ?3
245 WHERE id = ?1 AND state = 'queued' AND kind = 'every'",
246 params![trigger_id, eligible_at_ms, now_ms],
247 )?,
248 Effect::Complete => connection.execute(
249 "UPDATE triggers SET state = 'completed', updated_at_ms = ?2
250 WHERE id = ?1 AND state = 'queued' AND kind != 'every'",
251 params![trigger_id, now_ms],
252 )?,
253 Effect::Fault(_) | Effect::Requeue { .. } => {
254 return Err(StoreError::TriggerNotQueued {
255 trigger_id: trigger_id.into(),
256 });
257 }
258 };
259 if changed != 1 {
260 return Err(StoreError::TriggerNotQueued {
261 trigger_id: trigger_id.into(),
262 });
263 }
264 }
265 Ok(())
266}
267
268pub(crate) fn requeue(
271 connection: &Connection,
272 trigger_id: &str,
273 eligible_at_ms: i64,
274 now_ms: i64,
275) -> Result<usize, StoreError> {
276 connection
277 .execute(
278 "UPDATE triggers
279 SET state = 'queued', eligible_at_ms = ?2, updated_at_ms = ?3
280 WHERE id = ?1",
281 params![trigger_id, eligible_at_ms, now_ms],
282 )
283 .map_err(StoreError::from)
284}
285
286pub(crate) fn complete_for_ticket(
298 connection: &Connection,
299 ticket_id: &str,
300 now_ms: i64,
301) -> Result<usize, StoreError> {
302 connection
303 .execute(
304 "UPDATE triggers SET state = 'completed', updated_at_ms = ?2
305 WHERE ticket_id = ?1 AND state = 'queued'",
306 params![ticket_id, now_ms],
307 )
308 .map_err(StoreError::from)
309}
310
311pub(crate) fn facts(
314 connection: &Connection,
315 trigger_id: &str,
316) -> Result<Option<Trigger>, StoreError> {
317 let row: Option<(String, String, Option<i64>, Option<i64>)> = connection
318 .query_row(
319 "SELECT state, kind, eligible_at_ms, interval_ms FROM triggers WHERE id = ?1",
320 params![trigger_id],
321 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
322 )
323 .optional()?;
324 let Some((state, kind, eligible_at_ms, interval_ms)) = row else {
325 return Ok(None);
326 };
327 let state = TriggerState::parse(&state).ok_or_else(|| {
328 StoreError::from(rusqlite::Error::FromSqlConversionFailure(
329 0,
330 Type::Text,
331 Box::new(UnknownTriggerKind(state)),
332 ))
333 })?;
334 let kind = TriggerKind::parse(&kind).ok_or_else(|| {
335 StoreError::from(rusqlite::Error::FromSqlConversionFailure(
336 1,
337 Type::Text,
338 Box::new(UnknownTriggerKind(kind)),
339 ))
340 })?;
341 Ok(Some(Trigger {
342 state,
343 kind,
344 eligible_at_ms,
345 interval_ms,
346 }))
347}
348
349pub(crate) fn queued(connection: &Connection) -> Result<Vec<QueuedTrigger>, StoreError> {
352 let mut statement = connection.prepare(
353 "SELECT id, kind, ticket_id, project_id, eligible_at_ms, interval_ms
354 FROM triggers WHERE state = 'queued'
355 ORDER BY created_at_ms, id",
356 )?;
357 let triggers = statement
358 .query_map([], queued_trigger)?
359 .collect::<Result<Vec<_>, _>>()?;
360 Ok(triggers)
361}
362
363pub(crate) fn dispatchable(
365 connection: &Connection,
366 now_ms: i64,
367) -> Result<Vec<QueuedTrigger>, StoreError> {
368 let mut statement = connection.prepare(&format!(
369 "SELECT id, kind, ticket_id, project_id, eligible_at_ms, interval_ms
370 FROM triggers
371 WHERE {}
372 ORDER BY created_at_ms, id",
373 due_predicate("", "?1")
374 ))?;
375 let triggers = statement
376 .query_map(params![now_ms], queued_trigger)?
377 .collect::<Result<Vec<_>, _>>()?;
378 Ok(triggers)
379}
380
381pub(crate) fn next_eligible_at_ms(
384 connection: &Connection,
385 now_ms: i64,
386) -> Result<Option<i64>, StoreError> {
387 connection
388 .query_row(
389 "SELECT MIN(eligible_at_ms) FROM triggers
390 WHERE state = 'queued' AND eligible_at_ms > ?1",
391 params![now_ms],
392 |row| row.get(0),
393 )
394 .map_err(StoreError::from)
395}
396
397pub(crate) fn claimable_on(
401 connection: &Connection,
402 ticket_id: &str,
403 now_ms: i64,
404) -> Result<Option<QueuedTrigger>, StoreError> {
405 connection
406 .query_row(
407 &format!(
408 "SELECT tr.id, tr.kind, tr.ticket_id, tr.project_id, tr.eligible_at_ms,
409 tr.interval_ms
410 FROM triggers tr
411 JOIN tickets t ON t.id = ?1
412 WHERE {due}
413 AND {targets}
414 AND {passes_filters}
415 ORDER BY tr.created_at_ms, tr.id
416 LIMIT 1",
417 due = due_predicate("tr.", "?2"),
418 targets = TARGETS_TICKET,
419 passes_filters = passes_filters("tr.id"),
420 ),
421 params![ticket_id, now_ms],
422 queued_trigger,
423 )
424 .optional()
425 .map_err(StoreError::from)
426}
427
428pub(crate) fn for_release(
433 connection: &Connection,
434 ticket_id: &str,
435) -> Result<Option<String>, StoreError> {
436 connection
437 .query_row(
438 &format!(
439 "SELECT tr.id
440 FROM triggers tr
441 JOIN tickets t ON t.id = ?1
442 WHERE (tr.state = 'completed' OR (tr.state = 'queued' AND tr.kind = 'every'))
443 AND {targets}
444 AND {passes_filters}
445 ORDER BY tr.updated_at_ms DESC, tr.created_at_ms, tr.id
446 LIMIT 1",
447 targets = TARGETS_TICKET,
448 passes_filters = passes_filters("tr.id"),
449 ),
450 params![ticket_id],
451 |row| row.get(0),
452 )
453 .optional()
454 .map_err(StoreError::from)
455}
456
457pub(crate) fn deletion_plan(
460 connection: &Connection,
461 stale_tickets: &[String],
462 stale_projects: &[String],
463) -> Result<DeletionPlan, StoreError> {
464 let mut plan = DeletionPlan::default();
465 for ticket_id in stale_tickets {
466 let mut statement = connection.prepare("SELECT id FROM triggers WHERE ticket_id = ?1")?;
467 plan.doomed.extend(
468 statement
469 .query_map(params![ticket_id], |row| row.get::<_, String>(0))?
470 .collect::<Result<Vec<_>, _>>()?,
471 );
472 }
473 for project_id in stale_projects {
474 let mut statement =
475 connection.prepare("SELECT id, state FROM triggers WHERE project_id = ?1")?;
476 let triggers = statement
477 .query_map(params![project_id], |row| {
478 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
479 })?
480 .collect::<Result<Vec<_>, _>>()?;
481 for (trigger_id, state) in triggers {
482 if state == TriggerState::Queued.as_str() {
483 plan.doomed.insert(trigger_id);
484 } else {
485 plan.unpinned.push(trigger_id);
486 }
487 }
488 }
489 Ok(plan)
490}
491
492pub(crate) fn apply_deletion(
495 connection: &Connection,
496 plan: &DeletionPlan,
497) -> Result<usize, StoreError> {
498 for trigger_id in &plan.unpinned {
499 connection.execute(
500 "UPDATE triggers SET project_id = NULL WHERE id = ?1",
501 params![trigger_id],
502 )?;
503 }
504 let mut deleted = 0;
505 for trigger_id in &plan.doomed {
506 deleted += connection.execute(
507 "DELETE FROM trigger_filters WHERE trigger_id = ?1",
508 params![trigger_id],
509 )?;
510 deleted += connection.execute("DELETE FROM triggers WHERE id = ?1", params![trigger_id])?;
511 }
512 Ok(deleted)
513}
514
515pub(crate) fn delete_filters_for_ticket(
519 connection: &Connection,
520 ticket_id: &str,
521) -> Result<usize, StoreError> {
522 connection
523 .execute(
524 "DELETE FROM trigger_filters WHERE ticket_id = ?1",
525 params![ticket_id],
526 )
527 .map_err(StoreError::from)
528}
529
530fn reserve_ordinal(connection: &Connection) -> Result<i64, StoreError> {
534 let reserved: i64 = connection.query_row(
535 "SELECT next_ordinal FROM id_counters WHERE kind = 'trigger'",
536 [],
537 |row| row.get(0),
538 )?;
539 let existing: i64 = connection.query_row(
540 "SELECT COALESCE(MAX(CAST(SUBSTR(id, 3) AS INTEGER)), 0) + 1 FROM triggers",
541 [],
542 |row| row.get(0),
543 )?;
544 let ordinal = reserved.max(existing);
545 connection.execute(
546 "UPDATE id_counters SET next_ordinal = ?1 WHERE kind = 'trigger'",
547 params![ordinal + 1],
548 )?;
549 Ok(ordinal)
550}
551
552fn due_predicate(alias: &str, now: &str) -> String {
557 format!(
558 "{alias}state = 'queued' \
559 AND ({alias}kind IN ('immediate', 'auto') OR {alias}eligible_at_ms <= {now})"
560 )
561}
562
563const TARGETS_TICKET: &str = "(tr.ticket_id = t.id
566 OR (tr.ticket_id IS NULL
567 AND (tr.project_id IS NULL OR tr.project_id = t.project_id)))";
568
569pub(crate) fn passes_filters(trigger: &str) -> String {
577 format!(
578 "(NOT EXISTS (SELECT 1 FROM trigger_filters f
579 WHERE f.trigger_id = {trigger})
580 OR EXISTS (SELECT 1 FROM trigger_filters f
581 WHERE f.trigger_id = {trigger} AND f.ticket_id = t.id))"
582 )
583}
584
585#[derive(Debug)]
586struct UnknownTriggerKind(String);
587
588impl fmt::Display for UnknownTriggerKind {
589 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
590 write!(formatter, "unknown trigger kind `{}`", self.0)
591 }
592}
593
594impl std::error::Error for UnknownTriggerKind {}
595
596fn queued_trigger(row: &Row<'_>) -> rusqlite::Result<QueuedTrigger> {
597 let raw: String = row.get(1)?;
598 let kind = TriggerKind::parse(&raw).ok_or_else(|| {
599 rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(UnknownTriggerKind(raw)))
600 })?;
601 Ok(QueuedTrigger {
602 id: row.get(0)?,
603 kind,
604 ticket_id: row.get(2)?,
605 project_id: row.get(3)?,
606 eligible_at_ms: row.get(4)?,
607 interval_ms: row.get(5)?,
608 })
609}
610
611impl LocalSqlite {
612 pub(crate) fn enqueue_trigger(
616 &self,
617 request: &EnqueueRequest<'_>,
618 now_ms: i64,
619 ) -> Result<Enqueued, StoreError> {
620 let db = self.db();
621 let mut connection = db.lock();
622 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
623 let enqueued = enqueue(&transaction, request, now_ms)?;
624 transaction.commit()?;
625 Ok(enqueued)
626 }
627
628 pub fn insert_trigger(&self, trigger: &NewTrigger<'_>, now_ms: i64) -> Result<(), StoreError> {
633 let db = self.db();
634 let connection = db.lock();
635 insert(&connection, trigger, now_ms)
636 }
637
638 pub fn insert_trigger_filter(
640 &self,
641 trigger_id: &str,
642 ticket_id: &str,
643 ) -> Result<(), StoreError> {
644 let db = self.db();
645 let connection = db.lock();
646 insert_filter(&connection, trigger_id, ticket_id)
647 }
648
649 pub fn queued_triggers(&self) -> Result<Vec<QueuedTrigger>, StoreError> {
650 let db = self.db();
651 let connection = db.lock();
652 queued(&connection)
653 }
654
655 pub fn dispatchable_triggers(&self, now_ms: i64) -> Result<Vec<QueuedTrigger>, StoreError> {
656 let db = self.db();
657 let connection = db.lock();
658 dispatchable(&connection, now_ms)
659 }
660
661 pub fn has_claimable_trigger(&self, ticket_id: &str, now_ms: i64) -> Result<bool, StoreError> {
665 let db = self.db();
666 let connection = db.lock();
667 Ok(claimable_on(&connection, ticket_id, now_ms)?.is_some())
668 }
669
670 pub fn next_trigger_eligible_at_ms(&self, now_ms: i64) -> Result<Option<i64>, StoreError> {
671 let db = self.db();
672 let connection = db.lock();
673 next_eligible_at_ms(&connection, now_ms)
674 }
675
676 pub fn complete_merged_ticket_triggers(
686 &self,
687 now_ms: i64,
688 ) -> Result<Vec<(String, String)>, StoreError> {
689 let db = self.db();
690 let mut connection = db.lock();
691 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
692 let stranded = {
693 let mut statement = transaction.prepare(
694 "SELECT tr.id, tr.ticket_id
695 FROM triggers tr
696 JOIN tickets t ON t.id = tr.ticket_id
697 WHERE tr.state = 'queued' AND t.state = 'merged'
698 ORDER BY tr.created_at_ms, tr.id",
699 )?;
700 statement
701 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
702 .collect::<Result<Vec<(String, String)>, _>>()?
703 };
704 if stranded.is_empty() {
705 return Ok(Vec::new());
706 }
707 let tickets: BTreeSet<&str> = stranded
708 .iter()
709 .map(|(_, ticket_id)| ticket_id.as_str())
710 .collect();
711 for ticket_id in tickets {
712 complete_for_ticket(&transaction, ticket_id, now_ms)?;
713 }
714 transaction.commit()?;
715 Ok(stranded)
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use tempfile::tempdir;
722
723 use super::*;
724 use crate::db::Db;
725 use crate::domain::ticket::TicketState;
726 use crate::domain::trigger::{Event, Fault, step};
727
728 fn seeded(path: &std::path::Path) -> LocalSqlite {
729 let store = LocalSqlite::from_db(Db::open(path, 1_000).unwrap());
730 store
731 .insert_local_project("default", "projects/default.md", "Default", 1_000)
732 .unwrap();
733 for ticket in ["T1", "T2", "T3"] {
734 store
735 .insert_local_ticket(
736 ticket,
737 "default",
738 &format!("tickets/{ticket}.md"),
739 ticket,
740 &[],
741 &format!("sloop/{ticket}"),
742 Some("claude"),
743 None,
744 None,
745 "default",
746 TicketState::Ready,
747 1_000,
748 )
749 .unwrap();
750 }
751 store
752 }
753
754 fn request<'a>(kind: TriggerKind, filters: &'a [String]) -> EnqueueRequest<'a> {
755 EnqueueRequest {
756 kind,
757 ticket_id: None,
758 project_id: None,
759 eligible_at_ms: None,
760 interval_ms: None,
761 filters,
762 duplicates: Duplicates::Allow,
763 }
764 }
765
766 fn due_ness_matrix() -> Vec<(String, Trigger)> {
769 let mut rows = Vec::new();
770 let mut ordinal = 0;
771 for state in [
772 TriggerState::Queued,
773 TriggerState::Completed,
774 TriggerState::Cancelled,
775 ] {
776 for kind in [
777 TriggerKind::Immediate,
778 TriggerKind::Auto,
779 TriggerKind::At,
780 TriggerKind::Every,
781 TriggerKind::Overnight,
782 ] {
783 for eligible_at_ms in [None, Some(1_999), Some(2_000), Some(2_001)] {
784 ordinal += 1;
785 rows.push((
786 format!("TR{ordinal}"),
787 Trigger {
788 state,
789 kind,
790 eligible_at_ms,
791 interval_ms: Some(60_000),
792 },
793 ));
794 }
795 }
796 }
797 rows
798 }
799
800 #[test]
805 fn the_sql_due_predicate_and_the_domain_function_agree() {
806 let directory = tempdir().unwrap();
807 let store = seeded(&directory.path().join("sloop.db"));
808 let matrix = due_ness_matrix();
809 {
810 let db = store.db();
811 let connection = db.lock();
812 for (id, trigger) in &matrix {
813 insert(
814 &connection,
815 &NewTrigger {
816 id,
817 kind: trigger.kind,
818 ticket_id: None,
819 project_id: None,
820 eligible_at_ms: trigger.eligible_at_ms,
821 interval_ms: trigger.interval_ms,
822 },
823 1_000,
824 )
825 .unwrap();
826 connection
827 .execute(
828 "UPDATE triggers SET state = ?2 WHERE id = ?1",
829 params![id, trigger.state.as_str()],
830 )
831 .unwrap();
832 }
833 }
834
835 for now_ms in [0, 1_999, 2_000, 2_001, i64::MAX] {
836 let from_sql: BTreeSet<String> = store
837 .dispatchable_triggers(now_ms)
838 .unwrap()
839 .into_iter()
840 .map(|trigger| trigger.id)
841 .collect();
842 let from_domain: BTreeSet<String> = matrix
843 .iter()
844 .filter(|(_, trigger)| trigger.is_due(now_ms))
845 .map(|(id, _)| id.clone())
846 .collect();
847 assert_eq!(from_sql, from_domain, "disagreement at now_ms = {now_ms}");
848 }
849 assert!(!store.dispatchable_triggers(2_000).unwrap().is_empty());
850 }
851
852 #[test]
855 fn the_claim_path_lookup_shares_the_dispatch_scan_due_ness() {
856 let directory = tempdir().unwrap();
857 let store = seeded(&directory.path().join("sloop.db"));
858 for (ordinal, kind) in [TriggerKind::Immediate, TriggerKind::At, TriggerKind::Every]
859 .into_iter()
860 .enumerate()
861 {
862 store
863 .insert_trigger(
864 &NewTrigger {
865 id: &format!("TR{}", ordinal + 1),
866 kind,
867 ticket_id: Some("T1"),
868 project_id: None,
869 eligible_at_ms: Some(2_000),
870 interval_ms: Some(60_000),
871 },
872 1_000,
873 )
874 .unwrap();
875 }
876
877 for now_ms in [1_999, 2_000] {
878 let scanned = store
879 .dispatchable_triggers(now_ms)
880 .unwrap()
881 .into_iter()
882 .next()
883 .map(|trigger| trigger.id);
884 let db = store.db();
885 let connection = db.lock();
886 let claimable = claimable_on(&connection, "T1", now_ms)
887 .unwrap()
888 .map(|trigger| trigger.id);
889 assert_eq!(scanned, claimable, "at now_ms = {now_ms}");
890 }
891 }
892
893 #[test]
897 fn a_failure_after_the_row_insert_leaves_no_trigger_at_all() {
898 let directory = tempdir().unwrap();
899 let store = seeded(&directory.path().join("sloop.db"));
900 let filters = ["T2".to_owned(), "GONE".to_owned()];
901 let error = store
902 .enqueue_trigger(&request(TriggerKind::Immediate, &filters), 2_000)
903 .expect_err("a filter naming a missing ticket must be rejected");
904 assert!(
905 matches!(error, StoreError::Sqlite(_)),
906 "unexpected error: {error}"
907 );
908
909 assert!(
910 store.queued_triggers().unwrap().is_empty(),
911 "the trigger row outlived its filters, so it is now unrestricted"
912 );
913 let db = store.db();
914 let connection = db.lock();
915 let filter_rows: i64 = connection
916 .query_row("SELECT COUNT(*) FROM trigger_filters", [], |row| row.get(0))
917 .unwrap();
918 assert_eq!(filter_rows, 0);
919 }
920
921 #[test]
922 fn enqueue_writes_the_row_and_every_filter_together() {
923 let directory = tempdir().unwrap();
924 let store = seeded(&directory.path().join("sloop.db"));
925 let filters = ["T2".to_owned(), "T3".to_owned()];
926 let enqueued = store
927 .enqueue_trigger(&request(TriggerKind::Immediate, &filters), 2_000)
928 .unwrap();
929 assert!(!enqueued.reused);
930
931 let queued = store.queued_triggers().unwrap();
932 assert_eq!(queued.len(), 1);
933 assert_eq!(queued[0].id, enqueued.id);
934 assert_eq!(queued[0].kind, TriggerKind::Immediate);
935 assert_eq!(
936 store
937 .select_ready_ticket(None, &enqueued.id, 2_000)
938 .unwrap()
939 .as_deref(),
940 Some("T2")
941 );
942 }
943
944 #[test]
945 fn reuse_absorbs_a_repost_while_allow_mints_a_second_trigger() {
946 let directory = tempdir().unwrap();
947 let store = seeded(&directory.path().join("sloop.db"));
948 let reposted = EnqueueRequest {
949 ticket_id: Some("T1"),
950 duplicates: Duplicates::Reuse,
951 ..request(TriggerKind::Auto, &[])
952 };
953 let first = store.enqueue_trigger(&reposted, 2_000).unwrap();
954 let second = store.enqueue_trigger(&reposted, 3_000).unwrap();
955 assert_eq!(first.id, second.id);
956 assert!(second.reused);
957 assert_eq!(store.queued_triggers().unwrap().len(), 1);
958
959 let scheduled = EnqueueRequest {
960 eligible_at_ms: Some(9_000),
961 ..EnqueueRequest {
962 ticket_id: Some("T1"),
963 duplicates: Duplicates::Reuse,
964 ..request(TriggerKind::At, &[])
965 }
966 };
967 let third = store.enqueue_trigger(&scheduled, 3_000).unwrap();
968 assert_ne!(third.id, first.id);
969 assert_eq!(store.queued_triggers().unwrap().len(), 2);
970
971 let retimed = EnqueueRequest {
972 eligible_at_ms: Some(11_000),
973 ..scheduled.clone()
974 };
975 let fourth = store.enqueue_trigger(&retimed, 4_000).unwrap();
976 assert_eq!(fourth.id, third.id);
977 let requeued = store.queued_triggers().unwrap();
978 assert_eq!(requeued.len(), 2);
979 assert_eq!(
980 requeued
981 .iter()
982 .find(|trigger| trigger.id == third.id)
983 .unwrap()
984 .eligible_at_ms,
985 Some(11_000)
986 );
987
988 let run = EnqueueRequest {
989 ticket_id: Some("T1"),
990 ..request(TriggerKind::Immediate, &[])
991 };
992 let fifth = store.enqueue_trigger(&run, 5_000).unwrap();
993 let sixth = store.enqueue_trigger(&run, 5_000).unwrap();
994 assert_ne!(fifth.id, sixth.id);
995 assert_eq!(store.queued_triggers().unwrap().len(), 4);
996 }
997
998 #[test]
999 fn ids_never_collide_with_a_trigger_the_counter_has_forgotten() {
1000 let directory = tempdir().unwrap();
1001 let store = seeded(&directory.path().join("sloop.db"));
1002 store
1003 .insert_trigger(
1004 &NewTrigger {
1005 id: "TR7",
1006 kind: TriggerKind::Immediate,
1007 ticket_id: Some("T1"),
1008 project_id: None,
1009 eligible_at_ms: None,
1010 interval_ms: None,
1011 },
1012 1_000,
1013 )
1014 .unwrap();
1015 let enqueued = store
1016 .enqueue_trigger(&request(TriggerKind::Immediate, &[]), 2_000)
1017 .unwrap();
1018 assert_eq!(enqueued.id, "TR8");
1019 }
1020
1021 #[test]
1025 fn consume_persists_the_transition_the_domain_chose() {
1026 let directory = tempdir().unwrap();
1027 let store = seeded(&directory.path().join("sloop.db"));
1028 let recurring = store
1029 .enqueue_trigger(
1030 &EnqueueRequest {
1031 eligible_at_ms: Some(2_000),
1032 interval_ms: Some(60_000),
1033 ..request(TriggerKind::Every, &[])
1034 },
1035 1_000,
1036 )
1037 .unwrap();
1038 let once = store
1039 .enqueue_trigger(&request(TriggerKind::Immediate, &[]), 1_000)
1040 .unwrap();
1041
1042 let db = store.db();
1043 let connection = db.lock();
1044 for (id, expected) in [
1045 (
1046 &recurring.id,
1047 vec![Effect::Rearm {
1048 eligible_at_ms: 62_000,
1049 }],
1050 ),
1051 (&once.id, vec![Effect::Complete]),
1052 ] {
1053 let row = queued(&connection)
1054 .unwrap()
1055 .into_iter()
1056 .find(|queued| &queued.id == id)
1057 .expect("trigger is queued");
1058 let mut trigger = Trigger::from(&row);
1059 let effects = step(&mut trigger, Event::Fired, 2_000);
1060 assert_eq!(effects, expected);
1061 consume(&connection, id, &effects, 2_000).unwrap();
1062 }
1063 assert_eq!(
1064 queued(&connection).unwrap().len(),
1065 1,
1066 "the recurring trigger stays queued and the one-shot one does not"
1067 );
1068 assert!(matches!(
1069 consume(&connection, &once.id, &[Effect::Complete], 2_000),
1070 Err(StoreError::TriggerNotQueued { .. })
1071 ));
1072 }
1073
1074 #[test]
1075 fn a_faulting_effect_is_never_written() {
1076 let directory = tempdir().unwrap();
1077 let store = seeded(&directory.path().join("sloop.db"));
1078 let enqueued = store
1079 .enqueue_trigger(
1080 &EnqueueRequest {
1081 eligible_at_ms: Some(2_000),
1082 interval_ms: None,
1083 ..request(TriggerKind::Every, &[])
1084 },
1085 1_000,
1086 )
1087 .unwrap();
1088 let db = store.db();
1089 let connection = db.lock();
1090 let mut trigger = Trigger::from(&queued(&connection).unwrap()[0]);
1091 let effects = step(&mut trigger, Event::Fired, 2_000);
1092 assert_eq!(effects, [Effect::Fault(Fault::InvalidCadence)]);
1093 assert!(matches!(
1094 consume(&connection, &enqueued.id, &effects, 2_000),
1095 Err(StoreError::TriggerNotQueued { .. })
1096 ));
1097 assert_eq!(queued(&connection).unwrap().len(), 1);
1098 }
1099}