Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
c3eb2f2
Certify a restarting deploy in a canary worker before it rolls out
dawsontoth Oct 2, 2026
3b6e367
Close the gaps the code review found in the canary rollout
dawsontoth Oct 2, 2026
81ed664
Remove the deploy-validation apparatus the canary retired
dawsontoth Oct 2, 2026
f2c35ac
Keep the boot-outcome and canary tests independent of what ran before…
dawsontoth Oct 2, 2026
4ccc06f
Check a peer activation's deploy permission against the project it ac…
dawsontoth Oct 2, 2026
b531a29
Admit a held start on its own load, and never on a certification nobo…
dawsontoth Oct 2, 2026
5324205
Stop every held start before refusing a release, and time out the one…
dawsontoth Oct 2, 2026
17eb945
Wait for every start a gate-suite plan releases together before assig…
dawsontoth Oct 2, 2026
2768bb3
Keep the running workers' watchers paused while a canary decides
dawsontoth Oct 2, 2026
58b5880
Let a thread still loading its components ignore a rollout's watcher …
dawsontoth Oct 2, 2026
1d672fd
Release the requesting worker when its certifying deploy fails, too
dawsontoth Oct 2, 2026
7a73d82
Remove the temp file when a certification record cannot be written
dawsontoth Oct 2, 2026
622f958
Attribute a certification record without its ownership sidecar when t…
dawsontoth Oct 2, 2026
4ba8dc6
Report what a component threw when it cannot be renamed, and give the…
dawsontoth Oct 2, 2026
9212955
Start no replacement while another release is armed, before every rep…
dawsontoth Oct 2, 2026
57825b2
Report an activation's failure, not a withdrawal that failed after it
dawsontoth Oct 2, 2026
5a63fae
Format the withdrawal's failure handler
dawsontoth Oct 2, 2026
17d3fc4
Interrupt a release whose canary was stopped for another release's re…
dawsontoth Oct 2, 2026
aef5a7e
Report an activation's failure past its commit, not a commit that fai…
dawsontoth Oct 2, 2026
7c5c2ea
Decide a stopped shared canary's other releases by what it reported, …
dawsontoth Oct 2, 2026
e8dee8f
Replace a worker again when another release's refusal stopped its rep…
dawsontoth Oct 2, 2026
ec00736
Report progress when a rollout replaces a worker again
dawsontoth Oct 2, 2026
21b3909
Let a decision already under way record before a declined rollout closes
dawsontoth Oct 2, 2026
71f850b
Test the shutdown close in a process of its own, and keep a start boo…
dawsontoth Oct 2, 2026
b4fd8bf
Log why a silent held start is stopped without deciding anything
dawsontoth Oct 2, 2026
f57c741
Stop a silent held start whose only release is already being decided
dawsontoth Oct 2, 2026
b737c1b
Merge remote-tracking branch 'origin/main' into deploy-canary-rollout…
dawsontoth Oct 5, 2026
7141199
Keep a refused decision answerable until its requester reads it
dawsontoth Oct 5, 2026
919b0a2
Let a requesting worker the rollout retires finish its deploy
dawsontoth Oct 5, 2026
9b08ae3
Fail the activation job for a peer that left the cluster before its turn
dawsontoth Oct 5, 2026
02ec58b
Refuse a worker whose load crossed a release's commit, before it binds
dawsontoth Oct 5, 2026
b003b6b
Make worker 0's replacement the canary
dawsontoth Oct 5, 2026
286a164
Record this round's certification rules in the design notes
dawsontoth Oct 5, 2026
d4b1479
Keep each release's unread decision, not one per component
dawsontoth Oct 5, 2026
d07e39a
Wait for admission only where main answers the load report
dawsontoth Oct 5, 2026
9fab7d7
Hold a requester's shutdown only while its release is armed
dawsontoth Oct 5, 2026
56c9055
Retire a requesting worker 0 at its turn, and start again a copy the …
dawsontoth Oct 5, 2026
854e07f
Start no copy while a decision about its release is under way
dawsontoth Oct 5, 2026
bd75da8
Read an unheld fixture worker's exit delay when its shutdown arrives
dawsontoth Oct 5, 2026
73d8ea2
Start a requester's copy only once it has exited, where they cannot s…
dawsontoth Oct 5, 2026
9fadc69
Wait out any worker still answering a certifying deploy before starti…
dawsontoth Oct 5, 2026
72ff73c
Describe an env declaration's load failure without rewriting what it …
dawsontoth Oct 5, 2026
f3705bd
Bound a requester's drain by the canary verdict timeout until its rel…
dawsontoth Oct 5, 2026
d7cf9cc
Keep a worker answering a certifying deploy out of the restart's thro…
dawsontoth Oct 5, 2026
126b24d
Register a deploy that joins an open decision, so it neither goes uns…
dawsontoth Oct 5, 2026
9749366
Count a worker's joins of a decision apart
dawsontoth Oct 5, 2026
d8ca4f3
Merge branch 'main' of https://github.com/HarperFast/harper into depl…
dawsontoth Oct 5, 2026
4f635e3
Size the main unit cap from this branch's slowest leg, which already …
dawsontoth Oct 5, 2026
73f4c66
Let a deployed tree's declared entry decide before the application lo…
dawsontoth Oct 5, 2026
cb8c87d
Fence a release whose certification record cannot be read while main'…
dawsontoth Oct 5, 2026
39b8614
Install from root config over a deployed tree whose record cannot be …
dawsontoth Oct 5, 2026
07bb1b1
Refuse to certify a live release again while its certification record…
dawsontoth Oct 5, 2026
eeb6b46
Keep a decided deploy from being cut off, throttle workers only a que…
dawsontoth Oct 5, 2026
86ff43f
Defer a worker whose release was decided while its replacement booted…
dawsontoth Oct 5, 2026
973e7db
Skip shutting down a worker that has already exited, since its exit w…
dawsontoth Oct 5, 2026
475fbe6
Pick worker 0 among HTTP workers, identify a linked tree by its targe…
dawsontoth Oct 5, 2026
53334eb
Refuse to re-arm a release whose decision is still being read, and re…
dawsontoth Oct 5, 2026
02ce6f7
Start a slot again only once a predecessor retired at admission has e…
dawsontoth Oct 6, 2026
bcc1bf9
Merge the two JSDoc comments on join into one
dawsontoth Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,10 @@ jobs:
# from the Actions step API: a cap-killed sample is right-censored and reads far too low.
# Derived 2026-09-18 over the 30 `Unit Test` pushes since 2026-09-14, and only `resources`
# was re-budgeted — capped at 7m off a ~4m30-5m25 estimate nobody re-measured, it reached
# 6m58 completed, went red on main, and needed ~7m40 at its worst. `main` (3m19) sits at ~1.2x
# its own worst, not 1.5x. `lmdb` was re-derived 2026-09-28 over the pushes since 2026-09-26:
# 6m58 completed, went red on main, and needed ~7m40 at its worst. `main` was re-derived
# 2026-10-02: the eight pushes since 2026-10-01 took 3m12-3m59 against its 4m cap, and
# harper#2981's legs reached mocha's epilogue at 4m02-4m12 before the cap killed them, so 6m.
# `lmdb` was re-derived 2026-09-28 over the pushes since 2026-09-26:
# slowest completed 3m38, ~3m20 once `subscriptionSuperseded` stopped replaying the shared
# `test` database's whole audit log; 1.5x is 5m, and the legs killed at 4m ran longer than any
# that completed, so 6m. Its LMDB-`resources` phase stretches on a slow runner in tests that
Expand All @@ -112,7 +114,7 @@ jobs:
# core 4m50 -> 8m, indexes 2m50 -> 5m.
- name: 'Unit tests: main'
if: "!cancelled() && steps.setup-harper.outcome == 'success'"
timeout-minutes: 4
timeout-minutes: 6
run: npm run test:unit:main

