Skip to content

Commit a35b05e

Browse files
fix(dispatcher): run one durable worker-status handler per stream
The durable task stream started a goroutine per message. Workers resend their waiting entries every few seconds, and each worker-status handler loads every satisfied entry with its payload and sends it on the stream. When a worker stops draining, sends block and every resend adds another goroutine holding another payload batch until the engine runs out of memory. Run at most one worker-status handler per stream. Statuses that arrive while it runs are merged (highest invocation per task, entries unioned by branch and node) and handled next, in batches of up to 10k entries. Other durable message types are unchanged.
1 parent e2e6644 commit a35b05e

3 files changed

Lines changed: 616 additions & 0 deletions

File tree

‎internal/services/dispatcher/server_v1.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,8 @@ func (d *DispatcherServiceImpl) durableTask(
634634

635635
var reqWg sync.WaitGroup
636636

637+
statuses := newWorkerStatusCoalescer()
638+
637639
defer func() {
638640
for taskId := range registeredTasks {
639641
d.durableInvocations.CompareAndDelete(durableInvocationsKey{
@@ -726,6 +728,36 @@ func (d *DispatcherServiceImpl) durableTask(
726728
}
727729
}
728730

731+
// At most one worker-status handler runs per stream; statuses that arrive
732+
// meanwhile are merged and handled next, so a stalled stream holds one payload
733+
// batch. offer and reqWg.Add stay on this goroutine so Add never races Wait; the
734+
// runner exits between batches once ctx ends.
735+
if msg, isWorkerStatus := r.req.GetMessage().(*contracts.DurableTaskRequest_WorkerStatus); isWorkerStatus {
736+
if statuses.offer(msg.WorkerStatus) {
737+
reqWg.Add(1)
738+
go func() {
739+
defer reqWg.Done()
740+
741+
for ctx.Err() == nil {
742+
status := statuses.next()
743+
if status == nil {
744+
return
745+
}
746+
747+
req := &contracts.DurableTaskRequest{
748+
Message: &contracts.DurableTaskRequest_WorkerStatus{WorkerStatus: status},
749+
}
750+
751+
if err := d.handleDurableTaskRequest(ctx, invocation, req); err != nil {
752+
d.logDurableTaskRequestError(err)
753+
}
754+
}
755+
}()
756+
}
757+
758+
continue
759+
}
760+
729761
reqWg.Add(1)
730762
go func(req *contracts.DurableTaskRequest) {
731763
defer reqWg.Done()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package dispatcher
2+
3+
import (
4+
"sync"
5+
6+
contracts "github.com/hatchet-dev/hatchet/internal/services/shared/proto/v1"
7+
)
8+
9+
// maxWorkerStatusEntriesPerBatch is the most waiting entries next hands out at once, matching
10+
// the per-request cap the SDKs apply to worker statuses.
11+
const maxWorkerStatusEntriesPerBatch = 10_000
12+
13+
type branchNodeKey struct {
14+
branchId int64
15+
nodeId int64
16+
}
17+
18+
type pendingTaskStatus struct {
19+
entries map[branchNodeKey]*contracts.DurableTaskAwaitedCompletedEntry
20+
invocationCount int32
21+
}
22+
23+
// workerStatusCoalescer merges the worker-status messages of one durable task stream while a
24+
// single runner goroutine handles them, so a stream that stops draining holds at most one
25+
// status batch in flight plus the merged pending set.
26+
//
27+
// For each durable task only the highest invocation count seen is kept: a status for an older
28+
// invocation is dropped, and entries for the same invocation are unioned by (branch, node).
29+
type workerStatusCoalescer struct {
30+
pending map[string]*pendingTaskStatus
31+
mu sync.Mutex
32+
running bool // true while a runner goroutine owns draining
33+
}
34+
35+
func newWorkerStatusCoalescer() *workerStatusCoalescer {
36+
return &workerStatusCoalescer{pending: make(map[string]*pendingTaskStatus)}
37+
}
38+
39+
// offer merges status into the pending set and reports whether the caller must start a runner,
40+
// which is the case only when it flipped running from false to true. A status with nothing to
41+
// merge never starts a runner.
42+
func (c *workerStatusCoalescer) offer(status *contracts.DurableTaskWorkerStatusRequest) (startRunner bool) {
43+
c.mu.Lock()
44+
defer c.mu.Unlock()
45+
46+
for _, entry := range status.GetWaitingEntries() {
47+
if entry == nil {
48+
continue
49+
}
50+
51+
task, ok := c.pending[entry.DurableTaskExternalId]
52+
53+
switch {
54+
case !ok || entry.InvocationCount > task.invocationCount:
55+
task = &pendingTaskStatus{
56+
invocationCount: entry.InvocationCount,
57+
entries: make(map[branchNodeKey]*contracts.DurableTaskAwaitedCompletedEntry),
58+
}
59+
c.pending[entry.DurableTaskExternalId] = task
60+
case entry.InvocationCount < task.invocationCount:
61+
continue
62+
}
63+
64+
task.entries[branchNodeKey{branchId: entry.BranchId, nodeId: entry.NodeId}] = entry
65+
}
66+
67+
if c.running || len(c.pending) == 0 {
68+
return false
69+
}
70+
71+
c.running = true
72+
73+
return true
74+
}
75+
76+
// next takes whole tasks from the pending set as one worker-status request, stopping before a
77+
// task that would push the request past maxWorkerStatusEntriesPerBatch entries; a single task
78+
// larger than the cap goes out alone. The rest stays pending for the next call. When nothing is
79+
// pending it marks the runner as finished and returns nil, so the next offer starts a new
80+
// runner.
81+
func (c *workerStatusCoalescer) next() *contracts.DurableTaskWorkerStatusRequest {
82+
c.mu.Lock()
83+
defer c.mu.Unlock()
84+
85+
if len(c.pending) == 0 {
86+
c.running = false
87+
return nil
88+
}
89+
90+
req := &contracts.DurableTaskWorkerStatusRequest{}
91+
92+
for taskId, task := range c.pending {
93+
if len(req.WaitingEntries) > 0 && len(req.WaitingEntries)+len(task.entries) > maxWorkerStatusEntriesPerBatch {
94+
break
95+
}
96+
97+
for _, entry := range task.entries {
98+
req.WaitingEntries = append(req.WaitingEntries, entry)
99+
}
100+
101+
delete(c.pending, taskId)
102+
}
103+
104+
return req
105+
}

0 commit comments

Comments
 (0)