Repository navigation
Perfmon data is compressed per counter, so a one-counter trend reads only that counter's data (#5574) - #5579
Merged
Conversation
…er alone are re-grouped (#5574) New perfmon_stats chunks compress by server_id and counter_name. An hourly pass re-groups the already compressed chunks inside the 30-day trend reach, newest first, one transaction per chunk under the bounded lock timeout, for at most two minutes a pass. Below the floor (no per-chunk settings view, TimescaleDB 2.14.1) nothing changes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015vDtCAiuajanNsX2cbhnwg
…nverges the 30-day reach in hours (#5574) The re-group now decompresses a chunk in one transaction and compresses it in a second, so the ACCESS EXCLUSIVE lock that decompress_chunk takes at its end is released before the compress half; reads of the chunk no longer wait for the rewrite. The work moves from a 2-minute slice of each hourly pass to a background loop (PerfmonRegroupDrain): one chunk at a time, newest first, a pause after each chunk as long as it took (at least 15 s), a busy lock skips the chunk for the run, three failures in a row stop it, and it ends on its own once nothing is left.
… a busy lock, repeated failure, the compress retry, the plan shape, a concurrent reader and a service stop (#5574)
…e-cleanup ratchet), reader bound 2.5 s (#5574)
…e-group tests run in the timing collection (#5574)
erikdarlingdata
marked this pull request as ready for review
October 9, 2026 00:52
erikdarlingdata
enabled auto-merge (squash)
October 9, 2026 01:12
This was referenced Oct 9, 2026
Merged
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.
A one-counter perfmon trend over a long window now reads only that counter's compressed data. New perfmon chunks compress by server and counter. A background loop re-groups the chunks that were compressed by server alone. The hourly pass starts the loop. It works newest first, on the last 30 days, one chunk at a time, with a pause after each. A store whose TimescaleDB cannot do this keeps working exactly as before.
SECTION: Changed
ENTRY: - A long one-counter perfmon trend loads faster ([#5579]) - perfmon data is now compressed per counter as well as per server. Reading one counter (for example Batch Requests/sec) over 30 days opens only that counter's batches, not every counter's. Older compressed chunks are re-grouped in the background, back to 30 days or to the day before retention drops them, whichever is sooner. The loop takes one chunk at a time, newest first, and after each one it pauses as long as that chunk took. A read beside a re-group waits a few seconds at most. The web viewer's perfmon trend and the
get_perfmon_trendMCP tool now reach 30 days instead of 7.Details (part 1: the compression change)
The 30-day raise and the timing split are in this PR: see "The 30-day raise and the timing split".
What changes (file and symbol, all under
Darling/):PerformanceMonitor.Darling.Storage/TimescaleSupport.csPerfmonStatsSegmentBy = "server_id, counter_name".CompressionSegmentByForreturns it forperfmon_stats. The enable statement, the "is it converged" read and the new-chunk grouping all go through that one lookup, as collection_log compression: segment by collector as well as by server #4951 did for collection_log.server_idandcounter_nameby equality and nothing else. Itsobject_nametest is a suffix match. So those are the only two columns. The orderby is not set, so it stayscollection_time DESC.SettingsChangeBlockedAsync). The old collection_log method now calls it.RegroupPerfmonChunksAsyncpicks compressed perfmon chunks inside the 30-day reach. Their own grouping (fromtimescaledb_information.chunk_compression_settings) must differ fromserver_id,counter_name. It takes the newest first.decompress_chunk, a row count andcompress_chunkin ONE transaction behindSET LOCAL lock_timeout = '3s'. A busy lock skips the chunk to the next pass.TryRunBoundedDdlAsyncgot an overload that takes a body, because the re-group counts rows between the two calls. The statement-list overload calls it and behaves the same.PerformanceMonitor.Darling.Service/DarlingWorker.csgets a new last step,perfmon chunk re-group, in the store-object convergence list. It runs hourly only. The start-path delegate does nothing, so a restart is not held for minutes.PerfmonRegroupGateTestsis new, with 5 tests. They cover the grouping and its spellings, the three block reasons and the 2.14.1 view floor.TimescaleSupportTestsis updated deliberately. Every collector table keepsserver_id. Only perfmon_stats also hascounter_name.TimescaleDB version finding (checked, not assumed):
timescaledb_information.chunk_compression_settingsfirst appears in 2.14.1. The filesql/updates/2.14.0--2.14.1.sqlcreates it. The 2.14.0 views file does not have it.CollectionLogSegmentByLiveTests.RequirePerChunkSettingsAsynchas the same blind spot, because it checks>= 2.14. It is harmless there because it also checks the view.Measured on a local PostgreSQL 18.6 with TimescaleDB 2.30.1 (synthetic)
The data had 3 servers and 260 counter/instance series (200 distinct counter names). Samples came every minute for 30.7 days. That is 34.5 million rows, with 1.12 M rows per daily chunk. 27 chunks were compressed with the OLD grouping first. Then they were converged through the new code path.
server_id)server_id, counter_name)get_perfmon_stats(latest snapshot)d40a35fd...).get_perfmon_statsEXPLAIN (ANALYZE, BUFFERS), plus a progress query. It takes-v server_id=<id>.Round 2: readers are not blocked, and the 30-day reach converges in hours
This section replaces the
RegroupPerfmonChunksAsyncand "2 minutes per pass" bullets of part 1.The numbers come from a local PostgreSQL 18.6 with TimescaleDB 2.30.1 (shared_buffers 1 GB). The test chunk had 13,127,040 rows (43 servers x 61 counters, 1-minute samples). The largest store's chunks hold about 14 M. The chunk was compressed by
server_idfirst.A trend-read loop ran beside each re-group. It read one counter over 30 days, using the section-1 SQL of the rehearsal file. An idle read takes 15 ms.
1. Readers during a re-group
decompress_chunk, count,compress_chunkin ONE transactioncompress_chunk(chunk, recompress => true)Why:
decompress_chunkkeeps EXCLUSIVE (reads allowed) until its last step. There it takes ACCESS EXCLUSIVE to drop the old compressed chunk.compress_chunkkeeps EXCLUSIVE. This is from TimescaleDB 2.30.1tsl/src/compression/api.c,decompress_chunk_implandcompress_chunk_impl.recompress => trueis documented asIn-memory recompression is attempted first; it falls back to internal decompress/compress. See https://github.com/timescale/docs/blob/latest/api/compression/compress_chunk.md. The same sentence appears forconvert_to_columnstoreat https://www.tigerdata.com/docs/api/latest/hypercore/convert_to_columnstore.recompress => truefell back to the full rewrite in one statement (row 2).A long reader can hold the chunk (an open transaction that read it). Then the decompress ran 57 s and hit the 3 s lock timeout at its end. It rolled back, because it failed to get ACCESS EXCLUSIVE. A reader queued behind it waited at most 4.1 s (3 s plus noise).
That is the existing "busy lock skips the chunk" behaviour. It is bounded to a few seconds for readers. The lost work is the cost.
A crash between the two transactions
A crash leaves the chunk uncompressed, not broken. The compression policy compresses every uncompressed chunk older than a day with the hypertable's current grouping. That is the new one. The compress half is tried 3 times (3 s lock timeout each, 5 s apart,
if_not_compressed => true). After that the chunk is left to the policy, with one Warning. A service stop between the two says so at Information.2. Convergence in hours
PerfmonRegroupDrainis new (Darling/PerformanceMonitor.Darling.Storage/PerfmonRegroupDrain.cs). It is a background loop.DarlingWorker.ConvergeStoreObjectsAsync) only callsStartIfIdleafter the list and behind the TimescaleDB flag. No database work runs on its thread, so the pass is bounded again. The old list step is gone.PerfmonRegroupBlockedReasonis asked before every chunk.Rate and projection:
Test plan (round 2)
Darling.Testswith 0 warnings (after merging origin/dev).PerfmonRegroupGateTestsTotal 8, Failed 0. There are 3 new tests. The loop starts without waiting and runs once. A failed run lets a new one start. A stop ends it quietly. There is also the wiring pin.PerfmonRegroupLiveTestsTotal 1, Failed 0 on a local PostgreSQL 18.6 / TimescaleDB 2.30.1. The gate keeps the loop idle while the hypertable has the old grouping. After the settings change, 5 in-reach chunks are re-grouped newest first (log order equals catalog order). The chunk 45 days back is untouched. The count and a hash per (server, counter) are identical. There are 4 pauses of 90 s for 5 chunks (none after the last). A second run is a no-op with no Information line.CollectionLogSegmentByLiveTestsTotal 8, Failed 0. The bounded-DDL overload change did not move it.StoreObjectConvergenceTests14,StoreObjectHourlyStepSelectionTests3,TimescaleSupportTests79,RetentionReevaluationTests11 andTimescaleAvailabilityReprobeTests4: Failed 0.BoundedDdlSingleCopyTests.TheHourlyLockTimeout_IsSetInOneMethodOnlywas FAILING on the previous head. It counts the helper's overloads, and the first round added a second one. The first round never ran it. Fixed deliberately: it now expects exactly 2 overloads. The statement-list one delegates to the body one. The singleSET LOCAL lock_timeoutis still pinned inside the helper.*Census*477,*Ratchet*15,*Hygiene*90,*Scrub*123,*Guard*338,*DarlingStoreUpgrade*269 and*SingleCopy*4: Failed 0. The skips are live tests without a store.Test plan (round 1)
Darling.Testswith 0 warnings.PerfmonRegroupGateTestsTotal 5, Failed 0.TimescaleSupportTestsTotal 79, Failed 0 (16 live skipped, noDARLING_TEST_PG).StoreObjectConvergenceTestsTotal 7, Failed 0 (also on the merged rehearsal head).*Census*Total 477, Failed 0.*Hygiene*Total 90, Failed 0.*Guard*Total 338, Failed 0.*Ratchet*and*Scrub*were run in round 2 (above).Not done (handoff)
*Ratchet*and*Scrub*: round 1 did not run them. Round 2 and Round 2b did.ViewerDataService.Perfmon.csPerfmonTrendsSql:server_id = $1,counter_name IN (...)). So it benefits from the new grouping with no change. Its window cap was not checked. Lite stores perfmon in DuckDB (no TimescaleDB), so nothing changes there. Its chart window was not checked.Round 2b
The crash between the two halves is safe. Every remaining behaviour now has a live test that was seen failing with its production line reverted. No production code changed in this round, so the rehearsal build from before still stands. Round 2b changed only
PerfmonRegroupLiveTests.cs.Rehearsal branch
rehearsal/5574-on-5571:origin/dev, then Query Store interval tables are partitioned by day #5573 (0cd56725b), then this branch.DarlingWorker.cs. It kept the Query Store interval step. It dropped the old hourly re-group step, which this branch had already replaced with the background loop.dda26e6a8.8ee8668e3.origin/dev(already contained), Query Store interval tables are partitioned by day #5573 atc5fca7d1b, and this branch at8cec10e6d. No conflict this time. New head:ca0db631c018cd5084e8526829d98b249a066256. The Darling service builds with 0 warnings.Darling.Testsbuilds with 0 warnings.*QueryStoreInterval*Total 242, Failed 0.*PerfmonRegroup*Total 21, Failed 0.*Census*477,*Ratchet*15,*Hygiene*90,*Scrub*123 and*Guard*338: Failed 0. The skips are live tests without a store.The crash between the halves:
CompressRegroupedChunkAsyncalready passesif_not_compressed => true. The code already leaves an uncompressed chunk to the compression policy. So no fix was needed.if_not_compressedalready defaults to true. So the explicit argument only matters on releases where the default was false. The RED proof for that test sets it to false.New live tests in
PerfmonRegroupLiveTests(13 in the class now). Each makes its own scratch database.CompressionSegmentByForreturnsserver_idfor perfmon_statsif_not_compressed => falseThe plan test compares the same chunk before and after the re-group. Before, only
server_idis an index condition on the compressed chunk. After,counter_nameis there too. Its own RED proof is the segmentby revert, which stops the drain before the chunk is touched. The test's "before" half shows the plan can tell the two groupings apart.The reader test bound is 2.5 s. It proves nothing on a machine where a 1.5 M-row compress takes under 2.5 s. But it cannot fail there either.
RED proofs for the first round's test (
AnUpgradedStore_IsReGroupedNewestFirst_...) each failed on their own assertion:The test file holds a chunk's lock through a small disposable class, because the live-cleanup ratchet refuses a
finallyteardown in a live test.The 30-day ceiling raise is done in the last commit: see "The 30-day raise and the timing split".
Test plan (round 2b)
Darling.Testswith 0 warnings.*PerfmonRegroup*Total 21, Failed 0 (PerfmonRegroupLiveTestsTotal 13, Failed 0 after the last edit).*CollectionLogSegmentByLiveTests*8,*TimescaleSupportTests*79,*BoundedDdlSingleCopyTests*4 and*DarlingStoreUpgrade*269 (13 skipped): Failed 0.*Census*477,*Hygiene*90,*Scrub*123 and*Guard*338: Failed 0.*Ratchet*Total 15 had Failed 1 on the first run (the lockfinallyblocks). It was fixed, then Failed 0.Lite.Tests(nothing in Lite changed).Check round 1 fixes
Nine findings from the first correctness check are fixed on this branch (head b0a1a80). There is no migration.
ConvergeStoreObjectsAsyncsaid the first :30 tick starts the re-group. The first hourly pass, about 30 s after a start, starts it, and so does every hourly pass after. The comment is fixed. No test pinned the old wording.Revert proofs: I reverted each production line, saw the named test fail, then restored it. All nine failed together on the reverted build.
ABusyLock_PausesAsLongAsTheTimeItCost_AndALongOneSaysSo: the busy arm back to the floor.ADecompressThatHitsTheStatementCap_IsNotTriedAgainForTheLifeOfTheProcess: the remembered-chunk add removed.TheCompressHalf_IsNotRetriedAfterAnythingButABusyLock: retry on any outcome.WhenEveryCompressTryIsBusy_TheChunkIsLeftToThePolicy_WithOneWarning: streak fed again, two Warnings.AChunkThatStaysACandidateAfterItsReGroup_IsSkipped_WithOneWarningPerProcess: done-set check removed.AChunkWithNoPerChunkSettingsRow_IsACandidate: JOIN back.AChunkThatIsGoneAtDecompressTime_IsSkippedQuietly_NotFailed: the 42P01 arm removed.TheReach_IsClampedToTheRetentionLessADay: clamp removed.AFailedCandidatesRead_IsWarnedOncePerProcess_AndIsNotConvergence: Warning every time.BoundedDdlBusyStreakTests.TheWiring_ClearsOnApplied_AndOnFailed_AndEscalatesOnBusypins the number ofBusyStreaks.Clearcalls in the helper. It now expects 3, because the gone-relation arm clears a chunk's streak too. I changed it on purpose.Tests ran on a local PostgreSQL 18 with TimescaleDB 2.30.1 (Total / Failed):
PerfmonRegroup*29 / 0CollectionLogSegmentByLive*8 / 0TimescaleSupportTests79 / 0BoundedDdl*11 / 0DarlingStoreUpgrade*269 / 0 (13 skipped)StoreObjectConvergence*14 / 0The Darling tests and the service build with 0 warnings. I ran no full suite, because CI decides.
Check round 2 fixes
Four small defects and one weak assertion from the second check. The changes are in
TimescaleSupport.cs,PerfmonRegroupDrain.csandPerfmonRegroupLiveTests.cs. There is no migration, and the user-facing entry above does not change.statement_timeouton the role or database, or a cancel from another session. It still stops the drain from retrying the chunk until the service restarts, so a short server timeout does not cost a rewrite every hour. The Information line in the TimedOut arm ofPerfmonRegroupDrain.csnow gives the real elapsed time. When the cancel came first, it says "cancelled by the server (a statement_timeout or a cancel) after N s". It then names the 1800 s cap it fell short of. It says "hit the statement cap after N s" only when the statement ran to the cap. The new testADecompressTheServerCancelsBeforeTheCap_IsNotTriedAgain_AndTheLogSaysTheServerDidItgives the drain a 2 s sessionstatement_timeoutbeside a held lock. The existing cap test now also asserts the cap wording. RED: forcing the cap wording on every time-out fails the new test.ResolveMissingRelationAsyncinTimescaleSupport.csre-readstimescaledb_information.chunksafter a 42P01 at either half. A chunk that is not listed is Gone: a Debug line, no failure count, and no "the policy compresses it later" text. A chunk that is still listed, or a catalog that cannot be read, is a Failed chunk with a Warning that says so.CompressRegroupedChunkAsyncnow returns the chunk result (Regrouped, Gone or LeftUncompressed) instead of a bool, so the compress half can report Gone. The new testARelationErrorIsGoneOnlyWhenTheChunkCatalogAgrees_AtEitherHalfcovers the compress half on a missing chunk, and the helper on a listed chunk at both halves. RED: skipping the compress-half Gone return fails it. So does making the helper always answer Gone. We found no way to make a real decompress raise 42P01 on a chunk that still exists. The listed-chunk branch is therefore proven on the helper, not end to end.TheReach_IsClampedToTheRetentionLessADayreads the chunks inside its reach from the databasenow(). It reads them just before the run. It expects exactly those chunks re-grouped and the rest left alone. The comment onRunStartUtcsays which tests rely on slack and which read the clock. This is a test-only fix, so no production line can be reverted.Test plan (check round 2)
PerfmonRegroup*classes: Total 31, Failed 0, on a local PostgreSQL 18 with TimescaleDB 2.30.1 in UTC.BoundedDdl*,TimescaleSupportTestsandStoreObjectConvergence*together: Total 104, Failed 0.*Census*,*Ratchet*,*Hygiene*,*Scrub*and*Guard*in Darling.Tests: Total 965, Failed 0.origin/devand this branch (rehearsal head 9bd5474).The 30-day raise and the timing split
Merge of
origin/dev#5570 (the time range picker) is in dev. The merge had no conflicts, so no file needed a hand resolution.
WebReadReach.csandMcpHelpers.cscame in clean.The raise
get_perfmon_trendreaches 720 hours (30 days) on the web and over MCP, the same asget_wait_trend. On a large store, the cold 30-day read of one counter went from 11.2 s to 1.3 s. That was after the data was re-grouped by counter (see the table above). That is inside the 10 s bar the other raised trends were held to.WebReadReach.cs: the row isTrend("perfmon_stats", RawTrendHours). The block comment above the raw-table rows no longer listsget_perfmon_trendas left at 168 and cites the measured read.DarlingMcpTrendTools.cs(GetPerfmonTrend): the validator takesWebReadReach.MaxHoursFor("get_perfmon_trend"), likeget_wait_trend. The tool's guide tail now says "hours_back reaches 720 (30 days)". The number is not in thehours_backparameter text.EveryRaisedTool_StatesItsRowsNumberInTheGuideTail...requires it in the tail, because the parameter line is in everytools/listanswer.max_hoursfollows the row.Catalog_EveryHoursParam_CarriesMaxHoursFromTheTablepins it, and now pins 720 for this read.WebReadReachTestslistsget_perfmon_trendamong the raised reads and pinsMaxHoursForatRawTrendHours. A new test,ThePerfmonReGroupDrainsReach_IsThePerfmonTrendsReachInDays, tiesTimescaleSupport.PerfmonRegroupReachDaystoWebReadReach.RawTrendHours / 24and to the read's own reach, so the re-group always covers what the read reaches.WebReadReachLiveTestsasksget_perfmon_trendat 720 (answered, no error) and 721 (refused with "exceeds maximum of 720 hours (30 days)"), as it does for the other raw trends.WebReadReachTestsfail.ThePerfmonReGroupDrainsReach_IsThePerfmonTrendsReachInDays.EveryOptedInValidator_...andWebReadReachLiveTestsfail.Lite/Mcp/McpPerfmonTools.cs(line 77). Itsget_perfmon_trenduses the default 168-hourValidateWindow, as does every other Lite MCP trend. Lite has no reach table and no raised trend, and its perfmon data is in DuckDB, which the re-group does not touch. Nothing to mirror.The timing split
AReaderBesideTheReGroupOfALargeChunk_NeverWaitsMoreThanAFewSecondsandAServiceStopDuringTheCompressHalf_CancelsTheWait_AndSaysTheChunkIsLeftToThePolicystart a clock and judge a time against a limit. They moved, with every assertion and bar unchanged, into the newPerfmonRegroupTimingLiveTestswith[Collection("timing")], so they can run on a quiet process. Both mint their own scratch database, so the class carries the#1776 own-storecomment instead oflive-postgres.The helpers they share with
PerfmonRegroupLiveTestsare in the new staticPerfmonRegroupLiveSupport(nothing was copied). No guard rejects the undefined collection name: Census, Ratchet, Hygiene, Scrub, Guard, DocComment and theStage=Guardtrait all pass. I ran the clock rule of the open timing-collection change over the nine test files this PR touches. The only matches are the twoStopwatch.StartNewcalls inPerfmonRegroupTimingLiveTests. None of the files starts an STA thread.A time-of-day defect fixed in the same file
AFreshStore_CompressesItsFirstChunksWithTheNewGrouping_AndTheDrainHasNothingToDoseeded the chunks 2, 3 and 4 days back and expected the policy to compress all of them. The perfmon policy compresses after 1 day plus 3 hours. Between 00:00 and 03:00 UTC the chunk 2 days back is not yet due, and the test failed (it did, at 00:37 UTC). It now seeds 3, 4 and 5 days back, which are due at any hour. Every assertion is unchanged.Test plan (this part)
On a local PostgreSQL 18.6 with TimescaleDB 2.30.1, in UTC:
*PerfmonRegroup*,*BoundedDdl*,*TimescaleSupportTests*,*WebReadReach*and*DarlingMcpTrendTools*together: Total 187, Failed 0.*McpToolGuide*,*McpToolsListBudget*,*WebRangeKeptHistory*,*WebServerPageRange*,*WebPerfmon*,*WebTimeRange*,*TrendPayloadBudget*,*TrendStaleStats*,*PerfmonWaitStatisticsArtifact*: Total 237, Failed 0.*Census*,*Ratchet*,*Hygiene*,*Scrub*,*Guard*,*DocComment*: Total 973, Failed 0.Stage=Guardtrait: Total 1434, Failed 0.AReaderBesideTheReGroupOfALargeChunk_NeverWaitsMoreThanAFewSecondsfailed once in its new class. That run was on a cold local server, besideWebReadReachLiveTestsin parallel. The timing collection does not serialize until the timing-collection change merges. The test passed alone, beside that class again, and in the two full-class runs that include it. The bar is unchanged.Fixes #5574
Measured on a copy of a large store
These are from #5574's Part A (before) and Part B (after) on a restored copy of a large SQL Server store. Details are in issuecomment-6067584601 and issuecomment-6069386286.
UNION ALLlegMerge Appendwhere it used aSort. It stays under 10 ms.