Skip to main content

vertep_runtime/
machine.rs

1//! The deterministic part of a virtual actor: one macrostep from a journaled input to the
2//! effects its entry records, used identically by live turns and by replay.
3
4use std::collections::{BTreeMap, BTreeSet, HashSet};
5use std::sync::Arc;
6
7use oxrdf::{Literal, NamedNode, Triple};
8use serde::{Deserialize, Serialize};
9use vertep_core::datamodel::{
10    Datamodel, DatamodelRegistry, Environment, Event, EventType, SessionInfo, Value,
11};
12use vertep_core::session::{
13    self, Effect, Input, InvokeRequest, Options, SCXML_EVENT_PROCESSOR, Session, StartInput,
14    StepError,
15};
16use vertep_core::trace::MacrostepTrace;
17
18use crate::address::{self, Address};
19use crate::chart::{ChartInfo, UnhandledPolicy, split_type};
20use crate::journal::{
21    Effects, InputRecord, ReminderSpec, SendRecord, SnapshotData, TimerRecord, digest,
22    timer_triples,
23};
24use crate::json;
25use crate::vocab::{self as vt, node};
26
27/// An invocation started by an actor, with the events sent to it since, so that a chart child
28/// can be rebuilt from its parent's journal.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30pub struct InvocationLog {
31    /// The invocation.
32    pub request: InvokeRequest,
33    /// Events sent or forwarded to it, with the timestamp of the sending entry.
34    pub events: Vec<(Event, i64)>,
35}
36
37/// The runtime's per-actor state besides the session and datamodel: pending timers and
38/// reminders, open requests and running invocations. It is part of snapshots and rebuilt by
39/// replay.
40// @tg: implements:: [[openspec:durable-timers#Timer Cancellation]]
41#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
42pub struct RuntimeState {
43    /// Pending timers and reminders by IRI.
44    pub timers: BTreeMap<String, TimerRecord>,
45    /// Open requests: correlation id to the IRI of their timeout timer.
46    pub requests: BTreeMap<String, Option<String>>,
47    /// Correlation ids already answered.
48    pub answered: BTreeSet<String>,
49    /// Correlation ids whose timeout fired.
50    pub timed_out: BTreeSet<String>,
51    /// IRIs of cancelled timers.
52    pub cancelled: BTreeSet<String>,
53    /// Running invocations by invoke id.
54    pub invocations: BTreeMap<String, InvocationLog>,
55}
56
57/// Why a macrostep could not be run.
58#[derive(Clone, Debug, PartialEq)]
59pub enum MachineError {
60    /// The session has terminated.
61    Terminated,
62    /// The interpreter refused the input (budget exceeded, datamodel failure).
63    Step(String),
64    /// A snapshot could not be restored.
65    Snapshot(String),
66}
67
68impl core::fmt::Display for MachineError {
69    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
70        match self {
71            MachineError::Terminated => f.write_str("the session has terminated"),
72            MachineError::Step(e) => write!(f, "step failed: {e}"),
73            MachineError::Snapshot(e) => write!(f, "snapshot cannot be restored: {e}"),
74        }
75    }
76}
77
78/// What a machine needs from its node.
79pub(crate) struct Ctx<'a> {
80    pub registry: &'a dyn DatamodelRegistry,
81    pub ioprocessors: Value,
82    pub send_types: Vec<String>,
83    pub invoke_types: Vec<String>,
84}
85
86/// The result of applying one input.
87#[derive(Debug, Default)]
88pub(crate) struct Applied {
89    pub effects: Effects,
90    pub invokes: Vec<InvokeRequest>,
91    pub cancel_invokes: Vec<String>,
92    pub migrations: Vec<(String, String)>,
93}
94
95/// An actor's session, datamodel and runtime state under one chart version.
96pub(crate) struct Machine {
97    pub address: Address,
98    pub info: Arc<ChartInfo>,
99    pub session: Session,
100    pub dm: Box<dyn Datamodel>,
101    pub rt: RuntimeState,
102}
103
104impl Machine {
105    fn options(info: &ChartInfo, ctx: &Ctx<'_>) -> Options {
106        let mut o = Options::default();
107        for t in ctx.send_types.iter().chain(info.vt_send_types.iter()) {
108            if !o.send_types.contains(t) {
109                o.send_types.push(t.clone());
110            }
111        }
112        for t in &ctx.invoke_types {
113            if !o.invoke_types.contains(t) {
114                o.invoke_types.push(t.clone());
115            }
116        }
117        o
118    }
119
120    fn new_dm(info: &ChartInfo, ctx: &Ctx<'_>) -> Result<Box<dyn Datamodel>, MachineError> {
121        vertep_core::null::create_datamodel_for(ctx.registry, info.compiled.chart())
122            .map_err(|e| MachineError::Step(format!("{e:?}")))
123    }
124
125    fn info(address: &Address, info: &ChartInfo, ctx: &Ctx<'_>) -> SessionInfo {
126        SessionInfo {
127            sessionid: address.as_str().to_owned(),
128            name: info.ir.name.clone(),
129            ioprocessors: ctx.ioprocessors.clone(),
130        }
131    }
132
133    /// Starts the session (the start macrostep of entry 1), returning its effects.
134    pub fn start(
135        address: &Address,
136        info: Arc<ChartInfo>,
137        ctx: &Ctx<'_>,
138        env: Environment,
139    ) -> Result<(Machine, Vec<Effect>, MacrostepTrace), MachineError> {
140        let mut dm = Self::new_dm(&info, ctx)?;
141        let start = StartInput {
142            info: Self::info(address, &info, ctx),
143            env,
144            options: Self::options(&info, ctx),
145            data: Vec::new(),
146            sessions: None,
147        };
148        let o = session::start(&info.compiled, start, dm.as_mut())
149            .map_err(|e| MachineError::Step(format!("{e:?}")))?;
150        Ok((
151            Machine {
152                address: address.clone(),
153                info,
154                session: o.session,
155                dm,
156                rt: RuntimeState::default(),
157            },
158            o.effects,
159            o.trace,
160        ))
161    }
162
163    /// Restores a machine from a snapshot taken under `info`.
164    pub fn from_snapshot(
165        address: &Address,
166        info: Arc<ChartInfo>,
167        ctx: &Ctx<'_>,
168        s: &SnapshotData,
169    ) -> Result<Machine, MachineError> {
170        let mut dm = Self::new_dm(&info, ctx)?;
171        dm.init_session(&Self::info(address, &info, ctx))
172            .map_err(|e| MachineError::Snapshot(e.message))?;
173        let value =
174            json::from_text(&s.datamodel).map_err(|e| MachineError::Snapshot(e.to_string()))?;
175        dm.restore(&value)
176            .map_err(|e| MachineError::Snapshot(e.message))?;
177        let session: Session =
178            serde_json::from_str(&s.session).map_err(|e| MachineError::Snapshot(e.to_string()))?;
179        let rt: RuntimeState =
180            serde_json::from_str(&s.runtime).map_err(|e| MachineError::Snapshot(e.to_string()))?;
181        Ok(Machine {
182            address: address.clone(),
183            info,
184            session,
185            dm,
186            rt,
187        })
188    }
189
190    /// The active state ids.
191    pub fn active(&self) -> Vec<String> {
192        self.info.active(&self.session)
193    }
194
195    /// The configuration digest.
196    pub fn digest(&self) -> String {
197        digest(
198            &self.active(),
199            &self.dm.snapshot(),
200            self.session.is_terminated(),
201        )
202    }
203
204    /// Whether `event` would be accepted now: an active state defers it (`vt:defer`), or it
205    /// enables at least one transition of the current configuration. Guards run on a scratch
206    /// copy of the datamodel made from `registry`, so the machine is unchanged.
207    pub fn can(&self, registry: &dyn DatamodelRegistry, event: &Event) -> bool {
208        if self.session.is_terminated() {
209            return false;
210        }
211        if self
212            .info
213            .deferred(&self.session)
214            .iter()
215            .any(|d| vertep_core::kernel::descriptor_matches(d, &event.name))
216        {
217            return true;
218        }
219        let Ok(mut dm) =
220            vertep_core::null::create_datamodel_for(registry, self.info.compiled.chart())
221        else {
222            return false;
223        };
224        let info = SessionInfo {
225            sessionid: self.address.as_str().to_owned(),
226            name: self.info.ir.name.clone(),
227            ioprocessors: Value::Undefined,
228        };
229        if dm.init_session(&info).is_err()
230            || dm.restore(&self.dm.snapshot()).is_err()
231            || dm.check_event(event).is_err()
232        {
233            return false;
234        }
235        dm.set_event(event);
236        let chart = &self.info.compiled;
237        let q = ActiveIn {
238            chart,
239            session: &self.session,
240        };
241        !vertep_core::kernel::select_transitions(
242            chart,
243            &self.session.configuration,
244            &self.session.history,
245            Some(&event.name),
246            |t| {
247                chart
248                    .transition(t)
249                    .cond
250                    .as_deref()
251                    .is_none_or(|c| dm.eval_cond(c, &q).unwrap_or(false))
252            },
253        )
254        .is_empty()
255    }
256
257    /// A snapshot with IRI `iri` at `position`.
258    pub fn snapshot(&self, iri: &NamedNode, position: u64) -> SnapshotData {
259        SnapshotData {
260            iri: iri.as_str().to_owned(),
261            actor: self.address.as_str().to_owned(),
262            position,
263            version: self.info.version.as_str().to_owned(),
264            digest: self.digest(),
265            session: serde_json::to_string(&self.session).unwrap_or_default(),
266            datamodel: json::to_text(&self.dm.snapshot()),
267            runtime: serde_json::to_string(&self.rt).unwrap_or_default(),
268        }
269    }
270
271    /// The statements of the actor's current-state graph.
272    // @tg: implements:: [[openspec:runtime-ontology#Named Graph Layout]]
273    pub fn state_triples(&self, chart_type: Option<(&str, &str)>) -> HashSet<Triple> {
274        let a = self.address.node().clone();
275        let mut out = HashSet::new();
276        let mut add = |p: &str, o: oxrdf::Term| {
277            out.insert(Triple::new(a.clone(), node(p), o));
278        };
279        add(vt::RDF_TYPE, node(vt::ACTOR_CLASS).into());
280        add(vt::RDF_TYPE, node(vt::VIRTUAL_ACTOR).into());
281        add(vt::CHART_VERSION, self.info.version.named_node().into());
282        if let Some((t, k)) = chart_type
283            && let Ok(t) = NamedNode::new(t)
284        {
285            add(vt::CHART_TYPE, t.into());
286            add(vt::KEY, Literal::new_simple_literal(k).into());
287        }
288        add(
289            vt::STATUS,
290            node(if self.session.is_terminated() {
291                vt::TERMINATED
292            } else {
293                vt::RUNNING
294            })
295            .into(),
296        );
297        for id in self.active() {
298            add(vt::IN_STATE, state_node(&self.info, &id).into());
299        }
300        add(
301            vt::DATAMODEL_STATE,
302            Literal::new_simple_literal(json::to_text(&self.dm.snapshot())).into(),
303        );
304        for t in self.rt.timers.values() {
305            out.extend(timer_triples(t));
306        }
307        out
308    }
309
310    /// Applies one input: the start macrostep (for entry 1) and the macrostep of `input`,
311    /// returning everything the entry at `position` records.
312    pub fn apply(
313        &mut self,
314        input: &InputRecord,
315        position: u64,
316        ts: i64,
317        policy: UnhandledPolicy,
318        start_effects: Vec<Effect>,
319        start_trace: Option<&MacrostepTrace>,
320    ) -> Result<Applied, MachineError> {
321        let entry = self.address.entry_iri(position);
322        let env = Environment {
323            now_ms: ts,
324            seed: address::seed_of(&entry),
325        };
326        let mut out = Applied::default();
327        let ev = &input.event;
328        // runtime bookkeeping of the input
329        if let Some(t) = &input.timer
330            && self.rt.timers.get(t).is_some_and(|r| r.reminder.is_none())
331        {
332            self.rt.timers.remove(t);
333        }
334        if let Some(c) = &input.correlation {
335            if ev.name == vt::REPLY_EVENT
336                && let Some(timeout) = self.rt.requests.remove(c)
337            {
338                self.rt.answered.insert(c.clone());
339                out.effects.answers = Some(c.clone());
340                if let Some(t) = timeout
341                    && self.rt.timers.remove(&t).is_some()
342                {
343                    self.rt.cancelled.insert(t.clone());
344                    out.effects.cancels.push(t);
345                }
346            } else if ev.name == vt::TIMEOUT_EVENT && self.rt.requests.remove(c).is_some() {
347                self.rt.timed_out.insert(c.clone());
348            }
349        }
350        if let Some(id) = ev.name.strip_prefix("done.invoke.") {
351            self.rt.invocations.remove(id);
352        }
353        let mut traces: Vec<MacrostepTrace> = start_trace.into_iter().cloned().collect();
354        let step = |m: &mut Machine, e: Event| -> Result<session::Outcome, MachineError> {
355            session::step(
356                &m.info.compiled,
357                m.session.clone(),
358                Input::event(e, env),
359                m.dm.as_mut(),
360            )
361            .map_err(|e| match e {
362                StepError::Terminated => MachineError::Terminated,
363                other => MachineError::Step(format!("{other:?}")),
364            })
365        };
366        // A payload that fails its declared type is not delivered: the chart gets
367        // `error.execution` instead ([[openspec:type-system#Opt-In Chart Typing]]).
368        let admitted = vertep_core::datamodel::admit(self.dm.as_ref(), ev.clone());
369        let o = step(self, admitted)?;
370        let unhandled = o
371            .trace
372            .microsteps
373            .iter()
374            .find(|m| m.event.as_deref() == Some(ev.name.as_str()))
375            .is_some_and(|m| m.transitions.is_empty());
376        self.session = o.session;
377        let mut raw = start_effects;
378        raw.extend(o.effects);
379        traces.push(o.trace);
380        if unhandled {
381            out.effects.unhandled = Some(policy);
382            if policy == UnhandledPolicy::Error && !self.session.is_terminated() {
383                let data = Value::Object(vec![
384                    ("name".into(), Value::String(ev.name.clone())),
385                    ("data".into(), ev.data.clone()),
386                ]);
387                let e = Event {
388                    name: vt::UNHANDLED_EVENT.into(),
389                    type_: EventType::Platform,
390                    data,
391                    ..Event::default()
392                };
393                let o2 = step(self, e)?;
394                self.session = o2.session;
395                raw.extend(o2.effects);
396                traces.push(o2.trace);
397            }
398        }
399        for t in &traces {
400            for m in &t.microsteps {
401                for tr in &m.transitions {
402                    let frag = format!(
403                        "{}:t{}",
404                        vertep_rdf::project::state_fragment(&tr.source),
405                        tr.index
406                    );
407                    let iri = vertep_rdf::iri::node_iri(self.info.version.as_str(), &frag);
408                    if !out.effects.transitions.contains(&iri) {
409                        out.effects.transitions.push(iri);
410                    }
411                }
412            }
413        }
414        out.effects.transitions.sort();
415        self.collect(raw, &entry, position, ts, input, &mut out);
416        out.effects.terminated = self.session.is_terminated();
417        if out.effects.terminated {
418            let reminders: Vec<String> = self
419                .rt
420                .timers
421                .iter()
422                .filter(|(_, t)| t.reminder.is_some())
423                .map(|(k, _)| k.clone())
424                .collect();
425            for r in reminders {
426                self.rt.timers.remove(&r);
427                self.rt.cancelled.insert(r.clone());
428                out.effects.cancels.push(r);
429            }
430        }
431        Ok(out)
432    }
433
434    fn recipient(&self, target: Option<&str>) -> Option<String> {
435        match target {
436            None | Some("#_internal") => Some(self.address.as_str().to_owned()),
437            Some("#_parent") => None,
438            Some(t) => {
439                if let Some(sid) = t.strip_prefix("#_scxml_") {
440                    Address::parse(sid).ok().map(|a| a.as_str().to_owned())
441                } else if let Some(id) = t.strip_prefix("#_") {
442                    Some(Address::invoked(&self.address, id).as_str().to_owned())
443                } else {
444                    Address::parse(t).ok().map(|a| a.as_str().to_owned())
445                }
446            }
447        }
448    }
449
450    fn collect(
451        &mut self,
452        raw: Vec<Effect>,
453        entry: &NamedNode,
454        position: u64,
455        ts: i64,
456        input: &InputRecord,
457        out: &mut Applied,
458    ) {
459        let mut requests = 0usize;
460        for e in raw {
461            match e {
462                Effect::Send(req) => {
463                    let (base, params) = split_type(&req.type_);
464                    if base == vt::REMINDER_SEND_TYPE {
465                        let name = params.get("name").cloned().unwrap_or_default();
466                        let existing: Vec<String> = self
467                            .rt
468                            .timers
469                            .iter()
470                            .filter(|(_, t)| t.reminder.as_ref().is_some_and(|r| r.name == name))
471                            .map(|(k, _)| k.clone())
472                            .collect();
473                        for k in existing {
474                            self.rt.timers.remove(&k);
475                            self.rt.cancelled.insert(k.clone());
476                            out.effects.cancels.push(k);
477                        }
478                        if params.contains_key("cancel") {
479                            continue;
480                        }
481                        let period: i64 = params
482                            .get("period")
483                            .and_then(|p| p.parse().ok())
484                            .unwrap_or(1)
485                            .max(1);
486                        let t = TimerRecord {
487                            iri: address::timer_iri(entry, &format!("reminder:{name}"))
488                                .as_str()
489                                .to_owned(),
490                            actor: self.address.as_str().to_owned(),
491                            send_id: name.clone(),
492                            due_ms: ts.saturating_add(period),
493                            event: self.origin_event(req.event),
494                            recipient: self.address.as_str().to_owned(),
495                            reminder: Some(ReminderSpec {
496                                name,
497                                period_ms: period,
498                            }),
499                            correlation: None,
500                        };
501                        self.rt.timers.insert(t.iri.clone(), t.clone());
502                        out.effects.timers.push(t);
503                        continue;
504                    }
505                    if base == vt::MIGRATE_SEND_TYPE {
506                        out.migrations.push((
507                            params.get("to").cloned().unwrap_or_default(),
508                            params.get("migration").cloned().unwrap_or_default(),
509                        ));
510                        continue;
511                    }
512                    let recipient = self.recipient(req.target.as_deref());
513                    if let Some(id) = req.target.as_deref().and_then(|t| t.strip_prefix("#_"))
514                        && let Some(log) = self.rt.invocations.get_mut(id)
515                    {
516                        log.events.push((req.event.clone(), ts));
517                    }
518                    let correlation = if base == vt::REQUEST_SEND_TYPE {
519                        let c = self.address.correlation_id(position, requests);
520                        requests += 1;
521                        out.effects.requests.push(c.clone());
522                        let timeout = params.get("timeout").and_then(|t| t.parse::<i64>().ok());
523                        let timer = timeout.map(|t| {
524                            let rec = TimerRecord {
525                                iri: address::timer_iri(entry, &format!("timeout:{c}"))
526                                    .as_str()
527                                    .to_owned(),
528                                actor: self.address.as_str().to_owned(),
529                                send_id: format!("timeout:{c}"),
530                                due_ms: ts.saturating_add(t),
531                                event: self.origin_event(Event {
532                                    name: vt::TIMEOUT_EVENT.into(),
533                                    type_: EventType::Platform,
534                                    data: Value::Object(vec![(
535                                        "correlation".into(),
536                                        Value::String(c.clone()),
537                                    )]),
538                                    ..Event::default()
539                                }),
540                                recipient: self.address.as_str().to_owned(),
541                                reminder: None,
542                                correlation: Some(c.clone()),
543                            };
544                            self.rt.timers.insert(rec.iri.clone(), rec.clone());
545                            out.effects.timers.push(rec.clone());
546                            rec.iri
547                        });
548                        self.rt.requests.insert(c.clone(), timer);
549                        Some(c)
550                    } else if req.event.name == vt::REPLY_EVENT {
551                        input.correlation.clone()
552                    } else {
553                        None
554                    };
555                    let event = self.origin_event(req.event);
556                    if req.delay_ms > 0 {
557                        let t = TimerRecord {
558                            iri: address::timer_iri(entry, &req.sendid).as_str().to_owned(),
559                            actor: self.address.as_str().to_owned(),
560                            send_id: req.sendid.clone(),
561                            due_ms: ts
562                                .saturating_add(i64::try_from(req.delay_ms).unwrap_or(i64::MAX)),
563                            event,
564                            recipient: recipient.unwrap_or_else(|| req.target.unwrap_or_default()),
565                            reminder: None,
566                            correlation,
567                        };
568                        self.rt.timers.insert(t.iri.clone(), t.clone());
569                        out.effects.timers.push(t);
570                    } else {
571                        let k = out.effects.sends.len();
572                        out.effects.sends.push(SendRecord {
573                            iri: address::send_iri(entry, k).as_str().to_owned(),
574                            send_id: req.sendid,
575                            event,
576                            target: req.target,
577                            recipient,
578                            type_: req.type_,
579                            correlation,
580                        });
581                    }
582                }
583                Effect::Cancel { sendid } => {
584                    let found: Vec<String> = self
585                        .rt
586                        .timers
587                        .iter()
588                        .filter(|(_, t)| t.send_id == sendid && t.reminder.is_none())
589                        .map(|(k, _)| k.clone())
590                        .collect();
591                    for k in found {
592                        self.rt.timers.remove(&k);
593                        self.rt.cancelled.insert(k.clone());
594                        out.effects.cancels.push(k);
595                    }
596                }
597                Effect::Invoke(req) => {
598                    out.effects
599                        .invokes
600                        .push(serde_json::to_string(&req).unwrap_or_default());
601                    self.rt.invocations.insert(
602                        req.invokeid.clone(),
603                        InvocationLog {
604                            request: req.clone(),
605                            events: Vec::new(),
606                        },
607                    );
608                    out.invokes.push(req);
609                }
610                Effect::CancelInvoke { invokeid } => {
611                    self.rt.invocations.remove(&invokeid);
612                    out.cancel_invokes.push(invokeid);
613                }
614                Effect::Forward { invokeid, event } => {
615                    let k = out.effects.sends.len();
616                    if let Some(log) = self.rt.invocations.get_mut(&invokeid) {
617                        log.events.push((event.clone(), ts));
618                    }
619                    out.effects.sends.push(SendRecord {
620                        iri: address::send_iri(entry, k).as_str().to_owned(),
621                        send_id: format!("forward:{k}"),
622                        event: self.origin_event(event),
623                        target: Some(format!("#_{invokeid}")),
624                        recipient: Some(
625                            Address::invoked(&self.address, &invokeid)
626                                .as_str()
627                                .to_owned(),
628                        ),
629                        type_: SCXML_EVENT_PROCESSOR.to_owned(),
630                        correlation: None,
631                    });
632                }
633                Effect::Log { .. } | Effect::SessionDone { .. } => {}
634            }
635        }
636    }
637
638    // @tg: implements:: [[openspec:messaging#Event Origin]]
639    fn origin_event(&self, mut e: Event) -> Event {
640        e.origin = Some(self.address.as_str().to_owned());
641        e.origintype = Some(SCXML_EVENT_PROCESSOR.to_owned());
642        // A runtime-assigned origin inside the cluster is not a verified sender DID.
643        e.originverified = false;
644        e
645    }
646}
647
648/// `In()` against a session's configuration, for guards evaluated by [`Machine::can`].
649struct ActiveIn<'a> {
650    chart: &'a vertep_core::compile::CompiledChart,
651    session: &'a Session,
652}
653
654impl vertep_core::datamodel::StateQuery for ActiveIn<'_> {
655    fn in_state(&self, id: &str) -> bool {
656        self.session.is_active(self.chart, id)
657    }
658
659    fn active_states(&self) -> Option<Vec<String>> {
660        Some(self.session.active(self.chart))
661    }
662}
663
664/// The node IRI of state `id` in chart version `info`.
665pub fn state_node(info: &ChartInfo, id: &str) -> NamedNode {
666    NamedNode::new_unchecked(vertep_rdf::iri::node_iri(
667        info.version.as_str(),
668        &vertep_rdf::project::state_fragment(id),
669    ))
670}