Skip to content

GH-5673 TombstoneStream (hard stream delete) on JasperFx 2.90.0 - #5674

Merged
jeremydmiller merged 2 commits into
masterfrom
GH-5673-tombstone-stream
Oct 15, 2026
Merged

jeremydmiller merged 2 commits into
masterfrom
GH-5673-tombstone-stream

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #5673

What

JasperFx 2.90.0. Bumps all five JasperFx pins from 2.88.0. No Weasel bump: 9.45.1, the only newer Weasel, patches the SQL Server and Oracle providers only, and restore and build are clean on 9.45.0.

TombstoneStream(Guid) / TombstoneStream(string) on session.Events. It is queued and runs at SaveChangesAsync. The work is done by a new internal DeleteStreamOperation, named that way because "tombstone" already means Marten's sequence-gap bookkeeping stream. It goes through the shared seam: PostgresEventStoreDialect.BuildAuxiliaryOperations now fills the TombstoneStream slot, and its doc comment is updated.

  • One statement, built from data-modifying CTEs. It deletes the stream's rows from each mt_event_tag_* table (keyed by the stream's seq_ids), then mt_events, then mt_streams_identity when strict identity enforcement is on, then mt_streams. Natural key rows cascade from mt_streams.
  • Why one statement. Npgsql won't take ;-separated statements in a positional-parameter command. Starting extra batch commands inside one operation would break OperationPage's alignment of one command per operation. All CTEs read the same snapshot, so the tag delete still finds the events, and the FK checks run at the end of the statement. I verified this with partitioned FKs directly in Postgres before writing it.
  • Tenancy. Under conjoined tenancy every delete is scoped by tenant_id. Deleting through the parent tables reaches the archived partition, and the tenant predicate prunes to the tenant's partition under UseTenantPartitionedEvents.
  • Sequence. The event sequence is never touched, so sequence numbers are never reused.
  • Identity check. The stream identity is asserted through EnsureAsGuidStorage / EnsureAsStringStorage, so calling the wrong overload throws InvalidOperationException.

