Skip to main content

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}