Marten-owned rolling range partitions for time-series tables (#5093) - #5094
Merged
Conversation
…ries tables (#5093) `PartitionOn(x => x.When, x => x.ByExternallyManagedRangePartitions())` was the only workable option for a time-series document table, and its contract is "Marten builds the partitioned parent, the application writes the child-partition DDL itself." That is a capability gap, not a niche escape hatch: any table partitioned by time needs its partition set to MOVE -- provision next month, drop last year -- and RangePartitioning's declared-list delta reads both halves of that as drift, resolving to PartitionDelta.Rebuild. On a multi-gigabyte table that is unusable, so applications hand-write `CREATE TABLE ... PARTITION OF` / `DROP TABLE` instead. Opting out of Weasel means opting out of its ordering and dependency management, not just its DDL generation. Field case in JasperFx/CritterWatch#886: a hand-rolled rebuild of a partitioned metrics table re-created it in a schema where mt_immutable_timestamptz did not exist yet, so an ordinary computed index over a DateTimeOffset member failed with 42883. The index was correct; bypassing Weasel was the defect, and that failure mode is available to anyone doing time-series partitioning today. Weasel 9.21.0 (weasel#401) adds ManagedRangePartitions + RollingWindowPolicy: the partition set becomes a pure function of the policy and the clock, and RangePartitioning.CreateDelta turns purely additive whenever a manager is attached. This exposes that through the document mapping so users declare intent instead of managing DDL: opts.Schema.For<MetricsSample>() .Duplicate(x => x.BucketEnd) .PartitionOn(x => x.BucketEnd, x => x.ByRollingRange(PartitionPeriod.Month, periodsAhead: 3, periodsBehind: 12)); Provisioning at the leading edge rides the ordinary migration -- the delta is additive, so a rolled-forward window is a CREATE, never a rebuild. Retiring the trailing edge cannot ride migration, because migration never removes data, so MartenActivator drives the maintenance pass right after it applies changes on startup and before the assert-matches check (the assert would otherwise fail a deployment over the leading-edge partition the pass is about to create). AdvancedOperations gets ApplyRollingPartitionsAsync / RollPartitionsForwardAsync / DropAgedRollingPartitionsAsync for hosts whose processes outlive periodsAhead -- an hourly window especially. The managers are discovered from the database's own schema objects rather than tracked in StoreOptions, so this stays correct for any table that grows a rolling window later and re-running it is always idempotent. ByRollingRange asserts at configuration time that the partition key is a duplicated DateTime/DateTimeOffset member, since a window keyed on anything else is not a window. ByExternallyManagedRangePartitions() remains for genuinely external ownership (pg_partman). Weasel 9.20.2 -> 9.21.0. CoreTests, DocumentDbTests, MultiTenancyTests, TenantPartitionedEventsTests and EventSourcingTests all green on the bump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
4 tasks
This was referenced Jul 31, 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.
Closes #5093. Depends on JasperFx/weasel#401, shipped in Weasel 9.21.0 (bumped here from 9.20.2).
Problem
PartitionOn(x => x.When, x => x.ByExternallyManagedRangePartitions())was the only workable option for a time-series document table, and its contract is "Marten builds the partitioned parent, and the application writes the child-partition DDL itself."Any table partitioned by time needs its partition set to move: provision next month, drop last year.
RangePartitioning's declared-list delta reads both halves of that as drift — a moved window means actual has partitions the declaration no longer lists — which resolves toPartitionDelta.Rebuild. On a multi-gigabyte table that is unusable, so applications take the externally-managed door and hand-writeCREATE TABLE … PARTITION OF/DROP TABLE.Opting out of Weasel means opting out of its ordering and dependency management, not just its DDL generation. Field case in JasperFx/CritterWatch#886: a hand-rolled rebuild of a partitioned metrics table re-created it in a schema where
mt_immutable_timestamptzhad not been created yet, so an ordinary computed index over aDateTimeOffsetmember failed with42883. The index was correct; bypassing Weasel was the defect — and that failure mode is available to any Marten user doing time-series partitioning today.What this adds
PartitionPeriodcoversHour,Day,Week,Month,Year. Overloads also take aRollingWindowPolicydirectly, or a pre-builtManagedRangePartitions— pass the same manager to several document types to roll all of their tables forward in one pass. ATimeProvideris injectable so the window is testable without waiting on the calendar.How the two halves are driven
CreateDeltais purely additive, so a rolled-forward window is aCREATE, never a rebuildMartenActivatorstartup pass +Advanced.*The startup pass is gated on the same opt-in as the migration itself (
ApplyAllDatabaseChangesOnStartup) — that is how a host says "Marten owns this schema", and dropping a partition is emphatically a schema change. It runs beforeAssertDatabaseMatchesConfigurationAsync, on purpose: once the clock crosses a period boundary the database is legitimately missing the new leading-edge partition, and asserting first would fail a deployment over a difference the pass is about to close.For hosts whose processes outlive
periodsAhead— an hourly window especially —AdvancedOperationsgainsApplyRollingPartitionsAsync, plusRollPartitionsForwardAsync(additive only) andDropAgedRollingPartitionsAsync(retention only) for callers who want the halves separately.Design notes
StoreOptions. That stays correct for any table that grows a rolling window later, and makes re-running the pass unconditionally idempotent.ByRollingRangeasserts at configuration time that the partition key is a duplicatedDateTime/DateTimeOffsetmember. A window keyed on anything else is not a window, and this turns what would be an opaque partition-bound error during the first migration into a message that names the member.DEFAULToverflow partition is always created, so a row written outside the provisioned window is stored rather than rejected with a 23514.ByExternallyManagedRangePartitions()remains, now documented for what it is actually for: genuinely external ownership (pg_partman).Acceptance criteria from the issue
PartitionDelta.Rebuild—rolling_the_window_forward_is_additive_and_never_a_rebuildpins the diff atUpdatedrops_partitions_that_have_aged_past_the_retention_floorthe_apply_pass_is_idempotent_and_safe_to_run_concurrentlyTests
10 new tests in
src/CoreTests/Partitioning/rolling_range_partitioning.cs, driving the window with aFakeTimeProviderrather than the calendar: configuration shape, the declared window, initial creation of the whole window +DEFAULT, no-op re-apply, additive roll-forward with data survival, retention drop, the full host-startup pass throughApplyAllDatabaseChangesOnStartup, concurrent idempotency, overflow rows landing inDEFAULT, and the non-temporal partition key rejection.Green locally on net9.0 (and the partitioning tests on net10.0): CoreTests 504, DocumentDbTests 1090, MultiTenancyTests 159, TenantPartitionedEventsTests 240, EventSourcingTests 1478 — the last four also serving as the regression gate on the Weasel bump.
Known consumer: JasperFx/CritterWatch hand-writes exactly this for
mt_doc_metricssampleand can delete that code.🤖 Generated with Claude Code