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;