Skip to content

fix(orchestrate): re-derive component state leaf onto fresh trunk before the first push - #549

Merged
joshua-temple merged 1 commit into
mainfrom
fix/concurrent-state-leaf-clobber
Jul 10, 2026
Merged

joshua-temple merged 1 commit into
mainfrom
fix/concurrent-state-leaf-clobber

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

A shared-path commit can fire two component orchestrate lanes concurrently. Each finalizes its own state.components.<component>.<env> leaf into the one trunk manifest. The component write is re-appliable on a rejected push (it re-fetches trunk and node-patches only its own leaf, preserving siblings), but that guard only runs when the local branch is behind trunk.

The first commit in commitAndPush rested on the checkout-time manifest bytes. In the interleaving where the local HEAD is already at the fresh trunk tip but the working manifest is stale, the first git push fast-forwards with no rejection, so the re-apply never runs and the commit writes back a stale sibling leaf, reverting a value another component already committed. Observed live: a shared-path wave reverted one component's dev version while advancing the other. The Contents-API finalizers (promote, hotfix, rollback) were already immune because they re-read fresh trunk on every attempt including the first; the git orchestrate path did not.

Fix

internal/orchestrate/orchestrator.go commitAndPush (component-scoped path only): re-fetch and reset to the trunk tip, then re-derive the owned leaf via writeConfig before the first commit, so no attempt ever rests on a stale base. This mirrors the Contents-API path. The single-component path is unchanged and byte-identical (it writes the whole state node and has no sibling to converge against). Exposed git.RefetchAndReset for reuse; the retry ceiling and the cascade-state-write: attempt=N/M instrumentation are unchanged.

Verification

Regression test TestSharedParent_FirstAttemptReDerivesOntoFreshTrunk (red before, green after): two components finalizing off one shared parent, assert both leaves survive. Plus a 5-lane concurrent goroutine wave and both sequential orderings. go build ./..., go test ./... (2706 pass), go test ./... -race, golangci-lint run ./... all clean; single-component byte-identical baseline passes.

Caught by the v1-readiness fleet's shared-path concurrency wave.

…rst state commit

The component-scoped finalize committed its owned state leaf onto the checkout-time manifest and relied on a push rejection to surface staleness. That defends only when local HEAD is behind trunk. When HEAD already sits at the trunk tip but the working manifest is stale, the first push fast-forwards with no rejection, so the re-apply never runs and the stale sibling leaf lands, reverting a concurrent component's already-committed value. The Contents-API write path avoids this by re-reading fresh trunk on every attempt including the first.

Make commitAndPush re-fetch trunk, hard-reset the working tree to the upstream tip, and node-patch only the owned leaf back in before the first commit, so no attempt ever rests on a stale base. Gate it on the component-scoped path; the single-component path keeps its historical commit-then-rebase behavior byte-identical. Export RefetchAndReset for the shared re-sync. Add shared-parent convergence tests covering sequential orderings, a concurrent goroutine wave, a lane behind trunk, and the first-attempt stale-working-tree case that fails before this change.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple merged commit 2df5120 into main Jul 10, 2026
20 checks passed
@joshua-temple
joshua-temple deleted the fix/concurrent-state-leaf-clobber branch July 10, 2026 08:22
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