Document measured update-status behavior from live cluster updates (single-node and 4-node) - #33
Conversation
Follow-up to the review on #32. That PR corrected the update-status check to fail closed, but the guidance was still partly inferred: the review asked for verification on a live cluster during an update, and noted that a cluster with no update history is a separate case needing a brand-new cluster to observe. Both have now been measured end to end across a real 9.8.3 -> 9.8.4 update on a single-node cluster, sampled every 5 seconds from before the update started until it settled. The docs are updated to match what was observed: 1. "The API is effectively read-only" understated it. During apply, every /rest/v1/ endpoint tried (Cluster, Node, Drive, Condition, VirDomain, Update) returned a read timeout for minutes at a stretch -- reads fail too, and the failure is a HANG, not a refusal and not a 503: the connection is accepted and the backend never answers. So clients must set an explicit read timeout or they block indefinitely. update_status.json kept returning 200 throughout, because it is served as a static file independently of the REST backend -- which is why it, and no REST endpoint, is the progress channel. 2. Adds the observed state machine, and the reason both fields are mandatory. During prepare, updateStatus contains only percent and status -- masterState does not exist, so a masterState-only check reads None, which is falsy, and reports idle while packages are downloading. During apply, prepareStatus.state is already back to COMPLETE, so a prepareStatus-only check reports idle mid-update. masterState also has two in-progress values (EXECUTING and IN PROGRESS), so test != "COMPLETE" rather than matching a name. 3. A cluster that has never updated returns HTTP 404 with an HTML error body, so .json() raises -- there is no empty-JSON case, and parsing before checking the status code throws. Observed on three never-updated clusters running three different builds (9.6.32, 9.7.8, 9.8.3), so it tracks never having updated rather than the software version. 4. Progress should be driven off percent/currentComponent, which advanced monotonically with no decreases in any sample. statusdetails is display text and at least one placeholder value recurs at several percentages. 5. /rest/v1/Condition looks like a better answer -- there is a first-class condition.updateInProgress flag, true during prepare -- but it is a REST endpoint and stops answering during apply, exactly when it is needed. Also notes that Condition returns the full catalogue of every possible condition on every call, each with a boolean value, so presence carries no information. 6. POST /rest/v1/Update/{uuid}/apply returns 200 with an empty taskTag, so there is no task to poll and Rule 1's task-tag wait does not apply. Also qualifies the HyperCoreDynamicBalancer citation. It reads the correct two fields and does node failover, and it is correct across the sequence above -- but its guards are written as `if state and state != "COMPLETE"`, so an absent field reads as idle, and it cannot distinguish a never-updated cluster from every node being unreachable mid-update. Both are now called out so the pattern is not lifted unexamined. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d connection
Watched the node reboot phase of the same update. The single most practically
important finding, and it changes what client code has to catch.
Across one update the same client hitting the same cluster saw four distinct
failure shapes:
never updated update_status.json -> HTTP 404 + HTML body
apply, backend busy 200 on update_status.json, /rest/v1/* read timeout
(connection accepted, backend never answers)
node tearing down TLS error, ~100 s
node fully down read timeout, ~35 s
back up, backend starting 200 on update_status.json, /rest/v1/* HTTP 502 + HTML
The reboot alone moved through TWO shapes in sequence -- TLS error then read
timeout -- and took ~2m20s before the file answered again. At no point was the
error a refused connection, which is the one most people code for.
Three consequences now documented:
1. Catching only timeouts is a bug. requests.exceptions.SSLError subclasses
ConnectionError, NOT Timeout, so `except Timeout` lets the reboot through and
the caller crashes. Verified against the requests exception hierarchy. With
curl the same moment is exit 35 (SSL connect error), not 7; once the host is
fully down it becomes exit 28.
2. Check the status code before parsing. Both the 404 and the 502 return HTML,
so r.json() raises a decode error instead of yielding something inspectable
-- code shaped like r.json().get("prepareStatus", {}) throws rather than
failing closed.
3. A 502 means "ask again later", never "idle". It occupies the window where the
front-end web server is up but the REST backend has not finished starting --
a genuinely mid-update state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The update-detection guidance said /rest/v1/Condition dies with the API during apply. It fails at the other end too: condition.updateInProgress was still true about 17 s after masterState had already gone COMPLETE, clearing roughly half a minute later. A client gating writes on it would keep refusing to write after the update had finished. Measured on the same run as the rest of this PR, which completed 9.8.3 -> 9.8.4 in just over 30 minutes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Watched a 4-node cluster with running VMs upgrade 9.6.30 -> 9.6.32 (3.5 h), alongside the single-node 9.8.3 -> 9.8.4 run already in this PR (30 min). The 4-node run was done specifically to test whether the single-node total blackout was an artifact of having no peer to answer. It largely was. Scope correction to what this PR previously said: - "the REST API becomes entirely unavailable" -> "the updating NODE's REST API". On multi-node it is a rolling outage: one node at a time for ~8-10 minutes, peers serving both channels normally. 314 sample rounds had a healthy peer while another node was down; 2 had none. All four nodes followed the same pattern, and nodes on the new version served alongside nodes still on the old. - update_status.json is no longer described as simply surviving. It is more available than REST, not guaranteed: it goes away when its own node reboots, and one ~19 s window had it timing out on every node at once. So the guidance is two-part and neither half suffices alone: multi-endpoint failover for the per-node reboots that dominate an upgrade's wall clock, and retry with backoff for the brief all-node window plus ordinary flakiness (healthy peers measured ~97-98% available, not 100%). New, and none of it observable on one node: - A HOSTNAME IS ONE NODE. The cluster's DNS name failed and recovered in exactly the same sample rounds as one specific node IP, identical durations, across all three of that node's outages. A client configured with one hostname lost the cluster entirely while 3 of 4 nodes were healthy and serving; a client holding all four addresses never lost access. Rules 5 and 6 are therefore the same problem, and each now says so. - NEVER build an endpoint list from networkStatus == "ONLINE". It is the obvious implementation and it selects the dead node: during an update every peer reported the updating node as ONLINE / currentDisposition IN while its API was completely unreachable, and it answered ICMP. Those fields describe cluster membership, not API reachability. Only a request tests an endpoint. Rule 6 also now cites Node.vips (empty, deprecated in the spec) rather than just asserting no VIP exists. - Cluster.icosVersion is the ANSWERING node's version, so mid-upgrade the same request returns different versions depending on which node serves it (a 2/2 split was observed). Version-gated feature detection is therefore unreliable during an update -- pin the answer for an operation. It flips at that node's upgrade reboot, which also makes it a reliable per-node "done" signal. update_status.json by contrast is cluster-consistent, and updateStatus.status.node names the node under work in backplane addressing. - Timing and progress: 3.5 h for 4 nodes with VMs vs 30 min for an empty single node, mostly VM migration (~15 min per node on top of a ~10 min reboot). totalComponents scales with cluster size (688 vs 180), and percent is cluster-wide but NOT proportional to nodes completed -- 51% at 1 of 4 -- so don't scale it into an ETA. Multi-node prepare adds BEGIN and SYNC NODES. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Worth one sentence: a manually prepared, not-yet-applied update. Normally prepare runs straight into update, but a prepare can be triggered on its own from the CLI. It's rare and deliberate, but if the apply is delayed or forgotten, the cluster can sit in that state for a while. I'd expect the file then to show prepareStatus.state: "COMPLETE" with masterState unknown, which the rule reads as busy even though REST is fully responsive. Busy is the right answer for a cluster mid-maintenance, so the rule doesn't need to change. But it's worth documenting, so that someone debugging automation that refuses to write to a healthy-looking cluster knows to check for a staged update. It would be worth it to check this state on one of the test clusters and see how the rules would apply |
Why
Follow-up to @wvancollenburg's review on #32. That PR corrected the update-status check to fail closed, but the guidance was still partly inferred. The review asked for two things this PR now delivers:
Both were measured end to end across two real updates — a single-node cluster going 9.8.3 → 9.8.4 (30 min) and a 4-node cluster with running VMs going 9.6.30 → 9.6.32 (3.5 h) — sampled at a 5 s median interval (stretching to 20 s while correlation reads were timing out), from before each update started until it settled. Plus three never-updated clusters for the 404 case.
What was observed
1. "Effectively read-only" understated it — the API stops answering entirely, by hanging.
During apply, every
/rest/v1/endpoint tried (Cluster,Node,Drive,Condition,VirDomain,Update) returned a read timeout for minutes at a stretch. Reads fail too, and the failure mode is a hang: the connection is accepted and the backend never answers — not a refusal, not a503. A client with no read timeout blocks indefinitely instead of getting an error it can handle.update_status.jsonkept returning 200 the entire time, because the web server serves it as a static file independently of the REST backend. That is why it, and no REST endpoint, is the progress channel.2. Both fields are mandatory — and now there's a reason, not just a convention.
prepareStatus.stateupdateStatus.masterStateDOWNLOAD BUNDLE→DOWNLOAD RPMS→UPDATE RPMCOMPLETEEXECUTING⇄IN PROGRESSCOMPLETECOMPLETEEach field alone reports "idle" through a whole phase. During prepare,
updateStatusholds onlypercentandstatus—masterStatedoesn't exist, so.get()returnsNone, which is falsy. During apply,prepareStatus.stateis already back toCOMPLETE.masterStatealso has two in-progress values, so test!= "COMPLETE"rather than matching a name.And the original
updateStageclaim is confirmed dead: no top-levelupdateStagein any phase, in any sample.2b. "Unreachable" is at least four different failure shapes, and none of them is a refused connection.
This is the finding most likely to change real client code. Across one update, the same client hitting the same cluster saw:
update_status.json/rest/v1/*The reboot alone moved through two shapes in sequence and took ~2m20s before the file answered again. Consequences:
except Timeoutis a bug here.requests.exceptions.SSLErrorsubclassesConnectionError, notTimeout— so a timeout-only handler lets the reboot through and the caller crashes. Incurlthat moment is exit 35 (SSL connect error), not 7; fully down becomes exit 28.r.json()raises —r.json().get("prepareStatus", {})throws instead of failing closed.3. A never-updated cluster returns 404 with an HTML body — so
.json()raises; there is no empty-JSON case, and parsing before checking the status code throws. Seen on three never-updated clusters at three different builds (9.6.32, 9.7.8, 9.8.3), so it tracks never having updated, not the version.4. Drive progress off
percent/currentComponent— monotonic, no decreases in any sample.statusdetailsis display text and at least one placeholder value recurs at several percentages.5.
/rest/v1/Conditionis a trap for this. There's a first-classcondition.updateInProgressflag, true during prepare — but it's a REST endpoint and dies during apply, exactly when needed. (Also documented:Conditionreturns the full catalogue of every possible condition on every call, each with a booleanvalue, so presence carries no information.)6.
POST /rest/v1/Update/{uuid}/applyreturns 200 with an emptytaskTag— no task to poll, so Rule 1's task-tag wait doesn't apply.Multi-node changes the headline (added after the 4-node run)
The single-node run made the outage look total. It is per node. While the updating node was unreachable, its peers served both channels normally:
So the guidance is two-part, and neither half suffices: multi-endpoint failover for the per-node reboots that dominate the wall clock, and retry with backoff for the brief all-node window plus ordinary flakiness — healthy peers measured ~97–98% available, not 100%.
A hostname is one node, not the cluster. The cluster's DNS name failed and recovered in exactly the same sample rounds as one specific node IP, identical durations, across all three of that node's outages. A client configured with one hostname lost the cluster entirely while 3 of 4 nodes were healthy and serving; a client holding all four addresses never lost access. Rules 5 and 6 are the same problem, and both now say so.
Never build an endpoint list from
networkStatus == "ONLINE". It is the obvious implementation and it selects the dead node: every peer reported the updating node asONLINE/currentDisposition: INwhile its API was completely unreachable, and it answered ICMP. Those fields describe cluster membership, not API reachability — correctly. Only a request tests an endpoint. Rule 6 now also citesNode.vips(empty, deprecated in the spec) rather than just asserting no VIP exists.Cluster.icosVersionis the answering node's version. Mid-upgrade the same request returns different versions depending on which node serves it — a 2/2 split was observed directly. So version-gated feature detection is unreliable during an update; pin the answer for an operation rather than re-reading per call. It flips at that node's upgrade reboot, which also makes it a dependable per-node "done" signal.Timing and progress: 3.5 h for 4 nodes with VMs vs 30 min for an empty single node, mostly VM migration (~15 min per node on top of a ~10 min reboot).
totalComponentsscales with cluster size (688 vs 180).percentis cluster-wide but not proportional to nodes completed — 51% at 1 of 4 — so don't scale it into an ETA. Multi-node prepare addsBEGINandSYNC NODES.Also
Qualifies the
HyperCoreDynamicBalancercitation. It reads the correct two fields, does node failover, and is correct across the sequence above — but its guards are writtenif state and state != "COMPLETE", so an absent field reads as idle, and it can't distinguish a never-updated cluster from every node being unreachable mid-update. Both are now called out so the pattern isn't lifted unexamined.Files
CLAUDE.md— Rule 5 rewritten (tight version + phase table)docs/hypercore-api-field-notes.md— full detaildocs/hypercore-api-reference.html— matching section, reusing the existingtable.trapsstylingNot observed — deliberately not documented
Both questions this section originally listed have since been answered by the 4-node run and are documented above:
update_status.jsondoes go away while its own node reboots — as a TLS error, then a read timeout.update_status.jsonis cluster-consistent. The divergence is inCluster.icosVersion, which is per-node. That is the opposite of what I predicted.Still genuinely unmeasured, and deliberately not documented: the cause of the single ~19 s all-node stall. One occurrence in one run — no mechanism is asserted for it.
🤖 Generated with Claude Code