Skip to content

baselib: add protofsm+actor baselib - #10

Merged
sputn1ck merged 8 commits into
mainfrom
baselib
Nov 20, 2025
Merged

baselib: add protofsm+actor baselib#10
sputn1ck merged 8 commits into
mainfrom
baselib

Conversation

@sputn1ck

Copy link
Copy Markdown
Member

This pull request introduces a comprehensive implementation of a generic actor model for Go, complete with core actor logic, robust testing, and illustrative usage examples. The main changes include the creation of the core Actor type and supporting infrastructure, a suite of tests validating actor behaviors and lifecycle, and example files demonstrating basic and router-based actor usage.

Core actor implementation:

  • Introduced a generic Actor type in actor.go, supporting both "tell" (fire-and-forget) and "ask" (request-response) message patterns, mailbox management, lifecycle control (start/stop), and integration with a Dead Letter Office (DLO) for undeliverable messages. This includes the ActorConfig struct, envelope handling, and ActorRef/TellOnlyRef interfaces.

Testing and validation:

  • Added actor_test.go with extensive tests covering actor creation, message handling (tell/ask), error propagation, DLO interactions, and lifecycle management. Includes custom test harnesses, behaviors, and DLO observers for thorough coverage.

Usage examples:

  • Added example_basic_actor_test.go demonstrating how to create, use, and unregister a simple actor with direct messaging.
  • Added example_router_test.go illustrating how to spawn multiple actors under a service key and use a router with round-robin dispatching for message distribution.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @sputn1ck, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes a foundational actor model within the baselib package, enabling the development of more robust, concurrent, and decoupled systems in Go. It also provides a powerful integration layer with the protofsm package, allowing finite state machines to operate as actors, facilitating restart-safe and event-driven workflows. This architecture aims to improve system manageability, testability, and maintainability by promoting structured message passing and eliminating tight coupling between components.

Highlights

  • Generic Actor Model: Introduced a comprehensive, generic actor model for Go, supporting "tell" (fire-and-forget) and "ask" (request-response) message patterns, mailbox management, and lifecycle control within an ActorSystem.
  • Finite State Machine Integration: Integrated the new actor model with protofsm to enable restart-safe, event-driven workflows where FSMs run as actors and dispatch outbox events to other actors, facilitating clean separation of concerns.
  • Robust Testing & Examples: Added extensive unit and integration tests for actor behaviors, lifecycle, and Dead Letter Office (DLO) interactions, alongside detailed usage examples for basic actors, routers, and FSM integration.
  • Comprehensive Documentation: Provided detailed guides (PROTOFSM_ACTOR_GUIDE.md, actor/README.md) explaining core concepts, design patterns, and best practices for effectively using the new actor and protofsm packages.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@claude

claude Bot commented Nov 18, 2025

Copy link
Copy Markdown

Comprehensive PR Review: Actor Model + ProtoFSM Implementation

Overview

This PR introduces a substantial addition to the codebase: a generic actor model implementation and a protobuf-based finite state machine (FSM) framework. The implementation is well-structured, thoroughly documented, and includes comprehensive test coverage. However, there are several areas that need attention before merging.


Critical Issues

1. Goroutine Leak Risk in Future.ThenApply and Future.OnComplete

Location: baselib/actor/future.go:108-136, baselib/actor/future.go:143-152

Both ThenApply and OnComplete spawn goroutines that call Await(ctx) but don't have proper cleanup if the context is already cancelled when the goroutine starts.

// Current implementation spawns goroutine unconditionally
func (f *futureImpl[T]) ThenApply(ctx context.Context, fApply func(T) T) Future[T] {
    transformedPromise := NewPromise[T]()
    go func() {
        originalResult := f.Await(ctx)  // May block indefinitely if future never completes
        // ...
    }()
    return transformedPromise.Future()
}

Risk: If the future is never completed and the context passed to ThenApply/OnComplete is cancelled, the goroutine will still wait on the future's done channel, potentially causing a goroutine leak.

Recommendation: Check context cancellation before spawning the goroutine, or ensure proper timeout handling.


2. Missing Error Handling in State Machine Error Reporter

Location: baselib/protofsm/state_machine.go:460, baselib/protofsm/state_machine.go:480

if err != nil {
    s.cfg.ErrorReporter.ReportError(err)  // Potential nil pointer dereference!
    s.log.ErrorS(ctx, "Unable to apply event", err)
    go s.Stop()
    return
}

The code calls s.cfg.ErrorReporter.ReportError(err) without checking if ErrorReporter is nil. According to StateMachineCfg, ErrorReporter is optional.

Recommendation: Add nil check before calling ErrorReporter.ReportError():

if s.cfg.ErrorReporter != nil {
    s.cfg.ErrorReporter.ReportError(err)
}

3. Race Condition in ActorStateMachine.currentState

Location: baselib/protofsm/actor_wrapper.go:102-176

type ActorStateMachine[InternalEvent any, OutboxEvent ActorOutboxEvent, Env any] struct {
    sm           *StateMachine[InternalEvent, OutboxEvent, Env]
    system       *actor.ActorSystem
    currentState State[InternalEvent, OutboxEvent, Env]  // No synchronization!
}

