Skip to content

Delete a removed tenant's DCB tag rows before dropping its mt_events partition - #5720

Merged
jeremydmiller merged 2 commits into
JasperFx:masterfrom
lahma:fix/remove-tenant-tag-rows
Oct 23, 2026
Merged

jeremydmiller merged 2 commits into
JasperFx:masterfrom
lahma:fix/remove-tenant-tag-rows

Conversation

@lahma

@lahma lahma commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Under UseTenantPartitionedEvents, removing a tenant that has a tagged event fails. This affects RemoveMartenManagedTenantsAsync on a single database, and ShardedTenancy.RemoveTenantAsync (RemoveTenantFromShardAsync, which RemoveMartenManagedTenantsAsync also routes to under sharded tenancy):

Npgsql.PostgresException : 23503: removing partition "mt_events_alpha" violates foreign key constraint "mt_event_tag_removal_probe_seq_id_tenant_id_fkey"

It fails part-way through. Weasel's DropPartitionFromAllTables deletes the tenant's mt_tenant_partitions row and drops its mt_streams partition before it reaches mt_events. Measured on a single database after the failure:

  • the registry row and mt_streams_alpha are gone;
  • mt_events_alpha is still attached, with the tenant's events;
  • mt_events_sequence_alpha is 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 to mt_events that 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 an mt_events partition that a tag row still points into. #5694 made DeleteAllTenantDataAsync delete the tenant's tag rows first, and listed these two paths under "Not covered here".

Fix

  • TenantDataCleaner's tag-row delete moves to PerTenantPartitionedCleanup.DeleteTagRowsAsync, which takes a set of tenant ids (tenant_id = ANY(...)). DeleteAllTenantDataAsync behaves as before.
  • ShardedTenancy.RemoveTenantAsync calls it for the tenant just before the partition drop.
  • RemoveMartenManagedTenantsAsync does 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 (AssertValidPostgresqlIdentifiers rejects valid partition suffixes that start with a digit (breaks existing GUID-based tenancy) #4567), and the dictionary and Func overloads of AddMartenManagedTenantsAsync take any suffix, while the tenant's rows carry its id. So it first reads the tenant ids for those suffixes from mt_tenant_partitions (FindTenantIdsForSuffixesAsync). Those are exactly the rows the drop is about to delete.
  • The Drop-tenant cleanup gaps under UseTenantPartitionedEvents (orphaned sequences + progression rows) #4683 cleanup goes by those tenant ids too. RemoveMartenManagedTenantsAsync used 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 its mt_events_sequence_<suffix> and the match on its progression rows both missed, so the sequence, its HighWaterMark:<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.
  • Both removal paths delete tag rows only under UseTenantPartitionedEvents. Without it mt_events is not partitioned by tenant, the removal keeps the tenant's events, and their tag rows have to stay with them.
  • docs/events/dcb.md adds the two paths to the list of paths that delete tag rows first. It also stops claiming that the seq_id index saves the tenant-scoped deletes from a scan: DeleteAllTenantDataAsync and the two removals go by tenant_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 tries DETACH PARTITION CONCURRENTLY first, 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_events one, 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 drops mt_events_alpha and 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, then RemoveTenantFromShardAsync.
  • TenantPartitionedEventsTests/Admin/delete_all_tenant_data_orphan_sequence_pin.cs gains RemoveMartenManagedTenantsAsync_cleans_up_a_guid_tenant_named_by_its_partition_suffix. It removes a Guid tenant by its suffix and checks that its mt_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.

removing_a_tenant_with_tagged_events.removing_a_tenant_takes_its_tag_rows_and_leaves_another_tenants_alone
  Npgsql.PostgresException : 23503: removing partition "mt_events_alpha" violates foreign key constraint "mt_event_tag_removal_probe_seq_id_tenant_id_fkey"
removing_a_tenant_with_tagged_events.removing_a_guid_tenant_by_its_partition_suffix_takes_its_tag_rows
  Npgsql.PostgresException : 23503: removing partition "mt_events_c70d1b2b4340478cb0427713e2fbd799" violates foreign key constraint "mt_event_tag_removal_probe_seq_id_tenant_id_fkey"
removing_a_tagged_tenant_from_its_shard.removing_a_tenant_takes_its_tag_rows_and_leaves_another_tenants_alone
  Npgsql.PostgresException : 23503: removing partition "mt_events_rmtag_alpha" violates foreign key constraint "mt_event_tag_sharded_removal_seq_id_tenant_id_fkey"

The cleanup test fails against master's cleanup call (measured on this branch's first commit, which doesn't change that call):

RemoveMartenManagedTenantsAsync_cleans_up_a_guid_tenant_named_by_its_partition_suffix
  --- Error 1 ---  sequenceLeft should be False but was True
                   (the removed tenant's per-tenant sequence must not be left behind)
  --- Error 2 ---  rowsLeft should be empty but had 2 items and was
                   ["HighWaterMark:99d4f791-c0ac-4e96-be26-6843d096ec2b", "DelCountProjection:All:99d4f791-c0ac-4e96-be26-6843d096ec2b"]

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):

Noticed, not changed here

A [NaturalKey] aggregate's mt_natural_key_* table is not tenant-partitioned either, and its ON DELETE CASCADE foreign key to mt_streams(id, tenant_id) doesn't apply to a partition detach. So with [NaturalKey] under UseTenantPartitionedEvents, both RemoveMartenManagedTenantsAsync and DeleteAllTenantDataAsync fail on master:

Npgsql.PostgresException : 23503: removing partition "mt_streams_alpha" violates foreign key constraint "mt_natural_key_removalcase_stream_id_tenant_id_fkey"

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

lahma and others added 2 commits October 20, 2026 11:38
…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>
@jeremydmiller
jeremydmiller merged commit aa28b49 into JasperFx:master Oct 23, 2026
32 checks passed
@lahma
lahma deleted the fix/remove-tenant-tag-rows branch October 23, 2026 15:47
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.

2 participants