Skip to content

React to read model changes with IReadModelReactor#3402

Open
woksin wants to merge 6 commits into
mainfrom
issue-3359-read-model-watchers
Open

React to read model changes with IReadModelReactor#3402
woksin wants to merge 6 commits into
mainfrom
issue-3359-read-model-watchers

Conversation

@woksin

@woksin woksin commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

An IReadModelReactor reacts to a read model being added, modified or removed using convention-based handler methods — a convenience layer over the Watch APIs so you write a class instead of subscribing to an observable.

One nuance worth knowing: projection-backed read models get a precise, server-provided change type and the causing event's context; reducer-backed and [Materialized] read models infer the change type client-side (first sighting of a key → Added, thereafter Modified) and carry only the model key. Read-model-reactor side effects are best-effort (a read model reactor has no partition to fail), so design handlers to be idempotent.

Scope note: this PR previously sat at the top of a reactor stack. The reactor side-effect-append work (#3381) and reactor handler dependencies (#3358) shipped separately via #3383 and #3401 and are already in main. This PR is now scoped to the read-model reactor only.

Added

  • React to read model changes with IReadModelReactor: methods named Added, Modified or Removed are invoked by convention with the changed read model (a single instance or a collection), an optional EventContext, and services, and may return events as side effects. (React to read model changes. #3359)
  • [Materialized] opts a read model into materialized change tracking, deducing the change type by comparing successive materialized windows. (React to read model changes. #3359)

Changed

Known issue

  • The for_ReadModelReactors integration test fails only on the outofprocess + postgresql matrix cell (passes on mongodb/mssql/sqlite and locally). The read model materializes correctly but the live changeset notification is not delivered to the watcher in that configuration. Under investigation; see the diagnosis notes for details.

@woksin woksin added the minor label Jun 16, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark 'Cratis Benchmarks'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.

Benchmark suite Current: f41bb6e Previous: 9c41e1c Ratio
Cratis.Chronicle.Benchmarks.AppendManyBenchmark.AppendManyEvents(EventCount: 100) 225486424.65 ns (± 28388657.350109357) 68128824.7 ns (± 7671149.609764319) 3.31
Cratis.Chronicle.Benchmarks.AppendManyBenchmark.AppendManyEvents(EventCount: 1000) 1152342682.4 ns (± 106158111.52006766) 587439487.4 ns (± 115163188.51672608) 1.96

This comment was automatically generated by workflow using github-action-benchmark.

CC: @einari

@woksin woksin changed the base branch from copilot/reactor-side-effects-honor-append-result to issue-3358-reactor-read-model-dependencies June 19, 2026 11:31
@woksin woksin force-pushed the issue-3359-read-model-watchers branch from b6880a0 to 32cf03a Compare June 19, 2026 11:31
@woksin woksin changed the base branch from issue-3358-reactor-read-model-dependencies to main June 19, 2026 11:34
@woksin woksin changed the base branch from main to issue-3358-reactor-read-model-dependencies June 19, 2026 11:34
woksin and others added 3 commits June 19, 2026 13:36
Projections now record whether a change added, modified or removed a read
model instance, and carry the sequence number, occurrence time and
correlation id of the event that caused the change. The gRPC
ReadModelChangeset contract is extended to convey this downstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a convention-based IReadModelReactor whose Added, Modified and
Removed methods are dispatched by name when a materialized read model
changes. The first parameter is the read model (single or collection);
further parameters resolve the EventContext and services, and methods may
return side-effect events like a reactor. A [Materialized] attribute opts
a read model into materialized change tracking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@woksin woksin force-pushed the issue-3359-read-model-watchers branch from 32cf03a to 66ac026 Compare June 19, 2026 11:36
woksin added 2 commits June 19, 2026 20:38
# Conflicts:
#	.ai/rules/reactors.md
#	Directory.Packages.props
#	Documentation/statistics/coverage-data.js
Base automatically changed from issue-3358-reactor-read-model-dependencies to copilot/reactor-side-effects-honor-append-result June 20, 2026 00:56
@woksin woksin changed the base branch from copilot/reactor-side-effects-honor-append-result to main June 20, 2026 01:56
Read model reactors lived only in the read-models section. Add a pointer
from the reactors overview and link the side-effects guidance both ways so
readers browsing reactors can discover them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment on lines +83 to +89
foreach (var name in _keyPropertyNames)
{
if (obj.TryGetPropertyValue(name, out var value) && value is not null)
{
return value.ToString();
}
}
Comment on lines +154 to +157
catch (Exception ex)
{
logger.FailedDispatchingReadModelChange(reactorType.Name, ex);
}
@woksin

woksin commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Reacting to reducer-backed read models — fidelity concern (cc @einari)

This PR supports read-model reactors over reducer-backed read models as well as projection-backed ones. Flagging a semantic gap in the reducer path before we lean on it.

In ReadModelReactors.Subscribe<TReadModel>, projection-backed models get the precise change type (and the causing event's context) from the server, but reducer-backed models infer Added vs Modified from an in-memory set:

// Projections report the change type from the server; reducers compute changesets locally without one,
// so first-seen keys are tracked client-side to distinguish an addition from a modification.
var seenKeys = eventStore.Reducers.HasFor<TReadModel>()
    ? new HashSet<string>(StringComparer.Ordinal)
    : null;

seenKeys is created fresh on every Start()including reconnects, which are routine — and Watch is a live stream (no replay on subscribe). So after a reconnect, the first change to a pre-existing instance is reported as Added instead of Modified. Combined with read-model-reactor side effects being best-effort / fire-and-forget (a read model reactor "has no partition to fail"), the canonical "do X once when an instance is created" use case becomes unsafe for reducer-backed models — e.g. a welcome-email reactor would re-fire for existing instances after a reconnect.

Removed is reliable (server flag) and projection-backed read models are unaffected (exact change type from the server).

Options:

  1. Make it reliable — have the sink surface insert-vs-update for reducer-materialized writes (it already tracks this via __lastHandledEventSequenceNumber / upsert) so the change type doesn't depend on client memory. Then reducers are as trustworthy as projections.
  2. Gate / warn — opt-in or a startup warning for reducer-backed read-model reactors, beyond the doc note in reacting-to-changes.md.
  3. Restrict to projections — fail fast at registration for reducer-backed models and point reducer users at event reactors (full context + [OnceOnly] for exactly-once), which is the canonical answer for a reducer's inputs anyway.

@einari — do we want reducer-backed read-model reactors at all, and if so which direction do you prefer? Happy to implement whichever.

Comment thread Integration/Client/ChronicleConfigurableFixture.cs Fixed
@woksin woksin force-pushed the issue-3359-read-model-watchers branch from 6d9fb27 to 4eb7c19 Compare June 21, 2026 10:00
Comment on lines +57 to +60
catch (Exception ex)
{
snapshot = $"<threw {ex.GetType().Name}: {ex.Message}>";
}
Comment on lines +67 to +70
catch (Exception ex)
{
projState = $"<threw {ex.GetType().Name}: {ex.Message}>";
}
@woksin woksin force-pushed the issue-3359-read-model-watchers branch from e3e6b94 to f41bb6e Compare June 21, 2026 20:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant