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}