Skip to content

Continue the deploy workflow as new to bound its history - #794

Merged
emmanuelmaduwuba-lyft merged 1 commit into
mainfrom
deploy-continue-as-new
Oct 8, 2026
Merged

emmanuelmaduwuba-lyft merged 1 commit into
mainfrom
deploy-continue-as-new

Conversation

@emmanuelmaduwuba-lyft

@emmanuelmaduwuba-lyft emmanuelmaduwuba-lyft commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Why

The Deploy workflow only exits when its queue is empty. A queue that is locked with merged revisions waiting keeps the workflow alive indefinitely, and each idle hour adds a timer and a workflow task to its history, roughly 125 events a day. Two things go wrong as it grows:

  1. Past Temporal's hard history limit, the server terminates the workflow. The queue and lock live only in workflow memory (queue/worker.go: "we don't persist lock state anywhere"), so queued deploys are dropped without any notice.
  2. A large history can stop a workflow from making progress. After a worker restart, replaying it can exceed the SDK's 1-second deadlock detection, and every workflow task then fails.

What changes

The workflow continues as new at an idle point and carries its in-memory state into the next run:

  • When:
    • its history passes 10,000 events (behind workflow.GetVersion); or
    • it receives the new continue-as-new signal (workflows.DeployContinueAsNewSignalName, no input).
  • Only when idle: no deployment is running and the queue can't pop anything. The worker reports working while it waits on a locked queue with items, so idleness uses a new Worker.IsDeploying() plus Queue.CanPop() rather than GetState().
  • What's carried over (deploy.Request.ContinuedState):
    • queued deployments, in pop order;
    • lock state;
    • check run cache entries, so queued deployments keep updating their existing check runs instead of creating duplicates.
  • Before continuing: revision and unlock signals that arrived but weren't processed are handled, so they're carried over too.
  • Metrics: the workflow interceptor counts continue-as-new separately instead of as a failure, plus a continue_as_new counter tagged with the reason.

Why there's a signal as well as the history check

GetVersion returns DefaultVersion when it's first called during replay, and keeps that for the rest of the execution (sdk-go v1.16.0 internal_event_handlers.go, GetVersion). A workflow started before this change replays its old timeouts first, so the history check never applies to it. The signal moves those workflows over once. Their histories contain no such signal, so listening for it doesn't affect replay. New runs, including those created by the signal, use the history check from then on.

Replay safety

On histories recorded before this change, nothing new emits a command:

  • Registering the new signal channel and evaluating Idle(), HasPending() and the history length are pure.
  • GetVersion returns the default during replay.
  • Restoring state is a no-op when ContinuedState is nil.

The post-deploy PR revision child workflow is started with PARENT_CLOSE_POLICY_ABANDON, so it survives the parent continuing as new. The Terraform child workflow only runs while a deploy is in progress, which the idle check excludes.

Testing

New tests:

  • Runner:

    • continues as new on a request while idle;
    • on a request while busy, waits for the next idle timeout;
    • never continues while never idle;
    • continues on history over the limit while idle;
    • doesn't continue over the limit on a workflow started before the change (GetVersion mocked to default);
    • doesn't continue under the limit;
    • doesn't continue over the limit while deploying.

    Each case decodes the continue-as-new input and checks the carried state and the original repo and root.

  • queueContinuer.Snapshot drains a pending revision signal into the queue and applies a pending unlock.

  • restoreQueue restores order and check run IDs, and is a no-op without state.

  • Worker.IsDeploying is false for a worker that hasn't deployed, since InProgressStatus is the zero value.

Rollback

After a workflow has continued as new, code without this change would ignore ContinuedState on the next start and drop that queue. Fix forward rather than reverting once any workflow has continued.

@emmanuelmaduwuba-lyft
emmanuelmaduwuba-lyft added this pull request to stack #795 October 8, 2026 15:57
Base automatically changed from temporal-sdk-1.16 to main October 8, 2026 17:19
The Deploy workflow (one per repo and root) only exits when its queue is
empty. A queue that is locked with merged revisions waiting keeps the
workflow alive indefinitely, and each idle hour adds a timer and a workflow
task to its history. Some of these histories are approaching Temporal's
hard limit, where the server terminates the workflow. The queue and lock
live only in workflow memory, so termination drops queued deploys. A large
history can also stop a workflow from making progress after a worker
restart, when replaying it exceeds the SDK's deadlock detection.

The workflow now continues as new at an idle point (no deployment running
and nothing the queue can start), carrying its queue, lock state, and
check run IDs into the next run's request. Restored check run IDs keep
queued deployments updating their existing check runs instead of creating
duplicates. Before continuing, it handles revision and unlock signals that
arrived but weren't processed yet, so they're carried over too.

It continues as new when either:

- its history passes 10,000 events, using GetCurrentHistoryLength(). This
  is behind workflow.GetVersion, so histories recorded before this change
  replay unchanged.
- it receives the new "continue-as-new" signal (exported as
  workflows.DeployContinueAsNewSignalName). A workflow started before this
  change replays its old timeouts first, which fixes the version at its
  default, so the history check never applies to it. The signal moves those
  workflows over once. Their histories contain no such signal, so adding
  it doesn't affect replay.

The worker's state stays "working" while it waits on a locked queue that
still has items, so idleness is judged by the new Worker.IsDeploying()
plus Queue.CanPop() instead.

The workflow interceptor counted any returned error as a failure,
including continue-as-new. It now counts continue-as-new separately.
@emmanuelmaduwuba-lyft
emmanuelmaduwuba-lyft merged commit 1e6c0aa into main Oct 8, 2026
3 checks passed
@emmanuelmaduwuba-lyft
emmanuelmaduwuba-lyft deleted the deploy-continue-as-new branch October 8, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants