Virtual object keying
Each Restate VO uses the narrowest key that gives the serialization it needs:DeployWorkflowis keyed bydeployment_id. Each deployment is one workflow run, so multiple deployments in the same environment can build in parallel. It is a workflow rather than a virtual object because Deploy has no operations to serialise.EnvironmentServiceis keyed byenvironment_id. Promote and rollback run here. Both change which deployment is live in one environment, and both check the live pointer before they swap it. One key holds the check and the swap under one lock. With separate keys, two operations on the same environment can pass their checks against the same live pointer and then swap one after the other.RoutingServiceis keyed byenv_id. Frontline route assignment and the live-deployment swap serialize here. It is the write primitive thatDeployWorkflowandEnvironmentServicecall.DeploymentService(desired-state transitions, scheduled and immediate) is keyed bydeployment_id.
Build is a second handler on DeployWorkflow that Deploy calls on its own key, in the Restate scope builds with the workspace id as the limit key, and Restate’s concurrency rules cap how many run at once per workspace. See Build concurrency.
Key components:
- Control API deployment service —
svc/ctrl/services/deployment DeployWorkflowRestate workflow —svc/ctrl/worker/deployEnvironmentService(promote, rollback, environment deletion):svc/ctrl/worker/environmentRoutingService(route assignment + live swap) —svc/ctrl/worker/routingDeploymentServiceVO for scheduled and immediate desired-state transitions —svc/ctrl/worker/deploymentRunBuildLimitSynccron that writes the build concurrency rules:svc/ctrl/worker/cron/buildlimitsync- Superseded sibling cancellation —
svc/ctrl/worker/deploy/supersede.go
Flow: create deployment
Flow: cancel deployment
Flow: promote
Flow: rollback
Build concurrency
Restate limits how many builds run at once. Deploy callsBuild for a git source only, in the scope builds with the workspace id as the limit key, and a rule for builds/<workspace_id> or builds/* says how many such calls may run at the same time. An exact pattern beats *; no matching rule means no limit.
The database is the source of truth: a rule set by hand lasts at most 15 minutes. To raise a workspace’s cap, raise
builds_concurrent_max on its limits row.
Deploy waits for Build (Request, not Send), so cancelling Deploy cancels the Build too. A Build still waiting is removed and never runs. A running one has its image build aborted and frees its slot at once.
That last part needs Build to stay unsuspended for the whole build, which is why it is bound with WithInactivityTimeout(deploy.BuildKeepAliveWindow). Restate suspends an invocation after about a minute of inactivity, and a suspended Build cannot be told it was cancelled: it would run to completion and hold the workspace’s slot until it did. Keep that window above buildBackendDeadline, or cancel stops working for any build that outlives it.
- One queue per workspace, production and preview together, in the order builds became ready. No priority, no timeout: a waiting deployment stays
pendinguntil a build in its workspace finishes or it is cancelled. Buildrefuses to run outside scopebuildsor with a limit key other than its workspace. Restate picks the rule before it dispatches, so these checks report a mis-queued Build, they do not stop it from taking the wrong slot.- A build that still fails after all retries is stopped for good, and Deploy marks the deployment failed.
- All builds share one Restate partition. When its leader changes, every build in progress restarts from its last recorded step.
Commit deduplication
When a new deployment is created,Workflow.cancelOlderSiblings looks for older deployments on the same (app, environment, branch) that are still in the build queue (pending or awaiting_approval) and cancels them.
Once Restate lets a deployment’s Build run and it transitions to building, it is committed: newer commits will not supersede it. This avoids the pathological case where rapid pushes keep cancelling builds and nothing ever finishes.
Cancellation happens in three steps, all batched:
- One SELECT — list older queued sibling deployments with their invocation IDs.
- One batch UPDATE — stamp every sibling’s in-flight steps with
"Superseded by newer commit"(first-write-wins viaWHERE ended_at IS NULL). - One batch UPDATE — transition every sibling to
status=superseded. - N HTTP calls —
restateAdmin.CancelInvocationfor each sibling that has an invocation ID.
Instance readiness (promise-based)
AftercreateTopologies, the Deploy run enters waitForDeployments, which awaits a durable promise named instances_ready until krane reports pod readiness. Krane installs the per-deployment Cilium network policy itself when it applies each deployment, so the run does not create one.
waitForDeployments flow:
- Build per-region min replicas from the topologies just inserted.
- Require
numRegions - 1healthy regions (minimum 1, tolerating one regional outage). - Check the database once for instances that are already healthy.
- Otherwise await the promise, racing it against
regionReadyTimeout.
DeployWorkflow.NotifyInstancesReady, a SHARED handler that runs concurrently with the suspended run. A report that arrives before the run awaits is kept by the promise, so there is no awakeable to stash and no state to clear.
Caller: cluster.Service.ReportDeploymentStatus (the RPC krane calls to report instance state) runs a thundering-herd gate after the upsert transaction:
- Deployment must be in an active status (
starting | building | deploying | network | finalizing). - Look up per-region min replicas via
FindDeploymentTopologyMinReplicas. - Count running instances per region; require
numRegions - 1healthy regions (minimum 1 — tolerates one regional outage). - Dedup the notification via an in-process
sync.Mapso we don’t re-fire on every subsequent status report once the threshold is met. - Skip if the deployment’s Deploy invocation is no longer live in Restate. Wake also reports with status
deploying, and a notify for a finished run would leave a promise Restate never cleans up. - If threshold met (and not yet notified), send
NotifyInstancesReady(deployment_id)toDeployWorkflow.
Self-skip (belt-and-suspenders dedup)
In addition to the proactive cancel above, the Deploy handler checksHasNewerActiveDeployment at the top of its workflow. If a newer sibling on the same (app, env, branch) is already pending, starting, building, deploying, network, finalizing, ready, or awaiting_approval, the current deployment self-skips. This catches races where the proactive cancel didn’t land (e.g. the newer deployment hadn’t persisted its invocation ID yet).
State serialization (desired state)
Scheduled state changes are serialized via a Restate virtual object keyed by deployment ID insvc/ctrl/worker/deployment. The object stores a nonce for the most recent transition so older delayed requests no-op.
Retry policy
DeployWorkflow is registered with an exponential-backoff retry policy: 2s → 4s → 8s → 16s → 30s (capped), 15 attempts total (about 5 minutes). Short intervals keep cancel latency low, since a cancel lands at the next attempt boundary. On exhaustion the invocation pauses rather than being killed, so the compensation stack can still run when a restate.Run returns a terminal error. This replaces an older 150-attempt policy that could leave a deploy stuck retrying for about 24 hours. Build uses the same intervals and attempt count but is stopped for good on exhaustion, as described under Build concurrency.
Compensation stack
The Deploy handler maintains a LIFO compensation stack registered viaCompensation.Add, each step wrapped in restate.RunVoid. The stack fires on any error or cancellation:
- Mark the deployment as
failed(only if still in an active status — the conditionalUpdateDeploymentStatusIfActivequery prevents overwritingsupersededorready) - Undo topology inserts, route assignments, etc.