Repro + analysis: scheduler inconsistent-graph-state on edge-merge + job cancel (draft) - #22
Conversation
sender.Finalize read pw.req.Canceled without holding pw.mu while receiver.Cancel -> setRequest writes pw.req under pw.mu. The request-function goroutine's Finalize can run concurrently with a Cancel, so the read races the write (confirmed by go test -race). Read the flag under the mutex. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cancel Skip-guarded unit repro for the scheduler failure "failed to get edge: inconsistent graph state" (moby/buildkit#4733, #2303; EarthBuild/earthbuild#768). Two jobs build distinct vertices sharing cache-key seeds (forcing an edge merge); a shared cache-map barrier overlaps their key computation so the merge races job teardown. One job is cancelled at the merge point (Discard deferred, matching llbsolver.Solve), deleting its edge state from the actives map while the surviving job's merged edge still references it -> getEdge()==nil. Skipped by default (fails on current code by design); run with BUILDKIT_TEST_SCHEDULER_MERGE_CANCEL=1. Reproduces in ~2/3 runs within the iteration budget. Root cause: edgeIndex can hand back a merge target whose state was concurrently deleted (edge.release cleans the index only once releaserCount hits 0) -- the "merge to inactive state" scenario #4887 targeted, still reachable under the parallel-cancel window. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
| Branch | Total Count |
|---|---|
| main | 217 |
| This PR | 246 |
| Difference | +29 (13.36%) |
📁 Changes by file type:
| File Type | Change |
|---|---|
| Go files (.go) | ❌ +26 |
| Documentation (.md) | ➖ No change |
| Earthfiles | ➖ No change |
Keep up the great work migrating from Earthly to Earthbuild! 🚀
💡 Tips for finding more occurrences
Run locally to see detailed breakdown:
./.github/scripts/count-earthly.shNote that the goal is not to reach 0.
There is anticipated to be at least some occurences of earthly in the source code due to backwards compatibility with config files and language constructs.
…h-state The inconsistent-graph-state error site logged the entire ~10k-entry tracker ring via dgstTracker.String() (tens of KB). Datadog/CI ingestion truncates these giant messages (~76KB), dropping the newest-first tail that carries the one thing needed to diagnose the failure: whether the failing digest was added/deleted before the failed lookup. Replace that dump with a discrete structured line (edge_vertex_name/digest/ index + desired_state + a bounded, digest-scoped history) so the delete-> get-edge-not-found ordering for the failing digest survives ingestion. Also add a mutex to dgstTracker: String()/add() previously raced (read via %+v/ String concurrent with ring writes). Refs EarthBuild/earthbuild#768, moby/buildkit#4733. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Possible to unstack this from the big buildkitd upgrade. Might be a good idea actually so we can collect more field diagnostics from the added debug logs in v0.8.19 prior to the major upgrade release. |
| } | ||
|
|
||
| type dgstTracker struct { | ||
| mu sync.Mutex |
There was a problem hiding this comment.
Use sync.RWMutex for more granular access.
| pw.mu.Lock() | ||
| reqCanceled := pw.req.Canceled | ||
| pw.mu.Unlock() |
There was a problem hiding this comment.
| pw.mu.Lock() | |
| reqCanceled := pw.req.Canceled | |
| pw.mu.Unlock() | |
| pw.mu.RLock() | |
| reqCanceled := pw.req.Canceled | |
| pw.mu.RUnlock() |
| reqCanceled := pw.req.Canceled | ||
| pw.mu.Unlock() | ||
| if errors.Is(err, context.Canceled) && reqCanceled { | ||
| pw.status.Canceled = true |
There was a problem hiding this comment.
If it is concurrently as stipulated and implemented before, then this assignment is thread-unsafe.
| ctxA, cancelA := context.WithCancel(context.Background()) | ||
| ctxB, cancelB := context.WithTimeout(context.Background(), 5*time.Second) |
There was a problem hiding this comment.
| ctxA, cancelA := context.WithCancel(context.Background()) | |
| ctxB, cancelB := context.WithTimeout(context.Background(), 5*time.Second) | |
| ctxA, cancelA := context.WithCancel(t.Context()) | |
| ctxB, cancelB := context.WithTimeout(t.Context(), 5*time.Second) |
Draft — for discussion, not merge-ready
Investigation into the intermittent scheduler failure
failed to get edge: inconsistent graph state(and the siblingreturn leaving outgoing/incoming open) that we see under parallel Earthly builds — tracked internally at EarthBuild/earthbuild#768, upstream at moby/buildkit#4733 / #2303. This PR carries a local repro, one independent race fix, and a production-forensics logging change found along the way. The actual logic fix is left open pending discussion (see below).Stacked on
merge-in-buildkit-all-at-once.What's here
fix(solver): readrequnder lock in pipeFinalize.sender.Finalizereadpw.req.Canceledwithoutpw.muwhilereceiver.Cancel → setRequestwrites it underpw.mu. The request-function goroutine'sFinalizeraces a concurrentCancel(confirmed bygo test -race). Independent of the logic bug below; safe on its own.test(solver): skip-guarded repro of the inconsistent-graph-state failure. Green (skipped) in CI; run withBUILDKIT_TEST_SCHEDULER_MERGE_CANCEL=1.fix(solver): digest-scoped, untruncated log at the error site. The error site dumped the whole ~10k-entry tracker ring (dgstTracker.String(), tens of KB); log ingestion truncates it (~76 KB), dropping the newest-first tail that carries the delete→lookup ordering we need. Replaced with a discrete structured line (edge_vertex_name/digest/index+desired_state+ a bounded, digest-scoped history), and added a mutex todgstTracker(itsString()/add()previously raced). Intended to ship next release so the next production occurrence is definitively fingerprintable.The repro
Two jobs build distinct vertices that share cache-key seeds, so their edges must edge-merge. A shared cache-map barrier forces their key computation to overlap (so the merge races job teardown). One job is cancelled at the merge point, with
Discarddeferred to run afterBuildreturns — matchingllbsolver.Solve'sdefer j.Discard(). Reproduces in ~2 of 3 runs within the iteration budget.With commit 3 in place, the failing occurrence now logs (compact, digest-scoped):
i.e. the
deleteof the failing digest sits immediately before itsget-edge-not-found— the exact ordering the truncated dump used to lose.Root cause (what the event trace shows)
The scheduler performs an edge merge whose destination edge's state has already been removed from
activesby a concurrentJob.Discard. The edge survives inedgeIndexbecauseedge.release()only callsindex.ReleaseoncereleaserCountreaches 0 (solver/edge.go), soedgeIndex.LoadOrStorecan hand back a merge target whose state is gone. The merged edge then requests a deleted dependency →getEdge()==nil→ the error.This is the same "merge to inactive state" class that #4887 addressed, still reachable in the parallel-cancel window — i.e. #4887 looks incomplete for #4733.
Candidate fix directions (want maintainer input)
The correct fix is non-obvious and is why this is a draft:
mergeTo— but there's a TOCTOU unless the check +mergeTo+setEdgeare atomic underSolver.mu(dispatch currently holds onlyscheduler.muand takesSolver.muper-call).edgeIndexentry when a state leavesactives, independent ofreleaserCount.Job.Discardcoordinate with the scheduler so an edge is drained before its state is deleted.Each has lock-ordering implications we'd rather discuss than guess at.
Field evidence so far (o8t CI, ~15-day window)
Supportive but not yet corroborating:
rpc … evaluating released resultco-occurring in the same second as a failure (a released result being evaluated — the shape this mechanism predicts).Notes from the wider investigation (context, not in this PR)
addJobsunder-propagates job refs (its log-and-continueon not-found) — was falsified: the miss counter stayed 0 across every repro. The bug is merge-to-deleted-state, not ref undercount.-racealso surfaces data races originating solely in the fork'shelpMescheduler instrumentation (fmt.Sprintf("%+v", e)racingedge.release()). Removing that block eliminates them; handled separately as part of dropping that instrumentation.Status / confidence
testing/synctestversion of the repro) would close that gap.