Repository navigation
Description
Deployment tracking, replicated payload delivery, and rollback
This plan becomes the body of a new issue in HarperFast/harper. Once approved, we'll pause the four in-flight PRs (#530, #531, #536, harper-pro #146) and rebuild the deploy_component story around a replicated hdb_deployment system table that doubles as the payload-delivery channel.
Context
The original deploy_component effort split into four PRs solving four problems:
| In-flight PR | What it does |
|---|---|
| #530 | Multipart-streaming upload from CLI → origin (no 2 GB Buffer cap) |
| #531 | SSE-based progress events from server back to CLI |
| #536 | Staging the upload to a temp file on origin so peers can also receive it |
| harper-pro #146 | Direct-HTTPS peer relay because the WS operation transport has a frame cap |
The user's two insights collapse this story into a single coherent design:
- The deploy_component handler should write a persistent record from the very first byte — so Studio (or any client) can observe and audit deploys independent of the CLI, errors are queryable after the fact, and rollback has a target to roll back to.
- If we're already storing the payload as a blob attribute on that record, we shouldn't need a separate staging file or a separate peer-relay channel — Harper's replication (
replication/replicationConnection.ts) already supports streamed, chunked, back-pressured blob transfer (theBLOB_CHUNK = 146message type, sender at lines 1840–1896, receiver at lines 788–858). The 100 MB cap is per chunk, not per blob, so multi-GB payloads work today. Update/upsert records should use patch #146's direct-HTTPS relay was solving a problem Harper had already solved elsewhere.
So this issue isn't "tracking on top of the deploy stack" — it's the new spine, and the staging/relay PRs go away.
Design
1. New replicated system table: hdb_deployment
| Attribute | Type | Notes |
|---|---|---|
deployment_id |
string (UUID v4) | Hash attribute. Returned synchronously to the caller. |
project |
string | Component name. Indexed. |
package_identifier |
string | null | Result of derivePackageIdentifier() (Application.ts:446); null for raw-payload deploys. |
payload_hash |
string | null | SHA-256 of the tarball, computed once on origin during ingest. |
payload_size |
number | null | Uncompressed bytes (precomputed by getPackagedDirectorySize). |
payload_blob |
Blob | null | The tarball itself. Always populated on initial create so peers can replicate it. Pruned per-node after success per §3. |
status |
enum | pending → extracting → installing → loading → replicating → restarting → success | failed | rolled_back |
phase |
string | Current phase label, mirrors ProgressEmitter phase events. |
event_log |
object[] | Bounded JSON array (~200 entries) of {t, event, data}. Includes phase transitions and install summary; raw stdout/stderr aggregated. |
peer_results |
object[] | [{node, status, error?, started_at, completed_at}] populated after replication settles. |
origin_node |
string | Hostname of the coordinating node. |
restart_mode |
string | null | immediate / rolling / null. |
started_at, completed_at |
timestamp | Lifecycle bookends. |
user |
string | hdb_user.username of the requester. |
restorable |
boolean | Computed: payload_blob != null || package_identifier resolvable now. |
rollback_of |
string | null | Points at the prior deployment_id this row restored, when applicable. |
error |
object | null | On failure: {message, code, phase, stack?}. |
__createdtime__, __updatedtime__ |
auto | Standard LMDB metadata. |
Secondary indexes: project, status, started_at, payload_hash. Registered via the standard pattern in utility/hdbTerms.ts (SYSTEM_TABLE_NAMES) with an upgrade directive in upgrade/directivesManager.ts.
2. Lifecycle and how delivery works now
CLI deploy_component
│ multipart upload (busboy parser from #530 — kept)
▼
Origin Fastify handler
│ start writing row: status=pending, deployment_id=<new UUID>
│ pipe multipart file part directly into row.payload_blob.write()
│ emit SSE event `deployment_id` so the CLI/Studio can subscribe
│ ...as bytes flow, ProgressEmitter emits `upload` events
│ when the file part ends:
│ sha256 finalized → row.payload_hash set
│ row committed → replication ships row + chunked BLOB_CHUNKs to peers
▼
Origin operations.js deployComponent
│ status=extracting → application = new Application({payload: row.payload_blob.stream()})
│ status=installing → installApplication runs, emitter forwards lines
│ status=loading
│ status=replicating → replicateOperation({operation: deploy_component, deployment_id})
│ ↓
│ Peer ops handler receives operation
│ ↓
│ Peer reads `hdb_deployment[deployment_id]` (replicated row)
│ Peer reads row.payload_blob.stream() — awaits chunks if still arriving
│ Peer extracts, installs, loads, reports back
│ recorder.recordPeers(result.replicated) → peer_results array
│ status=restarting → restart_mode applied
▼
status=success (or status=failed in catch with error{})
│ always in a finally: recorder closes the row, ends SSE
▼
storageReclamation tick (per-node, async)
│ prunes payload_blob on rows where size > local threshold (§3)
The ProgressEmitter from #531 stays, but it now has two subscribers: the SSE writer (live to CLI) and the DeploymentRecorder (persistent). The recorder coalesces writes — every phase transition flushes immediately; install stdout/stderr aggregates every ~500 ms or 50 lines to avoid write storms.
No more _stagedPayloadPath. No more relayDeployToNode. The replicated row + blob does both jobs.
3. Payload retention: per-node, disk-pressure driven
Per the user's direction:
- The blob is always stored at create time (every node, no size limit) — this is required for delivery.
- After a deploy reaches a terminal state (
success,failed,rolled_back), each node independently considers pruning its local copy ofpayload_blob. - Pruning hooks into the existing
server/storageReclamation.tsonStorageReclamation()mechanism. This is already non-replicating — a node drops its local blob without that drop propagating, which is exactly the semantics we want: different nodes may keep different deploys' payloads based on local disk pressure. - The reclamation handler ranks candidates by
(size DESC, age DESC, success_age DESC)and drops blobs above a soft local floor (default50 MiB, configurable asdeployments.localBlobSoftCap) first; smaller blobs are kept longer. - No advertised "we always keep N" guarantee — a payload is
restorableif (it still has a local blob on the node serving the rollback) OR (package_identifieris still resolvable). Studio can show "payload available on M of N nodes" by querying each peer. - No
payloadRetentionCountknob (the previous draft proposed one — the disk-pressure model supersedes it).
A delete_deployment_payload operation remains, for explicit admin pruning.
4. Rollback semantics
rollback_deployment {deployment_id}:
- Loads the row from any node. If
restorable === falsecluster-wide → 4xx. - Constructs a synthetic
deploy_componentrequest internally:- Blob path: re-reads
payload_blob.stream()from whichever node has it (replication will pull if missing locally, since the deploy_component handler runs on the node it was called on and that node already replicated to its peers when the original deploy ran — see §6 caveat). - Reference path: re-resolves
package_identifier.
- Blob path: re-reads
- Creates a new row,
rollback_of = <source deployment_id>, runs the deploy lifecycle as normal.
Rollbacks are first-class deploys — they replicate, they show up in list_deployments, they themselves can be rolled back later.
5. Operations API
All gated by the existing permission model: super_user OR the operation named in the role's allowed operations list (confirmed by user — no new role needed). Five operations total; rollback folds into deploy_component, and subscribe folds into get_deployment via content negotiation.
| Operation | Args | Behavior |
|---|---|---|
deploy_component |
existing args + optional rollback_from?: deployment_id |
When rollback_from is set, skip the multipart upload: source the payload from the referenced row's payload_blob (or re-resolve its package_identifier if the blob is pruned locally). Run through the standard lifecycle. New row gets rollback_of = rollback_from. SSE behavior unchanged. |
list_deployments |
{project?, status?, since?, until?, limit?, offset?} |
{deployments, total}, newest first. |
get_deployment |
{deployment_id} |
Content-negotiated. Accept: application/json (default) → full record. Accept: text/event-stream → SSE: replays event_log on connect, then tails live ProgressEmitter events until terminal state, then done. For already-terminal deploys the replay closes immediately. Added to SSE_PROGRESS_OPERATIONS so the existing branch in server/serverHelpers/serverHandlers.js handles the switch. |
get_deployment_payload |
{deployment_id} |
Streams the tarball from the local row's payload_blob. 404 if pruned locally; client retries another node. |
delete_deployment_payload |
{deployment_id} |
Nulls the local blob. Does not replicate the deletion — per-node, like the auto-pruner. |
subscribe_deployment and rollback_deployment are intentionally absent.
6. Race condition: peer reads before blob arrives
When the origin calls replicateOperation(deploy_component, {deployment_id}), two things race to each peer: the operation message and the row+blob chunks. The peer's handler must await row.payload_blob.stream() (or .bytes()) — the existing Blob API already blocks on incomplete file writes (resources/blob.ts bytes() / stream()).
Harper's replication already has idle-based timeout for in-flight blobs: a BLOB_CHUNK stream is destroyed after ~60 s of no chunks arriving (replication/replicationConnection.ts:2255-2260). That's the correct shape — a 2 GB blob over a slow link should not time out as long as bytes are flowing.
We surface that existing knob as deployments.peerReceiveIdleTimeoutMs (default 60 s). When the underlying blob stream is destroyed by the idle watchdog, row.payload_blob.stream() rejects, the peer records {status: 'failed', error: 'blob stream idle timeout'} in peer_results, and origin marks the deploy failed if every peer's blob never finished arriving. No new wall-clock timeout layered on top.
Implementation slices
The four existing PRs collapse into three new slices. We pause #530, #531, #536, harper-pro #146 (move to draft, leave a comment linking to the new issue). The CLI-side work in #530 and #531 (multipart-builder, sseConsumer, deployRenderer) survives largely unchanged; the server-side rebuild happens here.
Slice A — Foundation: schema + blob-backed multipart receive
- Add
hdb_deploymenttoSYSTEM_TABLE_NAMES, upgrade directive to create on existing installs. - Server-side multipart handler (server/serverHelpers/multipartParser.ts — kept from feat(deploy): stream deploy_component as multipart/form-data #530) is rewired to write the file part directly into a new
hdb_deploymentrow'spayload_blobattribute, computing sha256 alongside. - Add
_deploymentIdtoreq. Return the ID as the first SSE event so the CLI prints it and so Studio can subscribe. deployComponentreads the payload fromrow.payload_blob.stream()instead of from the request body. (Single code path; no_stagedPayloadPathbranch.)list_deployments,get_deploymentoperations.- Tests: deploy succeeds end-to-end on a single node; row populated with hash, size, status=success.
Slice B — Lifecycle, recorder, replicated peer deploys
DeploymentRecorderclass subscribes toProgressEmitter, writes lifecycle transitions + boundedevent_log.- Peer-side
deployComponenthandler readshdb_deployment[deployment_id]and streams payload fromrow.payload_blob.replicateOperationkeeps the same signature; the operation body carries just{deployment_id}plus existing flags. - Origin gathers
peer_resultsfromreplicateOperationreturn. get_deploymentbecomes content-negotiated: add it toSSE_PROGRESS_OPERATIONS; onAccept: text/event-streamreplayevent_logthen tail emitter events.delete_deployment_payloadoperation.- 3-node cluster integration test: deploy from A, observe row+blob replication, observe
peer_results, query history from B, attachget_deploymentSSE from C mid-deploy and confirm replay+tail.
Slice C — Rollback, reclamation, retention
- Hook into
onStorageReclamation(server/storageReclamation.ts) with a handler that prunespayload_blobon terminal-state rows; ranks by size + age. - Config:
deployments.localBlobSoftCap(default 50 MiB),deployments.peerReceiveIdleTimeoutMs(default 60 s, surfacing the existing blob-stream idle threshold). - Extend
deploy_componentto acceptrollback_from?: deployment_id. Two source paths (blob, reference). New row written withrollback_ofset. restorablecomputed on read (joins local blob presence + package-identifier resolvability check, lazy).- Tests: roll back a small (blob-backed) deploy via
deploy_component {rollback_from}; roll back a large (pruned, package-identifier-only) deploy; refuse a rollback when neither source is available; reclamation drops large blobs first under simulated disk pressure.
The CLI-side rendering work (deployRenderer.ts, sseConsumer.ts, multipart-builder, getPackagedDirectorySize) lifts from #530/#531 essentially unchanged and lands within Slice A.
Critical files / integration points
- utility/hdbTerms.ts — add table constant + operation enum entries
- bin/upgrade.js + upgrade/directivesManager.ts — upgrade migration
- server/serverHelpers/multipartParser.ts — rewire to write into
payload_blob; survives from feat(deploy): stream deploy_component as multipart/form-data #530 - server/serverHelpers/progressEmitter.ts — gains a second subscriber (the recorder)
- server/serverHelpers/serverHandlers.js — SSE branch updated to surface
deployment_idas the first event - components/operations.js —
deployComponentreads payload from the replicated row; new ops dispatched - components/Application.ts — accepts a
Readablefrom a Blob stream (already true after feat(deploy): stream deploy_component as multipart/form-data #530);derivePackageIdentifier()reused unchanged - replication/replicationConnection.ts — no changes; we're using
BLOB_CHUNKas-is - resources/blob.ts — used as-is for both storage and the await-on-read semantics that peers depend on
- server/storageReclamation.ts — new handler registered for blob pruning
- New file:
components/deploymentRecorder.ts
Open items to call out in the issue
- Closing the existing PRs: feat(deploy): live SSE progress for deploy_component #531, feat(deploy): stage streamed payloads to a temp file for replication #536, harper-pro Update/upsert records should use patch #146 close in favor of Slice B. feat(deploy): stream deploy_component as multipart/form-data #530's CLI-side bits get cherry-picked into Slice A; its server-side multipart handler is rewired (not replaced).
- Authorization on payload reads:
get_deployment_payloadfor a previous deploy could expose source code to a user withlist_deploymentspermission. Should we gate it behind a stricterread_deployment_payloadop? (Plan defaults to: same permission asdeploy_component.) - Peer-receive idle timeout default: 60 s mirrors Harper's current blob-stream idle threshold; verify that's still adequate for cross-region replicas on a 1 GB payload.
get_deploymentSSE for already-terminal deploys: replayevent_log+ close immediately. Studio benefits from fetching the full event timeline of historical deploys via the same channel as live ones.deploy_component {rollback_from}arg validation: ifrollback_fromis set, the multipart body MUST be absent. Rejecting both-supplied early prevents a class of confusing failures.
Verification
- Slice A: deploy a small component locally;
list_deploymentsshows the row withstatus=success,payload_hashmatches,payload_blobretrievable viaget_deployment_payload. - Slice B: 3-node cluster; deploy from node A;
peer_resultspopulated for B and C; while a slow deploy is in flight,subscribe_deploymentfrom a second client receives the same events the CLI receives, then receives a finaldone. Kill the CLI mid-deploy; row reaches a terminal state regardless. - Slice C: rollback a blob-backed deploy, hash matches the original; rollback an
npm:deploy after blob pruning, package re-resolves; simulate disk pressure (fill the data dir, triggerrunReclamationHandlers), confirm the largest oldest blobs go first and smaller ones remain. - End-to-end: deploy a 2 GB tarball to a 3-node cluster; observe
BLOB_CHUNKtraffic in replication logs; confirm peers complete extraction after the blob finishes streaming; confirmlocalBlobSoftCaptriggers post-success pruning on every peer.
Should (partially) address #564
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
docs(design): document blob/put streaming race + system-table bootstr…
feat(deploy): Slice B1 — live SSE progress + event_log on hdb_deployment
feat(deploy): hdb_deployment system table + audit record on every deploy
docs(design): document blob/put streaming race + system-table bootstr…
feat(deploy): Slice B1 — live SSE progress + event_log on hdb_deployment
kriszyp commentedon May 23, 2026
Slice B2 tracked in #758 (peer-side blob reads + install-line streaming). Slice A (#655) and Slice B1 (#657) are merged; #531 closed in favor of this redesign.
feat(deploy): per-peer real-time tracking + REPLICATION_DATABASES fal…
fix(RecordEncoder): retain blob files still referenced by the new rec…
fix(RecordEncoder): retain blob files still referenced by the new rec…
test(cluster): multi-node deployment-tracking integration test (Slice…
feat(deploy): Slice B2 — peer-side blob reads + install-line streaming
kriszyp commentedon Jul 6, 2026
Concrete exhibit for the rollback dimension of this issue, from exploratory QA (harper
228eacc0f): a faileddeploy_componentdeletes the currently-serving component — a bad deploy takes down the running app.extractApplication(components/Application.ts:259-312) renames the existing component dir into.deploy-aside/<project>/before extracting the new tarball, with no restore-on-failure path (rollback is a stated future feature). Directly observed with a broken payload (invalid tar) over a working v1:[config.yaml, node_modules, web]→[]immediately after the failed deploy;.deploy-aside/qa474-atomicity-app/;ENOENTon.../web(static-file resolution reads disk per-request);restart_service http_workers, the route returned 404 — the component is gone, not "old code still serving."The error signal itself is fine (HTTP 500
{error:"Invalid tar header...", phase:"prepare", deployment_id},get_deployment→status:failed) — the problem is the torn state. Fix: restore the.deploy-aside/dir on extraction failure.Repro: local
qa-scratch/qa474-deploy-blob-reclaim.test.ts.30 remaining items
_recorder_finishedevent leaks intoevent_logand SSE streams #3062dawsontoth commentedon Oct 7, 2026
Multipart streaming upload path (#530): #3069 merged for v5.4. When the operations API answers a multipart request without reading its file part, Harper now discards the rest of the upload without parsing it. That covers a
deploy_componentrefusal before the payload is read (validation,isolatedon a payload deploy, isolation admission) and a missing or unknownoperation. The request completes and the connection can serve the next one; an upload still arriving a second after its response is cut off. Before this, a client that kept sending (curl does; the CLI exits on the error) stalled until a timeout.The one case left, a route whose
.pipe()loses its destination after answering, is #3096.dawsontoth commentedon Oct 14, 2026
CI deploys landed for v5.4.
harper deploy setup=true provider=github-actions(#3127) sets up OIDC trust for a workflow, and create-harper projects now deploy on merge tomainwithrestart=rolling(HarperFast/create-harper#147, documented in HarperFast/documentation#722). Their workflow waits for the rollout with its ownget_jobloop, which #3058 and #3060 would replace.🤖 Posted by Claude Code
dawsontoth commentedon Oct 28, 2026
Status as of 2026-10-28: the v5.4 deployment-tracking follow-ups have merged.
Merged
harper deploy restart=rollingexits 0 before the other nodes have taken the release #3058, Report which deployment is live on each node, and which releases each node keeps #3059, A rolling deploy's record sayssuccesseven when another node rejects the release #3060, Record where a deploy came from: the commit, the CI run and the trust policy #3061, The deployment recorder's internal_recorder_finishedevent leaks intoevent_logand SSE streams #3062 and Discard the rest of a multipart upload whose route's pipe loses its destination after the response #3096. A rolling deploy now records each node's outcome on the replicated deployment row, andharper deploy restart=rollingwaits onget_deploymentuntil every node reports. Each deploy records where it came from (commit, CI run and trust policy), plus deploy and restart times per application.list_releasesreports what is live on each node and which releases that node keeps. Failed payloads expire, and newerhdb_deploymentattributes are registered on upgraded installs. Document deploy tracking for 5.4: rollout records, the CLI wait, and list_releases documentation#751 (merged 10-28) documents all of it.harper deploy by_ref=true— deploy by git reference #1777 (shipped in feat(cli):harper deploy by_ref=true— deploy by git+https reference (+ credential) #1850), unhandledRejection duplicates the deploy_component 422 when the ingest write transaction times out #2056 (a forced ingest abort now gives one 422 and no unhandled rejection, on both engines) and Replicated redeploy of a static component: new pages 404 on a 5.1.19 cluster until a full cluster restart #1935 (didn't reproduce on a two-node harper-pro cluster, 9 of 9 runs).Still open
redeploy-restart-flag-deletion). Whether to make it fatal is still undecided; the change itself is one line.restart_serviceon core alone endsERROR).Gaps #3502 left, now filed as sub-issues
restart=rollingdeploy recordssuccessbefore the other nodes restart, and the CLI doesn't wait #3716 (P2): an uncertifiedrestart=rollingdeploy recordssuccessbefore the other nodes restart, and the CLI doesn't wait. Rolling restart job reports COMPLETE when a node was never restarted; failed job insert surfaces as "Empty job passed to parseMessage" #3468, the rolling restart job reportingCOMPLETEtoo early, is the job-side half.source#3717 (P3): a payload deploy's install fingerprint has nosource.list_releasesreports a release displaced before 5.4 asstaged#3719 (P3):list_releasesreports a release displaced before 5.4 asstaged.🤖 Posted by Claude Code
restart=rollingdeploy recordssuccessbefore the other nodes restart, and the CLI doesn't wait #3716source#3717list_releasesreports a release displaced before 5.4 asstaged#3719deployment_idis refused (409) for any kept release that loaded apackage:component #3748