Skip to content

Non-deterministic hang under parallel reconcile — DAG scheduler never converges; --concurrency 1 avoids it #828

Description

@dewet22

Thanks for flate — it's become the gate our Flux repo's CI trusts before Renovate automerges chart bumps.

I've been hitting a non-deterministic hang and managed to capture goroutine dumps that point fairly clearly at the DAG scheduler, so I wanted to write it up in case it's useful.

What happens

flate build all / flate test all hangs indefinitely — no output after the load phase, runs until the CI job timeout — on roughly half of all runs at the default --concurrency 48. The other half render normally in a few seconds, correct output. Which of my two clusters hangs varies run to run: the same commit rendered one clean in ~7s and hung the other past 150s on the next push.

At --concurrency 1 I've not seen it hang once — 8/8 clean runs, and the output is byte-identical to the good parallel runs (same document counts). So it looks like a race that only shows up once ≥2 nodes are dispatched concurrently.

What the dumps show

I wrapped flate in timeout -s QUIT to force a goroutine dump on hang, on two separate hung runs. Two things stood out.

1. The scheduler is parked waiting for a convergence that never comes. Goroutine 1 sits in sync.Cond.Wait:

goroutine 1 [runnable]:
  sync.(*Cond).Wait(...)
  flate/pkg/schedule.(*Scheduler).Run              schedule/schedule.go:241
  flate/pkg/orchestrator.(*Orchestrator).runDAG    orchestrator/dagrun.go:126
  flate/pkg/orchestrator.(*Orchestrator).render    orchestrator/run.go:188

2. Meanwhile it's spawning dispatch workers without bound. The highest goroutine id in a 90-second dump was 3,701,368 (~41k goroutines/sec). None are in a helm/values/template frame, so it isn't a render compute loop — they're all in this reconcile→require→fire cycle:

goroutine <N> [runnable]:
  flate/pkg/store.(*Store).fireUnderLock            store/events.go:208
  flate/pkg/store.(*Store).SetCondition             store/status.go:197
  flate/pkg/store.(*Store).UpdateStatus             store/status.go:171
  flate/pkg/controllers/base.(*Controller).Require  base/base.go:471
  flate/pkg/controllers/base.RequireRefresh[...]    base/base.go:513
  flate/pkg/controllers/helmrelease.(*Controller).reconcile   helmrelease/controller.go:157
  flate/pkg/controllers/base.RunWithStatusOutcome[...]         base/base.go:610
  flate/pkg/controllers/base.DispatchNode[...]                base/base.go:680
  flate/pkg/controllers/helmrelease.(*Controller).ReconcileNode  helmrelease/controller.go:110
  flate/pkg/orchestrator.dagDispatcher.Dispatch     orchestrator/dagrun.go:27
  flate/pkg/schedule.(*Scheduler).Run.func2         schedule/schedule.go:200
  flate/pkg/task.(*Service).Go.func1                task/task.go:78
  created by flate/pkg/task.(*Service).Go in goroutine 1

My read (a guess — I haven't traced the source)

It looks like reconcile-time Require/RequireRefresh writes a store condition and fireUnderLock notifies waiters, which re-dispatches DAG nodes, which reconcile and Require again — a refresh loop that doesn't reach a fixed point under concurrent dispatch, so the scheduler's convergence Cond is never satisfied. Serialising makes the condition updates settle in dependency order, which would explain why --concurrency 1 always converges. That's inference from the stacks though, not something I've confirmed in the code — you'll know the fixpoint accounting far better than I do.

I did notice a fair amount of prior work in this area (missed-wake / quiescence in #333, listener races in #274 and #440, rerun-at-drain in #703/#704), all merged before 0.4.10 — so this may be a remaining edge of the same class rather than anything new in the design.

Environment

  • flate 0.4.10 (latest), static linux/arm64 release binary, run in alpine:3 under the GitLab Kubernetes executor.
  • A Flux GitOps tree, ~40–90 HelmReleases across a dependency DAG (dependsOn and postBuild substitution relationships).
  • Repro: default --concurrency, re-run a few times to hit it; --concurrency 1 to avoid it.
  • GOMAXPROCS 128 (Ampere Altra node) — the high core count makes the goroutine churn vivid, but I don't think it's required to trigger the underlying race.

Happy to attach both full goroutine dumps or try patches / a --engine variant if that helps narrow it down. Thanks again.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions