Repository navigation
Delete a removed tenant's DCB tag rows before dropping its mt_events partition - #5720
Merged
Merged
Conversation
…partition Under UseTenantPartitionedEvents, RemoveMartenManagedTenantsAsync and ShardedTenancy.RemoveTenantAsync (RemoveTenantFromShardAsync) drop the tenant's mt_events partition. Each mt_event_tag_* table has a foreign key to mt_events that does not cascade, and the tag tables are not partitioned by tenant, so the detach failed with 23503 whenever the tenant had a tagged event: removing partition "mt_events_alpha" violates foreign key constraint "mt_event_tag_removal_probe_seq_id_tenant_id_fkey" The failure comes part-way through Weasel's drop: the registry row and the tenant's mt_streams partition are already gone, its mt_events partition and sequence remain, and a re-run fails with 42P01 on the missing mt_streams partition. JasperFx#5694 fixed the same refusal for DeleteAllTenantDataAsync by deleting the tenant's tag rows first, and named these two paths as a follow-up. Both now do the same, through the helper that DeleteAllTenantDataAsync used, moved into PerTenantPartitionedCleanup. RemoveMartenManagedTenantsAsync takes partition suffixes, and a Guid tenant's suffix is its "N" form while its tag rows carry the canonical Guid, so it first maps the suffixes to tenant ids from mt_tenant_partitions, the rows the drop is about to delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d, not suffix RemoveMartenManagedTenantsAsync is given partition suffixes, and handed them to the JasperFx#4683 cleanup as if they were tenant ids. A suffix is not always a tenant id: a Guid tenant's is its hyphen-free "N" form (JasperFx#4567), and the dictionary and Func overloads of AddMartenManagedTenantsAsync take any suffix. The registry lookup for the per-tenant sequence and the match on progression row names both missed such a tenant, so its mt_events_sequence_<suffix>, its HighWaterMark:<tenant> row and its per-projection rows were left behind. The cleanup now gets the tenant ids the suffixes map to in mt_tenant_partitions, the same ids the tag-row delete uses. Also corrects docs/events/dcb.md: the seq_id index serves the deletes that go by event, while the tenant-scoped deletes go by tenant_id and scan the tag table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 24, 2026
This was referenced Oct 26, 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.
Problem
Under
UseTenantPartitionedEvents, removing a tenant that has a tagged event fails. This affectsRemoveMartenManagedTenantsAsyncon a single database, andShardedTenancy.RemoveTenantAsync(RemoveTenantFromShardAsync, whichRemoveMartenManagedTenantsAsyncalso routes to under sharded tenancy):It fails part-way through. Weasel's
DropPartitionFromAllTablesdeletes the tenant'smt_tenant_partitionsrow and drops itsmt_streamspartition before it reachesmt_events. Measured on a single database after the failure:mt_streams_alphaare gone;mt_events_alphais still attached, with the tenant's events;mt_events_sequence_alphais still there.Re-running the removal after deleting the tag rows by hand then fails with
42P01: relation "….mt_streams_alpha" does not exist. Finishing the removal takes hand-written SQL.Root cause
It is the same refusal as #5694's partition-drop case. Each
mt_event_tag_*table has a foreign key tomt_eventsthat does not cascade; under partitioning it is on(seq_id, tenant_id). The tag tables are not partitioned by tenant, so PostgreSQL won't detach anmt_eventspartition that a tag row still points into. #5694 madeDeleteAllTenantDataAsyncdelete the tenant's tag rows first, and listed these two paths under "Not covered here".Fix
TenantDataCleaner's tag-row delete moves toPerTenantPartitionedCleanup.DeleteTagRowsAsync, which takes a set of tenant ids (tenant_id = ANY(...)).DeleteAllTenantDataAsyncbehaves as before.ShardedTenancy.RemoveTenantAsynccalls it for the tenant just before the partition drop.RemoveMartenManagedTenantsAsyncdoes the same, but it takes partition suffixes, and a suffix is not always a tenant id. A Guid tenant's suffix is its hyphen-free "N" form (AssertValidPostgresqlIdentifiersrejects valid partition suffixes that start with a digit (breaks existing GUID-based tenancy) #4567), and the dictionary andFuncoverloads ofAddMartenManagedTenantsAsynctake any suffix, while the tenant's rows carry its id. So it first reads the tenant ids for those suffixes frommt_tenant_partitions(FindTenantIdsForSuffixesAsync). Those are exactly the rows the drop is about to delete.RemoveMartenManagedTenantsAsyncused to hand its suffixes to the Drop-tenant cleanup gaps under UseTenantPartitionedEvents (orphaned sequences + progression rows) #4683 cleanup as if they were tenant ids. For a tenant whose suffix differs from its id, the registry lookup for itsmt_events_sequence_<suffix>and the match on its progression rows both missed, so the sequence, itsHighWaterMark:<tenant>row and its per-projection rows were left behind. A suffix whose registry row is already gone (an earlier removal that failed part-way) now maps to no tenant, so nothing keyed by tenant is cleaned up for it; before, the suffix itself was tried as a tenant id.UseTenantPartitionedEvents. Without itmt_eventsis not partitioned by tenant, the removal keeps the tenant's events, and their tag rows have to stay with them.docs/events/dcb.mdadds the two paths to the list of paths that delete tag rows first. It also stops claiming that theseq_idindex saves the tenant-scoped deletes from a scan:DeleteAllTenantDataAsyncand the two removals go bytenant_id, which neither the primary key nor that index leads with.PerTenantPartitionedCleanup's summary describes the tag rows and the suffix mapping, not only the Drop-tenant cleanup gaps under UseTenantPartitionedEvents (orphaned sequences + progression rows) #4683 cleanup.The tag delete runs as its own statement before the drop, as it does for
DeleteAllTenantDataAsync. Weasel's drop runs on its own connection and triesDETACH PARTITION CONCURRENTLYfirst, which PostgreSQL refuses inside a transaction block. If the drop then fails for some other reason, the tenant has lost only tag rows the removal was about to destroy. No schema change.The alternative. #5694 suggested that the right fix here might be to partition the tag tables by tenant, so the tenant's tag partition goes with the rest. That would also make removal cost independent of the tag table's size. But it is a schema change on a populated table. Weasel's drop would also have to take the tag partition before the
mt_eventsone, and its table ordering is commented out today. This PR keeps the shape #5694 chose. Partitioning can replace it later without a behaviour change.Tests
TenantPartitionedEventsTests/Dcb/removing_a_tenant_with_tagged_events.cs(PartitionedStoreContext):removing_a_tenant_takes_its_tag_rows_and_leaves_another_tenants_alone: two tenants carry the same tag value. Removing alpha dropsmt_events_alphaand alpha's tag row, keeps beta's, and beta's stream still reads.removing_a_guid_tenant_by_its_partition_suffix_takes_its_tag_rows: Guid tenants, removed by their "N" suffix.TenantPartitionedEventsTests/Sharded/removing_a_tagged_tenant_from_its_shard.cs: two tenants on one shard, thenRemoveTenantFromShardAsync.TenantPartitionedEventsTests/Admin/delete_all_tenant_data_orphan_sequence_pin.csgainsRemoveMartenManagedTenantsAsync_cleans_up_a_guid_tenant_named_by_its_partition_suffix. It removes a Guid tenant by its suffix and checks that itsmt_events_sequence_<suffix>and its progression rows are gone, and that the other Guid tenant's are not.Red on master
7045d09bb: the three tag tests fail.The cleanup test fails against master's cleanup call (measured on this branch's first commit, which doesn't change that call):
Green: all four. If the tag delete is keyed on the suffixes rather than the mapped tenant ids, the Guid tag case is red again with 23503.
Neighbouring tests on the fix (net10.0, PostgreSQL 17):
TenantPartitionedEventsTests, the whole project: 271 passed, 2 skipped (the twoBug_5044skips), 0 failed.EventSourcingTests.Dcb, which includes Delete the DCB tag rows of the events Marten deletes, and index the tag tables on seq_id #5694'sdeleting_tagged_events: 201 passed, 2 skipped, 0 failed.MultiTenancyTests:marten_managed_tenant_id_partitioning,sharded_tenancy_remove_lifecycle_testsand the othersharded_tenancy*classes, 43 of 43.Noticed, not changed here
A
[NaturalKey]aggregate'smt_natural_key_*table is not tenant-partitioned either, and itsON DELETE CASCADEforeign key tomt_streams(id, tenant_id)doesn't apply to a partition detach. So with[NaturalKey]underUseTenantPartitionedEvents, bothRemoveMartenManagedTenantsAsyncandDeleteAllTenantDataAsyncfail on master:It will get a PR of its own.
Searched open and closed issues and PRs for "tag table foreign key partition drop tenant",
RemoveMartenManagedTenantsAsync,DropPartitionFromAllTables, "dcb tag partition", "23503 tag" and "RemoveTenantAsync tag". Only #5694 and the archiving pair #5675 / #5679 come up.🤖 Generated with Claude Code