func (sm *ActorStateMachine[...]) Receive(ctx context.Context, e ActorMessage[InternalEvent]) ... {
    if e.StateQuery {
        return fn.Ok(ActorResponse[...]{
            CurrentState: sm.currentState,  // Read without lock
        })
    }
    // ...
    sm.currentState = newState  // Write without lock
}

While the actor model ensures sequential message processing within a single actor, the currentState field is accessed without synchronization. If multiple goroutines can call Receive concurrently (which shouldn't happen in a proper actor implementation but isn't enforced), this could cause data races.

Recommendation: Document clearly that ActorStateMachine.Receive must only be called by a single actor's processing loop, OR add synchronization primitives. Consider adding a comment explaining the concurrency guarantees.


Major Issues

4. Log Level Convention Violation

Location: baselib/protofsm/state_machine.go:462, baselib/protofsm/state_machine.go:482

Per CLAUDE.md:

CRITICAL: Only use error level for internal errors never expected during normal operation.

s.log.ErrorS(ctx, "Unable to apply event", err)

This uses error-level logging for state machine processing errors, which may include expected user-triggered failures. If FSM event processing can fail due to external conditions (invalid input, network issues, etc.), these should use warn or info levels.

Recommendation: Review whether FSM errors represent truly internal/unexpected errors. If not, downgrade to warn level.


5. Insufficient Test Coverage for Concurrent Scenarios

Location: baselib/actor/actor_test.go

While the test coverage is good (395 lines in actor_test.go), I didn't see specific tests for:

  • Concurrent Tell/Ask operations from multiple goroutines
  • Mailbox full scenarios (what happens when mailbox capacity is exceeded?)
  • Rapid actor start/stop cycles
  • Race conditions during shutdown

Recommendation: Add stress tests that:

  1. Send messages from multiple goroutines concurrently
  2. Test behavior when mailbox is full (currently will block indefinitely)
  3. Verify proper cleanup during rapid start/stop cycles

6. Router Creates New Instance on Every Dispatch

Location: baselib/protofsm/actor_wrapper.go:78-84

func (e RoutedOutboxEvent[M, R]) Dispatch(ctx context.Context, system *actor.ActorSystem) error {
    // Create a router for the service key.
    router := actor.NewRouter(
        system.Receptionist(), e.key, actor.NewRoundRobinStrategy[M, R](),
        nil,
    )
    // ...
}

A new router (and round-robin strategy) is created for every single dispatch. This means the round-robin counter resets each time, defeating the purpose of round-robin load balancing.

Recommendation: Router instances should be long-lived and reused. Consider:

  1. Caching routers in the ActorSystem or Environment
  2. Creating routers once during setup and passing them through

Code Quality Issues

7. Tab Width Configuration

Location: All Go files

Per CLAUDE.md:

IMPORTANT: Editors must be configured with tab = 8 spaces for correct formatting.

The code appears to use tabs, but there's no .editorconfig in baselib/ to enforce 8-space tabs. The root .editorconfig should be verified to ensure it applies to this directory.

Recommendation: Verify .editorconfig applies to baselib/**/*.go or add a local config.


8. Missing Structured Logging in Some Places

Location: baselib/protofsm/actor_wrapper.go:144

cfg.Logger.Debugf("Setting up FSM %s", extraInfo)

This uses Debugf instead of DebugS. Per CLAUDE.md:

YOU MUST use structured log methods (ending in S) with static messages

Recommendation: Convert to:

cfg.Logger.DebugS(ctx, "Setting up FSM",
    slog.String("env_type", extraInfo))

9. Function Comments Don't Follow Convention

Location: Multiple files, e.g., baselib/actor/actor.go:69-72

