cloudwego / cloudwego/eino-ext
fix(a2a): default in-memory EventQueue retains completed streaming tasks
- Dominant language
- Go
- Stars
- 811
- Forks
- 368
- Avg merge
- 16h 22m
- Merged PRs (30d)
- 13
Description
# Bug: default in-memory A2A EventQueue retains every completed streaming task
## Describe the bug
When server.Config.Queue is nil, RegisterHandlers installs inMemoryEventQueue. Each streaming request calls Reset, which stores a new unboundedChan in chanMap under the task ID. When the task finishes, Close only closes the channel; no code removes the task ID from chanMap.
As a result, every completed streaming task remains reachable from the long-lived A2AServer. This creates process-lifetime memory growth proportional to the number of distinct streaming tasks.
There is a second retention concern in unboundedChan.Receive: it advances the slice with ch.buffer = ch.buffer[1:] without clearing the consumed slot. Because the closed queue remains in chanMap, its backing array may retain consumed response-event pointers and their payloads.
## Relevant implementation
In a2a/server/eventqueue.go:
type inMemoryEventQueue struct {
chanMap sync.Map
}
func (i *inMemoryEventQueue) Reset(ctx context.Context, taskID string) error {
i.chanMap.Store(taskID, newUnboundedChan[*inMemoryEventQueuePair]())
return nil
}
func (i *inMemoryEventQueue) Close(ctx context.Context, taskID string) error {
v, ok := i.chanMap.Load(taskID)
if !ok {
return fmt.Errorf("failed to close queue: cannot find the queue of task[%s]", taskID)
}
v.(*unboundedChan[*inMemoryEventQueuePair]).Close()
return nil
}
There is no chanMap.Delete(taskID) or TTL cleanup path.
The receive path is:
val := ch.buffer[0]
ch.buffer = ch.buffer[1:]
return val, true
The consumed slot is not cleared.
## To reproduce
1. Register an A2A server with a MessageStreamingHandler and Config.Queue == nil.
2. Execute 100 distinct SendMessageStreaming requests and fully drain each stream.
3. Save each returned task ID.
4. Call ResubscribeTask for every completed task ID. All 100 calls still resolve the closed queue.
5. Wait 5 seconds and call runtime.GC().
6. Call ResubscribeTask again. All 100 queues are still addressable.
Observed output:
reproduced: Queue=nil fallback still resolves all 100 completed task queues immediately after Close
reproduced: Queue=nil fallback still resolves all 100 completed task queues after 5s and runtime.GC
--- PASS: TestEinoA2A_NilQueueKeepsCompletedTasksAddressable (5.02s)
The reproducer uses only public APIs: RegisterHandlers, SendMessageStreaming, and ResubscribeTask.
## Expected behavior
Completed queues should have a bounded lifecycle. For example, delete a queue after terminal state plus full drain, or retain it for a configurable bounded TTL for resubscription and then delete it. Consumed buffer slots should be cleared as well.
Client disconnects need special consideration: the producer may continue pushing after the original consumer exits, so an unbounded queue without eventual deletion can retain all remaining events indefinitely.
## Version
- github.com/cloudwego/eino-ext/a2a v0.0.1-alpha.13
- tag commit 3de165e8931812fa7ac4ea3a891694ab079e1c3f
- repository main at deb0fb056c20a760563159853481d95ae0bb0252 no longer contains the a2a module directory
## Environment
GOOS=linux
GOARCH=amd64
GOVERSION=go1.24.4
GOTOOLCHAIN=auto
CGO_ENABLED=1
## GC reachability
A2AServer -> inMemoryEventQueue -> sync.Map -> taskID -> unboundedChan -> buffer -> response events
Forcing GC cannot reclaim these queues while the server remains alive.
Contributor guide
Research direction
Start in a2a/server/eventqueue.go by tracing inMemoryEventQueue.Reset and Close, then inspect unboundedChan.Receive and the public streaming/resubscription flow described in the reproducer. Define and verify a bounded lifecycle for completed and abandoned queues, ensuring consumed buffer entries are releasable and completed task IDs are no longer retained indefinitely.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100