Skip to content

Add overridable finalise_state to Projector - #129

Merged
jonassvalin merged 3 commits into
mainfrom
projector-finalise-state
Sep 28, 2026
Merged

jonassvalin merged 3 commits into
mainfrom
projector-finalise-state

Conversation

@jonassvalin

Copy link
Copy Markdown
Contributor

Summary

Adds an overridable finalise_state(state) -> State method to Projector. project() calls it once after all events are applied and before id_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 the Projection by hand. That breaks silently if project() changes. finalise_state gives a supported hook at the projection boundary, following the existing update_metadata precedent.

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.
  • README section with an example and the contract overrides must meet.
  • Changelog fragment.

Implementation Details

  • apply() and update_metadata keep seeing unfinalised, per-event state. Finalising is a projection-boundary concern.
  • Exceptions from finalise_state propagate unwrapped, consistent with handler exceptions, so ProjectionEventProcessor saves nothing when finalisation fails.
  • ProjectionEventProcessor needs no change; it gets the behaviour through project().
  • Contract for overrides: project() is resumable, and ProjectionEventProcessor always passes the previously saved, already finalised state back in. So an override must not change what later handlers, update_metadata or id_factory compute: formally f(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.
  • With 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.
  • Deliberately not included: passing metadata/source to 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 named finalise-state (handlers are resolved by snake-casing the event name). The name is now reserved on Projector subclasses, as noted in the changelog.

Migration Guide

No migration needed. Subclasses that currently override project() to finalise state can remove the override and implement finalise_state instead. If they keep a custom project(), it must call self.finalise_state(state) itself. Adding an override to a projector with stored projections must not change fields that id_factory uses; otherwise those projections need rebuilding.

How to Verify

Automated Verification

  • All tests pass: mise run test (unit 1747, integration 134, component 3)
  • Type checking passes: mise run types:check
  • Linting passes: mise run lint:check
  • Formatting is correct: mise run format:check

New unit tests cover:

  • finalised state becomes the projection state, finalised exactly once after the last event;
  • finalisation on empty sources, for both initial-state paths;
  • the id derived from finalised state;
  • update_metadata and apply() see unfinalised state;
  • default behaviour unchanged;
  • resuming a projection equals a single fold;
  • exceptions propagate;
  • the processor saves finalised state for new and existing projections, and saves nothing when finalisation raises.

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

  • Ran the README example against the implementation. It strips the email and raises for an empty source.
  • Checked the changelog fragment assembles cleanly with scriv collect (result discarded).

Checklist

  • I have read the contributing guidelines
  • I have added/updated tests for my changes
  • I have updated documentation as needed
  • I have added a changelog fragment (if user-facing changes)
  • Breaking changes are documented with migration guidance

Related Issues

None. The implementation plan and its review are included under meta/plans/ and meta/reviews/plans/.

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.
@jonassvalin
jonassvalin merged commit 73ebbb3 into main Sep 28, 2026
3 checks 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.

3 participants