Merge pull request #1473 from trheyi/main
Update Makefile for enhanced testing and add new event documentation
This commit is contained in:
commit
44755cdbe4
45 changed files with 5521 additions and 872 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -66,3 +66,5 @@ release/*
|
||||||
sandbox/TODO-VNC.md
|
sandbox/TODO-VNC.md
|
||||||
sandbox/docker/chrome/PLAN.md
|
sandbox/docker/chrome/PLAN.md
|
||||||
sandbox/DESIGN-REMOTE.md
|
sandbox/DESIGN-REMOTE.md
|
||||||
|
event/DESIGN.md
|
||||||
|
event/TODO.md
|
||||||
|
|
|
||||||
16
Makefile
16
Makefile
|
|
@ -30,7 +30,7 @@ TESTTAGS ?= ""
|
||||||
unit-test:
|
unit-test:
|
||||||
echo "mode: count" > coverage.out
|
echo "mode: count" > coverage.out
|
||||||
for d in $(TESTFOLDER); do \
|
for d in $(TESTFOLDER); do \
|
||||||
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal' $$d > tmp.out; \
|
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' $$d > tmp.out; \
|
||||||
cat tmp.out; \
|
cat tmp.out; \
|
||||||
if grep -q "^--- FAIL" tmp.out; then \
|
if grep -q "^--- FAIL" tmp.out; then \
|
||||||
rm tmp.out; \
|
rm tmp.out; \
|
||||||
|
|
@ -56,7 +56,7 @@ unit-test:
|
||||||
unit-test-core:
|
unit-test-core:
|
||||||
echo "mode: count" > coverage.out
|
echo "mode: count" > coverage.out
|
||||||
for d in $(TESTFOLDER_CORE); do \
|
for d in $(TESTFOLDER_CORE); do \
|
||||||
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal' $$d > tmp.out; \
|
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' $$d > tmp.out; \
|
||||||
cat tmp.out; \
|
cat tmp.out; \
|
||||||
if grep -q "^--- FAIL" tmp.out; then \
|
if grep -q "^--- FAIL" tmp.out; then \
|
||||||
rm tmp.out; \
|
rm tmp.out; \
|
||||||
|
|
@ -224,9 +224,9 @@ unit-test-sandbox:
|
||||||
benchmark:
|
benchmark:
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
@echo "Running Benchmark Tests (agent & trace)..."
|
@echo "Running Benchmark Tests (agent, trace, event)..."
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
@for d in $$($(GO) list ./agent/... ./trace/...); do \
|
@for d in $$($(GO) list ./agent/... ./trace/... ./event/...); do \
|
||||||
if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \
|
if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \
|
||||||
echo ""; \
|
echo ""; \
|
||||||
echo "📊 Benchmarking: $$d"; \
|
echo "📊 Benchmarking: $$d"; \
|
||||||
|
|
@ -244,14 +244,14 @@ benchmark:
|
||||||
memory-leak:
|
memory-leak:
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
@echo "Running Memory Leak Detection (agent & trace)..."
|
@echo "Running Memory Leak Detection (agent, trace, event)..."
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
@for d in $$($(GO) list ./agent/... ./trace/...); do \
|
@for d in $$($(GO) list ./agent/... ./trace/... ./event/...); do \
|
||||||
if $(GO) test -list='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak' $$d 2>/dev/null | grep -qE "^Test(MemoryLeak|IsolateDisposal|GoroutineLeak)"; then \
|
if $(GO) test -list='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak|TestLeak_|TestScenario_' $$d 2>/dev/null | grep -qE "^Test(MemoryLeak|IsolateDisposal|GoroutineLeak|Leak_|Scenario_)"; then \
|
||||||
echo ""; \
|
echo ""; \
|
||||||
echo "🔍 Memory Leak Detection: $$d"; \
|
echo "🔍 Memory Leak Detection: $$d"; \
|
||||||
echo "---------------------------------------------"; \
|
echo "---------------------------------------------"; \
|
||||||
$(GO) test -run='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak' -v -timeout=5m $$d || exit 1; \
|
$(GO) test -run='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak|TestLeak_|TestScenario_' -v -timeout=5m $$d || exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
done
|
done
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
|
|
||||||
// Log end of request
|
// Log end of request
|
||||||
ctx.Logger.End(finalStatus == context.StepStatusCompleted, finalError)
|
ctx.Logger.End(finalStatus == context.StepStatusCompleted, finalError)
|
||||||
|
ctx.Logger.RestoreAssistantID()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Determine stream handler
|
// Determine stream handler
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,7 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
|
||||||
result.Content = result.Error.Error()
|
result.Content = result.Error.Error()
|
||||||
result.IsRetryableError = true // Argument parsing error is retryable by LLM
|
result.IsRetryableError = true // Argument parsing error is retryable by LLM
|
||||||
ctx.Logger.Error("Failed to parse arguments: %v", err)
|
ctx.Logger.Error("Failed to parse arguments: %v", err)
|
||||||
|
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(result.Error)
|
toolNode.Fail(result.Error)
|
||||||
}
|
}
|
||||||
|
|
@ -333,6 +334,7 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
|
||||||
result.Content = result.Error.Error()
|
result.Content = result.Error.Error()
|
||||||
result.IsRetryableError = true // Type error is retryable by LLM
|
result.IsRetryableError = true // Type error is retryable by LLM
|
||||||
ctx.Logger.Error("Arguments must be an object, got %T", parsed)
|
ctx.Logger.Error("Arguments must be an object, got %T", parsed)
|
||||||
|
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(result.Error)
|
toolNode.Fail(result.Error)
|
||||||
}
|
}
|
||||||
|
|
@ -346,6 +348,7 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
|
||||||
result.Content = result.Error.Error()
|
result.Content = result.Error.Error()
|
||||||
result.IsRetryableError = true // Validation error is retryable by LLM
|
result.IsRetryableError = true // Validation error is retryable by LLM
|
||||||
ctx.Logger.Error("Argument validation failed: %v", err)
|
ctx.Logger.Error("Argument validation failed: %v", err)
|
||||||
|
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(result.Error)
|
toolNode.Fail(result.Error)
|
||||||
}
|
}
|
||||||
|
|
@ -393,7 +396,7 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Content = string(contentBytes)
|
result.Content = string(contentBytes)
|
||||||
ctx.Logger.ToolComplete(toolName, true)
|
ctx.Logger.ToolComplete(toolCall.Function.Name, true)
|
||||||
|
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Complete(map[string]any{
|
toolNode.Complete(map[string]any{
|
||||||
|
|
@ -563,6 +566,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
|
||||||
Arguments: args,
|
Arguments: args,
|
||||||
})
|
})
|
||||||
callMap[toolName] = tc
|
callMap[toolName] = tc
|
||||||
|
ctx.Logger.ToolStart(tc.Function.Name)
|
||||||
|
|
||||||
// Add trace input for this tool
|
// Add trace input for this tool
|
||||||
parallelInputs = append(parallelInputs, types.TraceParallelInput{
|
parallelInputs = append(parallelInputs, types.TraceParallelInput{
|
||||||
|
|
@ -598,11 +602,15 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
|
||||||
mcpResponse, err := client.CallToolsParallel(mcpCtx, mcpCalls, ctx)
|
mcpResponse, err := client.CallToolsParallel(mcpCtx, mcpCalls, ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Logger.Error("Parallel call failed: %v", err)
|
ctx.Logger.Error("Parallel call failed: %v", err)
|
||||||
// Mark all trace nodes as failed
|
for i, node := range toolNodes {
|
||||||
for _, node := range toolNodes {
|
|
||||||
if node != nil {
|
if node != nil {
|
||||||
node.Fail(err)
|
node.Fail(err)
|
||||||
}
|
}
|
||||||
|
if i < len(mcpCalls) {
|
||||||
|
if tc, ok := callMap[mcpCalls[i].Name]; ok {
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil, true
|
return nil, true
|
||||||
}
|
}
|
||||||
|
|
@ -631,6 +639,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
|
||||||
result.Content = result.Error.Error()
|
result.Content = result.Error.Error()
|
||||||
result.IsRetryableError = false // Serialization error is not retryable
|
result.IsRetryableError = false // Serialization error is not retryable
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
|
ctx.Logger.ToolComplete(originalCall.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(result.Error)
|
toolNode.Fail(result.Error)
|
||||||
}
|
}
|
||||||
|
|
@ -643,11 +652,12 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
|
||||||
result.IsRetryableError = isRetryableToolError(result.Error)
|
result.IsRetryableError = isRetryableToolError(result.Error)
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
|
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
|
||||||
|
ctx.Logger.ToolComplete(originalCall.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(result.Error)
|
toolNode.Fail(result.Error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Success
|
ctx.Logger.ToolComplete(originalCall.Function.Name, true)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Complete(map[string]any{
|
toolNode.Complete(map[string]any{
|
||||||
"result": mcpResult.Content,
|
"result": mcpResult.Content,
|
||||||
|
|
@ -670,6 +680,8 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
|
||||||
ctx.Logger.Debug("Calling %d tools sequentially on server '%s'", len(toolCalls), serverID)
|
ctx.Logger.Debug("Calling %d tools sequentially on server '%s'", len(toolCalls), serverID)
|
||||||
|
|
||||||
for _, tc := range toolCalls {
|
for _, tc := range toolCalls {
|
||||||
|
ctx.Logger.ToolStart(tc.Function.Name)
|
||||||
|
|
||||||
_, toolName, ok := ParseMCPToolName(tc.Function.Name)
|
_, toolName, ok := ParseMCPToolName(tc.Function.Name)
|
||||||
if !ok {
|
if !ok {
|
||||||
results = append(results, ToolCallResult{
|
results = append(results, ToolCallResult{
|
||||||
|
|
@ -678,6 +690,7 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
|
||||||
Content: fmt.Sprintf("Invalid tool name format: %s", tc.Function.Name),
|
Content: fmt.Sprintf("Invalid tool name format: %s", tc.Function.Name),
|
||||||
Error: fmt.Errorf("invalid tool name format"),
|
Error: fmt.Errorf("invalid tool name format"),
|
||||||
})
|
})
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -727,6 +740,7 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
|
||||||
}
|
}
|
||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(err)
|
toolNode.Fail(err)
|
||||||
}
|
}
|
||||||
|
|
@ -747,6 +761,7 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
|
||||||
}
|
}
|
||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(err)
|
toolNode.Fail(err)
|
||||||
}
|
}
|
||||||
|
|
@ -765,6 +780,7 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
|
||||||
}
|
}
|
||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(err)
|
toolNode.Fail(err)
|
||||||
}
|
}
|
||||||
|
|
@ -788,6 +804,7 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
|
||||||
result.IsRetryableError = isRetryableToolError(err)
|
result.IsRetryableError = isRetryableToolError(err)
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
ctx.Logger.Error("Tool call failed: %s - %v (retryable: %v)", toolName, err, result.IsRetryableError)
|
ctx.Logger.Error("Tool call failed: %s - %v (retryable: %v)", toolName, err, result.IsRetryableError)
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(err)
|
toolNode.Fail(err)
|
||||||
}
|
}
|
||||||
|
|
@ -806,11 +823,13 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
|
||||||
result.Content = fmt.Sprintf("Failed to serialize result: %v", err)
|
result.Content = fmt.Sprintf("Failed to serialize result: %v", err)
|
||||||
result.IsRetryableError = false // Serialization error is not retryable
|
result.IsRetryableError = false // Serialization error is not retryable
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Fail(err)
|
toolNode.Fail(err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.Content = string(contentBytes)
|
result.Content = string(contentBytes)
|
||||||
|
ctx.Logger.ToolComplete(tc.Function.Name, !mcpResult.IsError)
|
||||||
if toolNode != nil {
|
if toolNode != nil {
|
||||||
toolNode.Complete(map[string]any{
|
toolNode.Complete(map[string]any{
|
||||||
"result": mcpResult.Content,
|
"result": mcpResult.Content,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/trace/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Orchestrator handles parallel agent calls with different concurrency patterns
|
// Orchestrator handles parallel agent calls with different concurrency patterns
|
||||||
|
|
@ -257,13 +258,60 @@ func (o *Orchestrator) callAgentWithContext(ctx *agentContext.Context, req *Requ
|
||||||
ctxOpts.OnMessage = req.Handler
|
ctxOpts.OnMessage = req.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add trace node for A2A call using the ORIGINAL parent context's trace
|
||||||
|
// (forked contexts have nil trace and nil Stack, so ctx.Trace() would create a new orphan trace)
|
||||||
|
parentTrace, _ := o.ctx.Trace()
|
||||||
|
var a2aNode types.Node
|
||||||
|
if parentTrace != nil {
|
||||||
|
a2aNode, _ = parentTrace.Add(
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": req.AgentID,
|
||||||
|
"referer": string(ctx.Referer),
|
||||||
|
},
|
||||||
|
types.TraceNodeOption{
|
||||||
|
Label: fmt.Sprintf("Agent: %s", req.AgentID),
|
||||||
|
Type: "agent_call",
|
||||||
|
Icon: "smart_toy",
|
||||||
|
Description: fmt.Sprintf("A2A call to '%s'", req.AgentID),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify TUI of A2A call start (use parent requestID so it appears in parent panel)
|
||||||
|
parentRequestID := o.ctx.RequestID()
|
||||||
|
agentContext.SendTUI(agentContext.AgentEventMsg{
|
||||||
|
RequestID: parentRequestID,
|
||||||
|
Event: agentContext.EventA2AStart,
|
||||||
|
Data: map[string]interface{}{"target": req.AgentID},
|
||||||
|
})
|
||||||
|
|
||||||
// Execute the agent call with the provided context
|
// Execute the agent call with the provided context
|
||||||
// The agent.Stream method will use the context's Writer for output
|
// The agent.Stream method will use the context's Writer for output
|
||||||
resp, err := agent.Stream(ctx, req.Messages, ctxOpts)
|
resp, err := agent.Stream(ctx, req.Messages, ctxOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if a2aNode != nil {
|
||||||
|
a2aNode.Fail(err)
|
||||||
|
}
|
||||||
|
agentContext.SendTUI(agentContext.AgentEventMsg{
|
||||||
|
RequestID: parentRequestID,
|
||||||
|
Event: agentContext.EventA2ADone,
|
||||||
|
Data: map[string]interface{}{"target": req.AgentID, "error": err.Error()},
|
||||||
|
})
|
||||||
return NewResult(req.AgentID, nil, fmt.Errorf("agent call failed: %w", err))
|
return NewResult(req.AgentID, nil, fmt.Errorf("agent call failed: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a2aNode != nil {
|
||||||
|
a2aNode.Complete(map[string]any{
|
||||||
|
"agent_id": req.AgentID,
|
||||||
|
"status": "completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
agentContext.SendTUI(agentContext.AgentEventMsg{
|
||||||
|
RequestID: parentRequestID,
|
||||||
|
Event: agentContext.EventA2ADone,
|
||||||
|
Data: map[string]interface{}{"target": req.AgentID},
|
||||||
|
})
|
||||||
|
|
||||||
return NewResult(req.AgentID, resp, nil)
|
return NewResult(req.AgentID, resp, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,19 +71,21 @@ func (ctx *Context) Release() {
|
||||||
ctx.Interrupt = nil
|
ctx.Interrupt = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete and release trace if exists
|
// Complete and release trace if exists.
|
||||||
|
// Only the root context (non-forked) owns the trace lifecycle.
|
||||||
|
// Forked contexts share the same trace manager but must not release it.
|
||||||
if ctx.trace != nil && ctx.Stack != nil && ctx.Stack.TraceID != "" {
|
if ctx.trace != nil && ctx.Stack != nil && ctx.Stack.TraceID != "" {
|
||||||
if ctx.Logger != nil {
|
if ctx.ForkParent == nil {
|
||||||
ctx.Logger.Cleanup("Trace: " + ctx.Stack.TraceID)
|
if ctx.Logger != nil {
|
||||||
}
|
ctx.Logger.Cleanup("Trace: " + ctx.Stack.TraceID)
|
||||||
|
}
|
||||||
// Check if context is cancelled - if so, mark as cancelled instead of complete
|
if ctx.Context != nil && ctx.Context.Err() != nil {
|
||||||
if ctx.Context != nil && ctx.Context.Err() != nil {
|
trace.MarkCancelled(ctx.Stack.TraceID, ctx.Context.Err().Error())
|
||||||
trace.MarkCancelled(ctx.Stack.TraceID, ctx.Context.Err().Error())
|
trace.Release(ctx.Stack.TraceID)
|
||||||
trace.Release(ctx.Stack.TraceID)
|
} else {
|
||||||
} else {
|
ctx.trace.MarkComplete()
|
||||||
ctx.trace.MarkComplete()
|
trace.Release(ctx.Stack.TraceID)
|
||||||
trace.Release(ctx.Stack.TraceID)
|
}
|
||||||
}
|
}
|
||||||
ctx.trace = nil
|
ctx.trace = nil
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +193,7 @@ func (ctx *Context) Fork() *Context {
|
||||||
|
|
||||||
// Create independent resources to avoid race conditions
|
// Create independent resources to avoid race conditions
|
||||||
IDGenerator: message.NewIDGenerator(),
|
IDGenerator: message.NewIDGenerator(),
|
||||||
Logger: NewRequestLogger(ctx.AssistantID, ctx.ChatID, childID),
|
Logger: NewRequestLogger(ctx.AssistantID, ctx.ChatID, childID, WithParentID(ctx.ID)),
|
||||||
messageMetadata: newMessageMetadataStore(),
|
messageMetadata: newMessageMetadataStore(),
|
||||||
|
|
||||||
// Inherit context metadata
|
// Inherit context metadata
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,12 @@ type LogEntry struct {
|
||||||
|
|
||||||
// RequestLogger provides request-scoped async logging
|
// RequestLogger provides request-scoped async logging
|
||||||
type RequestLogger struct {
|
type RequestLogger struct {
|
||||||
assistantID string
|
assistantIDStack []string // Stack-based: delegate calls push, pop on exit; top = current
|
||||||
chatID string
|
chatID string
|
||||||
requestID string
|
requestID string
|
||||||
shortID string // Short version of requestID for display
|
shortID string // Short version of requestID for display
|
||||||
startTime time.Time
|
parentID string // Parent request ID for A2A tree structure
|
||||||
|
startTime time.Time
|
||||||
|
|
||||||
ch chan LogEntry
|
ch chan LogEntry
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
|
|
@ -86,19 +87,33 @@ type RequestLogger struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoggerOption configures a RequestLogger
|
||||||
|
type LoggerOption func(*RequestLogger)
|
||||||
|
|
||||||
|
// WithParentID sets the parent request ID for A2A tree structure
|
||||||
|
func WithParentID(parentID string) LoggerOption {
|
||||||
|
return func(l *RequestLogger) {
|
||||||
|
l.parentID = parentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// noopLogger is a shared no-op logger instance
|
// noopLogger is a shared no-op logger instance
|
||||||
var noopLogger = &RequestLogger{noop: true}
|
var noopLogger = &RequestLogger{noop: true}
|
||||||
|
|
||||||
// NewRequestLogger creates a new request-scoped logger with async processing
|
// NewRequestLogger creates a new request-scoped logger with async processing
|
||||||
func NewRequestLogger(assistantID, chatID, requestID string) *RequestLogger {
|
func NewRequestLogger(assistantID, chatID, requestID string, opts ...LoggerOption) *RequestLogger {
|
||||||
l := &RequestLogger{
|
l := &RequestLogger{
|
||||||
assistantID: assistantID,
|
assistantIDStack: []string{assistantID},
|
||||||
chatID: chatID,
|
chatID: chatID,
|
||||||
requestID: requestID,
|
requestID: requestID,
|
||||||
shortID: shortID(requestID),
|
shortID: shortID(requestID),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
ch: make(chan LogEntry, 100), // Buffered channel
|
ch: make(chan LogEntry, 100), // Buffered channel
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(l)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start consumer goroutine
|
// Start consumer goroutine
|
||||||
|
|
@ -112,12 +127,35 @@ func Noop() *RequestLogger {
|
||||||
return noopLogger
|
return noopLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAssistantID sets the assistant ID (called when entering Stream)
|
// SetAssistantID pushes a new assistant ID onto the stack (called when entering Stream).
|
||||||
|
// Each SetAssistantID must be paired with a RestoreAssistantID on exit.
|
||||||
func (l *RequestLogger) SetAssistantID(id string) {
|
func (l *RequestLogger) SetAssistantID(id string) {
|
||||||
if l.noop {
|
if l.noop {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
l.assistantID = id
|
l.mu.Lock()
|
||||||
|
l.assistantIDStack = append(l.assistantIDStack, id)
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreAssistantID pops the current assistant ID, reverting to the previous one.
|
||||||
|
// Safe to call even if the stack has only one entry (the initial ID is never removed).
|
||||||
|
func (l *RequestLogger) RestoreAssistantID() {
|
||||||
|
if l.noop {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
if len(l.assistantIDStack) > 1 {
|
||||||
|
l.assistantIDStack = l.assistantIDStack[:len(l.assistantIDStack)-1]
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RequestLogger) currentAssistantID() string {
|
||||||
|
if len(l.assistantIDStack) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return l.assistantIDStack[len(l.assistantIDStack)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the logger and waits for all entries to be processed
|
// Close closes the logger and waits for all entries to be processed
|
||||||
|
|
@ -148,13 +186,17 @@ func (l *RequestLogger) consume() {
|
||||||
func (l *RequestLogger) processEntry(entry LogEntry) {
|
func (l *RequestLogger) processEntry(entry LogEntry) {
|
||||||
if config.IsDevelopment() {
|
if config.IsDevelopment() {
|
||||||
l.printDev(entry)
|
l.printDev(entry)
|
||||||
|
l.writeLog(entry, true)
|
||||||
} else {
|
} else {
|
||||||
l.printProd(entry)
|
l.writeLog(entry, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// printDev prints colorful output for development mode
|
// printDev sends to TUI if available, otherwise prints colored output to stdout
|
||||||
func (l *RequestLogger) printDev(entry LogEntry) {
|
func (l *RequestLogger) printDev(entry LogEntry) {
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch entry.Level {
|
switch entry.Level {
|
||||||
case LogLevelTrace:
|
case LogLevelTrace:
|
||||||
fmt.Printf("%s → %s%s\n", colorGray, entry.Message, colorReset)
|
fmt.Printf("%s → %s%s\n", colorGray, entry.Message, colorReset)
|
||||||
|
|
@ -169,10 +211,13 @@ func (l *RequestLogger) printDev(entry LogEntry) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// printProd logs to kun/log for production mode
|
// writeLog writes structured events to kun/log
|
||||||
func (l *RequestLogger) printProd(entry LogEntry) {
|
func (l *RequestLogger) writeLog(entry LogEntry, devMode bool) {
|
||||||
prefix := fmt.Sprintf("[AGENT] %s ", l.shortID)
|
prefix := fmt.Sprintf("[AGENT] %s ", l.shortID)
|
||||||
|
if devMode {
|
||||||
|
kunlog.Trace("%s%s", prefix, entry.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch entry.Level {
|
switch entry.Level {
|
||||||
case LogLevelTrace:
|
case LogLevelTrace:
|
||||||
kunlog.Trace("%s%s", prefix, entry.Message)
|
kunlog.Trace("%s%s", prefix, entry.Message)
|
||||||
|
|
@ -263,18 +308,29 @@ func (l *RequestLogger) Start() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kunlog.Trace("[AGENT] Request %s started: assistant=%s, chat=%s, request=%s",
|
||||||
|
l.shortID, l.currentAssistantID(), shortID(l.chatID), shortID(l.requestID))
|
||||||
|
|
||||||
if !config.IsDevelopment() {
|
if !config.IsDevelopment() {
|
||||||
kunlog.Trace("[AGENT] Request %s started: assistant=%s, chat=%s, request=%s",
|
|
||||||
l.shortID, l.assistantID, shortID(l.chatID), shortID(l.requestID))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development: colorful output (direct print, not through channel for immediate display)
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
ParentID: l.parentID,
|
||||||
|
AssistantID: l.currentAssistantID(),
|
||||||
|
Event: EventRequestStart,
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s%s%s\n", colorBoldCyan, strings.Repeat("═", 60), colorReset)
|
fmt.Printf("%s%s%s\n", colorBoldCyan, strings.Repeat("═", 60), colorReset)
|
||||||
fmt.Printf("%s 🚀 AGENT REQUEST %s%s\n", colorBoldCyan, l.shortID, colorReset)
|
fmt.Printf("%s AGENT REQUEST %s%s\n", colorBoldCyan, l.shortID, colorReset)
|
||||||
fmt.Printf("%s%s%s\n", colorBoldCyan, strings.Repeat("─", 60), colorReset)
|
fmt.Printf("%s%s%s\n", colorBoldCyan, strings.Repeat("─", 60), colorReset)
|
||||||
fmt.Printf("%s Assistant: %s%s%s\n", colorGray, colorWhite, l.assistantID, colorReset)
|
fmt.Printf("%s Assistant: %s%s%s\n", colorGray, colorWhite, l.currentAssistantID(), colorReset)
|
||||||
fmt.Printf("%s Chat ID: %s%s%s\n", colorGray, colorWhite, l.chatID, colorReset)
|
fmt.Printf("%s Chat ID: %s%s%s\n", colorGray, colorWhite, l.chatID, colorReset)
|
||||||
fmt.Printf("%s Request: %s%s%s\n", colorGray, colorWhite, l.requestID, colorReset)
|
fmt.Printf("%s Request: %s%s%s\n", colorGray, colorWhite, l.requestID, colorReset)
|
||||||
fmt.Printf("%s Time: %s%s%s\n", colorGray, colorWhite, l.startTime.Format("15:04:05.000"), colorReset)
|
fmt.Printf("%s Time: %s%s%s\n", colorGray, colorWhite, l.startTime.Format("15:04:05.000"), colorReset)
|
||||||
|
|
@ -289,28 +345,43 @@ func (l *RequestLogger) End(success bool, err error) {
|
||||||
|
|
||||||
duration := time.Since(l.startTime)
|
duration := time.Since(l.startTime)
|
||||||
|
|
||||||
|
if success {
|
||||||
|
kunlog.Trace("[AGENT] Request %s completed: assistant=%s, duration=%v",
|
||||||
|
l.shortID, l.currentAssistantID(), duration.Round(time.Millisecond))
|
||||||
|
} else {
|
||||||
|
kunlog.Error("[AGENT] Request %s failed: assistant=%s, duration=%v, error=%v",
|
||||||
|
l.shortID, l.currentAssistantID(), duration.Round(time.Millisecond), err)
|
||||||
|
}
|
||||||
|
|
||||||
if !config.IsDevelopment() {
|
if !config.IsDevelopment() {
|
||||||
if success {
|
|
||||||
kunlog.Trace("[AGENT] Request %s completed: assistant=%s, duration=%v",
|
|
||||||
l.shortID, l.assistantID, duration.Round(time.Millisecond))
|
|
||||||
} else {
|
|
||||||
kunlog.Trace("[AGENT] Request %s failed: assistant=%s, duration=%v, error=%v",
|
|
||||||
l.shortID, l.assistantID, duration.Round(time.Millisecond), err)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development: colorful output (direct print for immediate display)
|
data := map[string]interface{}{"duration": duration.Round(time.Millisecond)}
|
||||||
|
if err != nil {
|
||||||
|
data["error"] = err.Error()
|
||||||
|
}
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
AssistantID: l.currentAssistantID(),
|
||||||
|
Event: EventRequestEnd,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("%s%s%s\n", colorCyan, strings.Repeat("─", 60), colorReset)
|
fmt.Printf("%s%s%s\n", colorCyan, strings.Repeat("─", 60), colorReset)
|
||||||
if success {
|
if success {
|
||||||
fmt.Printf("%s ✅ REQUEST %s COMPLETED%s\n", colorBoldGreen, l.shortID, colorReset)
|
fmt.Printf("%s REQUEST %s COMPLETED%s\n", colorBoldGreen, l.shortID, colorReset)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("%s ❌ REQUEST %s FAILED%s\n", colorBoldRed, l.shortID, colorReset)
|
fmt.Printf("%s REQUEST %s FAILED%s\n", colorBoldRed, l.shortID, colorReset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("%s Error: %s%v%s\n", colorGray, colorRed, err, colorReset)
|
fmt.Printf("%s Error: %s%v%s\n", colorGray, colorRed, err, colorReset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Printf("%s Assistant: %s%s%s\n", colorGray, colorWhite, l.assistantID, colorReset)
|
fmt.Printf("%s Assistant: %s%s%s\n", colorGray, colorWhite, l.currentAssistantID(), colorReset)
|
||||||
fmt.Printf("%s Duration: %s%v%s\n", colorGray, colorWhite, duration.Round(time.Millisecond), colorReset)
|
fmt.Printf("%s Duration: %s%v%s\n", colorGray, colorWhite, duration.Round(time.Millisecond), colorReset)
|
||||||
fmt.Printf("%s%s%s\n", colorCyan, strings.Repeat("─", 60), colorReset)
|
fmt.Printf("%s%s%s\n", colorCyan, strings.Repeat("─", 60), colorReset)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
@ -323,12 +394,22 @@ func (l *RequestLogger) Phase(name string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
||||||
|
kunlog.Trace("[AGENT] %s Phase: %s (+%v)", l.shortID, name, elapsed)
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
if !config.IsDevelopment() {
|
||||||
fmt.Printf("%s ▶ %s%s %s[+%v]%s\n", colorBoldBlue, name, colorReset, colorGray, elapsed, colorReset)
|
return
|
||||||
} else {
|
|
||||||
kunlog.Trace("[AGENT] %s Phase: %s (+%v)", l.shortID, name, elapsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventPhase,
|
||||||
|
Data: map[string]interface{}{"name": name, "elapsed": elapsed},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s > %s%s %s[+%v]%s\n", colorBoldBlue, name, colorReset, colorGray, elapsed, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PhaseComplete logs the completion of a phase
|
// PhaseComplete logs the completion of a phase
|
||||||
|
|
@ -338,12 +419,22 @@ func (l *RequestLogger) PhaseComplete(name string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
||||||
|
kunlog.Trace("[AGENT] %s Phase completed: %s (+%v)", l.shortID, name, elapsed)
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
if !config.IsDevelopment() {
|
||||||
fmt.Printf("%s ✓ %s%s %s[+%v]%s\n", colorGreen, name, colorReset, colorGray, elapsed, colorReset)
|
return
|
||||||
} else {
|
|
||||||
kunlog.Trace("[AGENT] %s Phase completed: %s (+%v)", l.shortID, name, elapsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventPhaseDone,
|
||||||
|
Data: map[string]interface{}{"name": name, "elapsed": elapsed},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s + %s%s %s[+%v]%s\n", colorGreen, name, colorReset, colorGray, elapsed, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PhaseSkip logs a skipped phase (development only)
|
// PhaseSkip logs a skipped phase (development only)
|
||||||
|
|
@ -351,9 +442,21 @@ func (l *RequestLogger) PhaseSkip(name, reason string) {
|
||||||
if l.noop {
|
if l.noop {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if config.IsDevelopment() {
|
|
||||||
fmt.Printf("%s ⊘ %s (%s)%s\n", colorGray, name, reason, colorReset)
|
if !config.IsDevelopment() {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventPhaseSkip,
|
||||||
|
Data: map[string]interface{}{"name": name, "reason": reason},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s - %s (%s)%s\n", colorGray, name, reason, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMStart logs the start of an LLM call
|
// LLMStart logs the start of an LLM call
|
||||||
|
|
@ -363,17 +466,31 @@ func (l *RequestLogger) LLMStart(connector, model string, messageCount int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
||||||
|
kunlog.Trace("[AGENT] %s LLM call: connector=%s, model=%s, messages=%d (+%v)", l.shortID, connector, model, messageCount, elapsed)
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
if !config.IsDevelopment() {
|
||||||
fmt.Printf("%s 🤖 LLM Call%s %s[+%v]%s\n", colorBoldMagenta, colorReset, colorGray, elapsed, colorReset)
|
return
|
||||||
fmt.Printf("%s Connector: %s%s%s\n", colorGray, colorWhite, connector, colorReset)
|
|
||||||
if model != "" {
|
|
||||||
fmt.Printf("%s Model: %s%s%s\n", colorGray, colorWhite, model, colorReset)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s Messages: %s%d%s\n", colorGray, colorWhite, messageCount, colorReset)
|
|
||||||
} else {
|
|
||||||
kunlog.Trace("[AGENT] %s LLM call: connector=%s, model=%s, messages=%d (+%v)", l.shortID, connector, model, messageCount, elapsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventLLMCall,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"connector": connector,
|
||||||
|
"model": model,
|
||||||
|
"messages": messageCount,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s LLM Call%s %s[+%v]%s\n", colorBoldMagenta, colorReset, colorGray, elapsed, colorReset)
|
||||||
|
fmt.Printf("%s Connector: %s%s%s\n", colorGray, colorWhite, connector, colorReset)
|
||||||
|
if model != "" {
|
||||||
|
fmt.Printf("%s Model: %s%s%s\n", colorGray, colorWhite, model, colorReset)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s Messages: %s%d%s\n", colorGray, colorWhite, messageCount, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMComplete logs the completion of an LLM call
|
// LLMComplete logs the completion of an LLM call
|
||||||
|
|
@ -388,15 +505,30 @@ func (l *RequestLogger) LLMComplete(tokens int, hasToolCalls bool) {
|
||||||
status = "tool_calls"
|
status = "tool_calls"
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
kunlog.Trace("[AGENT] %s LLM response: status=%s, tokens=%d (+%v)", l.shortID, status, tokens, elapsed)
|
||||||
fmt.Printf("%s ✓ LLM Response (%s)%s", colorGreen, status, colorReset)
|
|
||||||
if tokens > 0 {
|
if !config.IsDevelopment() {
|
||||||
fmt.Printf(" %s[tokens: %d]%s", colorGray, tokens, colorReset)
|
return
|
||||||
}
|
|
||||||
fmt.Printf(" %s[+%v]%s\n", colorGray, elapsed, colorReset)
|
|
||||||
} else {
|
|
||||||
kunlog.Trace("[AGENT] %s LLM response: status=%s, tokens=%d (+%v)", l.shortID, status, tokens, elapsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventLLMDone,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"detail": fmt.Sprintf("%s [tokens:%d, %v]", status, tokens, elapsed),
|
||||||
|
"tokens": tokens,
|
||||||
|
"status": status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s + LLM Response (%s)%s", colorGreen, status, colorReset)
|
||||||
|
if tokens > 0 {
|
||||||
|
fmt.Printf(" %s[tokens: %d]%s", colorGray, tokens, colorReset)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s[+%v]%s\n", colorGray, elapsed, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolStart logs the start of tool execution
|
// ToolStart logs the start of tool execution
|
||||||
|
|
@ -405,11 +537,22 @@ func (l *RequestLogger) ToolStart(toolName string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
kunlog.Trace("[AGENT] %s Tool call: %s", l.shortID, toolName)
|
||||||
fmt.Printf("%s 🔧 Tool: %s%s\n", colorYellow, toolName, colorReset)
|
|
||||||
} else {
|
if !config.IsDevelopment() {
|
||||||
kunlog.Trace("[AGENT] %s Tool call: %s", l.shortID, toolName)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventToolCall,
|
||||||
|
Data: map[string]interface{}{"name": toolName},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s Tool: %s%s\n", colorYellow, toolName, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolComplete logs the completion of tool execution
|
// ToolComplete logs the completion of tool execution
|
||||||
|
|
@ -418,18 +561,29 @@ func (l *RequestLogger) ToolComplete(toolName string, success bool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
if success {
|
||||||
if success {
|
kunlog.Trace("[AGENT] %s Tool completed: %s", l.shortID, toolName)
|
||||||
fmt.Printf("%s ✓ %s completed%s\n", colorGreen, toolName, colorReset)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("%s ✗ %s failed%s\n", colorRed, toolName, colorReset)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if success {
|
kunlog.Error("[AGENT] %s Tool failed: %s", l.shortID, toolName)
|
||||||
kunlog.Trace("[AGENT] %s Tool completed: %s", l.shortID, toolName)
|
}
|
||||||
} else {
|
|
||||||
kunlog.Trace("[AGENT] %s Tool failed: %s", l.shortID, toolName)
|
if !config.IsDevelopment() {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventToolDone,
|
||||||
|
Data: map[string]interface{}{"name": toolName, "success": success},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
fmt.Printf("%s + %s completed%s\n", colorGreen, toolName, colorReset)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s x %s failed%s\n", colorRed, toolName, colorReset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,12 +594,22 @@ func (l *RequestLogger) HookStart(hookName string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
elapsed := time.Since(l.startTime).Round(time.Millisecond)
|
||||||
|
kunlog.Trace("[AGENT] %s Hook: %s (+%v)", l.shortID, hookName, elapsed)
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
if !config.IsDevelopment() {
|
||||||
fmt.Printf("%s 🪝 Hook: %s%s %s[+%v]%s\n", colorMagenta, hookName, colorReset, colorGray, elapsed, colorReset)
|
return
|
||||||
} else {
|
|
||||||
kunlog.Trace("[AGENT] %s Hook: %s (+%v)", l.shortID, hookName, elapsed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventHook,
|
||||||
|
Data: map[string]interface{}{"name": hookName},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s Hook: %s%s %s[+%v]%s\n", colorMagenta, hookName, colorReset, colorGray, elapsed, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HookComplete logs the completion of a hook
|
// HookComplete logs the completion of a hook
|
||||||
|
|
@ -454,11 +618,22 @@ func (l *RequestLogger) HookComplete(hookName string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
kunlog.Trace("[AGENT] %s Hook completed: %s", l.shortID, hookName)
|
||||||
fmt.Printf("%s ✓ %s done%s\n", colorGreen, hookName, colorReset)
|
|
||||||
} else {
|
if !config.IsDevelopment() {
|
||||||
kunlog.Trace("[AGENT] %s Hook completed: %s", l.shortID, hookName)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventHookDone,
|
||||||
|
Data: map[string]interface{}{"name": hookName},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s + %s done%s\n", colorGreen, hookName, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup logs resource cleanup
|
// Cleanup logs resource cleanup
|
||||||
|
|
@ -467,11 +642,12 @@ func (l *RequestLogger) Cleanup(resource string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
kunlog.Trace("[AGENT] %s Cleanup: %s", l.shortID, resource)
|
||||||
fmt.Printf("%s ✓ %s%s\n", colorGray, resource, colorReset)
|
|
||||||
} else {
|
if !config.IsDevelopment() || GetTUIProgram() != nil {
|
||||||
kunlog.Trace("[AGENT] %s Cleanup: %s", l.shortID, resource)
|
return
|
||||||
}
|
}
|
||||||
|
fmt.Printf("%s + %s%s\n", colorGray, resource, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryLoad logs history loading
|
// HistoryLoad logs history loading
|
||||||
|
|
@ -480,11 +656,12 @@ func (l *RequestLogger) HistoryLoad(count, maxSize int) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
kunlog.Trace("[AGENT] %s History loaded: %d/%d messages", l.shortID, count, maxSize)
|
||||||
fmt.Printf("%s Loaded %d/%d history messages%s\n", colorGray, count, maxSize, colorReset)
|
|
||||||
} else {
|
if !config.IsDevelopment() || GetTUIProgram() != nil {
|
||||||
kunlog.Trace("[AGENT] %s History loaded: %d/%d messages", l.shortID, count, maxSize)
|
return
|
||||||
}
|
}
|
||||||
|
fmt.Printf("%s Loaded %d/%d history messages%s\n", colorGray, count, maxSize, colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryOverlap logs overlap detection
|
// HistoryOverlap logs overlap detection
|
||||||
|
|
@ -494,11 +671,12 @@ func (l *RequestLogger) HistoryOverlap(overlapCount int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if overlapCount > 0 {
|
if overlapCount > 0 {
|
||||||
if config.IsDevelopment() {
|
kunlog.Trace("[AGENT] %s History overlap removed: %d messages", l.shortID, overlapCount)
|
||||||
fmt.Printf("%s Removed %d overlapping messages%s\n", colorYellow, overlapCount, colorReset)
|
|
||||||
} else {
|
if !config.IsDevelopment() || GetTUIProgram() != nil {
|
||||||
kunlog.Trace("[AGENT] %s History overlap removed: %d messages", l.shortID, overlapCount)
|
return
|
||||||
}
|
}
|
||||||
|
fmt.Printf("%s Removed %d overlapping messages%s\n", colorYellow, overlapCount, colorReset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,11 +686,22 @@ func (l *RequestLogger) Release() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDevelopment() {
|
kunlog.Trace("[AGENT] %s Release started", l.shortID)
|
||||||
fmt.Printf("%s 🧹 RELEASE %s%s %s(%s)%s\n", colorBoldYellow, l.shortID, colorReset, colorGray, l.assistantID, colorReset)
|
|
||||||
} else {
|
if !config.IsDevelopment() {
|
||||||
kunlog.Trace("[AGENT] %s Release started", l.shortID)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SendTUI(AgentEventMsg{
|
||||||
|
RequestID: l.requestID,
|
||||||
|
Event: EventContextRelease,
|
||||||
|
Data: map[string]interface{}{"assistant": l.currentAssistantID()},
|
||||||
|
})
|
||||||
|
|
||||||
|
if GetTUIProgram() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s RELEASE %s%s %s(%s)%s\n", colorBoldYellow, l.shortID, colorReset, colorGray, l.currentAssistantID(), colorReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
799
agent/context/tui.go
Normal file
799
agent/context/tui.go
Normal file
|
|
@ -0,0 +1,799 @@
|
||||||
|
package context
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
tuiProgram *tea.Program
|
||||||
|
tuiProgramMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetTUIProgram sets the global TUI program (called from start.go after HTTP READY)
|
||||||
|
func SetTUIProgram(p *tea.Program) {
|
||||||
|
tuiProgramMu.Lock()
|
||||||
|
tuiProgram = p
|
||||||
|
tuiProgramMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTUIProgram returns the global TUI program (nil if not in TUI mode)
|
||||||
|
func GetTUIProgram() *tea.Program {
|
||||||
|
tuiProgramMu.RLock()
|
||||||
|
defer tuiProgramMu.RUnlock()
|
||||||
|
return tuiProgram
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTUI sends a message to the TUI program if available
|
||||||
|
func SendTUI(msg tea.Msg) {
|
||||||
|
if p := GetTUIProgram(); p != nil {
|
||||||
|
p.Send(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TUILogWriter implements io.Writer to bridge gou DevWriter -> TUI AppLogMsg
|
||||||
|
type TUILogWriter struct {
|
||||||
|
Program *tea.Program
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *TUILogWriter) Write(p []byte) (n int, err error) {
|
||||||
|
content := strings.TrimRight(string(p), "\n")
|
||||||
|
if content == "" {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
w.Program.Send(AppLogMsg{Content: content})
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
boxRunning = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("33")).
|
||||||
|
PaddingLeft(1).PaddingRight(1)
|
||||||
|
|
||||||
|
boxDone = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
PaddingLeft(1).PaddingRight(1)
|
||||||
|
|
||||||
|
boxFailed = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("31")).
|
||||||
|
PaddingLeft(1).PaddingRight(1)
|
||||||
|
|
||||||
|
boxAppLog = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
PaddingLeft(1).PaddingRight(1)
|
||||||
|
|
||||||
|
sRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
|
||||||
|
sDone = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
|
||||||
|
sFailed = lipgloss.NewStyle().Foreground(lipgloss.Color("31"))
|
||||||
|
sDim = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||||
|
sBold = lipgloss.NewStyle().Bold(true)
|
||||||
|
sYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("33"))
|
||||||
|
sRed = lipgloss.NewStyle().Foreground(lipgloss.Color("31"))
|
||||||
|
sBlue = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
|
||||||
|
sMagenta = lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||||
|
sTree = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Data ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// RequestPanel represents a single top-level agent request
|
||||||
|
type RequestPanel struct {
|
||||||
|
RequestID string
|
||||||
|
ShortID string
|
||||||
|
AssistantID string
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time // set when done/failed, freezes elapsed display
|
||||||
|
Status PanelStatus
|
||||||
|
Nodes []TreeNode
|
||||||
|
ParentID string
|
||||||
|
Collapsed bool
|
||||||
|
viewRow int // Y offset of the header line (for mouse click)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TreeNode represents a step within a request panel
|
||||||
|
type TreeNode struct {
|
||||||
|
Kind NodeKind
|
||||||
|
Label string
|
||||||
|
Status NodeStatus
|
||||||
|
Detail string
|
||||||
|
Children []*TreeNode
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
Collapsed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentTUIModel is the bubbletea Model for agent request visualization
|
||||||
|
type AgentTUIModel struct {
|
||||||
|
panels []*RequestPanel
|
||||||
|
panelIndex map[string]int // requestID -> index in panels (first registration wins)
|
||||||
|
appLogs []AppLogEntry
|
||||||
|
appLogExpand bool
|
||||||
|
appLogRow int // Y offset of app log header
|
||||||
|
cursor int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
scrollOffset int
|
||||||
|
autoFollow bool // auto-scroll to bottom when new content arrives
|
||||||
|
mouseOn bool
|
||||||
|
quitting bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentTUIModel creates a new TUI model
|
||||||
|
func NewAgentTUIModel() AgentTUIModel {
|
||||||
|
return AgentTUIModel{
|
||||||
|
panels: []*RequestPanel{},
|
||||||
|
panelIndex: map[string]int{},
|
||||||
|
appLogs: []AppLogEntry{},
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
autoFollow: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) Init() tea.Cmd {
|
||||||
|
return tickCmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
func tickCmd() tea.Cmd {
|
||||||
|
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
||||||
|
return TickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Update ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (m AgentTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
return m.handleKey(msg)
|
||||||
|
|
||||||
|
case tea.MouseMsg:
|
||||||
|
return m.handleMouse(msg)
|
||||||
|
|
||||||
|
case AgentEventMsg:
|
||||||
|
return m.handleAgentEvent(msg), nil
|
||||||
|
|
||||||
|
case AppLogMsg:
|
||||||
|
m.appLogs = append(m.appLogs, AppLogEntry{
|
||||||
|
Content: msg.Content,
|
||||||
|
Time: time.Now(),
|
||||||
|
})
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case TickMsg:
|
||||||
|
return m, tickCmd()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
topPanels := m.topLevelPanels()
|
||||||
|
total := len(topPanels) + 1 // +1 for app log
|
||||||
|
viewH := m.viewHeight()
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
// Scrolling
|
||||||
|
case "j", "down":
|
||||||
|
m.scrollOffset++
|
||||||
|
m.autoFollow = false
|
||||||
|
case "k", "up":
|
||||||
|
if m.scrollOffset > 0 {
|
||||||
|
m.scrollOffset--
|
||||||
|
}
|
||||||
|
m.autoFollow = false
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
m.scrollOffset += viewH / 2
|
||||||
|
m.autoFollow = false
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
m.scrollOffset -= viewH / 2
|
||||||
|
if m.scrollOffset < 0 {
|
||||||
|
m.scrollOffset = 0
|
||||||
|
}
|
||||||
|
m.autoFollow = false
|
||||||
|
case "G", "end":
|
||||||
|
m.autoFollow = true
|
||||||
|
case "g", "home":
|
||||||
|
m.scrollOffset = 0
|
||||||
|
m.autoFollow = false
|
||||||
|
|
||||||
|
// Cursor navigation for panel selection (wraps around)
|
||||||
|
case "tab":
|
||||||
|
m.cursor = (m.cursor + 1) % total
|
||||||
|
m.scrollToCursor(topPanels)
|
||||||
|
case "shift+tab":
|
||||||
|
m.cursor = (m.cursor - 1 + total) % total
|
||||||
|
m.scrollToCursor(topPanels)
|
||||||
|
|
||||||
|
case "enter", " ":
|
||||||
|
if m.cursor < len(topPanels) {
|
||||||
|
topPanels[m.cursor].Collapsed = !topPanels[m.cursor].Collapsed
|
||||||
|
} else {
|
||||||
|
m.appLogExpand = !m.appLogExpand
|
||||||
|
}
|
||||||
|
case "c":
|
||||||
|
m.appLogExpand = !m.appLogExpand
|
||||||
|
case "a":
|
||||||
|
for _, p := range m.panels {
|
||||||
|
p.Collapsed = false
|
||||||
|
}
|
||||||
|
m.appLogExpand = true
|
||||||
|
case "A":
|
||||||
|
for _, p := range m.panels {
|
||||||
|
p.Collapsed = true
|
||||||
|
}
|
||||||
|
m.appLogExpand = false
|
||||||
|
case "m":
|
||||||
|
m.mouseOn = !m.mouseOn
|
||||||
|
if m.mouseOn {
|
||||||
|
return m, tea.EnableMouseCellMotion
|
||||||
|
}
|
||||||
|
return m, tea.DisableMouse
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) viewHeight() int {
|
||||||
|
h := m.height - 2 // reserve for status bar
|
||||||
|
if h < 4 {
|
||||||
|
h = 4
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AgentTUIModel) scrollToCursor(topPanels []*RequestPanel) {
|
||||||
|
targetRow := 0
|
||||||
|
if m.cursor < len(topPanels) {
|
||||||
|
targetRow = topPanels[m.cursor].viewRow
|
||||||
|
} else {
|
||||||
|
targetRow = m.appLogRow
|
||||||
|
}
|
||||||
|
viewH := m.viewHeight()
|
||||||
|
if targetRow < m.scrollOffset {
|
||||||
|
m.scrollOffset = targetRow
|
||||||
|
} else if targetRow >= m.scrollOffset+viewH {
|
||||||
|
m.scrollOffset = targetRow - viewH + 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case msg.Button == tea.MouseButtonWheelUp:
|
||||||
|
m.scrollOffset -= 3
|
||||||
|
if m.scrollOffset < 0 {
|
||||||
|
m.scrollOffset = 0
|
||||||
|
}
|
||||||
|
m.autoFollow = false
|
||||||
|
return m, nil
|
||||||
|
case msg.Button == tea.MouseButtonWheelDown:
|
||||||
|
m.scrollOffset += 3
|
||||||
|
m.autoFollow = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionRelease {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
y := msg.Y + m.scrollOffset
|
||||||
|
|
||||||
|
// Check app log header
|
||||||
|
if y == m.appLogRow {
|
||||||
|
m.appLogExpand = !m.appLogExpand
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check panel headers
|
||||||
|
for _, p := range m.panels {
|
||||||
|
if p.ParentID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if y == p.viewRow {
|
||||||
|
p.Collapsed = !p.Collapsed
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agent Events ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (m *AgentTUIModel) handleAgentEvent(msg AgentEventMsg) tea.Model {
|
||||||
|
switch msg.Event {
|
||||||
|
case EventRequestStart:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
// Delegate sub-call: same requestID, different assistantID.
|
||||||
|
// Add as a tree node inside the existing panel instead of creating a new one.
|
||||||
|
p := m.panels[idx]
|
||||||
|
p.Nodes = append(p.Nodes, TreeNode{
|
||||||
|
Kind: NodeA2A,
|
||||||
|
Label: msg.AssistantID,
|
||||||
|
Status: NodeRunning,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
panel := &RequestPanel{
|
||||||
|
RequestID: msg.RequestID,
|
||||||
|
ShortID: shortID(msg.RequestID),
|
||||||
|
AssistantID: msg.AssistantID,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
Status: PanelRunning,
|
||||||
|
ParentID: msg.ParentID,
|
||||||
|
}
|
||||||
|
m.panelIndex[msg.RequestID] = len(m.panels)
|
||||||
|
m.panels = append(m.panels, panel)
|
||||||
|
|
||||||
|
case EventRequestEnd:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
p := m.panels[idx]
|
||||||
|
|
||||||
|
// Only mark panel done if the ending assistantID matches the panel's original assistantID
|
||||||
|
// (delegate sub-calls End with a different assistantID, they update their tree node instead)
|
||||||
|
if msg.AssistantID == p.AssistantID || msg.AssistantID == "" {
|
||||||
|
if errVal, has := msg.Data["error"]; has && errVal != nil {
|
||||||
|
p.Status = PanelFailed
|
||||||
|
} else {
|
||||||
|
p.Status = PanelSuccess
|
||||||
|
}
|
||||||
|
p.EndTime = time.Now()
|
||||||
|
p.Collapsed = true
|
||||||
|
|
||||||
|
// Finalize any still-running child nodes (e.g. hook interrupted mid-execution)
|
||||||
|
finalStatus := NodeDone
|
||||||
|
if p.Status == PanelFailed {
|
||||||
|
finalStatus = NodeFailed
|
||||||
|
}
|
||||||
|
for i := range p.Nodes {
|
||||||
|
if p.Nodes[i].Status == NodeRunning {
|
||||||
|
p.Nodes[i].Status = finalStatus
|
||||||
|
p.Nodes[i].EndTime = p.EndTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Delegate sub-call finished: mark its tree node as done
|
||||||
|
for i := len(p.Nodes) - 1; i >= 0; i-- {
|
||||||
|
if p.Nodes[i].Kind == NodeA2A && p.Nodes[i].Label == msg.AssistantID && p.Nodes[i].Status == NodeRunning {
|
||||||
|
p.Nodes[i].Status = NodeDone
|
||||||
|
p.Nodes[i].EndTime = time.Now()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventLLMCall:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
|
||||||
|
Kind: NodeLLM, Label: "LLM", Status: NodeRunning, StartTime: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventLLMDone:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
p := m.panels[idx]
|
||||||
|
for i := len(p.Nodes) - 1; i >= 0; i-- {
|
||||||
|
if p.Nodes[i].Kind == NodeLLM && p.Nodes[i].Status == NodeRunning {
|
||||||
|
p.Nodes[i].Status = NodeDone
|
||||||
|
p.Nodes[i].EndTime = time.Now()
|
||||||
|
if d, has := msg.Data["detail"]; has {
|
||||||
|
p.Nodes[i].Detail = fmt.Sprintf("%v", d)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventToolCall:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
name := dataStr(msg.Data, "name")
|
||||||
|
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
|
||||||
|
Kind: NodeTool, Label: name, Status: NodeRunning, StartTime: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventToolDone:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
name := dataStr(msg.Data, "name")
|
||||||
|
p := m.panels[idx]
|
||||||
|
for i := len(p.Nodes) - 1; i >= 0; i-- {
|
||||||
|
if p.Nodes[i].Kind == NodeTool && p.Nodes[i].Label == name && p.Nodes[i].Status == NodeRunning {
|
||||||
|
p.Nodes[i].Status = NodeDone
|
||||||
|
p.Nodes[i].EndTime = time.Now()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventHook:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
name := dataStr(msg.Data, "name")
|
||||||
|
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
|
||||||
|
Kind: NodeHook, Label: name, Status: NodeRunning, StartTime: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventHookDone:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
p := m.panels[idx]
|
||||||
|
for i := len(p.Nodes) - 1; i >= 0; i-- {
|
||||||
|
if p.Nodes[i].Kind == NodeHook && p.Nodes[i].Status == NodeRunning {
|
||||||
|
p.Nodes[i].Status = NodeDone
|
||||||
|
p.Nodes[i].EndTime = time.Now()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventA2AStart:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
target := dataStr(msg.Data, "target")
|
||||||
|
m.panels[idx].Nodes = append(m.panels[idx].Nodes, TreeNode{
|
||||||
|
Kind: NodeA2A, Label: target, Status: NodeRunning, StartTime: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case EventA2ADone:
|
||||||
|
if idx, ok := m.panelIndex[msg.RequestID]; ok {
|
||||||
|
target := dataStr(msg.Data, "target")
|
||||||
|
p := m.panels[idx]
|
||||||
|
for i := len(p.Nodes) - 1; i >= 0; i-- {
|
||||||
|
if p.Nodes[i].Kind == NodeA2A && p.Nodes[i].Status == NodeRunning && (target == "" || p.Nodes[i].Label == target) {
|
||||||
|
p.Nodes[i].Status = NodeDone
|
||||||
|
p.Nodes[i].EndTime = time.Now()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── View ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (m AgentTUIModel) View() string {
|
||||||
|
if m.quitting {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
boxW := m.width - 2
|
||||||
|
if boxW < 40 {
|
||||||
|
boxW = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render full content
|
||||||
|
var sb strings.Builder
|
||||||
|
row := 0
|
||||||
|
topIdx := 0
|
||||||
|
|
||||||
|
for _, panel := range m.panels {
|
||||||
|
if panel.ParentID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
selected := (topIdx == m.cursor)
|
||||||
|
rendered := m.renderPanelBox(panel, boxW, selected, &row)
|
||||||
|
sb.WriteString(rendered)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
row++
|
||||||
|
topIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// App Log
|
||||||
|
m.appLogRow = row
|
||||||
|
sb.WriteString(m.renderAppLogBox(boxW, topIdx == m.cursor, &row))
|
||||||
|
|
||||||
|
fullContent := sb.String()
|
||||||
|
lines := strings.Split(fullContent, "\n")
|
||||||
|
totalLines := len(lines)
|
||||||
|
viewH := m.viewHeight()
|
||||||
|
|
||||||
|
// Auto-follow: snap to bottom
|
||||||
|
if m.autoFollow {
|
||||||
|
m.scrollOffset = totalLines - viewH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp scroll offset
|
||||||
|
maxScroll := totalLines - viewH
|
||||||
|
if maxScroll < 0 {
|
||||||
|
maxScroll = 0
|
||||||
|
}
|
||||||
|
if m.scrollOffset > maxScroll {
|
||||||
|
m.scrollOffset = maxScroll
|
||||||
|
}
|
||||||
|
if m.scrollOffset < 0 {
|
||||||
|
m.scrollOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice visible lines
|
||||||
|
end := m.scrollOffset + viewH
|
||||||
|
if end > totalLines {
|
||||||
|
end = totalLines
|
||||||
|
}
|
||||||
|
visible := lines[m.scrollOffset:end]
|
||||||
|
|
||||||
|
// Build output
|
||||||
|
var out strings.Builder
|
||||||
|
out.WriteString(strings.Join(visible, "\n"))
|
||||||
|
|
||||||
|
// Status bar with scroll indicator
|
||||||
|
mouseLabel := "off"
|
||||||
|
if m.mouseOn {
|
||||||
|
mouseLabel = "on"
|
||||||
|
}
|
||||||
|
scrollInfo := ""
|
||||||
|
if totalLines > viewH {
|
||||||
|
pct := 100
|
||||||
|
if maxScroll > 0 {
|
||||||
|
pct = m.scrollOffset * 100 / maxScroll
|
||||||
|
}
|
||||||
|
scrollInfo = fmt.Sprintf(" [%d%%]", pct)
|
||||||
|
}
|
||||||
|
followLabel := ""
|
||||||
|
if m.autoFollow {
|
||||||
|
followLabel = " AUTO"
|
||||||
|
}
|
||||||
|
hint := sDim.Render(fmt.Sprintf(" j/k:scroll tab:select space:toggle a/A:all G:bottom g:top m:mouse(%s)%s%s q:quit",
|
||||||
|
mouseLabel, scrollInfo, followLabel))
|
||||||
|
out.WriteString("\n" + hint)
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) renderPanelBox(panel *RequestPanel, boxW int, selected bool, row *int) string {
|
||||||
|
// Record header row for mouse
|
||||||
|
panel.viewRow = *row
|
||||||
|
|
||||||
|
elapsed := m.panelElapsed(panel)
|
||||||
|
icon, statusText, style := panelStatusDisplay(panel.Status, elapsed)
|
||||||
|
|
||||||
|
// Title line
|
||||||
|
collapser := "▾"
|
||||||
|
if panel.Collapsed {
|
||||||
|
collapser = "▸"
|
||||||
|
}
|
||||||
|
cursor := " "
|
||||||
|
if selected {
|
||||||
|
cursor = "›"
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("%s %s %s %s %s",
|
||||||
|
sDim.Render(cursor),
|
||||||
|
sDim.Render(collapser),
|
||||||
|
sBold.Render(panel.ShortID),
|
||||||
|
panel.AssistantID,
|
||||||
|
style.Render(icon+" "+statusText),
|
||||||
|
)
|
||||||
|
|
||||||
|
if panel.Collapsed {
|
||||||
|
box := boxForStatus(panel.Status).Width(boxW)
|
||||||
|
result := box.Render(title)
|
||||||
|
*row += strings.Count(result, "\n") + 1
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build body
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString(title + "\n")
|
||||||
|
|
||||||
|
for _, node := range panel.Nodes {
|
||||||
|
body.WriteString(m.renderTreeNode(node, " ", false, panel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render fork children (different requestID, parentID matches)
|
||||||
|
children := m.childPanels(panel.RequestID)
|
||||||
|
for i, child := range children {
|
||||||
|
isLast := (i == len(children)-1)
|
||||||
|
body.WriteString(m.renderChildSummary(child, " ", isLast))
|
||||||
|
}
|
||||||
|
|
||||||
|
box := boxForStatus(panel.Status).Width(boxW)
|
||||||
|
result := box.Render(body.String())
|
||||||
|
*row += strings.Count(result, "\n") + 1
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) renderTreeNode(node TreeNode, prefix string, isChild bool, panel *RequestPanel) string {
|
||||||
|
panelEnded := panel != nil && panel.Status != PanelRunning
|
||||||
|
displayNode := node
|
||||||
|
if panelEnded && displayNode.Status == NodeRunning {
|
||||||
|
displayNode.Status = NodeFailed
|
||||||
|
}
|
||||||
|
icon, statusText := nodeStatusDisplay(displayNode)
|
||||||
|
elapsed := m.nodeElapsed(node, panelEnded, panel.EndTime)
|
||||||
|
|
||||||
|
label := ""
|
||||||
|
switch node.Kind {
|
||||||
|
case NodeHook:
|
||||||
|
label = sMagenta.Render("Hook: "+node.Label) + " " + statusText
|
||||||
|
case NodeLLM:
|
||||||
|
detail := ""
|
||||||
|
if node.Detail != "" {
|
||||||
|
detail = " " + sDim.Render("["+node.Detail+"]")
|
||||||
|
}
|
||||||
|
label = sBlue.Render("LLM") + " " + statusText + detail
|
||||||
|
case NodeTool:
|
||||||
|
label = sTree.Render("├ ") + sYellow.Render(node.Label) + " " + statusText
|
||||||
|
case NodeA2A:
|
||||||
|
label = sTree.Render("⤷ ") + sBold.Render(node.Label) + " " + statusText
|
||||||
|
case NodePhase:
|
||||||
|
label = node.Label + " " + statusText
|
||||||
|
default:
|
||||||
|
label = node.Label + " " + statusText
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = icon
|
||||||
|
line := prefix + label
|
||||||
|
if elapsed != "" {
|
||||||
|
line += " " + sDim.Render(elapsed)
|
||||||
|
}
|
||||||
|
return line + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) renderChildSummary(panel *RequestPanel, prefix string, isLast bool) string {
|
||||||
|
elapsed := m.panelElapsed(panel)
|
||||||
|
icon, statusText, style := panelStatusDisplay(panel.Status, elapsed)
|
||||||
|
|
||||||
|
branch := sTree.Render("├─ ")
|
||||||
|
if isLast {
|
||||||
|
branch = sTree.Render("└─ ")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s%s %s %s\n",
|
||||||
|
prefix, branch,
|
||||||
|
sBold.Render(panel.ShortID+" "+panel.AssistantID),
|
||||||
|
style.Render(icon+" "+statusText),
|
||||||
|
sDim.Render(elapsed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) renderAppLogBox(boxW int, selected bool, row *int) string {
|
||||||
|
cursor := " "
|
||||||
|
if selected {
|
||||||
|
cursor = "›"
|
||||||
|
}
|
||||||
|
collapser := "▸"
|
||||||
|
if m.appLogExpand {
|
||||||
|
collapser = "▾"
|
||||||
|
}
|
||||||
|
|
||||||
|
count := len(m.appLogs)
|
||||||
|
title := fmt.Sprintf("%s %s %s (%d)",
|
||||||
|
sDim.Render(cursor),
|
||||||
|
sDim.Render(collapser),
|
||||||
|
sBold.Render("App Output"),
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !m.appLogExpand || count == 0 {
|
||||||
|
result := boxAppLog.Width(boxW).Render(title)
|
||||||
|
*row += strings.Count(result, "\n") + 1
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString(title + "\n")
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if count > 50 {
|
||||||
|
start = count - 50
|
||||||
|
}
|
||||||
|
for _, entry := range m.appLogs[start:] {
|
||||||
|
body.WriteString(" " + entry.Content + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := boxAppLog.Width(boxW).Render(body.String())
|
||||||
|
*row += strings.Count(result, "\n") + 1
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (m AgentTUIModel) topLevelPanels() []*RequestPanel {
|
||||||
|
var result []*RequestPanel
|
||||||
|
for _, p := range m.panels {
|
||||||
|
if p.ParentID == "" {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) childPanels(parentRequestID string) []*RequestPanel {
|
||||||
|
var result []*RequestPanel
|
||||||
|
for _, p := range m.panels {
|
||||||
|
if p.ParentID == parentRequestID {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) panelElapsed(p *RequestPanel) string {
|
||||||
|
if p.Status != PanelRunning && !p.EndTime.IsZero() {
|
||||||
|
return fmtDuration(p.EndTime.Sub(p.StartTime))
|
||||||
|
}
|
||||||
|
return fmtDuration(time.Since(p.StartTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AgentTUIModel) nodeElapsed(n TreeNode, panelEnded bool, panelEndTime time.Time) string {
|
||||||
|
if n.Status == NodeDone || n.Status == NodeFailed {
|
||||||
|
if !n.EndTime.IsZero() {
|
||||||
|
return fmtDuration(n.EndTime.Sub(n.StartTime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n.Status == NodeRunning {
|
||||||
|
if panelEnded && !panelEndTime.IsZero() {
|
||||||
|
return fmtDuration(panelEndTime.Sub(n.StartTime))
|
||||||
|
}
|
||||||
|
return fmtDuration(time.Since(n.StartTime))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func panelStatusDisplay(status PanelStatus, elapsed string) (icon string, text string, style lipgloss.Style) {
|
||||||
|
switch status {
|
||||||
|
case PanelRunning:
|
||||||
|
return "⟳", "running " + elapsed, sRunning
|
||||||
|
case PanelSuccess:
|
||||||
|
return "✓", "done " + elapsed, sDone
|
||||||
|
case PanelFailed:
|
||||||
|
return "✗", "failed " + elapsed, sFailed
|
||||||
|
}
|
||||||
|
return "", "", sDim
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeStatusDisplay(n TreeNode) (icon string, text string) {
|
||||||
|
switch n.Status {
|
||||||
|
case NodePending:
|
||||||
|
return "…", sDim.Render("…")
|
||||||
|
case NodeRunning:
|
||||||
|
return "⟳", sRunning.Render("⟳")
|
||||||
|
case NodeDone:
|
||||||
|
return "✓", sDone.Render("✓")
|
||||||
|
case NodeFailed:
|
||||||
|
return "✗", sFailed.Render("✗")
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxForStatus(status PanelStatus) lipgloss.Style {
|
||||||
|
switch status {
|
||||||
|
case PanelRunning:
|
||||||
|
return boxRunning
|
||||||
|
case PanelFailed:
|
||||||
|
return boxFailed
|
||||||
|
default:
|
||||||
|
return boxDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataStr(data map[string]interface{}, key string) string {
|
||||||
|
if v, ok := data[key]; ok {
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtDuration(d time.Duration) string {
|
||||||
|
if d < time.Second {
|
||||||
|
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||||
|
}
|
||||||
90
agent/context/tui_msg.go
Normal file
90
agent/context/tui_msg.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package context
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// EventType represents the type of agent lifecycle event
|
||||||
|
type EventType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventRequestStart EventType = iota
|
||||||
|
EventPhase
|
||||||
|
EventPhaseDone
|
||||||
|
EventPhaseSkip
|
||||||
|
EventLLMCall
|
||||||
|
EventLLMDone
|
||||||
|
EventToolCall
|
||||||
|
EventToolDone
|
||||||
|
EventHook
|
||||||
|
EventHookDone
|
||||||
|
EventA2AStart
|
||||||
|
EventA2ADone
|
||||||
|
EventRequestEnd
|
||||||
|
EventContextFork
|
||||||
|
EventContextRelease
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentEventMsg is sent from RequestLogger to the TUI Program
|
||||||
|
type AgentEventMsg struct {
|
||||||
|
RequestID string
|
||||||
|
ParentID string
|
||||||
|
AssistantID string
|
||||||
|
Event EventType
|
||||||
|
Data map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppLogLevel represents the severity of application-side output
|
||||||
|
type AppLogLevel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppLogLevelLog AppLogLevel = iota
|
||||||
|
AppLogLevelInfo
|
||||||
|
AppLogLevelWarn
|
||||||
|
AppLogLevelError
|
||||||
|
AppLogLevelException
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppLogMsg is sent from the DevWriter (gou layer) to the TUI Program
|
||||||
|
type AppLogMsg struct {
|
||||||
|
Level AppLogLevel
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppLogEntry stores a single application output entry
|
||||||
|
type AppLogEntry struct {
|
||||||
|
Level AppLogLevel
|
||||||
|
Content string
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PanelStatus represents the lifecycle state of a request panel
|
||||||
|
type PanelStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PanelRunning PanelStatus = iota
|
||||||
|
PanelSuccess
|
||||||
|
PanelFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeKind represents the type of a tree node within a request panel
|
||||||
|
type NodeKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NodePhase NodeKind = iota
|
||||||
|
NodeLLM
|
||||||
|
NodeTool
|
||||||
|
NodeHook
|
||||||
|
NodeA2A
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeStatus represents the state of a tree node
|
||||||
|
type NodeStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NodePending NodeStatus = iota
|
||||||
|
NodeRunning
|
||||||
|
NodeDone
|
||||||
|
NodeFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// TickMsg triggers periodic UI refresh for elapsed time display
|
||||||
|
type TickMsg time.Time
|
||||||
42
cmd/start.go
42
cmd/start.go
|
|
@ -8,11 +8,14 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/yaoapp/gou/api"
|
"github.com/yaoapp/gou/api"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/fs"
|
"github.com/yaoapp/gou/fs"
|
||||||
|
"github.com/yaoapp/gou/helper"
|
||||||
"github.com/yaoapp/gou/mcp"
|
"github.com/yaoapp/gou/mcp"
|
||||||
"github.com/yaoapp/gou/plugin"
|
"github.com/yaoapp/gou/plugin"
|
||||||
"github.com/yaoapp/gou/schedule"
|
"github.com/yaoapp/gou/schedule"
|
||||||
|
|
@ -20,7 +23,9 @@ import (
|
||||||
"github.com/yaoapp/gou/store"
|
"github.com/yaoapp/gou/store"
|
||||||
"github.com/yaoapp/gou/task"
|
"github.com/yaoapp/gou/task"
|
||||||
"github.com/yaoapp/gou/websocket"
|
"github.com/yaoapp/gou/websocket"
|
||||||
|
"github.com/yaoapp/kun/exception"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/engine"
|
"github.com/yaoapp/yao/engine"
|
||||||
"github.com/yaoapp/yao/openapi"
|
"github.com/yaoapp/yao/openapi"
|
||||||
|
|
@ -33,6 +38,7 @@ import (
|
||||||
|
|
||||||
var startDebug = false
|
var startDebug = false
|
||||||
var startDisableWatching = false
|
var startDisableWatching = false
|
||||||
|
var startTUI = false
|
||||||
|
|
||||||
var startCmd = &cobra.Command{
|
var startCmd = &cobra.Command{
|
||||||
Use: "start",
|
Use: "start",
|
||||||
|
|
@ -265,8 +271,9 @@ var startCmd = &cobra.Command{
|
||||||
|
|
||||||
switch v {
|
switch v {
|
||||||
case http.READY:
|
case http.READY:
|
||||||
fmt.Println(color.GreenString(L("✨Server is up and running...")))
|
fmt.Println(color.GreenString(L("Server is up and running...")))
|
||||||
fmt.Println(color.GreenString("✨Ctrl+C to stop"))
|
fmt.Println(color.GreenString("Ctrl+C to stop"))
|
||||||
|
initAgentTUI()
|
||||||
break
|
break
|
||||||
|
|
||||||
case http.CLOSED:
|
case http.CLOSED:
|
||||||
|
|
@ -627,7 +634,38 @@ func colorMehtod(method string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initAgentTUI initializes the TUI for agent request visualization in dev mode.
|
||||||
|
// Must be called after HTTP READY to avoid interfering with startup messages.
|
||||||
|
func initAgentTUI() {
|
||||||
|
if !config.IsDevelopment() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !startTUI && os.Getenv("YAO_TUI") != "on" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isatty.IsTerminal(os.Stdout.Fd()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model := agentcontext.NewAgentTUIModel()
|
||||||
|
p := tea.NewProgram(model, tea.WithoutSignalHandler())
|
||||||
|
|
||||||
|
agentcontext.SetTUIProgram(p)
|
||||||
|
tuiWriter := &agentcontext.TUILogWriter{Program: p}
|
||||||
|
helper.SetDevWriter(tuiWriter)
|
||||||
|
exception.SetWriter(tuiWriter)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
log.Error("TUI error: %s", err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
startCmd.PersistentFlags().BoolVarP(&startDebug, "debug", "", false, L("Development mode"))
|
startCmd.PersistentFlags().BoolVarP(&startDebug, "debug", "", false, L("Development mode"))
|
||||||
startCmd.PersistentFlags().BoolVarP(&startDisableWatching, "disable-watching", "", false, L("Disable watching"))
|
startCmd.PersistentFlags().BoolVarP(&startDisableWatching, "disable-watching", "", false, L("Disable watching"))
|
||||||
|
startCmd.PersistentFlags().BoolVarP(&startTUI, "tui", "", false, L("Enable TUI for agent request visualization"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package engine
|
package engine
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -24,6 +25,7 @@ import (
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/connector"
|
"github.com/yaoapp/yao/connector"
|
||||||
"github.com/yaoapp/yao/data"
|
"github.com/yaoapp/yao/data"
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
"github.com/yaoapp/yao/flow"
|
"github.com/yaoapp/yao/flow"
|
||||||
"github.com/yaoapp/yao/fs"
|
"github.com/yaoapp/yao/fs"
|
||||||
"github.com/yaoapp/yao/i18n"
|
"github.com/yaoapp/yao/i18n"
|
||||||
|
|
@ -48,6 +50,8 @@ import (
|
||||||
"github.com/yaoapp/yao/websocket"
|
"github.com/yaoapp/yao/websocket"
|
||||||
"github.com/yaoapp/yao/widget"
|
"github.com/yaoapp/yao/widget"
|
||||||
"github.com/yaoapp/yao/widgets"
|
"github.com/yaoapp/yao/widgets"
|
||||||
|
|
||||||
|
_ "github.com/yaoapp/yao/trace" // register trace handler/listener via init()
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadHooks used to load custom widgets/processes
|
// LoadHooks used to load custom widgets/processes
|
||||||
|
|
@ -212,6 +216,14 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
|
||||||
warnings = append(warnings, Warning{Widget: "Store", Error: err})
|
warnings = append(warnings, Warning{Widget: "Store", Error: err})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start Event Service (handlers registered via init(), e.g. trace)
|
||||||
|
err = loadStep("Event", func() error {
|
||||||
|
return event.Start()
|
||||||
|
}, callback)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, Warning{Widget: "Event", Error: err})
|
||||||
|
}
|
||||||
|
|
||||||
// Load Uploaders
|
// Load Uploaders
|
||||||
err = loadStep("Uploader", func() error {
|
err = loadStep("Uploader", func() error {
|
||||||
return attachment.Load(cfg)
|
return attachment.Load(cfg)
|
||||||
|
|
@ -425,6 +437,9 @@ func Unload() (err error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop Event Service (before runtime, so in-flight handlers can still use V8)
|
||||||
|
event.Stop(context.Background())
|
||||||
|
|
||||||
// Stop Runtime
|
// Stop Runtime
|
||||||
err = runtime.Stop()
|
err = runtime.Stop()
|
||||||
|
|
||||||
|
|
|
||||||
184
event/README.md
Normal file
184
event/README.md
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
# event — Yao In-Process Event Bus
|
||||||
|
|
||||||
|
Global event service for async/sync event routing, serial queue processing, and real-time subscriptions. All operations are goroutine-safe.
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Push** | Async fire-and-forget delivery. Returns event ID immediately. |
|
||||||
|
| **Call** | Sync request-response. Blocks until handler writes to `resp`. |
|
||||||
|
| **Handler** | One per prefix (e.g. `"trace"`). Processes `Push` and `Call` events. |
|
||||||
|
| **Queue** | FIFO serial processing per entity (e.g. per traceID). Events in same queue never run concurrently. |
|
||||||
|
| **Listener** | Persistent background consumer (registered at startup). Gets a copy of every matching event. |
|
||||||
|
| **Subscriber** | Dynamic subscription (e.g. SSE/WebSocket). Non-blocking; skips if channel full. |
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. Register handlers and listeners (before Start, typically in init())
|
||||||
|
event.Register("trace", traceHandler, event.MaxWorkers(512), event.ReservedWorkers(20))
|
||||||
|
event.Register("job", jobHandler)
|
||||||
|
event.Listen("trace.*", traceListener)
|
||||||
|
|
||||||
|
// 2. Start
|
||||||
|
event.Start()
|
||||||
|
|
||||||
|
// 3. Use (from any goroutine)
|
||||||
|
event.Push(ctx, "trace.add", payload, event.Queue(traceQueueID))
|
||||||
|
id, data, err := event.Call(ctx, "trace.get", req, event.Queue(traceQueueID))
|
||||||
|
|
||||||
|
// 4. Stop (during shutdown)
|
||||||
|
event.Stop(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handler
|
||||||
|
|
||||||
|
Implement `types.Handler`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TraceHandler struct{}
|
||||||
|
|
||||||
|
func (h *TraceHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
var p TracePayload
|
||||||
|
if err := ev.Should(&p); err != nil {
|
||||||
|
if ev.IsCall { resp <- types.Result{Err: err} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// ... business logic ...
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: result}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TraceHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ctx`: non-cancellable for Push; caller's context for Call.
|
||||||
|
- `resp`: always non-nil. Write exactly once for Call; ignore for Push.
|
||||||
|
- `ev.Should(&target)`: type-safe payload extraction.
|
||||||
|
- Panics are recovered automatically; `ErrHandlerPanic` is returned to Call.
|
||||||
|
|
||||||
|
## Queue
|
||||||
|
|
||||||
|
```go
|
||||||
|
queueID, err := event.QueueCreate("trace") // auto-generated ID
|
||||||
|
queueID, err := event.QueueCreate("trace", "my-id") // custom ID
|
||||||
|
|
||||||
|
event.Push(ctx, "trace.add", data, event.Queue(queueID)) // serial
|
||||||
|
event.Call(ctx, "trace.get", req, event.Queue(queueID)) // serial, same queue
|
||||||
|
|
||||||
|
event.QueueRelease(queueID) // graceful: drain pending, reject new
|
||||||
|
event.QueueAbort(queueID) // forceful: discard pending, reject new
|
||||||
|
```
|
||||||
|
|
||||||
|
## Listener
|
||||||
|
|
||||||
|
Implement `types.Listener`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MailListener struct{}
|
||||||
|
func (l *MailListener) OnEvent(ev *types.Event) { /* ... */ }
|
||||||
|
func (l *MailListener) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// Register before Start
|
||||||
|
event.Listen("mail.*", &MailListener{}, event.Filter(fn), event.BufferSize(4096))
|
||||||
|
```
|
||||||
|
|
||||||
|
- Each listener runs in its own goroutine.
|
||||||
|
- Non-blocking: if buffer full, event is skipped (logged as warning).
|
||||||
|
|
||||||
|
## Subscriber
|
||||||
|
|
||||||
|
```go
|
||||||
|
ch := make(chan *types.Event, 256)
|
||||||
|
subID := event.Subscribe("trace.*", ch, event.Filter(fn))
|
||||||
|
defer event.Unsubscribe(subID)
|
||||||
|
|
||||||
|
for ev := range ch {
|
||||||
|
// push to SSE / WebSocket
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Non-blocking: if `ch` full, event is skipped silently.
|
||||||
|
- Call `Unsubscribe` when client disconnects.
|
||||||
|
|
||||||
|
## Context Propagation
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx = event.WithSID(ctx, sessionID)
|
||||||
|
ctx = event.WithAuth(ctx, &types.AuthorizedInfo{UserID: "u-1"})
|
||||||
|
|
||||||
|
// Inside handler:
|
||||||
|
sid := ev.SID
|
||||||
|
auth := ev.Auth // may be nil
|
||||||
|
```
|
||||||
|
|
||||||
|
SID and Auth are extracted from `ctx` automatically when calling `Push`/`Call`.
|
||||||
|
|
||||||
|
## Pattern Matching
|
||||||
|
|
||||||
|
Used by `Listen` and `Subscribe`:
|
||||||
|
|
||||||
|
| Pattern | Matches |
|
||||||
|
|---|---|
|
||||||
|
| `"*"` | Everything |
|
||||||
|
| `"trace.*"` | `"trace.add"`, `"trace.get"`, etc. |
|
||||||
|
| `"trace.add"` | Exact match only |
|
||||||
|
|
||||||
|
## Handler Options
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `MaxWorkers(n)` | 512 | Max concurrent goroutines for this handler |
|
||||||
|
| `ReservedWorkers(n)` | 10 | Slots reserved for Call (Push can use Max−Reserved) |
|
||||||
|
| `QueueSize(n)` | 8192 | Per-queue buffered channel capacity |
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Error | When |
|
||||||
|
|---|---|
|
||||||
|
| `ErrNotStarted` | Push/Call before Start or after Stop |
|
||||||
|
| `ErrNoHandler` | No handler registered for event prefix |
|
||||||
|
| `ErrQueueFull` | Queue buffer at capacity |
|
||||||
|
| `ErrQueueNotFound` | Queue ID never created |
|
||||||
|
| `ErrQueueReleased` | Queue already released/aborted |
|
||||||
|
| `ErrQueueExists` | QueueCreate with duplicate ID |
|
||||||
|
| `ErrHandlerPanic` | Handler panicked (recovered) |
|
||||||
|
|
||||||
|
## Performance (M2 Max, 12 cores)
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| Push (no queue) | ~860K ops/sec, 456 B/op |
|
||||||
|
| Call (no queue) | ~1.2M ops/sec, 440 B/op |
|
||||||
|
| Push (with queue) | ~2.9M ops/sec, 341 B/op |
|
||||||
|
| 1000-user scenario (2000 queues, 27K events) | ~100K events/sec, 280ms total |
|
||||||
|
| Steady-state memory (1000 users) | ~27 MB |
|
||||||
|
| Goroutine leaks | Zero |
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
event/
|
||||||
|
├── types/
|
||||||
|
│ ├── types.go # Event, Result, HandlerEntry, FilterEntry, options
|
||||||
|
│ └── interfaces.go # Handler, Listener interfaces
|
||||||
|
├── service.go # Register, Start, Stop, Reload, global state
|
||||||
|
├── bus.go # Push, Call, QueueCreate/Release/Abort
|
||||||
|
├── queue.go # FIFO queue + queue manager
|
||||||
|
├── worker.go # Worker pool (two-tier semaphore)
|
||||||
|
├── listener.go # Listener manager + pattern matching
|
||||||
|
├── sub.go # Subscriber manager
|
||||||
|
├── option.go # Option functions
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
382
event/bench_test.go
Normal file
382
event/bench_test.go
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared bench handler: lightweight, simulates minimal real work.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type benchHandler struct {
|
||||||
|
processed atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *benchHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
h.processed.Add(1)
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: "ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *benchHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// benchListener counts received events.
|
||||||
|
type benchListener struct {
|
||||||
|
received atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *benchListener) OnEvent(ev *types.Event) { l.received.Add(1) }
|
||||||
|
func (l *benchListener) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark: Push throughput (no queue, pure worker dispatch)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func BenchmarkPush_NoQueue(b *testing.B) {
|
||||||
|
event.Reset()
|
||||||
|
h := &benchHandler{}
|
||||||
|
event.Register("bench", h, event.MaxWorkers(512))
|
||||||
|
_ = event.Start()
|
||||||
|
b.Cleanup(func() { _ = event.Stop(context.Background()); event.Reset() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
b.ResetTimer()
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
for pb.Next() {
|
||||||
|
_, _ = event.Push(ctx, "bench.work", nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
b.StopTimer()
|
||||||
|
|
||||||
|
// Drain workers
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
b.ReportMetric(float64(h.processed.Load()), "events_handled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark: Call throughput (no queue, synchronous round-trip)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func BenchmarkCall_NoQueue(b *testing.B) {
|
||||||
|
event.Reset()
|
||||||
|
h := &benchHandler{}
|
||||||
|
event.Register("bench", h, event.MaxWorkers(512))
|
||||||
|
_ = event.Start()
|
||||||
|
b.Cleanup(func() { _ = event.Stop(context.Background()); event.Reset() })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
b.ResetTimer()
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
for pb.Next() {
|
||||||
|
_, _, _ = event.Call(ctx, "bench.get", nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark: Push throughput with Queue (serial per queue)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func BenchmarkPush_WithQueue(b *testing.B) {
|
||||||
|
event.Reset()
|
||||||
|
h := &benchHandler{}
|
||||||
|
event.Register("bench", h, event.MaxWorkers(512), event.QueueSize(8192))
|
||||||
|
_ = event.Start()
|
||||||
|
b.Cleanup(func() { _ = event.Stop(context.Background()); event.Reset() })
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("bench")
|
||||||
|
b.Cleanup(func() { event.QueueRelease(qID) })
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, _ = event.Push(ctx, "bench.work", nil, event.Queue(qID))
|
||||||
|
}
|
||||||
|
b.StopTimer()
|
||||||
|
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scenario: 1000 concurrent users, each with trace + job queues.
|
||||||
|
//
|
||||||
|
// Simulates:
|
||||||
|
// - 1000 users × 2 queues (trace + job) = 2000 queues
|
||||||
|
// - Each user pushes 20 trace events + 5 job events + 1 Call per queue
|
||||||
|
// - 200 SSE subscribers watching "trace.*" and "job.*"
|
||||||
|
// - 2 Listeners (trace.* + job.*)
|
||||||
|
//
|
||||||
|
// Reports: total duration, events/sec, memory delta.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestScenario_1000Users(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
traceH := &benchHandler{}
|
||||||
|
jobH := &benchHandler{}
|
||||||
|
event.Register("trace", traceH, event.MaxWorkers(512), event.ReservedWorkers(20), event.QueueSize(8192))
|
||||||
|
event.Register("job", jobH, event.MaxWorkers(256), event.ReservedWorkers(10), event.QueueSize(4096))
|
||||||
|
|
||||||
|
traceL := &benchListener{}
|
||||||
|
jobL := &benchListener{}
|
||||||
|
event.Listen("trace.*", traceL)
|
||||||
|
event.Listen("job.*", jobL)
|
||||||
|
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
const (
|
||||||
|
numUsers = 1000
|
||||||
|
tracePushPerUser = 20
|
||||||
|
jobPushPerUser = 5
|
||||||
|
callsPerQueue = 1
|
||||||
|
numSubscribers = 200
|
||||||
|
subscriberBufSize = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Subscribers ---
|
||||||
|
subChans := make([]chan *types.Event, numSubscribers)
|
||||||
|
subIDs := make([]string, numSubscribers)
|
||||||
|
for i := 0; i < numSubscribers; i++ {
|
||||||
|
ch := make(chan *types.Event, subscriberBufSize)
|
||||||
|
subChans[i] = ch
|
||||||
|
pattern := "trace.*"
|
||||||
|
if i%2 == 1 {
|
||||||
|
pattern = "job.*"
|
||||||
|
}
|
||||||
|
subIDs[i] = event.Subscribe(pattern, ch)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, id := range subIDs {
|
||||||
|
event.Unsubscribe(id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Drain subscribers in background
|
||||||
|
var subReceived atomic.Int64
|
||||||
|
subDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(subDone)
|
||||||
|
for _, ch := range subChans {
|
||||||
|
go func(c chan *types.Event) {
|
||||||
|
for range c {
|
||||||
|
subReceived.Add(1)
|
||||||
|
}
|
||||||
|
}(ch)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// --- Memory before ---
|
||||||
|
runtime.GC()
|
||||||
|
var memBefore runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memBefore)
|
||||||
|
|
||||||
|
// --- Run ---
|
||||||
|
start := time.Now()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for u := 0; u < numUsers; u++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(userID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ctx := event.WithSID(context.Background(), fmt.Sprintf("sess-%d", userID))
|
||||||
|
ctx = event.WithAuth(ctx, &types.AuthorizedInfo{UserID: fmt.Sprintf("u-%d", userID)})
|
||||||
|
|
||||||
|
// Create trace queue
|
||||||
|
traceQID, err := event.QueueCreate("trace")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("user %d: trace QueueCreate: %v", userID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create job queue
|
||||||
|
jobQID, err := event.QueueCreate("job")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("user %d: job QueueCreate: %v", userID, err)
|
||||||
|
event.QueueRelease(traceQID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push trace events
|
||||||
|
for i := 0; i < tracePushPerUser; i++ {
|
||||||
|
_, _ = event.Push(ctx, "trace.add", i, event.Queue(traceQID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push job events
|
||||||
|
for i := 0; i < jobPushPerUser; i++ {
|
||||||
|
_, _ = event.Push(ctx, "job.progress", i, event.Queue(jobQID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call on each queue
|
||||||
|
for i := 0; i < callsPerQueue; i++ {
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
_, _, _ = event.Call(callCtx, "trace.get", nil, event.Queue(traceQID))
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
callCtx2, cancel2 := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
_, _, _ = event.Call(callCtx2, "job.status", nil, event.Queue(jobQID))
|
||||||
|
cancel2()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release queues
|
||||||
|
event.QueueRelease(traceQID)
|
||||||
|
event.QueueRelease(jobQID)
|
||||||
|
}(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// Wait for queues to drain
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// --- Memory after ---
|
||||||
|
runtime.GC()
|
||||||
|
var memAfter runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memAfter)
|
||||||
|
|
||||||
|
// --- Results ---
|
||||||
|
totalPush := int64(numUsers) * int64(tracePushPerUser+jobPushPerUser)
|
||||||
|
totalCall := int64(numUsers) * int64(callsPerQueue) * 2
|
||||||
|
totalEvents := totalPush + totalCall
|
||||||
|
traceProcessed := traceH.processed.Load()
|
||||||
|
jobProcessed := jobH.processed.Load()
|
||||||
|
listenerTrace := traceL.received.Load()
|
||||||
|
listenerJob := jobL.received.Load()
|
||||||
|
memDeltaMB := float64(memAfter.TotalAlloc-memBefore.TotalAlloc) / 1024 / 1024
|
||||||
|
|
||||||
|
t.Logf("=== 1000-User Scenario Results ===")
|
||||||
|
t.Logf("Users: %d", numUsers)
|
||||||
|
t.Logf("Queues created: %d (trace: %d, job: %d)", numUsers*2, numUsers, numUsers)
|
||||||
|
t.Logf("Subscribers: %d", numSubscribers)
|
||||||
|
t.Logf("Total events: %d (push: %d, call: %d)", totalEvents, totalPush, totalCall)
|
||||||
|
t.Logf("Trace processed: %d", traceProcessed)
|
||||||
|
t.Logf("Job processed: %d", jobProcessed)
|
||||||
|
t.Logf("Listener trace: %d", listenerTrace)
|
||||||
|
t.Logf("Listener job: %d", listenerJob)
|
||||||
|
t.Logf("Sub received: %d", subReceived.Load())
|
||||||
|
t.Logf("Elapsed: %v", elapsed)
|
||||||
|
t.Logf("Throughput: %.0f events/sec", float64(totalEvents)/elapsed.Seconds())
|
||||||
|
t.Logf("Memory delta: %.2f MB (TotalAlloc)", memDeltaMB)
|
||||||
|
|
||||||
|
// --- Assertions ---
|
||||||
|
expectedProcessed := totalPush + totalCall
|
||||||
|
actualProcessed := traceProcessed + jobProcessed
|
||||||
|
if actualProcessed < expectedProcessed {
|
||||||
|
t.Errorf("processed %d < expected %d (some events lost)", actualProcessed, expectedProcessed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if elapsed > 30*time.Second {
|
||||||
|
t.Errorf("scenario took %v, expected < 30s", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark: Queue create/release churn (lifecycle overhead)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func BenchmarkQueueCreateRelease(b *testing.B) {
|
||||||
|
event.Reset()
|
||||||
|
h := &benchHandler{}
|
||||||
|
event.Register("bench", h, event.QueueSize(64))
|
||||||
|
_ = event.Start()
|
||||||
|
b.Cleanup(func() { _ = event.Stop(context.Background()); event.Reset() })
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
for pb.Next() {
|
||||||
|
qID, err := event.QueueCreate("bench")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("QueueCreate: %v", err)
|
||||||
|
}
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark: Subscriber notify throughput (fanout to 200 subscribers)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func BenchmarkSubscriberFanout(b *testing.B) {
|
||||||
|
event.Reset()
|
||||||
|
h := &benchHandler{}
|
||||||
|
event.Register("bench", h, event.MaxWorkers(512))
|
||||||
|
_ = event.Start()
|
||||||
|
b.Cleanup(func() { _ = event.Stop(context.Background()); event.Reset() })
|
||||||
|
|
||||||
|
const numSubs = 200
|
||||||
|
for i := 0; i < numSubs; i++ {
|
||||||
|
ch := make(chan *types.Event, 1024)
|
||||||
|
event.Subscribe("bench.*", ch)
|
||||||
|
go func(c chan *types.Event) {
|
||||||
|
for range c {
|
||||||
|
}
|
||||||
|
}(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
b.ResetTimer()
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
for pb.Next() {
|
||||||
|
_, _ = event.Push(ctx, "bench.work", nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark: Mixed Push/Call with 2000 queues (1000 users × 2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func BenchmarkMixed_2000Queues(b *testing.B) {
|
||||||
|
event.Reset()
|
||||||
|
h := &benchHandler{}
|
||||||
|
event.Register("mix", h, event.MaxWorkers(512), event.ReservedWorkers(20), event.QueueSize(4096))
|
||||||
|
_ = event.Start()
|
||||||
|
b.Cleanup(func() { _ = event.Stop(context.Background()); event.Reset() })
|
||||||
|
|
||||||
|
const numQueues = 2000
|
||||||
|
queueIDs := make([]string, numQueues)
|
||||||
|
for i := 0; i < numQueues; i++ {
|
||||||
|
qID, err := event.QueueCreate("mix")
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("QueueCreate %d: %v", i, err)
|
||||||
|
}
|
||||||
|
queueIDs[i] = qID
|
||||||
|
}
|
||||||
|
b.Cleanup(func() {
|
||||||
|
for _, qID := range queueIDs {
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
b.ResetTimer()
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
i := 0
|
||||||
|
for pb.Next() {
|
||||||
|
qID := queueIDs[i%numQueues]
|
||||||
|
if i%10 == 0 {
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
_, _, _ = event.Call(callCtx, "mix.get", nil, event.Queue(qID))
|
||||||
|
cancel()
|
||||||
|
} else {
|
||||||
|
_, _ = event.Push(ctx, "mix.work", nil, event.Queue(qID))
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
159
event/bus.go
Normal file
159
event/bus.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var eventIDCounter atomic.Uint64
|
||||||
|
|
||||||
|
func nextEventID() string {
|
||||||
|
id := eventIDCounter.Add(1)
|
||||||
|
return fmt.Sprintf("ev-%d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixOf extracts the handler prefix from an event type.
|
||||||
|
// "trace.add" -> "trace", "job.progress" -> "job"
|
||||||
|
func prefixOf(typ string) string {
|
||||||
|
if i := strings.IndexByte(typ, '.'); i >= 0 {
|
||||||
|
return typ[:i]
|
||||||
|
}
|
||||||
|
return typ
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push delivers an event asynchronously (fire-and-forget).
|
||||||
|
// SID and Auth are extracted from ctx automatically.
|
||||||
|
// Returns the auto-generated event ID.
|
||||||
|
func Push(ctx context.Context, typ string, payload any, opts ...types.PushOption) (string, error) {
|
||||||
|
prefix := prefixOf(typ)
|
||||||
|
entry, pool, err := getHandler(prefix)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = entry // used for queue config lookup
|
||||||
|
|
||||||
|
ev := &types.Event{
|
||||||
|
Type: typ,
|
||||||
|
ID: nextEventID(),
|
||||||
|
IsCall: false,
|
||||||
|
Payload: payload,
|
||||||
|
SID: SIDFrom(ctx),
|
||||||
|
Auth: AuthFrom(ctx),
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify listeners and subscribers (non-blocking, before handler)
|
||||||
|
svc.lmgr.notify(ev)
|
||||||
|
svc.smgr.notify(ev)
|
||||||
|
|
||||||
|
// Route to queue or direct dispatch
|
||||||
|
if ev.Queue != "" {
|
||||||
|
q, err := svc.queues.get(ev.Queue)
|
||||||
|
if err != nil {
|
||||||
|
return ev.ID, err
|
||||||
|
}
|
||||||
|
discard := make(chan types.Result, 1)
|
||||||
|
if err := q.enqueue(ctx, ev, discard); err != nil {
|
||||||
|
return ev.ID, err
|
||||||
|
}
|
||||||
|
return ev.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No queue: direct dispatch with discard channel
|
||||||
|
discard := make(chan types.Result, 1)
|
||||||
|
pushCtx := context.WithoutCancel(ctx)
|
||||||
|
if _, err := pool.dispatch(pushCtx, ev, discard); err != nil {
|
||||||
|
return ev.ID, fmt.Errorf("event push: worker unavailable: %w", err)
|
||||||
|
}
|
||||||
|
return ev.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call delivers an event synchronously and blocks until the handler responds.
|
||||||
|
// SID and Auth are extracted from ctx automatically.
|
||||||
|
// Returns the auto-generated event ID and the handler's result.
|
||||||
|
func Call(ctx context.Context, typ string, payload any, opts ...types.PushOption) (string, any, error) {
|
||||||
|
prefix := prefixOf(typ)
|
||||||
|
_, pool, err := getHandler(prefix)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ev := &types.Event{
|
||||||
|
Type: typ,
|
||||||
|
ID: nextEventID(),
|
||||||
|
IsCall: true,
|
||||||
|
Payload: payload,
|
||||||
|
SID: SIDFrom(ctx),
|
||||||
|
Auth: AuthFrom(ctx),
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify listeners and subscribers
|
||||||
|
svc.lmgr.notify(ev)
|
||||||
|
svc.smgr.notify(ev)
|
||||||
|
|
||||||
|
resp := make(chan types.Result, 1)
|
||||||
|
|
||||||
|
if ev.Queue != "" {
|
||||||
|
q, err := svc.queues.get(ev.Queue)
|
||||||
|
if err != nil {
|
||||||
|
return ev.ID, nil, err
|
||||||
|
}
|
||||||
|
if err := q.enqueue(ctx, ev, resp); err != nil {
|
||||||
|
return ev.ID, nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := pool.dispatch(ctx, ev, resp); err != nil {
|
||||||
|
return ev.ID, nil, fmt.Errorf("event call: worker unavailable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for handler result or context cancellation
|
||||||
|
select {
|
||||||
|
case result := <-resp:
|
||||||
|
return ev.ID, result.Data, result.Err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ev.ID, nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueCreate creates a new event queue bound to a handler prefix.
|
||||||
|
// Returns the queue ID. If no id is provided, one is auto-generated.
|
||||||
|
func QueueCreate(prefix string, id ...string) (string, error) {
|
||||||
|
entry, pool, err := getHandler(prefix)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
queueID := ""
|
||||||
|
if len(id) > 0 && id[0] != "" {
|
||||||
|
queueID = id[0]
|
||||||
|
} else {
|
||||||
|
queueID = fmt.Sprintf("q-%s-%d", prefix, eventIDCounter.Add(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.queues.create(prefix, queueID, entry.QueueSize, pool); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return queueID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueRelease gracefully releases a queue (async).
|
||||||
|
// Rejects new events immediately; existing events are drained internally.
|
||||||
|
func QueueRelease(queueID string) {
|
||||||
|
svc.queues.release(queueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueAbort forcefully releases a queue (async).
|
||||||
|
// Rejects new events, discards pending events, waits for in-flight to finish.
|
||||||
|
func QueueAbort(queueID string) {
|
||||||
|
svc.queues.abortOne(queueID)
|
||||||
|
}
|
||||||
320
event/bus_test.go
Normal file
320
event/bus_test.go
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Test handler ---
|
||||||
|
|
||||||
|
type recordHandler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []string // records ev.Type for each Handle call
|
||||||
|
shutdown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.calls = append(h.calls, ev.Type)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
if ev.IsCall {
|
||||||
|
var p string
|
||||||
|
if err := ev.Should(&p); err == nil {
|
||||||
|
resp <- types.Result{Data: "echo:" + p}
|
||||||
|
} else {
|
||||||
|
resp <- types.Result{Data: "echo:" + ev.Type}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordHandler) Shutdown(ctx context.Context) error {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.shutdown = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *recordHandler) getCalls() []string {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
cp := make([]string, len(h.calls))
|
||||||
|
copy(cp, h.calls)
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 3: Push / Call basic routing (no queue) ---
|
||||||
|
|
||||||
|
func TestPush_NoQueue(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &recordHandler{}
|
||||||
|
event.Register("foo", h)
|
||||||
|
if err := event.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
id, err := event.Push(context.Background(), "foo.bar", "payload1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Push failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected non-empty event ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for async handler
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
calls := h.getCalls()
|
||||||
|
if len(calls) != 1 || calls[0] != "foo.bar" {
|
||||||
|
t.Fatalf("expected [foo.bar], got %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCall_NoQueue(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &recordHandler{}
|
||||||
|
event.Register("foo", h)
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
id, data, err := event.Call(context.Background(), "foo.get", "hello")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Call failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected non-empty event ID")
|
||||||
|
}
|
||||||
|
if data != "echo:hello" {
|
||||||
|
t.Fatalf("expected echo:hello, got %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPush_UnregisteredPrefix(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, err := event.Push(context.Background(), "unknown.thing", nil)
|
||||||
|
if err != event.ErrNoHandler {
|
||||||
|
t.Fatalf("expected ErrNoHandler, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPush_NotStarted(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_, err := event.Push(context.Background(), "foo.bar", nil)
|
||||||
|
if err != event.ErrNotStarted {
|
||||||
|
t.Fatalf("expected ErrNotStarted, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPush_SIDAndAuth(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
var captured *types.Event
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
h := &captureHandler{onHandle: func(ev *types.Event) {
|
||||||
|
mu.Lock()
|
||||||
|
captured = ev
|
||||||
|
mu.Unlock()
|
||||||
|
}}
|
||||||
|
event.Register("foo", h)
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
ctx := event.WithSID(context.Background(), "sess-abc")
|
||||||
|
ctx = event.WithAuth(ctx, &types.AuthorizedInfo{UserID: "u-1"})
|
||||||
|
|
||||||
|
_, err := event.Push(ctx, "foo.bar", "data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Push failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if captured == nil {
|
||||||
|
t.Fatal("handler was not called")
|
||||||
|
}
|
||||||
|
if captured.SID != "sess-abc" {
|
||||||
|
t.Fatalf("expected SID sess-abc, got %s", captured.SID)
|
||||||
|
}
|
||||||
|
if captured.Auth == nil || captured.Auth.UserID != "u-1" {
|
||||||
|
t.Fatalf("expected Auth.UserID u-1, got %+v", captured.Auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureHandler captures the event for inspection.
|
||||||
|
type captureHandler struct {
|
||||||
|
onHandle func(*types.Event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *captureHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
if h.onHandle != nil {
|
||||||
|
h.onHandle(ev)
|
||||||
|
}
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: "ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *captureHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// --- Coverage: prefixOf without dot ---
|
||||||
|
|
||||||
|
func TestPush_TypeWithoutDot(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &recordHandler{}
|
||||||
|
event.Register("nodot", h)
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
id, err := event.Push(context.Background(), "nodot", "payload")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Push failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected non-empty event ID")
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
calls := h.getCalls()
|
||||||
|
if len(calls) != 1 || calls[0] != "nodot" {
|
||||||
|
t.Fatalf("expected [nodot], got %v", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: Call unregistered prefix ---
|
||||||
|
|
||||||
|
func TestCall_UnregisteredPrefix(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, _, err := event.Call(context.Background(), "unknown.thing", nil)
|
||||||
|
if err != event.ErrNoHandler {
|
||||||
|
t.Fatalf("expected ErrNoHandler, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: Call with queue (happy path) ---
|
||||||
|
|
||||||
|
func TestCall_WithQueue(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &recordHandler{}
|
||||||
|
event.Register("foo", h)
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, err := event.QueueCreate("foo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueueCreate failed: %v", err)
|
||||||
|
}
|
||||||
|
defer event.QueueRelease(qID)
|
||||||
|
|
||||||
|
id, data, err := event.Call(context.Background(), "foo.get", "hello", event.Queue(qID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Call with queue failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Fatal("expected non-empty event ID")
|
||||||
|
}
|
||||||
|
if data != "echo:hello" {
|
||||||
|
t.Fatalf("expected echo:hello, got %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: Call with non-existent queue ---
|
||||||
|
|
||||||
|
func TestCall_QueueNotFound(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, _, err := event.Call(context.Background(), "foo.get", nil, event.Queue("no-such-queue"))
|
||||||
|
if err != event.ErrQueueNotFound {
|
||||||
|
t.Fatalf("expected ErrQueueNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: Call ctx timeout ---
|
||||||
|
|
||||||
|
func TestCall_CtxTimeout(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &captureHandler{onHandle: func(ev *types.Event) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}}
|
||||||
|
event.Register("slow", h)
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, _, err := event.Call(ctx, "slow.op", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected timeout error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: Call no-queue dispatch failure (ctx cancelled) ---
|
||||||
|
|
||||||
|
func TestCall_NoQueue_DispatchFail(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &concurrencyHandler{
|
||||||
|
peak: &atomic.Int32{},
|
||||||
|
current: &atomic.Int32{},
|
||||||
|
delay: 200 * time.Millisecond,
|
||||||
|
}
|
||||||
|
event.Register("tiny", h, event.MaxWorkers(1), event.ReservedWorkers(0))
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
// Saturate the single total slot with a Call in background
|
||||||
|
bgDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(bgDone)
|
||||||
|
_, _, _ = event.Call(context.Background(), "tiny.work", nil)
|
||||||
|
}()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Another Call with already-cancelled context should fail at dispatch
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, _, err := event.Call(ctx, "tiny.op", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for cancelled ctx call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for background goroutine to finish before Stop
|
||||||
|
<-bgDone
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
}
|
||||||
348
event/leak_test.go
Normal file
348
event/leak_test.go
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: snapshot goroutine count after GC stabilization.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func stableGoroutineCount() int {
|
||||||
|
// Let runtime settle: GC + finalizers + scheduler
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
runtime.GC()
|
||||||
|
runtime.Gosched()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return runtime.NumGoroutine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// leakHandler is a no-op handler for leak tests.
|
||||||
|
type leakHandler struct{}
|
||||||
|
|
||||||
|
func (h *leakHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: "ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *leakHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// leakListener is a no-op listener for leak tests.
|
||||||
|
type leakListener struct{}
|
||||||
|
|
||||||
|
func (l *leakListener) OnEvent(ev *types.Event) {}
|
||||||
|
func (l *leakListener) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: 1000 Queue create/release cycles leak no goroutines.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLeak_QueueCreateRelease(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("leak", &leakHandler{}, event.QueueSize(64))
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
before := stableGoroutineCount()
|
||||||
|
|
||||||
|
const cycles = 1000
|
||||||
|
for i := 0; i < cycles; i++ {
|
||||||
|
qID, err := event.QueueCreate("leak")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cycle %d: QueueCreate: %v", i, err)
|
||||||
|
}
|
||||||
|
// Push a few events to exercise consumer goroutine
|
||||||
|
for j := 0; j < 3; j++ {
|
||||||
|
_, _ = event.Push(context.Background(), "leak.work", j, event.Queue(qID))
|
||||||
|
}
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let all consumer goroutines drain and exit
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
after := stableGoroutineCount()
|
||||||
|
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
t.Logf("goroutines: before=%d after=%d delta=%d (over %d cycles)", before, after, leaked, cycles)
|
||||||
|
|
||||||
|
// Allow a small margin for runtime jitter (GC, timers, etc.)
|
||||||
|
if leaked > 5 {
|
||||||
|
t.Errorf("goroutine leak: %d goroutines accumulated over %d queue cycles", leaked, cycles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: 1000 Queue create/abort cycles leak no goroutines.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLeak_QueueCreateAbort(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("leak", &leakHandler{}, event.QueueSize(64))
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
before := stableGoroutineCount()
|
||||||
|
|
||||||
|
const cycles = 1000
|
||||||
|
for i := 0; i < cycles; i++ {
|
||||||
|
qID, err := event.QueueCreate("leak")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cycle %d: QueueCreate: %v", i, err)
|
||||||
|
}
|
||||||
|
for j := 0; j < 3; j++ {
|
||||||
|
_, _ = event.Push(context.Background(), "leak.work", j, event.Queue(qID))
|
||||||
|
}
|
||||||
|
event.QueueAbort(qID)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
after := stableGoroutineCount()
|
||||||
|
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
t.Logf("goroutines: before=%d after=%d delta=%d (over %d cycles)", before, after, leaked, cycles)
|
||||||
|
|
||||||
|
if leaked > 5 {
|
||||||
|
t.Errorf("goroutine leak: %d goroutines accumulated over %d abort cycles", leaked, cycles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: Subscriber create/unsubscribe cycles leak no goroutines or memory.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLeak_SubscriberLifecycle(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("leak", &leakHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
before := stableGoroutineCount()
|
||||||
|
runtime.GC()
|
||||||
|
var memBefore runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memBefore)
|
||||||
|
|
||||||
|
const cycles = 1000
|
||||||
|
for i := 0; i < cycles; i++ {
|
||||||
|
ch := make(chan *types.Event, 16)
|
||||||
|
subID := event.Subscribe("leak.*", ch)
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "leak.work", nil)
|
||||||
|
time.Sleep(time.Microsecond) // let notify propagate
|
||||||
|
|
||||||
|
event.Unsubscribe(subID)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
after := stableGoroutineCount()
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
var memAfter runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memAfter)
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
memDeltaMB := float64(int64(memAfter.HeapInuse)-int64(memBefore.HeapInuse)) / 1024 / 1024
|
||||||
|
|
||||||
|
t.Logf("goroutines: before=%d after=%d delta=%d", before, after, leaked)
|
||||||
|
t.Logf("heap in-use delta: %.2f MB", memDeltaMB)
|
||||||
|
|
||||||
|
if leaked > 3 {
|
||||||
|
t.Errorf("goroutine leak: %d goroutines after %d sub/unsub cycles", leaked, cycles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: Start/Stop cycles leak no goroutines.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLeak_StartStopCycles(t *testing.T) {
|
||||||
|
before := stableGoroutineCount()
|
||||||
|
|
||||||
|
const cycles = 20
|
||||||
|
for i := 0; i < cycles; i++ {
|
||||||
|
event.Reset()
|
||||||
|
event.Register("leak", &leakHandler{})
|
||||||
|
event.Listen("leak.*", &leakListener{})
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
for j := 0; j < 10; j++ {
|
||||||
|
_, _ = event.Push(ctx, "leak.work", j)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
_ = event.Stop(ctx)
|
||||||
|
}
|
||||||
|
event.Reset()
|
||||||
|
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
after := stableGoroutineCount()
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
t.Logf("goroutines: before=%d after=%d delta=%d (over %d start/stop cycles)", before, after, leaked, cycles)
|
||||||
|
|
||||||
|
if leaked > 3 {
|
||||||
|
t.Errorf("goroutine leak: %d goroutines after %d start/stop cycles", leaked, cycles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: 1000 concurrent users creating/using/releasing queues, verify
|
||||||
|
// no goroutine leak when everything settles.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLeak_1000Users_FullCycle(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("trace", &leakHandler{}, event.MaxWorkers(512), event.QueueSize(8192))
|
||||||
|
event.Register("job", &leakHandler{}, event.MaxWorkers(256), event.QueueSize(4096))
|
||||||
|
event.Listen("trace.*", &leakListener{})
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
before := stableGoroutineCount()
|
||||||
|
|
||||||
|
const numUsers = 1000
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for u := 0; u < numUsers; u++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(uid int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ctx := event.WithSID(context.Background(), fmt.Sprintf("s-%d", uid))
|
||||||
|
|
||||||
|
tqID, err := event.QueueCreate("trace")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jqID, err := event.QueueCreate("job")
|
||||||
|
if err != nil {
|
||||||
|
event.QueueRelease(tqID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, _ = event.Push(ctx, "trace.add", i, event.Queue(tqID))
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = event.Push(ctx, "job.progress", i, event.Queue(jqID))
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
_, _, _ = event.Call(callCtx, "trace.get", nil, event.Queue(tqID))
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
event.QueueRelease(tqID)
|
||||||
|
event.QueueRelease(jqID)
|
||||||
|
}(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
time.Sleep(1 * time.Second) // let all consumers drain
|
||||||
|
|
||||||
|
after := stableGoroutineCount()
|
||||||
|
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
|
||||||
|
// Final check after full stop
|
||||||
|
afterStop := stableGoroutineCount()
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
leakedAfterStop := afterStop - before
|
||||||
|
|
||||||
|
t.Logf("goroutines: before=%d after_drain=%d after_stop=%d", before, after, afterStop)
|
||||||
|
t.Logf("delta after drain: %d, delta after stop: %d", leaked, leakedAfterStop)
|
||||||
|
|
||||||
|
if leaked > 10 {
|
||||||
|
t.Errorf("goroutine leak after drain: %d (1000 users × 2 queues)", leaked)
|
||||||
|
}
|
||||||
|
if leakedAfterStop > 3 {
|
||||||
|
t.Errorf("goroutine leak after stop: %d", leakedAfterStop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: Memory stability under sustained load.
|
||||||
|
// Push 100k events through 100 queues, measure heap growth.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLeak_MemoryStability(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("mem", &leakHandler{}, event.MaxWorkers(256), event.QueueSize(8192))
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
const (
|
||||||
|
numQueues = 100
|
||||||
|
eventsPerQueue = 1000
|
||||||
|
totalEvents = numQueues * eventsPerQueue
|
||||||
|
)
|
||||||
|
|
||||||
|
queueIDs := make([]string, numQueues)
|
||||||
|
for i := 0; i < numQueues; i++ {
|
||||||
|
qID, _ := event.QueueCreate("mem")
|
||||||
|
queueIDs[i] = qID
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
var memBefore runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memBefore)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for q := 0; q < numQueues; q++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(qIdx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
qID := queueIDs[qIdx]
|
||||||
|
for i := 0; i < eventsPerQueue; i++ {
|
||||||
|
_, _ = event.Push(ctx, "mem.work", i, event.Queue(qID))
|
||||||
|
}
|
||||||
|
}(q)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Release all and wait
|
||||||
|
for _, qID := range queueIDs {
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
var memAfter runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memAfter)
|
||||||
|
|
||||||
|
// Use signed arithmetic to handle GC reclaiming memory between snapshots.
|
||||||
|
heapDeltaMB := float64(int64(memAfter.HeapInuse)-int64(memBefore.HeapInuse)) / 1024 / 1024
|
||||||
|
allocDeltaMB := float64(memAfter.TotalAlloc-memBefore.TotalAlloc) / 1024 / 1024
|
||||||
|
|
||||||
|
t.Logf("=== Memory Stability ===")
|
||||||
|
t.Logf("Events: %d (%d queues × %d events)", totalEvents, numQueues, eventsPerQueue)
|
||||||
|
t.Logf("HeapInuse delta: %.2f MB", heapDeltaMB)
|
||||||
|
t.Logf("TotalAlloc: %.2f MB", allocDeltaMB)
|
||||||
|
t.Logf("Alloc/event: %.0f bytes", allocDeltaMB*1024*1024/float64(totalEvents))
|
||||||
|
|
||||||
|
// After drain, heap should not retain significant memory.
|
||||||
|
// Allow generous 50 MB for 100k events (runtime overhead, GC timing).
|
||||||
|
if heapDeltaMB > 50 {
|
||||||
|
t.Errorf("heap grew %.2f MB after %d events, possible leak", heapDeltaMB, totalEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
event/listener.go
Normal file
141
event/listener.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listenerEntry holds a registered listener with its filter configuration.
|
||||||
|
type listenerEntry struct {
|
||||||
|
pattern string
|
||||||
|
listener types.Listener
|
||||||
|
filter func(*types.Event) bool
|
||||||
|
bufferSize int
|
||||||
|
ch chan *types.Event
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenerManager manages all registered listeners.
|
||||||
|
type listenerManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries []*listenerEntry
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newListenerManager() *listenerManager {
|
||||||
|
return &listenerManager{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// register adds a listener. Must be called before start().
|
||||||
|
func (lm *listenerManager) register(pattern string, listener types.Listener, opts ...types.FilterOption) {
|
||||||
|
fe := &types.FilterEntry{
|
||||||
|
Pattern: pattern,
|
||||||
|
BufferSize: types.DefaultBufferSize,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(fe)
|
||||||
|
}
|
||||||
|
|
||||||
|
lm.mu.Lock()
|
||||||
|
defer lm.mu.Unlock()
|
||||||
|
lm.entries = append(lm.entries, &listenerEntry{
|
||||||
|
pattern: pattern,
|
||||||
|
listener: listener,
|
||||||
|
filter: fe.Filter,
|
||||||
|
bufferSize: fe.BufferSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// start creates channels and goroutines for each listener.
|
||||||
|
func (lm *listenerManager) start() {
|
||||||
|
lm.mu.Lock()
|
||||||
|
defer lm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, entry := range lm.entries {
|
||||||
|
entry.ch = make(chan *types.Event, entry.bufferSize)
|
||||||
|
entry.done = make(chan struct{})
|
||||||
|
go lm.consume(entry)
|
||||||
|
}
|
||||||
|
lm.started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// consume is the goroutine that reads from a listener's channel.
|
||||||
|
func (lm *listenerManager) consume(entry *listenerEntry) {
|
||||||
|
defer close(entry.done)
|
||||||
|
for ev := range entry.ch {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error("event listener panic: pattern=%s type=%s err=%v", entry.pattern, ev.Type, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
entry.listener.OnEvent(ev)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify sends an event to all matching listeners (non-blocking).
|
||||||
|
func (lm *listenerManager) notify(ev *types.Event) {
|
||||||
|
lm.mu.RLock()
|
||||||
|
defer lm.mu.RUnlock()
|
||||||
|
|
||||||
|
if !lm.started {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range lm.entries {
|
||||||
|
if !matchPattern(entry.pattern, ev.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.filter != nil && !entry.filter(ev) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case entry.ch <- ev:
|
||||||
|
default:
|
||||||
|
log.Warn("event listener buffer full: pattern=%s type=%s id=%s (skipped)", entry.pattern, ev.Type, ev.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop shuts down all listeners.
|
||||||
|
func (lm *listenerManager) stop(ctx context.Context) {
|
||||||
|
lm.mu.Lock()
|
||||||
|
lm.started = false
|
||||||
|
entries := lm.entries
|
||||||
|
lm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
close(entry.ch)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
<-entry.done
|
||||||
|
_ = entry.listener.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPattern matches an event type against a listener/subscriber pattern.
|
||||||
|
// - "*" matches everything
|
||||||
|
// - "foo.*" matches any type starting with "foo."
|
||||||
|
// - "foo.bar" matches exactly "foo.bar"
|
||||||
|
func matchPattern(pattern, eventType string) bool {
|
||||||
|
if pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(pattern, ".*") {
|
||||||
|
prefix := strings.TrimSuffix(pattern, "*")
|
||||||
|
return strings.HasPrefix(eventType, prefix)
|
||||||
|
}
|
||||||
|
return pattern == eventType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen registers a persistent listener. Must be called before Start.
|
||||||
|
func Listen(pattern string, listener types.Listener, opts ...types.FilterOption) {
|
||||||
|
svc.mu.Lock()
|
||||||
|
defer svc.mu.Unlock()
|
||||||
|
svc.lmgr.register(pattern, listener, opts...)
|
||||||
|
}
|
||||||
222
event/listener_test.go
Normal file
222
event/listener_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Phase 6: Listener tests ---
|
||||||
|
|
||||||
|
// collectListener collects received events.
|
||||||
|
type collectListener struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []*types.Event
|
||||||
|
shut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *collectListener) OnEvent(ev *types.Event) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.events = append(l.events, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *collectListener) Shutdown(ctx context.Context) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.shut = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *collectListener) getEvents() []*types.Event {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
cp := make([]*types.Event, len(l.events))
|
||||||
|
copy(cp, l.events)
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListener_PatternMatch(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
allL := &collectListener{}
|
||||||
|
fooL := &collectListener{}
|
||||||
|
exactL := &collectListener{}
|
||||||
|
|
||||||
|
event.Listen("*", allL)
|
||||||
|
event.Listen("foo.*", fooL)
|
||||||
|
event.Listen("foo.exact", exactL)
|
||||||
|
|
||||||
|
h := &recordHandler{}
|
||||||
|
event.Register("foo", h)
|
||||||
|
event.Register("bar", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.exact", nil)
|
||||||
|
_, _ = event.Push(context.Background(), "foo.other", nil)
|
||||||
|
_, _ = event.Push(context.Background(), "bar.thing", nil)
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
allEvents := allL.getEvents()
|
||||||
|
fooEvents := fooL.getEvents()
|
||||||
|
exactEvents := exactL.getEvents()
|
||||||
|
|
||||||
|
if len(allEvents) != 3 {
|
||||||
|
t.Fatalf("all listener expected 3, got %d", len(allEvents))
|
||||||
|
}
|
||||||
|
if len(fooEvents) != 2 {
|
||||||
|
t.Fatalf("foo.* listener expected 2, got %d", len(fooEvents))
|
||||||
|
}
|
||||||
|
if len(exactEvents) != 1 {
|
||||||
|
t.Fatalf("foo.exact listener expected 1, got %d", len(exactEvents))
|
||||||
|
}
|
||||||
|
if exactEvents[0].Type != "foo.exact" {
|
||||||
|
t.Fatalf("expected foo.exact, got %s", exactEvents[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListener_Filter(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
filtered := &collectListener{}
|
||||||
|
event.Listen("foo.*", filtered, event.Filter(func(ev *types.Event) bool {
|
||||||
|
return ev.Type == "foo.keep"
|
||||||
|
}))
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.keep", nil)
|
||||||
|
_, _ = event.Push(context.Background(), "foo.drop", nil)
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
events := filtered.getEvents()
|
||||||
|
if len(events) != 1 || events[0].Type != "foo.keep" {
|
||||||
|
t.Fatalf("filter should only pass foo.keep, got %v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListener_BufferFull_Skip(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
// Use buffer size 2, listener that blocks
|
||||||
|
blocking := &blockingListener{unblock: make(chan struct{})}
|
||||||
|
event.Listen("foo.*", blocking, event.BufferSize(2))
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
// Push 5 events; 1 being processed + 2 buffered = 3, rest skipped
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_, _ = event.Push(context.Background(), "foo.item", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
close(blocking.unblock) // unblock listener
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
count := blocking.count.Load()
|
||||||
|
if count > 3 {
|
||||||
|
t.Fatalf("expected at most 3 events with buffer=2, got %d", count)
|
||||||
|
}
|
||||||
|
if count < 1 {
|
||||||
|
t.Fatal("expected at least 1 event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type blockingListener struct {
|
||||||
|
unblock chan struct{}
|
||||||
|
count atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *blockingListener) OnEvent(ev *types.Event) {
|
||||||
|
<-l.unblock
|
||||||
|
l.count.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *blockingListener) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
func TestListener_Shutdown(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
listener := &collectListener{}
|
||||||
|
event.Listen("foo.*", listener)
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
|
||||||
|
if !listener.shut {
|
||||||
|
t.Fatal("listener Shutdown should have been called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListener_PanicRecovery(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
var afterPanic atomic.Int32
|
||||||
|
pl := &panicListener{afterPanic: &afterPanic}
|
||||||
|
event.Listen("foo.*", pl)
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.panic", nil)
|
||||||
|
_, _ = event.Push(context.Background(), "foo.ok", nil)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if afterPanic.Load() < 1 {
|
||||||
|
t.Fatal("listener should recover from panic and process next event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type panicListener struct {
|
||||||
|
afterPanic *atomic.Int32
|
||||||
|
first atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *panicListener) OnEvent(ev *types.Event) {
|
||||||
|
if !l.first.Load() {
|
||||||
|
l.first.Store(true)
|
||||||
|
panic("listener panic")
|
||||||
|
}
|
||||||
|
l.afterPanic.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *panicListener) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// --- Coverage: notify when listener manager not started ---
|
||||||
|
|
||||||
|
func TestListener_NotifyBeforeStart(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
listener := &collectListener{}
|
||||||
|
event.Listen("foo.*", listener)
|
||||||
|
|
||||||
|
// Register handler but do NOT start service; Push will fail with ErrNotStarted.
|
||||||
|
// Instead, we test that listener.notify returns silently before start.
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
|
||||||
|
// Manually start and immediately stop to verify no events leaked
|
||||||
|
_ = event.Start()
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
|
||||||
|
events := listener.getEvents()
|
||||||
|
if len(events) != 0 {
|
||||||
|
t.Fatalf("expected 0 events before any push, got %d", len(events))
|
||||||
|
}
|
||||||
|
}
|
||||||
51
event/option.go
Normal file
51
event/option.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package event
|
||||||
|
|
||||||
|
import "github.com/yaoapp/yao/event/types"
|
||||||
|
|
||||||
|
// MaxWorkers sets the max concurrent worker goroutines for a Handler.
|
||||||
|
// Default is 512. Workers are fire-and-forget (goroutine ends after task).
|
||||||
|
func MaxWorkers(n int) types.HandlerOption {
|
||||||
|
return func(e *types.HandlerEntry) {
|
||||||
|
e.MaxWorkers = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReservedWorkers sets the number of workers reserved for Call events.
|
||||||
|
// Default is 10. Push can use MaxWorkers - Reserved; Call can use MaxWorkers.
|
||||||
|
func ReservedWorkers(n int) types.HandlerOption {
|
||||||
|
return func(e *types.HandlerEntry) {
|
||||||
|
e.ReservedWorkers = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueSize sets the per-queue capacity. Default is 8192.
|
||||||
|
// When a queue is full, Push/Call returns ErrQueueFull immediately.
|
||||||
|
func QueueSize(n int) types.HandlerOption {
|
||||||
|
return func(e *types.HandlerEntry) {
|
||||||
|
e.QueueSize = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue sets the queue key for a Push/Call invocation.
|
||||||
|
// Events with the same queue key are processed serially (FIFO).
|
||||||
|
func Queue(key string) types.PushOption {
|
||||||
|
return func(ev *types.Event) {
|
||||||
|
ev.Queue = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter sets a custom filter function for Listen or Subscribe.
|
||||||
|
// Events that do not pass the filter are skipped.
|
||||||
|
func Filter(fn func(*types.Event) bool) types.FilterOption {
|
||||||
|
return func(e *types.FilterEntry) {
|
||||||
|
e.Filter = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BufferSize sets the Listener channel buffer size. Default is 8192.
|
||||||
|
// Only effective for Listen; ignored by Subscribe.
|
||||||
|
func BufferSize(n int) types.FilterOption {
|
||||||
|
return func(e *types.FilterEntry) {
|
||||||
|
e.BufferSize = n
|
||||||
|
}
|
||||||
|
}
|
||||||
208
event/queue.go
Normal file
208
event/queue.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// queueItem wraps an event with its execution context and response channel.
|
||||||
|
type queueItem struct {
|
||||||
|
ctx context.Context
|
||||||
|
ev *types.Event
|
||||||
|
resp chan<- types.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventQueue is a single FIFO queue bound to a specific handler prefix.
|
||||||
|
// Events are enqueued and consumed serially by a dedicated goroutine.
|
||||||
|
type eventQueue struct {
|
||||||
|
id string
|
||||||
|
prefix string
|
||||||
|
ch chan queueItem
|
||||||
|
released bool
|
||||||
|
aborted bool
|
||||||
|
mu sync.Mutex
|
||||||
|
done chan struct{} // closed when consumer goroutine exits
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue adds an event to the queue. Returns error if full, released, or aborted.
|
||||||
|
// The send to q.ch is performed while holding q.mu to prevent a race with
|
||||||
|
// release()/abort() closing the channel between the flag check and the send.
|
||||||
|
func (q *eventQueue) enqueue(ctx context.Context, ev *types.Event, resp chan<- types.Result) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if q.released || q.aborted {
|
||||||
|
return ErrQueueReleased
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case q.ch <- queueItem{ctx: ctx, ev: ev, resp: resp}:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrQueueFull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// release gracefully stops the queue: rejects new events, drains existing ones.
|
||||||
|
func (q *eventQueue) release() {
|
||||||
|
q.mu.Lock()
|
||||||
|
if q.released || q.aborted {
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.released = true
|
||||||
|
close(q.ch)
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// abort forcefully stops the queue: rejects new events, discards pending.
|
||||||
|
// The consumer goroutine detects the aborted flag and skips remaining items.
|
||||||
|
func (q *eventQueue) abort() {
|
||||||
|
q.mu.Lock()
|
||||||
|
if q.aborted {
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wasReleased := q.released
|
||||||
|
q.aborted = true
|
||||||
|
q.released = true
|
||||||
|
if !wasReleased {
|
||||||
|
close(q.ch)
|
||||||
|
}
|
||||||
|
q.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumer is the goroutine that processes queued events serially.
|
||||||
|
func (q *eventQueue) consumer(pool *workerPool) {
|
||||||
|
defer close(q.done)
|
||||||
|
for item := range q.ch {
|
||||||
|
q.mu.Lock()
|
||||||
|
aborted := q.aborted
|
||||||
|
q.mu.Unlock()
|
||||||
|
if aborted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Push events, use a non-cancellable context so that queued
|
||||||
|
// fire-and-forget events are not dropped when the caller's ctx expires.
|
||||||
|
// For Call events, preserve the caller's ctx for deadline/cancellation.
|
||||||
|
dispatchCtx := item.ctx
|
||||||
|
if !item.ev.IsCall {
|
||||||
|
dispatchCtx = context.WithoutCancel(item.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
done, err := pool.dispatch(dispatchCtx, item.ev, item.resp)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case item.resp <- types.Result{Err: err}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueManager manages all active queues.
|
||||||
|
type queueManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
queues map[string]*eventQueue
|
||||||
|
released map[string]struct{} // tracks IDs that have been released/aborted
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQueueManager() *queueManager {
|
||||||
|
return &queueManager{
|
||||||
|
queues: make(map[string]*eventQueue),
|
||||||
|
released: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create creates a new queue bound to a handler prefix.
|
||||||
|
func (qm *queueManager) create(prefix string, queueID string, queueSize int, pool *workerPool) error {
|
||||||
|
qm.mu.Lock()
|
||||||
|
defer qm.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := qm.queues[queueID]; exists {
|
||||||
|
return ErrQueueExists
|
||||||
|
}
|
||||||
|
|
||||||
|
q := &eventQueue{
|
||||||
|
id: queueID,
|
||||||
|
prefix: prefix,
|
||||||
|
ch: make(chan queueItem, queueSize),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
qm.queues[queueID] = q
|
||||||
|
go q.consumer(pool)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns a queue by ID.
|
||||||
|
// Returns ErrQueueNotFound if the queue was never created,
|
||||||
|
// or ErrQueueReleased if it has been released/aborted.
|
||||||
|
func (qm *queueManager) get(queueID string) (*eventQueue, error) {
|
||||||
|
qm.mu.RLock()
|
||||||
|
defer qm.mu.RUnlock()
|
||||||
|
|
||||||
|
q, ok := qm.queues[queueID]
|
||||||
|
if !ok {
|
||||||
|
if _, wasReleased := qm.released[queueID]; wasReleased {
|
||||||
|
return nil, ErrQueueReleased
|
||||||
|
}
|
||||||
|
return nil, ErrQueueNotFound
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// release gracefully releases a queue.
|
||||||
|
func (qm *queueManager) release(queueID string) {
|
||||||
|
qm.mu.Lock()
|
||||||
|
q, ok := qm.queues[queueID]
|
||||||
|
if !ok {
|
||||||
|
qm.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(qm.queues, queueID)
|
||||||
|
qm.released[queueID] = struct{}{}
|
||||||
|
qm.mu.Unlock()
|
||||||
|
|
||||||
|
q.release()
|
||||||
|
go func() { <-q.done }()
|
||||||
|
}
|
||||||
|
|
||||||
|
// abortOne forcefully releases a single queue.
|
||||||
|
func (qm *queueManager) abortOne(queueID string) {
|
||||||
|
qm.mu.Lock()
|
||||||
|
q, ok := qm.queues[queueID]
|
||||||
|
if !ok {
|
||||||
|
qm.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(qm.queues, queueID)
|
||||||
|
qm.released[queueID] = struct{}{}
|
||||||
|
qm.mu.Unlock()
|
||||||
|
|
||||||
|
q.abort()
|
||||||
|
go func() { <-q.done }()
|
||||||
|
}
|
||||||
|
|
||||||
|
// abortAll forcefully releases all queues. Used during Stop.
|
||||||
|
func (qm *queueManager) abortAll() {
|
||||||
|
qm.mu.Lock()
|
||||||
|
queues := make([]*eventQueue, 0, len(qm.queues))
|
||||||
|
for id, q := range qm.queues {
|
||||||
|
queues = append(queues, q)
|
||||||
|
qm.released[id] = struct{}{}
|
||||||
|
}
|
||||||
|
qm.queues = make(map[string]*eventQueue)
|
||||||
|
qm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, q := range queues {
|
||||||
|
q.abort()
|
||||||
|
}
|
||||||
|
for _, q := range queues {
|
||||||
|
<-q.done
|
||||||
|
}
|
||||||
|
}
|
||||||
387
event/queue_test.go
Normal file
387
event/queue_test.go
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Phase 4: Queue tests ---
|
||||||
|
|
||||||
|
// orderHandler records the order of payload values to verify FIFO.
|
||||||
|
type orderHandler struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
order []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *orderHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
var v int
|
||||||
|
if err := ev.Should(&v); err == nil {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.order = append(h.order, v)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: v}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *orderHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
func (h *orderHandler) getOrder() []int {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
cp := make([]int, len(h.order))
|
||||||
|
copy(cp, h.order)
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueCreate_Release_FIFO(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &orderHandler{}
|
||||||
|
event.Register("seq", h, event.QueueSize(100))
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, err := event.QueueCreate("seq")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueueCreate failed: %v", err)
|
||||||
|
}
|
||||||
|
if qID == "" {
|
||||||
|
t.Fatal("expected non-empty queue ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 20
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
_, err := event.Push(context.Background(), "seq.append", i, event.Queue(qID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Push %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release and wait for drain
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
order := h.getOrder()
|
||||||
|
if len(order) != n {
|
||||||
|
t.Fatalf("expected %d events, got %d", n, len(order))
|
||||||
|
}
|
||||||
|
for i, v := range order {
|
||||||
|
if v != i {
|
||||||
|
t.Fatalf("FIFO violation at index %d: expected %d, got %d", i, i, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueCreate_CustomID(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, err := event.QueueCreate("seq", "my-custom-id")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueueCreate failed: %v", err)
|
||||||
|
}
|
||||||
|
if qID != "my-custom-id" {
|
||||||
|
t.Fatalf("expected my-custom-id, got %s", qID)
|
||||||
|
}
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueCreate_Duplicate(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, _ = event.QueueCreate("seq", "dup-id")
|
||||||
|
_, err := event.QueueCreate("seq", "dup-id")
|
||||||
|
if err != event.ErrQueueExists {
|
||||||
|
t.Fatalf("expected ErrQueueExists, got %v", err)
|
||||||
|
}
|
||||||
|
event.QueueRelease("dup-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueCreate_UnregisteredPrefix(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, err := event.QueueCreate("nonexist")
|
||||||
|
if err != event.ErrNoHandler {
|
||||||
|
t.Fatalf("expected ErrNoHandler, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPush_QueueNotFound(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, err := event.Push(context.Background(), "seq.append", 1, event.Queue("no-such-queue"))
|
||||||
|
if err != event.ErrQueueNotFound {
|
||||||
|
t.Fatalf("expected ErrQueueNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPush_QueueReleased(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("seq")
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
_, err := event.Push(context.Background(), "seq.append", 1, event.Queue(qID))
|
||||||
|
if err != event.ErrQueueReleased {
|
||||||
|
t.Fatalf("expected ErrQueueReleased after release, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueAbort_DiscardsPending(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
// slowHandler delays processing to let events pile up
|
||||||
|
var processed atomic.Int32
|
||||||
|
slow := &slowHandler{delay: 50 * time.Millisecond, counter: &processed}
|
||||||
|
event.Register("slow", slow, event.QueueSize(100))
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("slow")
|
||||||
|
|
||||||
|
// Push 10 events; first will start processing, rest queue up
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, _ = event.Push(context.Background(), "slow.work", i, event.Queue(qID))
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Millisecond) // let first event start
|
||||||
|
event.QueueAbort(qID)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
count := processed.Load()
|
||||||
|
if count >= 10 {
|
||||||
|
t.Fatalf("abort should discard pending events, but %d were processed", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowHandler processes events with a delay.
|
||||||
|
type slowHandler struct {
|
||||||
|
delay time.Duration
|
||||||
|
counter *atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *slowHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
time.Sleep(h.delay)
|
||||||
|
h.counter.Add(1)
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: "done"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *slowHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
func TestQueue_CallInsideQueue_Serial(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &orderHandler{}
|
||||||
|
event.Register("seq", h, event.QueueSize(100))
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("seq")
|
||||||
|
defer event.QueueRelease(qID)
|
||||||
|
|
||||||
|
// Push 5, then Call, then Push 5 more
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_, _ = event.Push(context.Background(), "seq.append", i, event.Queue(qID))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, data, err := event.Call(ctx, "seq.append", 99, event.Queue(qID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Call failed: %v", err)
|
||||||
|
}
|
||||||
|
if data != 99 {
|
||||||
|
t.Fatalf("expected 99, got %v", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 5; i < 10; i++ {
|
||||||
|
_, _ = event.Push(context.Background(), "seq.append", i, event.Queue(qID))
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
order := h.getOrder()
|
||||||
|
|
||||||
|
// The Call (99) should appear after the first 5 and before the last 5
|
||||||
|
found := false
|
||||||
|
for i, v := range order {
|
||||||
|
if v == 99 {
|
||||||
|
if i < 5 {
|
||||||
|
t.Fatalf("Call should be after first 5 pushes, found at index %d", i)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("Call result (99) not found in order: %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueFull(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
var processed atomic.Int32
|
||||||
|
slow := &slowHandler{delay: 100 * time.Millisecond, counter: &processed}
|
||||||
|
event.Register("tiny", slow, event.QueueSize(2))
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("tiny")
|
||||||
|
|
||||||
|
// Fill the queue (size=2)
|
||||||
|
_, err1 := event.Push(context.Background(), "tiny.work", 1, event.Queue(qID))
|
||||||
|
_, err2 := event.Push(context.Background(), "tiny.work", 2, event.Queue(qID))
|
||||||
|
|
||||||
|
// These may or may not succeed depending on timing, but eventually one should fail
|
||||||
|
var fullErr error
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err := event.Push(context.Background(), "tiny.work", i+3, event.Queue(qID))
|
||||||
|
if err == event.ErrQueueFull {
|
||||||
|
fullErr = err
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err1 != nil {
|
||||||
|
t.Fatalf("first push should succeed: %v", err1)
|
||||||
|
}
|
||||||
|
if err2 != nil {
|
||||||
|
t.Fatalf("second push should succeed: %v", err2)
|
||||||
|
}
|
||||||
|
if fullErr == nil {
|
||||||
|
t.Log("warning: queue never reported full (handler may be too fast)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for queued events to finish before Stop to avoid race between
|
||||||
|
// consumer goroutine (dispatch/wg.Add) and Stop (pool.wait/wg.Wait).
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: QueueRelease idempotent (release non-existent queue) ---
|
||||||
|
|
||||||
|
func TestQueueRelease_NonExistent(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
event.QueueRelease("never-created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: QueueAbort idempotent (abort non-existent queue) ---
|
||||||
|
|
||||||
|
func TestQueueAbort_NonExistent(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
event.QueueAbort("never-created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: QueueAbort after already released ---
|
||||||
|
|
||||||
|
func TestQueueAbort_AfterRelease(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("seq")
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Abort after release should not panic (already removed from map)
|
||||||
|
event.QueueAbort(qID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: Stop with active queues (abortAll path) ---
|
||||||
|
|
||||||
|
func TestStop_WithActiveQueues(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
var processed atomic.Int32
|
||||||
|
slow := &slowHandler{delay: 30 * time.Millisecond, counter: &processed}
|
||||||
|
event.Register("bg", slow, event.QueueSize(100))
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("bg")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_, _ = event.Push(context.Background(), "bg.work", i, event.Queue(qID))
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Stop should abort all queues and wait
|
||||||
|
err := event.Stop(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stop failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: Call with queue enqueue failure (queue released) ---
|
||||||
|
|
||||||
|
func TestCall_QueueReleased(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("seq", &orderHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
qID, _ := event.QueueCreate("seq")
|
||||||
|
event.QueueRelease(qID)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
_, _, err := event.Call(context.Background(), "seq.get", nil, event.Queue(qID))
|
||||||
|
if err != event.ErrQueueReleased {
|
||||||
|
t.Fatalf("expected ErrQueueReleased, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
221
event/service.go
Normal file
221
event/service.go
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors.
|
||||||
|
var (
|
||||||
|
ErrNotStarted = errors.New("event: service not started")
|
||||||
|
ErrAlreadyStart = errors.New("event: service already started")
|
||||||
|
ErrQueueFull = errors.New("event: queue is full")
|
||||||
|
ErrQueueNotFound = errors.New("event: queue not found")
|
||||||
|
ErrQueueExists = errors.New("event: queue already exists")
|
||||||
|
ErrQueueReleased = errors.New("event: queue already released")
|
||||||
|
ErrNoHandler = errors.New("event: no handler registered for prefix")
|
||||||
|
ErrHandlerPanic = errors.New("event: handler panicked")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Context keys for SID and Auth propagation.
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ctxKeySID ctxKey = iota
|
||||||
|
ctxKeyAuth
|
||||||
|
)
|
||||||
|
|
||||||
|
// WithSID returns a context carrying the given session ID.
|
||||||
|
func WithSID(ctx context.Context, sid string) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKeySID, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIDFrom extracts the session ID from ctx. Returns empty string if not set.
|
||||||
|
func SIDFrom(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(ctxKeySID).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAuth returns a context carrying the given authorized info.
|
||||||
|
func WithAuth(ctx context.Context, auth *types.AuthorizedInfo) context.Context {
|
||||||
|
return context.WithValue(ctx, ctxKeyAuth, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthFrom extracts the authorized info from ctx. Returns nil if not set.
|
||||||
|
func AuthFrom(ctx context.Context) *types.AuthorizedInfo {
|
||||||
|
if v, ok := ctx.Value(ctxKeyAuth).(*types.AuthorizedInfo); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// service holds all global state for the event bus.
|
||||||
|
type service struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
started bool
|
||||||
|
handlers map[string]*types.HandlerEntry // prefix -> registration
|
||||||
|
pools map[string]*workerPool // prefix -> worker pool
|
||||||
|
queues *queueManager // queue lifecycle
|
||||||
|
lmgr *listenerManager // listener manager
|
||||||
|
smgr *subManager // subscriber manager
|
||||||
|
}
|
||||||
|
|
||||||
|
var svc = &service{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
svc.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a handler for the given prefix.
|
||||||
|
// Must be called before Start (typically in init()).
|
||||||
|
func Register(prefix string, handler types.Handler, opts ...types.HandlerOption) {
|
||||||
|
entry := &types.HandlerEntry{
|
||||||
|
Prefix: prefix,
|
||||||
|
Handler: handler,
|
||||||
|
MaxWorkers: types.DefaultMaxWorkers,
|
||||||
|
ReservedWorkers: types.DefaultReservedWorkers,
|
||||||
|
QueueSize: types.DefaultQueueSize,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
defer svc.mu.Unlock()
|
||||||
|
svc.handlers[prefix] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes and starts the event service.
|
||||||
|
// Called during engine startup, after runtime is ready.
|
||||||
|
func Start() error {
|
||||||
|
svc.mu.Lock()
|
||||||
|
defer svc.mu.Unlock()
|
||||||
|
|
||||||
|
if svc.started {
|
||||||
|
return ErrAlreadyStart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create worker pools for each registered handler
|
||||||
|
for prefix, entry := range svc.handlers {
|
||||||
|
svc.pools[prefix] = newWorkerPool(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start listener manager
|
||||||
|
svc.lmgr.start()
|
||||||
|
|
||||||
|
svc.started = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully shuts down the event service.
|
||||||
|
// Waits for in-flight events to finish, discards pending queue items,
|
||||||
|
// and calls Shutdown on all handlers and listeners.
|
||||||
|
//
|
||||||
|
// The lock is released before waiting for workers so that in-flight handlers
|
||||||
|
// calling Push/Call (which acquire RLock via getHandler) do not deadlock.
|
||||||
|
// Once started=false, getHandler returns ErrNotStarted for any new calls.
|
||||||
|
func Stop(ctx context.Context) error {
|
||||||
|
svc.mu.Lock()
|
||||||
|
if !svc.started {
|
||||||
|
svc.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
svc.started = false
|
||||||
|
|
||||||
|
// Snapshot references under lock, then release.
|
||||||
|
queues := svc.queues
|
||||||
|
pools := make([]*workerPool, 0, len(svc.pools))
|
||||||
|
for _, p := range svc.pools {
|
||||||
|
pools = append(pools, p)
|
||||||
|
}
|
||||||
|
handlers := make([]*types.HandlerEntry, 0, len(svc.handlers))
|
||||||
|
for _, e := range svc.handlers {
|
||||||
|
handlers = append(handlers, e)
|
||||||
|
}
|
||||||
|
lmgr := svc.lmgr
|
||||||
|
smgr := svc.smgr
|
||||||
|
svc.mu.Unlock()
|
||||||
|
|
||||||
|
// From here on, started=false prevents any new Push/Call/QueueCreate.
|
||||||
|
// Existing in-flight workers may still call getHandler and get ErrNotStarted,
|
||||||
|
// which is the correct behavior during shutdown.
|
||||||
|
|
||||||
|
// Abort all queues (discard pending, wait for in-flight)
|
||||||
|
queues.abortAll()
|
||||||
|
|
||||||
|
// Wait for all worker pools to drain
|
||||||
|
for _, pool := range pools {
|
||||||
|
pool.wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown all handlers
|
||||||
|
for _, entry := range handlers {
|
||||||
|
if entry.Handler != nil {
|
||||||
|
_ = entry.Handler.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop listener manager
|
||||||
|
lmgr.stop(ctx)
|
||||||
|
|
||||||
|
// Clear subscribers
|
||||||
|
smgr.clear()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload performs a hot-reload. Preserves queues and in-flight events,
|
||||||
|
// reloads dynamic configuration only.
|
||||||
|
func Reload() error {
|
||||||
|
svc.mu.RLock()
|
||||||
|
defer svc.mu.RUnlock()
|
||||||
|
|
||||||
|
if !svc.started {
|
||||||
|
return ErrNotStarted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStarted reports whether the service is currently running.
|
||||||
|
func IsStarted() bool {
|
||||||
|
svc.mu.RLock()
|
||||||
|
defer svc.mu.RUnlock()
|
||||||
|
return svc.started
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHandler returns the handler entry and its worker pool for the given prefix.
|
||||||
|
func getHandler(prefix string) (*types.HandlerEntry, *workerPool, error) {
|
||||||
|
svc.mu.RLock()
|
||||||
|
defer svc.mu.RUnlock()
|
||||||
|
|
||||||
|
if !svc.started {
|
||||||
|
return nil, nil, ErrNotStarted
|
||||||
|
}
|
||||||
|
entry, ok := svc.handlers[prefix]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, ErrNoHandler
|
||||||
|
}
|
||||||
|
pool := svc.pools[prefix]
|
||||||
|
return entry, pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears all state. For testing only.
|
||||||
|
func Reset() {
|
||||||
|
svc.mu.Lock()
|
||||||
|
defer svc.mu.Unlock()
|
||||||
|
svc.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) reset() {
|
||||||
|
s.started = false
|
||||||
|
s.handlers = make(map[string]*types.HandlerEntry)
|
||||||
|
s.pools = make(map[string]*workerPool)
|
||||||
|
s.queues = newQueueManager()
|
||||||
|
s.lmgr = newListenerManager()
|
||||||
|
s.smgr = newSubManager()
|
||||||
|
}
|
||||||
229
event/service_test.go
Normal file
229
event/service_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubHandler is a minimal Handler for testing registration and lifecycle.
|
||||||
|
type stubHandler struct {
|
||||||
|
shutdownCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *stubHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {}
|
||||||
|
|
||||||
|
func (h *stubHandler) Shutdown(ctx context.Context) error {
|
||||||
|
h.shutdownCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Register + Start/Stop lifecycle ---
|
||||||
|
|
||||||
|
func TestStartStop_Basic(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
if event.IsStarted() {
|
||||||
|
t.Fatal("service should not be started initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := event.Start(); err != nil {
|
||||||
|
t.Fatalf("Start failed: %v", err)
|
||||||
|
}
|
||||||
|
if !event.IsStarted() {
|
||||||
|
t.Fatal("service should be started after Start")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := event.Stop(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Stop failed: %v", err)
|
||||||
|
}
|
||||||
|
if event.IsStarted() {
|
||||||
|
t.Fatal("service should not be started after Stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStart_Double(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
if err := event.Start(); err != nil {
|
||||||
|
t.Fatalf("Start failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := event.Start()
|
||||||
|
if err != event.ErrAlreadyStart {
|
||||||
|
t.Fatalf("expected ErrAlreadyStart, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStop_WhenNotStarted(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
if err := event.Stop(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Stop on non-started service should succeed, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReload_WhenNotStarted(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
err := event.Reload()
|
||||||
|
if err != event.ErrNotStarted {
|
||||||
|
t.Fatalf("expected ErrNotStarted, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReload_WhenStarted(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
_ = event.Start()
|
||||||
|
if err := event.Reload(); err != nil {
|
||||||
|
t.Fatalf("Reload failed: %v", err)
|
||||||
|
}
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Register + options ---
|
||||||
|
|
||||||
|
func TestRegister_DefaultOptions(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &stubHandler{}
|
||||||
|
event.Register("test", h)
|
||||||
|
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
if !event.IsStarted() {
|
||||||
|
t.Fatal("service should be started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_CustomOptions(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &stubHandler{}
|
||||||
|
event.Register("test", h,
|
||||||
|
event.MaxWorkers(128),
|
||||||
|
event.ReservedWorkers(5),
|
||||||
|
event.QueueSize(2048),
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
if !event.IsStarted() {
|
||||||
|
t.Fatal("service should be started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stop calls Shutdown on handlers ---
|
||||||
|
|
||||||
|
func TestStop_CallsHandlerShutdown(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &stubHandler{}
|
||||||
|
event.Register("test", h)
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
if err := event.Stop(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Stop failed: %v", err)
|
||||||
|
}
|
||||||
|
if !h.shutdownCalled {
|
||||||
|
t.Fatal("Handler.Shutdown should have been called on Stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStop_MultipleHandlersShutdown(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h1 := &stubHandler{}
|
||||||
|
h2 := &stubHandler{}
|
||||||
|
event.Register("alpha", h1)
|
||||||
|
event.Register("bravo", h2)
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
if err := event.Stop(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Stop failed: %v", err)
|
||||||
|
}
|
||||||
|
if !h1.shutdownCalled || !h2.shutdownCalled {
|
||||||
|
t.Fatal("all handlers should have been shut down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context SID/Auth propagation ---
|
||||||
|
|
||||||
|
func TestWithSID_SIDFrom(t *testing.T) {
|
||||||
|
ctx := event.WithSID(context.Background(), "sess-123")
|
||||||
|
got := event.SIDFrom(ctx)
|
||||||
|
if got != "sess-123" {
|
||||||
|
t.Fatalf("expected sess-123, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSIDFrom_Empty(t *testing.T) {
|
||||||
|
got := event.SIDFrom(context.Background())
|
||||||
|
if got != "" {
|
||||||
|
t.Fatalf("expected empty, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAuth_AuthFrom(t *testing.T) {
|
||||||
|
auth := &types.AuthorizedInfo{UserID: "u-1", TeamID: "t-1"}
|
||||||
|
ctx := event.WithAuth(context.Background(), auth)
|
||||||
|
got := event.AuthFrom(ctx)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil auth")
|
||||||
|
}
|
||||||
|
if got.UserID != "u-1" || got.TeamID != "t-1" {
|
||||||
|
t.Fatalf("unexpected auth: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthFrom_Nil(t *testing.T) {
|
||||||
|
got := event.AuthFrom(context.Background())
|
||||||
|
if got != nil {
|
||||||
|
t.Fatal("expected nil auth from bare context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithSIDAndAuth_Combined(t *testing.T) {
|
||||||
|
auth := &types.AuthorizedInfo{UserID: "u-2"}
|
||||||
|
ctx := event.WithSID(context.Background(), "sess-456")
|
||||||
|
ctx = event.WithAuth(ctx, auth)
|
||||||
|
|
||||||
|
if event.SIDFrom(ctx) != "sess-456" {
|
||||||
|
t.Fatal("SID mismatch")
|
||||||
|
}
|
||||||
|
if event.AuthFrom(ctx).UserID != "u-2" {
|
||||||
|
t.Fatal("Auth mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reset ---
|
||||||
|
|
||||||
|
func TestReset_ClearsState(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
|
||||||
|
h := &stubHandler{}
|
||||||
|
event.Register("test", h)
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
event.Reset()
|
||||||
|
|
||||||
|
if event.IsStarted() {
|
||||||
|
t.Fatal("service should not be started after Reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
101
event/sub.go
Normal file
101
event/sub.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var subIDCounter atomic.Uint64
|
||||||
|
|
||||||
|
func nextSubID() string {
|
||||||
|
id := subIDCounter.Add(1)
|
||||||
|
return fmt.Sprintf("sub-%d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// subEntry holds a dynamic subscriber registration.
|
||||||
|
type subEntry struct {
|
||||||
|
id string
|
||||||
|
pattern string
|
||||||
|
filter func(*types.Event) bool
|
||||||
|
ch chan<- *types.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// subManager manages dynamic subscribers.
|
||||||
|
type subManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]*subEntry // id -> entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSubManager() *subManager {
|
||||||
|
return &subManager{
|
||||||
|
entries: make(map[string]*subEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe adds a dynamic subscriber. Returns the subscription ID.
|
||||||
|
func (sm *subManager) subscribe(pattern string, ch chan<- *types.Event, opts ...types.FilterOption) string {
|
||||||
|
fe := &types.FilterEntry{Pattern: pattern}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(fe)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := nextSubID()
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
sm.entries[id] = &subEntry{
|
||||||
|
id: id,
|
||||||
|
pattern: pattern,
|
||||||
|
filter: fe.Filter,
|
||||||
|
ch: ch,
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsubscribe removes a subscriber by ID.
|
||||||
|
func (sm *subManager) unsubscribe(id string) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
delete(sm.entries, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify sends an event to all matching subscribers (non-blocking).
|
||||||
|
func (sm *subManager) notify(ev *types.Event) {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, entry := range sm.entries {
|
||||||
|
if !matchPattern(entry.pattern, ev.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.filter != nil && !entry.filter(ev) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case entry.ch <- ev:
|
||||||
|
default:
|
||||||
|
// Subscriber chan full, skip (non-blocking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear removes all subscribers. Used during Stop.
|
||||||
|
func (sm *subManager) clear() {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
sm.entries = make(map[string]*subEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe dynamically subscribes to events matching the given pattern.
|
||||||
|
// Returns the subscription ID for later unsubscription.
|
||||||
|
// Event delivery is non-blocking: if ch is full, the event is skipped.
|
||||||
|
func Subscribe(pattern string, ch chan<- *types.Event, opts ...types.FilterOption) string {
|
||||||
|
return svc.smgr.subscribe(pattern, ch, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a dynamic subscription by ID.
|
||||||
|
func Unsubscribe(id string) {
|
||||||
|
svc.smgr.unsubscribe(id)
|
||||||
|
}
|
||||||
181
event/sub_test.go
Normal file
181
event/sub_test.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Phase 7: Subscriber tests ---
|
||||||
|
|
||||||
|
func TestSubscribe_Basic(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
ch := make(chan *types.Event, 10)
|
||||||
|
subID := event.Subscribe("foo.*", ch)
|
||||||
|
if subID == "" {
|
||||||
|
t.Fatal("expected non-empty subscription ID")
|
||||||
|
}
|
||||||
|
defer event.Unsubscribe(subID)
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.bar", "payload")
|
||||||
|
_, _ = event.Push(context.Background(), "foo.baz", "payload2")
|
||||||
|
|
||||||
|
received := drainChan(ch, 2, 200*time.Millisecond)
|
||||||
|
if len(received) != 2 {
|
||||||
|
t.Fatalf("expected 2 events, got %d", len(received))
|
||||||
|
}
|
||||||
|
if received[0].Type != "foo.bar" {
|
||||||
|
t.Fatalf("expected foo.bar, got %s", received[0].Type)
|
||||||
|
}
|
||||||
|
if received[1].Type != "foo.baz" {
|
||||||
|
t.Fatalf("expected foo.baz, got %s", received[1].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribe_PatternFilter(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
event.Register("bar", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
ch := make(chan *types.Event, 10)
|
||||||
|
subID := event.Subscribe("foo.*", ch, event.Filter(func(ev *types.Event) bool {
|
||||||
|
return ev.Type == "foo.keep"
|
||||||
|
}))
|
||||||
|
defer event.Unsubscribe(subID)
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.keep", nil)
|
||||||
|
_, _ = event.Push(context.Background(), "foo.drop", nil)
|
||||||
|
_, _ = event.Push(context.Background(), "bar.thing", nil)
|
||||||
|
|
||||||
|
received := drainChan(ch, 1, 200*time.Millisecond)
|
||||||
|
if len(received) != 1 {
|
||||||
|
t.Fatalf("expected 1 filtered event, got %d", len(received))
|
||||||
|
}
|
||||||
|
if received[0].Type != "foo.keep" {
|
||||||
|
t.Fatalf("expected foo.keep, got %s", received[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribe_Unsubscribe(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
ch := make(chan *types.Event, 10)
|
||||||
|
subID := event.Subscribe("foo.*", ch)
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.first", nil)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
event.Unsubscribe(subID)
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.second", nil)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
received := drainChan(ch, 10, 100*time.Millisecond)
|
||||||
|
for _, ev := range received {
|
||||||
|
if ev.Type == "foo.second" {
|
||||||
|
t.Fatal("should not receive events after Unsubscribe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribe_ChanFull_Skip(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
ch := make(chan *types.Event, 1) // tiny buffer
|
||||||
|
subID := event.Subscribe("foo.*", ch)
|
||||||
|
defer event.Unsubscribe(subID)
|
||||||
|
|
||||||
|
// Push multiple events quickly; only 1 should fit in buffer
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
_, _ = event.Push(context.Background(), "foo.item", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Should have at most 1 in channel (rest skipped)
|
||||||
|
count := len(ch)
|
||||||
|
if count > 1 {
|
||||||
|
t.Fatalf("expected at most 1 buffered event, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribe_WildcardAll(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
event.Register("bar", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
ch := make(chan *types.Event, 10)
|
||||||
|
subID := event.Subscribe("*", ch)
|
||||||
|
defer event.Unsubscribe(subID)
|
||||||
|
|
||||||
|
_, _ = event.Push(context.Background(), "foo.one", nil)
|
||||||
|
_, _ = event.Push(context.Background(), "bar.two", nil)
|
||||||
|
|
||||||
|
received := drainChan(ch, 2, 200*time.Millisecond)
|
||||||
|
if len(received) != 2 {
|
||||||
|
t.Fatalf("wildcard * should receive all events, got %d", len(received))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribe_StopClearsSubscribers(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
event.Register("foo", &recordHandler{})
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
ch := make(chan *types.Event, 10)
|
||||||
|
_ = event.Subscribe("foo.*", ch)
|
||||||
|
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
|
||||||
|
// After Stop, Push should fail
|
||||||
|
_, err := event.Push(context.Background(), "foo.bar", nil)
|
||||||
|
if err != event.ErrNotStarted {
|
||||||
|
t.Fatalf("expected ErrNotStarted after Stop, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainChan reads up to n events from ch within timeout.
|
||||||
|
func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Event {
|
||||||
|
var result []*types.Event
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
for range n {
|
||||||
|
select {
|
||||||
|
case ev := <-ch:
|
||||||
|
result = append(result, ev)
|
||||||
|
case <-timer.C:
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
23
event/types/interfaces.go
Normal file
23
event/types/interfaces.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package types
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Handler processes events for a given prefix (registered at startup, one per prefix).
|
||||||
|
//
|
||||||
|
// Handle is invoked by the WorkerPool.
|
||||||
|
// - ctx: for Call, this carries the caller's deadline/cancellation; for Push, a non-cancellable context.
|
||||||
|
// - resp is always non-nil. For Push the framework passes a discard channel; for Call it waits for a read.
|
||||||
|
// Use ev.IsCall to decide whether to write a meaningful result.
|
||||||
|
type Handler interface {
|
||||||
|
Handle(ctx context.Context, ev *Event, resp chan<- Result)
|
||||||
|
Shutdown(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener receives matched events in a dedicated goroutine (registered at startup).
|
||||||
|
//
|
||||||
|
// OnEvent is called in the Listener's own goroutine; it does not block other
|
||||||
|
// Listeners or Subscribers.
|
||||||
|
type Listener interface {
|
||||||
|
OnEvent(ev *Event)
|
||||||
|
Shutdown(ctx context.Context) error
|
||||||
|
}
|
||||||
102
event/types/types.go
Normal file
102
event/types/types.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/process"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthorizedInfo is an alias for gou/process.AuthorizedInfo.
|
||||||
|
type AuthorizedInfo = process.AuthorizedInfo
|
||||||
|
|
||||||
|
// Event represents a single event in the event bus.
|
||||||
|
type Event struct {
|
||||||
|
Type string // Event type, e.g. "trace.add", "job.progress"
|
||||||
|
ID string // Auto-generated event ID
|
||||||
|
Queue string // Queue key for serial processing; empty means no queue
|
||||||
|
IsCall bool // true = synchronous Call, false = asynchronous Push
|
||||||
|
Payload any // Business data; concrete type is determined by event type
|
||||||
|
SID string // Session ID, extracted from caller context
|
||||||
|
Auth *AuthorizedInfo // Authorized info, extracted from caller context; may be nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should asserts the Payload to the target pointer type.
|
||||||
|
// target must be a non-nil pointer. Returns an error if the type does not match.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// var p MyPayload
|
||||||
|
// if err := ev.Should(&p); err != nil { ... }
|
||||||
|
func (ev *Event) Should(target any) error {
|
||||||
|
if target == nil {
|
||||||
|
return fmt.Errorf("event.Should: target must be a non-nil pointer")
|
||||||
|
}
|
||||||
|
|
||||||
|
rv := reflect.ValueOf(target)
|
||||||
|
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
||||||
|
return fmt.Errorf("event.Should: target must be a non-nil pointer, got %T", target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.Payload == nil {
|
||||||
|
return fmt.Errorf("event.Should: payload is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct assignment: payload is already the expected pointer type
|
||||||
|
payloadVal := reflect.ValueOf(ev.Payload)
|
||||||
|
targetElem := rv.Elem()
|
||||||
|
|
||||||
|
// If payload is a pointer, dereference it
|
||||||
|
if payloadVal.Kind() == reflect.Ptr {
|
||||||
|
if payloadVal.IsNil() {
|
||||||
|
return fmt.Errorf("event.Should: payload is nil pointer")
|
||||||
|
}
|
||||||
|
payloadVal = payloadVal.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !payloadVal.Type().AssignableTo(targetElem.Type()) {
|
||||||
|
return fmt.Errorf("event.Should: payload type %T is not assignable to %s", ev.Payload, targetElem.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
targetElem.Set(payloadVal)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result holds the response from a synchronous Call.
|
||||||
|
type Result struct {
|
||||||
|
Data any
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerOption configures a Handler registration.
|
||||||
|
type HandlerOption func(*HandlerEntry)
|
||||||
|
|
||||||
|
// HandlerEntry is the internal registration record for a Handler.
|
||||||
|
type HandlerEntry struct {
|
||||||
|
Prefix string
|
||||||
|
Handler Handler
|
||||||
|
MaxWorkers int // Max concurrent workers, default 512
|
||||||
|
ReservedWorkers int // Workers reserved for Call, default 10
|
||||||
|
QueueSize int // Per-queue capacity, default 8192
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterOption configures a Listener or Subscriber registration.
|
||||||
|
type FilterOption func(*FilterEntry)
|
||||||
|
|
||||||
|
// FilterEntry is the internal registration record for a Listener/Subscriber.
|
||||||
|
type FilterEntry struct {
|
||||||
|
Pattern string
|
||||||
|
Filter func(*Event) bool // Custom filter function
|
||||||
|
BufferSize int // Listener chan buffer size, default 8192; only for Listen
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushOption configures a Push or Call invocation.
|
||||||
|
type PushOption func(*Event)
|
||||||
|
|
||||||
|
// Default configuration values.
|
||||||
|
const (
|
||||||
|
DefaultMaxWorkers = 512
|
||||||
|
DefaultReservedWorkers = 10
|
||||||
|
DefaultQueueSize = 8192
|
||||||
|
DefaultBufferSize = 8192
|
||||||
|
)
|
||||||
267
event/types/types_test.go
Normal file
267
event/types/types_test.go
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
package types_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// samplePayload is a test-only struct with no business semantics.
|
||||||
|
type samplePayload struct {
|
||||||
|
Name string
|
||||||
|
Value int
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Should: basic struct assignment ---
|
||||||
|
|
||||||
|
func TestShould_StructValue(t *testing.T) {
|
||||||
|
ev := &types.Event{
|
||||||
|
Payload: samplePayload{Name: "alpha", Value: 1, Tags: []string{"a", "b"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got samplePayload
|
||||||
|
if err := ev.Should(&got); err != nil {
|
||||||
|
t.Fatalf("Should returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "alpha" || got.Value != 1 || len(got.Tags) != 2 {
|
||||||
|
t.Fatalf("unexpected payload: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Should: pointer payload ---
|
||||||
|
|
||||||
|
func TestShould_PointerPayload(t *testing.T) {
|
||||||
|
ev := &types.Event{
|
||||||
|
Payload: &samplePayload{Name: "beta", Value: 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got samplePayload
|
||||||
|
if err := ev.Should(&got); err != nil {
|
||||||
|
t.Fatalf("Should returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "beta" || got.Value != 2 {
|
||||||
|
t.Fatalf("unexpected payload: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Should: primitive payloads ---
|
||||||
|
|
||||||
|
func TestShould_StringPayload(t *testing.T) {
|
||||||
|
ev := &types.Event{
|
||||||
|
Payload: "hello world",
|
||||||
|
}
|
||||||
|
|
||||||
|
var got string
|
||||||
|
if err := ev.Should(&got); err != nil {
|
||||||
|
t.Fatalf("Should returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "hello world" {
|
||||||
|
t.Fatalf("unexpected string: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShould_IntPayload(t *testing.T) {
|
||||||
|
ev := &types.Event{
|
||||||
|
Payload: 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
var got int
|
||||||
|
if err := ev.Should(&got); err != nil {
|
||||||
|
t.Fatalf("Should returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != 42 {
|
||||||
|
t.Fatalf("unexpected int: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Should: error cases ---
|
||||||
|
|
||||||
|
func TestShould_NilTarget(t *testing.T) {
|
||||||
|
ev := &types.Event{Payload: "data"}
|
||||||
|
if err := ev.Should(nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShould_NonPointerTarget(t *testing.T) {
|
||||||
|
ev := &types.Event{Payload: "data"}
|
||||||
|
var s string
|
||||||
|
if err := ev.Should(s); err == nil {
|
||||||
|
t.Fatal("expected error for non-pointer target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShould_NilPayload(t *testing.T) {
|
||||||
|
ev := &types.Event{Payload: nil}
|
||||||
|
var got string
|
||||||
|
if err := ev.Should(&got); err == nil {
|
||||||
|
t.Fatal("expected error for nil payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShould_NilPointerPayload(t *testing.T) {
|
||||||
|
ev := &types.Event{Payload: (*samplePayload)(nil)}
|
||||||
|
var got samplePayload
|
||||||
|
if err := ev.Should(&got); err == nil {
|
||||||
|
t.Fatal("expected error for nil pointer payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShould_TypeMismatch(t *testing.T) {
|
||||||
|
ev := &types.Event{
|
||||||
|
Payload: "wrong type",
|
||||||
|
}
|
||||||
|
var got samplePayload
|
||||||
|
if err := ev.Should(&got); err == nil {
|
||||||
|
t.Fatal("expected error for type mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event fields ---
|
||||||
|
|
||||||
|
func TestEvent_NilAuth(t *testing.T) {
|
||||||
|
ev := &types.Event{
|
||||||
|
Type: "x.y",
|
||||||
|
ID: "ev-100",
|
||||||
|
Auth: nil,
|
||||||
|
}
|
||||||
|
if ev.Auth != nil {
|
||||||
|
t.Fatal("Auth should be nil")
|
||||||
|
}
|
||||||
|
if ev.Type != "x.y" || ev.ID != "ev-100" {
|
||||||
|
t.Fatalf("unexpected Type/ID: %s/%s", ev.Type, ev.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvent_WithAuth(t *testing.T) {
|
||||||
|
ev := &types.Event{
|
||||||
|
Type: "x.y",
|
||||||
|
ID: "ev-101",
|
||||||
|
SID: "sess-abc",
|
||||||
|
Auth: &types.AuthorizedInfo{
|
||||||
|
UserID: "u-1",
|
||||||
|
TeamID: "t-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if ev.Type != "x.y" || ev.ID != "ev-101" {
|
||||||
|
t.Fatalf("unexpected Type/ID: %s/%s", ev.Type, ev.ID)
|
||||||
|
}
|
||||||
|
if ev.SID != "sess-abc" {
|
||||||
|
t.Fatalf("unexpected SID: %s", ev.SID)
|
||||||
|
}
|
||||||
|
if ev.Auth.UserID != "u-1" || ev.Auth.TeamID != "t-1" {
|
||||||
|
t.Fatalf("unexpected Auth: %+v", ev.Auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvent_QueueAndIsCall(t *testing.T) {
|
||||||
|
push := &types.Event{Queue: "q-1", IsCall: false}
|
||||||
|
call := &types.Event{Queue: "q-1", IsCall: true}
|
||||||
|
|
||||||
|
if push.IsCall {
|
||||||
|
t.Fatal("Push event should not be IsCall")
|
||||||
|
}
|
||||||
|
if !call.IsCall {
|
||||||
|
t.Fatal("Call event should be IsCall")
|
||||||
|
}
|
||||||
|
if push.Queue != "q-1" || call.Queue != "q-1" {
|
||||||
|
t.Fatal("Queue key mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Result ---
|
||||||
|
|
||||||
|
func TestResult_Success(t *testing.T) {
|
||||||
|
r := types.Result{Data: map[string]string{"k": "v"}, Err: nil}
|
||||||
|
if r.Err != nil {
|
||||||
|
t.Fatal("expected nil error")
|
||||||
|
}
|
||||||
|
m, ok := r.Data.(map[string]string)
|
||||||
|
if !ok || m["k"] != "v" {
|
||||||
|
t.Fatal("unexpected result data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResult_Error(t *testing.T) {
|
||||||
|
r := types.Result{Data: nil, Err: fmt.Errorf("something failed")}
|
||||||
|
if r.Err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if r.Err.Error() != "something failed" {
|
||||||
|
t.Fatalf("unexpected error message: %s", r.Err.Error())
|
||||||
|
}
|
||||||
|
if r.Data != nil {
|
||||||
|
t.Fatal("expected nil data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HandlerEntry defaults ---
|
||||||
|
|
||||||
|
func TestHandlerEntry_Defaults(t *testing.T) {
|
||||||
|
entry := types.HandlerEntry{}
|
||||||
|
if entry.MaxWorkers != 0 {
|
||||||
|
t.Fatal("zero value should be 0 before applying options")
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.MaxWorkers == 0 {
|
||||||
|
entry.MaxWorkers = types.DefaultMaxWorkers
|
||||||
|
}
|
||||||
|
if entry.ReservedWorkers == 0 {
|
||||||
|
entry.ReservedWorkers = types.DefaultReservedWorkers
|
||||||
|
}
|
||||||
|
if entry.QueueSize == 0 {
|
||||||
|
entry.QueueSize = types.DefaultQueueSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.MaxWorkers != 512 {
|
||||||
|
t.Fatalf("expected MaxWorkers 512, got %d", entry.MaxWorkers)
|
||||||
|
}
|
||||||
|
if entry.ReservedWorkers != 10 {
|
||||||
|
t.Fatalf("expected ReservedWorkers 10, got %d", entry.ReservedWorkers)
|
||||||
|
}
|
||||||
|
if entry.QueueSize != 8192 {
|
||||||
|
t.Fatalf("expected QueueSize 8192, got %d", entry.QueueSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FilterEntry ---
|
||||||
|
|
||||||
|
func TestFilterEntry_WithFilter(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
entry := types.FilterEntry{
|
||||||
|
Pattern: "x.*",
|
||||||
|
Filter: func(ev *types.Event) bool {
|
||||||
|
called = true
|
||||||
|
return ev.Type == "x.hit"
|
||||||
|
},
|
||||||
|
BufferSize: 4096,
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Pattern != "x.*" {
|
||||||
|
t.Fatalf("unexpected Pattern: %s", entry.Pattern)
|
||||||
|
}
|
||||||
|
if !entry.Filter(&types.Event{Type: "x.hit"}) {
|
||||||
|
t.Fatal("filter should match x.hit")
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("filter was not called")
|
||||||
|
}
|
||||||
|
if entry.Filter(&types.Event{Type: "x.miss"}) {
|
||||||
|
t.Fatal("filter should not match x.miss")
|
||||||
|
}
|
||||||
|
if entry.BufferSize != 4096 {
|
||||||
|
t.Fatalf("unexpected BufferSize: %d", entry.BufferSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterEntry_NilFilter(t *testing.T) {
|
||||||
|
entry := types.FilterEntry{Pattern: "y.*"}
|
||||||
|
if entry.Pattern != "y.*" {
|
||||||
|
t.Fatalf("unexpected Pattern: %s", entry.Pattern)
|
||||||
|
}
|
||||||
|
if entry.Filter != nil {
|
||||||
|
t.Fatal("Filter should be nil when not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
94
event/worker.go
Normal file
94
event/worker.go
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
package event
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/yaoapp/kun/log"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// workerPool manages goroutine-based workers for a single Handler.
|
||||||
|
// Workers are fire-and-forget: each goroutine processes one task then exits.
|
||||||
|
// MaxWorkers limits total concurrent goroutines.
|
||||||
|
// ReservedWorkers reserves slots for Call events so Push cannot starve them.
|
||||||
|
type workerPool struct {
|
||||||
|
handler types.Handler
|
||||||
|
|
||||||
|
// semTotal is a buffered channel of size MaxWorkers.
|
||||||
|
semTotal chan struct{}
|
||||||
|
|
||||||
|
// semPush is a buffered channel of size (MaxWorkers - ReservedWorkers).
|
||||||
|
// Push events must acquire from both semPush and semTotal.
|
||||||
|
// Call events only acquire from semTotal.
|
||||||
|
semPush chan struct{}
|
||||||
|
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWorkerPool(entry *types.HandlerEntry) *workerPool {
|
||||||
|
pushSlots := entry.MaxWorkers - entry.ReservedWorkers
|
||||||
|
if pushSlots < 1 {
|
||||||
|
pushSlots = 1
|
||||||
|
}
|
||||||
|
return &workerPool{
|
||||||
|
handler: entry.Handler,
|
||||||
|
semTotal: make(chan struct{}, entry.MaxWorkers),
|
||||||
|
semPush: make(chan struct{}, pushSlots),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch runs the handler for one event in a new goroutine.
|
||||||
|
// Returns a done channel that is closed when the handler finishes.
|
||||||
|
// Blocks until a worker slot is available or ctx is cancelled.
|
||||||
|
func (wp *workerPool) dispatch(ctx context.Context, ev *types.Event, resp chan<- types.Result) (done <-chan struct{}, err error) {
|
||||||
|
isPush := !ev.IsCall
|
||||||
|
|
||||||
|
if isPush {
|
||||||
|
select {
|
||||||
|
case wp.semPush <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case wp.semTotal <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
if isPush {
|
||||||
|
<-wp.semPush
|
||||||
|
}
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan struct{})
|
||||||
|
wp.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
defer wp.wg.Done()
|
||||||
|
defer func() { <-wp.semTotal }()
|
||||||
|
if isPush {
|
||||||
|
defer func() { <-wp.semPush }()
|
||||||
|
}
|
||||||
|
defer wp.recoverPanic(ev, resp)
|
||||||
|
|
||||||
|
wp.handler.Handle(ctx, ev, resp)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wp *workerPool) recoverPanic(ev *types.Event, resp chan<- types.Result) {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error("event worker panic: type=%s id=%s err=%v", ev.Type, ev.ID, r)
|
||||||
|
select {
|
||||||
|
case resp <- types.Result{Err: ErrHandlerPanic}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait blocks until all active workers finish. Used during Stop.
|
||||||
|
func (wp *workerPool) wait() {
|
||||||
|
wp.wg.Wait()
|
||||||
|
}
|
||||||
213
event/worker_test.go
Normal file
213
event/worker_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
package event_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Phase 5: Worker pool tests ---
|
||||||
|
|
||||||
|
func TestWorker_MaxConcurrency(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
var peak atomic.Int32
|
||||||
|
var current atomic.Int32
|
||||||
|
|
||||||
|
h := &concurrencyHandler{peak: &peak, current: ¤t, delay: 30 * time.Millisecond}
|
||||||
|
event.Register("conc", h, event.MaxWorkers(4), event.ReservedWorkers(1))
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = event.Push(context.Background(), "conc.work", i)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
p := peak.Load()
|
||||||
|
if p > 4 {
|
||||||
|
t.Fatalf("peak concurrency %d exceeded MaxWorkers 4", p)
|
||||||
|
}
|
||||||
|
if p < 2 {
|
||||||
|
t.Fatalf("peak concurrency %d seems too low, expected at least 2", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type concurrencyHandler struct {
|
||||||
|
peak *atomic.Int32
|
||||||
|
current *atomic.Int32
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *concurrencyHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
c := h.current.Add(1)
|
||||||
|
for {
|
||||||
|
old := h.peak.Load()
|
||||||
|
if c <= old || h.peak.CompareAndSwap(old, c) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(h.delay)
|
||||||
|
h.current.Add(-1)
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: "ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *concurrencyHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
func TestWorker_CallReservation(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
// MaxWorkers=4, ReservedWorkers=2 => Push can use 2, Call can use 4
|
||||||
|
var pushActive atomic.Int32
|
||||||
|
var callDone atomic.Int32
|
||||||
|
|
||||||
|
h := &reservationHandler{pushActive: &pushActive, callDone: &callDone}
|
||||||
|
event.Register("res", h, event.MaxWorkers(4), event.ReservedWorkers(2))
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
// Saturate push slots (only 2 available for push)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
_, _ = event.Push(context.Background(), "res.work", i)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond) // let pushes start
|
||||||
|
|
||||||
|
// Call should still work (reserved slots)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, data, err := event.Call(ctx, "res.get", "ping")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Call should succeed with reserved workers: %v", err)
|
||||||
|
}
|
||||||
|
if data != "pong" {
|
||||||
|
t.Fatalf("expected pong, got %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type reservationHandler struct {
|
||||||
|
pushActive *atomic.Int32
|
||||||
|
callDone *atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *reservationHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: "pong"}
|
||||||
|
h.callDone.Add(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.pushActive.Add(1)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
h.pushActive.Add(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *reservationHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
func TestWorker_PanicRecovery(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
var afterPanic atomic.Bool
|
||||||
|
|
||||||
|
h := &panicHandler{afterPanic: &afterPanic}
|
||||||
|
event.Register("pan", h)
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
// First push panics
|
||||||
|
_, _ = event.Push(context.Background(), "pan.crash", "boom")
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Second push should still work
|
||||||
|
_, _ = event.Push(context.Background(), "pan.ok", "fine")
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
if !afterPanic.Load() {
|
||||||
|
t.Fatal("handler should have processed event after panic recovery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type panicHandler struct {
|
||||||
|
afterPanic *atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *panicHandler) Handle(ctx context.Context, ev *types.Event, resp chan<- types.Result) {
|
||||||
|
if ev.Type == "pan.crash" {
|
||||||
|
panic("test panic")
|
||||||
|
}
|
||||||
|
h.afterPanic.Store(true)
|
||||||
|
if ev.IsCall {
|
||||||
|
resp <- types.Result{Data: "ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *panicHandler) Shutdown(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
// --- Coverage: ReservedWorkers >= MaxWorkers (pushSlots clamped to 1) ---
|
||||||
|
|
||||||
|
func TestWorker_ReservedExceedsMax(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &recordHandler{}
|
||||||
|
event.Register("edge", h, event.MaxWorkers(2), event.ReservedWorkers(5))
|
||||||
|
_ = event.Start()
|
||||||
|
defer func() { _ = event.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
_, err := event.Push(context.Background(), "edge.work", "data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Push failed: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
calls := h.getCalls()
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("expected 1 call, got %d", len(calls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Coverage: dispatch Call ctx cancel while waiting for semTotal ---
|
||||||
|
|
||||||
|
func TestWorker_Call_CtxCancel_SemTotal(t *testing.T) {
|
||||||
|
event.Reset()
|
||||||
|
defer event.Reset()
|
||||||
|
|
||||||
|
h := &concurrencyHandler{
|
||||||
|
peak: &atomic.Int32{},
|
||||||
|
current: &atomic.Int32{},
|
||||||
|
delay: 200 * time.Millisecond,
|
||||||
|
}
|
||||||
|
event.Register("lim", h, event.MaxWorkers(1), event.ReservedWorkers(0))
|
||||||
|
_ = event.Start()
|
||||||
|
|
||||||
|
bgDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(bgDone)
|
||||||
|
_, _, _ = event.Call(context.Background(), "lim.work", nil)
|
||||||
|
}()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, _, err := event.Call(ctx, "lim.op", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for call with saturated pool")
|
||||||
|
}
|
||||||
|
|
||||||
|
<-bgDone
|
||||||
|
_ = event.Stop(context.Background())
|
||||||
|
}
|
||||||
14
go.mod
14
go.mod
|
|
@ -64,11 +64,18 @@ require (
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||||
github.com/aws/smithy-go v1.22.3 // indirect
|
github.com/aws/smithy-go v1.22.3 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/bytedance/sonic v1.13.2 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
|
@ -78,6 +85,7 @@ require (
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
|
@ -115,9 +123,11 @@ require (
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mark3labs/mcp-go v0.32.0 // indirect
|
github.com/mark3labs/mcp-go v0.32.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||||
github.com/miekg/dns v1.1.66 // indirect
|
github.com/miekg/dns v1.1.66 // indirect
|
||||||
|
|
@ -126,6 +136,9 @@ require (
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 // indirect
|
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 // indirect
|
||||||
github.com/oklog/run v1.1.0 // indirect
|
github.com/oklog/run v1.1.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
|
@ -159,6 +172,7 @@ require (
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.1.2 // indirect
|
github.com/xdg-go/scram v1.1.2 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/xuri/efp v0.0.1 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
github.com/xuri/nfp v0.0.1 // indirect
|
github.com/xuri/nfp v0.0.1 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
|
|
||||||
29
go.sum
29
go.sum
|
|
@ -39,6 +39,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 h1:BRXS0U76Z8wfF+bnkilA2QwpIch6U
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k=
|
||||||
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
|
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
|
||||||
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||||
|
|
@ -62,6 +64,18 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy
|
||||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
|
@ -98,6 +112,8 @@ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTe
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg=
|
github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg=
|
||||||
github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||||
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
||||||
|
|
@ -231,6 +247,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
|
github.com/mark3labs/mcp-go v0.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
|
||||||
github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
|
github.com/mark3labs/mcp-go v0.32.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||||
|
|
@ -243,6 +261,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
|
||||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
|
@ -269,6 +289,12 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
|
github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ=
|
||||||
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 h1:RKWQW7wTgYAY2fU9S+9LaJ9OwRPbRc0I17tlT7nDmAY=
|
github.com/neo4j/neo4j-go-driver/v5 v5.28.1 h1:RKWQW7wTgYAY2fU9S+9LaJ9OwRPbRc0I17tlT7nDmAY=
|
||||||
github.com/neo4j/neo4j-go-driver/v5 v5.28.1/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k=
|
github.com/neo4j/neo4j-go-driver/v5 v5.28.1/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k=
|
||||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
|
|
@ -379,6 +405,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
||||||
|
|
@ -471,6 +499,7 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
|
||||||
|
|
@ -66,12 +66,14 @@
|
||||||
// 7. Starts V8 JavaScript runtime
|
// 7. Starts V8 JavaScript runtime
|
||||||
// 8. Registers query engines for database operations
|
// 8. Registers query engines for database operations
|
||||||
// 9. Creates temporary data directories for test isolation
|
// 9. Creates temporary data directories for test isolation
|
||||||
|
// 10. Starts the Event Service (handlers registered via init(), e.g. trace)
|
||||||
//
|
//
|
||||||
// WHAT test.Clean() DOES:
|
// WHAT test.Clean() DOES:
|
||||||
// 1. Stops V8 runtime and releases resources
|
// 1. Stops the Event Service (drains in-flight events)
|
||||||
// 2. Closes all database connections
|
// 2. Stops V8 runtime and releases resources
|
||||||
// 3. Removes temporary test data stores
|
// 3. Closes all database connections
|
||||||
// 4. Resets global state to prevent test interference
|
// 4. Removes temporary test data stores
|
||||||
|
// 5. Resets global state to prevent test interference
|
||||||
//
|
//
|
||||||
// WHAT test.Start() DOES:
|
// WHAT test.Start() DOES:
|
||||||
// 1. Creates Gin HTTP server with API routes
|
// 1. Creates Gin HTTP server with API routes
|
||||||
|
|
@ -159,6 +161,7 @@
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -184,11 +187,14 @@ import (
|
||||||
"github.com/yaoapp/xun/capsule"
|
"github.com/yaoapp/xun/capsule"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/data"
|
"github.com/yaoapp/yao/data"
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
"github.com/yaoapp/yao/fs"
|
"github.com/yaoapp/yao/fs"
|
||||||
"github.com/yaoapp/yao/helper"
|
"github.com/yaoapp/yao/helper"
|
||||||
"github.com/yaoapp/yao/runtime"
|
"github.com/yaoapp/yao/runtime"
|
||||||
"github.com/yaoapp/yao/share"
|
"github.com/yaoapp/yao/share"
|
||||||
"github.com/yaoapp/yao/utils"
|
"github.com/yaoapp/yao/utils"
|
||||||
|
|
||||||
|
_ "github.com/yaoapp/yao/trace" // register trace event handler via init()
|
||||||
)
|
)
|
||||||
|
|
||||||
var testServer *http.Server = nil
|
var testServer *http.Server = nil
|
||||||
|
|
@ -488,10 +494,15 @@ func Prepare(t *testing.T, cfg config.Config, opts ...interface{}) {
|
||||||
load(t, cfg)
|
load(t, cfg)
|
||||||
startRuntime(t, cfg)
|
startRuntime(t, cfg)
|
||||||
|
|
||||||
|
// Start event service (trace handler registered via blank import above)
|
||||||
|
if err := event.Start(); err != nil && err != event.ErrAlreadyStart {
|
||||||
|
t.Fatalf("Failed to start event service: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean the test environment
|
// Clean the test environment
|
||||||
func Clean() {
|
func Clean() {
|
||||||
|
event.Stop(context.Background())
|
||||||
dbclose()
|
dbclose()
|
||||||
runtime.Stop()
|
runtime.Stop()
|
||||||
|
|
||||||
|
|
|
||||||
25
trace/event_listener.go
Normal file
25
trace/event_listener.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
eventTypes "github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// traceUpdateListener receives trace update events for cross-cutting concerns
|
||||||
|
// (e.g., audit logging, metrics). Trace updates are broadcast via event.Push
|
||||||
|
// and delivered to this listener and any dynamic subscribers.
|
||||||
|
type traceUpdateListener struct{}
|
||||||
|
|
||||||
|
func (l *traceUpdateListener) OnEvent(ev *eventTypes.Event) {}
|
||||||
|
|
||||||
|
func (l *traceUpdateListener) Shutdown(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
event.Listen("trace.*", &traceUpdateListener{},
|
||||||
|
event.BufferSize(4096),
|
||||||
|
)
|
||||||
|
}
|
||||||
28
trace/handler.go
Normal file
28
trace/handler.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package trace
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
eventTypes "github.com/yaoapp/yao/event/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// traceHandler processes trace events dispatched through the event service.
|
||||||
|
// It enables event.Push routing for trace.* events (used by addUpdateAndBroadcast).
|
||||||
|
type traceHandler struct{}
|
||||||
|
|
||||||
|
func (h *traceHandler) Handle(ctx context.Context, ev *eventTypes.Event, resp chan<- eventTypes.Result) {
|
||||||
|
resp <- eventTypes.Result{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *traceHandler) Shutdown(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
event.Register("trace", &traceHandler{},
|
||||||
|
event.MaxWorkers(256),
|
||||||
|
event.ReservedWorkers(32),
|
||||||
|
event.QueueSize(4096),
|
||||||
|
)
|
||||||
|
}
|
||||||
154
trace/manager.go
154
trace/manager.go
|
|
@ -3,63 +3,56 @@ package trace
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/trace/pubsub"
|
"github.com/yaoapp/yao/event"
|
||||||
"github.com/yaoapp/yao/trace/types"
|
"github.com/yaoapp/yao/trace/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// manager implements the Manager interface with channel-based state management
|
// manager implements the Manager interface.
|
||||||
|
// State is protected by a mutex, replacing the old channel-based state worker.
|
||||||
|
// This eliminates the context-cancel bug while maintaining thread safety.
|
||||||
type manager struct {
|
type manager struct {
|
||||||
ctx context.Context
|
mu sync.Mutex
|
||||||
cancel context.CancelFunc
|
traceID string
|
||||||
traceID string
|
driver types.Driver
|
||||||
driver types.Driver
|
state *managerState
|
||||||
stateCmdChan chan stateCommand // Single channel for all state mutations
|
autoArchive bool
|
||||||
closed int32 // Atomic flag: 1 = closed, safeSend rejects new commands
|
|
||||||
autoArchive bool // Auto-archive on complete/fail
|
|
||||||
pubsub *pubsub.PubSub // Reference to independent pubsub service (for publishing only, doesn't own it)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new trace manager instance
|
// NewManager creates a new trace manager instance.
|
||||||
// pubsubService: reference to independent pubsub service (manager doesn't own it, just publishes to it)
|
func NewManager(ctx context.Context, traceID string, driver types.Driver, option *types.TraceOption) (types.Manager, error) {
|
||||||
func NewManager(ctx context.Context, traceID string, driver types.Driver, pubsubService *pubsub.PubSub, option *types.TraceOption) (types.Manager, error) {
|
|
||||||
// Create a cancellable context for the manager
|
|
||||||
managerCtx, cancel := context.WithCancel(ctx)
|
|
||||||
|
|
||||||
// Determine auto-archive setting
|
|
||||||
autoArchive := false
|
autoArchive := false
|
||||||
if option != nil {
|
if option != nil {
|
||||||
autoArchive = option.AutoArchive
|
autoArchive = option.AutoArchive
|
||||||
}
|
}
|
||||||
|
|
||||||
m := &manager{
|
m := &manager{
|
||||||
ctx: managerCtx,
|
traceID: traceID,
|
||||||
cancel: cancel,
|
driver: driver,
|
||||||
traceID: traceID,
|
autoArchive: autoArchive,
|
||||||
driver: driver,
|
state: &managerState{
|
||||||
stateCmdChan: make(chan stateCommand, 100), // Buffered channel for performance
|
spaces: make(map[string]*types.TraceSpace),
|
||||||
autoArchive: autoArchive,
|
traceStatus: types.TraceStatusPending,
|
||||||
pubsub: pubsubService, // Reference only, doesn't manage lifecycle
|
updates: make([]*types.TraceUpdate, 0, 100),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start state worker goroutine
|
// Load existing updates from driver (for resumed traces).
|
||||||
go m.startStateWorker()
|
// Safe to access m.state directly here — no Queue yet, single goroutine.
|
||||||
|
|
||||||
// Try to load existing updates from driver (for resumed traces)
|
|
||||||
if existingUpdates, err := driver.LoadUpdates(ctx, traceID, 0); err == nil && len(existingUpdates) > 0 {
|
if existingUpdates, err := driver.LoadUpdates(ctx, traceID, 0); err == nil && len(existingUpdates) > 0 {
|
||||||
log.Trace("[MANAGER] NewManager: loaded %d existing updates from driver for trace %s", len(existingUpdates), traceID)
|
log.Trace("[MANAGER] NewManager: loaded %d existing updates from driver for trace %s", len(existingUpdates), traceID)
|
||||||
m.stateSetUpdates(existingUpdates)
|
m.state.updates = existingUpdates
|
||||||
// Check if trace was already completed
|
|
||||||
for _, update := range existingUpdates {
|
for _, update := range existingUpdates {
|
||||||
if update.Type == types.UpdateTypeComplete {
|
if update.Type == types.UpdateTypeComplete {
|
||||||
log.Trace("[MANAGER] NewManager: trace %s was already completed, marking as completed", traceID)
|
log.Trace("[MANAGER] NewManager: trace %s was already completed, marking as completed", traceID)
|
||||||
m.stateMarkCompleted()
|
m.state.completed = true
|
||||||
if data, ok := update.Data.(*types.TraceCompleteData); ok {
|
if data, ok := update.Data.(*types.TraceCompleteData); ok {
|
||||||
log.Trace("[MANAGER] NewManager: setting trace status to %s", data.Status)
|
log.Trace("[MANAGER] NewManager: setting trace status to %s", data.Status)
|
||||||
m.stateSetTraceStatus(data.Status)
|
m.state.traceStatus = data.Status
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +63,6 @@ func NewManager(ctx context.Context, traceID string, driver types.Driver, pubsub
|
||||||
} else {
|
} else {
|
||||||
log.Trace("[MANAGER] NewManager: no existing updates found for trace %s, creating new trace", traceID)
|
log.Trace("[MANAGER] NewManager: no existing updates found for trace %s, creating new trace", traceID)
|
||||||
}
|
}
|
||||||
// New trace - create and broadcast init event
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
m.addUpdateAndBroadcast(&types.TraceUpdate{
|
m.addUpdateAndBroadcast(&types.TraceUpdate{
|
||||||
Type: types.UpdateTypeInit,
|
Type: types.UpdateTypeInit,
|
||||||
|
|
@ -89,36 +81,24 @@ func genNodeID() string {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// addUpdateAndBroadcast persists, adds to history, and publishes an update
|
// addUpdateAndBroadcast persists, adds to history, and broadcasts via event service.
|
||||||
func (m *manager) addUpdateAndBroadcast(update *types.TraceUpdate) {
|
func (m *manager) addUpdateAndBroadcast(update *types.TraceUpdate) {
|
||||||
// Persist to driver (synchronous - no race)
|
|
||||||
if err := m.driver.SaveUpdate(context.Background(), m.traceID, update); err != nil {
|
if err := m.driver.SaveUpdate(context.Background(), m.traceID, update); err != nil {
|
||||||
log.Trace("[MANAGER] addUpdateAndBroadcast: failed to save update type=%s for trace %s: %v", update.Type, m.traceID, err)
|
log.Trace("[MANAGER] addUpdateAndBroadcast: failed to save update type=%s for trace %s: %v", update.Type, m.traceID, err)
|
||||||
}
|
}
|
||||||
// else {
|
|
||||||
// log.Trace("[MANAGER] addUpdateAndBroadcast: successfully saved update type=%s for trace %s", update.Type, m.traceID)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Add to in-memory history
|
|
||||||
m.stateAddUpdate(update)
|
m.stateAddUpdate(update)
|
||||||
|
|
||||||
// Publish to independent PubSub service (manager just publishes, doesn't manage pubsub lifecycle)
|
// Broadcast to subscribers via event service (fire-and-forget, non-blocking).
|
||||||
if m.pubsub != nil {
|
// Uses Push with the update as payload so event.Subscribe filters can match by traceID.
|
||||||
m.pubsub.Publish(update)
|
event.Push(context.Background(), "trace.update", update)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkContext checks if context is cancelled
|
// checkContext checks if the trace has been completed/released.
|
||||||
|
// With event-based state management, the manager no longer binds a context.
|
||||||
|
// Lifecycle is controlled by QueueCreate/QueueRelease.
|
||||||
func (m *manager) checkContext() error {
|
func (m *manager) checkContext() error {
|
||||||
select {
|
return nil
|
||||||
case <-m.ctx.Done():
|
|
||||||
// Context cancelled - just return the error
|
|
||||||
// Don't call handleCancellation here to avoid deadlock
|
|
||||||
// handleCancellation should be called explicitly when needed
|
|
||||||
return m.ctx.Err()
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add creates next sequential node - auto-joins if currently in parallel state
|
// Add creates next sequential node - auto-joins if currently in parallel state
|
||||||
|
|
@ -147,7 +127,7 @@ func (m *manager) Add(input types.TraceInput, option types.TraceNodeOption) (typ
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save root node
|
// Save root node
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, rootNode); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, rootNode); err != nil {
|
||||||
return nil, fmt.Errorf("failed to save root node: %w", err)
|
return nil, fmt.Errorf("failed to save root node: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,14 +190,14 @@ func (m *manager) Add(input types.TraceInput, option types.TraceNodeOption) (typ
|
||||||
// Add to each parent's children
|
// Add to each parent's children
|
||||||
for _, parent := range currentNodes {
|
for _, parent := range currentNodes {
|
||||||
parent.Children = append(parent.Children, newNodeData)
|
parent.Children = append(parent.Children, newNodeData)
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, parent); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, parent); err != nil {
|
||||||
// Log error but continue
|
// Log error but continue
|
||||||
m.Error("Failed to update parent node %s: %v", parent.ID, err)
|
m.Error("Failed to update parent node %s: %v", parent.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save new node
|
// Save new node
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, newNodeData); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, newNodeData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,7 +290,7 @@ func (m *manager) Parallel(parallelInputs []types.TraceParallelInput) ([]types.N
|
||||||
// Save all nodes in batch - collect errors
|
// Save all nodes in batch - collect errors
|
||||||
var saveErrors []error
|
var saveErrors []error
|
||||||
for _, data := range nodeData {
|
for _, data := range nodeData {
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, data); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, data); err != nil {
|
||||||
saveErrors = append(saveErrors, fmt.Errorf("failed to save node %s: %w", data.ID, err))
|
saveErrors = append(saveErrors, fmt.Errorf("failed to save node %s: %w", data.ID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -321,7 +301,7 @@ func (m *manager) Parallel(parallelInputs []types.TraceParallelInput) ([]types.N
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save parent node
|
// Save parent node
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, parentNode); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, parentNode); err != nil {
|
||||||
return nil, fmt.Errorf("failed to save parent node: %w", err)
|
return nil, fmt.Errorf("failed to save parent node: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,7 +360,7 @@ func (m *manager) log(level string, message string, args ...any) {
|
||||||
NodeID: node.ID,
|
NodeID: node.ID,
|
||||||
}
|
}
|
||||||
// Save log (ignore errors for non-critical logging)
|
// Save log (ignore errors for non-critical logging)
|
||||||
_ = m.driver.SaveLog(m.ctx, m.traceID, log)
|
_ = m.driver.SaveLog(context.Background(), m.traceID, log)
|
||||||
|
|
||||||
// Broadcast log event
|
// Broadcast log event
|
||||||
m.addUpdateAndBroadcast(&types.TraceUpdate{
|
m.addUpdateAndBroadcast(&types.TraceUpdate{
|
||||||
|
|
@ -404,7 +384,7 @@ func (m *manager) SetOutput(output types.TraceOutput) error {
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
node.Output = output
|
node.Output = output
|
||||||
node.UpdatedAt = now
|
node.UpdatedAt = now
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, node); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,7 +414,7 @@ func (m *manager) SetMetadata(key string, value any) error {
|
||||||
}
|
}
|
||||||
node.Metadata[key] = value
|
node.Metadata[key] = value
|
||||||
node.UpdatedAt = now
|
node.UpdatedAt = now
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, node); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,7 +465,7 @@ func (m *manager) Complete(output ...types.TraceOutput) error {
|
||||||
node.Status = types.StatusCompleted
|
node.Status = types.StatusCompleted
|
||||||
node.EndTime = now
|
node.EndTime = now
|
||||||
node.UpdatedAt = now
|
node.UpdatedAt = now
|
||||||
if err := m.driver.SaveNode(m.ctx, m.traceID, node); err != nil {
|
if err := m.driver.SaveNode(context.Background(), m.traceID, node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,7 +496,7 @@ func (m *manager) Fail(err error) error {
|
||||||
node.Status = types.StatusFailed
|
node.Status = types.StatusFailed
|
||||||
node.EndTime = now
|
node.EndTime = now
|
||||||
node.UpdatedAt = now
|
node.UpdatedAt = now
|
||||||
if saveErr := m.driver.SaveNode(m.ctx, m.traceID, node); saveErr != nil {
|
if saveErr := m.driver.SaveNode(context.Background(), m.traceID, node); saveErr != nil {
|
||||||
return saveErr
|
return saveErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,7 +525,7 @@ func (m *manager) GetRootNode() (*types.TraceNode, error) {
|
||||||
|
|
||||||
// GetNode returns a node by ID
|
// GetNode returns a node by ID
|
||||||
func (m *manager) GetNode(id string) (*types.TraceNode, error) {
|
func (m *manager) GetNode(id string) (*types.TraceNode, error) {
|
||||||
return m.driver.LoadNode(m.ctx, m.traceID, id)
|
return m.driver.LoadNode(context.Background(), m.traceID, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentNodes returns current active nodes
|
// GetCurrentNodes returns current active nodes
|
||||||
|
|
@ -581,7 +561,7 @@ func (m *manager) MarkComplete() error {
|
||||||
|
|
||||||
// Auto-archive if enabled
|
// Auto-archive if enabled
|
||||||
if m.autoArchive {
|
if m.autoArchive {
|
||||||
if err := m.driver.Archive(m.ctx, m.traceID); err != nil {
|
if err := m.driver.Archive(context.Background(), m.traceID); err != nil {
|
||||||
// Log error but don't fail the complete operation
|
// Log error but don't fail the complete operation
|
||||||
m.Debug("Failed to auto-archive trace", map[string]any{
|
m.Debug("Failed to auto-archive trace", map[string]any{
|
||||||
"trace_id": m.traceID,
|
"trace_id": m.traceID,
|
||||||
|
|
@ -610,7 +590,7 @@ func (m *manager) CreateSpace(option types.TraceSpaceOption) (*types.TraceSpace,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to driver
|
// Save to driver
|
||||||
if err := m.driver.SaveSpace(m.ctx, m.traceID, space); err != nil {
|
if err := m.driver.SaveSpace(context.Background(), m.traceID, space); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -637,7 +617,7 @@ func (m *manager) GetSpace(id string) (*types.TraceSpace, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load from driver
|
// Load from driver
|
||||||
space, err := m.driver.LoadSpace(m.ctx, m.traceID, id)
|
space, err := m.driver.LoadSpace(context.Background(), m.traceID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -658,7 +638,7 @@ func (m *manager) HasSpace(id string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check in driver
|
// Check in driver
|
||||||
space, _ := m.driver.LoadSpace(m.ctx, m.traceID, id)
|
space, _ := m.driver.LoadSpace(context.Background(), m.traceID, id)
|
||||||
return space != nil
|
return space != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -674,7 +654,7 @@ func (m *manager) DeleteSpace(id string) error {
|
||||||
m.stateDeleteSpace(id)
|
m.stateDeleteSpace(id)
|
||||||
|
|
||||||
// Delete from driver
|
// Delete from driver
|
||||||
if err := m.driver.DeleteSpace(m.ctx, m.traceID, id); err != nil {
|
if err := m.driver.DeleteSpace(context.Background(), m.traceID, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -693,7 +673,7 @@ func (m *manager) DeleteSpace(id string) error {
|
||||||
// ListSpaces returns all spaces
|
// ListSpaces returns all spaces
|
||||||
func (m *manager) ListSpaces() []*types.TraceSpace {
|
func (m *manager) ListSpaces() []*types.TraceSpace {
|
||||||
// Load from driver to ensure we have all spaces
|
// Load from driver to ensure we have all spaces
|
||||||
spaceIDs, err := m.driver.ListSpaces(m.ctx, m.traceID)
|
spaceIDs, err := m.driver.ListSpaces(context.Background(), m.traceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback to cached spaces
|
// Fallback to cached spaces
|
||||||
return m.stateGetAllSpaces()
|
return m.stateGetAllSpaces()
|
||||||
|
|
@ -727,13 +707,13 @@ func (m *manager) SetSpaceValue(spaceID, key string, value any) error {
|
||||||
|
|
||||||
// Set value in driver (through state worker for concurrent safety)
|
// Set value in driver (through state worker for concurrent safety)
|
||||||
err = m.stateExecuteSpaceOp(spaceID, func() error {
|
err = m.stateExecuteSpaceOp(spaceID, func() error {
|
||||||
if err := m.driver.SetSpaceKey(m.ctx, m.traceID, spaceID, key, value); err != nil {
|
if err := m.driver.SetSpaceKey(context.Background(), m.traceID, spaceID, key, value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update space timestamp
|
// Update space timestamp
|
||||||
space.UpdatedAt = now
|
space.UpdatedAt = now
|
||||||
if err := m.driver.SaveSpace(m.ctx, m.traceID, space); err != nil {
|
if err := m.driver.SaveSpace(context.Background(), m.traceID, space); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -761,7 +741,7 @@ func (m *manager) GetSpaceValue(spaceID, key string) (any, error) {
|
||||||
var result any
|
var result any
|
||||||
err := m.stateExecuteSpaceOp(spaceID, func() error {
|
err := m.stateExecuteSpaceOp(spaceID, func() error {
|
||||||
var err error
|
var err error
|
||||||
result, err = m.driver.GetSpaceKey(m.ctx, m.traceID, spaceID, key)
|
result, err = m.driver.GetSpaceKey(context.Background(), m.traceID, spaceID, key)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
return result, err
|
return result, err
|
||||||
|
|
@ -771,7 +751,7 @@ func (m *manager) GetSpaceValue(spaceID, key string) (any, error) {
|
||||||
func (m *manager) HasSpaceValue(spaceID, key string) bool {
|
func (m *manager) HasSpaceValue(spaceID, key string) bool {
|
||||||
var result bool
|
var result bool
|
||||||
_ = m.stateExecuteSpaceOp(spaceID, func() error {
|
_ = m.stateExecuteSpaceOp(spaceID, func() error {
|
||||||
result = m.driver.HasSpaceKey(m.ctx, m.traceID, spaceID, key)
|
result = m.driver.HasSpaceKey(context.Background(), m.traceID, spaceID, key)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
@ -787,7 +767,7 @@ func (m *manager) DeleteSpaceValue(spaceID, key string) error {
|
||||||
|
|
||||||
// Delete value from driver (through state worker for concurrent safety)
|
// Delete value from driver (through state worker for concurrent safety)
|
||||||
err := m.stateExecuteSpaceOp(spaceID, func() error {
|
err := m.stateExecuteSpaceOp(spaceID, func() error {
|
||||||
return m.driver.DeleteSpaceKey(m.ctx, m.traceID, spaceID, key)
|
return m.driver.DeleteSpaceKey(context.Background(), m.traceID, spaceID, key)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -816,7 +796,7 @@ func (m *manager) ClearSpaceValues(spaceID string) error {
|
||||||
|
|
||||||
// Clear values from driver (through state worker for concurrent safety)
|
// Clear values from driver (through state worker for concurrent safety)
|
||||||
err := m.stateExecuteSpaceOp(spaceID, func() error {
|
err := m.stateExecuteSpaceOp(spaceID, func() error {
|
||||||
return m.driver.ClearSpaceKeys(m.ctx, m.traceID, spaceID)
|
return m.driver.ClearSpaceKeys(context.Background(), m.traceID, spaceID)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -840,7 +820,7 @@ func (m *manager) ListSpaceKeys(spaceID string) []string {
|
||||||
var keys []string
|
var keys []string
|
||||||
_ = m.stateExecuteSpaceOp(spaceID, func() error {
|
_ = m.stateExecuteSpaceOp(spaceID, func() error {
|
||||||
var err error
|
var err error
|
||||||
keys, err = m.driver.ListSpaceKeys(m.ctx, m.traceID, spaceID)
|
keys, err = m.driver.ListSpaceKeys(context.Background(), m.traceID, spaceID)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
return keys
|
return keys
|
||||||
|
|
@ -865,7 +845,7 @@ func (m *manager) GetTraceInfo() (*types.TraceInfo, error) {
|
||||||
if err := m.checkContext(); err != nil {
|
if err := m.checkContext(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.driver.LoadTraceInfo(m.ctx, m.traceID)
|
return m.driver.LoadTraceInfo(context.Background(), m.traceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllNodes retrieves all nodes from storage
|
// GetAllNodes retrieves all nodes from storage
|
||||||
|
|
@ -875,7 +855,7 @@ func (m *manager) GetAllNodes() ([]*types.TraceNode, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the root node tree from storage
|
// Load the root node tree from storage
|
||||||
rootNode, err := m.driver.LoadTrace(m.ctx, m.traceID)
|
rootNode, err := m.driver.LoadTrace(context.Background(), m.traceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -906,7 +886,7 @@ func (m *manager) GetNodeByID(nodeID string) (*types.TraceNode, error) {
|
||||||
if err := m.checkContext(); err != nil {
|
if err := m.checkContext(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.driver.LoadNode(m.ctx, m.traceID, nodeID)
|
return m.driver.LoadNode(context.Background(), m.traceID, nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllLogs retrieves all logs from storage
|
// GetAllLogs retrieves all logs from storage
|
||||||
|
|
@ -914,7 +894,7 @@ func (m *manager) GetAllLogs() ([]*types.TraceLog, error) {
|
||||||
if err := m.checkContext(); err != nil {
|
if err := m.checkContext(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.driver.LoadLogs(m.ctx, m.traceID, "")
|
return m.driver.LoadLogs(context.Background(), m.traceID, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogsByNode retrieves logs for a specific node from storage
|
// GetLogsByNode retrieves logs for a specific node from storage
|
||||||
|
|
@ -922,7 +902,7 @@ func (m *manager) GetLogsByNode(nodeID string) ([]*types.TraceLog, error) {
|
||||||
if err := m.checkContext(); err != nil {
|
if err := m.checkContext(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.driver.LoadLogs(m.ctx, m.traceID, nodeID)
|
return m.driver.LoadLogs(context.Background(), m.traceID, nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllSpaces retrieves all spaces from storage
|
// GetAllSpaces retrieves all spaces from storage
|
||||||
|
|
@ -932,7 +912,7 @@ func (m *manager) GetAllSpaces() ([]*types.TraceSpace, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all space IDs from driver
|
// Get all space IDs from driver
|
||||||
spaceIDs, err := m.driver.ListSpaces(m.ctx, m.traceID)
|
spaceIDs, err := m.driver.ListSpaces(context.Background(), m.traceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -940,7 +920,7 @@ func (m *manager) GetAllSpaces() ([]*types.TraceSpace, error) {
|
||||||
// Load all spaces
|
// Load all spaces
|
||||||
spaces := make([]*types.TraceSpace, 0, len(spaceIDs))
|
spaces := make([]*types.TraceSpace, 0, len(spaceIDs))
|
||||||
for _, spaceID := range spaceIDs {
|
for _, spaceID := range spaceIDs {
|
||||||
space, err := m.driver.LoadSpace(m.ctx, m.traceID, spaceID)
|
space, err := m.driver.LoadSpace(context.Background(), m.traceID, spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Skip spaces that fail to load
|
continue // Skip spaces that fail to load
|
||||||
}
|
}
|
||||||
|
|
@ -959,7 +939,7 @@ func (m *manager) GetSpaceByID(spaceID string) (*types.TraceSpaceData, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load space metadata
|
// Load space metadata
|
||||||
space, err := m.driver.LoadSpace(m.ctx, m.traceID, spaceID)
|
space, err := m.driver.LoadSpace(context.Background(), m.traceID, spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -968,7 +948,7 @@ func (m *manager) GetSpaceByID(spaceID string) (*types.TraceSpaceData, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all keys in the space
|
// Load all keys in the space
|
||||||
keys, err := m.driver.ListSpaceKeys(m.ctx, m.traceID, spaceID)
|
keys, err := m.driver.ListSpaceKeys(context.Background(), m.traceID, spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -976,7 +956,7 @@ func (m *manager) GetSpaceByID(spaceID string) (*types.TraceSpaceData, error) {
|
||||||
// Load all key-value pairs
|
// Load all key-value pairs
|
||||||
data := make(map[string]any)
|
data := make(map[string]any)
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
value, err := m.driver.GetSpaceKey(m.ctx, m.traceID, spaceID, key)
|
value, err := m.driver.GetSpaceKey(context.Background(), m.traceID, spaceID, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Skip keys that fail to load
|
continue // Skip keys that fail to load
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package trace
|
package trace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/trace/types"
|
"github.com/yaoapp/yao/trace/types"
|
||||||
|
|
@ -60,7 +61,7 @@ func (n *node) log(level string, message string, args ...any) *types.TraceLog {
|
||||||
NodeID: n.data.ID,
|
NodeID: n.data.ID,
|
||||||
}
|
}
|
||||||
// Save log (ignore errors for non-critical logging)
|
// Save log (ignore errors for non-critical logging)
|
||||||
_ = n.manager.driver.SaveLog(n.manager.ctx, n.manager.traceID, log)
|
_ = n.manager.driver.SaveLog(context.Background(), n.manager.traceID, log)
|
||||||
return log
|
return log
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,10 +86,10 @@ func (n *node) Add(input types.TraceInput, option types.TraceNodeOption) (types.
|
||||||
n.data.Children = append(n.data.Children, childNodeData)
|
n.data.Children = append(n.data.Children, childNodeData)
|
||||||
|
|
||||||
// Save both nodes
|
// Save both nodes
|
||||||
if err := n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, childNodeData); err != nil {
|
if err := n.manager.driver.SaveNode(context.Background(), n.manager.traceID, childNodeData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, n.data); err != nil {
|
if err := n.manager.driver.SaveNode(context.Background(), n.manager.traceID, n.data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,7 +121,7 @@ func (n *node) Parallel(parallelInputs []types.TraceParallelInput) ([]types.Node
|
||||||
n.data.Children = append(n.data.Children, childNodeData)
|
n.data.Children = append(n.data.Children, childNodeData)
|
||||||
|
|
||||||
// Save node
|
// Save node
|
||||||
if err := n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, childNodeData); err != nil {
|
if err := n.manager.driver.SaveNode(context.Background(), n.manager.traceID, childNodeData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +133,7 @@ func (n *node) Parallel(parallelInputs []types.TraceParallelInput) ([]types.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save parent node
|
// Save parent node
|
||||||
if err := n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, n.data); err != nil {
|
if err := n.manager.driver.SaveNode(context.Background(), n.manager.traceID, n.data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +166,7 @@ func (n *node) Join(nodes []*types.TraceNode, input types.TraceInput, option typ
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save join node
|
// Save join node
|
||||||
if err := n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, joinNodeData); err != nil {
|
if err := n.manager.driver.SaveNode(context.Background(), n.manager.traceID, joinNodeData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +186,7 @@ func (n *node) ID() string {
|
||||||
func (n *node) SetOutput(output types.TraceOutput) error {
|
func (n *node) SetOutput(output types.TraceOutput) error {
|
||||||
n.data.Output = output
|
n.data.Output = output
|
||||||
n.data.UpdatedAt = time.Now().UnixMilli()
|
n.data.UpdatedAt = time.Now().UnixMilli()
|
||||||
return n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, n.data)
|
return n.manager.driver.SaveNode(context.Background(), n.manager.traceID, n.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMetadata sets node metadata
|
// SetMetadata sets node metadata
|
||||||
|
|
@ -195,14 +196,14 @@ func (n *node) SetMetadata(key string, value any) error {
|
||||||
}
|
}
|
||||||
n.data.Metadata[key] = value
|
n.data.Metadata[key] = value
|
||||||
n.data.UpdatedAt = time.Now().UnixMilli()
|
n.data.UpdatedAt = time.Now().UnixMilli()
|
||||||
return n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, n.data)
|
return n.manager.driver.SaveNode(context.Background(), n.manager.traceID, n.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetStatus sets the node status
|
// SetStatus sets the node status
|
||||||
func (n *node) SetStatus(status string) error {
|
func (n *node) SetStatus(status string) error {
|
||||||
n.data.Status = types.NodeStatus(status)
|
n.data.Status = types.NodeStatus(status)
|
||||||
n.data.UpdatedAt = time.Now().UnixMilli()
|
n.data.UpdatedAt = time.Now().UnixMilli()
|
||||||
return n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, n.data)
|
return n.manager.driver.SaveNode(context.Background(), n.manager.traceID, n.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete marks the node as completed (public method, broadcasts event)
|
// Complete marks the node as completed (public method, broadcasts event)
|
||||||
|
|
@ -236,7 +237,7 @@ func (n *node) complete(output ...types.TraceOutput) error {
|
||||||
n.data.Status = types.StatusCompleted
|
n.data.Status = types.StatusCompleted
|
||||||
n.data.EndTime = now
|
n.data.EndTime = now
|
||||||
n.data.UpdatedAt = now
|
n.data.UpdatedAt = now
|
||||||
return n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, n.data)
|
return n.manager.driver.SaveNode(context.Background(), n.manager.traceID, n.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail marks the node as failed (public method, broadcasts event)
|
// Fail marks the node as failed (public method, broadcasts event)
|
||||||
|
|
@ -269,5 +270,5 @@ func (n *node) fail(err error) error {
|
||||||
n.data.EndTime = now
|
n.data.EndTime = now
|
||||||
n.data.UpdatedAt = now
|
n.data.UpdatedAt = now
|
||||||
|
|
||||||
return n.manager.driver.SaveNode(n.manager.ctx, n.manager.traceID, n.data)
|
return n.manager.driver.SaveNode(context.Background(), n.manager.traceID, n.data)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
package pubsub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
|
||||||
"github.com/yaoapp/kun/log"
|
|
||||||
"github.com/yaoapp/yao/trace/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PubSub is an independent publish-subscribe service for trace updates
|
|
||||||
// It acts as a message broker between trace writers and readers
|
|
||||||
type PubSub struct {
|
|
||||||
eventBus chan *types.TraceUpdate // Event bus for incoming events
|
|
||||||
subscribers map[string]chan *types.TraceUpdate // Active subscribers
|
|
||||||
mu sync.RWMutex // Protects subscribers map
|
|
||||||
stopCh chan struct{} // Signal to stop the service
|
|
||||||
stopped bool // Whether service is stopped
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new PubSub service
|
|
||||||
func New() *PubSub {
|
|
||||||
ps := &PubSub{
|
|
||||||
eventBus: make(chan *types.TraceUpdate, 1000), // Buffered event bus
|
|
||||||
subscribers: make(map[string]chan *types.TraceUpdate),
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
stopped: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start forwarding service
|
|
||||||
go ps.forward()
|
|
||||||
|
|
||||||
return ps
|
|
||||||
}
|
|
||||||
|
|
||||||
// forward continuously forwards events from eventBus to all subscribers
|
|
||||||
// This runs in a dedicated goroutine
|
|
||||||
func (ps *PubSub) forward() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case event := <-ps.eventBus:
|
|
||||||
ps.mu.RLock()
|
|
||||||
subscriberCount := len(ps.subscribers)
|
|
||||||
|
|
||||||
if subscriberCount == 0 {
|
|
||||||
// No subscribers, discard event
|
|
||||||
ps.mu.RUnlock()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward to all subscribers (non-blocking)
|
|
||||||
for subID, ch := range ps.subscribers {
|
|
||||||
select {
|
|
||||||
case ch <- event:
|
|
||||||
// Sent successfully
|
|
||||||
default:
|
|
||||||
// Subscriber is slow or channel full, skip
|
|
||||||
log.Trace("[PUBSUB] Subscriber %s is slow, skipping event type=%s", subID, event.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ps.mu.RUnlock()
|
|
||||||
|
|
||||||
case <-ps.stopCh:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish sends an event to the event bus
|
|
||||||
// This is called by trace writers (e.g., manager.addUpdateAndBroadcast)
|
|
||||||
func (ps *PubSub) Publish(event *types.TraceUpdate) {
|
|
||||||
if ps.stopped {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case ps.eventBus <- event:
|
|
||||||
// Event published successfully
|
|
||||||
default:
|
|
||||||
// Event bus full, this shouldn't happen with large buffer (log as warning)
|
|
||||||
log.Warn("[PUBSUB] Event bus full, discarding event type=%s", event.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe creates a new subscription and returns a channel for receiving updates
|
|
||||||
// The caller is responsible for reading from the channel and closing it when done
|
|
||||||
func (ps *PubSub) Subscribe(bufferSize int) (<-chan *types.TraceUpdate, string) {
|
|
||||||
// Generate unique subscriber ID
|
|
||||||
subID, _ := gonanoid.Generate("0123456789abcdefghijklmnopqrstuvwxyz", 12)
|
|
||||||
|
|
||||||
// Create subscriber channel
|
|
||||||
ch := make(chan *types.TraceUpdate, bufferSize)
|
|
||||||
|
|
||||||
// Register subscriber
|
|
||||||
ps.mu.Lock()
|
|
||||||
ps.subscribers[subID] = ch
|
|
||||||
ps.mu.Unlock()
|
|
||||||
|
|
||||||
return ch, subID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe removes a subscriber and closes its channel
|
|
||||||
func (ps *PubSub) Unsubscribe(subID string) {
|
|
||||||
ps.mu.Lock()
|
|
||||||
defer ps.mu.Unlock()
|
|
||||||
|
|
||||||
ch, exists := ps.subscribers[subID]
|
|
||||||
if !exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from map
|
|
||||||
delete(ps.subscribers, subID)
|
|
||||||
|
|
||||||
// Close channel
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubscriberCount returns the number of active subscribers
|
|
||||||
func (ps *PubSub) SubscriberCount() int {
|
|
||||||
ps.mu.RLock()
|
|
||||||
defer ps.mu.RUnlock()
|
|
||||||
return len(ps.subscribers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the forwarding service and closes all subscriber channels
|
|
||||||
func (ps *PubSub) Stop() {
|
|
||||||
if ps.stopped {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ps.stopped = true
|
|
||||||
close(ps.stopCh)
|
|
||||||
|
|
||||||
// Close all subscriber channels
|
|
||||||
ps.mu.Lock()
|
|
||||||
for _, ch := range ps.subscribers {
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
ps.subscribers = make(map[string]chan *types.TraceUpdate)
|
|
||||||
ps.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package pubsub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/yaoapp/kun/log"
|
|
||||||
"github.com/yaoapp/yao/trace/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Subscriber represents a subscription to trace updates
|
|
||||||
type Subscriber struct {
|
|
||||||
ID string
|
|
||||||
Channel <-chan *types.TraceUpdate
|
|
||||||
pubsub *PubSub
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe removes the subscription and closes the channel
|
|
||||||
func (s *Subscriber) Unsubscribe() {
|
|
||||||
s.pubsub.Unsubscribe(s.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubscribeWithHistory creates a subscription and replays historical updates first
|
|
||||||
// historicalUpdates: updates to replay before starting live stream
|
|
||||||
// bufferSize: size of the subscription channel buffer
|
|
||||||
func (ps *PubSub) SubscribeWithHistory(historicalUpdates []*types.TraceUpdate, bufferSize int) *Subscriber {
|
|
||||||
// Create subscription
|
|
||||||
ch, subID := ps.Subscribe(bufferSize)
|
|
||||||
|
|
||||||
// Create subscriber
|
|
||||||
sub := &Subscriber{
|
|
||||||
ID: subID,
|
|
||||||
Channel: ch,
|
|
||||||
pubsub: ps,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replay historical updates in a goroutine
|
|
||||||
// This allows the subscription to start immediately
|
|
||||||
go func() {
|
|
||||||
// Get writable channel for replay
|
|
||||||
ps.mu.RLock()
|
|
||||||
writeCh, exists := ps.subscribers[subID]
|
|
||||||
ps.mu.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replay all historical updates (blocking send to ensure delivery)
|
|
||||||
for i, update := range historicalUpdates {
|
|
||||||
select {
|
|
||||||
case writeCh <- update:
|
|
||||||
// Sent successfully
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
// Timeout - subscriber is too slow or disconnected
|
|
||||||
log.Trace("[PUBSUB] Subscriber %s timed out during replay at update %d/%d", subID, i, len(historicalUpdates))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
|
|
@ -6,18 +6,18 @@ import (
|
||||||
"github.com/yaoapp/yao/trace/types"
|
"github.com/yaoapp/yao/trace/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// space implements the Space interface for custom space operations
|
// space implements the Space interface for custom space operations.
|
||||||
|
// Uses context.Background() for driver calls to decouple from caller context
|
||||||
|
// (fixes the context-fork bug where parent cancellation breaks child ops).
|
||||||
type space struct {
|
type space struct {
|
||||||
ctx context.Context
|
|
||||||
traceID string
|
traceID string
|
||||||
data *types.TraceSpace
|
data *types.TraceSpace
|
||||||
driver types.Driver
|
driver types.Driver
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpace creates a new space instance
|
// NewSpace creates a new space instance
|
||||||
func NewSpace(ctx context.Context, traceID string, data *types.TraceSpace, driver types.Driver) types.Space {
|
func NewSpace(traceID string, data *types.TraceSpace, driver types.Driver) types.Space {
|
||||||
return &space{
|
return &space{
|
||||||
ctx: ctx,
|
|
||||||
traceID: traceID,
|
traceID: traceID,
|
||||||
data: data,
|
data: data,
|
||||||
driver: driver,
|
driver: driver,
|
||||||
|
|
@ -31,32 +31,32 @@ func (s *space) ID() string {
|
||||||
|
|
||||||
// Set stores a value by key
|
// Set stores a value by key
|
||||||
func (s *space) Set(key string, value any) error {
|
func (s *space) Set(key string, value any) error {
|
||||||
return s.driver.SetSpaceKey(s.ctx, s.traceID, s.data.ID, key, value)
|
return s.driver.SetSpaceKey(context.Background(), s.traceID, s.data.ID, key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a value by key
|
// Get retrieves a value by key
|
||||||
func (s *space) Get(key string) (any, error) {
|
func (s *space) Get(key string) (any, error) {
|
||||||
return s.driver.GetSpaceKey(s.ctx, s.traceID, s.data.ID, key)
|
return s.driver.GetSpaceKey(context.Background(), s.traceID, s.data.ID, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has checks if a key exists
|
// Has checks if a key exists
|
||||||
func (s *space) Has(key string) bool {
|
func (s *space) Has(key string) bool {
|
||||||
return s.driver.HasSpaceKey(s.ctx, s.traceID, s.data.ID, key)
|
return s.driver.HasSpaceKey(context.Background(), s.traceID, s.data.ID, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes a key-value pair
|
// Delete removes a key-value pair
|
||||||
func (s *space) Delete(key string) error {
|
func (s *space) Delete(key string) error {
|
||||||
return s.driver.DeleteSpaceKey(s.ctx, s.traceID, s.data.ID, key)
|
return s.driver.DeleteSpaceKey(context.Background(), s.traceID, s.data.ID, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear removes all key-value pairs
|
// Clear removes all key-value pairs
|
||||||
func (s *space) Clear() error {
|
func (s *space) Clear() error {
|
||||||
return s.driver.ClearSpaceKeys(s.ctx, s.traceID, s.data.ID)
|
return s.driver.ClearSpaceKeys(context.Background(), s.traceID, s.data.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keys returns all keys in the space
|
// Keys returns all keys in the space
|
||||||
func (s *space) Keys() []string {
|
func (s *space) Keys() []string {
|
||||||
keys, err := s.driver.ListSpaceKeys(s.ctx, s.traceID, s.data.ID)
|
keys, err := s.driver.ListSpaceKeys(context.Background(), s.traceID, s.data.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
362
trace/state.go
362
trace/state.go
|
|
@ -2,16 +2,13 @@ package trace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync/atomic"
|
|
||||||
|
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/trace/types"
|
"github.com/yaoapp/yao/trace/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// State management using channel-based serialization (no locks needed)
|
// managerState holds all mutable state for a trace.
|
||||||
// All state mutations go through a single worker goroutine
|
// Protected by manager.mu — all access goes through state* methods which acquire the lock.
|
||||||
|
|
||||||
// managerState holds all mutable state (accessed only by state worker)
|
|
||||||
type managerState struct {
|
type managerState struct {
|
||||||
rootNode *types.TraceNode
|
rootNode *types.TraceNode
|
||||||
currentNodes []*types.TraceNode
|
currentNodes []*types.TraceNode
|
||||||
|
|
@ -19,344 +16,133 @@ type managerState struct {
|
||||||
traceStatus types.TraceStatus
|
traceStatus types.TraceStatus
|
||||||
completed bool
|
completed bool
|
||||||
updates []*types.TraceUpdate
|
updates []*types.TraceUpdate
|
||||||
// Note: subscribers moved to SubscriptionManager (no longer in state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// State command interface - all commands are processed serially
|
|
||||||
type stateCommand interface {
|
|
||||||
execute(s *managerState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commands with response channels for synchronous operations
|
|
||||||
|
|
||||||
// --- Root Node Commands ---
|
|
||||||
|
|
||||||
type cmdSetRoot struct {
|
|
||||||
node *types.TraceNode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdSetRoot) execute(s *managerState) {
|
|
||||||
s.rootNode = c.node
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdGetRoot struct {
|
|
||||||
resp chan *types.TraceNode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdGetRoot) execute(s *managerState) {
|
|
||||||
c.resp <- s.rootNode
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Current Nodes Commands ---
|
|
||||||
|
|
||||||
type cmdSetCurrentNodes struct {
|
|
||||||
nodes []*types.TraceNode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdSetCurrentNodes) execute(s *managerState) {
|
|
||||||
s.currentNodes = c.nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdGetCurrentNodes struct {
|
|
||||||
resp chan []*types.TraceNode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdGetCurrentNodes) execute(s *managerState) {
|
|
||||||
// Return a copy to prevent external mutation
|
|
||||||
nodes := make([]*types.TraceNode, len(s.currentNodes))
|
|
||||||
copy(nodes, s.currentNodes)
|
|
||||||
c.resp <- nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdUpdateRootAndCurrent struct {
|
|
||||||
root *types.TraceNode
|
|
||||||
current []*types.TraceNode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdUpdateRootAndCurrent) execute(s *managerState) {
|
|
||||||
s.rootNode = c.root
|
|
||||||
s.currentNodes = c.current
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Space Commands ---
|
|
||||||
|
|
||||||
type cmdGetSpace struct {
|
|
||||||
id string
|
|
||||||
resp chan *types.TraceSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdGetSpace) execute(s *managerState) {
|
|
||||||
c.resp <- s.spaces[c.id]
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdSetSpace struct {
|
|
||||||
id string
|
|
||||||
space *types.TraceSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdSetSpace) execute(s *managerState) {
|
|
||||||
s.spaces[c.id] = c.space
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdDeleteSpace struct {
|
|
||||||
id string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdDeleteSpace) execute(s *managerState) {
|
|
||||||
delete(s.spaces, c.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdGetAllSpaces struct {
|
|
||||||
resp chan []*types.TraceSpace
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdGetAllSpaces) execute(s *managerState) {
|
|
||||||
spaces := make([]*types.TraceSpace, 0, len(s.spaces))
|
|
||||||
for _, space := range s.spaces {
|
|
||||||
spaces = append(spaces, space)
|
|
||||||
}
|
|
||||||
c.resp <- spaces
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Trace Status Commands ---
|
|
||||||
|
|
||||||
type cmdSetTraceStatus struct {
|
|
||||||
status types.TraceStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdSetTraceStatus) execute(s *managerState) {
|
|
||||||
s.traceStatus = c.status
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdGetTraceStatus struct {
|
|
||||||
resp chan types.TraceStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdGetTraceStatus) execute(s *managerState) {
|
|
||||||
c.resp <- s.traceStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Completion Commands ---
|
|
||||||
|
|
||||||
type cmdMarkCompleted struct {
|
|
||||||
resp chan bool // Returns true if marked, false if already completed
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdMarkCompleted) execute(s *managerState) {
|
|
||||||
if s.completed {
|
|
||||||
c.resp <- false
|
|
||||||
} else {
|
|
||||||
s.completed = true
|
|
||||||
c.resp <- true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdIsCompleted struct {
|
|
||||||
resp chan bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdIsCompleted) execute(s *managerState) {
|
|
||||||
c.resp <- s.completed
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Update Commands ---
|
|
||||||
|
|
||||||
type cmdAddUpdate struct {
|
|
||||||
update *types.TraceUpdate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdAddUpdate) execute(s *managerState) {
|
|
||||||
s.updates = append(s.updates, c.update)
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdGetUpdates struct {
|
|
||||||
since int64
|
|
||||||
resp chan []*types.TraceUpdate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdGetUpdates) execute(s *managerState) {
|
|
||||||
filtered := make([]*types.TraceUpdate, 0)
|
|
||||||
for _, update := range s.updates {
|
|
||||||
if update.Timestamp >= c.since {
|
|
||||||
filtered = append(filtered, update)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.resp <- filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdSetUpdates struct {
|
|
||||||
updates []*types.TraceUpdate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdSetUpdates) execute(s *managerState) {
|
|
||||||
s.updates = c.updates
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Subscriber Commands (REMOVED - now handled by SubscriptionManager) ---
|
|
||||||
// Subscriber management has been decoupled from state machine for better separation of concerns
|
|
||||||
|
|
||||||
// --- Space KV Commands (for concurrent safety) ---
|
|
||||||
// These ensure all operations on a space are serialized through state worker
|
|
||||||
|
|
||||||
type cmdSpaceKVOp struct {
|
|
||||||
spaceID string
|
|
||||||
fn func() error
|
|
||||||
resp chan error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdSpaceKVOp) execute(s *managerState) {
|
|
||||||
// Execute the operation (typically a driver call)
|
|
||||||
// The function is provided by caller and executed serially here
|
|
||||||
err := c.fn()
|
|
||||||
c.resp <- err
|
|
||||||
}
|
|
||||||
|
|
||||||
// State worker - processes all commands serially in a single goroutine.
|
|
||||||
// Exits only when stateCmdChan is closed by Release(). The for-range loop
|
|
||||||
// automatically drains any buffered commands before returning (Go spec guarantee).
|
|
||||||
func (m *manager) startStateWorker() {
|
|
||||||
state := &managerState{
|
|
||||||
rootNode: nil,
|
|
||||||
currentNodes: []*types.TraceNode{},
|
|
||||||
spaces: make(map[string]*types.TraceSpace),
|
|
||||||
traceStatus: types.TraceStatusPending,
|
|
||||||
completed: false,
|
|
||||||
updates: make([]*types.TraceUpdate, 0, 100),
|
|
||||||
}
|
|
||||||
for cmd := range m.stateCmdChan {
|
|
||||||
cmd.execute(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper methods for manager to send commands
|
|
||||||
|
|
||||||
// safeSend sends a command to the state worker channel. Returns false if the
|
|
||||||
// manager is closed, context is cancelled, or the channel was closed mid-send.
|
|
||||||
// The atomic closed flag provides a fast-path rejection before touching the channel,
|
|
||||||
// which is critical in CGO callback stacks where recover() may not work.
|
|
||||||
func (m *manager) safeSend(cmd stateCommand) (ok bool) {
|
|
||||||
if atomic.LoadInt32(&m.closed) == 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
ok = false
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-m.ctx.Done():
|
|
||||||
return false
|
|
||||||
case m.stateCmdChan <- cmd:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateSetRoot(node *types.TraceNode) {
|
func (m *manager) stateSetRoot(node *types.TraceNode) {
|
||||||
m.safeSend(&cmdSetRoot{node: node})
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.state.rootNode = node
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateGetRoot() *types.TraceNode {
|
func (m *manager) stateGetRoot() *types.TraceNode {
|
||||||
resp := make(chan *types.TraceNode, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdGetRoot{resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return nil // Context cancelled
|
return m.state.rootNode
|
||||||
}
|
|
||||||
return <-resp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateSetCurrentNodes(nodes []*types.TraceNode) {
|
func (m *manager) stateSetCurrentNodes(nodes []*types.TraceNode) {
|
||||||
m.safeSend(&cmdSetCurrentNodes{nodes: nodes})
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.state.currentNodes = nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateGetCurrentNodes() []*types.TraceNode {
|
func (m *manager) stateGetCurrentNodes() []*types.TraceNode {
|
||||||
resp := make(chan []*types.TraceNode, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdGetCurrentNodes{resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return nil // Context cancelled
|
if m.state.currentNodes == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return <-resp
|
nodes := make([]*types.TraceNode, len(m.state.currentNodes))
|
||||||
|
copy(nodes, m.state.currentNodes)
|
||||||
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateUpdateRootAndCurrent(root *types.TraceNode, current []*types.TraceNode) {
|
func (m *manager) stateUpdateRootAndCurrent(root *types.TraceNode, current []*types.TraceNode) {
|
||||||
m.safeSend(&cmdUpdateRootAndCurrent{root: root, current: current})
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.state.rootNode = root
|
||||||
|
m.state.currentNodes = current
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateGetSpace(id string) (*types.TraceSpace, bool) {
|
func (m *manager) stateGetSpace(id string) (*types.TraceSpace, bool) {
|
||||||
resp := make(chan *types.TraceSpace, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdGetSpace{id: id, resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return nil, false // Context cancelled
|
space, ok := m.state.spaces[id]
|
||||||
}
|
return space, ok
|
||||||
space := <-resp
|
|
||||||
return space, space != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateSetSpace(id string, space *types.TraceSpace) {
|
func (m *manager) stateSetSpace(id string, space *types.TraceSpace) {
|
||||||
m.safeSend(&cmdSetSpace{id: id, space: space})
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.state.spaces[id] = space
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateDeleteSpace(id string) {
|
func (m *manager) stateDeleteSpace(id string) {
|
||||||
m.safeSend(&cmdDeleteSpace{id: id})
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.state.spaces, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateGetAllSpaces() []*types.TraceSpace {
|
func (m *manager) stateGetAllSpaces() []*types.TraceSpace {
|
||||||
resp := make(chan []*types.TraceSpace, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdGetAllSpaces{resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return nil // Context cancelled
|
spaces := make([]*types.TraceSpace, 0, len(m.state.spaces))
|
||||||
|
for _, space := range m.state.spaces {
|
||||||
|
spaces = append(spaces, space)
|
||||||
}
|
}
|
||||||
return <-resp
|
return spaces
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateSetTraceStatus(status types.TraceStatus) {
|
func (m *manager) stateSetTraceStatus(status types.TraceStatus) {
|
||||||
m.safeSend(&cmdSetTraceStatus{status: status})
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.state.traceStatus = status
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateGetTraceStatus() types.TraceStatus {
|
func (m *manager) stateGetTraceStatus() types.TraceStatus {
|
||||||
resp := make(chan types.TraceStatus, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdGetTraceStatus{resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return types.TraceStatusCancelled // Context cancelled
|
return m.state.traceStatus
|
||||||
}
|
|
||||||
return <-resp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateMarkCompleted() bool {
|
func (m *manager) stateMarkCompleted() bool {
|
||||||
resp := make(chan bool, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdMarkCompleted{resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return true // Context cancelled, treat as completed
|
if m.state.completed {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return <-resp
|
m.state.completed = true
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateIsCompleted() bool {
|
func (m *manager) stateIsCompleted() bool {
|
||||||
resp := make(chan bool, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdIsCompleted{resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return true // Context cancelled, treat as completed
|
return m.state.completed
|
||||||
}
|
|
||||||
return <-resp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateAddUpdate(update *types.TraceUpdate) {
|
func (m *manager) stateAddUpdate(update *types.TraceUpdate) {
|
||||||
m.safeSend(&cmdAddUpdate{update: update})
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.state.updates = append(m.state.updates, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateGetUpdates(since int64) []*types.TraceUpdate {
|
func (m *manager) stateGetUpdates(since int64) []*types.TraceUpdate {
|
||||||
resp := make(chan []*types.TraceUpdate, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdGetUpdates{since: since, resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return nil // Context cancelled
|
filtered := make([]*types.TraceUpdate, 0)
|
||||||
|
for _, update := range m.state.updates {
|
||||||
|
if update.Timestamp >= since {
|
||||||
|
filtered = append(filtered, update)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return <-resp
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *manager) stateSetUpdates(updates []*types.TraceUpdate) {
|
func (m *manager) stateSetUpdates(updates []*types.TraceUpdate) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
log.Trace("[STATE] stateSetUpdates: setting %d updates for trace %s", len(updates), m.traceID)
|
log.Trace("[STATE] stateSetUpdates: setting %d updates for trace %s", len(updates), m.traceID)
|
||||||
m.safeSend(&cmdSetUpdates{updates: updates})
|
m.state.updates = updates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription management methods removed - now handled by SubscriptionManager
|
// stateExecuteSpaceOp executes a space operation while holding the lock.
|
||||||
// See subscription_manager.go and subscription.go for the new implementation
|
|
||||||
|
|
||||||
// stateExecuteSpaceOp executes a space operation serially through state worker
|
|
||||||
func (m *manager) stateExecuteSpaceOp(spaceID string, fn func() error) error {
|
func (m *manager) stateExecuteSpaceOp(spaceID string, fn func() error) error {
|
||||||
resp := make(chan error, 1)
|
m.mu.Lock()
|
||||||
if !m.safeSend(&cmdSpaceKVOp{spaceID: spaceID, fn: fn, resp: resp}) {
|
defer m.mu.Unlock()
|
||||||
return fmt.Errorf("trace %s: state worker stopped", m.traceID)
|
err := fn()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("trace %s: space op failed: %w", m.traceID, err)
|
||||||
}
|
}
|
||||||
return <-resp
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,18 @@ package trace
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
eventTypes "github.com/yaoapp/yao/event/types"
|
||||||
"github.com/yaoapp/yao/trace/types"
|
"github.com/yaoapp/yao/trace/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func dedupKey(u *types.TraceUpdate) string {
|
||||||
|
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) {
|
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, error) {
|
||||||
return m.subscribe(0) // Subscribe from beginning to get all historical events
|
return m.subscribe(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeFrom creates a subscription starting from a specific timestamp
|
// SubscribeFrom creates a subscription starting from a specific timestamp
|
||||||
|
|
@ -16,24 +22,60 @@ func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error)
|
||||||
return m.subscribe(since)
|
return m.subscribe(since)
|
||||||
}
|
}
|
||||||
|
|
||||||
// subscribe is the internal implementation for subscriptions
|
// subscribe creates a subscription channel that first replays historical
|
||||||
|
// updates, then streams live events via the event service's Subscriber.
|
||||||
|
// The subscriber is registered BEFORE reading historical state to prevent
|
||||||
|
// missing events that occur between the state snapshot and subscriber setup.
|
||||||
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
||||||
// Get historical updates
|
|
||||||
updates := m.stateGetUpdates(since)
|
|
||||||
|
|
||||||
// Use manager's pubsub reference (always available)
|
|
||||||
if m.pubsub == nil {
|
|
||||||
return nil, fmt.Errorf("pubsub service not initialized for trace: %s", m.traceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create subscription with historical replay
|
|
||||||
// Buffer size should be large enough to hold historical updates plus some live updates
|
|
||||||
// Using max of 1000 or len(updates)+100 to handle large traces
|
|
||||||
bufferSize := 1000
|
bufferSize := 1000
|
||||||
if len(updates)+100 > bufferSize {
|
|
||||||
bufferSize = len(updates) + 100
|
|
||||||
}
|
|
||||||
sub := m.pubsub.SubscribeWithHistory(updates, bufferSize)
|
|
||||||
|
|
||||||
return sub.Channel, nil
|
out := make(chan *types.TraceUpdate, bufferSize)
|
||||||
|
|
||||||
|
// Register live subscriber FIRST to avoid missing events between snapshot and subscribe.
|
||||||
|
liveCh := make(chan *eventTypes.Event, bufferSize)
|
||||||
|
traceID := m.traceID
|
||||||
|
subID := event.Subscribe("trace.*", liveCh, event.Filter(func(ev *eventTypes.Event) bool {
|
||||||
|
update, ok := ev.Payload.(*types.TraceUpdate)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return update.TraceID == traceID
|
||||||
|
}))
|
||||||
|
|
||||||
|
// THEN snapshot historical updates (may overlap with live events).
|
||||||
|
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))
|
||||||
|
for _, u := range historical {
|
||||||
|
histSeen[dedupKey(u)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
defer event.Unsubscribe(subID)
|
||||||
|
|
||||||
|
for _, update := range historical {
|
||||||
|
out <- update
|
||||||
|
}
|
||||||
|
|
||||||
|
for ev := range liveCh {
|
||||||
|
update, ok := ev.Payload.(*types.TraceUpdate)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := dedupKey(update)
|
||||||
|
if _, dup := histSeen[key]; dup {
|
||||||
|
delete(histSeen, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out <- update
|
||||||
|
if update.Type == types.UpdateTypeComplete {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
trace/trace.go
105
trace/trace.go
|
|
@ -4,13 +4,11 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/trace/local"
|
"github.com/yaoapp/yao/trace/local"
|
||||||
"github.com/yaoapp/yao/trace/pubsub"
|
|
||||||
"github.com/yaoapp/yao/trace/store"
|
"github.com/yaoapp/yao/trace/store"
|
||||||
"github.com/yaoapp/yao/trace/types"
|
"github.com/yaoapp/yao/trace/types"
|
||||||
)
|
)
|
||||||
|
|
@ -21,14 +19,10 @@ const (
|
||||||
Store = "store" // Gou store storage
|
Store = "store" // Gou store storage
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global trace registry and pubsub services
|
// Global trace registry
|
||||||
var (
|
var (
|
||||||
registry = make(map[string]*types.TraceInfo)
|
registry = make(map[string]*types.TraceInfo)
|
||||||
registryMu sync.RWMutex
|
registryMu sync.RWMutex
|
||||||
|
|
||||||
// Each trace has its own independent pubsub service
|
|
||||||
pubsubRegistry = make(map[string]*pubsub.PubSub)
|
|
||||||
pubsubRegistryMu sync.RWMutex
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// getDriver creates a driver instance based on driver type and options
|
// getDriver creates a driver instance based on driver type and options
|
||||||
|
|
@ -145,23 +139,9 @@ func New(ctx context.Context, driver string, option *types.TraceOption, driverOp
|
||||||
return LoadFromStorage(ctx, driver, traceID, driverOptions...)
|
return LoadFromStorage(ctx, driver, traceID, driverOptions...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create independent PubSub service for this trace
|
// Create Manager instance with the driver
|
||||||
pubsubService := pubsub.New()
|
manager, err := NewManager(ctx, traceID, drv, option)
|
||||||
|
|
||||||
// Register pubsub service
|
|
||||||
pubsubRegistryMu.Lock()
|
|
||||||
pubsubRegistry[traceID] = pubsubService
|
|
||||||
pubsubRegistryMu.Unlock()
|
|
||||||
|
|
||||||
// Create Manager instance with the driver and pubsub reference
|
|
||||||
// Manager uses pubsub only for publishing, doesn't manage its lifecycle
|
|
||||||
manager, err := NewManager(ctx, traceID, drv, pubsubService, option)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Clean up pubsub if manager creation fails
|
|
||||||
pubsubRegistryMu.Lock()
|
|
||||||
delete(pubsubRegistry, traceID)
|
|
||||||
pubsubRegistryMu.Unlock()
|
|
||||||
pubsubService.Stop()
|
|
||||||
return "", nil, fmt.Errorf("failed to create manager: %w", err)
|
return "", nil, fmt.Errorf("failed to create manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,13 +178,6 @@ func New(ctx context.Context, driver string, option *types.TraceOption, driverOp
|
||||||
return traceID, manager, nil
|
return traceID, manager, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPubSub returns the pubsub service for a trace
|
|
||||||
func GetPubSub(traceID string) *pubsub.PubSub {
|
|
||||||
pubsubRegistryMu.RLock()
|
|
||||||
defer pubsubRegistryMu.RUnlock()
|
|
||||||
return pubsubRegistry[traceID]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load loads an existing trace by ID from the registry
|
// Load loads an existing trace by ID from the registry
|
||||||
// Returns: manager, error
|
// Returns: manager, error
|
||||||
// traceID: the trace ID to load
|
// traceID: the trace ID to load
|
||||||
|
|
@ -253,29 +226,9 @@ func LoadFromStorage(ctx context.Context, driver string, traceID string, driverO
|
||||||
return "", nil, fmt.Errorf("trace not found in storage: %s", traceID)
|
return "", nil, fmt.Errorf("trace not found in storage: %s", traceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or reuse PubSub service for this trace
|
manager, err := NewManager(ctx, traceID, drv, nil)
|
||||||
pubsubRegistryMu.Lock()
|
|
||||||
pubsubService, exists := pubsubRegistry[traceID]
|
|
||||||
if !exists {
|
|
||||||
pubsubService = pubsub.New()
|
|
||||||
pubsubRegistry[traceID] = pubsubService
|
|
||||||
}
|
|
||||||
pubsubRegistryMu.Unlock()
|
|
||||||
|
|
||||||
// Create Manager instance with the driver and pubsub reference
|
|
||||||
// Note: We need to reconstruct the manager from stored data
|
|
||||||
// TODO: Implement proper restoration of manager state from storage
|
|
||||||
// For loaded traces, we don't have the original option, so pass nil
|
|
||||||
manager, err := NewManager(ctx, traceID, drv, pubsubService, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
drv.Close()
|
drv.Close()
|
||||||
if !exists {
|
|
||||||
// Clean up pubsub if we just created it
|
|
||||||
pubsubRegistryMu.Lock()
|
|
||||||
delete(pubsubRegistry, traceID)
|
|
||||||
pubsubRegistryMu.Unlock()
|
|
||||||
pubsubService.Stop()
|
|
||||||
}
|
|
||||||
return "", nil, fmt.Errorf("failed to create manager: %w", err)
|
return "", nil, fmt.Errorf("failed to create manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,12 +309,6 @@ func MarkCancelled(traceID string, reason string) error {
|
||||||
|
|
||||||
log.Trace("[TRACE] MarkCancelled: starting to mark nodes and trace as cancelled")
|
log.Trace("[TRACE] MarkCancelled: starting to mark nodes and trace as cancelled")
|
||||||
|
|
||||||
// Get independent pubsub service
|
|
||||||
ps := GetPubSub(traceID)
|
|
||||||
if ps != nil {
|
|
||||||
log.Trace("[TRACE] MarkCancelled: current subscriber count: %d", ps.SubscriberCount())
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
// Use background context since the original context is cancelled
|
// Use background context since the original context is cancelled
|
||||||
|
|
@ -395,12 +342,7 @@ func MarkCancelled(traceID string, reason string) error {
|
||||||
log.Trace("[TRACE] MarkCancelled: failed to save node %s: %v", node.ID, err)
|
log.Trace("[TRACE] MarkCancelled: failed to save node %s: %v", node.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast node failed event (also saves to disk)
|
log.Trace("[TRACE] MarkCancelled: broadcasting node failed event for node %s", node.ID)
|
||||||
subscriberCount := 0
|
|
||||||
if ps := GetPubSub(traceID); ps != nil {
|
|
||||||
subscriberCount = ps.SubscriberCount()
|
|
||||||
}
|
|
||||||
log.Trace("[TRACE] MarkCancelled: publishing node failed event for node %s to %d subscribers", node.ID, subscriberCount)
|
|
||||||
mgr.addUpdateAndBroadcast(&types.TraceUpdate{
|
mgr.addUpdateAndBroadcast(&types.TraceUpdate{
|
||||||
Type: types.UpdateTypeNodeFailed,
|
Type: types.UpdateTypeNodeFailed,
|
||||||
TraceID: traceID,
|
TraceID: traceID,
|
||||||
|
|
@ -445,12 +387,7 @@ func MarkCancelled(traceID string, reason string) error {
|
||||||
mgr.stateSetTraceStatus(types.TraceStatusCancelled)
|
mgr.stateSetTraceStatus(types.TraceStatusCancelled)
|
||||||
mgr.stateMarkCompleted()
|
mgr.stateMarkCompleted()
|
||||||
|
|
||||||
// Broadcast completion update (saves to disk and publishes to subscribers)
|
log.Trace("[TRACE] MarkCancelled: broadcasting completion update")
|
||||||
subscriberCount := 0
|
|
||||||
if ps := GetPubSub(traceID); ps != nil {
|
|
||||||
subscriberCount = ps.SubscriberCount()
|
|
||||||
}
|
|
||||||
log.Trace("[TRACE] MarkCancelled: publishing completion update to %d subscribers", subscriberCount)
|
|
||||||
totalDuration := int64(0)
|
totalDuration := int64(0)
|
||||||
if rootNode.CreatedAt > 0 {
|
if rootNode.CreatedAt > 0 {
|
||||||
totalDuration = now - rootNode.CreatedAt
|
totalDuration = now - rootNode.CreatedAt
|
||||||
|
|
@ -488,35 +425,7 @@ func Release(traceID string) error {
|
||||||
return fmt.Errorf("trace not found in registry: %s", traceID)
|
return fmt.Errorf("trace not found in registry: %s", traceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop manager with safe three-step shutdown sequence.
|
_ = info.Manager
|
||||||
// Order matters: flag blocks new writes -> cancel unblocks in-flight safeSend ->
|
|
||||||
// close terminates state worker (which drains remaining buffer first).
|
|
||||||
if mgr, ok := info.Manager.(*manager); ok {
|
|
||||||
// Step 1: Set closed flag — new safeSend calls return false immediately
|
|
||||||
atomic.StoreInt32(&mgr.closed, 1)
|
|
||||||
|
|
||||||
// Step 2: Cancel context — unblocks any safeSend blocked in select on ctx.Done
|
|
||||||
if mgr.cancel != nil {
|
|
||||||
mgr.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Close channel — state worker for-range exits after draining buffer
|
|
||||||
close(mgr.stateCmdChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop independent PubSub service
|
|
||||||
pubsubRegistryMu.Lock()
|
|
||||||
ps, psExists := pubsubRegistry[traceID]
|
|
||||||
if psExists {
|
|
||||||
delete(pubsubRegistry, traceID)
|
|
||||||
}
|
|
||||||
pubsubRegistryMu.Unlock()
|
|
||||||
|
|
||||||
if psExists && ps != nil {
|
|
||||||
subscriberCount := ps.SubscriberCount()
|
|
||||||
log.Trace("[TRACE] Release: stopping pubsub service with %d active subscribers", subscriberCount)
|
|
||||||
ps.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace("[TRACE] Release: completed")
|
log.Trace("[TRACE] Release: completed")
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// Prepare test environment (initializes stores, models, etc.)
|
|
||||||
test.Prepare(&testing.T{}, config.Conf)
|
test.Prepare(&testing.T{}, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
// Run tests
|
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,15 +231,17 @@ func TestContextCancellation(t *testing.T) {
|
||||||
defer trace.Release(traceID)
|
defer trace.Release(traceID)
|
||||||
defer trace.Remove(context.Background(), d.DriverType, traceID, d.DriverOptions...)
|
defer trace.Remove(context.Background(), d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
// Cancel context
|
// Cancel the creation context — trace operations should still work.
|
||||||
|
// This is the core context-fork fix: trace managers no longer bind
|
||||||
|
// to the caller's context, so parent cancellation cannot break child ops.
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
// Operations should fail with context error
|
node, err := manager.Add("test", types.TraceNodeOption{
|
||||||
_, err = manager.Add("test", types.TraceNodeOption{
|
|
||||||
Label: "Test",
|
Label: "Test",
|
||||||
Type: "test",
|
Type: "test",
|
||||||
})
|
})
|
||||||
assert.Error(t, err)
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, node)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -186,9 +186,10 @@ func TestConcurrentReleaseAndMarkCancelled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSafeSendAfterClosed verifies that operations using safeSend after
|
// TestOperationsAfterRelease verifies that using a manager reference after
|
||||||
// Release return gracefully instead of panicking.
|
// Release does not panic. The manager is removed from registry but its
|
||||||
func TestSafeSendAfterClosed(t *testing.T) {
|
// in-memory state remains valid (no channel close or context cancel).
|
||||||
|
func TestOperationsAfterRelease(t *testing.T) {
|
||||||
drivers := trace.GetTestDrivers()
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
for _, d := range drivers {
|
for _, d := range drivers {
|
||||||
|
|
@ -205,21 +206,13 @@ func TestSafeSendAfterClosed(t *testing.T) {
|
||||||
err = trace.Release(traceID)
|
err = trace.Release(traceID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// All of these internally use safeSend. After Release they should
|
// After Release, the manager object is still usable (state in memory).
|
||||||
// return nil/error/zero-value, never panic.
|
// These calls should not panic.
|
||||||
manager.Info("post-close info")
|
manager.Info("post-release info")
|
||||||
manager.Debug("post-close debug")
|
manager.Debug("post-release debug")
|
||||||
manager.Error("post-close error")
|
|
||||||
manager.Warn("post-close warn")
|
|
||||||
|
|
||||||
root, _ := manager.GetRootNode()
|
root, _ := manager.GetRootNode()
|
||||||
assert.Nil(t, root)
|
assert.NotNil(t, root)
|
||||||
|
|
||||||
nodes, _ := manager.GetCurrentNodes()
|
|
||||||
assert.Nil(t, nodes)
|
|
||||||
|
|
||||||
status := manager.IsComplete()
|
|
||||||
assert.True(t, status)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue