Progress
Publish a live, bounded status value for operators without changing durable control flow.
A three-minute import looks identical to a stuck one from the outside: both sit in active.
Progress closes that gap. It is a mutable, latest-value-only projection of what the handler is
doing right now — operator data, not control flow. After a retry, progress cannot tell
Workhorse to skip a stage; that is what checkpoints are for.
Publish the latest value
Call HandlerContext.setProgress with any JSON value your operator surface understands.
Each accepted update replaces the previous one.
await ctx.setProgress({ phase: "reading", processed: 0 });
for await (const batch of readBatches(payload.source)) {
await importBatch(batch);
processed += batch.length;
await ctx.setProgress({ phase: "importing", processed });
}HandlerContext.getProgress returns the latest retained value from inside a handler. Outside
one, Admin.getProgress reads it by job ID, and a custom runtime can write through
Queue.updateProgress with the claimed job and worker identity. The
dashboard renders the value live.
Python exposes the handler methods as set_progress and get_progress. Go exposes SetProgress
and GetProgress. Their returned progress records carry the same revision and ownership provenance
as the TypeScript record.
Why progress is rate-limited
Every changed value updates one job_progress row and appends a small progress_updated
event. A tight loop — say, one update per imported row — would churn the database without
making the interface more useful; no operator reads a thousand updates a second.
PostgreSQL therefore size-caps the value and rate-limits changed writes. A caller that
updates too quickly gets ProgressRateLimitError, which says when it may retry. In the loop
above, updating once per batch stays comfortably inside the limit.
Repeating an identical value is a free no-op: it does not advance the revision or append an
event. You can call setProgress defensively without paying for it.
What survives, and who may write
Each accepted update records the attempt, fence token, worker ID, revision, and timestamps. The latest value survives retries and terminal materialization, until retention removes the job identity — so a failed job's last reported phase remains readable evidence.
Only the current unexpired owner can write. If a stale generation tries — an old handler
still running after its lease was recovered — it gets ProgressLeaseLostError, so a zombie
cannot overwrite the newer activation's status.
Keep the division of labor sharp: progress is what an operator watches; a checkpoint is what another activation reuses. If losing the value would change what your code does next, it is not progress — make it a checkpoint.
Next
- Durable execution — save immutable completion evidence
- Queries and timelines — read job and lifecycle state
- Dashboard — show progress to operators
Exact value limits, update cadence, revisions, and event fields: architecture reference.