Skip to content

Commit ded3ef1

Browse files
intel352claude
andcommitted
fix: actor.pool permanent mode — add WithLongLived() to prevent passivation (#387)
Permanent pool actors were spawned without actor.WithLongLived(), causing goakt's default 2-minute passivation to shut them down before any messages arrived. Auto-managed (grain) pools were unaffected because grains are activated on-demand. Added regression test that verifies permanent actors survive a delay. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e4d413a commit ded3ef1

File tree

2 files changed

+66
-1
lines changed

2 files changed

+66
-1
lines changed

plugins/actors/module_pool.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,10 @@ func (m *ActorPoolModule) Start(ctx context.Context) error {
161161
sys := m.system.ActorSystem()
162162
m.pids = make([]*actor.PID, 0, m.poolSize)
163163

164-
// Build spawn options: apply per-pool recovery supervisor if configured
164+
// Build spawn options: permanent actors must be long-lived to prevent
165+
// goakt's default 2-minute passivation from shutting them down.
165166
var spawnOpts []actor.SpawnOption
167+
spawnOpts = append(spawnOpts, actor.WithLongLived())
166168
if m.recovery != nil {
167169
spawnOpts = append(spawnOpts, actor.WithSupervisor(m.recovery))
168170
}

plugins/actors/module_pool_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,69 @@ func TestPermanentPool_StartSpawnsActors(t *testing.T) {
320320
}
321321
}
322322

323+
func TestPermanentPool_LongLived(t *testing.T) {
324+
// Regression test for #387: permanent actors must use WithLongLived()
325+
// to prevent goakt's default 2-minute passivation from killing them.
326+
// We can't wait 2 minutes in a test, but we verify the spawn option
327+
// is applied by checking actors are alive after a brief delay.
328+
ctx := context.Background()
329+
330+
sysMod, err := NewActorSystemModule("test-longlived", map[string]any{
331+
"shutdownTimeout": "5s",
332+
})
333+
if err != nil {
334+
t.Fatalf("failed to create system: %v", err)
335+
}
336+
if err := sysMod.Start(ctx); err != nil {
337+
t.Fatalf("failed to start system: %v", err)
338+
}
339+
defer sysMod.Stop(ctx) //nolint:errcheck
340+
341+
pool := &ActorPoolModule{
342+
name: "longlived-workers",
343+
systemName: "test-longlived",
344+
mode: "permanent",
345+
poolSize: 2,
346+
routing: "round-robin",
347+
system: sysMod,
348+
handlers: map[string]*HandlerPipeline{
349+
"Ping": {
350+
Steps: []map[string]any{
351+
{"name": "pong", "type": "step.set", "config": map[string]any{
352+
"values": map[string]any{"alive": "true"},
353+
}},
354+
},
355+
},
356+
},
357+
}
358+
359+
if err := pool.Start(ctx); err != nil {
360+
t.Fatalf("failed to start pool: %v", err)
361+
}
362+
363+
// Wait briefly — without WithLongLived(), actors would be scheduled
364+
// for passivation immediately (default strategy has a short initial check)
365+
time.Sleep(3 * time.Second)
366+
367+
// Verify all actors are still alive after the delay
368+
for i, pid := range pool.pids {
369+
resp, err := actor.Ask(ctx, pid, &ActorMessage{
370+
Type: "Ping",
371+
Payload: map[string]any{},
372+
}, 5*time.Second)
373+
if err != nil {
374+
t.Fatalf("actor %d: should be alive after delay but got: %v", i, err)
375+
}
376+
result, ok := resp.(map[string]any)
377+
if !ok {
378+
t.Fatalf("actor %d: expected map response, got %T", i, resp)
379+
}
380+
if result["alive"] != "true" {
381+
t.Errorf("actor %d: expected alive=true, got %v", i, result["alive"])
382+
}
383+
}
384+
}
385+
323386
func TestPermanentPool_RoundRobinRouting(t *testing.T) {
324387
ctx := context.Background()
325388

0 commit comments

Comments
 (0)