Enhance subscription management by adding cancel functions and improving channel handling
- Update the Subscribe and SubscribeFrom methods to return a cancel function, ensuring proper resource cleanup when subscriptions are no longer needed. - Modify unsubscribe logic to close channels safely, preventing potential panics from sending on closed channels. - Enhance test cases to utilize the new cancel functionality, ensuring robust handling of subscriptions in various scenarios.
This commit is contained in:
parent
7fa54c50f3
commit
1e4e1224e2
11 changed files with 252 additions and 40 deletions
33
event/sub.go
33
event/sub.go
|
|
@ -54,14 +54,25 @@ func (sm *subManager) subscribe(pattern string, ch chan<- *types.Event, opts ...
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsubscribe removes a subscriber by ID.
|
// unsubscribe removes a subscriber by ID and closes its channel
|
||||||
|
// so that any goroutine blocked on `range ch` will unblock and exit.
|
||||||
func (sm *subManager) unsubscribe(id string) {
|
func (sm *subManager) unsubscribe(id string) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
entry, ok := sm.entries[id]
|
||||||
delete(sm.entries, id)
|
delete(sm.entries, id)
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
if ok && entry.ch != nil {
|
||||||
|
func() {
|
||||||
|
defer func() { recover() }()
|
||||||
|
close(entry.ch)
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// notify sends an event to all matching subscribers (non-blocking).
|
// notify sends an event to all matching subscribers (non-blocking).
|
||||||
|
// Recovers from send-on-closed-channel panics that may occur if
|
||||||
|
// unsubscribe closes a channel concurrently.
|
||||||
func (sm *subManager) notify(ev *types.Event) {
|
func (sm *subManager) notify(ev *types.Event) {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
|
@ -73,19 +84,31 @@ func (sm *subManager) notify(ev *types.Event) {
|
||||||
if entry.filter != nil && !entry.filter(ev) {
|
if entry.filter != nil && !entry.filter(ev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
func() {
|
||||||
|
defer func() { recover() }()
|
||||||
select {
|
select {
|
||||||
case entry.ch <- ev:
|
case entry.ch <- ev:
|
||||||
default:
|
default:
|
||||||
// Subscriber chan full, skip (non-blocking)
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear removes all subscribers. Used during Stop.
|
// clear removes all subscribers and closes their channels. Used during Stop.
|
||||||
func (sm *subManager) clear() {
|
func (sm *subManager) clear() {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
old := sm.entries
|
||||||
sm.entries = make(map[string]*subEntry)
|
sm.entries = make(map[string]*subEntry)
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, entry := range old {
|
||||||
|
if entry.ch != nil {
|
||||||
|
func() {
|
||||||
|
defer func() { recover() }()
|
||||||
|
close(entry.ch)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe dynamically subscribes to events matching the given pattern.
|
// Subscribe dynamically subscribes to events matching the given pattern.
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ func TestSubscribe_StopClearsSubscribers(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// drainChan reads up to n events from ch within timeout.
|
// drainChan reads up to n events from ch within timeout.
|
||||||
|
// Stops early if the channel is closed.
|
||||||
func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Event {
|
func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Event {
|
||||||
var result []*types.Event
|
var result []*types.Event
|
||||||
timer := time.NewTimer(timeout)
|
timer := time.NewTimer(timeout)
|
||||||
|
|
@ -171,7 +172,10 @@ func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Even
|
||||||
|
|
||||||
for range n {
|
for range n {
|
||||||
select {
|
select {
|
||||||
case ev := <-ch:
|
case ev, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
result = append(result, ev)
|
result = append(result, ev)
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,14 @@ func handleStreamMode(c *gin.Context, manager types.Manager, info *types.TraceIn
|
||||||
c.Header("X-Accel-Buffering", "no")
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
// Subscribe to trace updates
|
// Subscribe to trace updates
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Send error as SSE event
|
// Send error as SSE event
|
||||||
fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"Failed to subscribe: %s\"}\n\n", err.Error())
|
fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"Failed to subscribe: %s\"}\n\n", err.Error())
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Stream events
|
// Stream events
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
@ -73,22 +74,18 @@ func handleStreamMode(c *gin.Context, manager types.Manager, info *types.TraceIn
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-clientGone:
|
case <-clientGone:
|
||||||
// Client disconnected
|
|
||||||
return
|
return
|
||||||
|
|
||||||
case update, ok := <-updates:
|
case update, ok := <-updates:
|
||||||
if !ok {
|
if !ok {
|
||||||
// Channel closed
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format and send SSE event
|
|
||||||
err := sendSSEEvent(c.Writer, *update)
|
err := sendSSEEvent(c.Writer, *update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if trace is complete
|
|
||||||
if update.Type == types.UpdateTypeComplete {
|
if update.Type == types.UpdateTypeComplete {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package trace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/event"
|
"github.com/yaoapp/yao/event"
|
||||||
eventTypes "github.com/yaoapp/yao/event/types"
|
eventTypes "github.com/yaoapp/yao/event/types"
|
||||||
|
|
@ -12,13 +13,16 @@ func dedupKey(u *types.TraceUpdate) string {
|
||||||
return fmt.Sprintf("%s:%s:%d", u.Type, u.NodeID, u.Timestamp)
|
return fmt.Sprintf("%s:%s:%d", u.Type, u.NodeID, u.Timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe creates a new subscription for trace updates (replays all historical events from the beginning)
|
// Subscribe creates a new subscription for trace updates (replays all historical events from the beginning).
|
||||||
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, error) {
|
// Returns the update channel and a cancel function. The caller MUST call
|
||||||
|
// cancel when done (e.g., client disconnect) to release the goroutine.
|
||||||
|
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, func(), error) {
|
||||||
return m.subscribe(0)
|
return m.subscribe(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeFrom creates a subscription starting from a specific timestamp
|
// SubscribeFrom creates a subscription starting from a specific timestamp.
|
||||||
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error) {
|
// Returns the update channel and a cancel function.
|
||||||
|
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, func(), error) {
|
||||||
return m.subscribe(since)
|
return m.subscribe(since)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,12 +30,14 @@ func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error)
|
||||||
// updates, then streams live events via the event service's Subscriber.
|
// updates, then streams live events via the event service's Subscriber.
|
||||||
// The subscriber is registered BEFORE reading historical state to prevent
|
// The subscriber is registered BEFORE reading historical state to prevent
|
||||||
// missing events that occur between the state snapshot and subscriber setup.
|
// missing events that occur between the state snapshot and subscriber setup.
|
||||||
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
//
|
||||||
|
// The returned cancel function triggers event.Unsubscribe which closes
|
||||||
|
// liveCh, causing the goroutine to exit via `for range liveCh`.
|
||||||
|
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, func(), error) {
|
||||||
bufferSize := 1000
|
bufferSize := 1000
|
||||||
|
|
||||||
out := make(chan *types.TraceUpdate, bufferSize)
|
out := make(chan *types.TraceUpdate, bufferSize)
|
||||||
|
|
||||||
// Register live subscriber FIRST to avoid missing events between snapshot and subscribe.
|
|
||||||
liveCh := make(chan *eventTypes.Event, bufferSize)
|
liveCh := make(chan *eventTypes.Event, bufferSize)
|
||||||
traceID := m.traceID
|
traceID := m.traceID
|
||||||
subID := event.Subscribe("trace.*", liveCh, event.Filter(func(ev *eventTypes.Event) bool {
|
subID := event.Subscribe("trace.*", liveCh, event.Filter(func(ev *eventTypes.Event) bool {
|
||||||
|
|
@ -42,19 +48,23 @@ func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
||||||
return update.TraceID == traceID
|
return update.TraceID == traceID
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// THEN snapshot historical updates (may overlap with live events).
|
|
||||||
historical := m.stateGetUpdates(since)
|
historical := m.stateGetUpdates(since)
|
||||||
|
|
||||||
// Build a set of historical event identifiers for dedup.
|
|
||||||
// Key: "type:nodeID:timestamp" is unique enough for trace events.
|
|
||||||
histSeen := make(map[string]struct{}, len(historical))
|
histSeen := make(map[string]struct{}, len(historical))
|
||||||
for _, u := range historical {
|
for _, u := range historical {
|
||||||
histSeen[dedupKey(u)] = struct{}{}
|
histSeen[dedupKey(u)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cancelOnce sync.Once
|
||||||
|
cancel := func() {
|
||||||
|
cancelOnce.Do(func() {
|
||||||
|
event.Unsubscribe(subID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(out)
|
defer close(out)
|
||||||
defer event.Unsubscribe(subID)
|
defer cancel()
|
||||||
|
|
||||||
for _, update := range historical {
|
for _, update := range historical {
|
||||||
out <- update
|
out <- update
|
||||||
|
|
@ -77,5 +87,5 @@ func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return out, nil
|
return out, cancel, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,8 +331,9 @@ func TestAutoCompleteParentEvents(t *testing.T) {
|
||||||
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
// Subscribe to updates
|
// Subscribe to updates
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Collect updates in background
|
// Collect updates in background
|
||||||
var receivedUpdates []*types.TraceUpdate
|
var receivedUpdates []*types.TraceUpdate
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ func BenchmarkSubscription(b *testing.B) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe
|
// Subscribe
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatalf("Failed to subscribe: %s", err.Error())
|
b.Fatalf("Failed to subscribe: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +301,7 @@ func BenchmarkSubscription(b *testing.B) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
trace.Release(traceID)
|
trace.Release(traceID)
|
||||||
trace.Remove(ctx, trace.Local, traceID)
|
trace.Remove(ctx, trace.Local, traceID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ func TestConcurrentSubscribers(t *testing.T) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
numSubscribers := 5
|
numSubscribers := 5
|
||||||
subscribers := make([]<-chan *types.TraceUpdate, numSubscribers)
|
subscribers := make([]<-chan *types.TraceUpdate, numSubscribers)
|
||||||
|
cancels := make([]func(), numSubscribers)
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
||||||
for i := 0; i < numSubscribers; i++ {
|
for i := 0; i < numSubscribers; i++ {
|
||||||
|
|
@ -186,14 +187,22 @@ func TestConcurrentSubscribers(t *testing.T) {
|
||||||
go func(idx int) {
|
go func(idx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
sub, err := manager.Subscribe()
|
sub, cancelSub, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
subscribers[idx] = sub
|
subscribers[idx] = sub
|
||||||
|
cancels[idx] = cancelSub
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, c := range cancels {
|
||||||
|
if c != nil {
|
||||||
|
c()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Verify all subscriptions were created
|
// Verify all subscriptions were created
|
||||||
|
|
|
||||||
|
|
@ -259,10 +259,11 @@ func TestMemoryLeakComplexScenarios(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "WithSubscription",
|
name: "WithSubscription",
|
||||||
execute: func(m types.Manager) error {
|
execute: func(m types.Manager) error {
|
||||||
updates, err := m.Subscribe()
|
updates, cancel, err := m.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Drain updates in background with timeout
|
// Drain updates in background with timeout
|
||||||
done := make(chan bool)
|
done := make(chan bool)
|
||||||
|
|
@ -563,7 +564,7 @@ func TestGoroutineLeak(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe (creates goroutines)
|
// Subscribe (creates goroutines)
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Subscribe failed at iteration %d: %s", i, err.Error())
|
t.Errorf("Subscribe failed at iteration %d: %s", i, err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -598,6 +599,7 @@ func TestGoroutineLeak(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
trace.Release(traceID)
|
trace.Release(traceID)
|
||||||
trace.Remove(ctx, trace.Local, traceID)
|
trace.Remove(ctx, trace.Local, traceID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
157
trace/trace_subscription_leak_test.go
Normal file
157
trace/trace_subscription_leak_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package trace_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/trace"
|
||||||
|
"github.com/yaoapp/yao/trace/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stableGoroutines waits for runtime to settle and returns goroutine count.
|
||||||
|
func stableGoroutines() int {
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
runtime.GC()
|
||||||
|
runtime.Gosched()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return runtime.NumGoroutine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLeak_SubscriptionClientDisconnect reproduces the goroutine leak that
|
||||||
|
// occurs when an SSE client subscribes to a trace and then disconnects
|
||||||
|
// without the trace ever completing (no UpdateTypeComplete sent).
|
||||||
|
//
|
||||||
|
// The subscription goroutine in subscription.go blocks on
|
||||||
|
// `for ev := range liveCh` and never exits because:
|
||||||
|
// 1. liveCh is never closed (event.Unsubscribe only deletes the map entry)
|
||||||
|
// 2. The goroutine only returns on UpdateTypeComplete
|
||||||
|
// 3. No context/cancellation mechanism exists
|
||||||
|
//
|
||||||
|
// This simulates the real-world scenario: SSE handler returns on client
|
||||||
|
// disconnect, but the subscription goroutine keeps running forever.
|
||||||
|
func TestLeak_SubscriptionClientDisconnect(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before := stableGoroutines()
|
||||||
|
|
||||||
|
const numClients = 10
|
||||||
|
|
||||||
|
for i := 0; i < numClients; i++ {
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Client subscribes (like SSE handler calling manager.Subscribe())
|
||||||
|
updates, cancel, err := manager.Subscribe()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, updates)
|
||||||
|
|
||||||
|
// Simulate some trace activity
|
||||||
|
_, err = manager.Add("step", types.TraceNodeOption{Label: "Processing"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Read a couple of events (like the SSE handler would)
|
||||||
|
timeout := time.After(500 * time.Millisecond)
|
||||||
|
drain:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-updates:
|
||||||
|
if !ok {
|
||||||
|
break drain
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
break drain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client disconnects: SSE handler calls cancel (deferred).
|
||||||
|
// This triggers event.Unsubscribe which closes liveCh,
|
||||||
|
// allowing the subscription goroutine to exit.
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
trace.Release(traceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for goroutines to settle
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
after := stableGoroutines()
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
t.Logf("goroutines: before=%d after=%d leaked=%d (over %d simulated client disconnects)", before, after, leaked, numClients)
|
||||||
|
|
||||||
|
// Each Subscribe() spawns a goroutine that should eventually exit.
|
||||||
|
// If it doesn't, we'll see roughly numClients leaked goroutines.
|
||||||
|
if leaked >= numClients {
|
||||||
|
t.Errorf("goroutine leak detected: %d goroutines leaked after %d client disconnects. "+
|
||||||
|
"Subscription goroutines are not cleaned up when clients disconnect without trace completion.",
|
||||||
|
leaked, numClients)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLeak_SubscriptionEventServiceStop reproduces the goroutine leak when
|
||||||
|
// event.Stop() is called (e.g., during shutdown) while subscriptions are active.
|
||||||
|
//
|
||||||
|
// event.Stop() calls smgr.clear() which deletes all subscriber entries but
|
||||||
|
// does NOT close their channels, leaving goroutines blocked on `range liveCh`.
|
||||||
|
func TestLeak_SubscriptionEventServiceStop(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before := stableGoroutines()
|
||||||
|
|
||||||
|
const numSubs = 5
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create multiple subscriptions (simulating multiple SSE clients)
|
||||||
|
for i := 0; i < numSubs; i++ {
|
||||||
|
_, _, err := manager.Subscribe()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate some activity
|
||||||
|
_, err = manager.Add("work", types.TraceNodeOption{Label: "Working"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Stop event service (like during server shutdown)
|
||||||
|
err = event.Stop(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Restart event service for other tests
|
||||||
|
err = event.Start()
|
||||||
|
if err != nil && err != event.ErrAlreadyStart {
|
||||||
|
t.Fatalf("Failed to restart event service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trace.Release(traceID)
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
after := stableGoroutines()
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
t.Logf("goroutines: before=%d after=%d leaked=%d (over %d subscriptions + event.Stop)", before, after, leaked, numSubs)
|
||||||
|
|
||||||
|
if leaked >= numSubs {
|
||||||
|
t.Errorf("goroutine leak detected: %d goroutines leaked after event.Stop() with %d active subscriptions. "+
|
||||||
|
"smgr.clear() does not close subscriber channels.",
|
||||||
|
leaked, numSubs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,9 +24,10 @@ func TestSubscription(t *testing.T) {
|
||||||
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
// Subscribe to updates
|
// Subscribe to updates
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, updates)
|
assert.NotNil(t, updates)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Collect updates in background
|
// Collect updates in background
|
||||||
var receivedUpdates []*types.TraceUpdate
|
var receivedUpdates []*types.TraceUpdate
|
||||||
|
|
@ -146,9 +147,10 @@ func TestSubscribeFrom(t *testing.T) {
|
||||||
|
|
||||||
// Real scenario: User refreshes page and resumes from last known timestamp
|
// Real scenario: User refreshes page and resumes from last known timestamp
|
||||||
// This should replay events from resumeTimestamp onwards
|
// This should replay events from resumeTimestamp onwards
|
||||||
updates, err := manager.SubscribeFrom(resumeTimestamp)
|
updates, cancelSub, err := manager.SubscribeFrom(resumeTimestamp)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, updates)
|
assert.NotNil(t, updates)
|
||||||
|
defer cancelSub()
|
||||||
|
|
||||||
// Collect updates
|
// Collect updates
|
||||||
var receivedUpdates []*types.TraceUpdate
|
var receivedUpdates []*types.TraceUpdate
|
||||||
|
|
@ -233,14 +235,17 @@ func TestMultipleSubscribers(t *testing.T) {
|
||||||
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
// Create multiple subscribers
|
// Create multiple subscribers
|
||||||
sub1, err := manager.Subscribe()
|
sub1, cancel1, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel1()
|
||||||
|
|
||||||
sub2, err := manager.Subscribe()
|
sub2, cancel2, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel2()
|
||||||
|
|
||||||
sub3, err := manager.Subscribe()
|
sub3, cancel3, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel3()
|
||||||
|
|
||||||
// Collect updates from all subscribers
|
// Collect updates from all subscribers
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,13 @@ type Manager interface {
|
||||||
MarkComplete() error
|
MarkComplete() error
|
||||||
|
|
||||||
// Subscription Operations
|
// Subscription Operations
|
||||||
// Subscribe subscribes to trace updates (replay history + real-time)
|
// Subscribe subscribes to trace updates (replay history + real-time).
|
||||||
Subscribe() (<-chan *TraceUpdate, error)
|
// Returns the update channel and a cancel function. The caller MUST call
|
||||||
// SubscribeFrom subscribes from a specific timestamp (for resume)
|
// cancel when done (e.g., client disconnect) to release the goroutine.
|
||||||
SubscribeFrom(since int64) (<-chan *TraceUpdate, error)
|
Subscribe() (<-chan *TraceUpdate, func(), error)
|
||||||
|
// SubscribeFrom subscribes from a specific timestamp (for resume).
|
||||||
|
// Returns the update channel and a cancel function.
|
||||||
|
SubscribeFrom(since int64) (<-chan *TraceUpdate, func(), error)
|
||||||
// IsComplete checks if the trace is completed
|
// IsComplete checks if the trace is completed
|
||||||
IsComplete() bool
|
IsComplete() bool
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue