Refactor Robot Execution and Pool Management

- Updated the Executor to implement atomic slot acquisition for robot executions, preventing race conditions and ensuring proper quota management.
- Introduced the TryAcquireSlot method in the Robot struct for atomic checks and reservations of execution slots, enhancing concurrency handling.
- Adjusted the Worker to requeue tasks when quota is exceeded, improving error handling and system stability.
- Enhanced tests for concurrent access and quota management, ensuring robust functionality under load conditions.
- Updated comments and documentation for clarity on new methods and their intended use.
This commit is contained in:
Max 2026-01-14 20:41:02 +08:00
parent e2bad9bf52
commit bcbb6b8024
8 changed files with 197 additions and 33 deletions

View file

@ -42,7 +42,26 @@ func NewWithCallback(delay time.Duration, onStart, onEnd func()) *Executor {
// Execute executes a robot through all phases
// Stub: returns empty execution (will be implemented in Phase 3+)
func (e *Executor) Execute(ctx *types.Context, robot *types.Robot, trigger types.TriggerType, data interface{}) (*types.Execution, error) {
// Track execution count
// Create execution record first
execID := utils.NewID()
exec := &types.Execution{
ID: execID,
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
Status: types.ExecRunning,
Phase: types.PhaseInspiration,
}
// Atomically check quota and acquire slot
// This prevents race condition where multiple workers pass CanRun() check
// but then all add executions, exceeding the quota
if !robot.TryAcquireSlot(exec) {
return nil, types.ErrQuotaExceeded
}
defer robot.RemoveExecution(execID)
// Track execution count (after successful slot acquisition)
e.execCount.Add(1)
e.currentCount.Add(1)
defer e.currentCount.Add(-1)
@ -56,19 +75,6 @@ func (e *Executor) Execute(ctx *types.Context, robot *types.Robot, trigger types
defer e.onEnd()
}
// Track on robot
execID := utils.NewID()
exec := &types.Execution{
ID: execID,
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
Status: types.ExecRunning,
Phase: types.PhaseInspiration,
}
robot.AddExecution(exec)
defer robot.RemoveExecution(execID)
// Simulate execution delay
if e.delay > 0 {
time.Sleep(e.delay)

View file

@ -187,16 +187,18 @@ func TestRobotConcurrencyLimit(t *testing.T) {
}
// Wait a bit for execution to start
time.Sleep(50 * time.Millisecond)
time.Sleep(150 * time.Millisecond)
// Robot should have at most 2 running (Quota.Max=2)
runningCount := robot.RunningCount()
assert.LessOrEqual(t, runningCount, 2, "Robot should not exceed Quota.Max")
// Wait for all to complete
time.Sleep(500 * time.Millisecond)
// Wait for all to complete (with re-enqueue, need more time)
// 5 jobs with Max=2: ~3 batches * 100ms exec + poll overhead
time.Sleep(800 * time.Millisecond)
assert.Equal(t, 5, exec.ExecCount())
// All 5 jobs should eventually execute
assert.GreaterOrEqual(t, exec.ExecCount(), 5, "All jobs should eventually execute")
}
// TestRobotQueueLimit tests per-robot queue limit

View file

@ -66,19 +66,11 @@ func (w *Worker) run() {
// execute processes a single queue item
func (w *Worker) execute(item *QueueItem) {
// Check if robot can run (quota check before marking as running)
// Pre-check if robot can run (non-atomic, just for early rejection)
// The actual atomic check happens inside Executor.Execute() via TryAcquireSlot()
if !item.Robot.CanRun() {
// Robot has reached max concurrent executions
// Try to put back to queue for later processing
//
// Queue length is our system load threshold:
// - If queue has space: task waits for robot quota
// - If queue is full: system is overloaded, drop task
if !w.pool.queue.Enqueue(item) {
// Queue full = system overloaded, drop task (protective discard)
fmt.Printf("Worker %d: Task for robot %s dropped (queue full, system overloaded)\n",
w.id, item.Robot.MemberID)
}
// Robot likely at quota, re-enqueue for later
w.requeue(item, "quota pre-check failed")
return
}
@ -87,9 +79,15 @@ func (w *Worker) execute(item *QueueItem) {
defer w.pool.decrementRunning()
// Execute via Executor interface
// Note: Executor.Execute() does atomic quota check via TryAcquireSlot()
execution, err := w.executor.Execute(item.Ctx, item.Robot, item.Trigger, item.Data)
if err != nil {
// Check if it's a quota error (race condition - another worker got the slot)
if err == types.ErrQuotaExceeded {
w.requeue(item, "quota exceeded (race)")
return
}
fmt.Printf("Worker %d: Execution failed for robot %s: %v\n",
w.id, item.Robot.MemberID, err)
return
@ -100,3 +98,15 @@ func (w *Worker) execute(item *QueueItem) {
w.id, execution.ID, item.Robot.MemberID, execution.Status)
}
}
// requeue attempts to put the item back in the queue
func (w *Worker) requeue(item *QueueItem, reason string) {
// Queue length is our system load threshold:
// - If queue has space: task waits for robot quota
// - If queue is full: system is overloaded, drop task
if !w.pool.queue.Enqueue(item) {
// Queue full = system overloaded, drop task (protective discard)
fmt.Printf("Worker %d: Task for robot %s dropped (queue full, %s)\n",
w.id, item.Robot.MemberID, reason)
}
}

View file

@ -90,10 +90,10 @@ func TestWorkerRespectsRobotQuota(t *testing.T) {
// Wait for all to complete
// With Quota.Max=2, jobs execute in batches: 2+2+1 = 3 batches
// Each batch: 100ms exec + 100ms poll = ~200ms, total ~600ms, add buffer
time.Sleep(800 * time.Millisecond)
time.Sleep(1000 * time.Millisecond)
// All should eventually execute
assert.Equal(t, 5, exec.ExecCount())
assert.GreaterOrEqual(t, exec.ExecCount(), 5, "All jobs should eventually execute")
}
// TestWorkerReenqueueOnQuotaFull tests that jobs are re-enqueued when quota is full

View file

@ -30,7 +30,7 @@ func Init() error {
globalCache = cache.New()
globalDedup = dedup.New()
globalStore = store.New()
globalPool = pool.New(10) // Default pool size
globalPool = pool.New() // Default pool size
globalTrigger = trigger.New()
globalExecutor = executor.New()
globalManager = manager.New()

View file

@ -23,6 +23,9 @@ var ErrRobotPaused = errors.New("robot is paused")
// ErrRobotBusy indicates robot has reached max concurrent executions
var ErrRobotBusy = errors.New("robot has reached max concurrent executions")
// ErrQuotaExceeded indicates robot quota was exceeded (atomic check failed)
var ErrQuotaExceeded = errors.New("robot quota exceeded")
// ErrTriggerDisabled indicates trigger type is disabled for this robot
var ErrTriggerDisabled = errors.New("trigger type is disabled for this robot")

View file

@ -35,6 +35,7 @@ type Robot struct {
}
// CanRun checks if robot can accept new execution
// Note: This is a read-only check. For atomic check-and-acquire, use TryAcquireSlot()
func (r *Robot) CanRun() bool {
r.execMu.RLock()
defer r.execMu.RUnlock()
@ -44,6 +45,32 @@ func (r *Robot) CanRun() bool {
return len(r.executions) < r.Config.Quota.GetMax()
}
// TryAcquireSlot atomically checks if robot can run and reserves a slot
// Returns true if slot was acquired, false if quota is full
// This prevents race conditions between CanRun() check and AddExecution()
func (r *Robot) TryAcquireSlot(exec *Execution) bool {
r.execMu.Lock()
defer r.execMu.Unlock()
// Get max quota
maxQuota := 2 // default
if r.Config != nil {
maxQuota = r.Config.Quota.GetMax()
}
// Check if we can add
if len(r.executions) >= maxQuota {
return false // quota full
}
// Reserve slot by adding execution
if r.executions == nil {
r.executions = make(map[string]*Execution)
}
r.executions[exec.ID] = exec
return true
}
// RunningCount returns current running execution count
func (r *Robot) RunningCount() int {
r.execMu.RLock()
@ -52,6 +79,7 @@ func (r *Robot) RunningCount() int {
}
// AddExecution adds an execution to tracking
// Note: Prefer TryAcquireSlot() for atomic check-and-add
func (r *Robot) AddExecution(exec *Execution) {
r.execMu.Lock()
defer r.execMu.Unlock()

View file

@ -237,6 +237,121 @@ func TestRobotConcurrentAccess(t *testing.T) {
assert.Equal(t, 0, count)
}
func TestRobotTryAcquireSlot(t *testing.T) {
t.Run("acquire slot when under quota", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
exec := &types.Execution{ID: "exec1"}
acquired := robot.TryAcquireSlot(exec)
assert.True(t, acquired)
assert.Equal(t, 1, robot.RunningCount())
assert.NotNil(t, robot.GetExecution("exec1"))
})
t.Run("fail to acquire when at quota", func(t *testing.T) {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 2},
},
}
// Fill quota
robot.TryAcquireSlot(&types.Execution{ID: "exec1"})
robot.TryAcquireSlot(&types.Execution{ID: "exec2"})
// Try to acquire one more
exec3 := &types.Execution{ID: "exec3"}
acquired := robot.TryAcquireSlot(exec3)
assert.False(t, acquired)
assert.Equal(t, 2, robot.RunningCount())
assert.Nil(t, robot.GetExecution("exec3"))
})
t.Run("acquire with nil config uses default quota", func(t *testing.T) {
robot := &types.Robot{
Config: nil, // default quota is 2
}
exec1 := &types.Execution{ID: "exec1"}
exec2 := &types.Execution{ID: "exec2"}
exec3 := &types.Execution{ID: "exec3"}
assert.True(t, robot.TryAcquireSlot(exec1))
assert.True(t, robot.TryAcquireSlot(exec2))
assert.False(t, robot.TryAcquireSlot(exec3)) // should fail at default max=2
})
}
func TestRobotTryAcquireSlotConcurrent(t *testing.T) {
// Test that TryAcquireSlot is atomic and prevents exceeding quota
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 5},
},
}
// Launch 20 goroutines trying to acquire slots
successCount := make(chan bool, 20)
for i := 0; i < 20; i++ {
go func(id int) {
exec := &types.Execution{ID: string(rune('A' + id))}
success := robot.TryAcquireSlot(exec)
successCount <- success
}(i)
}
// Count successes
acquired := 0
for i := 0; i < 20; i++ {
if <-successCount {
acquired++
}
}
// Should have exactly 5 successful acquisitions (quota max)
assert.Equal(t, 5, acquired, "Should acquire exactly quota max slots")
assert.Equal(t, 5, robot.RunningCount(), "Running count should match quota max")
}
func TestRobotTryAcquireSlotRaceCondition(t *testing.T) {
// Stress test to verify no race condition in TryAcquireSlot
for iteration := 0; iteration < 100; iteration++ {
robot := &types.Robot{
Config: &types.Config{
Quota: &types.Quota{Max: 3},
},
}
// Launch many goroutines simultaneously
successCount := make(chan bool, 50)
for i := 0; i < 50; i++ {
go func(id int) {
exec := &types.Execution{ID: string(rune('A'+id%26)) + string(rune('0'+id/26))}
success := robot.TryAcquireSlot(exec)
successCount <- success
}(i)
}
// Count successes
acquired := 0
for i := 0; i < 50; i++ {
if <-successCount {
acquired++
}
}
// Should never exceed quota
assert.Equal(t, 3, acquired, "Iteration %d: Should acquire exactly quota max slots", iteration)
assert.Equal(t, 3, robot.RunningCount(), "Iteration %d: Running count should match quota max", iteration)
}
}
func TestExecutionStructure(t *testing.T) {
t.Run("execution with all fields", func(t *testing.T) {
exec := &types.Execution{