Skip to content

Implement JasperFx's IDocumentCommitListener (jasperfx#679) - #5261

Merged
jeremydmiller merged 1 commit into
masterfrom
cw/jasperfx-679-document-commit-listener
Aug 18, 2026
Merged

Implement JasperFx's IDocumentCommitListener (jasperfx#679)#5261
jeremydmiller merged 1 commit into
masterfrom
cw/jasperfx-679-document-commit-listener

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Bumps the JasperFx line to 2.52.0 and implements IDocumentCommitListener / IDocumentChangeSet / IDocumentDeletion — the store-agnostic post-commit session hook from jasperfx#679 / PR #680 — so a listener written once works unchanged on Marten, Polecat and Fisher.

Adapter, not inheritance — and the compiler forces it

src/Marten/Services/DocumentCommitListenerAdapter.cs is a new file holding three internal types. None of the contracts could be bolted onto an existing Marten type:

contract member Marten's existing member satisfies?
IReadOnlyList<object> Inserted IEnumerable<object> IChangeSet.Inserted no
IReadOnlyList<object> Updated IEnumerable<object> IChangeSet.Updated no
IReadOnlyList<IDocumentDeletion> Deleted IEnumerable<Weasel.Storage.IDeletion> IChangeSet.Deleted no
AfterCommitAsync(IDocumentSessionOperations, IDocumentChangeSet, CancellationToken) AfterCommitAsync(IDocumentSession, IChangeSet, CancellationToken) no — 3 of 4 positions differ

Putting IDocumentChangeSet on IChangeSet would be a breaking change to every existing Marten listener; putting it on ISessionWorkTracker would additionally rope in ProjectionUpdateBatch, whose Inserted/Updated/Deleted all throw NotSupportedException.

Worth stating explicitly, because it differs from #669 and #673: no member of this contract has a default implementation, so a near-miss here is CS0535 at build time rather than a silent bind to a throwing default. The trap that remains is the wiring — a store that declares the interfaces perfectly and never invokes the listener compiles clean and passes every other suite. That is what the compliance suite catches.

The snapshot is load-bearing, and it is proven

MartenDocumentChangeSet materialises all three collections in its constructor. Marten's IChangeSet is the session's live UnitOfWork: its members are lazy LINQ chains re-walked over _operations on every read, and DocumentSessionBase.SaveChangesAsync calls _workTracker.Reset() immediately after the listener loop.

Verified with a negative control rather than asserted — a lazy forward (=> inner.Inserted.ToArray() on the property) fails 4 of the 10 compliance facts:

  • the_change_set_survives_the_session_moving_on
  • the_change_set_carries_the_written_document
  • each_commit_raises_its_own_callback
  • a_document_written_twice_is_reported_by_both_commits

Ancillary-store decision: PRIMARY STORE ONLY, mirroring IInitialData

The sweep in AddMarten() sits directly beside options.InitialData.AddRange(s.GetServices<IInitialData>()) and behaves identically: the main store only.

A bare GetServices<IDocumentCommitListener>() from the StoreOptions factory cannot tell which store a registration was meant for, so sweeping it onto ancillary stores as well would attach every listener in the application to every AddMartenStore<T>(), silently and with no way to opt one out. Ancillary stores opt in explicitly through the existing IConfigureMarten<T> seam:

services.ConfigureMarten<IOrdersStore>(opts => opts.AddCommitListener(listener));

StoreOptions.AddCommitListener(IDocumentCommitListener) is the one new public API — the adapter itself stays internal, since it is a detail of the bridge rather than something a user should construct. It is also what the compliance fixture uses to replay DocumentComplianceConfig.CommitListeners.

Both halves are pinned by tests, including the_sweep_does_NOT_reach_an_ancillary_store — if that ever goes green the other way round it is a behaviour change for every multi-store application, not a bug fix.

Tests

  • DocumentCommitListenerCompliance — 10/10 passing, enrolled as document_commit_listener_compliance in the existing DocumentComplianceCollection.
  • All document + event compliance suites: 319/319 passing.
  • 5 new Marten-side DI facts in src/CoreTests/registering_document_commit_listeners.cs, covering what the compliance suite structurally cannot: MartenDocumentComplianceFixture builds a bare new StoreOptions() and replays listeners by hand, so no container is involved and the AddMarten sweep is exercised by nothing over there. They cover the sweep, multiple registrations (GetServices vs GetService), the ancillary boundary in both directions, and deletion descriptors end-to-end.
  • CoreTests: 535 passed / 5 failed. Those 5 failures reproduce identically on clean origin/master on this machine (530 passed / 5 failed — the delta is exactly the 5 tests this PR adds). They are DDL/advisory-lock contention in the shared test database (Unable to attain a global lock, constraint "fkey_mt_events_stream_id" ... already exists) and are unrelated to this change: Bug_4185_codegen_conflict_projection_with_secondary_store_dependency, both Bug_4187_ancillary_store_isolation facts, rolling_range_partitioning.the_host_startup_pass_..., jasper_fx_mechanics.divergent_application_assembly_reuse_warning_is_buffered_and_logged.
  • DocumentDbTests SessionMechanics: 96/96 passing — the existing listener pipeline is untouched.

