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            // Only the caller knows whether the repost carried a new time; an
129            // absent one must not re-time the trigger already in the queue.
130            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
194/// The queued trigger of this kind pinned to this ticket, if there is one.
195/// This is what makes reposting idempotent.
196pub(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
213/// Moves a queued trigger's eligibility without touching its state. Reposting
214/// with a different `--at` time is the only caller.
215pub(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
230/// Persists the effects of firing a trigger, inside the claim's transaction.
231///
232/// The `state` and `kind` guards are the concurrency control, not decoration:
233/// exactly one claimer can move a queued trigger, so a zero-row update means
234/// another claimer won the race and the whole claim must be rejected. The
235/// domain decided *which* effect; this decides nothing.
236pub(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            // A fault is the domain refusing to write; the claim path rejects
256            // before it gets here, so reaching this arm is a caller bug.
257            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
272/// Returns a trigger to the queue at a given instant. A retried run hands its
273/// demand back so the ticket can be picked up again once its cooldown expires.
274pub(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
290/// Retires the triggers pinned to a ticket that has just settled to `merged`.
291/// A pinned trigger resolves through `ticket_is_dispatchable`, which demands
292/// `state = 'ready'`, and a merged ticket never returns there: leaving it
293/// queued is demand that can never be met but is still counted. Running this
294/// in the settle transaction means the trigger dies at the instant the ticket
295/// merges, with no window where the two disagree.
296///
297/// Kind is deliberately not consulted — see `domain::trigger::step` under
298/// `Event::Completed`, which is where that rule is defined and tested. An
299/// unpinned trigger is demand for whatever is ready, so it is out of scope by
300/// construction: the `ticket_id` match excludes it.
301pub(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
315/// One trigger's schedulable state, whatever it is — the input the domain
316/// transition needs when a caller holds only an id.
317pub(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
353/// The queued triggers, oldest first. This is the queue as `sloop status`
354/// reports it, without the due-ness filter.
355pub(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
367/// The due triggers, oldest first — the dispatcher's scan.
368pub(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
385/// The earliest future eligibility instant, which is how long the dispatcher
386/// may sleep before demand it already holds becomes due.
387pub(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
401/// The due trigger that would select this ticket, if any. Shared by the claim
402/// path, which asks inside its transaction, and by the reporting path, which
403/// asks read-only.
404pub(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
432/// The trigger a released run should hand its demand back to when the lease
433/// did not record one. Deliberately looser than [`claimable_on`]: the trigger
434/// that fired has already been completed or rearmed, so this searches settled
435/// and recurring ones, most recently touched first.
436pub(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
461/// Which triggers a reindex must unpin or delete. Read-only, so the caller can
462/// hand the doomed set to run cleanup before the rows disappear.
463pub(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    // A trigger pinned to a vanished ticket is demand for work that no longer
470    // exists, whatever state it is in: the row has to go because `ticket_id`
471    // references the ticket.
472    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    // A project is a scope, not an owner. Live demand scoped to a vanished
481    // project can never be satisfied, so it dies; a settled trigger is history
482    // and only loses the scope.
483    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
502/// Carries out a [`DeletionPlan`] and returns the number of rows deleted.
503/// Unpinning is not counted: nothing was dropped, only rescoped.
504pub(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
525/// Drops the `--only` restrictions naming a ticket that no longer exists.
526/// Separate from [`apply_deletion`] because it is the filter rows that point
527/// at the ticket, not the triggers.
528pub(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
540/// Reserves the next trigger ordinal, taking the high-water mark of the live
541/// table into account so a restored or hand-edited database cannot mint an id
542/// that already exists.
543fn 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    // `SUBSTR` skips the two-character `TR` prefix; see
550    // `run_store::tx::reserve_ordinal`, which does the same for `notes`.
551    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
564/// The SQL mirror of [`Trigger::is_due`], written once and formatted into every
565/// query that scans for due demand, so the two cannot drift into disagreement
566/// the way two hand-copied predicates would. `alias` qualifies the trigger
567/// table (`""` or `"tr."`) and `now` names the bound parameter.
568fn 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
575/// Whether trigger `tr` aims at ticket `t`: pinned to it, or unpinned and
576/// either unscoped or scoped to the ticket's project.
577const 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
581/// Whether ticket `t` survives the `--only` restriction of the trigger named by
582/// the SQL expression `trigger` — a column reference in the join queries here,
583/// a bound parameter in ticket selection.
584///
585/// Absent filter rows mean *no restriction*, which is exactly why [`enqueue`]
586/// must write a trigger and its filters atomically: a trigger that outlived its
587/// filters would read as unrestricted.
588pub(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    /// The standalone half of [`enqueue`]: opens the transaction the atomicity
625    /// guarantee needs, for a caller that has none of its own. `sloop run`
626    /// takes this path; `sloop post` joins its ticket write instead.
627    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    /// Seeding primitive: writes one trigger row with a caller-chosen id.
641    /// Tests use it to build a fixture queue; production creation is
642    /// [`LocalSqlite::enqueue_trigger`], which mints the id and cannot leave a
643    /// trigger without its filters.
644    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    /// Seeding primitive; see [`LocalSqlite::insert_trigger`].
651    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    /// Whether any queued trigger could select this ticket: the reporting
674    /// mirror of the claim path's selection, asked per ticket rather than as
675    /// "does the queue hold anything at all".
676    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    /// Retires triggers left queued against a ticket that merged before the
689    /// settle path knew to retire them. [`complete_for_ticket`] only applies
690    /// from the next settlement onwards, and a merged ticket never settles
691    /// again, so anything already stranded needs this one-off sweep.
692    ///
693    /// Returns the `(trigger_id, ticket_id)` pairs it completed, so the caller
694    /// can report a startup mutation rather than perform it silently. A
695    /// database with nothing stranded selects no rows and writes nothing, which
696    /// makes repeated runs free.
697    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        // By ticket, not by trigger: two triggers stranded on one ticket are
720        // retired by a single named transition rather than a second no-op.
721        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    /// Every combination of state, kind, and schedule, so neither the SQL nor
781    /// the domain function can be right by coincidence on a narrow fixture.
782    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    /// The anti-drift test the reporting and dispatch gates did not have. The
815    /// SQL predicate exists only so a large queue can be filtered in the
816    /// database; `Trigger::is_due` is the definition, and if the two ever
817    /// disagree this fails rather than silently dispatching the wrong set.
818    #[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        // A fixture that agreed only because both sides were empty would prove
864        // nothing.
865        assert!(!store.dispatchable_triggers(2_000).unwrap().is_empty());
866    }
867
868    /// The claim path's per-ticket lookup must answer with the same due-ness as
869    /// the dispatcher's scan; both format the one shared predicate.
870    #[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    /// The bug the second creation path hid. `select_ready_ticket` treats
910    /// absent filter rows as *no restriction*, so a trigger that survived
911    /// without its filters would select any ready ticket in the repository.
912    #[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        // `trigger_filters.ticket_id` references `tickets`, so the second
917        // filter fails after the row and the first filter are already written.
918        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        // The restriction is in force: T1 is ready but not named.
954        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        // A different kind is different demand, so it is not absorbed.
979        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        // Reposting with a new time re-times the trigger already queued.
992        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        // `Allow` is the run path: two invocations, two triggers.
1010        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    /// The transition and its storage guard, together: firing a recurring
1044    /// trigger rearms it and leaves it queued, firing a one-shot one retires it,
1045    /// and a claimer that arrives after the retirement is told so.
1046    #[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        // The `state = 'queued'` guard is what makes a retired trigger
1091        // unmovable: a second claimer holding the same effect is rejected
1092        // rather than quietly re-retiring it. A rearm carries no such
1093        // protection and needs none — a recurring trigger is *meant* to stay
1094        // queued, and double-spawning is prevented by the conditional ticket
1095        // claim that runs before this.
1096        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}