Repository navigation
INTR-626 - Let provisioners place several workflow deployments on one sidecar - #207
Conversation
32fadc2 to
14c8999
Compare
alexanderguy
left a comment
There was a problem hiding this comment.
This is a pull-request review done by an agent, following the repository's pull-request-review flow on 14c89996.
Requesting changes on the four comments. The branch reads clearly as it stands: a provisioner may keep several deployments on a sidecar it already runs, hello stays within the incarnation cap, and a stop that reached disk stays unrouted. CI on run 37492819281 is green. The comments are where a restart or a reuse can still release a copy that should stay, or accept a placement after destroy has stopped the sidecar.
69cb9a2 to
bdc01b8
Compare
bdc01b8 to
2672cb7
Compare
alexanderguy
left a comment
There was a problem hiding this comment.
This is a pull-request review done by an agent, on the rebased head. Requesting changes on the comment about a deployment the hello left out.
alexanderguy
left a comment
There was a problem hiding this comment.
LGTM
This is a pull-request review done by an agent. The open comment is followed up in INTR-643.
2672cb7 to
2fe3a20
Compare
A worker that misses its reconnect deadline may only be cut off from the Hub and still running its deployment, and the Hub cannot restore the files it holds. Starting the deployment again on new capacity could run it twice or lose that state, so the opt-in replacement goes and a lost worker is always failed and released. Replacement stays for provisioning, which has run nothing yet.
Redeploying a missing copy could repeat effects or mix its local state with the Hub's history, so a connected sidecar's missing deployment is failed and released. Initialization now serves only first deploys, whose anchors have no previous public key to clear, capture or restore. With no redeploy left, the docs and comments that described one now say a deployment keeps its deploy tree for its life.
The Hub's copy of a deployment's history only seeds its first deploy. A sidecar that already has one of its branches holds what an earlier copy left there, and moving that branch would silently mix the two histories, so the transfer is rejected as a conflict. A branch already at the sent commit, or holding only the empty start an earlier attempt created, is still taken, so a retried first deploy succeeds.
Nothing on the Hub sends sync.request, and the Hub rejects every agent-state pack a sidecar pushes, so the sync.request handler and the state push an undeploy made before deleting the agent directory could only fail. With no push left to report on, agent.undeploy.ack drops its statePushed field.
Pack transfer ids came from counters that restart with their process, so after a Hub restart a sidecar's late answer to a transfer the previous Hub process sent could settle a new transfer that reused its id. Random ids avoid reusing the same sequence across process restarts on either end.
A sidecar credential authenticated exactly one probe or allocation, and every generation change or release closed the socket, so a provisioner could not place more than one deployment on a sidecar it already runs. The credential now authenticates the sidecar for every binding it currently hosts. Each binding keeps its own generation fence, and leaving a connection detaches and undeploys only that allocation instead of closing a socket the others still use. Adopting a probe's sidecar no longer disconnects the probe's binding. When an allocation is not yet attached to its connected sidecar, the reconciler syncs that sidecar's bindings before it checks readiness again, since the sidecar may have registered before the allocation was placed on it. For the same reason a sidecar that announces a deployment it no longer hosts is told to undeploy it rather than disconnected, and a frame a probe's connection may not send is dropped by the per-address checks rather than closing a socket that may also carry allocations.
A late stop or undeploy answer can arrive after another connection has taken the address, so teardown must remove the route only while its connection still owns it.
Allocated deployments use workflow addresses from their first deploy, so the legacy session-address set stays empty. Remove it and its unused queue, and rename the remaining delivery retention setting to mailHoldTTLMs to describe the pending mail it still holds.
A late answer naming only an address could settle a newer request, so each deploy and undeploy now has its own typed reply and request id. Bound error replies and shorten locally produced errors so a worker cannot make the Hub retain an arbitrarily large failure message.
Whether a probe or deployment shares a sidecar is the provisioner's decision, but the Hub reserved every sidecar for a single allocation. A provisioner can now answer ensure with an existing sidecar it runs for the same binding. Releasing work on a shared sidecar does not stop the sidecar, so a reconnecting sidecar takes a deployment's route back only once its first deploy completed and while its run has not ended. The Hub undeploys a copy whose deploy is still uncertain. A copy of a run that has ended takes no route either, even when its own history never recorded the end. One whose run ended on its own stays unrouted until the Hub releases the deployment, so a reconnect does not cut its retention short, but one of a run the Hub cancelled is undeployed: a restart that finds its run record still on disk may be running that run again. Accepting a reused sidecar deletes the identity the Hub minted for the request. Workflow runs reference sidecars, so their sidecar column gets an index; without it each such delete reads every run ever stored. Probe synchronization and readiness share one connection deadline. Expiry cancels pending work and releases the probe's capacity hold. Serialize reuse checks with removal of hosting bindings, so a placement cannot commit against ownership that disappears during its check. Lock sidecars in identifier order before their probes or allocations, and keep probe adoption under that same lock through the handoff.
The sidecar passed its Hub URL, id and bearer token into every workflow child's environment, though no child uses them. The token authenticates the sidecar for every deployment it hosts, so anything in one deployment that reads or passes on its environment, such as a tool's subprocess, could hand out what it takes to connect as the sidecar and take over the others. This keeps the token out of what a child is given; it does not isolate the child, which as the sidecar's OS user can still read the sidecar's own environment.
A sidecar's mail transport holds the signing key of every deployment it hosts, and the supervisor handed the transport whatever sender its child named, so a child could send mail signed as another deployment on the same sidecar.
Signing can yield while a local recipient is unregistered, and skipping that recipient silently loses its mail. Resolve every intended local inbox before delivery so the send fails before any mail is delivered.
A sidecar registers every deployment it hosts on one mail transport to sign their mail, and the transport delivered mail for any registered address into a local inbox nothing reads. Mail from one deployment to another on the same sidecar, or to itself, was lost while the Hub recorded it as delivered. The sidecar's transport now relays every recipient to the Hub, which routes it like any other mail. A deployment's copy to itself now reaches the Hub as a recipient, so a mail naming another deployment and copying its own sender names two deployments, which the Hub refuses. The sidecar refuses such a send itself, so the sender sees the error.
The Hub left a push unanswered when its connection no longer routed the address, or when receiving the pack failed. The sender holds a transfer open until it is answered and queues later pushes to the same repository behind it, so one unanswered push held back the deployment's history until the connection closed. The Hub now answers every transfer once.
The sidecar kept each deployment's push state for as long as it ran. A sidecar shared by many deployments runs long, so every undeployed one left its state behind, and a push still queued for it went on shipping to a Hub that no longer routes the address. Undeploy now drops it.
Probe materialization and deployment applies use separate cache roots, with one shared cache instance for the callers of each root. Pin entries through extraction and wait for removals in progress so callers cannot evict files another is reading, even when that temporarily exceeds the cache's size limit.
A sidecar handled every Hub frame on one serial queue, so a slow deploy, undeploy, or probe held up every other deployment on the sidecar and the heartbeat pong with it: a co-hosted deploy waited past the Hub's request timeout, and a pong held for two ping intervals dropped the link and every allocation the sidecar hosts. Frames for one address are still handled one at a time in arrival order, but frames for different addresses no longer wait on each other. Pongs are handled as they arrive and probes run on their own. Sender-key refreshes and evictions, the sender keys run grants carry, and each inbound mail's signature check run on a lane of their own, in the order the Hub sent them, so a mail is checked against exactly the keys that arrived before it. The link now caches the keys run grants carry and tells the grants handler whether they landed. A workflow control that times out is reported as still initializing only while a deploy of its own address is unanswered.
Undeploy kept a deployment's local conversation copy, though only that deployment's child respawns and sidecar restarts read it. A sidecar shared by many deployments runs long, so every undeployed one left its copy behind. The documented undeploy steps also said the agent's key pair was left in place, though undeploy deletes the agent directory with the key pair in it.
A sidecar that failed part of an undeploy logged the failure and acknowledged the undeploy anyway, so the Hub took the deployment for gone while its child or local files could remain. A failed step also skipped every later one, leaving the deployment's address registered. The sidecar now runs every teardown step even when an earlier one fails and answers agent.undeploy.error naming each step that failed. An agent directory that is already gone is not a failure.
When the Hub stopped redelivering an unacknowledged mail, because its retries ran out or its retention expired, it reported the mail undelivered. A workflow dispatch row stands behind dispatched mail and decides what becomes of it: it delivers the mail again once the deployment is next ready, or is abandoned when the deployment ends. Reporting the mail undelivered as well could announce a loss that did not happen.
Redelivering an address's pending mail cancels each entry's retry timer and arms a new one, but a retry whose timer had already fired could still be waiting on its sender-key lookup. It then sent the mail a second time and took the entry's retry over from the redelivery. Each entry now names the retry loop that owns it, and a retry from a loop a redelivery replaced sends nothing.
A shared sidecar can outlive an allocation generation, so addresses alone cannot identify which deployment a frame belongs to. Stamp each incarnation with its generation and reconcile a bounded inventory before welcome confirms its routes, preserving the other work on the socket. The inventory needs a handshake that carries it, so hello and welcome replace register and reconnect, and the sidecar holds back anything the Hub must receive until welcome arrives. A deployment that stops stays held with its local state until the Hub undeploys it, so the inventory reports it instead of losing it. Undeploy is ordered by generation for the same reason: the Hub drops a deployment's routes before undeploying it, a later deploy of an address waits for the answer to an earlier undeploy, an undeploy runs even after its connection closed, and one for a superseded generation is answered without touching the newer copy. Mail with no dispatch row follows its deployment to the next routed generation. A deployment's outbound mail is signed and stamped through its held incarnation, so stop and undeploy refuse new sends and wait for those in flight before the incarnation is released. A token whose sidecar hosts nothing current still resolves, so its hello can report what it holds and the Hub can undeploy that before turning it away.
A stop or undeploy can remove a route while mail still awaits an acknowledgement. Apply the same retention as a disconnect, allowing a later route to recover it instead of announcing a premature loss. A retry with no route now reports mail without a dispatch row as undelivered, while dispatched mail is left to its durable row.
Losing the connection says nothing about the deployment's local write, so leave the push due for recovery without poisoning the next write. An explicit flush rejects while work remains blocked or was abandoned by teardown, rather than claiming that unsent history has drained.
A crash during undeploy needs a surviving record for the next boot to report, so the Hub can request cleanup again. Delete the run record after the agent directory and local state to preserve that recovery path.
Sharing sidecars is a provisioner decision, so the local-process provisioner now takes an optional share key and places work with the same key on one running sidecar, stopping it when its last allocation leaves. The e2e hub shares by tenant when E2E_SHARE_SIDECARS=tenant, and a spec checks that two deployments then run on one process.
A deployment's first push to a repository is resent once if it fails, to absorb the Hub creating the repository during the push, but every failure triggered the resend, including a push the Hub refused because it does not route the address. A rejected transfer now carries the receiver's reason, and only a corrupt reject, which that race produces, is resent.
The link queues what it owes the Hub while no connection is welcomed, and dropped the oldest frame once the queue was full, so a long outage could drop mail its sender had already been told was queued. The queue is shared by every deployment on the sidecar, so one busy deployment could cost another its mail. A full queue now drops correlation registrations, which the next welcome re-sends, and then audit copies of delivered mail, and fails a send when only mail still to be routed remains.
A hello reports every incarnation a sidecar holds and is refused past a bound, but nothing bounded how many deployments a provisioner could place on one sidecar. A sidecar past the bound could never register again, retrying forever with every deployment on it unroutable. The Hub now refuses to place a deployment on a sidecar that already hosts 128, and the sidecar refuses a deploy past them. The bound drops from 512 to 128 because a restarting sidecar restores every deployment it holds before it connects. A boot reads run records in name order and restores at most 128, leaving the rest unspawned and unreported for the Hub to fail. The same records are left out on every boot over an unchanged set.
An undeploy deleted the run record but left the deployment's workflow-run repository on disk, so on a sidecar that keeps running across deployments its data directory grew without bound. The repository store can now remove a repository together with what it keeps in memory about it, and undeploy removes a single-step deployment's grants repository and, as its last step, the run repository with the record inside it.
When the Hub fails a deployment's runs because it lost or gave up its capacity, the reason was kept only on the allocation, which is then released. The anchor run, and each run failed for that reason, now keep the failure code and message too, and the run view reports them once the failure no longer waits on the deployment's history.
A tool can leave read-only directories that make every cleanup retry fail. Restore directory write permission as the owning sidecar user before retrying removal, so undeploy can reclaim that scratch state.
A child that stopped or failed to restore could disappear from the sidecar's inventory and restart from its old record. Persist and report its stopped state, then reconcile its history before the Hub fails the runs and retains the copy for inspection. Bound the reported error and history tips so an untrusted sidecar cannot inflate what the Hub holds until it fails the deployment. A stop the Hub drives also marks the record stopped instead of removing it, and undeploy marks it tearing-down until every other step succeeds, so a restart reports what it holds rather than reviving a run the Hub ended. A reconnecting copy of a run the Hub failed is undeployed like a cancelled one, even when accepted history is still being projected and its status remains live. A restart that lost its mark may be running it. Serialize record updates and removal so a self-termination mark cannot race a source rotation or cleanup. Update only sources during rotation to preserve lifecycle state and sealed credentials, and reject updates to missing records or another generation. Records past the hello limit at boot stay unspawned and are reported as stopped after every welcome rather than going unreported, so the Hub fails them like any other stopped deployment. Keep unrestored addresses reserved until teardown succeeds, and reject undeploys for older generations before touching their local state. Suppress delayed stop reports once the copy they describe is gone.
A sidecar connects only once it has restored every deployment it holds, and it restored them one at a time, each child given up to 30 seconds to become ready, so a full sidecar could take over an hour to come back. Restoring 8 at a time bounds a full sidecar's ready timeouts to about 8 minutes in total. More at once compete for CPU and memory on a small host until children miss their ready timeout.
The Hub gave every disconnected sidecar two minutes to come back before it failed the sidecar's deployments, too short for a sidecar restoring many deployments and not something a tenant could choose. The limit is now maxDisconnected, next to maxLifetime in the lifecycle policy, inherited and capped the same way, with a 15-minute platform default. Each allocation records its deployment's limit. A Hub start restarts the clock: a deadline stored before the Hub went down used to fail a sidecar that had no chance to reconnect during the outage.
A first deploy that failed before its deploy frame was sent was retried at the capped backoff for as long as it kept failing, holding its capacity and leaving its run live with nothing running it. The Hub now records when such a deploy first failed and, a minute later, fails the deployment and releases its capacity like any other unrecoverable loss. Failures around a deployment that already runs keep being retried without a limit, as they say nothing about the deployment.
The test resumed a second run under the same run id while the first was still relaying, so the two shared a commit buffer and the first run's later commits could land inside the second.
A parked run read its log, then started tailing it for the signal. A signal committed in between moved the step in the log but never reached the tail, so the run waited forever. A restored run hits this when the signal arrives while it re-parks. Start the wait first, then read the log, and take a signal the log already gave the step.
2fe3a20 to
847c405
Compare
Why
The Hub reserved every sidecar for a single allocation. A sidecar credential authenticated exactly one probe or allocation, and every generation change or release closed the socket. A provisioner therefore had to start new capacity for every deployment, even when it already ran a sidecar that could host the work.
Whether work shares a sidecar is the provisioner's decision, not the Hub's. This PR lets a provisioner answer
ensurewith a sidecar it already runs. To make that safe, the sidecar link, mail, history pushes, and undeploy no longer assume one deployment per sidecar. The Hub also stops trying to recover a lost or missing deployment. A worker that misses its reconnect deadline may still be running, and the Hub cannot restore the files it holds. Starting the deployment again elsewhere could run it twice or lose that state, so the Hub fails the deployment and releases it instead.docs/SIDECAR_PLACEMENT.mdhas the provisioner contract.What this adds
Sidecar reuse in the provisioner contract.
EnsureSidecarResulttakes an optionalsidecarId. The Hub accepts that sidecar only while it still hosts another probe or allocation of the same provisioner binding, counting work that is being released or replaced. Otherwise it rejects the placement withsidecar_reuse_rejected.destroyreleases one allocation from the sidecar. The provisioner decides when to stop the sidecar, and it has to track which allocations run on which sidecar durably, because the Hub never re-announces them.One connection, many bindings. A sidecar credential now authenticates the sidecar for every binding it currently hosts. Each binding keeps its own generation fence. Releasing an allocation detaches and undeploys only that allocation, without closing the socket. Adopting a probe's sidecar no longer disconnects the probe.
hello/welcomehandshake.registerandreconnectare replaced byhello, which reports a bounded inventory of the incarnations the sidecar holds, each stamped with its generation. The sidecar holds back anything the Hub must receive untilwelcomearrives. Deploy and undeploy replies are matched by request id, with separateagent.deploy.errorandagent.undeploy.errorframes. Undeploys are ordered by generation, so an undeploy for a superseded generation never touches the newer copy.No Hub-side recovery of a running deployment. A lost worker is failed and released, not replaced. A connected sidecar that no longer holds a deployment has that deployment failed, not redeployed. Replacement remains only for provisioning, which has run nothing yet. Initialization now serves only first deploys. A workflow-run seed that would move existing history is refused as a conflict.
Route rules on a shared sidecar. A reconnecting sidecar gets a deployment's route back only if its first deploy completed and its run has not ended. A copy whose deploy is still uncertain is undeployed. A copy whose run ended on its own stays unrouted until the Hub releases it, so a reconnect does not cut its retention short. A copy of a run the Hub cancelled or failed is undeployed, since a restart that lost its stopped mark may be running that run again.
Self-terminated deployments. A child that stops or fails to restore is marked stopped in its run record and reported in a
deployment.stoppedframe. The Hub reconciles its history, then fails its runs and keeps the copy for inspection. A stop the Hub drives also marks the record stopped instead of removing it. Undeploy marks the record tearing-down and removes it last, so a crash in the middle of an undeploy still leaves a record for the next boot to report.Isolation between co-hosted deployments.
This does not isolate co-hosted deployments from each other. Every child still runs as the sidecar's OS user - so it's up to the provisioner plugin to properly advertise whether it shares the sidecar.
Per-address frame lanes. The sidecar used to handle every Hub frame on one serial queue, so one slow deploy could hold up every other deployment on the sidecar, including the heartbeat pong. Frames for one address are still handled in order, but different addresses no longer wait on each other. Pongs and probes run on their own. Sender-key updates and inbound signature checks share a separate ordered lane, so a mail is checked against exactly the keys that arrived before it.
Long-lived sidecar hygiene. Undeploy now cleans up everything the deployment left on the sidecar: its run repository, grants repository, local conversation copy, push state, and directories a tool left read-only. It runs every teardown step even when one fails, and answers with an error naming the failed steps. Pack transfer ids are random, so a late answer from before a restart cannot settle a new transfer. Shared closure-cache entries stay pinned while callers read them.
Mail and push reliability on a shared link.
Capacity limits. A sidecar hosts at most 128 deployments, the most its
hellocan report. The Hub refuses to place another on a full sidecar, and the sidecar refuses a deploy past the limit. At boot the sidecar restores 8 deployments at a time and restores at most 128. Records past the limit stay unspawned and are reported stopped after everywelcome.Lifecycle additions.
maxDisconnectedsits next tomaxLifetimein the lifecycle policy, with the same inheritance and caps, a15mdefault, and theWORKFLOW_DEFAULT_MAX_DISCONNECTEDenv var. It replaces the fixed 2-minute window, and a Hub start restarts the clock.failureCodeandfailureMessage.Removed dead paths. Removed:
sync.request, the undeploy's agent-state push (statePushedis gone fromagent.undeploy.ack), the Hub's unused session-address routes, and the disconnect queue. The remaining retention setting is renamed tomailHoldTTLMs.E2E coverage. The local-process provisioner can share processes between allocations. With
E2E_SHARE_SIDECARS=tenant, a spec checks that two deployments run on one process.Example: a provisioner that reuses sidecars
Every
ensureanswers with the one sidecar this provisioner runs. The firstensurestarts it, because the Hub mints a sidecar's identity and token per request. The provisioner stops the sidecar when its last allocation is destroyed, since the Hub only accepts a sidecar that still hosts other work.API changes
maxDisconnected, and the lifecycle status route returns it.failureCodeandfailureMessage.recovering: the Hub is replacing capacity whose provisioning failed before the deployment first ran.Database migrations
0101_sidecar_reusedropssidecar_allocation_active_sidecar_idx, which allowed only one active allocation per sidecar. It adds a partial index onworkflow_run.sidecar_id, because accepting a reused sidecar deletes the identity the Hub minted, and without the index each delete scans every run.0102_workflow_run_failure_reasonadds nullablefailure_codeandfailure_messagetoworkflow_run.0103_sidecar_allocation_max_disconnectedaddsmax_disconnected_mstosidecar_allocation. Existing rows are backfilled to the 15-minute default, and the column then becomesNOT NULLwith a check that it is> 0.0104_sidecar_allocation_first_deploy_failureadds nullablefirst_deploy_failed_attosidecar_allocation. It records when a first deploy first failed, so the 1-minute limit counts from the same point across Hub restarts.Reviewer notes
hello/welcome, typed deploy and undeploy replies, generation stamps). Hubs and sidecars from before this PR cannot talk to ones from after it.packages/hub-sessions/src/ws/sidecar-handler.ts,packages/hub-agent/src/ws/hub-link.ts,apps/sidecar/src/workflow-host-wiring.ts, andpackages/db/src/sidecar-allocation-store.ts. The drizzle snapshots inflate the line count by about 18k lines.a4d9c7023) and the reuse contract (5e4d4361f). The isolation, lane, and cleanup fixes follow, and the lifecycle limits are at the top.