Repository navigation
Scope MarkEventsAsSkipped to a tenant under UseTenantPartitionedEvents (#5234 follow-up) - #5698
Merged
jeremydmiller merged 1 commit intoOct 19, 2026
Conversation
JasperFx#5234 follow-up) Under UseTenantPartitionedEvents every tenant draws seq_id from its own mt_events_sequence_{suffix}, so seq_id is not unique across tenants. IMartenDatabase.MarkEventsAsSkipped(long[]) updated mt_events by seq_id alone, so skipping one tenant's event #N also skipped event #N of every other tenant, which then silently dropped out of FetchStreamAsync, live aggregation, rebuilds and subscription replays. JasperFx#5234 flagged it as the fourth site of that shape and JasperFx#5236 left it open as an API decision. - Add MarkEventsAsSkipped(string tenantId, long[] sequences, ...), which appends "and tenant_id = :tenant_id" under conjoined event tenancy (the ConjoinedEventFilter gate). It is a default interface member that throws, so it is additive for other IMartenDatabase implementers, as ConfiguredCommandTimeout (JasperFx#5269) and SequenceFeature (JasperFx#5665) are. - The tenant-less overload throws InvalidOperationException under UseTenantPartitionedEvents, naming the new overload. Everywhere else it is unchanged. - docs/events/skipping.md gains a multi-tenancy section, and the by-tenant sample names the tenant. The async daemon's own skipping (SkipApplyErrors) is in memory and per tenant and never calls this method, so it is not affected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 20, 2026
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.
Follows up #5234 and #5236. #5236 scoped masking and compaction to the calling tenant and left the fourth site #5234 flagged open on purpose:
This PR proposes an answer to that API question. It is one possible shape and you may prefer another. The core assertion, that tenant beta's event stays unskipped, holds whatever shape you choose. Only the assertions about which overload throws would change.
The defect
Under
Events.UseTenantPartitionedEvents, each tenant drawsseq_idfrom its ownmt_events_sequence_{suffix}, soseq_id = 1exists in every tenant's partition. WithEnableEventSkippingInProjectionsOrSubscriptionson,IMartenDatabase.MarkEventsAsSkipped(long[])runsA host that reads tenant
alpha's sequences throughalpha's session and passes them to the documented call (store.Storage.Database.MarkEventsAsSkipped(sequences),docs/events/skipping.md) also skips the same-numbered event of every other tenant. Those events then silently drop out of:FetchStreamAsync;AggregateStreamAsync;FetchLatest/FetchForWriting;Nothing throws. Unlike #5234, nothing is deleted or overwritten: resetting
is_skippedrestores the events.The async daemon is not affected. Its own poison-event skipping (
SkipApplyErrors) does not callMarkEventsAsSkippedand never writesis_skipped:ProjectionExecution.buildBatchWithSkipping/GroupedProjectionExecutioncallEventRange.SkipEventSequence, which removes the event from the in-memory batch, bumps a metric and records a dead letter.EventLoaderaddsd.tenant_id = '{tenant}'for a per-tenant shard;DistributesAgentsPerTenantmakes Wolverine-managed hosts fan out per tenant too.The only exposure is a host, or a tool, calling the public API.
The proposed shape
IMartenDatabase.MarkEventsAsSkipped(string tenantId, long[] sequences, CancellationToken token = default).and tenant_id = :tenant_idunderTenancyStyle.Conjoined, the same gateConjoinedEventFilteruses since Scope event masking and stream compaction to the calling tenant #5236.TenantIdStyle.MaybeCorrectTenantId, as the session factories do.UseTenantPartitionedEventswith anInvalidOperationExceptionthat names the new overload. Everywhere else it is unchanged: with one global sequence a sequence names exactly one event, conjoined or not.NotSupportedException, so it is additive for anyone implementingIMartenDatabase.IMartenDatabasealready does for the members it has grown recently:ConfiguredCommandTimeout(Multi-database tenancy ignores Command Timeout on shard connection strings; every batch stays at the 5s store default #5269) andSequenceFeature(AllObjects() connects and applies the HiLo DDL when a numeric-id document is registered, so db-dump is not offline #5665).MartenDatabaseimplements it.StandinDatabase, which already throwsNotImplementedExceptionfor the old overload, takes the default.string, so no existing call shape ((long[]),(long[], CancellationToken),(null),(null, token)) rebinds to it.docs/events/skipping.mdgets a short multi-tenancy section. Themark_events_as_skipped_by_tenantsample becomes a snippet that finds the tenant's database and names the tenant, which is right for every topology: one database, a database per tenant, or sharded.Alternatives you might prefer:
UseTenantPartitionedEventsinstead ofConjoined;session.Events), so the session's tenant scopes it;Tests
src/TenantPartitionedEventsTests/Regressions/Bug_5234_mark_events_as_skipped_per_tenant.cs, onPartitionedStoreContext, mirrorsBug_5234_cross_tenant_event_rewrites:seq_id, so it cannot pass vacuously.the_tenantless_overload_must_not_skip_another_tenants_eventcalls the documentedMarkEventsAsSkipped(long[])with alpha's sequence. It asserts that beta's event is not skipped, readingis_skippeddirectly and throughFetchStreamAsync. Then it asserts that the call threw theInvalidOperationExceptionnaming the new overload. It fails on master.skipping_by_tenant_skips_only_that_tenants_eventchecks that the new overload skips alpha's event and leaves beta's alone. It needs the new API, so it cannot compile on master. Instead it was checked by mutation: dropping the tenant predicate from the fix turns it red with the same assertion.src/EventSourcingTests/marking_events_as_skipped_by_tenant.cscovers the stores outside partitioning:Red, on unmodified master
Mutation (the fix without its tenant predicate)
Green
The suites were run on both target frameworks, against
postgres:17-alpine.Bug_5234_mark_events_as_skipped_per_tenantTenantPartitionedEventsTests, the whole project (includesBug_5234_cross_tenant_event_rewrites)EventSourcingTests:marking_events_as_skipped_by_tenant,marking_events_as_skipped_as_Guid_identified,marking_events_as_skipped_as_string_identified,Bug_4416_closed_shape_metadata_bindersThe net9.0 failure was
dynamic_tenant_lifecycle_on_shard_during_daemon.tenant_added_to_shard_mid_run_is_provisioned_and_catches_up_without_daemon_restart, with "should be 3 but was 1". That is the known flake its[Trait("Retry", "3")]comment records: the daemon has not yet caught up on the shard. It does not touch skipping. A fresh-process rerun of that class, together with bothBug_5234_*classes on net9.0, passed 7/7.docs/events/skipping.md:dotnet mdsnippetsregenerates the new snippet byte for byte;markdownlint-cli --disable MD009andcspell --config ./docs/cSpell.jsonare clean.🤖 Generated with Claude Code