Skip to content

Add an absolute-deadline timer variant (e.g. schedule_timer_until(deadline)) #34

Description

@pinodeca

Summary

Add a variant of schedule_timer that accepts an absolute wall-clock deadline instead of a relative Duration, e.g.:

/// Schedule a timer that fires at an absolute point in time.
pub fn schedule_timer_until(&self, deadline: std::time::SystemTime) -> DurableFuture<()>;

This is a small, unopinionated primitive that complements the existing relative-delay timer.

Motivation

Today the only timer primitive is relative:

pub fn schedule_timer(&self, delay: std::time::Duration) -> DurableFuture<()>

Internally, however, the timer is already modeled as an absolute fire time. schedule_timer reads the current time and converts the delay into an absolute timestamp that is recorded durably in history and replayed verbatim:

// src/lib.rs (schedule_timer)
let now = inner.now_ms();
let fire_at_ms = now.saturating_add(delay_ms);
inner.emit_action(Action::CreateTimer { scheduling_event_id: 0, fire_at_ms });
// ... recorded as EventKind::TimerCreated { fire_at_ms }, replayed verbatim

So the durable record (Action::CreateTimer { fire_at_ms } / TimerCreated { fire_at_ms }) is already an absolute ms-since-epoch deadline. A schedule_timer_until(deadline) variant would simply set fire_at_ms = deadline_ms directly instead of now + delay. No changes to the action/event schema, the provider's visible_at handling, or replay are required — it's a thin constructor over machinery that already exists.

Why a deadline timer is better than schedule_timer(deadline - now)

A deadline can be expressed today by reading the deterministic clock and subtracting:

let now = ctx.utc_now().await?;           // recorded syscall
let delay = deadline.duration_since(now).unwrap_or(Duration::ZERO);
ctx.schedule_timer(delay).await;

This works, but it has rough edges that a first-class primitive removes:

  • Intent. "Fire at T" is clearer than "fire after (T − now)" and avoids a category of off-by-reference bugs where the delta is computed against one clock reading but applied against another.
  • Negative / overflow handling. Callers must manually clamp deadline - now to a non-negative Duration (duration_since returns Err when the deadline is already in the past). A schedule_timer_until can define this once (past deadline ⇒ fire immediately).
  • One fewer recorded syscall. The deadline - now form needs a utc_now() reading purely to compute the delay. With an absolute timer the caller often already has the deadline and doesn't need an extra recorded utc_now event in history.

How this helps pg_durable

pg_durable (microsoft/pg_durable) builds durable workflows on top of duroxide and exposes a df.wait_for_schedule('<cron>') step that suspends an orchestration until the next cron tick. The natural, replay-safe implementation is:

let now = ctx.utc_now().await?;                 // deterministic
let next = cron.next_tick_after(now);           // pure computation
ctx.schedule_timer_until(next).await;           // fire exactly at the cron tick

The cron target is intrinsically an absolute timestamp, so being able to hand that timestamp directly to the timer matches the domain exactly. It removes the manual next - now clamp (cron ticks can land in the past by the time the node runs, especially across continue_as_new loop iterations used for recurring schedules) and expresses "wake at the tick" precisely. More generally, any "wait until a deadline / SLA / scheduled-at time" pattern (delayed jobs, timeouts expressed as absolute times, scheduled pipelines) maps directly onto this primitive.

Proposed API

impl OrchestrationContext {
    /// Schedule a timer that fires at an absolute wall-clock deadline.
    ///
    /// Equivalent to `schedule_timer` but anchored to an absolute point in time
    /// rather than a delay from "now". A deadline already in the past fires
    /// immediately (next turn). The deadline is recorded durably and replayed
    /// verbatim, exactly like the existing relative timer.
    pub fn schedule_timer_until(&self, deadline: std::time::SystemTime) -> DurableFuture<()>;
}

Implementation sketch: convert deadline to ms-since-epoch and emit Action::CreateTimer { fire_at_ms } directly, reusing the existing timer future/replay path. schedule_timer(delay) could optionally be reimplemented as schedule_timer_until(now + delay) to keep a single code path.

Backwards compatibility

Purely additive — new method, no change to existing signatures, the on-history action/event format, or replay semantics.

Happy to send a PR if this direction looks good.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions