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