// NewActor creates a new actor instance with the given ID and behavior.
// It initializes the actor's internal structures but does not start its
// message processing goroutine. The Start() method must be called to begin
// processing messages.
func NewActor[M Message, R any](cfg ActorConfig[M, R]) *Actor[M, R] {

Per CLAUDE.md:

Every function must have a comment starting with the function name

Current: "NewActor creates..."
Should be: "NewActor creates... NewActor initializes..."

This applies to many functions. The comment should start by stating what NewActor does (already good) but should emphasize the function name.

Recommendation: Review all exported function comments to ensure they start with the function name and explain how/why, not just what.


10. Line Length Violations

Location: Multiple, e.g., baselib/protofsm/state_machine.go:377

//nolint:ll
for nextEvent := eventQueue.Dequeue(); nextEvent.IsSome(); nextEvent = eventQueue.Dequeue() {

The code uses //nolint:ll to suppress line length warnings. Per CLAUDE.md:

80-character line limit (best effort)

While structured logging lines can exceed 80 chars, other code should strive for this limit.

Recommendation: Refactor long lines where possible:

for nextEvent := eventQueue.Dequeue(); 
    nextEvent.IsSome(); 
    nextEvent = eventQueue.Dequeue() {

Performance Considerations

11. Actor Mailbox Blocking Behavior

Location: baselib/actor/actor.go:189-203, baselib/actor/actor.go:230-245

When the mailbox is full, Tell and Ask will block until space is available or context is cancelled. This could cause unexpected latency or deadlocks if not carefully managed.

Recommendation:

  1. Document this behavior clearly in function comments
  2. Consider adding a non-blocking "TryTell" variant that returns a bool
  3. Add metrics/logging for mailbox capacity warnings

12. State Machine Event Queue is Unbuffered

Location: baselib/protofsm/state_machine.go:184

events: make(chan InternalEvent, 1),

The event channel has a buffer of only 1. This means the second SendEvent call will block until the first event is processed.

Recommendation: Consider making this configurable or using a larger buffer (e.g., 10-100) to allow more event batching. This is especially important for high-throughput FSMs.


Security Considerations

13. DLO Sends Use context.Background()

Location: baselib/actor/actor.go:142, baselib/actor/actor.go:259

a.dlo.Tell(context.Background(), env.message)

When forwarding messages to the DLO during shutdown, the code uses context.Background() instead of respecting the original context. This means DLO sends cannot be cancelled even if the system is shutting down.

Recommendation: Consider:

  1. Using a derived context with a short timeout
  2. Making DLO sends truly fire-and-forget by dropping messages if the DLO itself is stopped

Documentation Quality

14. Excellent Documentation Overall

The documentation is exceptional:

  • PROTOFSM_ACTOR_GUIDE.md (870 lines) provides comprehensive usage patterns
  • actor/README.md (475 lines) explains core concepts thoroughly
  • Extensive examples with clear explanations
  • Decision logs and troubleshooting sections

Praise: This is production-quality documentation that will significantly reduce onboarding time.


15. Missing Package-Level Documentation

Location: baselib/actor/actor.go:1, baselib/protofsm/state_machine.go:1

Go convention is to have package-level documentation comments before the package declaration.

Recommendation: Add package-level comments:

// Package actor provides a generic actor model implementation for Go,
// enabling message-passing concurrency with lifecycle management.
package actor

Test Quality

16. Test Coverage is Strong

  • actor_test.go: 395 lines covering basic operations, DLO, lifecycle
  • system_test.go: 942 lines covering system-level integration
  • future_test.go: 449 lines covering async patterns
  • Multiple example tests demonstrating usage

Praise: The test harness pattern (actorTestHarness) is excellent and makes tests very readable.


17. Missing Integration Tests for ProtoFSM + Actor

Location: baselib/protofsm/

While there are unit tests for the state machine and actor system separately, I didn't see integration tests that verify:

  1. Outbox event dispatch to actors works correctly
  2. FSM resume after state machine restart
  3. Error propagation through the actor->FSM->actor chain

Recommendation: Add integration tests in baselib/example/example_test.go that exercise the full actor+FSM workflow.


Positive Highlights

Excellent use of Go generics for type safety
Comprehensive documentation with real-world usage examples
Clean separation of concerns between actor model and FSM
Proper use of fn.Result and fn.Option for error handling
Good test harness design making tests maintainable
Thoughtful API design with Tell/Ask patterns
Service key abstraction for location transparency


Recommendations Summary

Before Merge (Critical):

  1. ✅ Fix nil pointer risk in ErrorReporter usage
  2. ✅ Address goroutine leak potential in Future methods
  3. ✅ Fix router reuse issue in RoutedOutboxEvent.Dispatch
  4. ✅ Add concurrency safety documentation for ActorStateMachine

Before Production Use (Major):

  1. ✅ Add concurrent operation tests
  2. ✅ Review and adjust log levels per style guide
  3. ✅ Add mailbox full handling strategy
  4. ✅ Make event queue buffer configurable

Nice to Have (Minor):

  1. ✅ Fix function comment style throughout
  2. ✅ Convert to structured logging everywhere
  3. ✅ Add package-level documentation
  4. ✅ Refactor long lines

Verdict

This is a high-quality implementation with excellent documentation and test coverage. The actor model design is sound and follows established patterns from Akka/Erlang. However, the critical issues must be addressed before merging to avoid potential runtime failures.

The codebase demonstrates strong engineering practices, and with the suggested fixes, this will be a valuable addition to the project.

Recommendation: Request changes for critical issues, approve after fixes.


Review completed with:

  • 22 files changed (+6179 lines)
  • Focus areas: concurrency safety, error handling, performance, style compliance
  • Based on repository conventions in CLAUDE.md and docs/development_guidelines.md

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a well-designed and comprehensive actor model implementation for Go, along with a protofsm integration. The code is well-structured, with clear separation of concerns, good use of generics, and extensive testing. The documentation, including the README.md and the PROTOFSM_ACTOR_GUIDE.md, is excellent and provides valuable context and usage patterns.

My review has identified a few critical and high-severity issues that should be addressed:

  • A potential deadlock in the StateMachine.Receive method.
  • A panic that can occur when registering an actor on a shut-down ActorSystem.
  • Silent message loss due to a nil Dead Letter Office in the RoutedOutboxEvent dispatch logic.

I've also included a few medium-severity suggestions for improving documentation clarity and fixing a potential build issue with the specified Go version in go.mod.

Overall, this is a high-quality contribution that will be a great addition to the baselib.

Comment on lines +298 to +300
func (s *StateMachine[InternalEvent, OutboxEvent, Env]) Receive(
ctx context.Context, e ActorMessage[InternalEvent]) fn.Result[[]OutboxEvent] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The Receive method on StateMachine is intended to implement the actor.ActorBehavior interface. However, it calls s.AskEvent(ctx, e.Event), which sends a message to the syncEvents channel and waits for a response. If this StateMachine instance were used as an actor's behavior, its Receive method would be executed by the actor's single processing goroutine. This same goroutine is also responsible for reading from syncEvents in the driveMachine loop. This creates a deadlock: the goroutine blocks sending to syncEvents and can never read from it. This method is dangerously misleading and should be removed to prevent accidental use. The ActorStateMachine wrapper in actor_wrapper.go provides the correct, non-deadlocking implementation.

Comment thread baselib/actor/system.go
Comment on lines +111 to +113
func RegisterWithSystem[M Message, R any](as *ActorSystem, id string, key ServiceKey[M, R],
behavior ActorBehavior[M, R],
) ActorRef[M, R] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling RegisterWithSystem on a system that has been shut down will cause a panic. This is because Shutdown() sets as.actors to nil, and RegisterWithSystem then attempts a write to this nil map. The system should gracefully handle operations after shutdown.

A check should be added at the beginning of the function to see if the system is shut down. For example:

if as.ctx.Err() != nil {
	// To avoid returning nil and causing a panic, we can create and
	// return a reference to a dummy actor that is already stopped.
	// This ensures that any calls to the returned ref will fail
	// with ErrActorTerminated.
	dummyCfg := ActorConfig[M, R]{ID: id}
	dummyActor := NewActor(dummyCfg)
	dummyActor.Stop()
	return dummyActor.Ref()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is important too.

Comment thread baselib/protofsm/actor_wrapper.go Outdated
// Create a router for the service key.
router := actor.NewRouter(
system.Receptionist(), e.key, actor.NewRoundRobinStrategy[M, R](),
nil, // No DLO yet.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Dead Letter Office (DLO) for the router is hardcoded to nil. If a Tell message is dispatched via this router and no actors are available for the service key, the message will be dropped silently instead of being routed to the system's DLO for diagnostics. The system's DLO should be used here.

Additionally, creating a new router on every Dispatch call is inefficient and could be a performance bottleneck. Consider caching router instances.

Suggested change
nil, // No DLO yet.
system.DeadLetters(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good suggestion.

Comment thread baselib/actor/README.md
Comment on lines +24 to +27
Concurrency is managed by the actor system, allowing many actors to
This model inherently promotes loose coupling, as actors do not share state
execute concurrently without explicit lock management by the developer for actor
state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This paragraph appears to be malformed, likely from an editing error, which makes it difficult to read. The sentences are merged and do not flow correctly.

Suggested change
Concurrency is managed by the actor system, allowing many actors to
This model inherently promotes loose coupling, as actors do not share state
execute concurrently without explicit lock management by the developer for actor
state.
Concurrency is managed by the actor system, allowing many actors to execute concurrently without explicit lock management by the developer for actor state. This model inherently promotes loose coupling, as actors do not share state.

Comment thread baselib/go.mod Outdated
@@ -0,0 +1,45 @@
module github.com/lightninglabs/ark/baselib

go 1.23.12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Go version is set to 1.23.12, which appears to be a typo or a version that is not yet released. This could cause compatibility issues for other developers. The go.sum file contains entries for Go 1.21. It's recommended to use a recent, stable Go version that the code is compatible with, such as 1.21 or 1.22.

Suggested change
go 1.23.12
go 1.21

@bhandras

Copy link
Copy Markdown
Member

I think this piece of slop represents the PR really well:

Actor System Overview

The baselib provides two core components that work together:

  1. Actor Package (actor/)

This is a message-passing concurrency framework inspired by the actor model:

Core Concepts:

  • Actor: A goroutine with a mailbox (buffered channel) that processes messages sequentially
  • ActorRef: A reference for sending messages to actors
    • Tell(ctx, msg) - fire-and-forget (async)
    • Ask(ctx, msg) - request-response (returns Future)
  • ActorSystem: Manages actor lifecycle and a service registry (Receptionist pattern)
  • ServiceKey: Type-safe lookup key for finding actors by service name

Key Implementation Details (from actor/actor.go):

  • Each actor has its own goroutine running a process() loop (actor.go:113)
  • Messages are wrapped in envelope[M, R] with an optional Promise for Ask operations (actor.go:32)
  • Dead Letter Office (DLO) handles messages to terminated actors (actor.go:140)
  • Thread-safe lifecycle with startOnce and stopOnce (actor.go:60-63)
  1. ProtoFSM Package (protofsm/)

This is a type-safe, event-driven finite state machine:

Core Concepts:

  • State: Immutable data + transition logic via ProcessEvent()
  • Event: Internal events that trigger state transitions
  • OutboxEvent: External events emitted to actors for side effects
  • StateMachine: Event queue processor (state_machine.go:108)

Key Implementation Details (from protofsm/state_machine.go):

  • applyEvents() processes events until the queue is empty (state_machine.go:360)
  • Internal events can trigger cascading state transitions (state_machine.go:377)
  • Supports both async SendEvent() and sync AskEvent() patterns (state_machine.go:221, 240)
  • Accumulates outbox events during event processing chain (state_machine.go:369)
  1. Actor Wrapper (protofsm/actor_wrapper.go)

This is where the magic happens - it bridges FSM and actors:

ActorStateMachine (actor_wrapper.go:102)

Wraps a StateMachine as an ActorBehavior, enabling:

  1. FSM runs inside an actor - one FSM instance per actor
  2. Automatic outbox dispatch - outbox events automatically route to actors via service keys
  3. Environment injection - FSM receives its own ActorRef to share with other actors

Key Components:

  type ActorStateMachine[InternalEvent, OutboxEvent, Env] struct {
      sm           *StateMachine[...]  // The FSM
      system       *actor.ActorSystem  // Access to actor system
      currentState State[...]           // Current FSM state
  }

How it works (actor_wrapper.go:149):

  func (sm *ActorStateMachine) Receive(ctx, e ActorMessage[Event]) Result[ActorResponse] {
      // 1. Process event through FSM
      newState, outBoxEvents, err := sm.sm.applyEvents(ctx, sm.currentState, e.Event)

      // 2. Update state
      sm.currentState = newState

      // 3. Dispatch outbox events to actors
      for _, out := range outBoxEvents {
          out.Dispatch(ctx, sm.system)  // Routes to other actors!
      }

      return fn.Ok(ActorResponse{CurrentState: sm.currentState})
  }

RoutedOutboxEvent (actor_wrapper.go:51)

Enables location-transparent message routing:

  type RoutedOutboxEvent[M, R] struct {
      key  actor.ServiceKey[M, R]  // Where to send
      msg  M                        // What to send
      mode DeliveryMode             // Tell or Ask
  }

Dispatch (actor_wrapper.go:78):

  • Creates a router for the service key
  • Routes message to registered actors
  • Supports both Tell (async) and Ask (blocking) patterns

Environment Integration (actor_wrapper.go:108)

The FSM environment can receive an ActorRef:

  type TellRefEnv[InternalEvent] interface {
      SetTellOnlyRef(actor.TellOnlyRef[ActorMessage[InternalEvent]])
      GetTellOnlyRef() actor.TellOnlyRef[ActorMessage[InternalEvent]]
  }

This allows actors to send events back to the FSM (actor_wrapper.go:135-142).

Practical Example Flow

Looking at example/example_protofsm.go:

  1. Document submitted → StateInit processes EventSubmitDocument
  2. Emits outbox event → NewOutboxRequestReview() created with FSM's ActorRef
  3. Outbox dispatched → RoutedOutboxEvent.Dispatch() routes to ReviewServiceKey
  4. Review actor processes → ReviewServiceBehavior.Receive() starts review
  5. Actor sends event back → Uses msg.ReplyTo.Tell() to send EventApproved to FSM
  6. FSM transitions → StateAwaitingReview → StateApproved (terminal)

Key Benefits

  1. Separation of concerns: FSM = pure logic, Actors = side effects
  2. Restart safety: States are serializable, EventResume re-establishes operations
  3. Type safety: Sealed interfaces prevent invalid events/states
  4. Location transparency: Service keys decouple senders from receivers
  5. Testability: FSM states can be unit tested, full integration tested with actors

The actor wrapper is essentially the glue that lets you build restart-safe, event-driven workflows
where pure state machines coordinate with concurrent actors performing I/O and side effects.

@bhandras bhandras left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really nothing major on my end, LGTM!

I like this new Actor wrapped FSM concept a lot. It took a while to understand but once I got it it's pretty simple actually and very elegant, really nice work! 🎉

Comment thread baselib/actor/system.go
mu sync.RWMutex

// ctx is the main context for the actor system.
ctx context.Context

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to store this context?

Comment thread baselib/protofsm/actor_wrapper.go
Comment thread baselib/protofsm/actor_wrapper.go
Comment thread baselib/protofsm/actor_wrapper.go Outdated
extraInfo = "(full ref env)"
}

cfg.Logger.Debugf("Setting up FSM %s", extraInfo)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we log, we could log more info about the fsm itself, and what we do exactly. So maybe a name or type name, that we wrap the fsm in an actor and that the actor has full ref or just tell ref env.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm it's the fsms creators responsibility to set up the logger with prefixes. I changed this call to be debugS so the caller can use the ctx to log whatever they like

Comment thread baselib/protofsm/actor_wrapper.go Outdated
// Create a router for the service key.
router := actor.NewRouter(
system.Receptionist(), e.key, actor.NewRoundRobinStrategy[M, R](),
nil, // No DLO yet.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a good suggestion.

sm := &ActorStateMachine[InternalEvent, OutboxEvent, Env]{
sm: &machine,
system: system,
currentState: cfg.InitialState,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So iiuc this initial state is never published to subscribers right?

Comment thread baselib/protofsm/actor_wrapper.go
Comment thread baselib/go.mod Outdated
@claude

claude Bot commented Nov 20, 2025

Copy link
Copy Markdown

PR Review: Actor System + Proto FSM Implementation

Overview

This PR introduces a comprehensive actor model and finite state machine framework to the codebase. The implementation is well-structured and follows solid engineering principles. Overall, this is excellent work with thorough documentation and testing.

✅ Strengths

1. Excellent Documentation

  • PROTOFSM_ACTOR_GUIDE.md (870 lines) provides comprehensive usage patterns with real-world examples
  • actor/README.md (475 lines) explains actor model concepts thoroughly
  • Clear separation of concerns and well-documented design patterns
  • Multiple example files demonstrating different usage patterns

2. Strong Type Safety

  • Excellent use of Go generics for type-safe actor communication
  • Sealed interface pattern for events prevents invalid state transitions
  • ServiceKey design ensures type-safe actor discovery

3. Robust Concurrency Design

  • Proper use of sync.Once for Start/Stop operations (baselib/actor/actor.go:60-63)
  • Well-designed mailbox pattern with context-aware message passing
  • Clean shutdown with proper resource cleanup in Stop() (baselib/actor/actor.go:130-156)

4. Comprehensive Test Coverage

  • 23+ test functions across actor and system tests
  • Tests cover lifecycle management, concurrent operations, error handling
  • Good use of test helpers and behavior patterns

5. Production-Ready Features

  • Dead Letter Office (DLO) for undeliverable messages
  • Receptionist pattern for service discovery
  • Router with round-robin strategy
  • Restart safety with EventResume pattern

⚠️ Issues & Concerns

1. Potential Deadlock in Actor Stop (Medium Priority)

File: baselib/actor/actor.go:130-156

The process() method drains the mailbox on shutdown, but if messages keep arriving during drain, there could be edge cases:

case <-a.ctx.Done():
    close(a.mailbox)
    for env := range a.mailbox {  // Drains existing messages
        if a.dlo != nil {
            a.dlo.Tell(context.Background(), env.message)  // Could block if DLO is also shutting down
        }
    }

Recommendation: Consider adding a timeout or using a select with context for DLO.Tell operations during shutdown.

2. Race Condition in ActorStateMachine (Medium Priority)

File: baselib/protofsm/actor_wrapper.go:172-177

The currentState field is updated without synchronization:

sm.currentState = newState  // Line 177

While the actor model provides sequential message processing, if currentState is read from another goroutine (e.g., state query), this could race.

Recommendation: Protect currentState with a mutex or ensure all access is through the actor's message queue.

3. Error Handling in State Machine (Low Priority)

File: baselib/protofsm/state_machine.go:459-469

When applyEvents returns an error, the state machine calls go s.Stop(), which is non-blocking. The error is logged but not propagated to callers:

if err != nil {
    s.cfg.ErrorReporter.ReportError(err)
    s.log.ErrorS(ctx, "Unable to apply event", err)
    go s.Stop()  // Non-blocking
    return
}

Recommendation: Consider providing a way for callers to be notified of fatal errors, or document this behavior clearly.

4. Missing Nil Checks (Low Priority)

File: baselib/protofsm/state_machine.go:460

s.cfg.ErrorReporter.ReportError(err)  // ErrorReporter could be nil

Recommendation: Add nil check: if s.cfg.ErrorReporter != nil { s.cfg.ErrorReporter.ReportError(err) }

5. Unbounded Memory Growth Risk (Medium Priority)

File: baselib/protofsm/state_machine.go:365-370

The outbox slice accumulates all outbox events during event processing without bounds:

var outbox []OutboxEvent  // Line 369
// ... later in loop
outbox = append(outbox, events.Outbox...)  // Line 405

For long-running event chains or state machines with many internal events, this could consume significant memory.

Recommendation: Consider adding a maximum outbox size or processing outbox events in batches.

6. Context Usage Inconsistency (Low Priority)

File: baselib/actor/actor.go:142-144

When sending to DLO during shutdown, uses context.Background():

a.dlo.Tell(context.Background(), env.message)

This means DLO Tell operations could block indefinitely if the DLO actor is overwhelmed.

Recommendation: Use a timeout context or document this behavior.

🔧 Style & Convention Adherence

✅ Follows CLAUDE.md Guidelines:

  • Proper use of 8-space tabs (verified in .editorconfig)
  • Function comments start with function name
  • Structured logging would be applicable once integrated with btclog
  • Good code organization with logical stanzas

⚠️ Minor Style Issues:

  1. Some lines exceed 80 characters (e.g., baselib/actor/actor.go:181,209)
  2. A few functions lack detailed "how/why" comments (mostly behavior implementations)

🔒 Security Considerations

✅ No Critical Security Issues Found:

  • No SQL injection vectors (no SQL in this code)
  • No command injection risks
  • Proper context cancellation handling
  • No exposed credentials or secrets

⚠️ Potential DoS Vectors:

  1. Mailbox Flooding: An attacker with actor references could flood mailboxes. Consider rate limiting or bounded queues with overflow policies.
  2. Memory Exhaustion: The unbounded outbox accumulation (issue harness: basic infrastructure test environment #5 above) could be exploited.

📊 Performance Considerations

Positive:

  • Efficient message passing with buffered channels
  • Non-blocking Tell operations
  • Good use of goroutine pools

Potential Improvements:

  1. Actor Pool Recycling: Consider object pooling for frequently created/destroyed actors
  2. Mailbox Tuning: Default capacity of 100 (system.go:27) may need tuning for high-throughput scenarios
  3. Router Optimization: Round-robin router creates a new router on each dispatch (actor_wrapper.go:83) - consider caching

🧪 Testing Recommendations

While test coverage is good, consider adding:

  1. Stress tests: Many actors with high message volume
  2. Shutdown tests: Verify no goroutine leaks under various shutdown scenarios
  3. Error injection tests: Simulate DLO failures, mailbox full scenarios
  4. Property-based tests: Use rapid for FSM invariants (as suggested in CLAUDE.md)

📝 Documentation Suggestions

  1. Add godoc examples for common patterns (Actor creation, FSM usage)
  2. Document mailbox capacity tuning guidelines
  3. Add architecture diagram showing Actor ↔ FSM ↔ Outbox flow
  4. Document performance characteristics and recommended limits

✅ Approval Recommendation

This PR is approved with minor suggestions. The identified issues are manageable and don't block merging:

Must Fix Before Merge: None

Should Fix Soon: Issues #1, #2, #4, #5

Nice to Have: Issues #3, #6, style issues

🎯 Summary

This is a high-quality implementation of the actor model and FSM framework. The code demonstrates:

  • Strong understanding of concurrency patterns
  • Excellent documentation and examples
  • Thoughtful API design with type safety
  • Good test coverage

The issues identified are typical for concurrent systems and can be addressed iteratively. The architecture provides a solid foundation for building restart-safe, event-driven workflows.

Great work! 🎉


Reviewed according to guidelines in docs/development_guidelines.md and CLAUDE.md

@bhandras bhandras left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

@claude

claude Bot commented Nov 20, 2025

Copy link
Copy Markdown

Pull Request Review: baselib - protofsm+actor Implementation

Overview

This PR introduces a comprehensive actor model implementation and finite state machine (FSM) framework for Go. The implementation is well-architected and provides strong abstractions for concurrent, event-driven workflows. Overall, this is high-quality work with excellent documentation and test coverage.

Strengths

1. Excellent Documentation

  • The PROTOFSM_ACTOR_GUIDE.md (870 lines) and actor/README.md (475 lines) are exceptional
  • Clear examples demonstrating usage patterns
  • Comprehensive troubleshooting section
  • Well-documented code with literate programming style

2. Strong Type Safety

  • Extensive use of generics for type-safe message passing
  • Sealed interfaces pattern prevents type confusion
  • Service keys provide type-safe actor discovery

3. Good Test Coverage

  • Comprehensive test suites for both actor and protofsm packages
  • Unit tests, integration tests, and example tests
  • Test harnesses for common testing patterns

4. Clean Architecture

  • Clear separation of concerns (FSM logic vs side effects)
  • Location transparency via service keys
  • Proper lifecycle management

Code Quality Issues

CRITICAL: Structured Logging Violations

Per CLAUDE.md, you MUST use structured log methods ending in S with static messages. Several violations found:

baselib/protofsm/state_machine.go:222

s.log.Debugf("Sending event %T", event)  // ❌ WRONG

Should be:

s.log.DebugS(ctx, "Sending event",
	btclog.Fmt("event_type", "%T", event))

baselib/protofsm/state_machine.go:243

s.log.Debugf("Asking event %T", event)  // ❌ WRONG

Should be:

s.log.DebugS(ctx, "Asking event",
	btclog.Fmt("event_type", "%T", event))

Action Required: Convert all Debugf, Infof, Warnf, Errorf calls to structured equivalents (DebugS, InfoS, WarnS, ErrorS) with proper key-value pairs.

HIGH: Error Handling and Logging Levels

Issue 1: Incorrect Error Log Level

File: baselib/protofsm/state_machine.go:462-463

s.cfg.ErrorReporter.ReportError(err)
s.log.ErrorS(ctx, "Unable to apply event", err)

Problem: Per CLAUDE.md, only use error level for internal errors never expected during normal operation. If a state transition can fail due to external triggers (bad input, RPC failures, etc.), this should be warn or info level.

Recommendation: Review whether state transition failures are truly "internal errors" or could be triggered by external conditions. If the latter, use lower log levels.

Issue 2: Missing Context Propagation

The state machine uses context.Background() in several places:

File: baselib/actor/actor.go:142

a.dlo.Tell(context.Background(), env.message)

Problem: Using context.Background() loses tracing/cancellation context. Consider passing the actor's context or a detached context with trace IDs preserved.

MEDIUM: Potential Resource Leaks

Issue 1: Goroutine Leak in Future.OnComplete

File: baselib/actor/future.go:143-151

func (f *futureImpl[T]) OnComplete(ctx context.Context, cFunc func(fn.Result[T])) {
	go func() {
		result := f.Await(ctx)
		cFunc(result)
	}()
}

Problem: If the context is cancelled before the future completes, the goroutine blocks indefinitely on f.Await(ctx). While it will eventually return when the future completes, this could be a long wait.

Recommendation: Document this behavior or consider adding a timeout mechanism.

MEDIUM: Race Conditions

Issue 1: ActorStateMachine State Access

File: baselib/protofsm/actor_wrapper.go:162-189

The currentState field is accessed without synchronization:

type ActorStateMachine[...] struct {
	sm           *StateMachine[...]
	system       *actor.ActorSystem
	currentState State[...]  // ⚠️ No mutex protection
}

Analysis: This appears safe because:

  • Only accessed in Receive() method (sequential processing)
  • Actor mailbox ensures serial message handling

Recommendation: Add a comment documenting why this is safe without synchronization:

// currentState is only accessed from Receive(), which is called
// sequentially by the actor's message processing loop, so no
// synchronization is needed.
currentState State[InternalEvent, OutboxEvent, Env]

Security Concerns

No Major Security Issues Found

✅ No SQL injection vectors (no raw SQL)
✅ No XSS vectors (server-side only)
✅ No command injection (no shell execution)
✅ No obvious DoS vectors (bounded mailboxes)

Performance Considerations

1. Mailbox Backpressure

The default mailbox size is 100 (or 1 if misconfigured). Consider:

  • Documenting best practices for sizing mailboxes
  • Adding metrics for mailbox utilization

2. Router Actor Discovery

File: baselib/actor/router.go:88-98

Every Tell and Ask call queries the receptionist, acquiring a read lock. For high-throughput scenarios, this could be a bottleneck. Consider documenting this trade-off.

3. Event Loop Protection

File: baselib/protofsm/state_machine.go:365-435

No protection against infinite loops if states emit cycles. Recommendation: Add max iteration counter:

const maxEventIterations = 10000

iterCount := 0
for nextEvent := eventQueue.Dequeue(); nextEvent.IsSome(); nextEvent = eventQueue.Dequeue() {
	iterCount++
	if iterCount > maxEventIterations {
		return currentState, outbox, fmt.Errorf("max event iterations exceeded")
	}
	// ... existing logic
}

Test Coverage Assessment

Strong Coverage Areas

✅ Actor lifecycle (start, stop, restart)
✅ Tell and Ask patterns
✅ Dead Letter Office interactions
✅ Service key registration/unregistration
✅ Router round-robin strategy
✅ Future promise patterns

Missing Test Coverage

  1. Error Injection Tests - What happens when Behavior.Receive() panics?
  2. Concurrent Access Tests - Multiple goroutines sending to the same actor
  3. Property-Based Tests - Per CLAUDE.md, consider using pgregory.net/rapid for:
    • Message delivery order invariants
    • State machine transition validity
    • Router fairness properties

Specific Function Reviews

actor.go:NewActor (lines 73-101)

✅ Good: Validates mailbox capacity
⚠️ Issue: Default mailbox capacity of 1 seems very small. Consider 10-100:

if mailboxCapacity <= 0 {
	mailboxCapacity = 1  // ⚠️ Too small?
}

state_machine.go:applyEvents (lines 355-435)

✅ Good: Processes events until queue is empty
✅ Good: Accumulates outbox events
⚠️ Issue: No protection against infinite loops (see Performance section)

Overall Assessment

Rating: 8.5/10

This is excellent work with strong architecture, comprehensive documentation, and good test coverage. The actor model implementation is production-ready with minor fixes.

Approval Status: Approved with Minor Changes

Required Changes:

  1. Fix structured logging violations (MUST) - state_machine.go:222, 243
  2. Review error log levels (SHOULD) - state_machine.go:462

Suggested Changes:

  1. Add synchronization safety comment to ActorStateMachine.currentState
  2. Add infinite loop protection to applyEvents
  3. Document goroutine behavior in Future.OnComplete
  4. Consider increasing default mailbox capacity from 1 to 10-100
  5. Enhance test coverage with property-based tests using rapid

Commit Message Suggestions

When addressing feedback, follow the commit message format from CLAUDE.md:

baselib/protofsm: fix structured logging violations

Replace Debugf/Infof calls with structured logging methods
(DebugS/InfoS) per project guidelines. This ensures consistent
log formatting and enables better log parsing.
baselib/protofsm: add infinite loop protection

Add max iteration counter to applyEvents to prevent infinite
loops if FSM states emit cyclic internal events. Returns error
after 10k iterations.

Great work on this implementation! The actor model and FSM framework are well-designed and will provide solid foundations for concurrent workflows. 🚀

@sputn1ck
sputn1ck merged commit 3785286 into main Nov 20, 2025
18 checks passed
@sputn1ck
sputn1ck deleted the baselib branch November 20, 2025 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants