Skip to content

meta: an uninitialized membership is readable, not a storage error (scenario 10) - #252

Merged
allamiro merged 1 commit into
mainfrom
fix/10-uninitialized-membership
Aug 5, 2026
Merged

meta: an uninitialized membership is readable, not a storage error (scenario 10)#252
allamiro merged 1 commit into
mainfrom
fix/10-uninitialized-membership

Conversation

@allamiro

@allamiro allamiro commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Stacked on #251 (which is stacked on #250). Bases retarget automatically as each merges.

vtopctl meta status against a freshly started metadata node fails with:

when Write Logs: joint membership with 0 configs cannot be stored in MetaMembership

A storage write error, reported by a read, on the first command an operator runs. Bootstrap hits the same fault — the co-located scenario (#215) has been unable to init since it landed.

Zero configs is not corruption

It is exactly how openraft represents a node that has never been initialized. A fresh node's startup state carries Membership { configs: [], nodes: {} }:

membership_state: MembershipState { committed: EffectiveMembership {
  log_id: None, membership: Membership { configs: [], nodes: {} }, voter_ids: {} }, ... }

membership_to_meta rejected that, and the converter runs on the metrics and status paths as well as the log-append path — so every read of an uninitialized node became an error. It now maps zero configs to an empty MetaMembership, which describes the state truthfully.

Whether an empty membership may be persisted is a different question with a different answer, so it is now asked at the write boundary: entry_to_meta refuses a membership log entry with no voters. A committed entry naming no voters describes a cluster in which no quorum can ever form, and replaying it would strand the group. Openraft does not produce one — if that changes, this fails loudly rather than writing it.

The misleading label is part of the bug

sto_err_logs labelled every failure from this converter when Write Logs, including failures from paths that never write a log. That is why the symptom pointed at the storage layer, where nothing was wrong. Genuinely impossible config counts still error, now reported as a read fault.

A second defect the first one masked

init read the membership back from the metrics watch channel, which openraft publishes to asynchronously. A read taken the instant initialize returns can still observe the pre-init state, so a successful bootstrap could answer voters: [].

The race is wide open on a single-member group, which needs no peer round trip to initialize — which is why the co-located scenario, the only one that bootstraps one member, is the only one that hit it. init now waits (bounded, 2s) for a membership with voters, and on expiry returns what it last saw rather than inventing an error for an operation that already succeeded.

Verification

Scenario 10 passes. It now asserts on the reported voter rather than the exit code, which is what makes the second fix testable — an exit code alone passes on voters: []. Mutation-checked: reverting init to the immediate read fails the scenario.

Three unit tests cover the converter:

  • an uninitialized membership reads as empty rather than erroring
  • an empty membership is refused at the log write boundary
  • an impossible config count is reported as a read fault, not a write

Why this was invisible

The live-chaos harness never runs in CI, so scenario 10 has been red on main since #237 without signal. Wiring it in is the next change; this PR and #251 are the two failures that had to be fixed before that suite could be enabled without turning main red.


Summary by cubic

Fixes false storage errors on fresh metadata nodes by treating zero-config memberships as readable and only rejecting them when writing to the log. Also makes init return the actual voter by waiting briefly for metrics, fixing scenario 10.

  • Bug Fixes
    • Read path: membership_to_meta maps zero configs to an empty MetaMembership; impossible config counts are reported as READ faults.
    • Write path: entry_to_meta refuses to persist membership entries with no voters.
    • Admin init: waits up to 2s for a published membership with voters; on timeout, returns the last seen state.
    • Tests/scripts: scenario 10 now asserts the reported voter; unit tests added for uninitialized reads, write refusal, and READ labeling.

Written for commit f6ba1ba. Summary will update on new commits.

Review in cubic

@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Re-trigger cubic

@allamiro
allamiro force-pushed the fix/09-post-failover-produce branch from 62e9d9f to b3b66eb Compare August 5, 2026 19:27
@allamiro
allamiro force-pushed the fix/10-uninitialized-membership branch from b47cfb2 to de308d3 Compare August 5, 2026 19:27
allamiro added a commit that referenced this pull request Aug 5, 2026
The live-chaos harness drives real processes over real TLS — metadata Raft,
replication, fencing, promotion, failover — and it has never run in CI. No
workflow referenced it. It executed only when someone ran it by hand.

That gap is not theoretical. Scenario 09 sat red on main from #236, scenario
10 from #237, a data node presented a metadata node's certificate to the admin
endpoint across three issues, and scenario 01 failed on a scheduling race that
appeared and vanished between runs. Every one of those was found by running
the suite manually this week, not by CI, and each had been on main for weeks.

The harness was not linted either: the `shell` filter listed `docker/*.sh` and
`docker/tests/**`, so editing `lib.sh` — which all thirteen scenarios source —
triggered nothing at all.

Both are now covered:

  * `shell` gains `scripts/live-chaos/**`, and a second shellcheck step lints
    the harness with `-x -P scripts/live-chaos/scenarios` so the scenarios'
    dynamic `source` resolves. Without `-P`, every scenario reports SC1091 plus
    spurious "may not be assigned" warnings for variables lib.sh does define —
    noise that trains people to ignore the job. The tree is clean under it.

  * A new `chaos` filter and `live-chaos` job run the suite. Scoped, not
    blanket: only the harness itself and the four crates whose binaries it
    executes can change a scenario's outcome, so a docs or dashboard edit does
    not pay for a cluster. Debug binaries, because these scenarios assert
    correctness rather than throughput.

Unprivileged user and mount namespaces are enabled and then PROBED. Ubuntu
24.04 restricts unprivileged userns by default, and scenarios 05, 05b, and 06
need it. The probe does not skip on failure: these scenarios fail with a
remediation message rather than skipping, so a runner without namespaces turns
the job red honestly instead of reporting success for three scenarios that
never ran — which is exactly the silent gap this job exists to close.

Per-scenario logs upload on failure. A failing scenario names a log file, and
without the artifact that diagnosis dies with the runner.

Enabled only now because the suite had to be green first: #251, #252, and #253
fixed the three real failures. Local state is 9 of 13 passing, with the four
remaining failures all requiring Linux facilities absent on macOS — `unshare`
for 05, 05b, 06 and a clock shim for 07 — which is what this job exists to
exercise. Scenario 01 was run five times after the race fix: 5 of 5.
@allamiro
allamiro changed the base branch from fix/09-post-failover-produce to main August 5, 2026 19:28
…cenario 10)

