Skip to main content

aion/lifecycle/
deadline.rs

1//! Engine-side handler that drives an elapsed workflow deadline to a
2//! `WorkflowTimedOut` terminal.
3//!
4//! Registered on the timer bridge at engine construction, this is the seam the
5//! `TimerService` demuxes a reserved `deadline:{run_id}` fire to. It records the
6//! terminal under the per-handle recorder lock — with a terminal re-check so it
7//! loses cleanly to a concurrent completion — then tears the run down matching
8//! `terminate::cancel` discipline: kill the process, refresh visibility, notify
9//! result awaiters, and deregister.
10//!
11//! It holds a `Weak<RuntimeHandle>` (never a strong one) so the engine's
12//! `RuntimeHandle` → `EngineNifState` → timer bridge → handler chain does not
13//! cycle back into the runtime — the same cycle-avoidance the timer bridge's
14//! `Weak<EngineNifState>` observes.
15
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Weak};
18
19use aion_core::{Event, RunId, TimerCancelCause, WorkflowId};
20use aion_store::EventStore;
21use aion_store::visibility::VisibilityStore;
22use chrono::Utc;
23
24use crate::durability::Recorder;
25use crate::registry::{Registry, TerminalOutcome, WorkflowHandle};
26use crate::runtime::RuntimeHandle;
27use crate::time::timer_service::live_timers_in_active_segment;
28use crate::time::{DeadlineHandler, DeadlineHandlerError, WORKFLOW_TIMEOUT_DESCRIPTOR};
29
30use super::completion::terminal_outcome_from_history;
31use super::visibility::upsert_workflow_visibility;
32
33/// Whether the elapsed deadline records a fresh terminal, resumes an interrupted
34/// teardown of its own prior terminal, or loses cleanly to a competing terminal.
35enum DeadlineDisposition {
36    /// This call appended `WorkflowTimedOut`; run the full teardown.
37    Appended,
38    /// Our own `WorkflowTimedOut` is already durable but teardown was
39    /// interrupted; resume the idempotent teardown without a second terminal.
40    ResumeTeardown,
41    /// A competing terminal already won (or the deadline is no longer live);
42    /// nothing to record and nothing to tear down.
43    LoseCleanly,
44    /// This engine's timer wheel has been torn down; the run belongs to
45    /// whichever engine owns it now, so record nothing and tear nothing down.
46    ///
47    /// 🔴 DELIBERATELY NOT [`Self::LoseCleanly`], though both end in `Ok(())`.
48    /// `LoseCleanly` means a competing TERMINAL won and this run is settled;
49    /// `StoodDown` means this run is not settled at all and this engine has
50    /// merely stopped being the one entitled to speak for it. Folding them would
51    /// put an operator reading the debug line on the wrong trail, and this lane
52    /// exists precisely because one message was raised for two causes.
53    StoodDown,
54}
55
56/// Records `WorkflowTimedOut` and tears down a run whose deadline elapsed.
57pub struct WorkflowDeadlineHandler {
58    /// Weak to avoid the `RuntimeHandle`↔`EngineNifState`↔bridge cycle; upgraded
59    /// only to kill the timed-out process.
60    runtime: Weak<RuntimeHandle>,
61    store: Arc<dyn EventStore>,
62    visibility_store: Arc<dyn VisibilityStore>,
63    registry: Arc<Registry>,
64    /// The timer bridge's own `shut_down` latch, SHARED (not copied).
65    ///
66    /// 🔴 THE DEADLINE PATH IS THE ONE DURABLE WRITER THE WHEEL'S APPEND
67    /// BOUNDARY CANNOT REACH. `TimerService::fire_timer_guarded` demuxes a
68    /// reserved `deadline:{run}` fire to this handler BEFORE the generic
69    /// record-then-deliver path, so the boundary refusal in
70    /// `TimerNifBridge::record_workflow_event` — which is what stops an ordinary
71    /// `TimerFired` from being appended by an engine that has stood down — is
72    /// never on a deadline's route. Without this flag a deadline task already
73    /// inside its poll when `shutdown_timer_wheel` ran would go on to append a
74    /// durable `WorkflowTimedOut` and tear the run down, for a run a successor
75    /// engine may already own: a second writer for one workflow, which is the
76    /// #119 breach and load-bearing invariant 3.
77    ///
78    /// `abort` cannot prevent it — a `JoinHandle::abort` does not stop a task
79    /// that has already entered a poll — so the refusal has to sit here, at the
80    /// point of writing.
81    stand_down: Arc<AtomicBool>,
82}
83
84impl WorkflowDeadlineHandler {
85    /// Assembles a deadline handler from the engine's teardown dependencies.
86    ///
87    /// `runtime` is held weakly on purpose (see the module docs); the rest are
88    /// the same durable store, visibility index, and active registry the
89    /// `terminate::cancel` path uses.
90    ///
91    /// `stand_down` must be the timer bridge's OWN latch, shared by `Arc` — see
92    /// the field. A fresh flag here would compile, pass every test that sets it
93    /// directly, and gate nothing in production, because nothing would ever set
94    /// it.
95    ///
96    /// 🔴 CRATE-PRIVATE BECAUSE IT HAS NO BUSINESS BEING PUBLIC — AND FOR NO
97    /// LARGER REASON THAN THAT.
98    ///
99    /// An earlier version of this comment justified the narrowing with a threat:
100    /// that a downstream consumer of `aion-rs` could build a handler around
101    /// `Arc::new(AtomicBool::new(false))` and get a deadline writer that never
102    /// stands down. **That threat was not real, and the boundary it implied does
103    /// not exist.** Both halves are wrong, and recording why is worth more than
104    /// the tidier sentence it replaces:
105    ///
106    /// - The attack was unreachable. `register_deadline_handler`
107    ///   (`runtime/nif_timer_bridge.rs`) is ALREADY `pub(crate)`, so no external
108    ///   caller could register such a handler in the first place; the timer
109    ///   service would never route a fire to it. The registration seam's claim
110    ///   was already true, not aspirational.
111    /// - The larger hole the sentence implied was closed is wide open, and this
112    ///   constructor is nowhere near it. `Recorder::new` is `pub`, `durability`
113    ///   is a `pub mod` re-exporting it, and `record_workflow_timed_out`,
114    ///   `record_workflow_continued_as_new`, and `record_workflow_failed` are all
115    ///   `pub`. Any downstream consumer can write any terminal into any history
116    ///   without going near a deadline handler. Narrowing this signature buys
117    ///   nothing against that, and pretending otherwise would leave the next
118    ///   reader believing in a wall that is not there.
119    ///
120    /// What the narrowing IS good for: this constructor takes five collaborators
121    /// that must be the engine's own, one of which — `stand_down` — is only
122    /// correct when it is the timer bridge's shared latch rather than a fresh
123    /// flag. Nothing in the signature can enforce that, so the type keeps the
124    /// only guarantee it can: the sole supported way to build one is the seam
125    /// that supplies the right latch. `pub(crate)` states that in the language
126    /// instead of in a comment. Do not widen it back.
127    #[must_use]
128    pub(crate) fn new(
129        runtime: Weak<RuntimeHandle>,
130        store: Arc<dyn EventStore>,
131        visibility_store: Arc<dyn VisibilityStore>,
132        registry: Arc<Registry>,
133        stand_down: Arc<AtomicBool>,
134    ) -> Self {
135        Self {
136            runtime,
137            store,
138            visibility_store,
139            registry,
140            stand_down,
141        }
142    }
143
144    /// Body of the timeout terminal + teardown, returning typed engine errors.
145    async fn drive_timed_out(
146        &self,
147        workflow_id: WorkflowId,
148        run_id: RunId,
149    ) -> Result<(), crate::EngineError> {
150        let Some(handle) = self.registry.get(&workflow_id, &run_id)? else {
151            // No registered handle. This is NOT automatically a no-op: a cold
152            // engine (or a shard adopter) never registers a terminal run, so a
153            // recovered deadline row whose durable history shows `WorkflowTimedOut`
154            // with teardown left unfinished reaches here with no handle. Complete
155            // that teardown registry-free — this is the ONLY actor that finishes
156            // it. A non-timeout terminal, or a fully-torn-down run, is a genuine
157            // no-op (its deadline is already retired or was never this run's).
158            return self
159                .finalize_timed_out_without_handle(&workflow_id, &run_id)
160                .await;
161        };
162
163        let disposition = self
164            .decide_disposition(&handle, &workflow_id, &run_id)
165            .await?;
166        match disposition {
167            DeadlineDisposition::LoseCleanly => Ok(()),
168            DeadlineDisposition::StoodDown => {
169                // Not a fault and not a loss: an orderly stand-down. The durable
170                // deadline row is untouched and still live, so whichever engine
171                // owns the run re-arms it and times the run out there. DEBUG,
172                // like every other stand-down in the crate.
173                tracing::debug!(
174                    %workflow_id,
175                    %run_id,
176                    "workflow deadline abandoned: this engine's timer wheel has been torn down and the deadline stays live for its owner"
177                );
178                Ok(())
179            }
180            DeadlineDisposition::Appended | DeadlineDisposition::ResumeTeardown => {
181                self.tear_down(&handle, &workflow_id, &run_id).await
182            }
183        }
184    }
185
186    /// Whether this engine has stood down and may no longer speak for the run.
187    ///
188    /// The one place the flag and its ordering are named. `SeqCst` matters: it
189    /// is the same store `shutdown_timer_wheel` performs, and the total order
190    /// across the two is what makes "set before we looked" observable at all.
191    fn stood_down(&self) -> bool {
192        self.stand_down.load(Ordering::SeqCst)
193    }
194
195    /// Decides — under the recorder lock — whether to append a fresh
196    /// `WorkflowTimedOut`, resume an interrupted teardown of an already-recorded
197    /// one, or lose cleanly.
198    ///
199    /// The terminal re-check, the deadline-liveness re-check, and the terminal
200    /// append are one critical section: a concurrent complete/fail/cancel
201    /// records through the same recorder, so checking outside the lock could
202    /// double-record a terminal or let a cancelled deadline still time the run
203    /// out.
204    ///
205    /// 🔴 THE STAND-DOWN CHECK IS DELIBERATELY NOT IN THAT LIST. An earlier
206    /// version of this doc put it there, which was false and load-bearing:
207    /// `shutdown_timer_wheel` sets the flag with an atomic store and a map
208    /// drain, and neither it nor either of its callers (`Engine::shutdown`,
209    /// `Engine::drop`) ever takes a recorder lock. So this lock excludes other
210    /// RECORDER WRITERS and excludes nothing whatever about the flag. Naming a
211    /// mechanism that does not cover the case is the error this file has now
212    /// made twice; see the note on the check itself.
213    async fn decide_disposition(
214        &self,
215        handle: &WorkflowHandle,
216        workflow_id: &WorkflowId,
217        run_id: &RunId,
218    ) -> Result<DeadlineDisposition, crate::EngineError> {
219        let recorder = handle.recorder();
220        let mut recorder = recorder.lock().await;
221        // 🔴 CHECK-THEN-ACT, AND SAYING OTHERWISE WAS THE DEFECT. This read is
222        // NOT made decisive by the lock it sits under — see the note on this
223        // function. A stand-down landing after this load and before a write
224        // below is not excluded by anything here, and the `.await` on
225        // `read_history` is a real yield point in that window.
226        //
227        // What this read IS worth: it settles the common case (the engine had
228        // already stood down when the fire began) before spending a store round
229        // trip, and it is re-taken immediately before EVERY durable write that
230        // follows, so the exposed window is an instruction or two rather than a
231        // store round trip. That is narrowing, not closing, and the difference
232        // is the whole reason this comment is worded the way it is.
233        //
234        // It cannot be closed here. Closing it means the stand-down setter and
235        // this writer taking one common lock — but the setter runs in a `Drop`,
236        // synchronously, over every workflow at once, so making it take a
237        // per-workflow async recorder lock is not available at any price. A
238        // second lock spanning one workflow is what invariant 3 forbids
239        // outright.
240        if self.stood_down() {
241            return Ok(DeadlineDisposition::StoodDown);
242        }
243        let history = self.store.read_history(workflow_id).await?;
244        match terminal_outcome_from_history(&history, run_id) {
245            Some(TerminalOutcome::TimedOut(_)) => {
246                // Our own terminal is durable but teardown did not finish (a
247                // dropped runtime, a failed visibility upsert, an interrupted
248                // fire). Resume the idempotent teardown — do NOT append again.
249                tracing::debug!(
250                    %workflow_id,
251                    %run_id,
252                    "workflow deadline re-fired after its WorkflowTimedOut was recorded; resuming teardown"
253                );
254                Ok(DeadlineDisposition::ResumeTeardown)
255            }
256            Some(_) => {
257                // A competing terminal (complete/fail/cancel/continue-as-new) won.
258                // The deadline loses — but if it is still outstanding, that
259                // terminal writer's own deadline cancellation did not commit (a
260                // two-write crash), so this fire REPAIRS it: retire the deadline
261                // here, under the recorder lock, rather than losing without
262                // cancelling and letting whole-history recovery keep re-arming it.
263                // This is the guaranteed re-drive for an interrupted non-timeout
264                // terminal transition — the live wheel or `recover_due`/`tick`
265                // re-arms the still-live deadline, and this fire completes D5.
266                tracing::debug!(
267                    %workflow_id,
268                    %run_id,
269                    "workflow deadline elapsed but another terminal was already recorded; retiring the deadline and losing"
270                );
271                // Re-taken after the `read_history` await: the repair below is
272                // a durable write, and a stand-down during that round trip
273                // means it is no longer ours to make. The owner's own recovery
274                // re-arms the still-live deadline and repairs it there.
275                if self.stood_down() {
276                    return Ok(DeadlineDisposition::StoodDown);
277                }
278                crate::time::retire_run_deadline(&mut recorder, &history, run_id).await?;
279                Ok(DeadlineDisposition::LoseCleanly)
280            }
281            None => {
282                // Re-check THIS deadline is still live: a cancel that recorded
283                // `TimerCancelled { WorkflowIntent }` before its terminal must win,
284                // so a retired deadline loses cleanly rather than timing the run
285                // out after its cancellation.
286                if crate::time::outstanding_deadline_timer(&history, run_id).is_none() {
287                    tracing::debug!(
288                        %workflow_id,
289                        %run_id,
290                        "workflow deadline elapsed but its timer was already retired; deadline loses"
291                    );
292                    return Ok(DeadlineDisposition::LoseCleanly);
293                }
294                // Re-taken immediately before the terminal. This is the write
295                // the whole stand-down exists to stop, and the `read_history`
296                // above was a yield point — so the load at the top of this
297                // function is too old to be the one that decides it.
298                if self.stood_down() {
299                    return Ok(DeadlineDisposition::StoodDown);
300                }
301                recorder
302                    .record_workflow_timed_out(Utc::now(), WORKFLOW_TIMEOUT_DESCRIPTOR.to_owned())
303                    .await?;
304                Ok(DeadlineDisposition::Appended)
305            }
306        }
307    }
308
309    /// Idempotent, resumable teardown after the `WorkflowTimedOut` terminal is
310    /// durable.
311    ///
312    /// Ordering is the invariant that makes resume reachable: the run's OWN
313    /// deadline timer stays live and its registry entry stays present until every
314    /// fallible teardown step has succeeded. So it retires the ordinary
315    /// (non-deadline) timers first, confirms process teardown and refreshes
316    /// visibility, notifies awaiters, and only THEN retires the deadline itself
317    /// and deregisters. A failure in any earlier step is PROPAGATED (not merely
318    /// logged): the handler returns it as a fire failure, the deadline remains
319    /// live, and recovery's `outstanding_future_timers` re-arms it so a later fire
320    /// re-enters here and resumes — rather than destroying both retry anchors
321    /// before the work that needs them.
322    ///
323    /// # Errors
324    ///
325    /// Returns the typed [`crate::EngineError`] from the first failing durable
326    /// step so recovery retries the interrupted teardown.
327    async fn tear_down(
328        &self,
329        handle: &WorkflowHandle,
330        workflow_id: &WorkflowId,
331        run_id: &RunId,
332    ) -> Result<(), crate::EngineError> {
333        // 1. Retire the run's ordinary (non-deadline) timers. The deadline is
334        //    deliberately NOT retired here — it is the resume anchor.
335        self.retire_ordinary_timers(handle, workflow_id, run_id)
336            .await?;
337
338        // 2. Stop the timed-out process. A cancel failure means it already
339        //    exited (benign); a dropped runtime is propagated so a re-fire under a
340        //    live runtime completes the kill.
341        match self.runtime.upgrade() {
342            Some(runtime) => {
343                if let Err(error) = runtime.cancel_pid(handle.pid()) {
344                    tracing::debug!(
345                        %workflow_id,
346                        %run_id,
347                        %error,
348                        "workflow process already exited during deadline teardown"
349                    );
350                }
351            }
352            None => {
353                return Err(crate::EngineError::Runtime {
354                    reason: format!(
355                        "runtime dropped during deadline teardown of {workflow_id}/{run_id}; a later re-fire resumes teardown"
356                    ),
357                });
358            }
359        }
360
361        // 3. Refresh visibility; a failure is propagated so it is retried.
362        upsert_workflow_visibility(
363            Arc::clone(&self.store),
364            Arc::clone(&self.visibility_store),
365            workflow_id,
366            run_id,
367        )
368        .await?;
369
370        // 4. Notify awaiters (a doorbell send; never a retry condition).
371        handle.completion().notify(TerminalOutcome::TimedOut(
372            WORKFLOW_TIMEOUT_DESCRIPTOR.to_owned(),
373        ));
374
375        // 5. Retire the deadline LAST, once teardown has otherwise succeeded, so
376        //    no earlier failure could have removed the resume anchor. Idempotent.
377        self.retire_deadline(handle, workflow_id, run_id).await?;
378
379        // 6. Deregister LAST.
380        self.registry.remove(workflow_id, run_id)?;
381        Ok(())
382    }
383
384    /// Retires the timed-out run's still-live ORDINARY timers (every live timer
385    /// except this run's own deadline) by recording `TimerCancelled { WorkflowIntent }`
386    /// for each, through the handle recorder under its lock. The deadline is
387    /// excluded so it stays live as the teardown resume anchor. Idempotent — a
388    /// re-run sees the same timers already retired and records nothing.
389    ///
390    /// # Errors
391    ///
392    /// Returns the typed [`crate::EngineError`] when history cannot be read or a
393    /// cancellation append fails, so the interrupted teardown is retried.
394    async fn retire_ordinary_timers(
395        &self,
396        handle: &WorkflowHandle,
397        workflow_id: &WorkflowId,
398        run_id: &RunId,
399    ) -> Result<(), crate::EngineError> {
400        let recorder = handle.recorder();
401        let mut recorder = recorder.lock().await;
402        let history = self.store.read_history(workflow_id).await?;
403        record_ordinary_timer_retirements(&mut recorder, &history, run_id).await?;
404        Ok(())
405    }
406
407    /// Registry-free completion of an interrupted timeout teardown.
408    ///
409    /// A cold engine and a shard adopter never register a terminal run, so a
410    /// recovered due deadline row reaches [`Self::drive_timed_out`] with no
411    /// handle. When durable history shows this run's own `WorkflowTimedOut` with
412    /// teardown left unfinished (an outstanding deadline or still-live ordinary
413    /// timers), this finishes the SAME durable steps the handle path runs —
414    /// ordinary timers first, visibility, then the deadline LAST — through an
415    /// independent recorder. It deliberately omits the handle-only side effects:
416    /// the process is already gone (the run is terminal), there are no local
417    /// awaiters this epoch, and nothing is registered to deregister. A non-timeout
418    /// or already-finished run is a clean no-op.
419    ///
420    /// # Errors
421    ///
422    /// Returns the typed [`crate::EngineError`] from the first failing durable
423    /// step so the caller (recovery) retries.
424    async fn finalize_timed_out_without_handle(
425        &self,
426        workflow_id: &WorkflowId,
427        run_id: &RunId,
428    ) -> Result<(), crate::EngineError> {
429        let history = self.store.read_history(workflow_id).await?;
430        if !matches!(
431            terminal_outcome_from_history(&history, run_id),
432            Some(TerminalOutcome::TimedOut(_))
433        ) {
434            tracing::debug!(
435                %workflow_id,
436                %run_id,
437                "unregistered deadline elapsed for a run that is not TimedOut; nothing to finalize"
438            );
439            return Ok(());
440        }
441        // 🔴 CHECK-THEN-ACT, exactly like the gate on the handle path — and for
442        // the same reason, not a different one. An earlier version of this
443        // comment drew a distinction ("honestly weaker than the other gate")
444        // that does not exist: neither gate is decisive, because the flag's
445        // setter takes no lock either path could share with it.
446        //
447        // What CANNOT be done is making it decisive — that needs the setter and
448        // this writer under one lock, and the setter is a synchronous `Drop`
449        // sweeping every workflow at once. What CAN be done, and now is, is
450        // re-reading before each of the three durable writes below: the earlier
451        // wording said it "cannot be strengthened", which conflated the two and
452        // was a limitation dressed up as a contract. One read guarding three
453        // writes separated by `.await` points left the second and third exposed
454        // for a whole visibility round trip.
455        if self.stood_down() {
456            tracing::debug!(
457                %workflow_id,
458                %run_id,
459                "unregistered deadline finalization abandoned: this engine's timer wheel has been torn down"
460            );
461            return Ok(());
462        }
463        let head = history.iter().map(Event::seq).max().unwrap_or_default();
464        let mut recorder = Recorder::resume_at(workflow_id.clone(), Arc::clone(&self.store), head);
465        // Ordinary timers first (the deadline is retired LAST), then visibility.
466        //
467        // Re-read before each. Abandoning PART WAY through is safe here and is
468        // the point of the ordering: the deadline is retired last, so a run
469        // abandoned mid-teardown still has a live deadline, and the owner's
470        // recovery re-arms it and finishes the same idempotent steps. Carrying
471        // on instead would be this engine writing to a run it has just been
472        // told is not its own.
473        record_ordinary_timer_retirements(&mut recorder, &history, run_id).await?;
474        if self.stood_down() {
475            tracing::debug!(
476                %workflow_id,
477                %run_id,
478                "unregistered deadline finalization abandoned after retiring ordinary timers: this engine's timer wheel has been torn down"
479            );
480            return Ok(());
481        }
482        upsert_workflow_visibility(
483            Arc::clone(&self.store),
484            Arc::clone(&self.visibility_store),
485            workflow_id,
486            run_id,
487        )
488        .await?;
489        if self.stood_down() {
490            tracing::debug!(
491                %workflow_id,
492                %run_id,
493                "unregistered deadline finalization abandoned before retiring the deadline: this engine's timer wheel has been torn down"
494            );
495            return Ok(());
496        }
497        crate::time::retire_run_deadline(&mut recorder, &history, run_id).await?;
498        Ok(())
499    }
500
501    /// Retires this run's own declared-timeout deadline as the final teardown
502    /// step, via the shared `retire_run_deadline` primitive. Idempotent — a
503    /// resumed teardown whose deadline is already retired records nothing.
504    ///
505    /// # Errors
506    ///
507    /// Returns the typed [`crate::EngineError`] when history cannot be read or the
508    /// cancellation append fails.
509    async fn retire_deadline(
510        &self,
511        handle: &WorkflowHandle,
512        workflow_id: &WorkflowId,
513        run_id: &RunId,
514    ) -> Result<(), crate::EngineError> {
515        let recorder = handle.recorder();
516        let mut recorder = recorder.lock().await;
517        let history = self.store.read_history(workflow_id).await?;
518        crate::time::retire_run_deadline(&mut recorder, &history, run_id).await?;
519        Ok(())
520    }
521}
522
523/// Records `TimerCancelled { WorkflowIntent }` for every still-live ORDINARY
524/// timer in the run's active segment — the deadline itself is excluded so it
525/// stays live as the teardown resume anchor. Shared by the handle-based teardown
526/// and the registry-free finalizer so both settle ordinary timers identically.
527/// Idempotent: a re-run sees the same timers already retired and records nothing.
528///
529/// # Errors
530///
531/// Returns the recorder's [`crate::durability::DurabilityError`] when a
532/// cancellation append fails.
533async fn record_ordinary_timer_retirements(
534    recorder: &mut Recorder,
535    history: &[Event],
536    run_id: &RunId,
537) -> Result<(), crate::durability::DurabilityError> {
538    let deadline = crate::time::outstanding_deadline_timer(history, run_id);
539    for timer_id in live_timers_in_active_segment(history) {
540        if deadline.as_ref() == Some(&timer_id) {
541            continue;
542        }
543        recorder
544            .record_timer_cancelled(Utc::now(), timer_id, TimerCancelCause::WorkflowIntent)
545            .await?;
546    }
547    Ok(())
548}
549
550#[async_trait::async_trait]
551impl DeadlineHandler for WorkflowDeadlineHandler {
552    async fn on_deadline_elapsed(
553        &self,
554        workflow_id: WorkflowId,
555        run_id: RunId,
556    ) -> Result<(), DeadlineHandlerError> {
557        self.drive_timed_out(workflow_id, run_id)
558            .await
559            .map_err(|error| DeadlineHandlerError(error.to_string()))
560    }
561}
562
563#[cfg(test)]
564#[path = "deadline_tests.rs"]
565mod tests;