keelrun_core/flow.rs
1//! Tier 2: the durable flow manager (architecture spec §4.3–4.4).
2//!
3//! A *flow* is a function whose intercepted effects are journaled as it runs, so
4//! that a crashed run can be re-executed from the top and each already-completed
5//! effect is *substituted* from the journal instead of re-invoked. This is the
6//! DBOS-style coordinator-free recovery model: no scheduler service exists, a
7//! process just scans for incomplete flows with expired leases and resumes them.
8//!
9//! Two durability boundaries meet here and must not contaminate each other
10//! (spec §4.3): retries *within* a step are the Tier 1 engine's business
11//! (attempts of one step, journaled as that step's `attempt` count);
12//! re-execution *of the flow* is this manager's business. So a
13//! [`FlowHandle::execute_step`] wraps [`Engine::execute`] — the step gets full
14//! Tier 1 resilience — and journals the single post-retry outcome.
15//!
16//! At-least-once honesty: a live step is journaled `running` *before* the effect
17//! runs and its terminal outcome recorded *before* the result is released, so a
18//! crash between those points leaves a `running` step that resume re-executes.
19//!
20//! # The async `execute_step` bridge (front-end binding concern)
21//!
22//! [`FlowHandle::execute_step`] is `async`, and both the synchronous AND
23//! asynchronous intercepted-call paths in the front-end bindings route through
24//! it while a flow is open (an earlier v0.1 restriction refused an async
25//! intercepted call with KEEL-E005 rather than let it silently downgrade to
26//! Tier 1 on the bare [`Engine`]; that refusal is retired now that the bridge is
27//! real — see `contracts/error-codes.json`'s retired async-in-flow case and
28//! `docs/ccr/0002-async-steps-and-idempotency-injection.md`).
29//!
30//! `&mut self` is what makes a single call to `execute_step` exclusive, but a
31//! flow handle is reachable from *multiple* concurrent async tasks in a
32//! language runtime (Python's `asyncio.gather`, Node's `Promise.all`) — so each
33//! binding wraps its open handle in an **async** mutex (never a blocking one)
34//! that a call acquires with the moral equivalent of `.lock().await` before
35//! touching the handle, and holds for the *entire* step: `seq` is claimed the
36//! instant `execute_step_with_idempotency_key` is entered (its very first
37//! statement, before any `.await`), and the guard is not released until the
38//! terminal outcome is recorded. This is what makes concurrent awaited effects
39//! **serialize in await order** — normative for every binding, spelled out in
40//! conformance/README.md's "Async steps inside a flow" section: a step's
41//! position in the journal is fixed by the order its call *reaches* the handle,
42//! never by completion order, so replay reproduces the exact same `(seq,
43//! step_key)` sequence deterministically. See `crates/keel-py/src/lib.rs`'s
44//! `execute_async` (the reference implementation) for the binding-side half of
45//! this contract; `FlowHandle` itself contributes only the `&mut self` exclusion
46//! and the at-entry `seq` claim above — it holds no lock and knows nothing about
47//! any particular language runtime.
48//!
49//! Replay correctness is checked, not assumed (spec §4.4): the `(seq, step_key)`
50//! encountered on replay must match the journal. A `seq` recorded under a
51//! *different* key is nondeterminism ([`ErrorCode::FlowNondeterminism`],
52//! KEEL-E031), handled per the `flows.on_nondeterminism` policy.
53//!
54//! # Re-entering a completed flow is pure replay
55//!
56//! A rerun of a flow that already reached `completed` is a *pure replay*: no
57//! lease is taken, no flow-level attempt is consumed, no heartbeat runs, and no
58//! effect ever fires — every step is substituted from the journal and the
59//! function reconstructs its result from the recorded values. This is what makes
60//! `keel run` on a finished pipeline instant and side-effect-free, and it is why
61//! re-entry must never surface a lease error (a completed flow is not `running`,
62//! so a naive `acquire_lease WHERE status='running'` would wrongly report
63//! KEEL-E030). A replay-only handle reaching a step with no recorded outcome is
64//! itself a divergence (the code grew a step since completion) and is refused
65//! with KEEL-E031 rather than run live.
66//!
67//! # Step-key convention (who names steps)
68//!
69//! Step keys are *front-end supplied*, never minted by this manager, so the key
70//! a live run records and the key a replay observes are produced by the same
71//! code path. Effect steps key on the request as `"(target)#(args_hash)"`
72//! ([`FlowHandle::step_key`]). Virtualized value steps (time/random) take an
73//! explicit caller key following the same `"<callable>#<args_hash>"` shape with
74//! the front end's language prefix and `-` for a niladic read — e.g. the Python
75//! front end and the golden fixtures both use `py:time.time#-`,
76//! `py:time.time_ns#-`, `py:random.random#-`. Keeping the convention in the
77//! caller means the fixtures, the live front end, and replay all agree on one
78//! key per read.
79
80use core::time::Duration;
81use std::sync::{Arc, Mutex};
82use std::time::Instant;
83
84use keel_core_api::policy::NondeterminismResponse;
85use keel_core_api::{
86 ENVELOPE_VERSION, ErrorClass, ErrorCode, KeelError, Outcome, OutcomeError, Request,
87};
88use keel_journal::{
89 Clock, FlowId, FlowStatus, Journal, NewFlow, ProcessId, StepKey, StepKind, StepOutcome,
90 StepStatus,
91};
92use serde_json::{Value, json};
93use tracing::{debug, warn};
94
95/// Where a `branch`-mode fresh attempt writes its steps, high above any real
96/// step count so the abandoned run's records (seqs `1..`) are preserved for
97/// audit (spec §4.4).
98const BRANCH_SEQ_BASE: u64 = 1_000_000;
99
100/// The reserved step slot holding the flow-level resume counter. Real steps are
101/// numbered from 1, so seq 0 never collides with them.
102const ATTEMPT_SEQ: u64 = 0;
103/// The step key of the reserved resume-counter marker.
104const ATTEMPT_KEY: &str = "flow:attempt";
105/// The field carrying an adapter-injected idempotency key inside a `running`
106/// step record's schema-tagged payload (contracts/adapter-pack.md
107/// "Idempotency-key injection" rule 3: the minted key is journaled with the
108/// step, and a resume that re-executes a crashed step injects the SAME key).
109const IDEMPOTENCY_KEY_FIELD: &str = "idempotency_key";
110
111use crate::engine::Engine;
112
113/// How a flow is identified and fenced. Identity is
114/// `(entrypoint, args_hash, explicit_key?)` (spec §4.3); `code_hash` fences
115/// replay across deploys (spec §4.4).
116#[derive(Debug, Clone)]
117pub struct FlowDescriptor {
118 /// The flow's entrypoint, e.g. `"py:pipeline.ingest:main"`.
119 pub entrypoint: String,
120 /// Hash of the flow's arguments; part of its identity.
121 pub args_hash: String,
122 /// An optional explicit identity key, disambiguating two runs that share an
123 /// entrypoint and args (spec §4.3).
124 pub explicit_key: Option<String>,
125 /// Hash of the flow's code, used to fence replay across deploys.
126 pub code_hash: Option<String>,
127}
128
129impl FlowDescriptor {
130 /// The deterministic storage key for this identity. Deterministic (no ULID
131 /// clock/random draw) so a rerun with the same identity keys the same flow
132 /// row — which is what makes resume-on-rerun work through the idempotent
133 /// [`Journal::begin_flow`].
134 #[must_use]
135 pub fn flow_id(&self) -> FlowId {
136 FlowId::new(format!(
137 "{}#{}#{}",
138 self.entrypoint,
139 self.args_hash,
140 self.explicit_key.as_deref().unwrap_or("")
141 ))
142 }
143}
144
145/// Tuning for a [`FlowManager`]: lease lifetime and the flow-level attempt cap
146/// (spec §4.3 leases; the cap turns a poison flow `dead`).
147#[derive(Debug, Clone, Copy)]
148pub struct FlowConfig {
149 /// How long an acquired lease is valid before another process may steal it.
150 pub lease_ttl: Duration,
151 /// Maximum flow-level (re-)execution attempts before the flow is marked
152 /// `dead` and refused (KEEL-E032). Distinct from Tier 1 step attempts.
153 pub max_attempts: u32,
154}
155
156impl Default for FlowConfig {
157 fn default() -> Self {
158 Self {
159 lease_ttl: Duration::from_secs(30),
160 max_attempts: 3,
161 }
162 }
163}
164
165/// The Tier 2 flow manager: opens and resumes durable flows over a [`Journal`],
166/// running each step's effect through the Tier 1 [`Engine`]. One per process,
167/// `&self`-concurrent; hands out a `&mut` [`FlowHandle`] per running flow.
168pub struct FlowManager {
169 engine: Arc<Engine>,
170 journal: Arc<dyn Journal>,
171 clock: Arc<dyn Clock>,
172 holder: ProcessId,
173 config: FlowConfig,
174}
175
176impl core::fmt::Debug for FlowManager {
177 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
178 f.debug_struct("FlowManager")
179 .field("holder", &self.holder)
180 .field("config", &self.config)
181 .finish_non_exhaustive()
182 }
183}
184
185impl FlowManager {
186 /// A manager with the default [`FlowConfig`].
187 #[must_use]
188 pub fn new(
189 engine: Arc<Engine>,
190 journal: Arc<dyn Journal>,
191 clock: Arc<dyn Clock>,
192 holder: ProcessId,
193 ) -> Self {
194 Self::with_config(engine, journal, clock, holder, FlowConfig::default())
195 }
196
197 /// A manager with explicit tuning.
198 #[must_use]
199 pub fn with_config(
200 engine: Arc<Engine>,
201 journal: Arc<dyn Journal>,
202 clock: Arc<dyn Clock>,
203 holder: ProcessId,
204 config: FlowConfig,
205 ) -> Self {
206 Self {
207 engine,
208 journal,
209 clock,
210 holder,
211 config,
212 }
213 }
214
215 /// Enter (begin or resume) the flow with this identity: open the row
216 /// (idempotent), take the lease, and hand back a handle in replay-or-live
217 /// mode. `Err` when the lease is held by a live holder (KEEL-E030) or the
218 /// flow is already `dead` (KEEL-E032).
219 ///
220 /// # Errors
221 /// - `KEEL-E030` if another holder's lease is still valid.
222 /// - `KEEL-E032` if the flow is `dead` (never auto-resumed).
223 /// - `KEEL-E040` if the journal cannot open the flow row.
224 pub fn enter_flow(&self, desc: &FlowDescriptor) -> Result<FlowHandle, KeelError> {
225 self.enter(
226 &desc.flow_id(),
227 &desc.entrypoint,
228 &desc.args_hash,
229 desc.code_hash.as_deref(),
230 )
231 }
232
233 /// Resume a flow the recovery scan surfaced ([`Journal::incomplete_flows`]),
234 /// fencing against the currently-deployed `code_hash`.
235 ///
236 /// # Errors
237 /// As [`enter_flow`](Self::enter_flow).
238 pub fn resume_flow(
239 &self,
240 flow: &keel_journal::FlowDescriptor,
241 current_code_hash: Option<&str>,
242 ) -> Result<FlowHandle, KeelError> {
243 self.enter(
244 &flow.flow_id,
245 &flow.entrypoint,
246 &flow.args_hash,
247 current_code_hash,
248 )
249 }
250
251 /// Flow-level attempt cap (distinct from Tier 1 step attempts): each
252 /// resume of a not-yet-completed flow consumes one attempt from the
253 /// reserved seq-0 counter; exceeding the cap marks the flow dead. Returns
254 /// the new attempt number, or `Err` if the cap was exceeded.
255 fn bump_attempt_or_kill(&self, flow_id: &FlowId, entrypoint: &str) -> Result<u32, KeelError> {
256 let prior = self
257 .journal
258 .step_at(flow_id, ATTEMPT_SEQ)
259 .map_err(|e| internal(format!("attempt lookup failed: {e}")))?
260 .map_or(0, |(_, o)| o.attempt);
261 let attempt = prior.saturating_add(1);
262 // A second-or-later attempt is a *recovery*: the flow did not run to
263 // completion in one process lifetime (crash, lease loss, deliberate
264 // resume). First entries (attempt == 1) are ordinary starts, not
265 // recoveries — only re-entries count toward the §4.5 metric.
266 if attempt >= 2 {
267 crate::metrics::record_flow_resume(entrypoint);
268 }
269 if attempt > self.config.max_attempts {
270 self.journal
271 .complete_flow(flow_id, FlowStatus::Dead)
272 .map_err(|e| internal(format!("mark-dead failed: {e}")))?;
273 return Err(KeelError {
274 code: ErrorCode::FlowDead,
275 message: format!(
276 "flow {flow_id} exceeded its {} attempt cap; marked dead (KEEL-E032)",
277 self.config.max_attempts
278 ),
279 });
280 }
281 Ok(attempt)
282 }
283
284 fn enter(
285 &self,
286 flow_id: &FlowId,
287 entrypoint: &str,
288 args_hash: &str,
289 current_code_hash: Option<&str>,
290 ) -> Result<FlowHandle, KeelError> {
291 self.journal
292 .begin_flow(&NewFlow {
293 flow_id: flow_id.clone(),
294 entrypoint: entrypoint.to_owned(),
295 args_hash: args_hash.to_owned(),
296 code_hash: current_code_hash.map(str::to_owned),
297 })
298 .map_err(|e| internal(format!("begin_flow failed: {e}")))?;
299
300 let existing = self
301 .journal
302 .get_flow(flow_id)
303 .map_err(|e| internal(format!("get_flow failed: {e}")))?;
304 let status = existing.as_ref().map_or(FlowStatus::Running, |f| f.status);
305
306 // A dead flow is never auto-resumed (spec §4.3).
307 if status == FlowStatus::Dead {
308 return Err(KeelError {
309 code: ErrorCode::FlowDead,
310 message: format!("flow {flow_id} is dead; refusing to resume (KEEL-E032)"),
311 });
312 }
313
314 // Re-entering a completed flow is PURE REPLAY (see module docs): hand
315 // back a replay-only handle that substitutes every step, takes no lease,
316 // spawns no heartbeat, and consumes no attempt. A naive lease acquire
317 // here would fail (a completed flow is not `running`) and wrongly report
318 // KEEL-E030; instead the rerun reconstructs its result from the journal.
319 if status == FlowStatus::Completed {
320 return Ok(self.new_handle(
321 flow_id.clone(),
322 status,
323 false,
324 None,
325 LeaseHeartbeatMonitor::new(),
326 true,
327 ));
328 }
329
330 let attempt = self.bump_attempt_or_kill(flow_id, entrypoint)?;
331
332 // A cleanly-failed flow is put back to `running` before re-leasing so a
333 // resume can proceed (and so a re-crash lands it in the recovery scan).
334 if status == FlowStatus::Failed {
335 self.journal
336 .complete_flow(flow_id, FlowStatus::Running)
337 .map_err(|e| internal(format!("reset-to-running failed: {e}")))?;
338 }
339
340 let acquired = self
341 .journal
342 .acquire_lease(flow_id, &self.holder, self.config.lease_ttl)
343 .map_err(|e| internal(format!("acquire_lease failed: {e}")))?;
344 if !acquired {
345 return Err(KeelError {
346 code: ErrorCode::FlowLeaseHeld,
347 message: format!(
348 "flow {flow_id} is leased by another holder; not resuming (KEEL-E030)"
349 ),
350 });
351 }
352
353 let now = self.clock.now_ms();
354 let marker = StepOutcome {
355 kind: StepKind::Marker,
356 attempt,
357 status: StepStatus::Ok,
358 payload: None,
359 error_class: None,
360 started_at: now,
361 ended_at: Some(now),
362 };
363 if let Err(e) =
364 self.journal
365 .record_step(flow_id, ATTEMPT_SEQ, &StepKey::new(ATTEMPT_KEY), &marker)
366 {
367 warn!(flow = %flow_id, error = %e, "attempt-counter record failed");
368 }
369
370 // Replay is fenced when the recorded code differs from what is running
371 // now: a divergence under a changed deploy downgrades fail→warn (§4.4).
372 let code_hash_fenced = match (existing.and_then(|f| f.code_hash), current_code_hash) {
373 (Some(recorded), Some(current)) => recorded != current,
374 _ => false,
375 };
376
377 // Seeded now, right after we confirmed via `acquire_lease` that we hold
378 // it: the monotonic reference point every subsequent `lease_lost` check
379 // measures elapsed real time against (architecture-spec §6).
380 let lease_monitor = LeaseHeartbeatMonitor::new();
381 let heartbeat = spawn_heartbeat(
382 Arc::clone(&self.journal),
383 flow_id.clone(),
384 self.holder.clone(),
385 self.config.lease_ttl,
386 lease_monitor.clone(),
387 );
388
389 Ok(self.new_handle(
390 flow_id.clone(),
391 status,
392 code_hash_fenced,
393 heartbeat,
394 lease_monitor,
395 false,
396 ))
397 }
398
399 /// Assemble a [`FlowHandle`] over this manager's shared engine/journal/clock.
400 /// `replay_only` marks a completed-flow re-entry (pure replay: every step is
401 /// substituted, no effect fires) — the handle is born already `completed` so
402 /// dropping it is quiet and a front-end `exit_flow` is a harmless re-stamp.
403 /// `lease_monitor` is unused by a replay-only handle (it never runs a live
404 /// step, so `lease_lost` is never consulted), but every path constructs one
405 /// so the field is never `Option`.
406 fn new_handle(
407 &self,
408 flow_id: FlowId,
409 entry_status: FlowStatus,
410 code_hash_fenced: bool,
411 heartbeat: Option<HeartbeatHandle>,
412 lease_monitor: LeaseHeartbeatMonitor,
413 replay_only: bool,
414 ) -> FlowHandle {
415 FlowHandle {
416 engine: Arc::clone(&self.engine),
417 journal: Arc::clone(&self.journal),
418 clock: Arc::clone(&self.clock),
419 holder: self.holder.clone(),
420 flow_id,
421 seq: 0,
422 code_hash_fenced,
423 replay_abandoned: false,
424 replay_only,
425 completed: replay_only,
426 entry_status,
427 heartbeat,
428 lease_ttl: self.config.lease_ttl,
429 lease_monitor,
430 }
431 }
432}
433
434/// A single running flow. Owns the step cursor (`seq`), so it is used by one
435/// task via `&mut`; the manager stays `&self`-concurrent for many flows.
436///
437/// Dropping a handle without [`complete`](Self::complete) leaves the flow
438/// `running` with its lease — exactly the crash shape recovery resumes.
439#[expect(
440 clippy::struct_excessive_bools,
441 reason = "each flag is an independent per-handle replay/lease predicate, not a \
442 packed state enum: code_hash_fenced, replay_abandoned, replay_only, completed"
443)]
444pub struct FlowHandle {
445 engine: Arc<Engine>,
446 journal: Arc<dyn Journal>,
447 clock: Arc<dyn Clock>,
448 /// This handle's lease holder id — checked before every live step so a
449 /// handle that has lost its lease fences (KEEL-E030) instead of running the
450 /// effect a second executor may already be running (double-fire defense).
451 holder: ProcessId,
452 flow_id: FlowId,
453 seq: u64,
454 code_hash_fenced: bool,
455 replay_abandoned: bool,
456 /// A completed-flow re-entry (pure replay): every step is substituted from
457 /// the journal and no effect fires; a step with no record is a divergence.
458 replay_only: bool,
459 /// The flow's status when this handle was opened (`completed` for a pure
460 /// replay handle, `running`/`failed` for a live entry) — the front end reads
461 /// it to distinguish "resumed" from "already finished".
462 entry_status: FlowStatus,
463 completed: bool,
464 /// The lease-renewal thread; stopped and joined when the handle drops so a
465 /// completed or crashed flow stops heart-beating.
466 heartbeat: Option<HeartbeatHandle>,
467 /// This handle's lease TTL, for [`FlowHandle::lease_lost`]'s monotonic
468 /// freshness check.
469 lease_ttl: Duration,
470 /// Monotonic view of our own last successful lease renewal, updated by the
471 /// heartbeat thread — see [`LeaseHeartbeatMonitor`].
472 lease_monitor: LeaseHeartbeatMonitor,
473}
474
475impl core::fmt::Debug for FlowHandle {
476 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
477 f.debug_struct("FlowHandle")
478 .field("flow_id", &self.flow_id)
479 .field("seq", &self.seq)
480 .field("entry_status", &self.entry_status)
481 .field("replay_only", &self.replay_only)
482 .field("completed", &self.completed)
483 .finish_non_exhaustive()
484 }
485}
486
487/// What resolving a step against the journal decided.
488enum StepPlan {
489 /// Run the effect live and journal it (fresh progress or a re-executed
490 /// crashed step).
491 Live,
492 /// Substitute this recorded outcome without invoking the effect.
493 Replay(StepOutcome),
494 /// The recorded key at this seq differs from the current one: nondeterminism.
495 Diverged { recorded: StepKey },
496}
497
498/// The context of a `warn`/`branch` divergence recovery, grouped so the
499/// re-execution helper stays within argument bounds.
500struct Divergence<'a> {
501 seq: u64,
502 recorded: &'a StepKey,
503 observed: &'a StepKey,
504 mode: &'static str,
505 preserve: bool,
506}
507
508impl FlowHandle {
509 /// This flow's storage id.
510 #[must_use]
511 pub fn flow_id(&self) -> &FlowId {
512 &self.flow_id
513 }
514
515 /// The flow's status when this handle was opened. `completed` means this is a
516 /// pure-replay handle (a rerun of an already-finished flow); `running` or
517 /// `failed` means a live entry/resume. The front end reads this to tell the
518 /// user "resumed" vs. "already completed".
519 #[must_use]
520 pub fn entry_status(&self) -> FlowStatus {
521 self.entry_status
522 }
523
524 /// Whether this handle is a completed-flow pure replay — every step is
525 /// substituted from the journal and no effect will fire.
526 #[must_use]
527 pub fn is_replay_only(&self) -> bool {
528 self.replay_only
529 }
530
531 fn now(&self) -> i64 {
532 self.clock.now_ms()
533 }
534
535 /// Whether this handle has definitively lost its lease. Two independent
536 /// checks (architecture-spec §6: generous TTLs absorb cross-machine clock
537 /// skew; heartbeats judge freshness on a *monotonic* clock):
538 ///
539 /// - **Cross-process arbitration** (journal wall clock, read-only here): has
540 /// another holder's `acquire_lease` overwritten `lease_holder`? That CAS
541 /// already did the wall-clock expiry comparison against the journal's own
542 /// record when it stole the row, so re-deriving it from *our* wall clock
543 /// here would be redundant — and exactly the redundant read that lets a
544 /// local NTP step spuriously flip the verdict.
545 /// - **Local freshness** (monotonic, never wall clock): has our own
546 /// heartbeat actually renewed within `lease_ttl` of *real elapsed time*?
547 /// [`LeaseHeartbeatMonitor`] tracks this with [`Instant`], which cannot
548 /// jump backwards or forwards under an NTP correction the way comparing
549 /// two `SystemTime` readings can — so a wall-clock step on this process
550 /// can neither spuriously expire a healthy lease nor paper over a
551 /// genuinely starved heartbeat.
552 ///
553 /// A missing flow row or a journal read error is *not* treated as loss
554 /// (resilience-first: at-least-once still holds via the `running` marker),
555 /// so a transient hiccup does not stall a legitimately-held flow.
556 fn lease_lost(&self) -> bool {
557 let still_recorded_holder = match self.journal.get_flow(&self.flow_id) {
558 Ok(Some(flow)) => flow.lease_holder.as_ref() == Some(&self.holder),
559 Ok(None) | Err(_) => true,
560 };
561 !still_recorded_holder || self.lease_monitor.elapsed() >= self.lease_ttl
562 }
563
564 /// Decide how the step at `seq` (with `key`) resolves against the journal.
565 fn plan_step(&self, seq: u64, key: &StepKey) -> StepPlan {
566 if self.replay_abandoned {
567 return StepPlan::Live;
568 }
569 match self.journal.step_at(&self.flow_id, seq) {
570 Ok(None) => StepPlan::Live,
571 Ok(Some((recorded_key, outcome))) => {
572 if recorded_key == *key {
573 // A crashed-mid-step record (`running`) is re-executed live;
574 // any terminal record is substituted.
575 match outcome.status {
576 StepStatus::Running => StepPlan::Live,
577 StepStatus::Ok | StepStatus::Error => StepPlan::Replay(outcome),
578 }
579 } else {
580 StepPlan::Diverged {
581 recorded: recorded_key,
582 }
583 }
584 }
585 Err(e) => {
586 // Resilience first: a read failure degrades to a live attempt
587 // rather than stalling the flow (at-least-once still holds — a
588 // re-record on resume corrects the journal).
589 warn!(flow = %self.flow_id, seq, error = %e, "step_at failed; executing live");
590 StepPlan::Live
591 }
592 }
593 }
594
595 /// The `(target)#(args_hash)` key identifying a step, matching
596 /// `steps.step_key` and the golden fixtures.
597 fn step_key(request: &Request) -> StepKey {
598 StepKey::new(format!(
599 "{}#{}",
600 request.target,
601 request.args_hash.as_deref().unwrap_or("-")
602 ))
603 }
604
605 /// Execute one journaled step: an intercepted effect wrapped in the Tier 1
606 /// engine (retries/timeout/breaker are attempts of this one step). On
607 /// replay the recorded outcome is substituted and `effect` is never called.
608 pub async fn execute_step<F>(&mut self, request: &Request, effect: F) -> Outcome
609 where
610 F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
611 {
612 self.execute_step_with_idempotency_key(request, None, effect)
613 .await
614 }
615
616 /// [`execute_step`](Self::execute_step), carrying the idempotency key the
617 /// adapter minted and injected for this call (contracts/adapter-pack.md
618 /// "Idempotency-key injection", rule 3). The key is journaled in the step's
619 /// `running` record, so a resume that re-executes a crashed step can read it
620 /// back ([`recorded_idempotency_key`](Self::recorded_idempotency_key)) and
621 /// inject the SAME key — making the at-least-once re-execution deduplicable
622 /// on the provider side. The key never feeds the step key (`args_hash` is
623 /// unchanged by injection), so replay matching is unaffected.
624 pub async fn execute_step_with_idempotency_key<F>(
625 &mut self,
626 request: &Request,
627 idempotency_key: Option<&str>,
628 effect: F,
629 ) -> Outcome
630 where
631 F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
632 {
633 self.seq += 1;
634 let seq = self.seq;
635 let key = Self::step_key(request);
636 let plan = self.plan_step(seq, &key);
637 // A completed-flow replay never runs an effect: substitute a recorded
638 // step, and treat a missing/diverging record as nondeterminism (the code
639 // grew or changed a step since the flow completed) rather than firing it.
640 if self.replay_only {
641 return match plan {
642 StepPlan::Replay(outcome) => replay_outcome(&self.flow_id, seq, &outcome),
643 _ => replay_miss_outcome(&self.flow_id, seq, &key),
644 };
645 }
646 match plan {
647 StepPlan::Replay(outcome) => replay_outcome(&self.flow_id, seq, &outcome),
648 StepPlan::Diverged { recorded } => {
649 self.on_divergence(seq, &recorded, &key, request, idempotency_key, effect)
650 .await
651 }
652 StepPlan::Live => {
653 self.run_live(
654 seq,
655 &key,
656 StepKind::Effect,
657 request,
658 idempotency_key,
659 effect,
660 )
661 .await
662 }
663 }
664 }
665
666 /// The idempotency key recorded for the flow's NEXT step, when that record
667 /// is a crashed (`running`) step under the same `step_key` — the
668 /// resume-reuse read of contracts/adapter-pack.md "Idempotency-key
669 /// injection" rule 3. Adapters call this *before* executing an injectable
670 /// step: `Some(key)` means a previous run crashed mid-step after minting
671 /// `key`, and re-executing with the SAME key lets the provider deduplicate
672 /// the at-least-once re-execution. `None` — mint fresh — on a fresh step, a
673 /// terminal record (the step will be substituted, no key is sent), a
674 /// diverging key, an abandoned replay, a pure-replay handle, or a journal
675 /// read failure (resilience first).
676 #[must_use]
677 pub fn recorded_idempotency_key(&self, step_key: &str) -> Option<String> {
678 if self.replay_abandoned || self.replay_only {
679 return None;
680 }
681 let key = StepKey::new(step_key);
682 match self.journal.step_at(&self.flow_id, self.seq + 1) {
683 Ok(Some((recorded, outcome)))
684 if recorded == key && outcome.status == StepStatus::Running =>
685 {
686 outcome
687 .payload
688 .as_deref()
689 .and_then(decode_payload)
690 .and_then(|v| {
691 v.get(IDEMPOTENCY_KEY_FIELD)
692 .and_then(Value::as_str)
693 .map(str::to_owned)
694 })
695 }
696 _ => None,
697 }
698 }
699
700 /// The `flows.on_nondeterminism` response effective for this handle: the
701 /// configured one, except a `code_hash` mismatch downgrades `fail`→`warn`
702 /// (a deploy that changed the code is expected to diverge; §4.4).
703 fn effective_response(&self) -> NondeterminismResponse {
704 let configured = self.engine.nondeterminism_response();
705 if self.code_hash_fenced && configured == NondeterminismResponse::Fail {
706 NondeterminismResponse::Warn
707 } else {
708 configured
709 }
710 }
711
712 /// Apply the nondeterminism policy to a `(seq, step_key)` divergence (§4.4).
713 async fn on_divergence<F>(
714 &mut self,
715 seq: u64,
716 recorded: &StepKey,
717 observed: &StepKey,
718 request: &Request,
719 idempotency_key: Option<&str>,
720 effect: F,
721 ) -> Outcome
722 where
723 F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
724 {
725 let (mode, preserve) = match self.effective_response() {
726 // Halt with a precise diagnostic; the caller fails the flow.
727 NondeterminismResponse::Fail => {
728 return diverged_outcome(&self.flow_id, seq, recorded, observed);
729 }
730 // Continue live from the divergence point, journaling a marker.
731 NondeterminismResponse::Warn => ("warn", false),
732 // Abandon replay for a fresh attempt, preserving the old records.
733 NondeterminismResponse::Branch => ("branch", true),
734 };
735 let div = Divergence {
736 seq,
737 recorded,
738 observed,
739 mode,
740 preserve,
741 };
742 self.branch_and_continue(div, request, idempotency_key, effect)
743 .await
744 }
745
746 /// Journal a branch `marker` and re-execute the divergent step live, then
747 /// stay live for the rest of the flow.
748 async fn branch_and_continue<F>(
749 &mut self,
750 div: Divergence<'_>,
751 request: &Request,
752 idempotency_key: Option<&str>,
753 effect: F,
754 ) -> Outcome
755 where
756 F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
757 {
758 self.journal_branch_marker(&div);
759 let live_seq = self.seq;
760 self.run_live(
761 live_seq,
762 div.observed,
763 StepKind::Effect,
764 request,
765 idempotency_key,
766 effect,
767 )
768 .await
769 }
770
771 /// Journal the branch marker, abandon replay, and advance the cursor to the
772 /// live-continuation seq (left in `self.seq`). With `preserve` (`branch`),
773 /// the marker + continuation go to a high seq lane so the abandoned run's
774 /// records survive for audit; otherwise (`warn`) they continue in place.
775 /// Shared by the effect and value (time/random) re-execution paths.
776 fn journal_branch_marker(&mut self, div: &Divergence<'_>) {
777 warn!(
778 flow = %self.flow_id, seq = div.seq, mode = div.mode,
779 expected = %div.recorded, observed = %div.observed,
780 "flow nondeterminism; abandoning replay"
781 );
782 self.replay_abandoned = true;
783 let marker_seq = if div.preserve {
784 BRANCH_SEQ_BASE + div.seq
785 } else {
786 div.seq
787 };
788 let now = self.now();
789 self.record(
790 marker_seq,
791 &StepKey::new(format!("flow:branch:{}", div.mode)),
792 &StepOutcome {
793 kind: StepKind::Marker,
794 attempt: 0,
795 status: StepStatus::Ok,
796 payload: encode_payload(&json!({
797 "mode": div.mode,
798 "expected": div.recorded.as_str(),
799 "observed": div.observed.as_str(),
800 })),
801 error_class: None,
802 started_at: now,
803 ended_at: Some(now),
804 },
805 );
806 // The divergent step re-executes in the slot after its marker;
807 // subsequent steps continue from there (replay is now abandoned).
808 self.seq = marker_seq + 1;
809 }
810
811 /// Journal (or replay) a virtualized clock read under the front-end-supplied
812 /// `key` (the module-docs convention, e.g. `py:time.time#-`). On replay the
813 /// recorded value is substituted so a resumed flow sees the same time; live,
814 /// `now_ms` is recorded and returned (spec §4.4).
815 ///
816 /// # Errors
817 /// `KEEL-E031` if this step diverges from the journal under `fail`.
818 pub fn journal_time(&mut self, key: &str, now_ms: i64) -> Result<i64, KeelError> {
819 let bytes = encode_payload(&json!(now_ms)).unwrap_or_default();
820 let recorded = self.resolve_value_step(key, StepKind::Time, bytes)?;
821 Ok(decode_payload(&recorded)
822 .and_then(|v| v.as_i64())
823 .unwrap_or(now_ms))
824 }
825
826 /// Journal (or replay) a virtualized random draw under the front-end-supplied
827 /// `key` (e.g. `py:random.random#-`). On replay the recorded bytes are
828 /// substituted; live, `bytes` are recorded and returned.
829 ///
830 /// # Errors
831 /// `KEEL-E031` if this step diverges from the journal under `fail`.
832 pub fn journal_random(&mut self, key: &str, bytes: Vec<u8>) -> Result<Vec<u8>, KeelError> {
833 self.resolve_value_step(key, StepKind::Random, bytes)
834 }
835
836 /// The shared replay/live machinery for a pure value step (time/random):
837 /// no side effect, so its recorded payload bytes are the whole outcome.
838 fn resolve_value_step(
839 &mut self,
840 key_str: &str,
841 kind: StepKind,
842 live_bytes: Vec<u8>,
843 ) -> Result<Vec<u8>, KeelError> {
844 self.seq += 1;
845 let seq = self.seq;
846 let key = StepKey::new(key_str);
847 let plan = self.plan_step(seq, &key);
848 // Pure replay: substitute the recorded value; never re-draw/re-read.
849 if self.replay_only {
850 return match plan {
851 StepPlan::Replay(outcome) => Ok(outcome.payload.unwrap_or_default()),
852 _ => Err(replay_miss_error(&self.flow_id, seq, &key)),
853 };
854 }
855 match plan {
856 StepPlan::Replay(outcome) => Ok(outcome.payload.unwrap_or_default()),
857 StepPlan::Live => {
858 self.record_value(seq, &key, kind, &live_bytes);
859 Ok(live_bytes)
860 }
861 StepPlan::Diverged { recorded } => {
862 let (mode, preserve) = match self.effective_response() {
863 NondeterminismResponse::Fail => {
864 return Err(diverged_error(&self.flow_id, seq, &recorded, &key));
865 }
866 NondeterminismResponse::Warn => ("warn", false),
867 NondeterminismResponse::Branch => ("branch", true),
868 };
869 let div = Divergence {
870 seq,
871 recorded: &recorded,
872 observed: &key,
873 mode,
874 preserve,
875 };
876 self.journal_branch_marker(&div);
877 let live_seq = self.seq;
878 self.record_value(live_seq, &key, kind, &live_bytes);
879 Ok(live_bytes)
880 }
881 }
882 }
883
884 /// Record a pure value step (time/random): instantaneous, terminal `ok`.
885 fn record_value(&self, seq: u64, key: &StepKey, kind: StepKind, bytes: &[u8]) {
886 let now = self.now();
887 self.record(
888 seq,
889 key,
890 &StepOutcome {
891 kind,
892 attempt: 0,
893 status: StepStatus::Ok,
894 payload: Some(bytes.to_vec()),
895 error_class: None,
896 started_at: now,
897 ended_at: Some(now),
898 },
899 );
900 }
901
902 /// Journal a `running` step, run the effect through the engine, and record
903 /// the terminal outcome *before* returning it (at-least-once honesty).
904 /// An adapter-injected `idempotency_key` is journaled IN the `running`
905 /// record (payload `{"idempotency_key": …}`), so a resume that re-executes
906 /// this step after a crash reads the same key back
907 /// ([`recorded_idempotency_key`](Self::recorded_idempotency_key)); the
908 /// terminal record replaces the payload with the outcome as before.
909 async fn run_live<F>(
910 &mut self,
911 seq: u64,
912 key: &StepKey,
913 kind: StepKind,
914 request: &Request,
915 idempotency_key: Option<&str>,
916 effect: F,
917 ) -> Outcome
918 where
919 F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
920 {
921 // Lease fence: before firing a live effect, confirm we still hold the
922 // lease. If it lapsed (heartbeat starved, clock jump) another process
923 // may have stolen it and be resuming this flow concurrently — running
924 // the effect now would double-fire it (charges, emails, LLM spend) and
925 // interleave step records. Fail the step loudly (KEEL-E030) instead of
926 // silently double-executing. A journal read error is resilience-first
927 // (proceed): only a definitive loss fences.
928 if self.lease_lost() {
929 warn!(flow = %self.flow_id, seq, "lease lost before live step; refusing to double-execute (KEEL-E030)");
930 return lease_lost_outcome(&self.flow_id, seq);
931 }
932 let started_at = self.now();
933 self.record(
934 seq,
935 key,
936 &StepOutcome {
937 kind,
938 attempt: 0,
939 status: StepStatus::Running,
940 payload: idempotency_key
941 .and_then(|k| encode_payload(&json!({ (IDEMPOTENCY_KEY_FIELD): k }))),
942 error_class: None,
943 started_at,
944 ended_at: None,
945 },
946 );
947
948 let outcome = self.engine.execute(request, effect).await;
949
950 let ended_at = self.now();
951 let (status, payload, error_class) = if outcome.result == "ok" {
952 (
953 StepStatus::Ok,
954 outcome.payload.as_ref().and_then(encode_payload),
955 None,
956 )
957 } else {
958 (
959 StepStatus::Error,
960 None,
961 outcome.error.as_ref().map(|e| e.class),
962 )
963 };
964 self.record(
965 seq,
966 key,
967 &StepOutcome {
968 kind,
969 attempt: outcome.attempts,
970 status,
971 payload,
972 error_class,
973 started_at,
974 ended_at: Some(ended_at),
975 },
976 );
977 outcome
978 }
979
980 /// Record a step, degrading a journal failure to a `warn!`. A lost record
981 /// costs replay dedup, never correctness: the `running` marker (or its
982 /// absence) makes resume re-execute the step, so the effect runs
983 /// at-least-once regardless.
984 fn record(&self, seq: u64, key: &StepKey, outcome: &StepOutcome) {
985 if let Err(e) = self.journal.record_step(&self.flow_id, seq, key, outcome) {
986 warn!(flow = %self.flow_id, seq, error = %e, "record_step failed; step not journaled");
987 }
988 }
989
990 /// Move the flow to a terminal status on scope exit. Idempotent-ish: a
991 /// second call re-stamps the status — except a `completed` flow is immutable
992 /// at the journal (`complete_flow` refuses to demote it), so re-running a
993 /// finished flow can never flip it to `failed`/`dead`.
994 pub fn complete(&mut self, status: FlowStatus) {
995 if let Err(e) = self.journal.complete_flow(&self.flow_id, status) {
996 warn!(flow = %self.flow_id, error = %e, "complete_flow failed");
997 }
998 self.completed = true;
999 }
1000
1001 /// Mark the flow `completed` (the success scope exit).
1002 pub fn complete_success(&mut self) {
1003 self.complete(FlowStatus::Completed);
1004 }
1005
1006 /// Mark the flow `failed` (a non-retryable failure exit).
1007 pub fn complete_failed(&mut self) {
1008 self.complete(FlowStatus::Failed);
1009 }
1010}
1011
1012impl Drop for FlowHandle {
1013 fn drop(&mut self) {
1014 // Stop and join the lease-renewal thread (HeartbeatHandle::drop).
1015 self.heartbeat = None;
1016 if !self.completed {
1017 // A handle dropped without complete() models a crash: the flow stays
1018 // `running` with its lease and is resumed after the lease expires.
1019 debug!(flow = %self.flow_id, "flow handle dropped uncompleted; left running for recovery");
1020 }
1021 }
1022}
1023
1024/// The monotonic timestamp of this handle's last successful lease renewal,
1025/// shared between the heartbeat thread (writer, [`Self::mark_renewed`]) and the
1026/// owning [`FlowHandle`] (reader, [`Self::elapsed`]). Built on [`Instant`], the
1027/// standard library's guaranteed-non-decreasing clock — unlike [`SystemTime`]
1028/// (used for every *stored* journal timestamp), it cannot be stepped backwards
1029/// by an NTP correction or a manual clock change, which is exactly the
1030/// property architecture-spec §6 asks lease heartbeats to have.
1031///
1032/// [`SystemTime`]: std::time::SystemTime
1033#[derive(Debug, Clone)]
1034struct LeaseHeartbeatMonitor(Arc<Mutex<Instant>>);
1035
1036impl LeaseHeartbeatMonitor {
1037 /// Starts "fresh" as of now — used both when a lease is first acquired
1038 /// (before the heartbeat thread has ticked even once) and for the unused
1039 /// monitor a replay-only handle is handed.
1040 fn new() -> Self {
1041 Self(Arc::new(Mutex::new(Instant::now())))
1042 }
1043
1044 /// Record a successful renewal at the current instant.
1045 fn mark_renewed(&self) {
1046 let mut at = self
1047 .0
1048 .lock()
1049 .expect("lease heartbeat monitor lock poisoned");
1050 *at = Instant::now();
1051 }
1052
1053 /// Real time elapsed since the last successful renewal (or since
1054 /// construction, if none has happened yet).
1055 fn elapsed(&self) -> Duration {
1056 self.0
1057 .lock()
1058 .expect("lease heartbeat monitor lock poisoned")
1059 .elapsed()
1060 }
1061}
1062
1063/// A running lease-renewal thread. Dropping this signals the thread to stop (by
1064/// disconnecting the channel) and joins it.
1065struct HeartbeatHandle {
1066 stop: Option<std::sync::mpsc::Sender<()>>,
1067 thread: Option<std::thread::JoinHandle<()>>,
1068}
1069
1070impl Drop for HeartbeatHandle {
1071 fn drop(&mut self) {
1072 // Dropping the sender disconnects the channel, waking the thread's
1073 // `recv_timeout` immediately so it exits without waiting out the period.
1074 drop(self.stop.take());
1075 if let Some(thread) = self.thread.take() {
1076 let _ = thread.join();
1077 }
1078 }
1079}
1080
1081/// Spawn the lease-renewal heartbeat on a dedicated **OS thread** (not a tokio
1082/// task). Under the synchronous front-end bindings the ambient runtime is a
1083/// current-thread runtime that only makes progress while a `block_on` is in
1084/// flight, so a tokio-task heartbeat starves exactly when it is needed — during
1085/// a long blocking step or pure-caller compute between steps — letting the lease
1086/// lapse while the flow is still alive. A std thread renews on real wall-clock
1087/// time regardless of whether any runtime is being polled. Renews every `ttl/2`
1088/// against the journal (which does its own wall-clock CAS — see
1089/// [`FlowHandle::lease_lost`]); every successful renewal also marks `monitor`,
1090/// the *monotonic* freshness signal the owning handle consults. A lost lease
1091/// (`Ok(false)`) is logged loudly (the step-level fence is what actually
1092/// prevents double execution).
1093fn spawn_heartbeat(
1094 journal: Arc<dyn Journal>,
1095 flow: FlowId,
1096 holder: ProcessId,
1097 ttl: Duration,
1098 monitor: LeaseHeartbeatMonitor,
1099) -> Option<HeartbeatHandle> {
1100 use std::sync::mpsc::{RecvTimeoutError, channel};
1101
1102 let period = (ttl / 2).max(Duration::from_millis(1));
1103 let (stop, rx) = channel::<()>();
1104 let thread = std::thread::Builder::new()
1105 .name(String::from("keel-lease-heartbeat"))
1106 .spawn(move || {
1107 // Loop until the sender is dropped/sent (handle closing) —
1108 // `recv_timeout` then yields `Ok`/`Disconnected` and the `while let`
1109 // ends; a `Timeout` is a renewal tick.
1110 while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(period) {
1111 match journal.acquire_lease(&flow, &holder, ttl) {
1112 Ok(true) => monitor.mark_renewed(),
1113 Ok(false) => {
1114 warn!(flow = %flow, "lease heartbeat: lease lost to another holder");
1115 }
1116 Err(e) => {
1117 warn!(flow = %flow, error = %e, "lease heartbeat renewal failed");
1118 }
1119 }
1120 }
1121 })
1122 .ok()?;
1123 Some(HeartbeatHandle {
1124 stop: Some(stop),
1125 thread: Some(thread),
1126 })
1127}
1128
1129/// A configuration/internal `KEEL-E040`.
1130fn internal(message: String) -> KeelError {
1131 KeelError {
1132 code: ErrorCode::Internal,
1133 message,
1134 }
1135}
1136
1137/// Self-describing schema tag stamped into every step payload blob, honoring
1138/// journal.sql's "MessagePack, schema-tagged" contract (`steps.payload`) and
1139/// mirroring the persistent cache's `keel.cache/v1` convention so one journal.db
1140/// uses one payload-tagging discipline.
1141const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
1142
1143/// The schema-tagged step-payload envelope, written by reference (no clone).
1144#[derive(serde::Serialize)]
1145struct StepPayloadRef<'a> {
1146 schema: &'a str,
1147 payload: &'a Value,
1148}
1149
1150/// The owned form read back before its tag is verified.
1151#[derive(serde::Deserialize)]
1152struct StepPayloadOwned {
1153 schema: String,
1154 payload: Value,
1155}
1156
1157/// MessagePack-encode a step payload with its schema tag (journal.sql:
1158/// `steps.payload` is "MessagePack, schema-tagged").
1159fn encode_payload(value: &Value) -> Option<Vec<u8>> {
1160 rmp_serde::to_vec_named(&StepPayloadRef {
1161 schema: STEP_PAYLOAD_SCHEMA,
1162 payload: value,
1163 })
1164 .ok()
1165}
1166
1167/// Decode a step payload. Prefers the schema-tagged envelope; falls back to a
1168/// bare value so journals written before the tag existed — including the golden
1169/// fixtures in `conformance/fixtures/journal/` — still replay (the tag is
1170/// introduced without a breaking on-disk migration, which is exactly what
1171/// "versioned, self-describing" buys).
1172fn decode_payload(bytes: &[u8]) -> Option<Value> {
1173 if let Ok(envelope) = rmp_serde::from_slice::<StepPayloadOwned>(bytes)
1174 && envelope.schema == STEP_PAYLOAD_SCHEMA
1175 {
1176 return Some(envelope.payload);
1177 }
1178 rmp_serde::from_slice(bytes).ok()
1179}
1180
1181/// A synthetic trace id for a step outcome the manager mints (replay /
1182/// divergence), distinct from the engine's `t-NNNNNN` live ids.
1183fn step_trace(flow: &FlowId, seq: u64) -> String {
1184 format!("flow-{flow}-s{seq}")
1185}
1186
1187/// Reconstruct an [`Outcome`] from a recorded step — the replay substitution.
1188fn replay_outcome(flow: &FlowId, seq: u64, step: &StepOutcome) -> Outcome {
1189 let mut outcome = base_outcome(step_trace(flow, seq));
1190 outcome.attempts = step.attempt;
1191 match step.status {
1192 StepStatus::Ok | StepStatus::Running => {
1193 outcome.result = String::from("ok");
1194 outcome.payload = step.payload.as_deref().and_then(decode_payload);
1195 }
1196 StepStatus::Error => {
1197 outcome.error = Some(OutcomeError {
1198 code: ErrorCode::NonRetryableError,
1199 class: step.error_class.unwrap_or(ErrorClass::Other),
1200 http_status: None,
1201 message: String::from("replayed failed step"),
1202 original: None,
1203 });
1204 }
1205 }
1206 outcome
1207}
1208
1209/// The precise expected-vs-actual diagnostic for a `(seq, step_key)` divergence
1210/// (spec §4.4), shared by the outcome and error surfaces.
1211fn divergence_message(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> String {
1212 format!("flow {flow} diverged at step {seq}: expected {recorded}, got {observed} (KEEL-E031)")
1213}
1214
1215/// The KEEL-E031 outcome for a divergence on an effect step (`execute_step`).
1216fn diverged_outcome(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> Outcome {
1217 let mut outcome = base_outcome(step_trace(flow, seq));
1218 outcome.error = Some(OutcomeError {
1219 code: ErrorCode::FlowNondeterminism,
1220 class: ErrorClass::Other,
1221 http_status: None,
1222 message: divergence_message(flow, seq, recorded, observed),
1223 original: None,
1224 });
1225 outcome
1226}
1227
1228/// The KEEL-E030 outcome for a live step whose lease was lost to another holder
1229/// — the step is refused rather than double-executed.
1230fn lease_lost_outcome(flow: &FlowId, seq: u64) -> Outcome {
1231 let mut outcome = base_outcome(step_trace(flow, seq));
1232 outcome.error = Some(OutcomeError {
1233 code: ErrorCode::FlowLeaseHeld,
1234 class: ErrorClass::Other,
1235 http_status: None,
1236 message: format!(
1237 "flow {flow} lost its lease before step {seq}; another holder may be resuming it \
1238 (KEEL-E030). Refusing to run the effect to avoid double execution."
1239 ),
1240 original: None,
1241 });
1242 outcome
1243}
1244
1245/// The KEEL-E031 error for a divergence on a value step (`journal_time` /
1246/// `journal_random`), which return `Result` rather than an `Outcome`.
1247fn diverged_error(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> KeelError {
1248 KeelError {
1249 code: ErrorCode::FlowNondeterminism,
1250 message: divergence_message(flow, seq, recorded, observed),
1251 }
1252}
1253
1254/// A completed-flow replay reached step `seq` (`observed`) with no matching
1255/// recorded outcome — the code grew or reordered a step since the flow finished.
1256/// Refused as nondeterminism (KEEL-E031) so no effect fires on a replay handle.
1257fn replay_miss_message(flow: &FlowId, seq: u64, observed: &StepKey) -> String {
1258 format!(
1259 "flow {flow} replay reached unrecorded step {seq} ({observed}); \
1260 the completed flow's code changed (KEEL-E031)"
1261 )
1262}
1263
1264/// The KEEL-E031 outcome for a replay-only miss on an effect step.
1265fn replay_miss_outcome(flow: &FlowId, seq: u64, observed: &StepKey) -> Outcome {
1266 let mut outcome = base_outcome(step_trace(flow, seq));
1267 outcome.error = Some(OutcomeError {
1268 code: ErrorCode::FlowNondeterminism,
1269 class: ErrorClass::Other,
1270 http_status: None,
1271 message: replay_miss_message(flow, seq, observed),
1272 original: None,
1273 });
1274 outcome
1275}
1276
1277/// The KEEL-E031 error for a replay-only miss on a value step.
1278fn replay_miss_error(flow: &FlowId, seq: u64, observed: &StepKey) -> KeelError {
1279 KeelError {
1280 code: ErrorCode::FlowNondeterminism,
1281 message: replay_miss_message(flow, seq, observed),
1282 }
1283}
1284
1285/// A fresh error/replay [`Outcome`] shell with the shared envelope defaults.
1286fn base_outcome(trace_id: String) -> Outcome {
1287 Outcome {
1288 v: ENVELOPE_VERSION,
1289 result: String::from("error"),
1290 payload: None,
1291 error: None,
1292 attempts: 0,
1293 from_cache: false,
1294 waits_ms: Vec::new(),
1295 throttled: false,
1296 throttle_wait_ms: 0,
1297 breaker: keel_core_api::BreakerState::Closed,
1298 trace_id,
1299 }
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304 use super::{LeaseHeartbeatMonitor, decode_payload, encode_payload};
1305 use core::time::Duration;
1306 use serde_json::json;
1307
1308 /// `LeaseHeartbeatMonitor` measures real elapsed time (architecture-spec
1309 /// §6's monotonic heartbeat), not a value anyone can inject — so this is
1310 /// necessarily a real-clock test with small real sleeps, the same
1311 /// precedent `the_heartbeat_renews_the_lease_on_a_real_clock` sets in
1312 /// `crates/keel-core/tests/flows.rs`.
1313 #[test]
1314 fn lease_heartbeat_monitor_tracks_real_elapsed_time_since_the_last_renewal() {
1315 let monitor = LeaseHeartbeatMonitor::new();
1316 assert!(
1317 monitor.elapsed() < Duration::from_millis(50),
1318 "freshly constructed: elapsed should be ~0"
1319 );
1320
1321 std::thread::sleep(Duration::from_millis(60));
1322 assert!(
1323 monitor.elapsed() >= Duration::from_millis(60),
1324 "elapsed grows with real time when nothing renews"
1325 );
1326
1327 monitor.mark_renewed();
1328 assert!(
1329 monitor.elapsed() < Duration::from_millis(50),
1330 "a renewal resets elapsed back to ~0"
1331 );
1332 }
1333
1334 #[test]
1335 fn payload_round_trips_through_the_schema_tag() {
1336 let value = json!({ "rows": 120, "nested": [1, 2, 3], "ok": true });
1337 let bytes = encode_payload(&value).expect("encodes");
1338 assert_eq!(decode_payload(&bytes), Some(value));
1339 }
1340
1341 #[test]
1342 fn legacy_bare_messagepack_still_decodes() {
1343 // Journals written before the tag (and the golden fixtures) stored the
1344 // bare value; the decoder must still read them (no breaking migration).
1345 let map = json!({ "rows": 120 });
1346 let bare_map = rmp_serde::to_vec_named(&map).expect("bare encodes");
1347 assert_eq!(decode_payload(&bare_map), Some(map));
1348
1349 // The fixtures' bare uint32 virtualized-time value (0xCE6A518600).
1350 let num = json!(1_783_727_616u64);
1351 let bare_num = rmp_serde::to_vec_named(&num).expect("bare encodes");
1352 assert_eq!(bare_num, vec![0xCE, 0x6A, 0x51, 0x86, 0x00]);
1353 assert_eq!(decode_payload(&bare_num), Some(num));
1354 }
1355}