cloudwego / cloudwego/eino-ext

fix(a2a): default in-memory EventQueue retains completed streaming tasks

Open
#990 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.