Skip to content

feat(durable): durable promises, timers, and retention/compaction#4971

Merged
bug-ops merged 1 commit into
mainfrom
feat/m28/4948-durable-promises-timers-retention
Jun 7, 2026
Merged

feat(durable): durable promises, timers, and retention/compaction#4971
bug-ops merged 1 commit into
mainfrom
feat/m28/4948-durable-promises-timers-retention

Conversation

@bug-ops

@bug-ops bug-ops commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Summary

Completes the DurableContext API surface — C5 of epic #4707 (spec-064, zeph-durable). Builds on C1–C4 (#4944/#4945/#4946/#4947, all merged) and adds durable promises, durable timers, and the retention/compaction machinery. Pure additive change to zeph-durable; zeph-core (the only reverse dependency) is unaffected.

Closes #4948.

What landed

Durable promises (promise.rs, FR-DE-05, INV-9)

  • ctx.promise::<T>() mints a PromiseId derived from (execution_id, step_id) plus a 32-byte CSPRNG resolver token (Zeroizing, zeroized on drop). Only the domain-separated BLAKE3 hash — bound to (promise_id, execution_id) — is stored in durable_promises.resolver_token_hash.
  • DurableHandle::resolve(id, token, value) authenticates the token in constant time (blake3::Hash equality) before sealing the value with a (execution_id, promise_id)-bound AAD and committing it. Wrong token → PromiseRejected; unknown promise → UnknownPromise; double-resolve is a no-op.
  • The resolver token is the only capability — a PromiseId is derivable from the journal and appears in spans, but the LLM never sees the token, so it cannot resolve its own promises (INV-9). DurableHandle must be kept off any LLM-reachable tool surface.
  • ctx.await_promise() parks on a tokio::sync::Notify keyed by promise id with a promise_poll_interval_secs DB-poll fallback; above max_parked_promises parked waiters it polls without registering.

Durable timers (timer.rs, FR-DE-06)

  • ctx.sleep_until(due) arms a durable_timers row at a deterministic TimerId::derive(exec, step) and parks until due; it fires its own timer even with no service running.
  • DurableTimerService polls due_timers(now) (covered by idx_durable_timers_due), fires them idempotently, and wakes parked waiters. A timer whose instant elapsed during downtime fires on the first poll after restart; a resumed sleep_until returns immediately because it re-derives the same TimerId.

Retention & compaction (retention.rs, spec NEVER: never on the hot path)

  • DurableRetentionService prunes terminal executions past ttl_completed_secs/ttl_failed_secs in prune_batch_size batches (children-then-parent FK order), yielding between batches.
  • Crossing the soft step cap (90% of max_steps_per_execution) spawns a background checkpoint fold (tracked in a JoinSet, drainable via ctx.drain_background()): the committed-idempotent prefix is packed into one AEAD-sealed Checkpoint (budget-capped at max_payload_bytes) and the folded rows are deleted. The hard cap (100%) aborts with StepCapExceeded.
  • On resume the cursor preloads checkpoints, reconstructing each folded step as a StepResult (idempotency key preserved) so folded idempotent steps replay their journaled value without re-running the op and without tripping the divergence guard (INV-3).

Design notes

  • Deterministic promise/timer ids. Promises/timers correlate across a crash-resume by deriving their ids from (execution_id, step_id) (UUIDv8) rather than a random UUIDv7, so a resumed program re-attaches to the pending row instead of minting an orphan. This is security-neutral: INV-9 makes the resolver token — not the id — the capability.
  • Backend surface. Promise/timer/checkpoint/prune methods are inherent on DurableBackendEnum (forwarding to LocalBackend's dedicated durable_promises/durable_timers tables), not on the sealed ExecutionBackend trait — they are local-table-specific; a future Restate backend would satisfy them via its own SDK, and the closed match makes that a compile-time prompt.
  • New deps (Layer-0 infra, INV-1): rand (resolver-token CSPRNG), zeroize (Zeroizing token), uuid v8 feature.

Tests & gates

  • cargo nextest run -p zeph-durable102 pass (+10): promise auth (right/wrong/unknown token, constant-time), notify-wake, resumed-promise await, timer arm/due/fire + restart re-arm, prune, checkpoint fold + replay-through-checkpoint, soft/hard cap.
  • cargo clippy --all-targets -- -D warnings clean on sqlite and postgres.
  • RUSTDOCFLAGS="--deny rustdoc::broken_intra_doc_links" cargo doc --no-deps clean; cargo test --doc 34 pass.
  • RUSTFLAGS="-D warnings" cargo check --all-targets clean on both backends.
  • cargo +nightly fmt --check clean; zeph-core (consumer) 1532 tests pass.

Spans added

durable.promise.create/await/resolve, durable.timer.arm/fire, durable.journal.prune (real body), durable.journal.checkpoint, durable.replay.cursor.preload.

Out of scope (other epic children)

CLI/TUI/config/init/migrate wiring (C6 #4949); the agent-loop adapter (A1 #4951, LLM live-API gate); subagent promise wiring (A4 #4954); the long-running service::run interval loops are exercised via the deterministic fire_due/single-prune paths, not a live tick; Postgres-server parity (testcontainers).

Complete the DurableContext API surface (C5 of epic #4707, spec-064).

Promises (FR-DE-05, INV-9): ctx.promise() mints a position-derived PromiseId
and a 32-byte CSPRNG resolver token (zeroized on drop); only its domain-
separated BLAKE3 hash, bound to (promise_id, execution_id), is persisted.
DurableHandle::resolve authenticates the token in constant time before sealing
and committing the value. The token is the sole capability, so the LLM cannot
resolve its own promises. await_promise parks on a Notify with a DB-poll
fallback and a max_parked_promises cap.

Timers (FR-DE-06): sleep_until arms a durable_timers row at a deterministic
TimerId derived from the program position; DurableTimerService fires due timers
and wakes parked waiters. A timer that elapsed during downtime fires on the
first poll after restart, and a resumed sleep_until returns immediately.

Retention: DurableRetentionService prunes terminal executions past their TTL in
yielding batches (never the dispatch hot path). Crossing the soft step cap (90%)
folds the committed-idempotent prefix into one AEAD-sealed Checkpoint on a
background task and deletes the folded rows; the hard cap aborts with
StepCapExceeded. A resume replays folded steps from the snapshot without
re-running them, preserving each step's idempotency key for the divergence guard.

Promise/timer state lives in the dedicated durable_promises/durable_timers
tables via inherent DurableBackendEnum forwarders (local-specific, off the
sealed trait). 102 crate tests pass; clippy/doc/RUSTFLAGS clean on sqlite and
postgres.
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust Rust code changes dependencies Dependency updates enhancement New feature or request size/XL Extra large PR (500+ lines) labels Jun 7, 2026
@bug-ops
bug-ops enabled auto-merge (squash) June 7, 2026 00:19
@bug-ops
bug-ops merged commit 19a7661 into main Jun 7, 2026
32 checks passed
@bug-ops
bug-ops deleted the feat/m28/4948-durable-promises-timers-retention branch June 7, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Dependency updates documentation Improvements or additions to documentation enhancement New feature or request rust Rust code changes size/XL Extra large PR (500+ lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(durable): durable promises, durable timers, retention and compaction

1 participant