feat: pluggable binary event serialization (IEventBinarySerializer) (#388) - #402
Merged
Merged
Conversation
…388) Parity with Marten's IEventBinarySerializer (marten#4515, shipped 9.20.2). CritterWatch ships Marten/PostgreSQL and Polecat/SQL Server flavors from one source tree, and CritterWatch#896 has already opted its highest-volume internal event into binary on the Marten side — measured at field scale as -97.9% on the wire, -60.4% on disk, -73.4% append p50. Until now the SQL Server flavor kept paying the JSON cost the Marten one shed. The API mirrors Marten's exactly so a store-agnostic consumer can wire either: opts.Events.UseBinarySerializer<TEvent>(serializer); // explicit, per event type [BinaryEvent] + opts.Events.DefaultBinarySerializer; // attribute-driven The part worth copying is the coexistence design, and that is what this follows: an additive nullable `bdata varbinary(max)` column beside `data`, with `bdata IS NULL` as the per-ROW discriminator. JSON and binary rows live in the same pc_events table, so the feature switches on for an existing store with no migration of existing event data and switches back off just as safely. The column is added unconditionally (not only when a serializer is configured) so the read projection has one fixed shape; Weasel's additive delta adds it on the next schema apply, and pre-existing rows read through the JSON path untouched. Quick mode was in scope from the start rather than as a phase 2 — Polecat is QuickAppend-only, so there is no Rich-mode split to sequence and the CritterWatch store's configuration is covered by the first pass. Notable implementation points: - Every read path dispatches, not just the main one. Polecat has four independent readers over pc_events (PcEventsRowReader, the two DCB tag-query readers in EventOperations, the LINQ EventListHandler, and the daemon's PolecatEventLoader), each with its own SELECT and its own deserialization. bdata is pinned at ordinal 10 — after the previously-locked 0-9 block, before the optional metadata columns, which shift to 11 — so the dispatch reads a stable ordinal everywhere. The two EventOperations projections that duplicated the canonical column list by hand now compose it from PcEventsRowReader instead; three hand-written copies of one list is how they stop agreeing. - Masking rewrites bdata, not just data. OverwriteEventOperation (ApplyEventDataMasking) previously wrote only the JSON column. For a binary event that would have left the original, unmasked payload readable in bdata — for a GDPR masking operation, the entire point missed. Same for CompletelyReplaceEvent and stream compaction, where the REPLACEMENT body's type decides the row's format and a stale bdata would keep being read. - A [BinaryEvent] type with no serializer configured throws instead of silently writing JSON, and the append path deliberately does not short-circuit the resolve to make that cheaper: a store that quietly ignored the attribute would have write-amplification characteristics that do not match its configuration, which is the problem the feature exists to solve. The resolve is a per-event-type cached dictionary hit. 12 tests covering the wire format (payload in bdata, '{}' placeholder in data), JSON/binary coexistence within a single stream, reading rows written before the serializer was configured, attribute vs explicit-registration precedence, the misconfiguration throw, and the dispatch through all of inline projections, the async daemon, the event LINQ provider, and masking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8tN8ApXiKhyVzia4iwmof
This was referenced Aug 2, 2026
Merged
jeremydmiller
added a commit
that referenced
this pull request
Aug 3, 2026
Minor rather than patch: 5.9.1's line added public surface (IEventBinarySerializer, EventStoreOptions.AddEventType/AddEventTypes) and moved the whole JasperFx/Weasel matrix forward. Since 5.9.1: - feat: pluggable binary event serialization via IEventBinarySerializer (#388/#402) - feat: EventStoreOptions.AddEventType / AddEventTypes (#395/#396) - fix: escape interpolated identifiers and literals in constructed SQL (#390/#403) - fix: throw a lone DcbConcurrencyException unwrapped from SaveChangesAsync (#394/#397) - deps: JasperFx 2.37.2 -> 2.38.0, Weasel 9.23.0 -> 9.23.2 (#405, #407) - Polecat's ProjectionScenario is now a thin subclass of the lifted JasperFx.Events.TestSupport harness rather than a seven-file copy of Marten's (#404/#408, jasperfx#616) -- a behavior change for anyone already using it, see the release notes - test infrastructure: compliance waves 1-3 (#393, #400, #407), parallel-safe test suite (#389), IntegrationContext.StoreOptions document cleaning (#398/#401)
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.
Closes #388.
Parity with Marten's
IEventBinarySerializer(marten#4515, shipped in 9.20.2). CritterWatch ships Marten/PostgreSQL and Polecat/SQL Server flavors from one source tree, and CritterWatch#896 has already opted its highest-volume internal event into binary on the Marten side — measured at field scale as −97.9% on the wire, −60.4% on disk, −73.4% append p50. Until now the SQL Server flavor kept paying the JSON cost the Marten one shed.API
Mirrored as-is, so a store-agnostic consumer can wire either flavor:
The coexistence design, which is the part worth copying
An additive nullable
bdata varbinary(max)column besidedata, withbdata IS NULLas the per-row discriminator. JSON and binary rows live in the samepc_eventstable, so the feature switches on for an existing store with no migration of existing event data — and switches back off just as safely.The column is added unconditionally rather than only when a serializer is configured, so the read projection has one fixed shape. Weasel's additive delta adds it on the next schema apply; rows written before it existed have
bdata = NULLand keep reading through the JSON path untouched.dataisNOT NULLand typedjsonon SQL Server 2025, so a binary row carries the'{}'placeholder there rather than NULL.Quick mode was in scope from the start rather than as a phase 2, per the issue's note. Polecat is QuickAppend-only, so there is no Rich-mode split to sequence and the CritterWatch store's configuration is covered by the first pass.
Implementation notes worth review attention
Every read path dispatches, not just the main one. Polecat has four independent readers over
pc_events, each with its own SELECT and its own deserialization:PcEventsRowReader(the canonical one), the two DCB tag-query readers inEventOperations, the LINQEventListHandler, and the daemon'sPolecatEventLoader.bdatais pinned at ordinal 10 — after the previously-locked 0–9 block, before the optional metadata columns, which shift to 11 — so the dispatch reads a stable ordinal everywhere. The twoEventOperationsprojections that duplicated the canonical column list by hand now compose it fromPcEventsRowReader; three hand-written copies of one list is how they stop agreeing.Masking rewrites
bdata, not justdata.OverwriteEventOperation(behindApplyEventDataMasking) previously wrote only the JSON column. For a binary event that would have left the original, unmasked payload readable inbdata— for a GDPR masking operation, the entire point missed. There is a test for exactly this. The same reasoning applies toCompletelyReplaceEventand stream compaction, where the replacement body's type decides the row's format and a stalebdatawould keep being read.A
[BinaryEvent]type with no serializer configured throws instead of silently writing JSON, and the append path deliberately does not short-circuit the resolve to make the common case cheaper. A store that quietly ignored the attribute would have write-amplification characteristics that do not match its configuration, which is the problem the feature exists to solve. The resolve is a per-event-type cachedConcurrentDictionaryhit.Tests
12 new tests: the wire format (payload in
bdata,'{}'indata, and a magic-header serializer so an assertion can prove the bytes really took the binary path), JSON/binary coexistence within a single stream, reading rows written before the serializer was configured, attribute-vs-explicit precedence, the misconfiguration throw, and the dispatch through each of inline projections, the async daemon, the event LINQ provider, and masking.Full
Polecat.Testssuite locally against dockerized SQL Server 2025, net10.0: 1650 total, 0 failed, 1647 passed, 3 skipped.🤖 Generated with Claude Code
https://claude.ai/code/session_01G8tN8ApXiKhyVzia4iwmof