Skip to main content

majordomus_cli/execution/
store.rs

1//! Where executions live while this process does: the snapshots, the retained event
2//! history, and the subscribers.
3//!
4//! Everything is bounded. A store that grew with the work it observed would be this
5//! executable's own memory leak, so the number of retained executions, the number of
6//! events kept per execution and the depth of a subscriber's queue all have a limit, and
7//! each limit has a defined behaviour when it is reached rather than an unwritten one.
8//!
9//! State moves in one place. [`ExecutionStore::publish`] applies an event to the snapshot
10//! under the lock, refusing a transition [`ExecutionState::may_move_to`] does not allow,
11//! assigns the sequence, retains the event and hands it to the subscribers — so a client
12//! reading the snapshot and a client reading the stream can never disagree about what
13//! happened, only about how much of it they have seen.
14
15use std::collections::{BTreeMap, VecDeque};
16use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20use serde_json::Value;
21
22use super::event::{EventPayload, ExecutionEvent};
23use super::model::{
24    Actor, Execution, ExecutionError, ExecutionId, ExecutionState, ProgressView, RepositoryRef,
25    StepState, StepView,
26};
27
28/// What the store keeps, and how much of it.
29///
30/// The defaults are for a development server a person is watching: enough history that a
31/// browser reload during a long run loses nothing, and little enough that a process left
32/// running for a week is not holding a transcript of it.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Limits {
35    /// How many executions are remembered. The oldest finished one is forgotten first; an
36    /// execution that is still running is never forgotten.
37    pub max_executions: usize,
38    /// How many events are retained per execution. The oldest are dropped and the
39    /// snapshot says so, so a client that asks for history knows it did not get all of it.
40    pub max_events: usize,
41    /// The longest a single log line may be; longer is cut with a marker.
42    pub max_log_chars: usize,
43    /// How many events a subscriber may be behind before it is told to resynchronise
44    /// instead of being waited for.
45    pub max_subscriber_queue: usize,
46    /// How many executions may be running at once. Beyond it, an execution waits queued.
47    pub max_running: usize,
48}
49
50impl Default for Limits {
51    fn default() -> Self {
52        Limits {
53            max_executions: 200,
54            max_events: 2_000,
55            max_log_chars: 2_000,
56            max_subscriber_queue: 512,
57            max_running: 4,
58        }
59    }
60}
61
62/// What a subscriber receives.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum Delivery {
65    /// One event, in order.
66    Event(Arc<ExecutionEvent>),
67    /// The subscriber fell behind and events were dropped rather than buffered. It must
68    /// read the snapshot and the history again from the sequence it last saw.
69    Lagged {
70        /// How many events were dropped.
71        dropped: u64,
72    },
73}
74
75/// A page of an execution's retained history.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct EventPage {
78    /// The events, oldest first.
79    pub events: Vec<Arc<ExecutionEvent>>,
80    /// The sequence of the last event in this page; the cursor for the next.
81    pub last_sequence: u64,
82    /// Whether more events follow this page right now.
83    pub more: bool,
84    /// Whether events before this page were dropped by the bound.
85    pub truncated: bool,
86}
87
88/// Which executions a subscriber wants.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Filter {
91    /// Every execution this process runs.
92    All,
93    /// Only these.
94    Only(Vec<ExecutionId>),
95}
96
97impl Filter {
98    /// Does this filter want events about `id`?
99    pub fn wants(&self, id: &ExecutionId) -> bool {
100        match self {
101            Filter::All => true,
102            Filter::Only(ids) => ids.contains(id),
103        }
104    }
105}
106
107/// The outcome of asking to cancel.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum CancelOutcome {
110    /// The token is set and a cancelling event was published.
111    Requested,
112    /// It had already been asked for.
113    AlreadyRequested,
114    /// It had already finished; nothing was done.
115    AlreadyFinished(ExecutionState),
116    /// No such execution.
117    Unknown,
118}
119
120/// What an execution is when it is created: everything about it that does not move.
121///
122/// ```
123/// use majordomus_cli::execution::{Actor, EventPayload, ExecutionId, ExecutionState};
124/// use majordomus_cli::execution::{ExecutionStore, Limits, RepositoryRef, Seed};
125/// let store = ExecutionStore::new(Limits::default());
126/// let seed = || Seed {
127///     capability: "executions.demonstrate",
128///     title: "Demonstrate an execution",
129///     input: serde_json::json!({ "steps": 1 }),
130///     cancellable: true,
131///     actor: Actor::reported(4242),
132///     repository: RepositoryRef { name: "r".into(), id: "id".into(), branch: None },
133/// };
134/// let id = ExecutionId::fresh();
135/// store.adopt(id.clone(), seed(), 4242).unwrap();
136/// let held = store.get(&id).unwrap();
137/// assert_eq!(held.capability, "executions.demonstrate");
138/// assert_eq!((held.state, held.actor.pid), (ExecutionState::Queued, Some(4242)));
139/// ```
140#[derive(Debug, Clone)]
141pub struct Seed<'a> {
142    /// The capability's canonical id.
143    pub capability: &'a str,
144    /// Its title.
145    pub title: &'a str,
146    /// The input, with every sensitive value already replaced.
147    pub input: Value,
148    /// Whether asking it to stop will do anything.
149    pub cancellable: bool,
150    /// Who asked for it.
151    pub actor: Actor,
152    /// Which repository it runs against.
153    pub repository: RepositoryRef,
154}
155
156struct Subscriber {
157    id: u64,
158    filter: Filter,
159    tx: std::sync::mpsc::SyncSender<Delivery>,
160    dropped: u64,
161}
162
163/// How often a process that runs an execution and reports it to a server says so, at the
164/// least, while nothing else happens: the reporter's heartbeat.
165pub const REPORT_HEARTBEAT: Duration = Duration::from_secs(2);
166
167/// How long a reported execution may go without a report before this store stops believing
168/// it is still going: fifteen heartbeats. A `majordomus run` that was killed sends nothing
169/// more, and an execution shown as running for ever would be a history no writer reaches.
170pub const REPORT_SILENCE: Duration = Duration::from_secs(30);
171
172/// The outcome of one report from the process that runs an execution.
173///
174/// ```
175/// use majordomus_cli::execution::{Actor, EventPayload, ExecutionId, ExecutionState};
176/// use majordomus_cli::execution::{ExecutionStore, Limits, RepositoryRef, Seed};
177/// let store = ExecutionStore::new(Limits::default());
178/// let seed = || Seed {
179///     capability: "executions.demonstrate",
180///     title: "Demonstrate an execution",
181///     input: serde_json::json!({ "steps": 1 }),
182///     cancellable: true,
183///     actor: Actor::reported(4242),
184///     repository: RepositoryRef { name: "r".into(), id: "id".into(), branch: None },
185/// };
186/// let id = ExecutionId::fresh();
187/// store.adopt(id.clone(), seed(), 4242).unwrap();
188/// // the reporter's own sequence: 1 is its `created`, already held here
189/// use majordomus_cli::execution::ReportOutcome;
190/// let outcome: ReportOutcome = store.report(&id, vec![(2, EventPayload::Started)]).unwrap();
191/// assert_eq!((outcome.applied, outcome.through, outcome.cancel_requested), (1, 2, false));
192/// ```
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ReportOutcome {
195    /// How many of the reported events were applied here.
196    pub applied: usize,
197    /// The reporter's sequence this store holds everything up to: where its next report
198    /// starts. Behind what was sent when the report had a gap in it.
199    pub through: u64,
200    /// Whether somebody asked this process to stop the execution. Only the process that
201    /// runs it can, so the reporter is told and cancels its own.
202    pub cancel_requested: bool,
203}
204
205/// Why an execution was not adopted from a report.
206///
207/// ```
208/// use majordomus_cli::execution::{Actor, EventPayload, ExecutionId, ExecutionState};
209/// use majordomus_cli::execution::{ExecutionStore, Limits, RepositoryRef, Seed};
210/// let store = ExecutionStore::new(Limits::default());
211/// let seed = || Seed {
212///     capability: "executions.demonstrate",
213///     title: "Demonstrate an execution",
214///     input: serde_json::json!({ "steps": 1 }),
215///     cancellable: true,
216///     actor: Actor::reported(4242),
217///     repository: RepositoryRef { name: "r".into(), id: "id".into(), branch: None },
218/// };
219/// use majordomus_cli::execution::{ActorKind, AdoptError};
220/// let own = ExecutionId::fresh();
221/// let mut mine = seed();
222/// mine.actor = Actor::of(ActorKind::Http);
223/// store.create(
224///     own.clone(), mine.capability, mine.title, mine.input, mine.cancellable, mine.actor,
225///     mine.repository,
226/// );
227/// assert_eq!(store.adopt(own.clone(), seed(), 4242), Err(AdoptError::Own(own)));
228/// ```
229#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
230pub enum AdoptError {
231    /// The id names an execution this process runs itself; a report cannot write into it.
232    #[error("{0} is an execution this process runs itself, not one another process reports")]
233    Own(ExecutionId),
234}
235
236/// What a store keeps about an execution another process runs and reports here.
237struct Reported {
238    /// The reporter's sequence of the last event taken: where its next report resumes.
239    through: u64,
240    /// When it last reported.
241    heard: Instant,
242    /// The process that runs it.
243    pid: u32,
244}
245
246struct Record {
247    snapshot: Execution,
248    events: VecDeque<Arc<ExecutionEvent>>,
249    /// The sequence of the oldest retained event; 1 while nothing has been dropped.
250    first_retained: u64,
251    cancel: Arc<AtomicBool>,
252    cancel_requested: bool,
253    started: Option<Instant>,
254    /// Present when the work runs in another process that reports it here.
255    reported: Option<Reported>,
256}
257
258struct Inner {
259    records: BTreeMap<ExecutionId, Record>,
260    order: VecDeque<ExecutionId>,
261    subscribers: Vec<Subscriber>,
262}
263
264/// The executions of one process.
265pub struct ExecutionStore {
266    limits: Limits,
267    inner: Mutex<Inner>,
268    next_subscriber: AtomicU64,
269}
270
271impl ExecutionStore {
272    /// A store with these limits.
273    pub fn new(limits: Limits) -> Self {
274        ExecutionStore {
275            limits,
276            inner: Mutex::new(Inner {
277                records: BTreeMap::new(),
278                order: VecDeque::new(),
279                subscribers: Vec::new(),
280            }),
281            next_subscriber: AtomicU64::new(1),
282        }
283    }
284
285    /// The limits this store was built with.
286    pub fn limits(&self) -> Limits {
287        self.limits
288    }
289
290    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
291        // a poisoned lock means a thread panicked while holding it. The snapshot it was
292        // editing is one execution's, the rest of the store is intact, and refusing every
293        // further execution because one handler panicked would turn a contained failure
294        // into an outage.
295        self.inner.lock().unwrap_or_else(|e| e.into_inner())
296    }
297
298    /// Record a new execution and publish its first event. Returns its cancellation token.
299    ///
300    /// The execution is created queued: what runs it decides when it starts.
301    #[allow(clippy::too_many_arguments)]
302    pub fn create(
303        &self,
304        id: ExecutionId,
305        capability: &str,
306        title: &str,
307        input: Value,
308        cancellable: bool,
309        actor: Actor,
310        repository: RepositoryRef,
311    ) -> Arc<AtomicBool> {
312        let created = self.insert(
313            id,
314            Seed {
315                capability,
316                title,
317                input,
318                cancellable,
319                actor,
320                repository,
321            },
322            None,
323        );
324        self.publish_created(created)
325    }
326
327    /// Hold an execution another process runs and reports here, so that this process's
328    /// listing, snapshot, history and live channel show it as they show their own.
329    ///
330    /// Nothing runs here: what the record says is what the reporter says, through
331    /// [`Self::report`]. Adopting an id already held as a reported execution changes
332    /// nothing, so a reporter may send its description with every report. An id this
333    /// process runs itself is refused.
334    ///
335    /// ```
336    /// use majordomus_cli::execution::{Actor, EventPayload, ExecutionId, ExecutionState};
337    /// use majordomus_cli::execution::{ExecutionStore, Limits, RepositoryRef, Seed};
338    /// let store = ExecutionStore::new(Limits::default());
339    /// let seed = || Seed {
340    ///     capability: "executions.demonstrate",
341    ///     title: "Demonstrate an execution",
342    ///     input: serde_json::json!({ "steps": 1 }),
343    ///     cancellable: true,
344    ///     actor: Actor::reported(4242),
345    ///     repository: RepositoryRef { name: "r".into(), id: "id".into(), branch: None },
346    /// };
347    /// let id = ExecutionId::fresh();
348    /// store.adopt(id.clone(), seed(), 4242).unwrap();
349    /// store.adopt(id.clone(), seed(), 4242).unwrap(); // again: nothing changes
350    /// assert!(store.is_reported(&id));
351    /// assert_eq!(store.get(&id).unwrap().event_count, 1, "one `created` event");
352    /// ```
353    pub fn adopt(&self, id: ExecutionId, seed: Seed<'_>, pid: u32) -> Result<(), AdoptError> {
354        {
355            let inner = self.lock();
356            if let Some(record) = inner.records.get(&id) {
357                return match record.reported {
358                    Some(_) => Ok(()),
359                    None => Err(AdoptError::Own(id)),
360                };
361            }
362        }
363        let created = self.insert(
364            id,
365            seed,
366            Some(Reported {
367                // the reporter's first event is its own `created`, which this store has
368                // just published for itself
369                through: 1,
370                heard: Instant::now(),
371                pid,
372            }),
373        );
374        self.publish_created(created);
375        Ok(())
376    }
377
378    /// Apply what the process running an execution reports, in its own sequence: each
379    /// event at most once and in order, so a report sent twice or cut short is harmless.
380    ///
381    /// An event after a gap is not applied; the answer's `through` says where the reporter
382    /// is to resume. An event this store's state machine refuses is passed over rather than
383    /// believed, as one from a handler of this process would be. `None` when the id is not
384    /// a reported execution of this store.
385    ///
386    /// ```
387    /// use majordomus_cli::execution::{Actor, EventPayload, ExecutionId, ExecutionState};
388    /// use majordomus_cli::execution::{ExecutionStore, Limits, RepositoryRef, Seed};
389    /// let store = ExecutionStore::new(Limits::default());
390    /// let seed = || Seed {
391    ///     capability: "executions.demonstrate",
392    ///     title: "Demonstrate an execution",
393    ///     input: serde_json::json!({ "steps": 1 }),
394    ///     cancellable: true,
395    ///     actor: Actor::reported(4242),
396    ///     repository: RepositoryRef { name: "r".into(), id: "id".into(), branch: None },
397    /// };
398    /// let id = ExecutionId::fresh();
399    /// store.adopt(id.clone(), seed(), 4242).unwrap();
400    /// let started = store.report(&id, vec![(2, EventPayload::Started)]).unwrap();
401    /// assert_eq!((started.applied, started.through), (1, 2));
402    /// // sent twice: nothing more is applied
403    /// assert_eq!(store.report(&id, vec![(2, EventPayload::Started)]).unwrap().applied, 0);
404    /// // after a gap: nothing is applied, and the answer says where to resume
405    /// let done = EventPayload::Completed { output: serde_json::json!(1) };
406    /// assert_eq!(store.report(&id, vec![(4, done)]).unwrap().through, 2);
407    /// assert_eq!(store.get(&id).unwrap().state, ExecutionState::Running);
408    /// assert!(store.report(&ExecutionId::fresh(), Vec::new()).is_none(), "not a reported one");
409    /// ```
410    pub fn report(
411        &self,
412        id: &ExecutionId,
413        events: impl IntoIterator<Item = (u64, EventPayload)>,
414    ) -> Option<ReportOutcome> {
415        let mut published = Vec::new();
416        let outcome = {
417            let mut inner = self.lock();
418            let mut through = inner.records.get(id)?.reported.as_ref()?.through;
419            let mut applied = 0usize;
420            for (sequence, payload) in events {
421                if sequence <= through {
422                    continue;
423                }
424                if sequence != through + 1 {
425                    break;
426                }
427                through = sequence;
428                let state = inner.records.get(id).map(|r| r.snapshot.state);
429                let said_already = match &payload {
430                    // the record was created here, with its own first event
431                    EventPayload::Created { .. } => true,
432                    // a stop this process asked for, which the reporter now repeats
433                    EventPayload::Cancelling { .. } => state == Some(ExecutionState::Cancelling),
434                    _ => false,
435                };
436                if said_already {
437                    continue;
438                }
439                if let Some(event) = self.publish_locked(&mut inner, id, payload) {
440                    applied += 1;
441                    published.push(event);
442                }
443            }
444            // the record was found reported above, under this same lock: it is still there
445            inner.records.get_mut(id).map(|record| {
446                record.reported.iter_mut().for_each(|reported| {
447                    reported.through = through;
448                    reported.heard = Instant::now();
449                });
450                ReportOutcome {
451                    applied,
452                    through,
453                    cancel_requested: record.cancel_requested,
454                }
455            })
456        };
457        for (event, deliveries) in published {
458            traced(&event, deliveries);
459        }
460        outcome
461    }
462
463    /// Fail every reported execution that is not finished and has not been heard from for
464    /// longer than `silence`, and answer which. The process that ran it was killed or lost
465    /// its way to this one; what it did afterwards is unknown here, which the error says.
466    ///
467    /// ```
468    /// use majordomus_cli::execution::{Actor, EventPayload, ExecutionId, ExecutionState};
469    /// use majordomus_cli::execution::{ExecutionStore, Limits, RepositoryRef, Seed};
470    /// let store = ExecutionStore::new(Limits::default());
471    /// let seed = || Seed {
472    ///     capability: "executions.demonstrate",
473    ///     title: "Demonstrate an execution",
474    ///     input: serde_json::json!({ "steps": 1 }),
475    ///     cancellable: true,
476    ///     actor: Actor::reported(4242),
477    ///     repository: RepositoryRef { name: "r".into(), id: "id".into(), branch: None },
478    /// };
479    /// use std::time::Duration;
480    /// let id = ExecutionId::fresh();
481    /// store.adopt(id.clone(), seed(), 4242).unwrap();
482    /// store.report(&id, vec![(2, EventPayload::Started)]).unwrap();
483    /// assert!(store.abandon_silent(Duration::from_secs(30)).is_empty(), "heard just now");
484    /// std::thread::sleep(Duration::from_millis(20));
485    /// assert_eq!(store.abandon_silent(Duration::from_millis(5)), vec![id.clone()]);
486    /// let failed = store.get(&id).unwrap();
487    /// assert_eq!(failed.state, ExecutionState::Failed);
488    /// assert_eq!(failed.error.unwrap().code, "unavailable");
489    /// ```
490    pub fn abandon_silent(&self, silence: Duration) -> Vec<ExecutionId> {
491        let mut published = Vec::new();
492        let abandoned = {
493            let mut inner = self.lock();
494            let silent: Vec<(ExecutionId, u32, u64)> = inner
495                .records
496                .iter()
497                .filter(|(_, r)| r.snapshot.state.is_active())
498                .filter_map(|(id, r)| {
499                    let reported = r.reported.as_ref()?;
500                    let quiet = reported.heard.elapsed();
501                    (quiet > silence).then(|| (id.clone(), reported.pid, quiet.as_secs()))
502                })
503                .collect();
504            let mut abandoned = Vec::new();
505            for (id, pid, quiet) in silent {
506                let payload = EventPayload::Failed {
507                    error: ExecutionError {
508                        code: "unavailable".into(),
509                        message: format!(
510                            "process {pid}, which ran this execution and reported it here, has not reported for {quiet} s; what it did afterwards is unknown to this server"
511                        ),
512                        suggestion: Some(
513                            "the terminal that ran it has the outcome; `majordomus run` reports to the server only while it is running".into(),
514                        ),
515                        correlation_id: id.to_string(),
516                    },
517                };
518                // an active execution may always fail, so the event is published; the
519                // id is named abandoned only when it was
520                let event = self.publish_locked(&mut inner, &id, payload);
521                abandoned.extend(event.is_some().then_some(id));
522                published.extend(event);
523            }
524            abandoned
525        };
526        for (event, deliveries) in published {
527            traced(&event, deliveries);
528        }
529        abandoned
530    }
531
532    /// Is this an execution another process runs and reports here?
533    ///
534    /// ```
535    /// use majordomus_cli::execution::{Actor, EventPayload, ExecutionId, ExecutionState};
536    /// use majordomus_cli::execution::{ExecutionStore, Limits, RepositoryRef, Seed};
537    /// let store = ExecutionStore::new(Limits::default());
538    /// let seed = || Seed {
539    ///     capability: "executions.demonstrate",
540    ///     title: "Demonstrate an execution",
541    ///     input: serde_json::json!({ "steps": 1 }),
542    ///     cancellable: true,
543    ///     actor: Actor::reported(4242),
544    ///     repository: RepositoryRef { name: "r".into(), id: "id".into(), branch: None },
545    /// };
546    /// let reported = ExecutionId::fresh();
547    /// store.adopt(reported.clone(), seed(), 4242).unwrap();
548    /// assert!(store.is_reported(&reported));
549    /// assert!(!store.is_reported(&ExecutionId::fresh()), "nothing is held under that id");
550    /// ```
551    pub fn is_reported(&self, id: &ExecutionId) -> bool {
552        self.lock()
553            .records
554            .get(id)
555            .is_some_and(|r| r.reported.is_some())
556    }
557
558    /// Insert a record, queued, and evict what the bound asks. Its `created` event is not
559    /// published yet: [`Self::publish_created`] does that once the lock is let go.
560    fn insert(
561        &self,
562        id: ExecutionId,
563        seed: Seed<'_>,
564        reported: Option<Reported>,
565    ) -> (ExecutionId, Arc<AtomicBool>, EventPayload) {
566        let Seed {
567            capability,
568            title,
569            input,
570            cancellable,
571            actor,
572            repository,
573        } = seed;
574        let cancel = Arc::new(AtomicBool::new(false));
575        let now = crate::peers::rfc3339(std::time::SystemTime::now());
576        let snapshot = Execution {
577            id: id.clone(),
578            capability: capability.to_string(),
579            title: title.to_string(),
580            state: ExecutionState::Queued,
581            created_at: now,
582            started_at: None,
583            finished_at: None,
584            duration_ms: None,
585            input: input.clone(),
586            output: None,
587            error: None,
588            progress: None,
589            steps: Vec::new(),
590            diagnostics: Vec::new(),
591            last_sequence: 0,
592            event_count: 0,
593            events_truncated: false,
594            cancellable,
595            actor,
596            repository,
597            correlation_id: id.to_string(),
598        };
599        {
600            let mut inner = self.lock();
601            inner.records.insert(
602                id.clone(),
603                Record {
604                    snapshot,
605                    events: VecDeque::new(),
606                    first_retained: 1,
607                    cancel: Arc::clone(&cancel),
608                    cancel_requested: false,
609                    started: None,
610                    reported,
611                },
612            );
613            inner.order.push_back(id.clone());
614            self.evict(&mut inner);
615        }
616        let created = EventPayload::Created {
617            capability: capability.to_string(),
618            title: title.to_string(),
619            input,
620        };
621        (id, cancel, created)
622    }
623
624    fn publish_created(
625        &self,
626        (id, cancel, created): (ExecutionId, Arc<AtomicBool>, EventPayload),
627    ) -> Arc<AtomicBool> {
628        self.publish(&id, created);
629        cancel
630    }
631
632    /// Forget the oldest finished executions until the bound holds. A running execution is
633    /// never forgotten, however old: the bound protects memory, and dropping the record of
634    /// work that is still happening would protect nothing and lose the only handle on it.
635    fn evict(&self, inner: &mut Inner) {
636        while inner.records.len() > self.limits.max_executions {
637            let Some(position) = inner.order.iter().position(|id| {
638                inner
639                    .records
640                    .get(id)
641                    .is_some_and(|r| r.snapshot.state.is_final())
642            }) else {
643                break;
644            };
645            if let Some(id) = inner.order.remove(position) {
646                inner.records.remove(&id);
647            }
648        }
649    }
650
651    /// Apply an event to an execution: move its state, update its snapshot, retain it, and
652    /// hand it to every subscriber that wants it.
653    ///
654    /// Returns the sequence the event was given, or `None` when there is no such execution
655    /// or the payload asks for a transition the state machine does not allow — which is
656    /// how a second `completed` after a `cancelled` is refused rather than believed.
657    pub fn publish(&self, id: &ExecutionId, payload: EventPayload) -> Option<u64> {
658        let (event, deliveries) = {
659            let mut inner = self.lock();
660            self.publish_locked(&mut inner, id, payload)?
661        };
662        traced(&event, deliveries);
663        Some(event.sequence)
664    }
665
666    /// [`Self::publish`] under a lock the caller already holds, for a caller whose own
667    /// decision has to be taken in the same instant as the transition it leads to.
668    fn publish_locked(
669        &self,
670        inner: &mut Inner,
671        id: &ExecutionId,
672        payload: EventPayload,
673    ) -> Option<(Arc<ExecutionEvent>, usize)> {
674        let limits = self.limits;
675        let record = inner.records.get_mut(id)?;
676        if let Some(next) = payload.implies_state() {
677            let current = record.snapshot.state;
678            if current == next {
679                // saying the same thing twice is not a transition and not an error
680            } else if !current.may_move_to(next) {
681                tracing::debug!(
682                    execution_id = %id,
683                    from = current.as_str(),
684                    to = next.as_str(),
685                    "an execution event was refused: the state machine does not allow the move"
686                );
687                return None;
688            }
689        }
690        let sequence = record.snapshot.last_sequence + 1;
691        apply(record, &payload, sequence, limits);
692        let event = Arc::new(ExecutionEvent::new(id.clone(), sequence, payload));
693        record.events.push_back(Arc::clone(&event));
694        while record.events.len() > limits.max_events {
695            record.events.pop_front();
696            record.first_retained += 1;
697            record.snapshot.events_truncated = true;
698        }
699        let deliveries = inner.fan_out(id, &event);
700        Some((event, deliveries))
701    }
702
703    /// The snapshot of one execution.
704    pub fn get(&self, id: &ExecutionId) -> Option<Execution> {
705        self.lock().records.get(id).map(|r| r.snapshot.clone())
706    }
707
708    /// Every execution this process remembers, newest first, optionally narrowed.
709    pub fn list(
710        &self,
711        state: Option<ExecutionState>,
712        capability: Option<&str>,
713        limit: usize,
714    ) -> Vec<Execution> {
715        let inner = self.lock();
716        inner
717            .order
718            .iter()
719            .rev()
720            .filter_map(|id| inner.records.get(id))
721            .map(|r| &r.snapshot)
722            .filter(|s| state.is_none_or(|want| s.state == want))
723            .filter(|s| capability.is_none_or(|want| s.capability == want))
724            .take(limit)
725            .cloned()
726            .collect()
727    }
728
729    /// How many executions this store remembers.
730    pub fn len(&self) -> usize {
731        self.lock().records.len()
732    }
733
734    /// Does it remember none?
735    pub fn is_empty(&self) -> bool {
736        self.len() == 0
737    }
738
739    /// How many executions are in a state right now.
740    pub fn count(&self, state: ExecutionState) -> usize {
741        self.lock()
742            .records
743            .values()
744            .filter(|r| r.snapshot.state == state)
745            .count()
746    }
747
748    /// How many are not finished: running, starting, cancelling or queued.
749    pub fn active(&self) -> usize {
750        self.lock()
751            .records
752            .values()
753            .filter(|r| r.snapshot.state.is_active())
754            .count()
755    }
756
757    /// A page of one execution's retained history, after a sequence.
758    pub fn events(&self, id: &ExecutionId, after: u64, limit: usize) -> Option<EventPage> {
759        let inner = self.lock();
760        let record = inner.records.get(id)?;
761        let all: Vec<Arc<ExecutionEvent>> = record
762            .events
763            .iter()
764            .filter(|e| e.sequence > after)
765            .cloned()
766            .collect();
767        let more = all.len() > limit;
768        let events: Vec<Arc<ExecutionEvent>> = all.into_iter().take(limit).collect();
769        Some(EventPage {
770            last_sequence: events
771                .last()
772                .map(|e| e.sequence)
773                .unwrap_or_else(|| after.max(record.snapshot.last_sequence)),
774            more,
775            // the client asked from a point the store no longer holds
776            truncated: after + 1 < record.first_retained,
777            events,
778        })
779    }
780
781    /// The cancellation token of an execution, for the worker that runs it.
782    pub fn token(&self, id: &ExecutionId) -> Option<Arc<AtomicBool>> {
783        self.lock().records.get(id).map(|r| Arc::clone(&r.cancel))
784    }
785
786    /// Ask an execution to stop. Publishes `execution.cancelling` and sets the token; the
787    /// handler decides when it stops, and a handler that never looks at its token will
788    /// finish normally, which the final state will say.
789    ///
790    /// Both happen under one hold of the lock. A worker reads the token without the lock,
791    /// but a handler that stops publishes `execution.cancelled`, which needs the lock that
792    /// `cancelling` is recorded under, so a handler that sees the token cannot publish
793    /// until `cancelling` is recorded, whichever of the two is done first inside. Recording
794    /// the state before the token is extra ordering on top of that: on its own it would
795    /// hold even were the token set after the lock is let go. What neither may be is the
796    /// token set in one hold and `cancelling` published in the next: a handler looking in
797    /// between publishes `cancelled` from `running` — a move the state machine refuses —
798    /// and the `cancelling` that follows is the last thing the execution ever says.
799    pub fn request_cancel(&self, id: &ExecutionId, by: &str) -> CancelOutcome {
800        let published = {
801            let mut inner = self.lock();
802            let Some(record) = inner.records.get_mut(id) else {
803                return CancelOutcome::Unknown;
804            };
805            let state = record.snapshot.state;
806            if state.is_final() {
807                return CancelOutcome::AlreadyFinished(state);
808            }
809            if record.cancel_requested {
810                return CancelOutcome::AlreadyRequested;
811            }
812            record.cancel_requested = true;
813            // a reported execution is stopped by the process that runs it, which the next
814            // report tells; a queued one there is still that process's to finish, so nothing
815            // is said here that its own report would then contradict
816            if record.reported.is_some() && state == ExecutionState::Queued {
817                return CancelOutcome::Requested;
818            }
819            let token = Arc::clone(&record.cancel);
820            // a queued execution has no worker to notice the token, so it is finished here
821            let payload = if state == ExecutionState::Queued {
822                EventPayload::Cancelled
823            } else {
824                EventPayload::Cancelling { by: by.to_string() }
825            };
826            let published = self.publish_locked(&mut inner, id, payload);
827            token.store(true, Ordering::SeqCst);
828            published
829        };
830        if let Some((event, deliveries)) = published {
831            traced(&event, deliveries);
832        }
833        CancelOutcome::Requested
834    }
835
836    /// Listen. The receiver is dropped to unsubscribe; the store forgets a subscriber the
837    /// first time a send finds it gone, so nothing has to be cleaned up by hand.
838    pub fn subscribe(&self, filter: Filter) -> (u64, std::sync::mpsc::Receiver<Delivery>) {
839        let (tx, rx) = std::sync::mpsc::sync_channel(self.limits.max_subscriber_queue);
840        let id = self.next_subscriber.fetch_add(1, Ordering::Relaxed);
841        self.lock().subscribers.push(Subscriber {
842            id,
843            filter,
844            tx,
845            dropped: 0,
846        });
847        (id, rx)
848    }
849
850    /// Change what a subscriber wants, without reconnecting.
851    pub fn resubscribe(&self, subscriber: u64, filter: Filter) -> bool {
852        let mut inner = self.lock();
853        match inner.subscribers.iter_mut().find(|s| s.id == subscriber) {
854            Some(s) => {
855                s.filter = filter;
856                true
857            }
858            None => false,
859        }
860    }
861
862    /// Stop listening.
863    pub fn unsubscribe(&self, subscriber: u64) {
864        self.lock().subscribers.retain(|s| s.id != subscriber);
865    }
866
867    /// How many subscribers this store is feeding.
868    pub fn subscribers(&self) -> usize {
869        self.lock().subscribers.len()
870    }
871}
872
873impl Inner {
874    /// Hand one event to every subscriber that wants it, and forget the ones that are
875    /// gone. Returns how many received it.
876    fn fan_out(&mut self, id: &ExecutionId, event: &Arc<ExecutionEvent>) -> usize {
877        let mut delivered = 0usize;
878        let mut gone = Vec::new();
879        for subscriber in self.subscribers.iter_mut() {
880            if !subscriber.filter.wants(id) {
881                continue;
882            }
883            if subscriber.dropped > 0 {
884                // tell it once that it missed something, before anything else
885                match subscriber.tx.try_send(Delivery::Lagged {
886                    dropped: subscriber.dropped,
887                }) {
888                    Ok(()) => subscriber.dropped = 0,
889                    Err(std::sync::mpsc::TrySendError::Full(_)) => {
890                        subscriber.dropped += 1;
891                        continue;
892                    }
893                    Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
894                        gone.push(subscriber.id);
895                        continue;
896                    }
897                }
898            }
899            match subscriber.tx.try_send(Delivery::Event(Arc::clone(event))) {
900                Ok(()) => delivered += 1,
901                // a slow client never stalls an execution: the event is dropped for that
902                // client alone and it is told to resynchronise
903                Err(std::sync::mpsc::TrySendError::Full(_)) => subscriber.dropped += 1,
904                Err(std::sync::mpsc::TrySendError::Disconnected(_)) => gone.push(subscriber.id),
905            }
906        }
907        if !gone.is_empty() {
908            self.subscribers.retain(|s| !gone.contains(&s.id));
909        }
910        delivered
911    }
912}
913
914/// Say in this process's log that an event was published, once the lock is let go.
915fn traced(event: &ExecutionEvent, deliveries: usize) {
916    tracing::debug!(
917        execution_id = %event.execution_id,
918        sequence = event.sequence,
919        event = event.type_name(),
920        subscribers = deliveries,
921        "execution event"
922    );
923}
924
925/// Fold one event into a snapshot. The only place a snapshot changes.
926fn apply(record: &mut Record, payload: &EventPayload, sequence: u64, limits: Limits) {
927    let now = crate::peers::rfc3339(std::time::SystemTime::now());
928    let s = &mut record.snapshot;
929    s.last_sequence = sequence;
930    s.event_count += 1;
931    if let Some(next) = payload.implies_state() {
932        s.state = next;
933    }
934    match payload {
935        EventPayload::Started => {
936            s.started_at = Some(now.clone());
937            record.started = Some(Instant::now());
938        }
939        EventPayload::Progress(p) => {
940            s.progress = Some(ProgressView {
941                current: p.current,
942                total: p.total,
943                message: p.message.clone(),
944            });
945        }
946        EventPayload::StepStarted { name, title } => {
947            s.steps.push(StepView {
948                name: name.clone(),
949                title: title.clone(),
950                state: StepState::Running,
951                started_at: now.clone(),
952                finished_at: None,
953                detail: None,
954            });
955        }
956        EventPayload::StepCompleted { name, ok, detail } => {
957            let state = if *ok {
958                StepState::Completed
959            } else {
960                StepState::Failed
961            };
962            match s.steps.iter_mut().rev().find(|s| &s.name == name) {
963                Some(step) => {
964                    step.state = state;
965                    step.finished_at = Some(now.clone());
966                    step.detail.clone_from(detail);
967                }
968                // a handler that reports a phase as decided without having announced it
969                // first is reporting the truth; inventing a start it never had would not be
970                None => s.steps.push(StepView {
971                    name: name.clone(),
972                    title: name.clone(),
973                    state,
974                    started_at: now.clone(),
975                    finished_at: Some(now.clone()),
976                    detail: detail.clone(),
977                }),
978            }
979        }
980        EventPayload::Diagnostic(d) => {
981            // bounded like everything else: a handler that reports a finding per file of a
982            // large repository must not grow the snapshot every list request answers
983            if s.diagnostics.len() < limits.max_events.min(200) {
984                s.diagnostics.push(d.clone());
985            }
986        }
987        EventPayload::Completed { output } => {
988            s.output = Some(output.clone());
989            s.error = None;
990        }
991        EventPayload::Failed { error } => {
992            s.error = Some(error.clone());
993            s.output = None;
994        }
995        // A cancellation after a failure keeps the failure: the guard, not a nested `if`,
996        // because the arm below it is the no-op the other case already falls to.
997        EventPayload::Cancelled if s.error.is_none() => {
998            s.error = Some(ExecutionError {
999                code: "cancelled".into(),
1000                message: "the execution was cancelled".into(),
1001                suggestion: None,
1002                correlation_id: s.correlation_id.clone(),
1003            });
1004        }
1005        _ => {}
1006    }
1007    if s.state.is_final() && s.finished_at.is_none() {
1008        s.finished_at = Some(now);
1009        s.duration_ms = record.started.map(|t| t.elapsed().as_millis() as u64);
1010    }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016    use crate::execution::model::{ActorKind, LogStream};
1017
1018    fn store() -> ExecutionStore {
1019        ExecutionStore::new(Limits::default())
1020    }
1021
1022    fn repository() -> RepositoryRef {
1023        RepositoryRef {
1024            name: "r".into(),
1025            id: "id".into(),
1026            branch: None,
1027        }
1028    }
1029
1030    fn start(store: &ExecutionStore) -> ExecutionId {
1031        let id = ExecutionId::fresh();
1032        store.create(
1033            id.clone(),
1034            "demo.x",
1035            "Demo",
1036            serde_json::json!({}),
1037            true,
1038            Actor::of(ActorKind::Internal),
1039            repository(),
1040        );
1041        id
1042    }
1043
1044    #[test]
1045    fn a_snapshot_and_the_stream_agree_at_every_step() {
1046        let store = store();
1047        let (_, rx) = store.subscribe(Filter::All);
1048        let id = start(&store);
1049        store.publish(&id, EventPayload::Started);
1050        store.publish(
1051            &id,
1052            EventPayload::Progress(ProgressView {
1053                current: 1,
1054                total: Some(2),
1055                message: Some("half".into()),
1056            }),
1057        );
1058        store.publish(
1059            &id,
1060            EventPayload::Completed {
1061                output: serde_json::json!({ "ok": true }),
1062            },
1063        );
1064        let snapshot = store.get(&id).expect("a snapshot");
1065        assert_eq!(snapshot.state, ExecutionState::Succeeded);
1066        assert_eq!(snapshot.output, Some(serde_json::json!({ "ok": true })));
1067        assert_eq!(snapshot.percent(), Some(50));
1068        assert!(snapshot.finished_at.is_some());
1069        assert_eq!(snapshot.last_sequence, 4);
1070        assert_eq!(snapshot.event_count, 4);
1071
1072        let seen: Vec<Arc<ExecutionEvent>> = rx
1073            .try_iter()
1074            .map(|d| match d {
1075                Delivery::Event(e) => e,
1076                Delivery::Lagged { .. } => panic!("a reader that kept up was told it lagged"),
1077            })
1078            .collect();
1079        assert_eq!(seen.len(), 4);
1080        assert_eq!(
1081            seen.iter().map(|e| e.sequence).collect::<Vec<_>>(),
1082            [1, 2, 3, 4],
1083            "sequences are dense and ordered"
1084        );
1085        assert_eq!(seen.last().unwrap().type_name(), "execution.completed");
1086    }
1087
1088    #[test]
1089    fn a_refused_transition_changes_nothing() {
1090        let store = store();
1091        let id = start(&store);
1092        store.publish(&id, EventPayload::Started);
1093        store.publish(
1094            &id,
1095            EventPayload::Completed {
1096                output: Value::Null,
1097            },
1098        );
1099        let before = store.get(&id).unwrap();
1100        assert_eq!(
1101            store.publish(&id, EventPayload::Started),
1102            None,
1103            "a finished execution does not start again"
1104        );
1105        assert_eq!(
1106            store.publish(
1107                &id,
1108                EventPayload::Failed {
1109                    error: ExecutionError {
1110                        code: "internal".into(),
1111                        message: "no".into(),
1112                        suggestion: None,
1113                        correlation_id: id.to_string()
1114                    }
1115                }
1116            ),
1117            None
1118        );
1119        assert_eq!(store.get(&id).unwrap(), before);
1120        assert_eq!(
1121            store.publish(&ExecutionId::fresh(), EventPayload::Started),
1122            None
1123        );
1124    }
1125
1126    #[test]
1127    fn history_replays_from_a_cursor_and_says_when_it_lost_the_start() {
1128        let limits = Limits {
1129            max_events: 4,
1130            ..Limits::default()
1131        };
1132        let store = ExecutionStore::new(limits);
1133        let id = start(&store);
1134        store.publish(&id, EventPayload::Started);
1135        for n in 0..6 {
1136            store.publish(
1137                &id,
1138                EventPayload::Log {
1139                    stream: LogStream::Handler,
1140                    message: format!("line {n}"),
1141                },
1142            );
1143        }
1144        let page = store.events(&id, 0, 100).expect("a page");
1145        assert_eq!(page.events.len(), 4, "the bound holds");
1146        assert!(page.truncated, "and says the start is gone");
1147        let snapshot = store.get(&id).unwrap();
1148        assert!(snapshot.events_truncated);
1149        assert_eq!(snapshot.event_count, 8, "counted, not retained");
1150
1151        let last = page.last_sequence;
1152        let after = store.events(&id, last, 100).unwrap();
1153        assert!(after.events.is_empty());
1154        assert!(!after.more);
1155        assert_eq!(after.last_sequence, last);
1156
1157        let paged = store.events(&id, 4, 2).unwrap();
1158        assert_eq!(paged.events.len(), 2);
1159        assert!(paged.more);
1160    }
1161
1162    #[test]
1163    fn a_slow_subscriber_is_told_to_resynchronise_and_never_stalls_the_execution() {
1164        let store = ExecutionStore::new(Limits {
1165            max_subscriber_queue: 2,
1166            ..Limits::default()
1167        });
1168        let (_, rx) = store.subscribe(Filter::All);
1169        let id = start(&store); // fills the queue: created is 1 of 2
1170        for n in 0..20 {
1171            store.publish(
1172                &id,
1173                EventPayload::Log {
1174                    stream: LogStream::Handler,
1175                    message: n.to_string(),
1176                },
1177            );
1178        }
1179        // the execution kept going regardless of the reader
1180        assert_eq!(store.get(&id).unwrap().event_count, 21);
1181        let deliveries: Vec<Delivery> = rx.try_iter().collect();
1182        assert!(deliveries.len() <= 3, "the queue stayed bounded");
1183        // drain and then publish once more: the reader is told what it missed
1184        store.publish(&id, EventPayload::Started);
1185        let mut told = false;
1186        for d in rx.try_iter() {
1187            if let Delivery::Lagged { dropped } = d {
1188                assert!(dropped > 0);
1189                told = true;
1190            }
1191        }
1192        assert!(told, "a lagging subscriber is told, not silently starved");
1193    }
1194
1195    #[test]
1196    fn a_dropped_receiver_is_forgotten_on_the_next_event() {
1197        let store = store();
1198        let (_, rx) = store.subscribe(Filter::All);
1199        assert_eq!(store.subscribers(), 1);
1200        drop(rx);
1201        let id = start(&store);
1202        store.publish(&id, EventPayload::Started);
1203        assert_eq!(store.subscribers(), 0);
1204    }
1205
1206    #[test]
1207    fn a_filter_narrows_and_can_be_changed_without_reconnecting() {
1208        let store = store();
1209        let a = start(&store);
1210        let (sub, rx) = store.subscribe(Filter::Only(vec![a.clone()]));
1211        let b = start(&store);
1212        store.publish(&b, EventPayload::Started);
1213        assert!(rx.try_iter().next().is_none(), "b was not wanted");
1214        assert!(store.resubscribe(sub, Filter::Only(vec![b.clone()])));
1215        store.publish(
1216            &b,
1217            EventPayload::Progress(ProgressView {
1218                current: 1,
1219                total: None,
1220                message: None,
1221            }),
1222        );
1223        assert!(matches!(rx.try_recv(), Ok(Delivery::Event(_))));
1224        store.unsubscribe(sub);
1225        assert_eq!(store.subscribers(), 0);
1226        assert!(!store.resubscribe(sub, Filter::All));
1227    }
1228
1229    #[test]
1230    fn cancelling_a_queued_execution_finishes_it_and_cancelling_twice_says_so() {
1231        let store = store();
1232        let id = start(&store);
1233        assert_eq!(store.request_cancel(&id, "test"), CancelOutcome::Requested);
1234        assert_eq!(store.get(&id).unwrap().state, ExecutionState::Cancelled);
1235        assert_eq!(
1236            store.request_cancel(&id, "test"),
1237            CancelOutcome::AlreadyFinished(ExecutionState::Cancelled)
1238        );
1239        assert_eq!(
1240            store.request_cancel(&ExecutionId::fresh(), "test"),
1241            CancelOutcome::Unknown
1242        );
1243
1244        let running = start(&store);
1245        store.publish(&running, EventPayload::Started);
1246        assert_eq!(
1247            store.request_cancel(&running, "test"),
1248            CancelOutcome::Requested
1249        );
1250        assert!(store.token(&running).unwrap().load(Ordering::SeqCst));
1251        assert_eq!(
1252            store.get(&running).unwrap().state,
1253            ExecutionState::Cancelling
1254        );
1255        assert_eq!(
1256            store.request_cancel(&running, "test"),
1257            CancelOutcome::AlreadyRequested
1258        );
1259        store.publish(&running, EventPayload::Cancelled);
1260        let snapshot = store.get(&running).unwrap();
1261        assert_eq!(snapshot.state, ExecutionState::Cancelled);
1262        assert_eq!(
1263            snapshot.error.map(|e| e.code),
1264            Some("cancelled".to_string())
1265        );
1266    }
1267
1268    #[test]
1269    fn a_handler_that_stops_the_instant_it_sees_the_token_still_ends_cancelled() {
1270        // A worker reads the token without the lock and publishes `cancelled` the moment it
1271        // sees it. Were the token visible, with the lock let go, before `cancelling` is
1272        // recorded, that `cancelled` would arrive from `running`, which the state machine
1273        // refuses, and the `cancelling` that landed after it would be the execution's last
1274        // word — the engine's test once waited its whole ten seconds on exactly that. A
1275        // worker spinning on the token makes the loaded runner's rare interleaving the
1276        // common one.
1277        for round in 0..300 {
1278            let store = Arc::new(store());
1279            let id = start(&store);
1280            store.publish(&id, EventPayload::Started);
1281            let token = store.token(&id).unwrap();
1282            let ready = Arc::new(AtomicBool::new(false));
1283            let worker = {
1284                let (store, id, ready) = (Arc::clone(&store), id.clone(), Arc::clone(&ready));
1285                std::thread::spawn(move || {
1286                    ready.store(true, Ordering::SeqCst);
1287                    // bounded, so that a cancel that never sets the token fails rather than hangs
1288                    let deadline = Instant::now() + std::time::Duration::from_secs(10);
1289                    while !token.load(Ordering::SeqCst) {
1290                        if Instant::now() >= deadline {
1291                            return None;
1292                        }
1293                        std::hint::spin_loop();
1294                    }
1295                    store.publish(&id, EventPayload::Cancelled)
1296                })
1297            };
1298            while !ready.load(Ordering::SeqCst) {
1299                std::hint::spin_loop();
1300            }
1301            assert_eq!(store.request_cancel(&id, "test"), CancelOutcome::Requested);
1302            let published = worker.join().unwrap();
1303            let state = store.get(&id).unwrap().state;
1304            assert!(
1305                published.is_some() && state == ExecutionState::Cancelled,
1306                "round {round}: the handler stopped and the execution says {state:?}"
1307            );
1308        }
1309
1310        // the other side of the same lock: once a queued execution is cancelled, a worker
1311        // that picked it up in that instant is refused its start, and so runs nothing
1312        let store = store();
1313        let id = start(&store);
1314        assert_eq!(store.request_cancel(&id, "test"), CancelOutcome::Requested);
1315        assert_eq!(store.publish(&id, EventPayload::Started), None);
1316        assert_eq!(store.get(&id).unwrap().state, ExecutionState::Cancelled);
1317    }
1318
1319    #[test]
1320    fn the_oldest_finished_execution_is_forgotten_and_a_running_one_never_is() {
1321        let store = ExecutionStore::new(Limits {
1322            max_executions: 2,
1323            ..Limits::default()
1324        });
1325        let running = start(&store);
1326        store.publish(&running, EventPayload::Started);
1327        let mut finished = Vec::new();
1328        for _ in 0..4 {
1329            let id = start(&store);
1330            store.publish(&id, EventPayload::Started);
1331            store.publish(
1332                &id,
1333                EventPayload::Completed {
1334                    output: Value::Null,
1335                },
1336            );
1337            finished.push(id);
1338        }
1339        assert!(
1340            store.get(&running).is_some(),
1341            "work in flight is not evicted"
1342        );
1343        assert!(
1344            store.get(finished.last().unwrap()).is_some(),
1345            "the newest is kept"
1346        );
1347        assert!(
1348            store.get(&finished[0]).is_none(),
1349            "the oldest finished is gone"
1350        );
1351        assert_eq!(store.active(), 1);
1352        assert_eq!(
1353            store.count(ExecutionState::Succeeded),
1354            store.list(Some(ExecutionState::Succeeded), None, 100).len()
1355        );
1356    }
1357
1358    fn seed() -> Seed<'static> {
1359        Seed {
1360            capability: "demo.x",
1361            title: "Demo",
1362            input: serde_json::json!({}),
1363            cancellable: true,
1364            actor: Actor::reported(4242),
1365            repository: repository(),
1366        }
1367    }
1368
1369    fn log(n: u32) -> EventPayload {
1370        EventPayload::Log {
1371            stream: LogStream::Handler,
1372            message: format!("line {n}"),
1373        }
1374    }
1375
1376    #[test]
1377    fn a_reported_execution_is_applied_once_in_order_and_resumes_after_a_gap() {
1378        let store = store();
1379        let (_, rx) = store.subscribe(Filter::All);
1380        let id = ExecutionId::fresh();
1381        store.adopt(id.clone(), seed(), 4242).expect("adopted");
1382        assert!(store.is_reported(&id));
1383        // adopting again — every report carries the description — changes nothing
1384        store
1385            .adopt(id.clone(), seed(), 4242)
1386            .expect("adopted twice");
1387        assert_eq!(store.get(&id).unwrap().event_count, 1, "one created event");
1388
1389        // the reporter's own sequence: 1 created, 2 queued, 3 started, 4 a log line
1390        let first = vec![
1391            (
1392                1,
1393                EventPayload::Created {
1394                    capability: "demo.x".into(),
1395                    title: "Demo".into(),
1396                    input: serde_json::json!({}),
1397                },
1398            ),
1399            (2, EventPayload::Queued { ahead: 0 }),
1400            (3, EventPayload::Started),
1401            (4, log(1)),
1402        ];
1403        let outcome = store
1404            .report(&id, first.clone())
1405            .expect("a reported execution");
1406        assert_eq!((outcome.applied, outcome.through), (3, 4));
1407        assert!(!outcome.cancel_requested);
1408        assert_eq!(store.get(&id).unwrap().state, ExecutionState::Running);
1409
1410        // the same report again is harmless
1411        let again = store.report(&id, first).unwrap();
1412        assert_eq!((again.applied, again.through), (0, 4));
1413
1414        // a report with a gap applies nothing past it and says where to resume
1415        let gapped = store.report(&id, vec![(6, log(3))]).unwrap();
1416        assert_eq!((gapped.applied, gapped.through), (0, 4));
1417        let resumed = store
1418            .report(
1419                &id,
1420                vec![
1421                    (5, log(2)),
1422                    (6, log(3)),
1423                    (
1424                        7,
1425                        EventPayload::Completed {
1426                            output: serde_json::json!({ "ok": true }),
1427                        },
1428                    ),
1429                ],
1430            )
1431            .unwrap();
1432        assert_eq!((resumed.applied, resumed.through), (3, 7));
1433        let snapshot = store.get(&id).unwrap();
1434        assert_eq!(snapshot.state, ExecutionState::Succeeded);
1435        assert_eq!(snapshot.output, Some(serde_json::json!({ "ok": true })));
1436        assert_eq!(snapshot.actor.pid, Some(4242));
1437        // the live channel saw it as it sees this process's own
1438        let types: Vec<&str> = rx
1439            .try_iter()
1440            .filter_map(|d| match d {
1441                Delivery::Event(e) => Some(e.type_name()),
1442                Delivery::Lagged { .. } => None,
1443            })
1444            .collect();
1445        assert_eq!(
1446            types,
1447            [
1448                "execution.created",
1449                "execution.queued",
1450                "execution.started",
1451                "execution.log",
1452                "execution.log",
1453                "execution.log",
1454                "execution.completed"
1455            ]
1456        );
1457    }
1458
1459    /// A report about an id this store holds nothing under answers nothing, and a reporter
1460    /// that repeats its own `created` past the one the store published is not believed twice.
1461    #[test]
1462    fn a_report_about_nothing_held_answers_nothing_and_a_repeated_created_is_passed_over() {
1463        let store = store();
1464        assert_eq!(
1465            store.report(&ExecutionId::fresh(), vec![(2, EventPayload::Started)]),
1466            None
1467        );
1468        let id = ExecutionId::fresh();
1469        store.adopt(id.clone(), seed(), 4242).expect("adopted");
1470        let outcome = store
1471            .report(
1472                &id,
1473                vec![(
1474                    2,
1475                    EventPayload::Created {
1476                        capability: "demo.x".into(),
1477                        title: "Demo".into(),
1478                        input: serde_json::json!({}),
1479                    },
1480                )],
1481            )
1482            .expect("a reported execution");
1483        assert_eq!((outcome.applied, outcome.through), (0, 2));
1484        assert_eq!(store.get(&id).unwrap().event_count, 1, "one created event");
1485    }
1486
1487    #[test]
1488    fn a_report_cannot_write_into_an_execution_this_process_runs() {
1489        let store = store();
1490        let own = start(&store);
1491        assert_eq!(
1492            store.adopt(own.clone(), seed(), 1),
1493            Err(AdoptError::Own(own.clone()))
1494        );
1495        assert!(!store.is_reported(&own));
1496        assert_eq!(
1497            store.report(&own, vec![(2, EventPayload::Started)]),
1498            None,
1499            "a report names a reported execution or nothing"
1500        );
1501        assert_eq!(store.get(&own).unwrap().state, ExecutionState::Queued);
1502    }
1503
1504    #[test]
1505    fn a_stop_asked_here_is_handed_to_the_process_that_runs_it() {
1506        let store = store();
1507        let queued = ExecutionId::fresh();
1508        store.adopt(queued.clone(), seed(), 7).unwrap();
1509        store
1510            .report(&queued, vec![(2, EventPayload::Queued { ahead: 1 })])
1511            .unwrap();
1512        // queued there: nothing is said here that the reporter would then contradict
1513        assert_eq!(
1514            store.request_cancel(&queued, "client"),
1515            CancelOutcome::Requested
1516        );
1517        assert_eq!(store.get(&queued).unwrap().state, ExecutionState::Queued);
1518        let told = store.report(&queued, Vec::new()).unwrap();
1519        assert!(told.cancel_requested, "the reporter is told to stop it");
1520        store
1521            .report(&queued, vec![(3, EventPayload::Cancelled)])
1522            .unwrap();
1523        assert_eq!(store.get(&queued).unwrap().state, ExecutionState::Cancelled);
1524
1525        let running = ExecutionId::fresh();
1526        store.adopt(running.clone(), seed(), 7).unwrap();
1527        store
1528            .report(
1529                &running,
1530                vec![
1531                    (2, EventPayload::Queued { ahead: 0 }),
1532                    (3, EventPayload::Started),
1533                ],
1534            )
1535            .unwrap();
1536        assert_eq!(
1537            store.request_cancel(&running, "client"),
1538            CancelOutcome::Requested
1539        );
1540        assert_eq!(
1541            store.get(&running).unwrap().state,
1542            ExecutionState::Cancelling
1543        );
1544        let before = store.get(&running).unwrap().event_count;
1545        // the reporter cancels its own and says so; the repeat is not a second event
1546        let outcome = store
1547            .report(
1548                &running,
1549                vec![
1550                    (
1551                        4,
1552                        EventPayload::Cancelling {
1553                            by: "server".into(),
1554                        },
1555                    ),
1556                    (5, EventPayload::Cancelled),
1557                ],
1558            )
1559            .unwrap();
1560        assert_eq!((outcome.applied, outcome.through), (1, 5));
1561        let snapshot = store.get(&running).unwrap();
1562        assert_eq!(snapshot.state, ExecutionState::Cancelled);
1563        assert_eq!(snapshot.event_count, before + 1);
1564    }
1565
1566    #[test]
1567    fn a_reporter_that_goes_silent_is_failed_rather_than_shown_running_for_ever() {
1568        let store = store();
1569        let id = ExecutionId::fresh();
1570        store.adopt(id.clone(), seed(), 99).unwrap();
1571        store
1572            .report(
1573                &id,
1574                vec![
1575                    (2, EventPayload::Queued { ahead: 0 }),
1576                    (3, EventPayload::Started),
1577                ],
1578            )
1579            .unwrap();
1580        let own = start(&store);
1581        store.publish(&own, EventPayload::Started);
1582        assert!(
1583            store.abandon_silent(REPORT_SILENCE).is_empty(),
1584            "heard just now"
1585        );
1586        std::thread::sleep(Duration::from_millis(20));
1587        assert_eq!(
1588            store.abandon_silent(Duration::from_millis(5)),
1589            vec![id.clone()]
1590        );
1591        let snapshot = store.get(&id).unwrap();
1592        assert_eq!(snapshot.state, ExecutionState::Failed);
1593        let error = snapshot.error.expect("an error");
1594        assert_eq!(error.code, "unavailable");
1595        assert!(error.message.contains("process 99"), "{}", error.message);
1596        assert_eq!(
1597            store.get(&own).unwrap().state,
1598            ExecutionState::Running,
1599            "this process's own execution is never abandoned"
1600        );
1601        assert!(store.abandon_silent(Duration::ZERO).is_empty(), "once");
1602        // a reporter that comes back finds it final, and nothing moves it
1603        store.report(
1604            &id,
1605            vec![(
1606                4,
1607                EventPayload::Completed {
1608                    output: Value::Null,
1609                },
1610            )],
1611        );
1612        assert_eq!(store.get(&id).unwrap().state, ExecutionState::Failed);
1613    }
1614
1615    #[test]
1616    fn listing_is_newest_first_and_narrows_by_state_and_capability() {
1617        let store = store();
1618        let a = start(&store);
1619        let b = start(&store);
1620        store.publish(&b, EventPayload::Started);
1621        let all = store.list(None, None, 10);
1622        assert_eq!(all.first().map(|e| e.id.clone()), Some(b.clone()));
1623        assert_eq!(all.len(), 2);
1624        assert_eq!(store.list(Some(ExecutionState::Queued), None, 10)[0].id, a);
1625        assert_eq!(store.list(None, Some("demo.x"), 10).len(), 2);
1626        assert!(store.list(None, Some("nothing.here"), 10).is_empty());
1627        assert_eq!(store.list(None, None, 1).len(), 1);
1628    }
1629}