Repository navigation
Add overridable finalise_state to Projector - #129
Merged
Merged
Conversation
project() now calls finalise_state once after all events are applied and before id_factory, using its result as the projection state and as the input to id_factory. The default returns the state unchanged, so existing projectors behave as before. This lets projectors apply cheap, unvalidated per-event transitions and do expensive work such as validation once per project() call, instead of overriding project() and rebuilding the Projection by hand. apply() and update_metadata keep seeing unfinalised state, and exceptions raised by finalise_state propagate unwrapped, so ProjectionEventProcessor saves nothing when finalisation fails.
Add a README section with an example and the contract overrides must meet when projections are resumed, plus a changelog fragment.
CorinChappy
approved these changes
Sep 25, 2026
tobyclemson
approved these changes
Sep 25, 2026
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.
Summary
Adds an overridable
finalise_state(state) -> Statemethod toProjector.project()calls it once after all events are applied and beforeid_factory, and uses its result as the projection state. The default returns the state unchanged, so existing projectors behave exactly as before.Motivation
Projectors with large, validated state (e.g. Pydantic models) currently re-validate the whole state in every event handler. In a downstream service this is 26–38% of CPU in production-like profiles. Copying without validation per event and validating once at the end cuts a 76-event rebuild from ~105–125ms to ~3–3.5ms, with identical output.
Today the only way to do this is to override
project(), which means copying its signature and rebuilding theProjectionby hand. That breaks silently ifproject()changes.finalise_stategives a supported hook at the projection boundary, following the existingupdate_metadataprecedent.Changes
Key Changes
Projector.finalise_state(state): new overridable method, identity by default.project()finalises once after the event loop (including for empty sources), then derives the id from, and stores, the finalised state.Implementation Details
apply()andupdate_metadatakeep seeing unfinalised, per-event state. Finalising is a projection-boundary concern.finalise_statepropagate unwrapped, consistent with handler exceptions, soProjectionEventProcessorsaves nothing when finalisation fails.ProjectionEventProcessorneeds no change; it gets the behaviour throughproject().project()is resumable, andProjectionEventProcessoralways passes the previously saved, already finalised state back in. So an override must not change what later handlers,update_metadataorid_factorycompute: formallyf(h(f(s))) == f(h(s)). Idempotency alone isn't enough. Validation and recomputing derived fields are fine; lossy normalisation (clamping, truncation) isn't. This is documented in the README.ProjectionEventProcessor, which projects one event per call, the hook still runs once per processed event. The saving comes from multi-event folds such as rebuilds.metadata/sourceto the hook, an async variant, or wrapping exceptions.Breaking Changes
None. The only behaviour change is for a projector that already defines a member called
finalise_state, or that receives an event namedfinalise-state(handlers are resolved by snake-casing the event name). The name is now reserved onProjectorsubclasses, as noted in the changelog.Migration Guide
No migration needed. Subclasses that currently override
project()to finalise state can remove the override and implementfinalise_stateinstead. If they keep a customproject(), it must callself.finalise_state(state)itself. Adding an override to a projector with stored projections must not change fields thatid_factoryuses; otherwise those projections need rebuilding.How to Verify
Automated Verification
mise run test(unit 1747, integration 134, component 3)mise run types:checkmise run lint:checkmise run format:checkNew unit tests cover:
update_metadataandapply()see unfinalised state;I also broke the implementation on purpose in seven ways: id from unfinalised state, unfinalised state saved, never called, called twice, called in the loop, called in
apply(), called before the loop. Each break made the tests aimed at it fail.Manual Verification
scriv collect(result discarded).Checklist
Related Issues
None. The implementation plan and its review are included under
meta/plans/andmeta/reviews/plans/.