Repository navigation
[flyte2] Parent action hangs forever when the WatchForUpdates subscriber channel drops a child's terminal update #8104
Description
Summary
ActionsClient.notifySubscribers drops an update whenever a parent's subscriber channel is full. If the dropped update is a child's terminal phase, the parent's SDK informer never learns that the child finished, and the parent waits forever. Nothing re-sends the update and the SDK never re-lists, so the run can't recover without an abort. The chart pins the channel size at 100, below the Go default of 1000 set in #7657, so moderate fan-out is enough to hit this.
Versions
- Chart
flyte-binaryv2.0.50, imageghcr.io/flyteorg/flyte-binary-v2:v2.0.50, chart-defaultactions.watchBufferSize: 100 - SDK
flyte2.10.2, with_U_USE_ACTIONS=1 - EKS, single backend replica
What we saw
The run had a root task that fanned out 42 parent tasks, 11 at a time. Each parent fanned out 350–560 children, 300 at a time, so about 3,300 task pods were running at peak.
- 30 parents succeeded. The other 12 are still RUNNING 12+ hours later. Every child of every parent is SUCCEEDED according to the API (
Action.listall), and each stuck parent submitted its full set of children. - The parents that hung were exactly those still running once the burst wound down. All 30 that succeeded had finished before the first one hung.
- Each stuck parent pod logs only
Informer cache sync timed outat startup. That timeout isn't the cause: three parents that logged it still succeeded. - Only the 12 stuck parents' TaskAction CRs remain. The children's CRs have been deleted.
- There's no server-side trace. The drop is logged at WARN, which the chart-default
logging.level: 1hides, and there's no metric yet (Add OTEL counter for dropped subscriber updates #7641).
Mechanism
Subscribecreates a channel of depthwatchBufferSizefor eachWatchForUpdatesstream (client.go#L456-L468).notifySubscribersdoes a non-blocking send and drops the update when the channel is full (client.go#L721-L733). Each child produces several updates (queued, running, terminal, deleted), and 300 children per parent completing in a burst easily pushes more than 100 into one channel faster than the stream drains them.- The stream keeps going after a drop (actions_service.go#L139-L173), so the client gets no error and doesn't reconnect.
- The SDK waits on each child's completion event with no timeout for task actions (_core.py#L347). The informer only re-lists when the stream errors (_informer.py#L221), and
watch_conn_timeout_secis stored but never used. - A reconnect wouldn't help anyway. The snapshot comes from
ListChildActions, which lists live TaskAction CRs (client.go#L397), and terminal children's CRs are already gone.
On main, the Go default is 1000, but the chart still sets watchBufferSize: 100 for both actions and runs, and the drop path is unchanged.
Suggested fixes
- Make the chart match fix(actions): bump TaskAction watch workers and buffer size #7657:
flyte-core-components.actions.watchBufferSize: 1000, or remove the override and let the Go default apply. We've set 1000 in our values as a workaround. - Make a drop recoverable instead of silent. One option is to end that subscriber's stream with an error on overflow, so the SDK reconnects and re-syncs. That only works if the reconnect snapshot includes children whose CRs have been cleaned up, for example by reading terminal state from the runs store rather than live CRs. Another option is to block that subscriber's send with a bounded timeout before giving up.
- On the SDK side, consider a periodic re-sync or a completion timeout as a backstop for pending children, so a lost update degrades into a delay rather than a permanent hang.
The counter in #7643 would make this visible but wouldn't stop the hang.
Repro sketch
Fan out several parents concurrently, each running flyte.map (or an equivalent asyncio.gather) over ~500 short child tasks with concurrency ≥ 200, on a single backend replica with the chart defaults. Some parents stay RUNNING after all their children succeed.
AI disclosure: written with Claude Code as a code assistant, reviewed by me.
Activity
runllm commentedon Oct 1, 2026
It seems likely that the issue is caused by the
notifySubscribersmethod in Flyte's ActionsClient dropping updates when the subscriber channel is full, which can result in a parent action never learning that its child has reached a terminal state. This is because the channel size is set to 100 by default in the chart, and a burst of child completions can easily overflow this buffer, especially with high fan-out. When a terminal update is dropped, the SDK's informer never receives it, and since the SDK only re-lists on stream errors (not on dropped messages), the parent waits forever for a completion event that will never arrive. Increasing the buffer size to 1000 (matching the Go default) or handling overflows by closing the stream with an error (so the SDK can reconnect and re-sync) are suggested mitigations. However, a reconnect currently would not help if the snapshot only includes live CRs, as terminal children may already be deleted from Kubernetes and thus not included in the re-list (client.go#L456-L468, actions_service.go#L139-L173, client.go#L397).My understanding is that the only workaround currently is to increase the
watchBufferSizeto 1000 or higher to reduce the chance of dropped updates, as you have done. A more robust fix would require Flyte to either block on full channels (with a timeout), error the stream on overflow, or have the SDK periodically re-sync or timeout on pending children. The issue is not visible by default due to logging level and lack of metrics, but can be surfaced by increasing logging or implementing the counter proposed in #7643. Would you like a step-by-step breakdown of the relevant code paths or more detail on possible mitigations?Sources:
This is an AI-generated response and your feedback is appreciated! Please leave a 👍 if this is helpful and 👎 if it is not.
Mention @Herald to ask follow-up questions. See runllm.com for more!
fix(runs): size watch subscriber channels from watchBufferSize (#8164)
fix(actions): never drop a child update from WatchForUpdates
fix(actions): never drop a child update from WatchForUpdates