AOT (#1018). The [RequiresUnreferencedCode] / [RequiresDynamicCode] attributes are carried up to RegisterTagType<TTag>() and RegisterTagType<TTag>(string), on both IEventStoreOptions and EventGraph. There is a new trim-safe overload, RegisterTagType<TTag, TInner>(Func<TTag, TInner> unwrapper, string? tableSuffix = null), backed by TagTypeRegistration.FromUnwrapper. Marten builds with no IL2026/IL3050 from tag registration. The GetMethod(nameof(TagTypeRegistration.Create)) auto-discovery lookup is unchanged and still resolves.

Tests

All runs are local against Postgres on :5432.

  • stream_tombstoning_compliance (StreamTombstoningCompliance, SupportsTombstoneStream => true): 9/9.
  • New EventSourcingTests/Archiving/tombstoning_streams.cs: 11 facts.
    • Archived partitioning, Guid/string × single/conjoined, deleting both an archived and a live stream.
    • Tag rows in a partitioned tag table, registered through the new unwrapper overload.
    • UseTenantPartitionedEvents deletes only the session tenant.
    • Strict identity enforcement releases the id.
    • Projected documents are left alone.
    • The wrong-identity overload is refused.
    • A docs sample.
  • Both of the above: 20/20 on net10.0 and on net9.0.
  • DCB, tag, archiving and strict-identity tests: 316 passed, 7 skipped, 0 failed.
  • Compliance, Aggregation, FetchForWriting, appending and Bugs: 1430 passed, 22 skipped, 0 failed.
  • src/Marten.slnx: builds with 0 errors.

Docs

  • docs/events/archiving.md: new section "Deleting a Stream Permanently", with a snippet sample and a comparison table against ArchiveStream. It covers queueing until SaveChangesAsync, tag rows, tenant scoping, the missing-stream no-op, partition coverage, sequence numbers not being reused, and projected documents being left alone.
  • docs/events/dcb.md: new section on registering tag types under trimming or Native AOT.

Found along the way (not fixed here)

Archiving a tagged stream under UseArchivedStreamPartitioning fails with an FK violation. mt_archive_stream moves the events to the archived partition but not the tag rows that reference them (mt_event_tag_*_seq_id_is_archived_fkey). This is unrelated to this change and is noted in the new test. It probably deserves its own issue.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J8KxC18Q51z5w4u6YARwe4

Bumps the JasperFx family to 2.90.0 and implements
IEventStoreOperations.TombstoneStream(Guid|string) as DeleteStreamOperation,
a queued single-statement delete of the stream's tag rows, events and stream
row (plus the mt_streams_identity row under strict identity enforcement),
scoped to the session tenant under conjoined tenancy. The event sequence is
never touched. Fills the EventAuxiliaryOperations.TombstoneStream slot in
PostgresEventStoreDialect.

Enrolls StreamTombstoningCompliance, flips SupportsTombstoneStream, and adds
Marten-specific tests for archived and tenant partitioning, strict identity
enforcement, projected documents and the identity mismatch guard.

Carries #1018's RequiresUnreferencedCode/RequiresDynamicCode up to
RegisterTagType<TTag> and adds the AOT-safe RegisterTagType<TTag, TInner>
(unwrapper) backed by TagTypeRegistration.FromUnwrapper.

Documents TombstoneStream in docs/events/archiving.md and the AOT tag
registration in docs/events/dcb.md.

Closes #5673

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KxC18Q51z5w4u6YARwe4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 8689bc8 into master Oct 15, 2026
32 checks passed
@jeremydmiller
jeremydmiller deleted the GH-5673-tombstone-stream branch October 15, 2026 17:10
erdtsieck pushed a commit to erdtsieck/marten that referenced this pull request Oct 19, 2026
… cannot work

Supersedes JasperFx#5663, whose API half landed independently in JasperFx#5674. What is left is
the part JasperFx#5674 did not carry: the diagnostics that point at the overload, the
argument validation, and the native coverage.

RegisterTagType<TTag>() reflects over the tag's members with ValueTypeInfo and
closes TagTypeRegistration<TTag, TInner> with MakeGenericType. JasperFx#5674 annotated
both inferring overloads RequiresUnreferencedCode/RequiresDynamicCode, but an
analyzer warning only reaches an application that is itself AOT-analyzed -- a
store configured from inside a library gets nothing, which is how JasperFx#5649 was
reported in the first place. EventGraph.CreateTagType now turns the failure into
an InvalidOperationException naming the one line to write, keeping the original
as its inner exception.

The two halves are gated differently on purpose. NotSupportedException and
MissingMethodException mean the generic could not be closed, which only happens
with no JIT, so they are reinterpreted only when dynamic code is unavailable.
InvalidValueTypeException is ambiguous -- the tag may genuinely not be a
single-value wrapper, or a trimmed image may have lost the members that say so,
and no runtime flag separates those because a trimmed non-AOT image still has
dynamic code -- so it is reinterpreted either way, with a message covering both
readings. The explicit overload is the answer to both.

Auto-discovery from projection identities had the inverse of that problem.
JasperFx#5657 logs a warning for a discovery failure, but screens InvalidValueTypeException
into a silent catch as "this identity was never a value type" -- which is exactly
how a trimmed native image reports a perfectly good tag whose members were
trimmed. The one scenario JasperFx#5649 reported was the one scenario that stayed quiet.
IsToleratedDiscoveryFailure now tolerates it only while dynamic code is
available, and the warning names the registration line including the
projection's ForAggregate<T>(). A trimmed non-AOT image is still silent, because
guessing the other way would warn on every projection whose identity is an
ordinary custom type.

Argument validation the overload did not have: a null unwrapper is refused, and
re-registering a tag type under a DIFFERENT inner type now throws rather than
silently keeping the first registration and leaving this call's unwrapper unused.

BEHAVIOUR CHANGE: an empty or whitespace tag table suffix is refused on every
RegisterTagType overload. mt_event_tag_ is a real table name, so "" was accepted
and put every tag type registered that way into one shared table. Pass null for
the default.

Marten.AotRuntimeSmoke gains six DCB checks -- tagging, a TagWith rule, Guid- and
string-backed tag queries, EventsExistAsync, FetchForWritingByTags into a
[BoundaryAggregate] with an append, DcbConcurrencyException, and the inferring
overload's message. Verified by RUNNING the native osx-arm64 binary: 48/48, and
the remedy message does fire natively. Marten.AotSmoke registers a tag through
the overload to hold the consumer-side analyzer axis clean.

Co-authored-by: Tore Hammervoll <hammervoll.tore@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3
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.

Implement IEventStoreOperations.TombstoneStream (hard stream delete) and enroll StreamTombstoningCompliance

1 participant