Repository: Tochemey/goakt (go)

Issue:
feat: make passivation configurable per actor during creation
**Problem description**
The current implementation sets a global passivation for all actors created in the system. This can be a bottleneck when there are some actors that are supposed to be long lived while others can be passivated.

**Solution description**

- Allow during the creation of an actor to set the passivation time. When the passivation time is not set the default passivation mechanism will be used.
- One can also during the creation of the actor disable passivation for the given actor.

**Alternative solutions**
N/A

**Additional context**
N/A

This feature will help fix this feature request https://github.com/Tochemey/ego/issues/117 cc: @sdil 

Pull request:
feat: add custom passivation to spawn option
- WithPassivateAfter: sets a custom passivation time
- WithLongLived: makes the given actor to live long.

closes #616 

cc: @sdil 

Patch:
diff --git a/actors/actor_system.go b/actors/actor_system.go
index 8e843c7..fcc659d 100644
--- a/actors/actor_system.go
+++ b/actors/actor_system.go
@@ -1767,11 +1767,22 @@ func (x *actorSystem) configPID(ctx context.Context, name string, actor Actor, o
 		pidOpts = append(pidOpts, withStash())
 	}
 
-	// disable passivation for system actor
-	if isReservedName(name) {
+	switch {
+	case isReservedName(name):
+		// disable passivation for system actor
 		pidOpts = append(pidOpts, withPassivationDisabled())
-	} else {
-		pidOpts = append(pidOpts, withPassivationAfter(x.expireActorAfter))
+	default:
+		switch {
+		case spawnConfig.passivateAfter == nil:
+			// use system-wide passivation settings
+			pidOpts = append(pidOpts, withPassivationAfter(x.expireActorAfter))
+		case *spawnConfig.passivateAfter < longLived:
+			// use custom passivation setting
+			pidOpts = append(pidOpts, withPassivationAfter(*spawnConfig.passivateAfter))
+		default:
+			// live forever :)
+			pidOpts = append(pidOpts, withPassivationDisabled())
+		}
 	}
 
 	pid, err := newPID(
diff --git a/actors/option.go b/actors/option.go
index dacac97..1fe0d11 100644
--- a/actors/option.go
+++ b/actors/option.go
@@ -53,8 +53,11 @@ func (f OptionFunc) Apply(c *actorSystem) {
 	f(c)
 }
 
-// WithExpireActorAfter sets the actor expiry duration.
-// After such duration an idle actor will be expired and removed from the actor system
+// WithExpireActorAfter sets a custom duration after which an idle actor
+// will be passivated. Passivation allows the actor system to free up
+// resources by stopping actors that have been inactive for the specified
+// duration. If the actor receives a message before this timeout,
+// the passivation timer is reset.
 func WithExpireActorAfter(duration time.Duration) Option {
 	return OptionFunc(
 		func(a *actorSystem) {
diff --git a/actors/spawn_option.go b/actors/spawn_option.go
index d41ac1c..588a1a2 100644
--- a/actors/spawn_option.go
+++ b/actors/spawn_option.go
@@ -24,12 +24,20 @@
 
 package actors
 
+import (
+	"time"
+)
+
 // spawnConfig defines the configuration to apply when creating an actor
 type spawnConfig struct {
 	// mailbox defines the mailbox to use when spawning the actor
 	mailbox Mailbox
 	// defines the supervisor strategies to apply
 	supervisorStrategies []*SupervisorStrategy
+	// specifies at what point in time to passivate the actor.
+	// when the actor is passivated it is stopped which means it does not consume
+	// any further resources like memory and cpu. The default value is 120 seconds
+	passivateAfter *time.Duration
 }
 
 // newSpawnConfig creates an instance of spawnConfig
@@ -73,3 +81,24 @@ func WithSupervisorStrategies(supervisorStrategies ...*SupervisorStrategy) Spawn
 		config.supervisorStrategies = supervisorStrategies
 	})
 }
+
+// WithPassivateAfter sets a custom duration after which an idle actor
+// will be passivated. Passivation allows the actor system to free up
+// resources by stopping actors that have been inactive for the specified
+// duration. If the actor receives a message before this timeout,
+// the passivation timer is reset.
+func WithPassivateAfter(after time.Duration) SpawnOption {
+	return spawnOption(func(config *spawnConfig) {
+		config.passivateAfter = &after
+	})
+}
+
+// WithLongLived ensures that the given actor, once created, will persist
+// for the entire lifespan of the running actor system. Unlike short-lived
+// actors that may be restarted or garbage-collected, a long-lived actor
+// remains active until the actor system itself shuts down.
+func WithLongLived() SpawnOption {
+	return spawnOption(func(config *spawnConfig) {
+		config.passivateAfter = &longLived
+	})
+}
diff --git a/actors/util.go b/actors/util.go
index f978cd6..ab9082f 100644
--- a/actors/util.go
+++ b/actors/util.go
@@ -65,6 +65,8 @@ const (
 )
 
 var (
+	// 1<<63-1 nanoseconds equals about 292 years, which isn't truly "forever" but is the longest valid time.Duration value in Golang.
+	longLived time.Duration = 1<<63 - 1
 	// NoSender means that there is no sender
 	NoSender *PID
 	// DefaultSupervisoryStrategy defines the default supervisory strategy


Test patch:
diff --git a/actors/actor_system_test.go b/actors/actor_system_test.go
index 047eb63..4377013 100644
--- a/actors/actor_system_test.go
+++ b/actors/actor_system_test.go
@@ -29,6 +29,7 @@ import (
 	"errors"
 	"net"
 	"strconv"
+	"sync"
 	"testing"
 	"time"
 
@@ -1781,4 +1782,66 @@ func TestActorSystem(t *testing.T) {
 		err = newActorSystem.Start(ctx)
 		require.Error(t, err)
 	})
+	t.Run("With Spawn with custom passivation", func(t *testing.T) {
+		ctx := context.TODO()
+		actorSystem, _ := NewActorSystem("testSys",
+			WithLogger(log.DiscardLogger),
+			WithPassivationDisabled())
+
+		require.NoError(t, actorSystem.Start(ctx))
+
+		util.Pause(time.Second)
+
+		// create the actor path
+		pid, err := actorSystem.Spawn(ctx, "test", newMockActor(),
+			WithPassivateAfter(passivateAfter))
+		require.NoError(t, err)
+		assert.NotNil(t, pid)
+
+		// let us sleep for some time to make the actor idle
+		wg := sync.WaitGroup{}
+		wg.Add(1)
+		go func() {
+			util.Pause(receivingDelay)
+			wg.Done()
+		}()
+		// block until timer is up
+		wg.Wait()
+		// let us send a message to the actor
+		err = Tell(ctx, pid, new(testpb.TestSend))
+		assert.Error(t, err)
+		assert.EqualError(t, err, ErrDead.Error())
+		assert.NoError(t, actorSystem.Stop(ctx))
+	})
+	t.Run("With Spawn with long lived", func(t *testing.T) {
+		ctx := context.TODO()
+		actorSystem, _ := NewActorSystem("testSys",
+			WithLogger(log.DiscardLogger),
+			WithExpireActorAfter(passivateAfter))
+
+		require.NoError(t, actorSystem.Start(ctx))
+
+		util.Pause(time.Second)
+
+		// create the actor path
+		pid, err := actorSystem.Spawn(ctx, "test", newMockActor(),
+			WithLongLived())
+		require.NoError(t, err)
+		assert.NotNil(t, pid)
+
+		// let us sleep for some time to make the actor idle
+		wg := sync.WaitGroup{}
+		wg.Add(1)
+		go func() {
+			util.Pause(receivingDelay)
+			wg.Done()
+		}()
+		// block until timer is up
+		wg.Wait()
+		// let us send a message to the actor
+		err = Tell(ctx, pid, new(testpb.TestSend))
+		assert.NoError(t, err)
+		assert.True(t, pid.IsRunning())
+		assert.NoError(t, actorSystem.Stop(ctx))
+	})
 }
diff --git a/actors/spawn_option_test.go b/actors/spawn_option_test.go
index f8425e6..5e94e2b 100644
--- a/actors/spawn_option_test.go
+++ b/actors/spawn_option_test.go
@@ -26,20 +26,40 @@ package actors
 
 import (
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 )
 
 func TestSpawnOption(t *testing.T) {
-	mailbox := NewUnboundedMailbox()
-	config := &spawnConfig{}
-	option := WithMailbox(mailbox)
-	option.Apply(config)
-	require.Equal(t, &spawnConfig{mailbox: mailbox}, config)
-	strategy := NewSupervisorStrategy(PanicError{}, NewStopDirective())
-	option = WithSupervisorStrategies(strategy)
-	option.Apply(config)
-	require.Equal(t, &spawnConfig{mailbox: mailbox, supervisorStrategies: []*SupervisorStrategy{strategy}}, config)
+	t.Run("spawn option with mailbox", func(t *testing.T) {
+		mailbox := NewUnboundedMailbox()
+		config := &spawnConfig{}
+		option := WithMailbox(mailbox)
+		option.Apply(config)
+		require.Equal(t, &spawnConfig{mailbox: mailbox}, config)
+	})
+	t.Run("spawn option with supervisor strategy", func(t *testing.T) {
+		config := &spawnConfig{}
+		strategy := NewSupervisorStrategy(PanicError{}, NewStopDirective())
+		option := WithSupervisorStrategies(strategy)
+		option.Apply(config)
+		require.Equal(t, &spawnConfig{supervisorStrategies: []*SupervisorStrategy{strategy}}, config)
+	})
+	t.Run("spawn option with passivation after", func(t *testing.T) {
+		config := &spawnConfig{}
+		second := time.Second
+		option := WithPassivateAfter(second)
+		option.Apply(config)
+		require.Equal(t, &spawnConfig{passivateAfter: &second}, config)
+	})
+
+	t.Run("spawn option with long-lived", func(t *testing.T) {
+		config := &spawnConfig{}
+		option := WithLongLived()
+		option.Apply(config)
+		require.Equal(t, &spawnConfig{passivateAfter: &longLived}, config)
+	})
 }
 
 func TestNewSpawnConfig(t *testing.T) {