Implement JasperFx's IDocumentCommitListener (jasperfx#679) - #5261
Merged
Conversation
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
This was referenced Aug 20, 2026
Closed
This was referenced Aug 27, 2026
Open
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.
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.csis a new file holding three internal types. None of the contracts could be bolted onto an existing Marten type:IReadOnlyList<object> InsertedIEnumerable<object> IChangeSet.InsertedIReadOnlyList<object> UpdatedIEnumerable<object> IChangeSet.UpdatedIReadOnlyList<IDocumentDeletion> DeletedIEnumerable<Weasel.Storage.IDeletion> IChangeSet.DeletedAfterCommitAsync(IDocumentSessionOperations, IDocumentChangeSet, CancellationToken)AfterCommitAsync(IDocumentSession, IChangeSet, CancellationToken)Putting
IDocumentChangeSetonIChangeSetwould be a breaking change to every existing Marten listener; putting it onISessionWorkTrackerwould additionally rope inProjectionUpdateBatch, whoseInserted/Updated/Deletedall throwNotSupportedException.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
MartenDocumentChangeSetmaterialises all three collections in its constructor. Marten'sIChangeSetis the session's liveUnitOfWork: its members are lazy LINQ chains re-walked over_operationson every read, andDocumentSessionBase.SaveChangesAsynccalls_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_onthe_change_set_carries_the_written_documenteach_commit_raises_its_own_callbacka_document_written_twice_is_reported_by_both_commitsAncillary-store decision: PRIMARY STORE ONLY, mirroring
IInitialDataThe sweep in
AddMarten()sits directly besideoptions.InitialData.AddRange(s.GetServices<IInitialData>())and behaves identically: the main store only.A bare
GetServices<IDocumentCommitListener>()from theStoreOptionsfactory 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 everyAddMartenStore<T>(), silently and with no way to opt one out. Ancillary stores opt in explicitly through the existingIConfigureMarten<T>seam: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 replayDocumentComplianceConfig.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 asdocument_commit_listener_compliancein the existingDocumentComplianceCollection.src/CoreTests/registering_document_commit_listeners.cs, covering what the compliance suite structurally cannot:MartenDocumentComplianceFixturebuilds a barenew StoreOptions()and replays listeners by hand, so no container is involved and theAddMartensweep is exercised by nothing over there. They cover the sweep, multiple registrations (GetServicesvsGetService), the ancillary boundary in both directions, and deletion descriptors end-to-end.origin/masteron 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, bothBug_4187_ancillary_store_isolationfacts,rolling_range_partitioning.the_host_startup_pass_...,jasper_fx_mechanics.divergent_application_assembly_reuse_warning_is_buffered_and_logged.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 fromsrc/CoreTests/Examples/DocumentCommitListenerSamples.cs. Verified snippet-stable (a secondmdsnippetsrun is a no-op on this file). I deliberately reverted the ~28 unrelated filesmdsnippetsre-synced from pre-existing drift on master, so this PR carries onlydiagnostics.md.9.28on the assumption the next release is a minor bump;Directory.Build.propsis left at9.27.0since releases are yours.Notes against the plan
UnitOfWorklives atsrc/Marten/Internal/UnitOfWork.cs, notsrc/Marten/Services/; theIChangeSetmembers are at lines 184–190 there, as described.IDocumentSessionalready derives fromJasperFx.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.IDeletiondeclaresobject Id(non-nullable) and inheritsType DocumentTypefromWeasel.Core.IStorageOperation— structurally a match forIDocumentDeletion, but C# has no structural implementation and putting a JasperFx interface on a Weasel type would make Weasel depend on JasperFx.Events. HenceMartenDocumentDeletion.SaveChangesAsyncreturns 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".