Skip to main content

aion/lifecycle/
pause.rs

1//! Durable operator pause/resume (#204).
2//!
3//! Pause holds NEW activity dispatch for a live, non-terminal run while keeping
4//! every durable record path alive (timer fires, signal receipts, drained
5//! completions). It is the Option-B "dispatch-hold" ruling of
6//! `docs/PAUSE-RESUME-DESIGN.md` §4: the resident process is NOT torn down; the
7//! hold lives at outbox claim time (see [`crate::lifecycle::pause::PausedRuns`]
8//! threaded into the outbox dispatcher), so a held row sits `Pending` — never
9//! `Claimed` — for the whole paused window, and release is purely resume plus the
10//! existing sweep.
11//!
12//! # Single-writer discipline
13//!
14//! Pause and resume of a RESIDENT run append through the live handle's own
15//! recorder (the cancel precedent, `terminate.rs`), never a side-constructed
16//! `Recorder::resume_at`: a resident run's handle already owns the single-writer
17//! recorder, and a second writer at the same head would desync the live
18//! recorder's sequence tracker and hard-fail its next append (the exact hazard
19//! the surviving refutation identified). Only the crashed-while-paused resume
20//! path — where the run is NOT resident — builds one continuous
21//! `Recorder::resume_at` and hands it to the reopen respawn machinery.
22//!
23//! # Naming
24//!
25//! Named `pause` (not `suspend`/`resume`) to avoid colliding with
26//! `transition.rs`'s non-durable residency flip and the signal-router `resume`
27//! vocabulary, exactly as reopen avoided `resume`.
28
29use std::collections::HashSet;
30use std::sync::{Arc, RwLock};
31
32use aion_core::{
33    Event, RunId, SearchAttributeSchema, WorkflowId, WorkflowStatus, run_segment,
34    status_from_events,
35};
36use aion_store::EventStore;
37use aion_store::visibility::VisibilityStore;
38use chrono::Utc;
39
40use crate::EngineError;
41use crate::durability::Recorder;
42use crate::loader::WorkflowCatalog;
43use crate::registry::{Registry, WorkflowHandle};
44use crate::runtime::RuntimeHandle;
45use crate::supervision::SupervisionTree;
46
47use super::reopen::{self, ReopenWorkflowContext};
48
49/// Shared, in-memory snapshot of the workflow ids whose outbox dispatch is held
50/// because the run is durably `Paused` (#204).
51///
52/// Cloneable handle over a shared set: the engine's pause/resume ops mutate it
53/// and the outbox dispatcher reads a snapshot at claim time to exclude held rows.
54/// It is rebuilt from durable state ([`EventStore::list_paused`]) at startup and
55/// at shard adoption, so a run paused before a `kill -9` keeps its rows held
56/// after restart — the in-memory set is a cache of the durable projection, never
57/// the source of truth.
58#[derive(Clone, Debug, Default)]
59pub struct PausedRuns {
60    inner: Arc<RwLock<HashSet<WorkflowId>>>,
61}
62
63impl PausedRuns {
64    /// Marks `workflow_id` held (paused).
65    pub fn insert(&self, workflow_id: WorkflowId) {
66        if let Ok(mut set) = self.inner.write() {
67            set.insert(workflow_id);
68        }
69    }
70
71    /// Releases `workflow_id` from the hold (resumed or cancelled).
72    pub fn remove(&self, workflow_id: &WorkflowId) {
73        if let Ok(mut set) = self.inner.write() {
74            set.remove(workflow_id);
75        }
76    }
77
78    /// Returns a point-in-time snapshot of the held set for a claim exclusion.
79    #[must_use]
80    pub fn snapshot(&self) -> HashSet<WorkflowId> {
81        self.inner.read().map(|set| set.clone()).unwrap_or_default()
82    }
83
84    /// Replaces the entire held set from the durable projection — the
85    /// startup/adoption rebuild from [`EventStore::list_paused`].
86    pub fn replace_all(&self, workflow_ids: impl IntoIterator<Item = WorkflowId>) {
87        if let Ok(mut set) = self.inner.write() {
88            *set = workflow_ids.into_iter().collect();
89        }
90    }
91
92    /// Merges additional paused ids into the held set (shard adoption widens the
93    /// set without dropping already-held runs from other shards).
94    pub fn extend(&self, workflow_ids: impl IntoIterator<Item = WorkflowId>) {
95        if let Ok(mut set) = self.inner.write() {
96            set.extend(workflow_ids);
97        }
98    }
99}
100
101/// Dependencies required to pause or resume a workflow run.
102pub struct PauseWorkflowContext<'a> {
103    /// Durable event store used to read history and construct recorders.
104    pub store: Arc<dyn EventStore>,
105    /// Visibility store the recorder projects the run into.
106    pub visibility_store: Arc<dyn VisibilityStore>,
107    /// Workflow catalog resolving the pinned package version to respawn.
108    pub catalog: Arc<WorkflowCatalog>,
109    /// Runtime boundary used to respawn a crashed-while-paused run on resume.
110    pub runtime: &'a Arc<RuntimeHandle>,
111    /// Structural supervision tree recording per-type supervisor placement.
112    pub supervision: Arc<SupervisionTree>,
113    /// Active execution registry keyed by workflow/run identifiers.
114    pub registry: &'a Arc<Registry>,
115    /// Schema shared with startup recovery's resident registration.
116    pub search_attribute_schema: Arc<SearchAttributeSchema>,
117    /// The shared dispatch-hold set to update.
118    pub paused_runs: PausedRuns,
119}
120
121impl<'a> PauseWorkflowContext<'a> {
122    fn reopen_context(&self) -> ReopenWorkflowContext<'a> {
123        ReopenWorkflowContext {
124            store: Arc::clone(&self.store),
125            visibility_store: Arc::clone(&self.visibility_store),
126            catalog: Arc::clone(&self.catalog),
127            runtime: self.runtime,
128            supervision: Arc::clone(&self.supervision),
129            registry: self.registry,
130            search_attribute_schema: Arc::clone(&self.search_attribute_schema),
131        }
132    }
133}
134
135fn status_name(status: WorkflowStatus) -> &'static str {
136    match status {
137        WorkflowStatus::Running => "Running",
138        WorkflowStatus::Completed => "Completed",
139        WorkflowStatus::Failed => "Failed",
140        WorkflowStatus::Cancelled => "Cancelled",
141        WorkflowStatus::TimedOut => "TimedOut",
142        WorkflowStatus::ContinuedAsNew => "ContinuedAsNew",
143        WorkflowStatus::Paused => "Paused",
144    }
145}
146
147/// Pauses a live, `Running` run: appends `WorkflowPaused` through the resident
148/// handle's own recorder (single-writer, cancel precedent) and inserts the run
149/// into the shared dispatch-hold set. The resident process is left alive.
150///
151/// # Errors
152///
153/// Returns [`EngineError::WorkflowNotFound`] when no history / no resident handle
154/// exists for `(id, run)`, and [`EngineError::InvalidState`] — naming the actual
155/// status — when the run is not `Running` (e.g. Completed, already Paused). A
156/// rejection appends nothing to history.
157pub async fn pause(
158    context: &PauseWorkflowContext<'_>,
159    id: &WorkflowId,
160    run: &RunId,
161    reason: Option<String>,
162    operator: Option<String>,
163) -> Result<WorkflowHandle, EngineError> {
164    // Validate against HISTORY first so the rejection names the run's true state
165    // (mirroring reopen): only a Running run can be paused.
166    let history = context.store.read_history(id).await?;
167    if history.is_empty() {
168        return Err(crate::engine::api::workflow_not_found(id, run));
169    }
170    let segment = run_segment(&history, run);
171    if segment.is_empty() {
172        return Err(crate::engine::api::workflow_not_found(id, run));
173    }
174    let status = status_from_events(segment);
175    if status != WorkflowStatus::Running {
176        return Err(EngineError::InvalidState {
177            reason: format!(
178                "workflow {id} run {run} is {}, not Running; only a Running run can be paused",
179                status_name(status)
180            ),
181        });
182    }
183
184    // A Running run is resident; append through its live recorder.
185    let handle = context
186        .registry
187        .get(id, run)?
188        .ok_or_else(|| crate::engine::api::workflow_not_found(id, run))?;
189    {
190        let recorder = handle.recorder();
191        let mut recorder = recorder.lock().await;
192        // Re-validate under the recorder lock: the exit monitor / a racing
193        // transition records through this same recorder, so only the lock makes
194        // the check-then-append atomic.
195        let history = context.store.read_history(id).await?;
196        let segment = run_segment(&history, run);
197        let status = status_from_events(segment);
198        if status != WorkflowStatus::Running {
199            return Err(EngineError::InvalidState {
200                reason: format!(
201                    "workflow {id} run {run} is {}, not Running; only a Running run can be paused",
202                    status_name(status)
203                ),
204            });
205        }
206        recorder
207            .record_workflow_paused(Utc::now(), run.clone(), reason, operator)
208            .await?;
209    }
210    context.paused_runs.insert(id.clone());
211    Ok(handle)
212}
213
214/// Resumes a `Paused` run: appends `WorkflowResumed`, releases the dispatch hold,
215/// and — when the run crashed while paused and is no longer resident — respawns
216/// it through the reopen recovery machinery, re-arming unfired timers.
217///
218/// # Errors
219///
220/// Returns [`EngineError::WorkflowNotFound`] when no history exists for
221/// `(id, run)`, and [`EngineError::InvalidState`] — naming the actual status —
222/// when the run is not `Paused`. A rejection appends nothing to history.
223pub async fn resume(
224    context: &PauseWorkflowContext<'_>,
225    id: &WorkflowId,
226    run: &RunId,
227    operator: Option<String>,
228) -> Result<WorkflowHandle, EngineError> {
229    let history = context.store.read_history(id).await?;
230    if history.is_empty() {
231        return Err(crate::engine::api::workflow_not_found(id, run));
232    }
233    let segment = run_segment(&history, run);
234    if segment.is_empty() {
235        return Err(crate::engine::api::workflow_not_found(id, run));
236    }
237    let status = status_from_events(segment);
238    if status != WorkflowStatus::Paused {
239        return Err(EngineError::InvalidState {
240            reason: format!(
241                "workflow {id} run {run} is {}, not Paused; only a Paused run can be resumed",
242                status_name(status)
243            ),
244        });
245    }
246
247    // Resident (paused-but-alive): append through the live recorder and release
248    // the hold — the ordinary sweep then claims the held rows.
249    if let Some(handle) = context.registry.get(id, run)? {
250        {
251            let recorder = handle.recorder();
252            let mut recorder = recorder.lock().await;
253            let history = context.store.read_history(id).await?;
254            let segment = run_segment(&history, run);
255            let status = status_from_events(segment);
256            if status != WorkflowStatus::Paused {
257                return Err(EngineError::InvalidState {
258                    reason: format!(
259                        "workflow {id} run {run} is {}, not Paused; only a Paused run can be resumed",
260                        status_name(status)
261                    ),
262                });
263            }
264            recorder
265                .record_workflow_resumed(Utc::now(), run.clone(), operator)
266                .await?;
267        }
268        context.paused_runs.remove(id);
269        return Ok(handle);
270    }
271
272    // Not resident (crashed while paused): respawn via the reopen machinery,
273    // replicating reopen's reconcile-after-append ordering (re-read the history
274    // so it INCLUDES WorkflowResumed before registering) and re-arming timers.
275    let rearm = reopen::rearmable_timers(segment);
276    let history_head = history.last().map(Event::seq).unwrap_or_default();
277    let mut recorder = Recorder::resume_at(id.clone(), Arc::clone(&context.store), history_head)
278        .with_visibility(run.clone(), Arc::clone(&context.visibility_store));
279    recorder
280        .record_workflow_resumed(Utc::now(), run.clone(), operator)
281        .await?;
282    for timer in rearm.iter().filter(|timer| timer.needs_restart_marker) {
283        recorder
284            .record_timer_started(Utc::now(), timer.timer_id.clone(), timer.fire_at)
285            .await?;
286    }
287    // The run is now durably Running; release the hold before respawn so a
288    // respawn failure still leaves the run recoverable via list_active.
289    context.paused_runs.remove(id);
290
291    let reopen_context = context.reopen_context();
292    let history = context.store.read_history(id).await?;
293    let handle = reopen::respawn_and_register(&reopen_context, id, run, &history, recorder).await?;
294    reopen::rearm_reopened_timers(&reopen_context, id, handle.pid(), &rearm).await?;
295    Ok(handle)
296}