- name: 'Unit tests: apitests'
Expand Down
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ Index of the design notes for the harper core: one line per note, grouped by the

## components/ — deploys and the load lifecycle

- [A deploy builds off to the side, is validated, and only then goes live](components/DESIGN.md#a-deploy-builds-off-to-the-side-is-validated-and-only-then-goes-live) — A deploy builds in `.deploy-staging`, validates that tree, then swaps live to `.deploy-aside` and the candidate in, waiting out handle holders with the old version in place; the component's root-config entry is journaled with the activation and published durably, under one lock, only after the swap commits; the release it displaces goes back under its own deployment id, which `deployment_id` activates again, and activating the id already live answers without a swap.
- [Component preparation is serialized across worker threads](components/DESIGN.md#component-preparation-is-serialized-across-worker-threads) — One component transaction at a time across workers; the lifecycle broadcast sits outside the lock; a deploy's own validation load never waits on a deploy; the load lock is keyed by application and plugin.
- [A deploy builds off to the side, is certified, and only then goes live](components/DESIGN.md#a-deploy-builds-off-to-the-side-is-certified-and-only-then-goes-live) — A deploy builds in `.deploy-staging`, certifies that tree, then swaps live to `.deploy-aside` and the candidate in, waiting out handle holders with the old version in place; the component's root-config entry is journaled with the activation and published durably, under one lock, only after the swap commits; a deploy that restarts workers is decided by a canary worker held out of traffic until it reports its load, and a rejection restores the release it replaced or fails the component closed; the release a swap displaces goes back under its own deployment id, which `deployment_id` activates again, and activating the id already live answers without a swap.
- [Component preparation is serialized across worker threads](components/DESIGN.md#component-preparation-is-serialized-across-worker-threads) — One component transaction at a time across workers; the lifecycle broadcast sits outside the lock; the load lock is keyed by application and plugin.
- [Peer-side deploy_component payload read: retryable blob stalls and `Readable.from()` cancellation](components/DESIGN.md#peer-side-deploy_component-payload-read-retryable-blob-stalls-and-readablefrom-cancellation) — Retry a `BLOB_UNAVAILABLE` read only before any byte reached the consumer; use `Readable.from()` for cancellation, not a hand-rolled `ReadableStream`.
- [An origin waits for a peer's deploy answer only as long as the peer may take](components/DESIGN.md#an-origin-waits-for-a-peers-deploy-answer-only-as-long-as-the-peer-may-take) — The replicated deploy's per-peer deadline sums the peer's own allowances, so it never undercuts a healthy peer; a missed deadline is a failure with an unknown outcome, not cancellation.
- [A replicated deploy reports each node's install fingerprint, and never acts on it](components/DESIGN.md#a-replicated-deploy-reports-each-nodes-install-fingerprint-and-never-acts-on-it) — Each node records the source identity its resolver already produced and the sha256 of its root lockfiles; the origin compares peers once on the aggregate and reports differences in a warning, the message and `peer_results`, without ever failing the deploy.
Expand Down
81 changes: 80 additions & 1 deletion bin/restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const ISOLATED_TOPOLOGY_REQUEST_TIMEOUT_MS = 5000;

let calledFromCli;

export { restart, restartService };
export { restart, restartService, activateDeploymentOnPeers };

// Add ITC event listener to main thread which will be called from child that receives restart request.
if (isMainThread) {
Expand Down Expand Up @@ -183,6 +183,72 @@ async function restart(req: any) {
return RESTART_RESPONSE;
}

const DEPLOYMENT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;

/**
* A certified rolling deploy's second half: the release is staged on every peer, and this activates it on one peer
* at a time, each certifying it with its own restart. Every peer is visited — each decides for itself — and the job
* fails at the end naming each one that did not take it.
*/
async function activateDeploymentOnPeers(activation: any) {
const { project, deployment_id: deploymentId, deployment_row: deploymentRow, nodes } = activation ?? {};
if (
typeof project !== 'string' ||
!DEPLOYMENT_ID_PATTERN.test(deploymentId) ||
!DEPLOYMENT_ID_PATTERN.test(deploymentRow) ||
(nodes !== undefined && (!Array.isArray(nodes) || nodes.some((node) => typeof node !== 'string')))
) {
throw handleHDBError(
new Error(),
'Invalid activate_deployment: expected project, deployment_id, deployment_row and an optional list of nodes',
HTTP_STATUS_CODES.BAD_REQUEST,
undefined,
undefined,
Comment thread
dawsontoth marked this conversation as resolved.
true
);
}
const peers = ((global as any).server.nodes ?? []).filter(
Comment thread
dawsontoth marked this conversation as resolved.
Outdated
(node) => node.name !== getThisNodeName() && (!nodes || nodes.includes(node.name))
);
const results = [];
if (peers.length === 0) return { activated: results };
const replication = (global as any).server.replication;
replication.monitorNodeCAs();
const { peerDeployAnswerTimeoutMs } = await import('../components/operations.js');
const timeoutMs = peerDeployAnswerTimeoutMs({ restart: true });
for (const node of peers) {
try {
const response = await replication.sendOperationToNode(
node,
{
operation: 'deploy_component',
project,
deployment_id: deploymentId,
_deploymentId: deploymentRow,
restart: true,
replicated: false,
},
{ timeoutMs }
);
const answer = response?.value ?? response?.body ?? response;
results.push({ node: node.name, certification: answer?.certification, message: answer?.message });
} catch (error) {
results.push({ node: node.name, error: error?.message ?? String(error) });
}
}
const failed = results.filter((result) => result.error);
if (failed.length > 0) {
const error: any = new Error(
`Deployment ${deploymentId} of ${project} was not activated on ${failed.length} of ${results.length} peer ` +
`node(s): ${failed.map((result) => `${result.node} (${result.error})`).join('; ')}`
);
// What a failed job records as its message, so get_job keeps every peer's outcome.
error.http_resp_msg = { error: error.message, activated: results };
throw error;
}
return { activated: results };
}

/**
* Used to restart a particular service, services includes - httpWorkers
* @param req
Expand Down Expand Up @@ -236,6 +302,19 @@ async function restartService(req: any) {
);
}
}
if (req.activate_deployment !== undefined) {
if (isMainThread) {
throw handleHDBError(
new Error(),
'activate_deployment runs as a job',
HTTP_STATUS_CODES.BAD_REQUEST,
undefined,
undefined,
true
);
}
return activateDeploymentOnPeers(req.activate_deployment);
}
processMan.expectedRestartOfChildren();
if (!isMainThread) {
if (req.replicated) {
Expand Down
Loading
Loading