fix: level-triggered reconcile so previews converge across the cluster [DO NOT MERGE — review] - #37
Merged
Merged
Conversation
The drain/queue fan-out is edge-triggered on the switchboard:gen counter: a node only re-walks switchboard:index when gen advances past its own last_gen, and once it records itself current at a generation it never re-walks. gen is a separate gossip key from the content it guards, so a lost or not-yet-replicated increment leaves a node "current" at a generation whose teardown it never materialized — the torn-down preview stays served with its database and <key>.toml override on disk, every health check green (switchboard#24, and the gen-vs-key-content propagation race). The index itself has an acknowledged lost-update window on top of that. The symptom on the live cluster: override .toml counts drifting between nodes and orphaned overrides lingering as 404s. Add a level-triggered reconcile pass in the daemon that converges this node's on-disk previews to the KV desired state directly, independent of gen: - Prune: classify each preview directory from its OWN switchboard:preview:<label> key (a plain set — only teardown gives it a TTL — so it is immune to both the gen propagation race and the index lost-update race). A key that is a genuine nil (retired) or carries intent:teardown is an orphan and is removed with the existing KEEP-guarded, exact-path teardown_preview. Pruning never trusts the index, so a live preview that transiently fell out of it is not false-positived into an orphan. - Add: for a label the index lists whose preview key says deploy but whose directory is absent, enqueue the exact job document so the existing claim -> coalesce -> validate -> deploy path provisions it. Correctness no longer depends on gen; it stays as the PHP fast-path's optimization hint. The pass is fail-safe: a KV read failure aborts it (prunes nothing), infra vhosts are protected by a keep-list plus the API site key, pruning is opt-in (--reconcile-prune; dry-run logs WOULD-prune until then), and a per-cycle cap bounds the blast radius. Two nodes reconciling the same desired state converge rather than fight (teardown and enqueue are both idempotent). New knobs (all off by default, so upgrade is a no-op): --reconcile-interval-secs, --reconcile-prune, --reconcile-deploy-missing, --reconcile-keep-sites, --reconcile-max-prunes-per-cycle, --reconcile-api-site.
|
ePHPm Preview — removed Preview deployment has been torn down. |
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug (confirmed in current source)
Preview fan-out is edge-triggered on the
switchboard:gencounter. The proximate short-circuit is in switchboard-api:switchboard-api/src/Cluster/DrainHandler.php:120-124—if ($gen === $lastGen) { return … 'checked' => 0, 'queued' => 0 }returns beforematerialize(). Once a node advanceslast_gento the current generation, every later drain no-ops and never re-walksswitchboard:index.genis a separate gossip key from the content it guards (switchboard:preview:<label>, written byClusterState::publishinClusterState.php). Agenincrement that is lost or not-yet-replicated (theincris best-effort,ClusterState.php:117-119) leaves a node "current" at a generation whose teardown it never materialized — the torn-down preview stays served, its DB and<key>.tomloverride on disk, every health check green (switchboard#24, and the gen-vs-key-content propagation race). On top of that,switchboard:indexis a read-modify-write with an acknowledged lost-update window (ClusterState.php:35-50).Both are the same fault: an edge trigger that misses a state it never observed an event for. Live symptom: override
.tomlcounts drifting between nodes (11/10/7) and orphaned overrides for torn-down previews lingering as 404s.The fix
A level-triggered reconcile pass in the daemon (
src/reconcile.rs), on its own interval, independent ofgen, converging this node's on-disk previews to the KV desired state read directly:sites_dirfrom its ownswitchboard:preview:<label>key. That key is a plainset(only teardown gives it a TTL), so it is immune to both thegenpropagation race and the index lost-update race. A genuine nil (retired) orintent:teardown→ orphan → removed via the existing KEEP-guarded, exact-pathteardown::teardown_preview(site dir,<key>.db*,<key>.toml,/tmp/ephpm-vhosts/<key>-<hash>, preserving*.single-db-bak). Pruning never trusts the index, so a live preview that transiently fell out of it is not false-positived into an orphan.deploybut whose directory is absent → enqueue the exact job document (Queue::enqueue) so the normal claim → coalesce → validate → deploy path runs it.Correctness no longer depends on
gen; it stays as the PHP fast-path's optimization hint (kept as-is). Idempotent under concurrent drains — teardown tolerates already-absent artifacts; a materialized job gets a fresh per-node filename and is coalesced — so two nodes converge rather than fight.Fail-safe posture
--reconcile-keep-sitesplus the API's own site key.--reconcile-prune); until set, dry-run logs each orphan it would remove at WARN — which alone would have surfaced the original incident (found by hand-diffing three nodes).--reconcile-interval-secs 0), so upgrade is a no-op.switchboard-api companion change?
Not required. The daemon is the process that owns on-disk state and already reads KV, and reading the per-site content keys directly sidesteps the index lost-update race the PHP
materialize()is subject to. TheDrainHandlergen short-circuit is left as the fast-path optimization; a PHP-side level-triggered materialize would be redundant with this and still exposed to the index race. Flagging for a maintainer decision if you'd prefer symmetry.Tests added
reconcile.rs: live-vs-orphan classification, teardown-intent-while-still-in-index prunes, orphan-not-in-index prunes, live-preview-missing-from-index is NOT pruned (the false-positive guard), add-when-missing, add opt-in, dir listing filters (infra/.tmp/files), dry-run removes nothing, real prune removes the dir, per-cycle cap defers.kv.rs:ClusterReader::snapshotend-to-end against a mock RESP server (AUTH → index → per-label previews, incl. an on-disk-only extra label and a genuine nil), dead-addr error,parse_index.queue.rs:enqueueround-trips through the consume path and leaves no temp file;has_job_for_labelsees pending and claimed.Gates green locally:
cargo check --all-targets,cargo clippy --all-targets -- -D warnings,cargo test(330 passed),cargo fmt --all -- --check,cargo +1.85 check --all-targets(MSRV).