actor: refactor Actor to use Mailbox interface

This commit refactors the Actor implementation to use the new Mailbox
interface instead of directly managing a channel. This change
significantly simplifies the actor's message processing loop and
improves separation of concerns.

The main changes include replacing the direct channel field with a
Mailbox interface, updating NewActor to create a ChannelMailbox
instance, and refactoring the process method to use the iterator
pattern provided by mailbox.Receive. The new implementation uses a
clean for-range loop over the mailbox's message iterator, eliminating
the complex select statement that previously handled both message
reception and context cancellation.

The Tell and Ask methods in actorRefImpl have been simplified to use
the mailbox's Send method, which internally handles both the caller's
context and the actor's context. This eliminates the need for complex
select statements in these methods and ensures consistent context
handling throughout the actor system.

Message draining during shutdown is now handled through the mailbox's
Drain method, providing a cleaner separation between normal message
processing and cleanup operations. The actor still properly sends
unprocessed messages to the Dead Letter Office and completes pending
promises with appropriate errors during shutdown.
This commit is contained in:
Olaoluwa Osuntokun committed 2026-02-09 19:39:06 -08:00
1 parent de7b0e1dd4
commit 20c5c9d6be
1 file changed
+44 -97
+44 -97
View File
@@ -45,7 +45,7 @@ type Actor[M Message, R any] struct {
behavior ActorBehavior[M, R] behavior ActorBehavior[M, R]
// mailbox is the incoming message queue for the actor. // mailbox is the incoming message queue for the actor.
mailbox chan envelope[M, R] mailbox Mailbox[M, R]
// ctx is the context governing the actor's lifecycle. // ctx is the context governing the actor's lifecycle.
ctx context.Context ctx context.Context
@@ -93,10 +93,13 @@ func NewActor[M Message, R any](cfg ActorConfig[M, R]) (*Actor[M, R],
mailboxCapacity = 1 mailboxCapacity = 1
} }
// Create mailbox - could be injected via config in the future.
mailbox := NewChannelMailbox[M, R](ctx, mailboxCapacity)
actor := &Actor[M, R]{ actor := &Actor[M, R]{
id: cfg.ID, id: cfg.ID,
behavior: cfg.Behavior, behavior: cfg.Behavior,
mailbox: make(chan envelope[M, R], mailboxCapacity), mailbox: mailbox,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
dlo: cfg.DLO, dlo: cfg.DLO,
@@ -123,76 +126,32 @@ func (a *Actor[M, R]) Start() {
// process is the main event loop for the actor. It continuously monitors its // process is the main event loop for the actor. It continuously monitors its
// mailbox for incoming messages and its context for cancellation signals. // mailbox for incoming messages and its context for cancellation signals.
func (a *Actor[M, R]) process() { func (a *Actor[M, R]) process() {
for { // Use the new iterator pattern for receiving messages.
select { for env := range a.mailbox.Receive(a.ctx) {
case env := <-a.mailbox: result := a.behavior.Receive(a.ctx, env.message)
result := a.behavior.Receive(a.ctx, env.message)
// If a promise was provided (i.e., it was an "ask" // If a promise was provided (i.e., it was an "ask"
// operation), complete the promise with the result from // operation), complete the promise with the result from
// the behavior. // the behavior.
if env.promise != nil { if env.promise != nil {
env.promise.Complete(result) env.promise.Complete(result)
} }
}
// The actor's context has been cancelled, signaling a stop // Context was cancelled or mailbox closed, drain remaining messages.
// request. Exit the processing loop to terminate the actor's a.mailbox.Close()
// goroutine. Before exiting, drain any remaining messages from
// the mailbox.
//
// NOTE: We intentionally do NOT close the mailbox channel here.
// Closing it would create a TOCTOU race with Tell/Ask, which
// check ctx.Err() before sending to the mailbox. Between that
// check and the actual send, the channel could be closed,
// causing a panic. Instead, we drain using a non-blocking
// select loop. Messages sent after this point will remain in
// the buffered channel and be garbage collected.
case <-a.ctx.Done():
log.Debugf("Actor %s: context cancelled, draining "+
"mailbox", a.id)
// Drain any remaining messages from the mailbox for env := range a.mailbox.Drain() {
// without closing the channel. // If a DLO is configured, send the original message there
drained := 0 // for auditing or potential manual reprocessing.
for { if a.dlo != nil {
select { a.dlo.Tell(context.Background(), env.message)
case env := <-a.mailbox: }
drained++
// If a DLO is configured, send the // If it was an Ask, complete the promise with an error
// original message there for auditing // indicating the actor terminated.
// or potential manual reprocessing. if env.promise != nil {
if a.dlo != nil { env.promise.Complete(fn.Err[R](ErrActorTerminated))
a.dlo.Tell(
context.Background(),
env.message,
)
}
// If it was an Ask, complete the
// promise with an error indicating the
// actor terminated.
if env.promise != nil {
env.promise.Complete(
fn.Err[R](
ErrActorTerminated,
),
)
}
default:
if drained > 0 {
log.Debugf("Actor %s: "+
"drained %d "+
"message(s) during "+
"shutdown",
a.id, drained)
}
// No more messages in the mailbox.
return
}
}
} }
} }
} }
@@ -227,22 +186,15 @@ func (ref *actorRefImpl[M, R]) Tell(ctx context.Context, msg M) {
return return
} }
select { env := envelope[M, R]{message: msg, promise: nil}
// Message successfully enqueued in the actor's mailbox.
case ref.actor.mailbox <- envelope[M, R]{message: msg, promise: nil}:
// The context for the Tell operation was cancelled before the message // Use mailbox Send method which internally checks both contexts.
// could be enqueued. The message is dropped. if !ref.actor.mailbox.Send(ctx, env) {
case <-ctx.Done(): // Failed to send - check if actor terminated.
log.Warnf("Tell to actor %s: message %s dropped "+ if ref.actor.ctx.Err() != nil {
"(caller context cancelled)", ref.actor.id, ref.trySendToDLO(msg)
msg.MessageType()) }
// Otherwise it was the caller's context that cancelled.
// The actor itself has been stopped/terminated.
case <-ref.actor.ctx.Done():
// If the actor is terminated and has a DLO, send the message
// there. Otherwise, it's dropped.
ref.trySendToDLO(msg)
} }
} }
@@ -271,21 +223,16 @@ func (ref *actorRefImpl[M, R]) Ask(ctx context.Context, msg M) Future[R] {
return promise.Future() return promise.Future()
} }
select { env := envelope[M, R]{message: msg, promise: promise}
// Attempt to send the message along with its promise to the actor's
// mailbox.
case ref.actor.mailbox <- envelope[M, R]{message: msg, promise: promise}:
// The context for the Ask operation was cancelled before the message // Use mailbox Send method which internally checks both contexts.
// could be enqueued. Complete the promise with the context's error to if !ref.actor.mailbox.Send(ctx, env) {
// unblock the caller. // Determine the error based on what failed.
case <-ctx.Done(): if ref.actor.ctx.Err() != nil {
promise.Complete(fn.Err[R](ctx.Err())) promise.Complete(fn.Err[R](ErrActorTerminated))
} else {
// The actor's context was cancelled (e.g., actor stopped) while this promise.Complete(fn.Err[R](ctx.Err()))
// Ask operation was attempting to send (e.g., mailbox was full). }
case <-ref.actor.ctx.Done():
promise.Complete(fn.Err[R](ErrActorTerminated))
} }
// Return the future associated with the promise, allowing the caller to // Return the future associated with the promise, allowing the caller to