diff --git a/Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs b/Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs index d04265a0a2..5c3917b868 100644 --- a/Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs @@ -111,6 +111,47 @@ public void Reads_ArePostgresDialect_NoTsqlIsms_NoBareNow(string sqlName) Assert.DoesNotContain("now()", sql, StringComparison.Ordinal); } + /// + /// The daily series is a LAST-SNAPSHOT selection, so the description has to say so (#3119). An + /// operator asking a maximum question of it — this job's longest run that day, whether it entered its + /// warning band — gets a confident wrong answer, because the day's peak is dropped rather than + /// smoothed, and nothing else on the surface says so. + /// + /// Asserted TOGETHER with the shipped SQL rather than on its own. The caveat is true only + /// while the read is a DISTINCT ON with a newest-first tiebreak; a read that became + /// max-preserving would make the sentence wrong in the other direction. Requiring both means either + /// half moving lands here and the pairing gets re-decided, instead of the sentence outliving the query + /// it describes. + /// + /// One ordered match rather than three substrings, and deliberately strict. A description + /// that said "last snapshot" about some other field and "maximum" about a third would satisfy three + /// independent Contains calls while telling a reader nothing about the daily series. Strictness + /// here fails toward re-deciding the sentence: a rewording that still carries the claim goes red and + /// gets re-approved, where a looser match would let a rewording that DROPPED it pass. + /// + [Fact] + public void TheDescription_SaysADailyPointIsALastSnapshotAndNotAMaximum() + { + var description = ToolMethods().Single().GetCustomAttribute()?.Description; + Assert.NotNull(description); + + /* The selection the caveat is about. Both halves: DISTINCT ON alone would keep an arbitrary row, + and it is the newest-first tiebreak that makes the kept row the day's LAST. */ + var sql = DarlingStoreMetricsReader.StoreMetricsDailySql; + Assert.Contains( + "DISTINCT ON (object_kind, object_name, date_trunc('day', metric_time))", + sql, + StringComparison.Ordinal); + Assert.Contains("metric_time DESC", sql, StringComparison.Ordinal); + + /* The claim, in one match: the daily point, what it IS, and what it is not. */ + Assert.Matches(@"daily point is that day's LAST snapshot[^.]*maximum", description!); + + /* And the route that answers what this series cannot, so the caveat leaves a reader somewhere to + go rather than only telling them to distrust the number in front of them. */ + Assert.Contains("timescaledb_information.job_history", description!, StringComparison.Ordinal); + } + [Fact] public void GetStoreMetrics_IsInTheServerInstructions() { diff --git a/Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs b/Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs index d209237aed..73cd9bf474 100644 --- a/Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs +++ b/Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs @@ -36,8 +36,9 @@ namespace Darling.Tests; /// , /// , /// ) or from the POPULATION THE COMMENT ITSELF -/// PUBLISHES. A pin that restated the summary would go stale by precisely the mechanism it exists to -/// stop. +/// PUBLISHES — or, for the live envelope, from a QUOTED READING the same sentence states, with the +/// figures drawn against that reading derived rather than restated. A pin that restated the summary +/// would go stale by precisely the mechanism it exists to stop. /// /// One pin is written to expire, deliberately. The comment's reason for taking the maximum /// rather than a percentile is partly that at this sample size the 95th percentile IS the maximum by nearest @@ -64,7 +65,12 @@ namespace Darling.Tests; /// mode that matters: an unfiltered reading migrating into the population a constant may be set from. The /// pre-narrowing band and the 3.8x ratio drawn against it are likewise evidence — that band is #3012's /// measurement and no constant here spells it — so they carry no pin. The excluded run's 1.45x ratio to the -/// population maximum IS pinned, because both of its terms are stated here. +/// population maximum IS pinned, because both of its terms are stated here. The live envelope's measured +/// maximum is evidence on the same footing, and its day count with it; what IS pinned is every figure the +/// prose draws AGAINST that maximum — the slot margin, the share of the slot and the distance past the +/// watch line are all arithmetic over it and the grid's own constants, plus the band the shipped +/// classifier actually returns for it, so the paragraph cannot state a verdict the code does not +/// produce. /// /// The guard is itself guarded, three ways. Every extraction asserts its pattern MATCHED before /// a value is compared. bumps each captured number ONE @@ -212,6 +218,17 @@ TheInadmissibleReadings_CannotBeFoldedIntoTheStatusVerifiedSeries and the as-at @"At ([0-9]+) s against a ([0-9]+)-second slot the margin is ([0-9]+) seconds", true); + /* THE LIVE ENVELOPE (#3119). The measured maximum itself is a quoted reading and derives from + nothing here — but it is captured anyway, because every figure stated against it is derived + from it and from the grid's constants, so a bump to the reading has to disagree with all of + them rather than pass as a second opinion. Every group is a pair, since each figure is stated + to one decimal and the verification works in tenths. */ + yield return ( + "the measured live envelope", + CeilingDeclaration, + @"this job's maximum was ([0-9]+)\.([0-9]+) s\. That leaves ([0-9]+)\.([0-9]+) s of the slot, ([0-9]+)\.([0-9]+)% of it, and sits ([0-9]+)\.([0-9]+) s PAST", + true); + yield return ( "the exclude-whole occupancy sentence", CompressionMinutesDeclaration, @@ -658,6 +675,63 @@ takes the maximum partly because at this sample size the 95th percentile is the Require(margin[1] == Slot, $"stated slot {margin[1]} s against {Slot} s"); Require(margin[2] == Slot - Ceiling, $"stated margin {margin[2]} s against {Slot - Ceiling} s derived"); + /* THE LIVE ENVELOPE (#3119), which is the margin sentence's counterpart: that one states the + clearance the published population carries and this one states what a measured day leaves. In + TENTHS throughout, because each figure is stated to one decimal and integer arithmetic keeps a + rounding mode from being able to move an assertion. */ + var live = Read("the measured live envelope"); + var measured10 = live[0] * 10 + live[1]; + var statedMargin10 = live[2] * 10 + live[3]; + var statedShareTenths = live[4] * 10 + live[5]; + var statedPastWatch10 = live[6] * 10 + live[7]; + + Require(measured10 > Ceiling * 10, + $"the measured maximum {measured10 / 10}.{measured10 % 10} s is at or below the {Ceiling} s " + + "recorded ceiling, so a paragraph stating it as an envelope NARROWER than the population's " + + "clearance is describing something that is not the case"); + + /* THE VERDICT, from the SHIPPED classifier and ABOVE the arithmetic, because Verify is fail-fast: + a maximum restated low enough to change its band trips the margin equality below on the way + past, and a verdict clause underneath that one could not go red on its own. It is also the only + clause here that reads the code rather than the constants - the two inequalities say where the + reading sits, and only the classifier says what the shipped code does with it. */ + Require( + TimescaleSupport.ClassifyRefreshSlotHeadroom(measured10 / 10.0) + == TimescaleSupport.RefreshSlotHeadroom.ApproachingSlot, + $"ClassifyRefreshSlotHeadroom bands {measured10 / 10}.{measured10 % 10} s as " + + $"{TimescaleSupport.ClassifyRefreshSlotHeadroom(measured10 / 10.0)} rather than " + + "ApproachingSlot, so the live envelope paragraph states a verdict the shipped classifier " + + $"does not produce. Against a {Slot} s slot the warning band opens at {WatchLine} s"); + + Require(statedMargin10 == Slot * 10 - measured10, + $"stated slot margin {statedMargin10 / 10}.{statedMargin10 % 10} s against " + + $"{(Slot * 10 - measured10) / 10}.{(Slot * 10 - measured10) % 10} s derived from the " + + $"{Slot} s slot less the measured maximum"); + + /* The distance PAST the watch line, which also carries the strict-past claim: a maximum at or + below the line makes this difference zero or negative, and no figure the pattern can hold + equals that. */ + Require(statedPastWatch10 == measured10 - WatchLine * 10, + $"stated distance past the watch line {statedPastWatch10 / 10}.{statedPastWatch10 % 10} s " + + $"against {(measured10 - WatchLine * 10) / 10}.{(measured10 - WatchLine * 10) % 10} s " + + $"derived from the {WatchLine} s line"); + + Require(statedShareTenths == statedMargin10 * 1000 / (Slot * 10), + $"stated {statedShareTenths / 10}.{statedShareTenths % 10}% of the slot against " + + $"{statedMargin10 * 1000 / (Slot * 10) / 10}.{statedMargin10 * 1000 / (Slot * 10) % 10}% " + + $"derived from {statedMargin10 / 10}.{statedMargin10 % 10} s over the {Slot} s slot, " + + "truncated to one decimal as the prose states it"); + + /* AND ITS CLOSING SCOPE, the same rule the snapshot enumeration below carries. An absolute day + has ENDED and stays true; "recently", "the latest day" or a bare "currently" is read against a + clock a doc comment does not have, so it rots while looking scoped. */ + Require( + Regex.IsMatch(ceilingProse, + @"Over [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] - one closed day"), + "the live envelope's population is no longer CLOSED by an absolute day, so those figures read " + + "as a claim about current load and are false as soon as the load moves. Restore a window " + + "that has ended, or drop the figures and keep the mechanism, which is the timeless half"); + /* The EXCLUDED run. Its clock arithmetic has to reproduce its own stated duration, it has to end one second after the stated boundary, and its ratio to the population maximum has to be the ratio the prose draws — which is what keeps that paragraph about this population rather than free-floating. */ diff --git a/Darling/Darling.Tests/TimescaleSupportTests.cs b/Darling/Darling.Tests/TimescaleSupportTests.cs index 8c7362dc3b..9da93f9c89 100644 --- a/Darling/Darling.Tests/TimescaleSupportTests.cs +++ b/Darling/Darling.Tests/TimescaleSupportTests.cs @@ -2656,6 +2656,11 @@ public void TheRefreshSlotLogLine_IsLeveledByBand_AndSaysNothingWithoutAReading( approaching); Assert.StartsWith("Warning:", approaching.Joined, StringComparison.Ordinal); + /* #3119: the Warning line names the route that can answer a maximum question. The hourly + self-metrics series serves a last-snapshot-per-day point, so it cannot stand alone as the + per-run history an operator following this line goes looking for. */ + Assert.Contains("timescaledb_information.job_history", approaching.Joined, StringComparison.Ordinal); + var exceeded = new CapturingTestLogger(); TimescaleSupport.LogHeaviestRefreshSlotHeadroom( new HeaviestRefreshSlotReading( diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs index 1969428b85..9c3c25c20e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -245,7 +245,7 @@ public static string Build(DarlingPeerDirectory.Snapshot peers) ### Store self-metrics - `get_store_metrics` reads the MONITORING STORE's own growth series — not a monitored SQL Server's. The service records an hourly self-metrics snapshot into the store: per-hypertable total size, pre/post-compression bytes and chunk count; the query-text / query-plan payload dimension tables' total size (the store's dominant payloads) and row counts; and the whole store's size with the enabled-server count. The tool returns the latest snapshot per object plus a daily series, including the whole-store daily growth in bytes and the derived per-server ingest rate (daily growth ÷ enabled servers) — the number to multiply when onboarding N servers. Use it for capacity forecasting: what is driving store growth and how fast. 400 days of history; no `server_name` parameter, because the store is the subject. + `get_store_metrics` reads the MONITORING STORE's own growth series — not a monitored SQL Server's. The service records an hourly self-metrics snapshot into the store: per-hypertable total size, pre/post-compression bytes and chunk count; the query-text / query-plan payload dimension tables' total size (the store's dominant payloads) and row counts; and the whole store's size with the enabled-server count. The tool returns the latest snapshot per object plus a daily series, including the whole-store daily growth in bytes and the derived per-server ingest rate (daily growth ÷ enabled servers) — the number to multiply when onboarding N servers. Each daily point is that day's LAST snapshot rather than its maximum, so a maximum question needs the per-run route the tool's own description names. Use it for capacity forecasting: what is driving store growth and how fast. 400 days of history; no `server_name` parameter, because the store is the subject. | Tool | Purpose | Key Parameters | |------|---------|----------------| diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs index 6ad457e95a..206154b16d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs @@ -36,7 +36,7 @@ public sealed class DarlingMcpStoreMetricsTools public const int MaxDaysBack = StoreSelfMetrics.RetentionDays; [McpServerTool(Name = "get_store_metrics"), Description( - "Gets the monitoring store's OWN size and growth metrics — not a monitored SQL Server's. The service records an hourly self-metrics snapshot: per-hypertable total size, pre/post-compression bytes and chunk count; the query-text and query-plan payload dimension tables' total size (the store's dominant payloads) and row counts; the whole store's size with the enabled-server count; and one row per TimescaleDB background job (CAGG refresh, compression, retention) with its last run duration, schedule interval, duration-vs-cadence percent, and run/failure totals — the jobs whose runtimes scale with fleet size. Returns the latest snapshot per object plus a daily series over the window, with the whole-store daily growth in bytes and the derived per-server ingest rate (daily growth / enabled servers). Also reports, read LIVE from the catalog rather than from the recorded series, every retention policy the rollup-coverage gate is holding PAUSED, with the tier's actual data span and how many times its configured drop_after horizon it is really holding — a held policy records zero failures and a normal-looking last run, so it is invisible in the stored job telemetry and is a common cause of unexplained store growth. Use for capacity forecasting: what is driving store growth, how fast, what adding N servers would multiply, which background job is closest to outgrowing its own cadence, and whether retention is actually running.")] + "Gets the monitoring store's OWN size and growth metrics — not a monitored SQL Server's. The service records an hourly self-metrics snapshot: per-hypertable total size, pre/post-compression bytes and chunk count; the query-text and query-plan payload dimension tables' total size (the store's dominant payloads) and row counts; the whole store's size with the enabled-server count; and one row per TimescaleDB background job (CAGG refresh, compression, retention) with its last run duration, schedule interval, duration-vs-cadence percent, and run/failure totals — the jobs whose runtimes scale with fleet size. Returns the latest snapshot per object plus a daily series over the window, with the whole-store daily growth in bytes and the derived per-server ingest rate (daily growth / enabled servers). Each daily point is that day's LAST snapshot, never its maximum or its mean — the settled figure a growth question wants, but it means a MAXIMUM question (what was this job's longest run that day, did it enter its warning band) cannot be answered from this series: the day's peak is DROPPED rather than smoothed, so a day whose worst run crossed a threshold reads as a day that never approached it. The route that carries one row per run is TimescaleDB's own job history (timescaledb_information.job_history). The hourly snapshot behind the series has the same limit one grain down: it samples last_run_duration once an hour at a fixed offset, so a run longer than that offset is never recorded at all. Also reports, read LIVE from the catalog rather than from the recorded series, every retention policy the rollup-coverage gate is holding PAUSED, with the tier's actual data span and how many times its configured drop_after horizon it is really holding — a held policy records zero failures and a normal-looking last run, so it is invisible in the stored job telemetry and is a common cause of unexplained store growth. Use for capacity forecasting: what is driving store growth, how fast, what adding N servers would multiply, which background job is closest to outgrowing its own cadence, and whether retention is actually running.")] public static async Task GetStoreMetrics( NpgsqlDataSource postgres, [Description("Days of daily-series history. Default 30; max 400 (the series' own retention).")] int days_back = 30) diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs index 2ca860aa36..d479bac49d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs @@ -20,7 +20,9 @@ namespace PerformanceMonitor.Darling.Service.Mcp; /// snapshot per object (one row per hypertable / dimension / the store itself, at each object's newest /// metric_time), and the daily series — the LAST sample of each object per day, which is what a /// growth/forecast question wants (the hourly grain exists so a single missed run costs nothing, not -/// because anyone forecasts by the hour). Plus the one derivable number the issue called out: the +/// because anyone forecasts by the hour) and what a MAXIMUM question cannot use: the day's largest +/// reading is dropped rather than smoothed, so a peak that crossed a threshold is invisible at this +/// grain (#3119). Plus the one derivable number the issue called out: the /// per-server daily ingest rate (whole-store daily growth divided by the enabled-server count), computed in /// — pure, so it is unit-tested without a store. /// @@ -48,7 +50,10 @@ FROM collect.store_metrics /// The daily series — the LAST sample of each object per day (DISTINCT ON over the day /// bucket, newest first within it), so each day contributes one settled point per object rather than - /// 24 near-duplicates. $1 window start (naive UTC). + /// 24 near-duplicates. A last snapshot and not a per-day maximum: the tiebreak takes the newest row + /// in the bucket, so the day's largest value is discarded unless it happens to be the last one, and + /// get_store_metrics' description says so where a caller reads it (#3119). $1 window start + /// (naive UTC). public const string StoreMetricsDailySql = @" SELECT DISTINCT ON (object_kind, object_name, date_trunc('day', metric_time)) object_kind, diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index be7dc176d0..c17831f67c 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -1880,13 +1880,28 @@ public static int RefreshPhaseMinutesFor(string view) /// AccessShareLock on the same hypertable — the mechanism #3012 measured, which never needed chunk /// overlap at all. /// - /// So: the small-residual reading holds while this job completes well inside its slot, and what - /// invalidates it is this number approaching x 60. At 594 s - /// against a 900-second slot the margin is 306 seconds, better than a third of the slot; the heaviest + /// So: the small-residual reading is conditional on how long this job runs, and what + /// invalidates it is that runtime approaching x 60. At 594 s + /// against a 900-second slot the margin is 306 seconds — the clearance the population above carries, a + /// property of that closed record rather than of current load; the heaviest /// slot is excluded WHOLE rather than guarded on the guard band being shorter than the refresh rather /// than on the refresh filling the slot (see ). A value at or past /// the slot width is asserted as a failure rather than accommodated: past that point the refresh runs /// into its neighbour and the grid needs redesigning, not renumbering. + /// + /// THE LIVE ENVELOPE, taken from the census read named above and stated apart from that + /// clearance because the two describe different populations (#3119). Over 2026-09-06 — one + /// closed day, its 22 runs read from timescaledb_information.job_history at one row per run — + /// this job's maximum was 778.4 s. That leaves 121.6 s of the slot, 13.5% of it, + /// and sits 28.4 s PAST , which + /// bands + /// . Short of the slot width, so the grid's + /// precondition holds and this is not the failure the paragraph above asserts — but the residual is D + /// wide and D is that maximum, so "completes well inside its slot" is a description of the closed + /// population and not of that day. RefreshCeilingProvenancePinTests derives every figure stated against + /// that maximum from the grid's own constants and takes the band from the shipped classifier, so a + /// moved grid step moves them all and a reading that stopped classifying as a warning goes red rather + /// than sitting here as prose. /// public const int HeaviestHourlyRefreshObservedCeilingSeconds = 594; @@ -1916,8 +1931,9 @@ public static int RefreshPhaseMinutesFor(string view) /// down as an answer — the same shape as , whose /2 is /// also a chosen fraction of a derived quantity. Two things had to hold. It must clear the routine band: /// five consecutive live readings during #3044's own review came back 335 s, 594 s, 465 s, 359 s and - /// 293 s — 32.6% to 66.0% of the slot — so a line at 83.3% leaves the observed peak a full sixth of the - /// slot below it and cannot cry wolf on load this store has already carried. And it must leave usable lead + /// 293 s — 32.6% to 66.0% of the slot — so a line at 83.3% leaves the peak of THAT set a full sixth of + /// the slot below it, which is what keeps it off the load those five represent. And it must leave + /// usable lead /// time: the remaining sixth is 150 s here, while the walk that carries this job through the hour advances /// by its own runtime each cycle (see the finish-to-start note on /// ), so the warning lands while the job still finishes inside @@ -1933,12 +1949,14 @@ public static int RefreshPhaseMinutesFor(string view) /// against the ceiling has to carry the decision on its own. /// /// This line sits ABOVE , and that is - /// what makes a crossing mean something. 750 s is 26% above the largest run the narrowed - /// regime has been observed to produce, so a reading in this band is not "approaching a known ceiling" — - /// it is this job doing something its own record has no instance of, which is a different signal calling - /// for a different response. The relationship is what is pinned, not the two numbers: a ceiling that rose - /// past this line would put the grid's own sizing figure inside the warning band, and the pin says so - /// rather than leaving a reader to notice. + /// what makes a crossing mean something. 750 s is 26% above the maximum of the closed + /// population that constant is derived from, so a reading in this band is not "approaching a known + /// ceiling" — it is past the whole of the record the compression grid is sized against, which is a + /// different signal calling for a different response. The band is reachable and not hypothetical: the + /// live envelope on puts a measured run past + /// this line, stated there and not restated here so the figures have one home. The relationship is what + /// is pinned, not the two numbers: a ceiling that rose past this line would put the grid's own sizing + /// figure inside the warning band, and the pin says so rather than leaving a reader to notice. /// public const int RefreshSlotWarningSeconds = RefreshPhaseSlotSeconds * 5 / 6; @@ -4204,9 +4222,15 @@ public static async Task> ReadJobCadenceRe /// this both tick hourly but on independent anchors, and the refresh's start-minute walks (finish-to-start, /// so it advances by its own runtime each cycle), so some runs are seen twice and some not at all. That is /// adequate and it is what the condition needs: the thing being watched is a runtime trending with volume - /// over days, and a figure that persists near the line is seen by every tick. The complete per-run series - /// already exists in collect.store_metrics (object_kind = 'background_job', #2136/V56) — this - /// read supplies the BOUND, which is what was missing, not the history. + /// over days, and a figure that persists near the line is seen by every tick. Neither this read nor the + /// recorded series it is often confused with is MAX-PRESERVING: collect.store_metrics + /// (object_kind = 'background_job', #2136/V56) is the same hourly grain taken by a different + /// sweep, and the daily point get_store_metrics serves from it is that day's LAST reading rather + /// than the day's largest — so a maximum question asked of either lands short, and a day's peak is + /// dropped rather than smoothed. The route that carries one row per run is + /// timescaledb_information.job_history, named on + /// and read there for the live envelope + /// (#3119). This read supplies the BOUND, which is what was missing, not the history. /// public static string HeaviestRefreshRuntimeSql => $@" @@ -4313,7 +4337,7 @@ public static void LogHeaviestRefreshSlotHeadroom(HeaviestRefreshSlotReading? re case RefreshSlotHeadroom.ApproachingSlot: logger.LogWarning( - "TimescaleDB: {View}'s hourly refresh last ran {Seconds:F0}s against the {Slot}s refresh slot it has to fit inside ({Percent:F1}% of it, {Clear:F0}s clear) — past the {Warn}s watch line. These runtimes scale with raw data volume, and at the slot width the compression phase grid has to be re-derived rather than renumbered (#3035). Its per-run series is collect.store_metrics where object_kind = 'background_job' (#3044).", + "TimescaleDB: {View}'s hourly refresh last ran {Seconds:F0}s against the {Slot}s refresh slot it has to fit inside ({Percent:F1}% of it, {Clear:F0}s clear) — past the {Warn}s watch line. These runtimes scale with raw data volume, and at the slot width the compression phase grid has to be re-derived rather than renumbered (#3035). Its per-run history is timescaledb_information.job_history, one row per run; the hourly collect.store_metrics series (object_kind = 'background_job') samples one reading an hour and serves a daily point that is the day's LAST, so neither answers a maximum question (#3044, #3119).", reading.View, reading.LastRunSeconds, RefreshPhaseSlotSeconds, reading.PercentOfSlot, reading.ClearOfSlotSeconds, RefreshSlotWarningSeconds); break;