Repository navigation
Support durable external signals for waiting flow steps #660
Description
Request
@cpursley asks whether core pgflow should support durable external signals, inspired by agoodway/pgflow#6. The reference implementation parks an Elixir job or flow task, stores an approval payload, and resumes the task after an external caller sends a signal.
Triage
The capability is relevant for approvals, webhooks, and other human-in-the-loop flows. Do not port the reference PR directly: it changes an Elixir implementation across 89 files and combines the core state machine with demos and dashboard work.
Priority is P3 / Maybe until a concrete pgflow TypeScript use case confirms the API and delivery semantics. The first deliverable is a design spike, not a public implementation.
Required design decisions
- Define durable signal identity.
run_id + step_slugis insufficient for map tasks, repeated waits, or several signal types. - Release the worker slot while a task waits. Do not keep a handler promise or Edge Function alive until approval arrives.
- Define early-signal buffering, duplicate delivery, payload replacement, timeout, and retry semantics.
- Define atomic transitions among queued, started, waiting, completed, failed, skipped, and future cancelled tasks.
- Define who may send a signal, including SQL grants, RLS expectations, service-role use, and payload redaction.
Prototype scope
Build one approval-flow prototype that proves:
- a task parks durably and releases worker capacity;
- a matching signal queues exactly one continuation;
- early and duplicate signals behave deterministically;
- timeout and terminal-run races cannot revive work;
- signals for another run, step, task index, or signal key remain isolated.
The prototype should compare an explicit signal-step DSL with a handler directive or typed error that parks the current task. Prefer the smaller API that preserves pgflow's current task lifecycle.
Done when
- One real TypeScript use case and caller are documented.
- The public API and durable signal key are proposed with examples.
- The SQL state machine, lock order, retries, timeout, and terminal races are specified.
- A focused prototype proves worker-slot release and delivery isolation.
- The investigation records an adopt, revise, or stop recommendation.
Out of scope for the spike
- Porting the Elixir demo or dashboard.
- Holding an Edge Function open while approval is pending.
- General event correlation or message-bus features.
- Shipping a public API before the state-machine prototype passes.
Activity
[-]Await signals with Approval demo tab[/-][+]Support durable external signals for waiting flow steps[/+]jumski commentedon Aug 28, 2026
Thanks @cpursley for taking the time to bring this up and point me to a concrete implementation.
This is a real gap in pgflow, and one I have wanted to solve for a long time. Approvals, webhook callbacks, and other externally completed work should be possible without keeping a worker or Edge Function alive.
The proposed approach follows the Oban model:
That gives useful behavior such as early-signal buffering and a built-in wait deadline. The tradeoff is that this is not a real continuation of the original handler invocation. Code before
await_signal()runs again, so every earlier side effect must be idempotent. It also adds a signal store, another task status, timeout recovery, and more queue transitions.I have had a simpler and more explicit model in mind for pgflow: represent the wait as a handlerless task without a queue.
The flow remains explicit:
When
wait_for_approvalbecomes ready, pgflow creates a durable task but sends no PGMQ message and starts no handler. Trusted server-side code completes or fails the task, and normal workflow progression startscharge.The main differences are:
await_signal()queue: falsetaskI currently prefer the manual-task direction because it keeps the workflow visible, avoids handler replay, and needs fewer states and races in the core.
It also aligns with an idea I want pgflow core to leave room for: multi-language flows where each step can be invoked by a worker in a different language. For example, a flow authored in TypeScript could have one step run on a Python GPU worker, another on a TypeScript worker, and an approval task completed by application server code. The durable task protocol should own progression rather than a control-flow exception tied to one worker runtime.
This does not make the additional signal behavior useless. Early buffering, core-managed deadlines, or a
signal()helper could later sit on top of manual tasks if concrete use cases need them. For example, a small signal layer could buffer a payload before the manual task exists and complete it when it becomes ready, without replaying a handler.I captured the manual-task direction in #661. It is deliberately a deferred design issue: the exact API, storage model, and release sequence are not final. I want to validate this simpler model before adding mid-handler wait and replay semantics to pgflow core.
I will keep this issue open while #661 is evaluated. If the manual-task model works, this issue can either close as covered or narrow to the remaining early-buffering, timeout, and named-signal capabilities.
cpursley commentedon Aug 28, 2026
Awesome, I'll do some thinking. We're not necessarily totally married to the current approach in the PR and open to unifying this if/where it makes sense (in the context of the various language/platform strengths).
cpursley commentedon Sep 14, 2026
@jumski have you thought more about the details of this design/schema? Turns out I need signal await type of feature in my product that uses pgflow and was planning to work on this. Glad to align with your simpler suggestion.
jumski commentedon Sep 14, 2026
Yes I have an epic that outlines a path to it and I'm working through the required changes to get there #653
The one that will allow queue less task is #661
The signals can he implemented on top of this simpler approach by just observing the changes to steps.
Can you describe your particular use case so I can understand better if it is a good fit?