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.
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 allhangs 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 1I'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 QUITto 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: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:
My read (a guess — I haven't traced the source)
It looks like reconcile-time
Require/RequireRefreshwrites a store condition andfireUnderLocknotifies waiters, which re-dispatches DAG nodes, which reconcile andRequireagain — a refresh loop that doesn't reach a fixed point under concurrent dispatch, so the scheduler's convergenceCondis never satisfied. Serialising makes the condition updates settle in dependency order, which would explain why--concurrency 1always 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
alpine:3under the GitLab Kubernetes executor.dependsOnand postBuild substitution relationships).--concurrency, re-run a few times to hit it;--concurrency 1to avoid it.Happy to attach both full goroutine dumps or try patches / a
--enginevariant if that helps narrow it down. Thanks again.