perf(observer)!: refactor observers to narrow interfaces and lazy evaluation#48
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR refactors the observer subsystem in
gstateto use narrow interfaces and introduces lazy event data cloning/evaluation. It addresses performance and design shortcomings from the monolithicObserverinterface 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
Narrow Interfaces:
The single, monolithic
Observerinterface has been refactored into nine specialized narrow interfaces:TransitionObserver(forOnTransition)GuardObserver(forOnGuardEvaluation)InvokeStartedObserver(forOnInvokeStarted)InvokeCompletedObserver(forOnInvokeCompleted)StateEnteredObserver(forOnStateEntered)StateExitedObserver(forOnStateExited)ActionObserver(forOnAction)EventReceivedObserver(forOnEventReceived)EventDroppedObserver(forOnEventDropped)Sealed Base & Marker Pattern:
The base
Observerinterface is now a sealed marker interface containing an unexported methodisObserver(). Users embed the newBaseObserverstruct in their custom observers, allowing them to implement only the narrow interfaces they need.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 usingsync.Onceand 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.
Variadic Configuration:
The obsolete
WithObserverandMultiObserverAPIs are removed. They are replaced by a variadicWithObservers(obs ...Observer)actor configuration option. At initialization,installObserversautomatically registers and partitions the provided observers into typed narrow slices within theActor.⚠ Breaking Changes
Embedding
BaseObserver:Custom observers must now embed
gstate.BaseObserver[S, E, D].Before:
After:
Removal of
NopObserver:NopObserveris removed. Migrate existing code by simply embeddingBaseObserver.Pointer Receivers for Event Payloads:
All observer methods now accept pointer types (e.g.
*TransitionEventinstead ofTransitionEvent).Event Data Field Removed:
The direct field
payload.Datahas been replaced bypayload.Data().Before:
After:
Custom JSON marshaling is implemented to preserve the original JSON structure.
Option API rename:
WithObserverandMultiObserverare removed. UseWithObservers(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.Data()clones viasync.Onceensures 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/opSendTransition_TransitionOnlyObserver:86,000 ns/op | 54,920 B/op | 632 allocs/opSendTransition_ThreeTransitionObservers:95,000 ns/op | 55,976 B/op | 652 allocs/opSendTransition_BaseObserver:87,000 ns/op | 54,936 B/op | 632 allocs/opNote: Benchmarks include the actor lifecycle shutdown (
actor.Stop()) teardown cost.Verification & Testing
BaseObserverandData()getters.observer_lazy_test.godeterministic-ly verifies exact clone counts and ensures sharing works correctly.just cisuccessfully running build, lints, vulnerability scans, race detectors, and fuzzers (FuzzHydrate,FuzzBuilder,FuzzEventSequence).