Skip to main content

sloop/work_state/
trigger.rs

1//! Trigger storage: every statement that reads or writes a trigger.
2//!
3//! The pure half of the concept lives in [`crate::domain::trigger`]; this is
4//! the coordination half. Two rules keep the concept owned rather than
5//! scattered:
6//!
7//! - **Each statement is written once.** The functions here take
8//!   `&Connection`, and `Transaction` dereferences to `Connection`, so a
9//!   caller inside a transaction and a caller on its own lock share one
10//!   statement instead of two copies that drift.
11//! - **Every write is a named transition.** No call site outside this module
12//!   spells `INSERT INTO triggers`, `UPDATE triggers`, or `DELETE FROM
13//!   triggers`; it names the transition it wants and this module decides the
14//!   SQL and the guard.
15
16use 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/// A trigger row to be inserted. The id is supplied because minting it is a
28/// separate reservation; [`enqueue`] is the verb that does both.
29#[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/// A queued trigger as the dispatcher sees it: the demand plus what it points
40/// at. Convert with `Trigger::from` to ask the domain a question about it.
41#[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/// What to do when the requested demand already exists as a queued trigger.
63/// The two creation paths genuinely disagree, so the disagreement is an
64/// argument rather than a property of which function was called.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Duplicates {
67    /// Reuse a queued trigger of the same kind pinned to the same ticket, so
68    /// reposting a ticket cannot pile up demand. `sloop post`.
69    Reuse,
70    /// Always mint a new trigger: two invocations mean two runs. `sloop run`.
71    Allow,
72}
73
74/// One request for demand. `filters` are the `--only` ticket ids, which are
75/// part of the request rather than a follow-up write — see [`enqueue`].
76#[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    /// Whether an existing queued trigger absorbed the request.
91    pub reused: bool,
92}
93
94/// Triggers to unpin or delete alongside the rows a reindex is removing.
95/// Computing the plan is separate from applying it because cross-store cleanup
96/// of runs needs the doomed set before the trigger rows go away.
97#[derive(Debug, Clone, Default, PartialEq, Eq)]
98pub(crate) struct DeletionPlan {
99    /// Triggers whose demand dies with the rows being deleted.
100    pub(crate) doomed: BTreeSet<String>,
101    /// Settled triggers scoped to a deleted project. They keep their history
102    /// with the scope dropped, so the project row can go.
103    pub(crate) unpinned: Vec<String>,
104}
105
106/// The one creation verb. Mints the id, writes the row, and writes every
107/// filter — all inside the caller's transaction.
108///
109/// Taking a `&Transaction` rather than a `&Connection` is the fix for a bug,
110/// not a style choice. The row and its filters must land together: an absent
111/// filter row reads as *no restriction*, so a half-written
112/// `sloop run --only TICK-5` used to become an *unrestricted* trigger able to
113/// select any ready ticket in the repository. It failed open, in the direction
114/// of running work nobody asked for.
115pub(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
192/// The queued trigger of this kind pinned to this ticket, if there is one.
193/// This is what makes reposting idempotent.
194pub(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
211/// Moves a queued trigger's eligibility without touching its state. Reposting
212/// with a different `--at` time is the only caller.
213pub(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
228/// Persists the effects of firing a trigger, inside the claim's transaction.
229///
230/// The `state` and `kind` guards are the concurrency control, not decoration:
231/// exactly one claimer can move a queued trigger, so a zero-row update means
232/// another claimer won the race and the whole claim must be rejected. The
233/// domain decided *which* effect; this decides nothing.
234pub(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
268/// Returns a trigger to the queue at a given instant. A retried run hands its
269/// demand back so the ticket can be picked up again once its cooldown expires.
270pub(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
286/// Retires the triggers pinned to a ticket that has just settled to `merged`.
287/// A pinned trigger resolves through `ticket_is_dispatchable`, which demands
288/// `state = 'ready'`, and a merged ticket never returns there: leaving it
289/// queued is demand that can never be met but is still counted. Running this
290/// in the settle transaction means the trigger dies at the instant the ticket
291/// merges, with no window where the two disagree.
292///
293/// Kind is deliberately not consulted — see `domain::trigger::step` under
294/// `Event::Completed`, which is where that rule is defined and tested. An
295/// unpinned trigger is demand for whatever is ready, so it is out of scope by
296/// construction: the `ticket_id` match excludes it.
297pub(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
311/// One trigger's schedulable state, whatever it is — the input the domain
312/// transition needs when a caller holds only an id.
313pub(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
349/// The queued triggers, oldest first. This is the queue as `sloop status`
350/// reports it, without the due-ness filter.
351pub(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
363/// The due triggers, oldest first — the dispatcher's scan.
364pub(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
381/// The earliest future eligibility instant, which is how long the dispatcher
382/// may sleep before demand it already holds becomes due.
383pub(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
397/// The due trigger that would select this ticket, if any. Shared by the claim
398/// path, which asks inside its transaction, and by the reporting path, which
399/// asks read-only.
400pub(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
428/// The trigger a released run should hand its demand back to when the lease
429/// did not record one. Deliberately looser than [`claimable_on`]: the trigger
430/// that fired has already been completed or rearmed, so this searches settled
431/// and recurring ones, most recently touched first.
432pub(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
457/// Which triggers a reindex must unpin or delete. Read-only, so the caller can
458/// hand the doomed set to run cleanup before the rows disappear.
459pub(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
492/// Carries out a [`DeletionPlan`] and returns the number of rows deleted.
493/// Unpinning is not counted: nothing was dropped, only rescoped.
494pub(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
515/// Drops the `--only` restrictions naming a ticket that no longer exists.
516/// Separate from [`apply_deletion`] because it is the filter rows that point
517/// at the ticket, not the triggers.
518pub(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
530/// Reserves the next trigger ordinal, taking the high-water mark of the live
531/// table into account so a restored or hand-edited database cannot mint an id
532/// that already exists.
533fn 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
552/// The SQL mirror of [`Trigger::is_due`], written once and formatted into every
553/// query that scans for due demand, so the two cannot drift into disagreement
554/// the way two hand-copied predicates would. `alias` qualifies the trigger
555/// table (`""` or `"tr."`) and `now` names the bound parameter.
556fn 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
563/// Whether trigger `tr` aims at ticket `t`: pinned to it, or unpinned and
564/// either unscoped or scoped to the ticket's project.
565const 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
569/// Whether ticket `t` survives the `--only` restriction of the trigger named by
570/// the SQL expression `trigger` — a column reference in the join queries here,
571/// a bound parameter in ticket selection.
572///
573/// Absent filter rows mean *no restriction*, which is exactly why [`enqueue`]
574/// must write a trigger and its filters atomically: a trigger that outlived its
575/// filters would read as unrestricted.
576pub(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    /// The standalone half of [`enqueue`]: opens the transaction the atomicity
613    /// guarantee needs, for a caller that has none of its own. `sloop run`
614    /// takes this path; `sloop post` joins its ticket write instead.
615    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    /// Seeding primitive: writes one trigger row with a caller-chosen id.
629    /// Tests use it to build a fixture queue; production creation is
630    /// [`LocalSqlite::enqueue_trigger`], which mints the id and cannot leave a
631    /// trigger without its filters.
632    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    /// Seeding primitive; see [`LocalSqlite::insert_trigger`].
639    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    /// Whether any queued trigger could select this ticket: the reporting
662    /// mirror of the claim path's selection, asked per ticket rather than as
663    /// "does the queue hold anything at all".
664    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    /// Retires triggers left queued against a ticket that merged before the
677    /// settle path knew to retire them. [`complete_for_ticket`] only applies
678    /// from the next settlement onwards, and a merged ticket never settles
679    /// again, so anything already stranded needs this one-off sweep.
680    ///
681    /// Returns the `(trigger_id, ticket_id)` pairs it completed, so the caller
682    /// can report a startup mutation rather than perform it silently. A
683    /// database with nothing stranded selects no rows and writes nothing, which
684    /// makes repeated runs free.
685    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    /// Every combination of state, kind, and schedule, so neither the SQL nor
767    /// the domain function can be right by coincidence on a narrow fixture.
768    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    /// The anti-drift test the reporting and dispatch gates did not have. The
801    /// SQL predicate exists only so a large queue can be filtered in the
802    /// database; `Trigger::is_due` is the definition, and if the two ever
803    /// disagree this fails rather than silently dispatching the wrong set.
804    #[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    /// The claim path's per-ticket lookup must answer with the same due-ness as
853    /// the dispatcher's scan; both format the one shared predicate.
854    #[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    /// The bug the second creation path hid. `select_ready_ticket` treats
894    /// absent filter rows as *no restriction*, so a trigger that survived
895    /// without its filters would select any ready ticket in the repository.
896    #[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    /// The transition and its storage guard, together: firing a recurring
1022    /// trigger rearms it and leaves it queued, firing a one-shot one retires it,
1023    /// and a claimer that arrives after the retirement is told so.
1024    #[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}