`vtopctl meta status` against a freshly started metadata node failed with
"when Write Logs: joint membership with 0 configs cannot be stored in
MetaMembership" — a storage write error, reported by a read, on the first
command an operator runs. Bootstrap hit the same fault: the co-located
scenario (#215) has been unable to `init` since it landed.

Zero joint configs is not corruption. It is exactly how openraft represents a
node that has never been initialized; a fresh node's startup state carries
`Membership { configs: [], nodes: {} }` until `init` lands. `membership_to_meta`
rejected it, and that converter runs on the metrics and status paths as well as
the log-append path, so every read of an uninitialized node became an error.
It now maps zero configs to an empty MetaMembership, which describes the state
truthfully.

Whether an empty membership may be PERSISTED is a different question with a
different answer, so it is now asked at the write boundary: `entry_to_meta`
refuses a membership log entry with no voters, because a committed entry
naming no voters describes a cluster in which no quorum can ever form, and
replaying it would strand the group. Openraft does not produce one; if that
changes, this fails loudly instead of writing it.

The remaining failures keep the strict error but report it as a READ fault.
`sto_err_logs` labelled every failure from this converter "when Write Logs",
including failures from paths that never write a log — which is why the
original symptom pointed at the storage layer, where nothing was wrong.

Bootstrap had a second defect the first one masked. `init` read the membership
back from the metrics watch channel, which openraft publishes to
asynchronously, so a read taken the instant `initialize` returns can still
observe the pre-init state. A successful bootstrap could therefore answer
"voters: []". The race is wide open on a single-member group, which needs no
peer round trip to initialize — which is why the co-located scenario, the only
one that bootstraps one member, is the only one that hit it. `init` now waits
(bounded, 2s) for a membership with voters, and on expiry returns what it last
saw rather than inventing an error for an operation that already succeeded.

Scenario 10 asserts on the reported voter rather than the exit code, so the
wait is covered: reverting it fails the scenario. Three unit tests cover the
converter — uninitialized reads as empty, empty is refused at the write
boundary, and an impossible config count is reported as a read fault.
@allamiro
allamiro force-pushed the fix/10-uninitialized-membership branch from de308d3 to f6ba1ba Compare August 5, 2026 19:28
@allamiro
allamiro merged commit 527d131 into main Aug 5, 2026
2 checks passed
allamiro added a commit that referenced this pull request Aug 5, 2026
The live-chaos harness drives real processes over real TLS — metadata Raft,
replication, fencing, promotion, failover — and it has never run in CI. No
workflow referenced it. It executed only when someone ran it by hand.

That gap is not theoretical. Scenario 09 sat red on main from #236, scenario
10 from #237, a data node presented a metadata node's certificate to the admin
endpoint across three issues, and scenario 01 failed on a scheduling race that
appeared and vanished between runs. Every one of those was found by running
the suite manually this week, not by CI, and each had been on main for weeks.

The harness was not linted either: the `shell` filter listed `docker/*.sh` and
`docker/tests/**`, so editing `lib.sh` — which all thirteen scenarios source —
triggered nothing at all.

Both are now covered:

  * `shell` gains `scripts/live-chaos/**`, and a second shellcheck step lints
    the harness with `-x -P scripts/live-chaos/scenarios` so the scenarios'
    dynamic `source` resolves. Without `-P`, every scenario reports SC1091 plus
    spurious "may not be assigned" warnings for variables lib.sh does define —
    noise that trains people to ignore the job. The tree is clean under it.

  * A new `chaos` filter and `live-chaos` job run the suite. Scoped, not
    blanket: only the harness itself and the four crates whose binaries it
    executes can change a scenario's outcome, so a docs or dashboard edit does
    not pay for a cluster. Debug binaries, because these scenarios assert
    correctness rather than throughput.

Unprivileged user and mount namespaces are enabled and then PROBED. Ubuntu
24.04 restricts unprivileged userns by default, and scenarios 05, 05b, and 06
need it. The probe does not skip on failure: these scenarios fail with a
remediation message rather than skipping, so a runner without namespaces turns
the job red honestly instead of reporting success for three scenarios that
never ran — which is exactly the silent gap this job exists to close.

Per-scenario logs upload on failure. A failing scenario names a log file, and
without the artifact that diagnosis dies with the runner.

Enabled only now because the suite had to be green first: #251, #252, and #253
fixed the three real failures. Local state is 9 of 13 passing, with the four
remaining failures all requiring Linux facilities absent on macOS — `unshare`
for 05, 05b, 06 and a clock shim for 07 — which is what this job exists to
exercise. Scenario 01 was run five times after the race fix: 5 of 5.
allamiro added a commit that referenced this pull request Aug 5, 2026
* ci: run and lint the live-chaos harness, path-gated

The live-chaos harness drives real processes over real TLS — metadata Raft,
replication, fencing, promotion, failover — and it has never run in CI. No
workflow referenced it. It executed only when someone ran it by hand.

That gap is not theoretical. Scenario 09 sat red on main from #236, scenario
10 from #237, a data node presented a metadata node's certificate to the admin
endpoint across three issues, and scenario 01 failed on a scheduling race that
appeared and vanished between runs. Every one of those was found by running
the suite manually this week, not by CI, and each had been on main for weeks.

The harness was not linted either: the `shell` filter listed `docker/*.sh` and
`docker/tests/**`, so editing `lib.sh` — which all thirteen scenarios source —
triggered nothing at all.

Both are now covered:

  * `shell` gains `scripts/live-chaos/**`, and a second shellcheck step lints
    the harness with `-x -P scripts/live-chaos/scenarios` so the scenarios'
    dynamic `source` resolves. Without `-P`, every scenario reports SC1091 plus
    spurious "may not be assigned" warnings for variables lib.sh does define —
    noise that trains people to ignore the job. The tree is clean under it.

  * A new `chaos` filter and `live-chaos` job run the suite. Scoped, not
    blanket: only the harness itself and the four crates whose binaries it
    executes can change a scenario's outcome, so a docs or dashboard edit does
    not pay for a cluster. Debug binaries, because these scenarios assert
    correctness rather than throughput.

Unprivileged user and mount namespaces are enabled and then PROBED. Ubuntu
24.04 restricts unprivileged userns by default, and scenarios 05, 05b, and 06
need it. The probe does not skip on failure: these scenarios fail with a
remediation message rather than skipping, so a runner without namespaces turns
the job red honestly instead of reporting success for three scenarios that
never ran — which is exactly the silent gap this job exists to close.

Per-scenario logs upload on failure. A failing scenario names a log file, and
without the artifact that diagnosis dies with the runner.

Enabled only now because the suite had to be green first: #251, #252, and #253
fixed the three real failures. Local state is 9 of 13 passing, with the four
remaining failures all requiring Linux facilities absent on macOS — `unshare`
for 05, 05b, 06 and a clock shim for 07 — which is what this job exists to
exercise. Scenario 01 was run five times after the race fix: 5 of 5.

* ci: run CI on every pull request, not only those targeting main

`pull_request: branches: [main]` meant a stacked PR ran no CI whatsoever. This
project stacks slices — each bases on the one below — so those PRs carried a
single check, an AI reviewer's comment, and nothing else. They could be
reviewed, approved, and merged with no build, no tests, and no lint.

Nor did merging the parent repair it. Retargeting a PR's base fires the
`edited` activity type, which is not in the default `pull_request` event set,
so the retargeted PR did not reliably re-trigger CI either. The first real
signal arrived on push to main — after the merge, on the branch that is
supposed to stay green.

The path filters already scope each run, so the added cost is only the jobs a
stacked slice actually needs.

This is the same shape as the gap the previous commit closes: a check that
exists and is trusted, but never runs on the change it is meant to guard.

* ci: make the live-chaos log artifact actually contain logs

The upload step collected nothing. Scenarios delete workdirs they generated
themselves, so by the time the step ran the `/tmp/vtop-chaos.*` tree was gone —
and `if-no-files-found: ignore` turned that into a silent success. The first
run confirmed it: the job passed and produced no artifact at all.

A diagnostic that appears to exist and is absent exactly when it is needed is
worse than none, because it stops anyone from looking for a real one.

Supplying CHAOS_WORKDIR fixes both halves: the logs land in a known path, and
because the harness only cleans up directories it generated itself, providing
one suppresses the cleanup. `if-no-files-found` becomes `error`, so a future
break in this wiring fails the job instead of quietly returning to collecting
nothing. Upload is now `if: failure()` — the logs are for diagnosing a failed
run, and uploading them on every green run is storage for nobody.

Verified by running a scenario with CHAOS_WORKDIR set and confirming the log
tree survives.

* ci: widen the chaos filter to the real dependency closure, and gate the upload correctly

Two review findings, both correct.

The chaos filter named four crates and asserted that nothing else could change
a scenario's outcome. That assertion was false. The two binaries this job runs
pull in every workspace crate:

  cargo tree -p vtop-node --edges normal
    → broker, log, meta, observe, protocol
  cargo tree -p vtop-cli --no-default-features --edges normal
    → adapters, broker, core, log, meta, observe, protocol, state, upload

The omissions were not marginal. vtop-log owns the segment format the
durability and recovery scenarios assert on, and vtop-protocol owns the
produce/fetch wire every scenario speaks — either could have broken the suite
with this job never running, which is precisely the hole this job exists to
close. The filter is now `crates/**`, derived from the closure rather than
from a guess about it.

That means the suite runs on most Rust changes. That is the honest cost of it
being the only thing validating replication and fencing against real
processes; a cheaper gate with a hole in it is what was just removed.

The log upload was gated on `failure()`, which is also true when the namespace
probe or the build failed. In those cases the suite never reached a scenario,
so no logs exist, and `if-no-files-found: error` would stack a spurious "no
files found" on top of the actual cause. It now keys on the run step's own
outcome, so the empty-artifact signal only fires once a run was actually
attempted — which is the only situation in which it means anything.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant