Repository navigation
Skip locking fresh dimension rows; halve the Query Store liveness-touch rate (#4249, #4250) - #4288
Merged
Merged
Conversation
…touch-guard divisor PayloadDimensions.UpsertSql: add a WHERE NOT EXISTS pre-filter (plain MVCC read, no lock) so a digest already fresh within the hour never reaches ON CONFLICT and never takes a row lock. The ON CONFLICT ... WHERE arm stays for the remaining cross-session race. Add ORDER BY u.digest to both branches, since the new anti-join lets the planner return survivors out of unnest's input order; keep the client-side sort too and rewrite the comment to say why both exist. Update the pinned exact-SQL tests and add a source pin that both branches carry the ORDER BY. QueryStoreLivenessTouchGuard.MarginShareDivisor: 4 -> 2 (12-hour guard, half the touches). No PruneMarginDays changed. Comment rewritten with the measured 6-hour-guard volume (~163K touches/hour, ~3.9M/day, all non-HOT) and the worst-case headroom (2-day floor / 12h = 4x). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
PreFilter_SkipsLockingFreshRows_ButStillRefreshesStaleOnes_AndInsertsNewDigests, against a real Postgres rig: - A second upsert of the same still-fresh 50-row batch, 30 minutes later, leaves xmax = 0 on every row (never locked) and advances pg_current_wal_lsn() by under 1 KB (measured 0 bytes on a raw-SQL rehearsal of the same shape). - A row stamped two hours ago is still refreshed. - A brand-new digest in the same flush is still inserted. Proved the pin once by reverting the WHERE NOT EXISTS pre-filter: all 50 fresh rows came back locked (expected 0, actual 50). Restored the fix immediately after; git diff on PayloadDimensions.cs is empty. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Restart_ANewConnectionRetouchesNothingFresh_TheGuardReadsStoredLastSeen, against a real Postgres rig. Stamps a plan and a text row through the touch guard, then probes the SAME references again from a brand-new connection (the closest proxy this static, connection-scoped design has to a new writer instance) five minutes later, still inside the guard window. Zero rows updated anywhere: the map, plan-dimension, and text relations all still carry the FIRST touch's last_seen stamp, none carry the second, and the fresh connection's fetch-probe verdicts still resolve. Confirms the guard's state lives entirely in the stored last_seen column, not in any writer-side memory a restart could lose or duplicate. 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 15:08
This was referenced Sep 25, 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 #4249. Part of #4250.
Why
Two sources of needless writes to the Darling store, both measured on production stores:
PayloadDimensions.UpsertSqlguards itsON CONFLICT DO UPDATEwith aWHEREthat skips a digest seen in the last hour. PostgreSQL locks each conflicting row before it evaluates thatWHERE. So a digest seen again inside the hour still wrote aHeap/LOCKWAL record and dirtied its page on every collection cycle. That was 12 to 23 percent of one store's WAL, and 12.4 percent of another's.last_seenis older than the guard. WithMarginShareDivisorat 4, the guard was 6 hours, a width with no measurement behind it. On the store that ingests Query Store, these touches came to about 163,000 an hour (3.9 million a day). All were non-HOT updates, and together they were about 13.5 percent of its WAL.What changes
#4249: skip fresh digests before any lock
In both branches of
UpsertSql(Darling/PerformanceMonitor.Darling.Storage/PayloadDimensions.cs):SELECT:WHERE NOT EXISTS (SELECT 1 FROM <dim> d WHERE d.digest = u.digest AND d.last_seen >= $3 - INTERVAL '1 hour'). It is a plain MVCC read, which takes no lock. A digest that is already fresh never reachesINSERTorON CONFLICT, so it writes nothing.ON CONFLICT ... WHEREguard stays. It covers the race the pre-filter cannot: two sessions whose pre-filter reads both run before either commits.ORDER BY u.digeston theSELECT. The pre-filter makes the statement a join, and a join can return rows out of input order. A hash anti-join returns them in hash order. The client-side digest sort inPayloadDimensionBatch.ToArrays(Payload-dim batch upserts deadlock under concurrent collection cycles: unnest arrays carry no cross-session lock order #1801) then no longer sets the order in which rows take their locks, and two sessions can deadlock. TheORDER BYsets that order whatever join the planner picks. The client-side sort stays.Not changed: sending payloads only for new digests saves network traffic, not disk writes, so it is out of scope.
Lite has no dimension tables. It stores query text and plan XML in each row and has no
ON CONFLICTupsert. So nothing there locks a row again when it sees the same content.#4250, items 1 and 2: a 12-hour guard
QueryStoreLivenessTouchGuard.MarginShareDivisorgoes from 4 to 2.StampSkewMarginHoursstays at 24 (fromQueryStorePlanMap.PruneMarginDays= 1), so the guard widens from 6 hours to 12, and the touch rate roughly halves.QueryStorePlanMap.MarginOrderingHoldsstill holds: the map's 1-day margin is still under the dimension margin of 2 days (ChunkIntervalDays + 1).last_seen, not anything the process or the connection holds. A new live test proves it.Item 3 of #4250, a HOT-only touch, waits for its own measurement.
Measured on a local PostgreSQL 18 rig
This is
EXPLAIN (ANALYZE, BUFFERS)of the new statement against aquery_plan_dimof 50,000 rows (28 MB). 5,000 rows were marked fresh in the last 5 minutes. The batch had 500 digests: 490 fresh and 10 new.The planner chose a nested loop anti-join with one index probe per batch row, not a hash anti-join. 10 of the 500 rows passed the pre-filter, 10 were inserted, and none reached
ON CONFLICT. It read 2,100 buffers, all from cache, in 1.5 ms. TheSortnode underInsertis theORDER BY. It sets the lock order for either join type.Tests
PayloadDimensionLiveTests.PreFilter_SkipsLockingFreshRows_ButStillRefreshesStaleOnes_AndInsertsNewDigests: a second upsert of the same fresh 50-row batch, 30 minutes later, leavesxmax = 0on every row, so none was locked. The WAL position moves less than 1 KB. A row stamped 2 hours earlier is refreshed, and a new digest in the same batch is inserted. With the pre-filter removed from one branch, the test failed: all 50 fresh rows came back locked. With the pre-filter restored, it passed.PayloadDimensionTests.UpsertSql_SourceCarriesOrderByOnBothBranches: both branches ofUpsertSqlcarryORDER BY u.digest.QueryStoreFetchProbeLivePostgresTests.Restart_ANewConnectionRetouchesNothingFresh_TheGuardReadsStoredLastSeen: a plan row and a text row are touched once. Then a new connection probes them again 5 minutes later, inside the guard. No row on the map, plan-dimension or text table is updated.Test plan
dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Debug: 0 warnings, 0 errors.PayloadDimensionTests,QueryStoreTouchGuardSingleSourceTests,QueryStorePlanFetchTestsandDarlingDimensionGcBoundTests: 91 tests, 0 failed.DocCommentHygieneTests: 77 tests, 0 failed.PayloadDimensionLiveTests, 18 tests, 0 failed.QueryStoreFetchProbeLivePostgresTests, 2 tests, 0 failed.Darling.Testsonce, against the rig on a newdarlingtestdatabase: 14,006 tests, 0 failed, 49 skipped, 1 not run. The skips are live classes that need a managed runtime or a log-format cluster. Dev's own CI run shows the same 1 not run.53405f7f: build, Darling PostgreSQL tests and Lite tests all pass.CHANGELOG entry
SECTION: Fixed
ENTRY:
REF:
[Skip locking fresh dimension rows; halve the Query Store liveness-touch rate (#4249, #4250) #4288]: Skip locking fresh dimension rows; halve the Query Store liveness-touch rate (#4249, #4250) #4288
🤖 Generated with Claude Code
https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