Skip to content

perf(observer)!: refactor observers to narrow interfaces and lazy evaluation#48

Merged
floodfx merged 1 commit into
mainfrom
perf/issue-31-skip-observer-snapshot
May 21, 2026
Merged

perf(observer)!: refactor observers to narrow interfaces and lazy evaluation#48
floodfx merged 1 commit into
mainfrom
perf/issue-31-skip-observer-snapshot

Conversation

@floodfx
Copy link
Copy Markdown
Owner

@floodfx floodfx commented May 21, 2026

Description

This PR refactors the observer subsystem in gstate to use narrow interfaces and introduces lazy event data cloning/evaluation. It addresses performance and design shortcomings from the monolithic Observer interface by ensuring that observers only register and pay for the specific events they care about, and that data payload copies are deferred until explicitly requested.

Fixes #31

Key Features & Design Decisions

  1. Narrow Interfaces:
    The single, monolithic Observer interface has been refactored into nine specialized narrow interfaces:

    • TransitionObserver (for OnTransition)
    • GuardObserver (for OnGuardEvaluation)
    • InvokeStartedObserver (for OnInvokeStarted)
    • InvokeCompletedObserver (for OnInvokeCompleted)
    • StateEnteredObserver (for OnStateEntered)
    • StateExitedObserver (for OnStateExited)
    • ActionObserver (for OnAction)
    • EventReceivedObserver (for OnEventReceived)
    • EventDroppedObserver (for OnEventDropped)
  2. Sealed Base & Marker Pattern:
    The base Observer interface is now a sealed marker interface containing an unexported method isObserver(). Users embed the new BaseObserver struct in their custom observers, allowing them to implement only the narrow interfaces they need.

  3. Lazy Data Cloning:
    Instead of eagerly deep-copying/cloning the actor data context for every single event hook, the data is lazily cloned only when the observer invokes the Data() method on the event payload pointer. This cloning is memoized using sync.Once and cached in an unexported field on the event.
    Multiple observers registered for the same event share the same event pointer, which guarantees that at most one clone is ever performed per lifecycle callback, regardless of how many observers subscribe to it.

  4. Variadic Configuration:
    The obsolete WithObserver and MultiObserver APIs are removed. They are replaced by a variadic WithObservers(obs ...Observer) actor configuration option. At initialization, installObservers automatically registers and partitions the provided observers into typed narrow slices within the Actor.


⚠ Breaking Changes

  1. Embedding BaseObserver:
    Custom observers must now embed gstate.BaseObserver[S, E, D].
    Before:

    type MyObserver struct{}
    // required implementing all 9 methods

    After:

    type MyObserver struct {
        gstate.BaseObserver[State, Event, Data]
    }
    func (o *MyObserver) OnTransition(ctx context.Context, ev *gstate.TransitionEvent[State, Event, Data]) {
        // Only implement what you need!
    }
  2. Removal of NopObserver:
    NopObserver is removed. Migrate existing code by simply embedding BaseObserver.

  3. Pointer Receivers for Event Payloads:
    All observer methods now accept pointer types (e.g. *TransitionEvent instead of TransitionEvent).

  4. Event Data Field Removed:
    The direct field payload.Data has been replaced by payload.Data().
    Before:

    log.Printf("State: %v", ev.Data)

    After:

    log.Printf("State: %v", ev.Data())

    Custom JSON marshaling is implemented to preserve the original JSON structure.

  5. Option API rename:
    WithObserver and MultiObserver are removed. Use WithObservers(obs ...Observer).


Performance Improvements

On Apple M1 Pro, the baseline allocation overhead dropped dramatically:

  • SendTransition_TrulyNoObserver (No Observers): ~220 allocs/op (~8,060 B/op) — down from ~632 allocs/op with a transition observer. This represents a ~65% reduction in allocation overhead for the pure hot path.
  • Shared Data Evaluation: Sharing Data() clones via sync.Once ensures that registering multiple observers to the same event doesn't trigger redundant clones.

Detailed local benchmark numbers (from BENCHMARKS.md):

  • SendTransition_TrulyNoObserver: 64,000 ns/op | 8,060 B/op | 220 allocs/op
  • SendTransition_TransitionOnlyObserver: 86,000 ns/op | 54,920 B/op | 632 allocs/op
  • SendTransition_ThreeTransitionObservers: 95,000 ns/op | 55,976 B/op | 652 allocs/op
  • SendTransition_BaseObserver: 87,000 ns/op | 54,936 B/op | 632 allocs/op

Note: Benchmarks include the actor lifecycle shutdown (actor.Stop()) teardown cost.


Verification & Testing

  • Tests Migrated: All existing tests and examples have been updated to target BaseObserver and Data() getters.
  • Lazy Evaluation Verification: A new test suite observer_lazy_test.go deterministic-ly verifies exact clone counts and ensures sharing works correctly.
  • CI green: Executed just ci successfully running build, lints, vulnerability scans, race detectors, and fuzzers (FuzzHydrate, FuzzBuilder, FuzzEventSequence).

@floodfx floodfx changed the title perf(observer): refactor observers to narrow interfaces and lazy evaluation perf(observer)!: refactor observers to narrow interfaces and lazy evaluation May 21, 2026
@floodfx floodfx merged commit 3a7f868 into main May 21, 2026
1 check passed
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.

Skip contextSnapshotPtr when observer is a no-op

1 participant