Skip to content

ManagedRangePartitions for SQL Server: rolling time-window partitions (weasel#401) - #414

Merged
jeremydmiller merged 1 commit into
masterfrom
feat/401-sqlserver-managed-range-partitions
Jul 30, 2026
Merged

ManagedRangePartitions for SQL Server: rolling time-window partitions (weasel#401)#414
jeremydmiller merged 1 commit into
masterfrom
feat/401-sqlserver-managed-range-partitions

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Part two of #401 — SQL Server parity for the PostgreSQL ManagedRangePartitions that shipped in #413. Closes the issue.

The gap

Weasel.SqlServer.Tables.Partitioning.RangePartitioning takes a static list of boundary values, so rolling a new period forward (NEXT USED + SPLIT) and retiring an aged one was left to the application. There is essentially no tooling for this on SQL Server — sliding-window management is a hand-rolled T-SQL chore whose published guidance amounts to "write a script that sets NEXT USED and SPLITs, and another that MERGEs".

What this adds

ManagedRangePartitions over the same RollingWindowPolicy the Postgres half uses:

var manager = new ManagedRangePartitions(
    RollingWindowPolicy.Monthly(periodsAhead: 3, periodsBehind: 6),
    column: "occurred_at");

table.PartitionByRollingWindow(manager);
  • RollForwardAsyncALTER PARTITION SCHEME … NEXT USED + ALTER PARTITION FUNCTION … SPLIT RANGE for boundaries the window has gained.
  • DropAgedPartitionsAsync — retire everything below the retention floor.
  • ApplyAsync — both, idempotent.

CreateDelta never returns Rebuild for a boundary-set difference; a column or type change still does.

Two decisions worth the review time

Retention is TRUNCATE and then MERGE RANGE, in that order. This is the point you flagged when we scoped it. Bare MERGE RANGE against a partition that still holds rows reclaims nothing — it moves those rows into the neighbouring partition. TRUNCATE TABLE … WITH (PARTITIONS (n)) (SQL Server 2016+) deallocates the pages in O(1), and the merge that follows is metadata-only against an empty partition. No staging table, so none of SWITCH OUT's structural preconditions. If the truncate fails the boundary is deliberately left in place rather than merging rows we failed to reclaim.

The declared boundaries are the window's period starts plus the exclusive end of the newest provisioned period. Without that trailing boundary the top partition is [newest period start, +infinity) and holds the newest period's rows — so every roll-forward would SPLIT a partition containing data, a fully logged row movement. With it the top partition is always beyond everything provisioned, therefore empty, and SPLIT stays metadata-only.

Smaller notes:

  • TableDelta now dispatches boundary migration on a new ISplittablePartitioning rather than the concrete RangePartitioning, so declarative and rolling-window strategies both migrate in place. ManagedTenantPartitions deliberately does not implement it and stays exempt, exactly as before.
  • Retention only retires boundaries landing exactly on a period start for the policy — the SQL Server analogue of the Postgres side's suffix parsing — so a hand-added boundary survives.
  • It does reclaim the underflow partition when the oldest boundary ages out. That partition holds late-arriving rows dated before the window ever started and is entirely below the retention floor; leaving it would mean data that retention can never reach.
  • Boundary literals are boxed through an explicit if, not a ternary: DateTime converts implicitly to DateTimeOffset, so a ternary over the two silently types itself as DateTimeOffset and renders every boundary in the offset-carrying shape regardless of the column type. Caught by the round-trip test.

Tests

14 new tests (6 pure-DDL/delta, 8 integration) covering the window boundaries, additive roll-forward, aged boundaries not reading as drift, column change still rebuilding, truncate+merge retention, underflow reclaim, leaving foreign boundaries alone, idempotent apply, and skipping a table whose partition function is absent.

Full SQL Server suite green: 337 passed, 0 failed, 8 skipped. PostgreSQL (819) and Core (50) suites re-run green as well.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PefhUPhDZcmSUhfQFBqXxR

…-forward and TRUNCATE+MERGE retention (weasel#401)

SQL Server parity for the PostgreSQL ManagedRangePartitions shipped in #413.
RangePartitioning here takes a static list of boundary values, so rolling a new
period forward (NEXT USED + SPLIT) and retiring an aged one was left to the
application -- a hand-rolled T-SQL chore with published recipes and no standard
tooling.

Adds Weasel.SqlServer ManagedRangePartitions over the same RollingWindowPolicy,
attached with Table.PartitionByRollingWindow. RollForwardAsync splits in the
boundaries the window has gained; DropAgedPartitionsAsync retires everything
below the retention floor; ApplyAsync does both idempotently.

Retention is TRUNCATE TABLE ... WITH (PARTITIONS (n)) and THEN MERGE RANGE, in
that order. MERGE RANGE against a partition that still holds rows reclaims
nothing -- it moves those rows into the neighbouring partition, which is the
opposite of the point. Truncating first deallocates the pages in O(1) and leaves
the merge metadata-only. If the truncate fails the boundary is left in place
rather than merging rows we failed to reclaim. Only boundaries landing exactly on
a period start for the policy are retired, so a hand-added boundary is left
alone; the underflow partition is reclaimed with the oldest boundary, since it
holds late-arriving rows dated before the window ever started.

The declared boundaries are the window's period starts PLUS the exclusive end of
the newest provisioned period. Without that trailing boundary the top partition
would hold the newest period's rows and every roll-forward would SPLIT a
partition containing data -- a fully logged row movement. With it the top
partition is always empty and SPLIT stays metadata-only.

TableDelta now dispatches boundary migration on a new ISplittablePartitioning
rather than the concrete RangePartitioning, so both declarative and rolling-window
strategies migrate in place. ManagedTenantPartitions deliberately does not
implement it and stays exempt, as before.

Closes #401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PefhUPhDZcmSUhfQFBqXxR
@jeremydmiller
jeremydmiller merged commit 3bc0a5f into master Jul 30, 2026
16 checks passed
@jeremydmiller
jeremydmiller deleted the feat/401-sqlserver-managed-range-partitions branch July 30, 2026 17:40
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.

1 participant