Skip to main content

aion_store/visibility/
record.rs

1//! The visibility row, the store contract, and the page it answers with.
2
3use std::collections::HashMap;
4
5use aion_core::{
6    OutstandingLease, PackageVersion, RunId, SearchAttributeValue, WorkflowId, WorkflowListRequest,
7    WorkflowStatus, WorkflowSummary,
8};
9use async_trait::async_trait;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::StoreError;
14
15/// The visibility store: rows in, pages out.
16///
17/// ONE ROW PER WORKFLOW IDENTITY. A workflow that continues as new keeps one
18/// history and one row: the successor generation's upsert REPLACES the
19/// predecessor's row in place, so a workloop is a single row for its whole
20/// life and a list never shows generations. `run_id` stays on the row as
21/// data — the current generation — and the generation chain remains readable
22/// from history under describe.
23///
24/// `record_visibility` is the ONLY write; the engine's Recorder calls it after
25/// every durable append it projects (lifecycle events re-project the whole
26/// row, every other append advances `updated_at`). `list_workflows` answers
27/// the list contract from rows alone — a backend never reads history here.
28#[async_trait]
29pub trait VisibilityStore: Send + Sync + 'static {
30    /// Upserts THE row for `record.workflow_id`, replacing any previous
31    /// generation's row and moving it under its new sort keys atomically.
32    /// The replace is part of the write, not a separate pass: after this
33    /// returns, the workflow has exactly one row and it is this one.
34    ///
35    /// # Errors
36    ///
37    /// Returns backend or serialization errors encountered while writing.
38    async fn record_visibility(&self, record: VisibilityRecord) -> Result<(), StoreError>;
39
40    /// Reads the workflow's row, or `None` when it has no row yet.
41    ///
42    /// # Errors
43    ///
44    /// Returns backend or serialization errors encountered while reading.
45    async fn get_visibility(
46        &self,
47        workflow_id: &WorkflowId,
48    ) -> Result<Option<VisibilityRecord>, StoreError>;
49
50    /// Removes the row for `workflow_id` IF it belongs to `run_id`, and any
51    /// row a store written before the one-row collapse still holds for that
52    /// `(workflow_id, run_id)` pair. Returns whether anything was removed.
53    ///
54    /// The reconciler is the caller: it prunes every generation that is not
55    /// the current one, which is how a pre-collapse store converges to one
56    /// row per workflow at its next boot without a migration step. The run
57    /// guard makes the prune safe against racing a newer generation's upsert:
58    /// a remove naming a superseded run never deletes the current row.
59    ///
60    /// # Errors
61    ///
62    /// Returns backend or serialization errors encountered while removing.
63    async fn remove_visibility(
64        &self,
65        workflow_id: &WorkflowId,
66        run_id: &RunId,
67    ) -> Result<bool, StoreError>;
68
69    /// Answers one page of the list contract: `request.filter` applied
70    /// BEFORE the limit, ordered by `request.sort` with `workflow_id` as the
71    /// total-order tie-break, continuing after `request.cursor`, at most
72    /// `request.limit` rows, with the filtered total.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`StoreError::InvalidQuery`] for a zero limit or a cursor that
77    /// was not minted under this exact `(namespace, filter, sort)`, and
78    /// backend or serialization errors encountered while reading.
79    async fn list_workflows(
80        &self,
81        request: &WorkflowListRequest,
82    ) -> Result<VisibilityPage, StoreError>;
83}
84
85/// One page of rows, before the server turns them into wire summaries.
86#[derive(Clone, Debug, PartialEq)]
87pub struct VisibilityPage {
88    /// The rows, in the requested order.
89    pub items: Vec<VisibilityRecord>,
90    /// The cursor naming the page's last row, present only when more rows
91    /// follow it.
92    pub next_cursor: Option<String>,
93    /// How many rows match the filter in total, cursor disregarded.
94    pub count: u64,
95}
96
97/// The complete row for one workflow execution.
98///
99/// Every field a list can sort or filter on is a first-class field here,
100/// projected by the engine when the row is written; `search_attributes`
101/// remains the full recorded attribute map for readers that need more.
102#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
103pub struct VisibilityRecord {
104    /// The namespace the run was placed in, from the `aion.namespace` start
105    /// attribute (`aion_core::DEFAULT_NAMESPACE` for a run recorded with no
106    /// placement).
107    pub namespace: String,
108    /// Logical workflow identifier.
109    pub workflow_id: WorkflowId,
110    /// Concrete run identifier for this workflow execution.
111    pub run_id: RunId,
112    /// Workflow type recorded when the execution started.
113    pub workflow_type: String,
114    /// Current projected status.
115    pub status: WorkflowStatus,
116    /// Recorded instant of the run's `WorkflowStarted`.
117    pub started_at: DateTime<Utc>,
118    /// Recorded instant of the run's latest event of any kind.
119    pub updated_at: DateTime<Utc>,
120    /// Recorded instant of the current lease's terminal event, if terminal.
121    pub ended_at: Option<DateTime<Utc>>,
122    /// The parent workflow, for a child execution.
123    pub parent: Option<WorkflowId>,
124    /// The operator-facing display name, from the `aion.display_name`
125    /// attribute (last write wins).
126    pub display_name: Option<String>,
127    /// The document kind from the `aion.kind` attribute (`"workloop"`), or
128    /// `None` for an ordinary workflow.
129    pub kind: Option<String>,
130    /// The step (activity type) that failed, only for a terminal-Failed run.
131    pub failed_step: Option<String>,
132    /// The terminal `WorkflowFailed` message, only for a terminal-Failed run.
133    pub failure_reason: Option<String>,
134    /// The full recorded search-attribute map.
135    pub search_attributes: HashMap<String, SearchAttributeValue>,
136    /// Every attempt a worker currently holds, earliest lease first — the
137    /// same fold as [`aion_core::outstanding_leases`] over the run's current
138    /// lease segment, maintained event by event by the engine's Recorder
139    /// through [`aion_core::apply_lease_transition`] (WA-010 R4). The
140    /// summary's `current_worker` is the last entry.
141    ///
142    /// Defaults to empty so a row written before the field existed decodes
143    /// as UNATTRIBUTED rather than failing to decode.
144    #[serde(default)]
145    pub outstanding_leases: Vec<OutstandingLease>,
146    /// The content hash of the package this run started under, from the run's
147    /// own `WorkflowStarted` (see [`WorkflowSummary::package_version`]).
148    ///
149    /// Defaults to `None` so a row written before the field existed decodes
150    /// as "not carried" rather than failing to decode — and is NEVER filled
151    /// in by looking up the type's current version.
152    #[serde(default)]
153    pub package_version: Option<PackageVersion>,
154    /// The `seq` of the last history event this row has folded — the row's
155    /// position on its workflow's event stream.
156    ///
157    /// A row is written AFTER its durable append and never ahead of it, so a
158    /// `head_seq` equal to the stream's current head means no event has landed
159    /// since the fold and the row's `status` is authoritative: a boot that
160    /// lists the in-flight workflows trusts such a row and never opens the
161    /// history behind it ([`crate::visibility::head::verdict`]). A row behind
162    /// the head (a crash between the append and the row write, or a write the
163    /// Recorder could only warn about) is re-derived from history, as is one
164    /// that has never been stamped.
165    ///
166    /// Defaults to `0` — no event has `seq` 0 — so a row written before the
167    /// field existed decodes as "unstamped, verify against history" rather
168    /// than failing to decode or being trusted unread.
169    #[serde(default)]
170    pub head_seq: u64,
171}
172
173impl VisibilityRecord {
174    /// The wire summary this row projects to.
175    #[must_use]
176    pub fn summary(&self) -> WorkflowSummary {
177        WorkflowSummary {
178            workflow_id: self.workflow_id.clone(),
179            run_id: self.run_id.clone(),
180            workflow_type: self.workflow_type.clone(),
181            status: self.status,
182            started_at: self.started_at,
183            updated_at: self.updated_at,
184            ended_at: self.ended_at,
185            parent: self.parent.clone(),
186            failed_step: self.failed_step.clone(),
187            failure_reason: self.failure_reason.clone(),
188            display_name: self.display_name.clone(),
189            kind: self.kind.clone(),
190            current_worker: aion_core::current_worker(&self.outstanding_leases),
191            package_version: self.package_version.clone(),
192        }
193    }
194}
195
196impl From<VisibilityRecord> for WorkflowSummary {
197    fn from(record: VisibilityRecord) -> Self {
198        Self {
199            workflow_id: record.workflow_id,
200            run_id: record.run_id,
201            workflow_type: record.workflow_type,
202            status: record.status,
203            started_at: record.started_at,
204            updated_at: record.updated_at,
205            ended_at: record.ended_at,
206            parent: record.parent,
207            failed_step: record.failed_step,
208            failure_reason: record.failure_reason,
209            display_name: record.display_name,
210            kind: record.kind,
211            current_worker: aion_core::current_worker(&record.outstanding_leases),
212            package_version: record.package_version,
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use std::sync::Arc;
220
221    use aion_core::{PackageVersion, WorkflowSummary};
222
223    use super::{VisibilityRecord, VisibilityStore};
224
225    #[test]
226    fn visibility_store_is_object_safe() {
227        let _: Option<Arc<dyn VisibilityStore>> = None;
228    }
229
230    /// A row persisted before `package_version` (and `outstanding_leases`)
231    /// existed decodes as "not carried": the summary reports `None`, and
232    /// nothing fills it in from the type's current version. A row that
233    /// carries the hash reports exactly that hash.
234    #[test]
235    fn a_row_written_before_the_package_field_decodes_as_not_carried()
236    -> Result<(), Box<dyn std::error::Error>> {
237        let legacy = serde_json::json!({
238            "namespace": "default",
239            "workflow_id": "00000000-0000-0000-0000-000000000001",
240            "run_id": "00000000-0000-0000-0000-000000000002",
241            "workflow_type": "checkout",
242            "status": "Running",
243            "started_at": "2023-11-14T22:13:21Z",
244            "updated_at": "2023-11-14T22:13:21Z",
245            "ended_at": null,
246            "parent": null,
247            "display_name": null,
248            "kind": null,
249            "failed_step": null,
250            "failure_reason": null,
251            "search_attributes": {}
252        });
253        let record: VisibilityRecord = serde_json::from_value(legacy.clone())?;
254        assert_eq!(record.package_version, None);
255        assert_eq!(record.summary().package_version, None);
256
257        let mut carried = legacy;
258        carried["package_version"] = serde_json::Value::String("c".repeat(64));
259        let record: VisibilityRecord = serde_json::from_value(carried)?;
260        assert_eq!(
261            record.summary().package_version,
262            Some(PackageVersion::new("c".repeat(64)))
263        );
264        assert_eq!(
265            WorkflowSummary::from(record).package_version,
266            Some(PackageVersion::new("c".repeat(64)))
267        );
268        Ok(())
269    }
270}