licoup_workflow_store/admission.rs
1//! Host-wide unfinished-work read for the durable Adaptive Flywheel store.
2//!
3//! The strategy database is the local authority for queued, paused, held and
4//! unfinished workflow work. [`StrategyStore::unfinished_local_work`] answers
5//! that question in one bounded read so a maintenance owner can refuse to
6//! replace installed state while this host still owns workflow work.
7//!
8//! # What blocks, and what does not
9//!
10//! Every durable category of locally owned work participates, so a maintenance
11//! decision cannot mistake a *request* for an observed exit:
12//!
13//! * An unfinished run (`strategy_runs.terminal=0`) blocks: its commands and
14//! transitions still have to settle. A run waiting on approval or a callback
15//! is not terminal, so it blocks too.
16//! * A `pending` or `claimed` queue item blocks: `pending` is work this host
17//! queued, `claimed` is work a local claimant holds. A claimant that
18//! disconnected without settling leaves exactly this row.
19//! * An in-flight or unresolved effect blocks. `claimed`, `running` and
20//! `cancel_requested` are work that has left, or is leaving, this host;
21//! `in_doubt` is work whose outcome could not be established. A cancellation
22//! request therefore does not prove idleness, a lost local carrier does not
23//! prove idleness, and an unknown effect position stays owned here until its
24//! owner resolves it. This is also how a run whose cancellation outcome is
25//! unknown is reported, since the run itself reaches a terminal status.
26//! * An active pause request blocks: the graph is negotiating a pause and the
27//! paused work is still owned here.
28//! * An active graph barrier blocks: the graph is deliberately held, and
29//! replacing the state it runs against would strand it.
30//! * An unsettled invocation blocks. Today every writer records settlement in
31//! the same write that admits the invocation, so this arm covers records a
32//! recovery path has yet to settle rather than a normal transient.
33//!
34//! `workflow_transition_intents`, `workflow_control_admissions` and
35//! `workflow_subscriptions` are delivery and idempotency records, not locally
36//! owned tasks, so they never block on their own.
37
38use anyhow::{Result, anyhow, ensure};
39use rusqlite::Connection;
40use serde::Serialize;
41use std::path::{Path, PathBuf};
42
43use super::store::{StrategyStore, preflight_existing_store};
44
45/// Which durable workflow record still owns work.
46#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
47#[serde(rename_all = "kebab-case")]
48pub enum WorkflowWorkKind {
49 /// A strategy run that has not reached a terminal snapshot.
50 Run,
51 /// A queued or claimed delivery item.
52 QueueItem,
53 /// An effect that has left, is leaving, or whose outcome is unknown.
54 Effect,
55 /// An invocation this host has not settled.
56 Invocation,
57 /// A graph negotiating a pause.
58 PauseRequest,
59 /// A deliberately held graph.
60 GraphBarrier,
61}
62
63impl WorkflowWorkKind {
64 /// The stable wire name, also used as the SQL discriminator.
65 pub const fn as_str(self) -> &'static str {
66 match self {
67 Self::Run => "workflow-run",
68 Self::QueueItem => "workflow-queue-item",
69 Self::Effect => "workflow-effect",
70 Self::Invocation => "workflow-invocation",
71 Self::PauseRequest => "workflow-pause-request",
72 Self::GraphBarrier => "workflow-graph-barrier",
73 }
74 }
75}
76
77/// One durable workflow record that makes this host's work unfinished.
78#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
79#[serde(rename_all = "camelCase")]
80pub struct WorkflowWorkBlocker {
81 pub kind: WorkflowWorkKind,
82 /// The graph, run or command the record belongs to. Empty for a queue item,
83 /// which is not bound to one graph; the run id for a run or effect.
84 pub graph_id: String,
85 /// Stable identity inside its kind: run id, queue item id, a command id, a
86 /// stop/pause target, `node#invocation` for an invocation, or `graph` for a
87 /// held graph.
88 pub identity: String,
89 /// The stored state that makes the record a blocker.
90 pub state: String,
91}
92
93/// The bounded answer of one durable-workflow unfinished-work read.
94#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct UnfinishedWorkflowWork {
97 pub blockers: Vec<WorkflowWorkBlocker>,
98 /// True when more blockers exist than
99 /// [`MAX_UNFINISHED_WORKFLOW_WORK`] reports.
100 pub truncated: bool,
101}
102
103impl UnfinishedWorkflowWork {
104 /// The empty answer for a host that has no strategy database yet.
105 pub fn empty() -> Self {
106 Self {
107 blockers: Vec::new(),
108 truncated: false,
109 }
110 }
111
112 /// No unfinished workflow work is recorded.
113 pub fn is_idle(&self) -> bool {
114 self.blockers.is_empty()
115 }
116
117 /// The reported blockers, in the read's deterministic order.
118 pub fn blockers(&self) -> &[WorkflowWorkBlocker] {
119 &self.blockers
120 }
121}
122
123/// Upper bound on the blockers one read returns.
124pub const MAX_UNFINISHED_WORKFLOW_WORK: usize = 128;
125
126impl StrategyStore {
127 /// The strategy database path for a data root, without opening or
128 /// creating the store.
129 pub fn database_path(portable_root: &Path) -> PathBuf {
130 portable_root
131 .join("client-state")
132 .join("adaptive-flywheel")
133 .join("strategies.sqlite3")
134 }
135}
136
137/// Every unfinished locally owned workflow task, read straight from the
138/// strategy database without opening, initializing, migrating or mutating it.
139///
140/// A maintenance decision must never change the work it decides about, so it
141/// uses this entry rather than [`StrategyStore::open`], which initializes the
142/// schema and retires superseded package trees. A data root without a database
143/// has no record, so it has no unfinished workflow work either; a database that
144/// is not the current shape or is missing a table this read needs is an error,
145/// because this reader cannot migrate it.
146pub fn read_unfinished_local_work(portable_root: &Path) -> Result<UnfinishedWorkflowWork> {
147 let path = StrategyStore::database_path(portable_root);
148 if !path.is_file() {
149 return Ok(UnfinishedWorkflowWork::empty());
150 }
151 preflight_existing_store(&path)?;
152 let connection = Connection::open_with_flags(
153 &path,
154 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
155 )?;
156 for table in [
157 "strategy_runs",
158 "strategy_commands",
159 "workflow_queue",
160 "workflow_invocations",
161 "workflow_pause_requests",
162 "workflow_graph_state",
163 ] {
164 let exists: bool = connection.query_row(
165 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
166 [table],
167 |row| row.get(0),
168 )?;
169 ensure!(exists, "workflow_schema_migration_required");
170 }
171 unfinished_local_work(&connection)
172}
173
174fn unfinished_local_work(connection: &Connection) -> Result<UnfinishedWorkflowWork> {
175 let sql = format!(
176 "SELECT kind, graph_id, identity, state FROM (
177 SELECT '{run}' AS kind, run_id AS graph_id, run_id AS identity,
178 'unfinished' AS state
179 FROM strategy_runs
180 WHERE COALESCE(terminal, 0)=0
181 UNION ALL
182 SELECT '{queue}', '', item_id, status
183 FROM workflow_queue
184 WHERE status IN ('pending','claimed')
185 UNION ALL
186 SELECT '{effect}', run_id, command_id, status
187 FROM strategy_commands
188 WHERE status IN ('claimed','running','cancel-requested','in-doubt')
189 UNION ALL
190 SELECT '{invocation}', graph_id, node_id||'#'||invocation_id, 'unsettled'
191 FROM workflow_invocations
192 WHERE settled=0
193 UNION ALL
194 SELECT '{pause}', graph_id, target, 'pause-requested'
195 FROM workflow_pause_requests
196 WHERE active=1
197 UNION ALL
198 SELECT '{barrier}', graph_id, 'graph', 'barrier-active'
199 FROM workflow_graph_state
200 WHERE barrier_active=1
201 )
202 ORDER BY kind, graph_id, identity
203 LIMIT {limit}",
204 run = WorkflowWorkKind::Run.as_str(),
205 queue = WorkflowWorkKind::QueueItem.as_str(),
206 effect = WorkflowWorkKind::Effect.as_str(),
207 invocation = WorkflowWorkKind::Invocation.as_str(),
208 pause = WorkflowWorkKind::PauseRequest.as_str(),
209 barrier = WorkflowWorkKind::GraphBarrier.as_str(),
210 limit = MAX_UNFINISHED_WORKFLOW_WORK + 1,
211 );
212 let mut statement = connection.prepare(&sql)?;
213 let rows = statement
214 .query_map([], |row| {
215 Ok((
216 row.get::<_, String>(0)?,
217 row.get::<_, String>(1)?,
218 row.get::<_, String>(2)?,
219 row.get::<_, String>(3)?,
220 ))
221 })?
222 .collect::<rusqlite::Result<Vec<_>>>()?;
223 let truncated = rows.len() > MAX_UNFINISHED_WORKFLOW_WORK;
224 let mut blockers = Vec::with_capacity(rows.len().min(MAX_UNFINISHED_WORKFLOW_WORK));
225 for (kind, graph_id, identity, state) in rows.into_iter().take(MAX_UNFINISHED_WORKFLOW_WORK) {
226 let kind = match kind.as_str() {
227 "workflow-run" => WorkflowWorkKind::Run,
228 "workflow-queue-item" => WorkflowWorkKind::QueueItem,
229 "workflow-effect" => WorkflowWorkKind::Effect,
230 "workflow-invocation" => WorkflowWorkKind::Invocation,
231 "workflow-pause-request" => WorkflowWorkKind::PauseRequest,
232 "workflow-graph-barrier" => WorkflowWorkKind::GraphBarrier,
233 other => return Err(anyhow!("workflow_work_kind_unknown: {other}")),
234 };
235 blockers.push(WorkflowWorkBlocker {
236 kind,
237 graph_id,
238 identity,
239 state,
240 });
241 }
242 Ok(UnfinishedWorkflowWork {
243 blockers,
244 truncated,
245 })
246}