1use std::cmp::min;
2use std::future::Future;
3use std::time::Duration;
4
5use chrono::{DateTime, Utc};
6use runledger_core::jobs::{WorkflowRunEnqueue, WorkflowRunStatus};
7use serde_json::Value;
8use sqlx::postgres::PgListener;
9use sqlx::types::Uuid;
10use tokio::time::{Instant, sleep_until};
11
12use crate::{DbPool, Error, QueryError, QueryErrorCategory, Result};
13
14use super::super::row_decode::{
15 parse_step_key_name, parse_workflow_run_status, parse_workflow_type_name,
16};
17use super::super::workflow_types::{
18 WorkflowRunDbRecord, WorkflowRunHandle, WorkflowRunHandleError, WorkflowRunHandleScope,
19 WorkflowRunResultRecord, WorkflowRunWaitOptions,
20};
21use super::enqueue::enqueue_workflow_run;
22use super::read::get_workflow_run_by_id;
23use super::runtime::WORKFLOW_RUN_TERMINAL_CHANNEL;
24
25struct WorkflowRunHandleLookupRow {
26 id: Uuid,
27 workflow_type: String,
28 organization_id: Option<Uuid>,
29 status: String,
30 idempotency_key: Option<String>,
31 result_step_key: Option<String>,
32 metadata: Value,
33 started_at: DateTime<Utc>,
34 finished_at: Option<DateTime<Utc>>,
35 created_at: DateTime<Utc>,
36 updated_at: DateTime<Utc>,
37}
38
39struct WorkflowRunResultLookupRow {
40 id: Uuid,
41 workflow_type: String,
42 organization_id: Option<Uuid>,
43 status: String,
44 result_step_key: Option<String>,
45 result: Option<Value>,
46 finished_at: Option<DateTime<Utc>>,
47}
48
49enum WorkflowRunResultLookup {
50 Pending,
51 Ready(WorkflowRunResultRecord),
52}
53
54impl WorkflowRunHandle {
55 pub async fn get_status(
56 &self,
57 ) -> std::result::Result<Option<WorkflowRunStatus>, WorkflowRunHandleError> {
58 load_workflow_run_status(&self.pool, self.scope, self.workflow_run_id)
59 .await
60 .map_err(WorkflowRunHandleError::from)
61 }
62
63 pub async fn get_run(
64 &self,
65 ) -> std::result::Result<Option<WorkflowRunDbRecord>, WorkflowRunHandleError> {
66 load_workflow_run_for_scope(&self.pool, self.scope, self.workflow_run_id)
67 .await
68 .map_err(WorkflowRunHandleError::from)
69 }
70
71 pub async fn get_result(
72 &self,
73 options: WorkflowRunWaitOptions,
74 ) -> std::result::Result<WorkflowRunResultRecord, WorkflowRunHandleError> {
75 let poll_interval = normalize_poll_interval(options.poll_interval);
76 if options.timeout == Some(Duration::ZERO) {
77 return match load_workflow_run_result_deadline_probe(
78 &self.pool,
79 self.scope,
80 self.workflow_run_id,
81 )
82 .await?
83 {
84 WorkflowRunResultLookup::Ready(record) => Ok(record),
85 WorkflowRunResultLookup::Pending => Err(WorkflowRunHandleError::Timeout),
86 };
87 }
88
89 let deadline = options.timeout.map(instant_after);
90
91 if let WorkflowRunResultLookup::Ready(record) =
92 load_workflow_run_result_before_deadline_or_final_probe(
93 &self.pool,
94 self.scope,
95 self.workflow_run_id,
96 deadline,
97 )
98 .await?
99 {
100 return Ok(record);
101 }
102 if deadline_has_elapsed(deadline) {
103 return Err(WorkflowRunHandleError::Timeout);
104 }
105
106 let mut listener = match connect_terminal_listener(&self.pool, deadline).await {
107 Ok(listener) => listener,
108 Err(WorkflowRunHandleError::Timeout) if deadline_has_elapsed(deadline) => {
109 return lookup_ready_or_timeout(
110 load_workflow_run_result_deadline_probe(
111 &self.pool,
112 self.scope,
113 self.workflow_run_id,
114 )
115 .await?,
116 );
117 }
118 Err(error) => return Err(error),
119 };
120 if deadline_has_elapsed(deadline) {
121 return lookup_ready_or_timeout(
122 load_workflow_run_result_deadline_probe(
123 &self.pool,
124 self.scope,
125 self.workflow_run_id,
126 )
127 .await?,
128 );
129 }
130
131 if let WorkflowRunResultLookup::Ready(record) =
134 load_workflow_run_result_before_deadline_or_final_probe(
135 &self.pool,
136 self.scope,
137 self.workflow_run_id,
138 deadline,
139 )
140 .await?
141 {
142 return Ok(record);
143 }
144 if deadline_has_elapsed(deadline) {
145 return Err(WorkflowRunHandleError::Timeout);
146 }
147
148 let mut next_poll = instant_after(poll_interval);
152
153 loop {
154 let wake = next_poll_wake(deadline, next_poll);
155
156 if let Some(active_listener) = listener.as_mut() {
157 tokio::select! {
158 notification = active_listener.recv() => {
159 match notification {
160 Ok(notification) => {
161 if listener_event_needs_result_lookup(
162 notification.payload(),
163 self.workflow_run_id,
164 deadline,
165 ) {
166 if let WorkflowRunResultLookup::Ready(record) =
167 load_workflow_run_result_after_notification(
168 &self.pool,
169 self.scope,
170 self.workflow_run_id,
171 deadline,
172 )
173 .await?
174 {
175 return Ok(record);
176 }
177 if deadline_has_elapsed(deadline) {
178 return Err(WorkflowRunHandleError::Timeout);
179 }
180 }
181 }
182 Err(error) => {
183 tracing::warn!(
184 error = %error,
185 workflow_run_id = %self.workflow_run_id,
186 "workflow result listener failed; falling back to polling"
187 );
188 listener = None;
189 if deadline_has_elapsed(deadline) {
190 if let WorkflowRunResultLookup::Ready(record) =
191 load_workflow_run_result_deadline_probe(
192 &self.pool,
193 self.scope,
194 self.workflow_run_id,
195 )
196 .await?
197 {
198 return Ok(record);
199 }
200 return Err(WorkflowRunHandleError::Timeout);
201 }
202 }
203 }
204 }
205 () = sleep_until(wake.instant) => {
206 next_poll = instant_after(poll_interval);
207 let lookup = load_workflow_run_result_after_poll_wake(
208 &self.pool,
209 self.scope,
210 self.workflow_run_id,
211 deadline,
212 wake.reached_deadline,
213 )
214 .await?;
215 if let WorkflowRunResultLookup::Ready(record) = lookup {
216 return Ok(record);
217 }
218 if wake.reached_deadline {
219 return Err(WorkflowRunHandleError::Timeout);
220 }
221 }
222 }
223 continue;
224 }
225
226 sleep_until(wake.instant).await;
227 next_poll = instant_after(poll_interval);
228 let lookup = load_workflow_run_result_after_poll_wake(
229 &self.pool,
230 self.scope,
231 self.workflow_run_id,
232 deadline,
233 wake.reached_deadline,
234 )
235 .await?;
236 if let WorkflowRunResultLookup::Ready(record) = lookup {
237 return Ok(record);
238 }
239 if wake.reached_deadline {
240 return Err(WorkflowRunHandleError::Timeout);
241 }
242 }
243 }
244}
245
246pub fn workflow_run_handle(
247 pool: &DbPool,
248 scope: WorkflowRunHandleScope,
249 workflow_run_id: Uuid,
250) -> WorkflowRunHandle {
251 WorkflowRunHandle {
252 workflow_run_id,
253 scope,
254 pool: pool.clone(),
255 }
256}
257
258pub async fn retrieve_workflow_run_handle(
259 pool: &DbPool,
260 scope: WorkflowRunHandleScope,
261 workflow_run_id: Uuid,
262) -> std::result::Result<WorkflowRunHandle, WorkflowRunHandleError> {
263 let Some(_) = load_workflow_run_status(pool, scope, workflow_run_id).await? else {
264 return Err(WorkflowRunHandleError::NotFound);
265 };
266
267 Ok(workflow_run_handle(pool, scope, workflow_run_id))
268}
269
270pub async fn enqueue_workflow_run_handle(
271 pool: &DbPool,
272 payload: &WorkflowRunEnqueue<'_>,
273) -> Result<WorkflowRunHandle> {
274 let workflow_run = enqueue_workflow_run(pool, payload).await?;
275 let scope = workflow_run
276 .organization_id
277 .map(WorkflowRunHandleScope::Organization)
278 .unwrap_or(WorkflowRunHandleScope::Global);
279
280 Ok(workflow_run_handle(pool, scope, workflow_run.id))
281}
282
283const DEADLINE_RESULT_LOOKUP_TIMEOUT: Duration = Duration::from_millis(250);
284
285async fn connect_terminal_listener(
286 pool: &DbPool,
287 deadline: Option<Instant>,
288) -> std::result::Result<Option<PgListener>, WorkflowRunHandleError> {
289 let mut listener = match await_before_deadline(deadline, PgListener::connect_with(pool)).await?
290 {
291 Ok(listener) => listener,
292 Err(error) => {
293 tracing::warn!(
294 error = %error,
295 "workflow result listener connect failed; using polling"
296 );
297 return Ok(None);
298 }
299 };
300
301 if !pool_can_spare_query_connection(pool) {
302 let pool_size = pool.size();
303 let pool_idle = pool.num_idle();
304 let max_connections = pool.options().get_max_connections();
305 tracing::debug!(
306 pool_size,
307 pool_idle,
308 max_connections,
309 "workflow result listener skipped; pool has no spare connection for race-closing reads"
310 );
311 return Ok(None);
312 }
313
314 if let Err(error) =
315 await_before_deadline(deadline, listener.listen(WORKFLOW_RUN_TERMINAL_CHANNEL)).await?
316 {
317 tracing::warn!(
318 error = %error,
319 "workflow result listener subscribe failed; using polling"
320 );
321 return Ok(None);
322 }
323
324 Ok(Some(listener))
325}
326
327fn pool_can_spare_query_connection(pool: &DbPool) -> bool {
328 pool.num_idle() > 0 || pool.size() < pool.options().get_max_connections()
329}
330
331fn normalize_poll_interval(poll_interval: Duration) -> Duration {
332 poll_interval.max(Duration::from_millis(1))
333}
334
335#[derive(Clone, Copy, Debug, Eq, PartialEq)]
336struct PollWake {
337 instant: Instant,
338 reached_deadline: bool,
339}
340
341fn next_poll_wake(deadline: Option<Instant>, next_poll: Instant) -> PollWake {
342 let Some(deadline) = deadline else {
343 return PollWake {
344 instant: next_poll,
345 reached_deadline: false,
346 };
347 };
348
349 PollWake {
350 instant: min(next_poll, deadline),
351 reached_deadline: deadline <= next_poll || deadline <= Instant::now(),
352 }
353}
354
355fn instant_after(duration: Duration) -> Instant {
356 instant_saturating_add(Instant::now(), duration)
357}
358
359fn instant_saturating_add(base: Instant, duration: Duration) -> Instant {
360 base.checked_add(duration)
361 .unwrap_or_else(|| far_future_instant(base))
362}
363
364fn far_future_instant(base: Instant) -> Instant {
365 let mut seconds = 60 * 60 * 24 * 365 * 100;
366 loop {
367 if let Some(instant) = base.checked_add(Duration::from_secs(seconds)) {
368 return instant;
369 }
370 seconds /= 2;
371 }
372}
373
374async fn await_before_deadline<F, T>(
375 deadline: Option<Instant>,
376 future: F,
377) -> std::result::Result<T, WorkflowRunHandleError>
378where
379 F: Future<Output = T>,
380{
381 let Some(deadline) = deadline else {
382 return Ok(future.await);
383 };
384
385 let now = Instant::now();
386 if deadline <= now {
387 return Err(WorkflowRunHandleError::Timeout);
388 }
389
390 tokio::time::timeout(deadline - now, future)
391 .await
392 .map_err(|_| WorkflowRunHandleError::Timeout)
393}
394
395fn notification_matches_workflow_run(payload: &str, workflow_run_id: Uuid) -> bool {
396 serde_json::from_str::<Value>(payload)
397 .ok()
398 .and_then(|value| {
399 value
400 .get("workflow_run_id")
401 .and_then(Value::as_str)
402 .and_then(|raw_id| Uuid::parse_str(raw_id).ok())
403 })
404 == Some(workflow_run_id)
405}
406
407fn listener_event_needs_result_lookup(
408 payload: &str,
409 workflow_run_id: Uuid,
410 deadline: Option<Instant>,
411) -> bool {
412 notification_matches_workflow_run(payload, workflow_run_id) || deadline_has_elapsed(deadline)
413}
414
415fn deadline_has_elapsed(deadline: Option<Instant>) -> bool {
416 deadline.is_some_and(|deadline| deadline <= Instant::now())
417}
418
419async fn load_workflow_run_status(
420 pool: &DbPool,
421 scope: WorkflowRunHandleScope,
422 workflow_run_id: Uuid,
423) -> Result<Option<WorkflowRunStatus>> {
424 let status = match scope {
425 WorkflowRunHandleScope::Organization(organization_id) => {
426 sqlx::query_scalar!(
427 "SELECT status::text AS \"status!\"
428 FROM workflow_runs
429 WHERE id = $1
430 AND organization_id = $2
431 LIMIT 1",
432 workflow_run_id,
433 organization_id,
434 )
435 .fetch_optional(pool)
436 .await
437 }
438 WorkflowRunHandleScope::Global => {
439 sqlx::query_scalar!(
440 "SELECT status::text AS \"status!\"
441 FROM workflow_runs
442 WHERE id = $1
443 AND organization_id IS NULL
444 LIMIT 1",
445 workflow_run_id,
446 )
447 .fetch_optional(pool)
448 .await
449 }
450 WorkflowRunHandleScope::Admin => {
451 sqlx::query_scalar!(
452 "SELECT status::text AS \"status!\"
453 FROM workflow_runs
454 WHERE id = $1
455 LIMIT 1",
456 workflow_run_id,
457 )
458 .fetch_optional(pool)
459 .await
460 }
461 }
462 .map_err(|error| Error::from_query_sqlx_with_context("load workflow handle status", error))?;
463
464 status.map(parse_workflow_run_status).transpose()
465}
466
467async fn load_workflow_run_for_scope(
468 pool: &DbPool,
469 scope: WorkflowRunHandleScope,
470 workflow_run_id: Uuid,
471) -> Result<Option<WorkflowRunDbRecord>> {
472 match scope {
473 WorkflowRunHandleScope::Organization(organization_id) => {
474 get_workflow_run_by_id(pool, Some(organization_id), workflow_run_id).await
475 }
476 WorkflowRunHandleScope::Global => {
477 load_global_workflow_run_by_id(pool, workflow_run_id).await
478 }
479 WorkflowRunHandleScope::Admin => get_workflow_run_by_id(pool, None, workflow_run_id).await,
480 }
481}
482
483async fn load_global_workflow_run_by_id(
484 pool: &DbPool,
485 workflow_run_id: Uuid,
486) -> Result<Option<WorkflowRunDbRecord>> {
487 let row = sqlx::query_as!(
488 WorkflowRunHandleLookupRow,
489 "SELECT
490 id,
491 workflow_type,
492 organization_id,
493 status::text AS \"status!\",
494 idempotency_key,
495 result_step_key,
496 metadata,
497 started_at,
498 finished_at,
499 created_at,
500 updated_at
501 FROM workflow_runs
502 WHERE id = $1
503 AND organization_id IS NULL
504 LIMIT 1",
505 workflow_run_id,
506 )
507 .fetch_optional(pool)
508 .await
509 .map_err(|error| Error::from_query_sqlx_with_context("load global workflow run", error))?;
510
511 row.map(workflow_run_db_record_from_handle_row).transpose()
512}
513
514fn workflow_run_db_record_from_handle_row(
515 row: WorkflowRunHandleLookupRow,
516) -> Result<WorkflowRunDbRecord> {
517 Ok(WorkflowRunDbRecord {
518 id: row.id,
519 workflow_type: parse_workflow_type_name(row.workflow_type)?,
520 organization_id: row.organization_id,
521 status: parse_workflow_run_status(row.status)?,
522 idempotency_key: row.idempotency_key,
523 result_step_key: row.result_step_key.map(parse_step_key_name).transpose()?,
524 metadata: row.metadata,
525 started_at: row.started_at,
526 finished_at: row.finished_at,
527 created_at: row.created_at,
528 updated_at: row.updated_at,
529 })
530}
531
532async fn load_workflow_run_result(
533 pool: &DbPool,
534 scope: WorkflowRunHandleScope,
535 workflow_run_id: Uuid,
536) -> std::result::Result<WorkflowRunResultLookup, WorkflowRunHandleError> {
537 let Some(row) = load_workflow_run_result_row(pool, scope, workflow_run_id).await? else {
538 return Err(WorkflowRunHandleError::NotFound);
539 };
540
541 let status = parse_workflow_run_status(row.status).map_err(WorkflowRunHandleError::from)?;
542 let Some(raw_step_key) = row.result_step_key else {
543 return Err(WorkflowRunHandleError::ResultNotDeclared);
544 };
545
546 match status {
547 WorkflowRunStatus::Running | WorkflowRunStatus::WaitingForExternal => {
548 Ok(WorkflowRunResultLookup::Pending)
549 }
550 WorkflowRunStatus::CompletedWithErrors | WorkflowRunStatus::Canceled => {
551 Err(WorkflowRunHandleError::UnsuccessfulTerminal { status })
552 }
553 WorkflowRunStatus::Succeeded => {
554 let Some(result) = row.result else {
555 return Err(WorkflowRunHandleError::ResultMissing);
556 };
557 let Some(finished_at) = row.finished_at else {
558 return Err(WorkflowRunHandleError::Storage(handle_internal_error(
559 "workflow result row is succeeded without finished_at",
560 )));
561 };
562
563 Ok(WorkflowRunResultLookup::Ready(WorkflowRunResultRecord {
564 workflow_run_id: row.id,
565 workflow_type: parse_workflow_type_name(row.workflow_type)
566 .map_err(WorkflowRunHandleError::from)?,
567 organization_id: row.organization_id,
568 result_step_key: parse_step_key_name(raw_step_key)
569 .map_err(WorkflowRunHandleError::from)?,
570 result,
571 finished_at,
572 }))
573 }
574 }
575}
576
577async fn load_workflow_run_result_row(
578 pool: &DbPool,
579 scope: WorkflowRunHandleScope,
580 workflow_run_id: Uuid,
581) -> std::result::Result<Option<WorkflowRunResultLookupRow>, WorkflowRunHandleError> {
582 let row = match scope {
583 WorkflowRunHandleScope::Organization(organization_id) => {
584 sqlx::query_as!(
585 WorkflowRunResultLookupRow,
586 "SELECT
587 id,
588 workflow_type,
589 organization_id,
590 status::text AS \"status!\",
591 result_step_key,
592 result,
593 finished_at
594 FROM workflow_runs
595 WHERE id = $1
596 AND organization_id = $2
597 LIMIT 1",
598 workflow_run_id,
599 organization_id,
600 )
601 .fetch_optional(pool)
602 .await
603 }
604 WorkflowRunHandleScope::Global => {
605 sqlx::query_as!(
606 WorkflowRunResultLookupRow,
607 "SELECT
608 id,
609 workflow_type,
610 organization_id,
611 status::text AS \"status!\",
612 result_step_key,
613 result,
614 finished_at
615 FROM workflow_runs
616 WHERE id = $1
617 AND organization_id IS NULL
618 LIMIT 1",
619 workflow_run_id,
620 )
621 .fetch_optional(pool)
622 .await
623 }
624 WorkflowRunHandleScope::Admin => {
625 sqlx::query_as!(
626 WorkflowRunResultLookupRow,
627 "SELECT
628 id,
629 workflow_type,
630 organization_id,
631 status::text AS \"status!\",
632 result_step_key,
633 result,
634 finished_at
635 FROM workflow_runs
636 WHERE id = $1
637 LIMIT 1",
638 workflow_run_id,
639 )
640 .fetch_optional(pool)
641 .await
642 }
643 }
644 .map_err(|error| {
645 WorkflowRunHandleError::Storage(Error::from_query_sqlx_with_context(
646 "load workflow result row",
647 error,
648 ))
649 })?;
650
651 Ok(row)
652}
653
654async fn load_workflow_run_result_before_deadline(
655 pool: &DbPool,
656 scope: WorkflowRunHandleScope,
657 workflow_run_id: Uuid,
658 deadline: Option<Instant>,
659) -> std::result::Result<WorkflowRunResultLookup, WorkflowRunHandleError> {
660 await_before_deadline(
661 deadline,
662 load_workflow_run_result(pool, scope, workflow_run_id),
663 )
664 .await?
665}
666
667async fn load_workflow_run_result_after_poll_wake(
668 pool: &DbPool,
669 scope: WorkflowRunHandleScope,
670 workflow_run_id: Uuid,
671 deadline: Option<Instant>,
672 reached_deadline: bool,
673) -> std::result::Result<WorkflowRunResultLookup, WorkflowRunHandleError> {
674 if reached_deadline {
675 return load_workflow_run_result_deadline_probe(pool, scope, workflow_run_id).await;
676 }
677
678 load_workflow_run_result_before_deadline_or_final_probe(pool, scope, workflow_run_id, deadline)
679 .await
680}
681
682async fn load_workflow_run_result_after_notification(
683 pool: &DbPool,
684 scope: WorkflowRunHandleScope,
685 workflow_run_id: Uuid,
686 deadline: Option<Instant>,
687) -> std::result::Result<WorkflowRunResultLookup, WorkflowRunHandleError> {
688 if deadline_has_elapsed(deadline) {
689 return load_workflow_run_result_deadline_probe(pool, scope, workflow_run_id).await;
690 }
691
692 load_workflow_run_result_before_deadline_or_final_probe(pool, scope, workflow_run_id, deadline)
693 .await
694}
695
696async fn load_workflow_run_result_before_deadline_or_final_probe(
697 pool: &DbPool,
698 scope: WorkflowRunHandleScope,
699 workflow_run_id: Uuid,
700 deadline: Option<Instant>,
701) -> std::result::Result<WorkflowRunResultLookup, WorkflowRunHandleError> {
702 match load_workflow_run_result_before_deadline(pool, scope, workflow_run_id, deadline).await {
703 Ok(WorkflowRunResultLookup::Pending) if deadline_has_elapsed(deadline) => {
704 load_workflow_run_result_deadline_probe(pool, scope, workflow_run_id).await
705 }
706 Ok(lookup) => Ok(lookup),
707 Err(WorkflowRunHandleError::Timeout) if deadline_has_elapsed(deadline) => {
708 load_workflow_run_result_deadline_probe(pool, scope, workflow_run_id).await
709 }
710 Err(error) => Err(error),
711 }
712}
713
714async fn load_workflow_run_result_deadline_probe(
715 pool: &DbPool,
716 scope: WorkflowRunHandleScope,
717 workflow_run_id: Uuid,
718) -> std::result::Result<WorkflowRunResultLookup, WorkflowRunHandleError> {
719 tokio::time::timeout(
720 DEADLINE_RESULT_LOOKUP_TIMEOUT,
721 load_workflow_run_result(pool, scope, workflow_run_id),
722 )
723 .await
724 .map_err(|_| WorkflowRunHandleError::Timeout)?
725}
726
727fn lookup_ready_or_timeout(
728 lookup: WorkflowRunResultLookup,
729) -> std::result::Result<WorkflowRunResultRecord, WorkflowRunHandleError> {
730 match lookup {
731 WorkflowRunResultLookup::Ready(record) => Ok(record),
732 WorkflowRunResultLookup::Pending => Err(WorkflowRunHandleError::Timeout),
733 }
734}
735
736fn handle_internal_error(message: &'static str) -> Error {
737 Error::QueryError(QueryError::from_classified(
738 QueryErrorCategory::Internal,
739 "workflow.handle_internal_state",
740 "Workflow handle state is invalid.",
741 message,
742 ))
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn listener_event_needs_lookup_for_match_or_elapsed_deadline() {
751 let workflow_run_id = Uuid::now_v7();
752 let matching_payload = serde_json::json!({
753 "workflow_run_id": workflow_run_id,
754 })
755 .to_string();
756 let unrelated_payload = serde_json::json!({
757 "workflow_run_id": Uuid::now_v7(),
758 })
759 .to_string();
760 let future_deadline = Some(instant_after(Duration::from_secs(60)));
761 let elapsed_deadline = Some(Instant::now() - Duration::from_millis(1));
762
763 assert!(listener_event_needs_result_lookup(
764 &matching_payload,
765 workflow_run_id,
766 future_deadline,
767 ));
768 assert!(!listener_event_needs_result_lookup(
769 &unrelated_payload,
770 workflow_run_id,
771 future_deadline,
772 ));
773 assert!(listener_event_needs_result_lookup(
774 &unrelated_payload,
775 workflow_run_id,
776 elapsed_deadline,
777 ));
778 }
779
780 #[test]
781 fn next_poll_wake_marks_elapsed_deadline_for_final_probe() {
782 let wake = next_poll_wake(
783 Some(Instant::now() - Duration::from_millis(1)),
784 instant_after(Duration::from_secs(30)),
785 );
786
787 assert!(wake.reached_deadline);
788 assert!(wake.instant <= Instant::now());
789 }
790
791 #[test]
792 fn instant_after_saturates_oversized_duration() {
793 let start = Instant::now();
794 let instant = instant_saturating_add(start, Duration::MAX);
795
796 assert!(instant >= start);
797 }
798}