Also in here

  • Directory.Packages.props — 2.52.0 across the five JasperFx pins, with the inline changelog comment extended per house convention (including why this bump's trap is not the 2.50.0/2.51.0 trap).
  • docs/diagnostics.md — a new "Store-agnostic commit listeners" section under Listening for Document Store Events, with three compiled mdsnippets samples from src/CoreTests/Examples/DocumentCommitListenerSamples.cs. Verified snippet-stable (a second mdsnippets run is a no-op on this file). I deliberately reverted the ~28 unrelated files mdsnippets re-synced from pre-existing drift on master, so this PR carries only diagnostics.md.

⚠️ The docs badge says 9.28 on the assumption the next release is a minor bump; Directory.Build.props is left at 9.27.0 since releases are yours.

Notes against the plan

  • UnitOfWork lives at src/Marten/Internal/UnitOfWork.cs, not src/Marten/Services/; the IChangeSet members are at lines 184–190 there, as described.
  • Marten's IDocumentSession already derives from JasperFx.Events.Documents.IDocumentSessionOperations (Implement the JasperFx persistence abstractions + reconcile Wolverine.Marten drift (wolverine#3907) #5216), so the session crosses the adapter as-is with no wrapper.
  • Weasel.Storage.IDeletion declares object Id (non-nullable) and inherits Type DocumentType from Weasel.Core.IStorageOperation — structurally a match for IDocumentDeletion, but C# has no structural implementation and putting a JasperFx interface on a Weasel type would make Weasel depend on JasperFx.Events. Hence MartenDocumentDeletion.
  • Marten short-circuits an empty unit of work (SaveChangesAsync returns early when !HasOutstandingWork()), so it does not fire for an empty commit. The contract explicitly permits either answer and the suite does not assert it — noting it because the XML docs said Marten's behaviour here "was never stated".

Bumps the JasperFx line to 2.52.0 and adopts IDocumentCommitListener /
IDocumentChangeSet / IDocumentDeletion, the store-agnostic post-commit
SESSION hook, so a listener written once works on Marten, Polecat and
Fisher alike.

Adopted as an ADAPTER rather than by widening Marten's own types, and that
is forced by the compiler rather than chosen:

  * IChangeSet.Inserted/Updated are IEnumerable<object>, which does not
    satisfy the contract's IReadOnlyList<object>
  * IChangeSet.Deleted is IEnumerable<Weasel.Storage.IDeletion>, which
    does not satisfy IReadOnlyList<IDocumentDeletion>
  * DocumentSessionListenerBase.AfterCommitAsync differs from the
    contract's signature in three of its four positions

Putting IDocumentChangeSet on IChangeSet would therefore be a breaking
change to every existing Marten listener, and putting it on
ISessionWorkTracker would additionally rope in ProjectionUpdateBatch,
whose Inserted/Updated/Deleted all throw NotSupportedException.

MartenDocumentChangeSet materialises the three collections in its
constructor. That is load-bearing, not defensive: Marten's IChangeSet IS
the session's live UnitOfWork, its members are lazy LINQ chains over
_operations, and SaveChangesAsync resets it immediately after the listener
loop. Proven by a negative control -- a lazy forward fails four of the ten
compliance facts.

Registration mirrors IInitialData exactly. AddMarten() sweeps
IDocumentCommitListener out of the container onto the MAIN store only;
ancillary stores opt in with
ConfigureMarten<T>(opts => opts.AddCommitListener(listener)). A bare sweep
cannot tell which store a registration was meant for, so applying it to
every AddMartenStore<T> would attach every listener to every store with no
way to opt one out.

Pinned by DocumentCommitListenerCompliance (10/10) plus five Marten-side
DI facts, which cover what the compliance fixture structurally cannot: it
builds a bare StoreOptions with no container, so the AddMarten sweep is
exercised by nothing over there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mpctjngqnraWVnDaqWtYf
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.

1 participant