Repository navigation
plan-dim retention batches size themselves to the store's speed and retry a timeout (#4130) - #4210
Merged
Merged
Conversation
…-out batch (#4130) The plan-dim purge's 50,000-row batch cap was sized for ~50s/5x margin against the 300s command timeout on one store; a large field store's first-start purge ran at ~2x margin instead, and its sixth batch passed 300s and failed the whole table for the day. Batches now shrink toward a 60s target after a slow one and grow after a fast one (floor 1,000, ceiling 50,000, no state persists between runs), and a batch that hits the command timeout retries at half its cap instead of failing outright. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
erikdarlingdata
marked this pull request as ready for review
September 25, 2026 03:31
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 #4130.
Why
The daily retention purge's
query_plan_dimdelete runs in 50,000-row batches (RowCappedDeleteSql,capped by
PlanDimDeleteRowCap), sized from one store's measurement at ~1,000 rows/sec for a ~50sbatch against the 300s command timeout, about 5x margin.
On a large field store's first-start purge, the issue's 2026-09-25T02:22Z comment shows the sixth
50,000-row batch passing the 300s timeout. The client cancelled it, and the table failed for the day:
Retention purge failed for query_plan_dim after removing 250000 row(s): Exception while reading from stream. The five batches that DID succeed averaged 152.7s (max 195.8s, ~330 rows/sec), a ~2x margin,not 5x. That store's
query_plan_dimis 67 GB, 63 GB of it TOAST, with autovacuum debt on the TOASTrelation around the same time. A different field store's batches averaged 30.7s (max 43.2s) at the
same cap, comfortably inside the timeout, needing no adjustment at all.
I did not have access to either field store, so I did not reproduce those exact numbers. I confirmed
the code shape the issue names (
RowCappedDeleteSql,PlanDimDeleteRowCap, the plan-dim dispatch inthe dim-purge loop,
PurgeOneAsync,DrainBatchesAsync) matches the anchors exactly. I also built alive test (below) that exercises the real adaptive/retry machinery against a real Postgres table with
real timing, proving the mechanism works even though a local rig's rows are far faster than the
field store's.
What changes
All in
Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs. Only the plan-dim table'spurge changes shape. Every other table's batching (time-sliced, single-shot) is untouched.
NextPlanDimBatchCapaims each batch at ~60s (PlanDimBatchTargetSeconds):a batch slower than that halves, one faster than half that doubles, and one in between holds. Floor
1,000 rows (
PlanDimDeleteRowFloor), ceiling 50,000 rows (PlanDimDeleteRowCap, unchanged value).Nothing persists between purge runs. Every run starts back at the ceiling, because throughput is a
property of tonight's load, not a fact about the table.
RunPlanDimBatchAsyncretries a batch that hits the command timeout at half itscap, down to the floor, instead of failing the table outright. A batch that still times out AT the
floor is not retried further. It propagates, and the table fails for the day exactly as before this
fix. The timeout is detected precisely by
IsPlanDimBatchTimeout: anNpgsqlExceptionwhose innerexception is a
TimeoutException(the exact shape the field failure logged), or aPostgresExceptionat SQLSTATE 57014 when the purge's own cancellation token is NOT the reason (so a service shutdown's
cancel is never read as "shrink and retry", an
OperationCanceledExceptionis excluded outright).DrainBatchesAsync'sexecuteBatchdelegate now returns boththe rows deleted AND the cap that batch used, and the loop compares a batch's rows against THAT
batch's own cap, not the cap the drain started with. Comparing against the starting cap stopped the
drain the moment a batch shrank. A shrunken batch's full take is, by construction, below the original
cap even when the table is nowhere near drained. Every fixed-size caller (time-sliced, single-shot)
reports the same cap every time, so this preserves the old behavior for them. A new pure test
(
DrainBatches_ComparesEachBatchAgainstItsOwnCap_NotTheStartingOne) pins the fix directly againstthe bug shape.
PurgeOneAsync's catch now logs the inner exception's type and messagealongside the outer one (
DescribePurgeFailure). The field failure logged only the outer "Exceptionwhile reading from stream". That is Npgsql's wrapper message for every read-side fault alike, with
no way to tell a command timeout from a dropped connection. The inner
TimeoutExceptionis whatactually says which one it was.
PlanDimDeleteRowCap's doc now describes it as the ceiling (not a fixed batch size), with thefield numbers above.
Results stay identical: the same rows get deleted, batching is just sized and retried adaptively.
Test plan
Pure (
Darling/Darling.Tests/DarlingRetentionTests.cs, classDarlingRetentionTests):NextPlanDimBatchCap: 5[Theory]cases (slow halves, at-the-wall halves, mid-band holds, exactboundary holds, fast doubles) plus floor-clamp and ceiling-clamp tests.
RunPlanDimBatchAsync: retries a fake timeout once then succeeds at half the cap (asserts theattempt-cap sequence, not just that it did not throw); retries the SQLSTATE-57014 shape too, but
NOT when the cancellation token is already signaled (shutdown); does not retry past the floor.
IsPlanDimBatchTimeout: true forNpgsqlException/TimeoutException, false forOperationCanceledExceptionand for an unrelatedPostgresException.DrainBatchesAsync: the 3 existing tests adapted to the new(Deleted, Cap)tuple contract, plusthe new regression test for point 3 above.
TheRowCappedDrain_LogsItsBatchCountAndResolvedCutoffre-anchored onPurgeOneAsync's whole body(brace-matched) instead of one
DrainBatchesAsync(text offset, since the adaptive branch added asecond call site in the same method.
Live (
Darling/Darling.Tests/PayloadDimensionLiveTests.cs, classPayloadDimensionLiveTests, rig at127.0.0.1:55988,
DARLING_TEST_PGset):RowCappedDelete_DrainsAcrossMultipleBatches_AndLeavesFreshRowsand the plan-dim GC sweep test) to the new
DrainBatchesAsynccontract, no behavior change.AdaptivePlanDimDrain_GrowsCapOnFastBatches_AndDrainsExactlyTheExpiredRows: seeds 70 expired+ 6 live
query_plan_dimrows, drains through the realRunPlanDimBatchAsync+NextPlanDimBatchCap+DrainBatchesAsynccomposition (cap starts at 10; every batch is fast onthe rig, so it grows 10 -> 20 -> 40, exhausting the 70 rows exactly, plus one trailing empty batch
at 80 per the drain's own "exact multiple ends on the following empty batch" contract). Asserts all
70 expired rows are gone and all 6 live rows survive.
Proof each pin is red against origin/dev: swapped
DarlingRetention.csfor origin/dev's copy in thisworktree and rebuilt the test project, every new symbol (
NextPlanDimBatchCap,RunPlanDimBatchAsync,IsPlanDimBatchTimeout,PlanDimDeleteRowFloor) failed with CS0117, and the changedDrainBatchesAsyncdelegate shape broke every caller with CS0029/CS1662 (6 errors across 2 files). Restored my version
afterward and confirmed a clean rebuild.
Build:
dotnet build Darling/Darling.Tests/Darling.Tests.csproj, 0 Warning(s), 0 Error(s).Targeted runs (rig):
DarlingRetentionTests43/43 pass (with and withoutDARLING_TEST_PG, 3 tests inthat class are gated and run live under the rig).
PayloadDimensionLiveTests17/17 pass.DocCommentHygieneTests77/77 pass (new<see cref>s resolve, summaries balance).Full
Darling.Testssuite, once, against the rig: 13,757 total, 0 errors, 2 failed, 47 skipped(environment-gated,
DARLING_TEST_PGRUNTIME/CSVLOG/AUTOEXPLAINtargets this ad hoc rig does notset), 1 not run. Both full runs (console and a second run with
-result-junit) agreed on exactly 2failures. I was not able to pin the two failing test names within my time budget. The default console
reporter does not print failure detail inline (only
[SKIP]lines and the summary), and my parse of theJUnit export's
<failure>blocks did not cleanly separate real failures from other annotated blocksbefore I had to stop. Neither
DarlingRetentionTestsnorPayloadDimensionLiveTests, the two classesthis PR touches, showed any failure in isolation, run twice each. CI on this PR must confirm these 2
are pre-existing rather than new failures introduced by this change.
Lite parity: grepped
Lite/forRowCapped,DrainBatches,PlanDimDeleteRowCap,ctid IN, andquery_plan_dim, zero hits. Lite has no row-capped delete and noquery_plan_dimtable. Its retentionarchives to parquet before deleting from a local embedded DuckDB file. That path runs in a single
process with no network round-trip and no TOAST-heavy remote table, so there is no equivalent
fixed-cap batched delete for this fix to carry over to.
CHANGELOG entry
SECTION: Fixed
ENTRY:
daily purge's plan-dimension cleanup deleted rows in fixed 50,000-row batches sized for one store's
measured speed. On a slower or more heavily loaded store, a single batch ran long enough to hit the
command timeout. The whole table's cleanup failed for that day. Batches now size themselves toward a
target duration, shrinking after a slow batch and growing after a fast one. A batch that still times
out retries once at half its size instead of giving up.
REF:
[plan-dim retention batches size themselves to the store's speed and retry a timeout (#4130) #4210]: plan-dim retention batches size themselves to the store's speed and retry a timeout (#4130) #4210
🤖 Generated with Claude Code
https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