Establish the live-postgres store in a collection fixture so the gated-live suite stops depending on test order (#1862) - #1874
Conversation
…by test order PgMigrations.MigrateAsync deliberately does not create the TimescaleDB extension - the migrations are engine-plain and TimescaleSupport.TryEnableAsync installs it at runtime, right after migration, on every service start. Nothing in the test suite stood in for that second step, so "the store is established" was an emergent property of test order: CREATE EXTENSION is persistent and database-wide, so the first live class to call TryEnableAsync silently established the store for the sixty-odd that ran after it. PayloadDimensionLiveTests.DimensionGc_DefersWhenAFactFloorIsUnmeasurable_ ThenPrunesOnceItIs reads timescaledb_information.continuous_aggregates without enabling anything itself. Run first against a fresh database it died 3-5ms in with 42P01; run after any of its three siblings it passed. The failure moved between runs because it landed on whichever class drew the short straw, which reads as though the change under test broke something it never touched. CI could not catch it - darling-pg builds a throwaway cluster per run, so green meant the scheduling lottery came up good, not that the suite was order-independent. The live-postgres collection now has a CollectionDefinition carrying LivePostgresStoreFixture, which migrates the store and enables TimescaleDB once, before the first class in the collection runs. Existing classes are unchanged - collection fixtures do not require injection. Migration runs BEFORE the extension deliberately: V23 branches on pg_extension, and TimescaleSupportTests depends on the fresh-store path where that guard skips. The fixture deliberately stops there - hypertable conversion, aggregates and retention are what the live classes are testing. Two ordering bugs it had been masking, both fixed: - A continuous aggregate cannot be built over a heap, and EnsureContinuousAggregatesAsync is failure-isolated per aggregate, so against un-converted tables it creates nothing and the next force-refresh dies on a view that was never created. The conversion moves into EnsureAggregatesWithoutPoliciesAsync, where three of its four callers already did it on their way in. - DarlingAnomalyBaselineTests asserted a return count of nine from EnsureBaselineFallbackViewsAsync. That return is how many it CREATED and it skips relations already present, so a store with a sibling's aggregate still standing legitimately answered eight - the reported 9/8. It now asserts all nine EXIST, which is what the surrounding comment says the test needs. Two guards, because a fixture that silently stopped running would bring the flake straight back. LivePostgresStoreFixtureTests establishes nothing and asserts the store is already migrated and extension-enabled; taking the fixture as a constructor parameter means unhooking it fails the class outright rather than passing by luck of ordering. The #1776 hygiene rule exempts the fixture by reflecting off the CollectionDefinition rather than accepting a third prose marker. Writing that exemption exposed a real hole in the hygiene rule: it matched the attribute anywhere in the 25-line header, so any class whose doc comment quoted [Collection("live-postgres")] while explaining the rule exempted itself from it. The attribute now counts only when it opens a line, which every real one does and no prose mention here does. Closes #1862. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewScope: test-infrastructure only ( Correctness — verified against the surrounding code, not just the PR description
Minor, non-blocking observations
OverallNo correctness bugs found. The fix is well-targeted at the actual root cause (order-dependent, database-persistent side effects with no collection-level setup), the two "masked" bugs it also fixes are genuine and independently verifiable against the referenced code, and the guard tests ( |
… stop racing it Pre-existing flake on dev, caught by CI on this branch and root-caused here. The same test failed on dev at 7e40c85 (an ancestor of this branch's base) with Expected 2 / Actual 3, and on this branch with Expected 1 / Actual 0 on the very next line. One cause, two failures that look nothing like each other. add_compression_policy creates its job SCHEDULED with no initial_start, and TimescaleDB launches it within a second or two - the #1788 behaviour. All three of these tests added the policy AFTER inserting eligible chunks, so that background run had chunks to compress and competed with the deterministic foreground run_job the tests are built around. The background session carries no lock_timeout (the default is wait-forever), so in the isolation test it queued behind the ACCESS EXCLUSIVE lock the test takes on the middle chunk and compressed it the instant the test rolled its blocker back, landing directly on the assertions: 3 compressed if it beat the first one, a torn 2-then-0 if the chunk flipped between the two reads. Reproduced by replaying the test's exact two-session sequence in SQL. The pre-fix ordering settles at 3 compressed / 0 uncompressed - dev's failure exactly. With the policy added and parked before any chunk exists it holds a stable 2 compressed / 1 uncompressed across every sample. The three tests that insert chunks now add the policy first, while there is nothing to compress, and park its job (scheduled => false, next_start => 'infinity') before any row is inserted. The ordering is the load-bearing half: parking a job that has already launched does not recall the run in flight. Same idiom the file already used for the #1760 sentinel probe, and the same lever PayloadDimensionLiveTests.EnsureAggregatesWithoutPoliciesAsync pulls against this behaviour on the continuous-aggregate side. run_job still executes a parked job, verified live, so the foreground path is unchanged. It never reproduced locally because the test cluster runs max_worker_processes = 8 against TimescaleDB's default max_background_workers = 16, so job launches routinely fail outright. Twenty consecutive local runs passed for that reason alone; raising the limit on the same rig made the background run fire every time. That provisioning gap is filed as #1888. Closes #1889. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dering # Conflicts: # CHANGELOG.md
| @@ -993,6 +1004,50 @@ FROM timescaledb_information.jobs | |||
| } | |||
|
|
|||
| /* Runs the policy body NOW instead of waiting out its schedule. job_id is INTEGER (#1586). */ | |||
There was a problem hiding this comment.
Nit: this one-line comment ("Runs the policy body NOW instead of waiting out its schedule. job_id is INTEGER (#1586).") describes RunPolicyAsync, not the newly-inserted AddCompressionPolicyParkedAsync it now sits directly above. Since AddCompressionPolicyParkedAsync's doc comment was added between this line and RunPolicyAsync's declaration, the old comment is now floating in the wrong place and reads as documentation for the wrong method. Move it down to sit directly above private static async Task RunPolicyAsync(...) (line 1051).
Review: test-suite-only PR, Darling/Postgres sideThis PR only touches What it does
Correctness spot-checks
One nit (posted inline)
No security concerns (test-only code; the SQL string interpolation into |
…#1888 exposed #1897 made live-test cleanup verified rather than swallowed, and #1900 landed the DMV resource-database work; both merge cleanly apart from adjacent CHANGELOG entries, which are kept side by side. Re-verifying the gated-live suite on the merged tree under this PR's raised worker settings turned three TimescaleSupportTests compression tests red every run - and they passed alone, and passed as a whole class. The helper that hands those tests a policy which cannot fire created and parked it as TWO autocommit statements, so the scheduler could take the job in between. Parking a job that has already launched does not recall the run in flight (#1874), and that run evaluates its body when it gets a worker: under full-suite load, late enough that the test's rows have landed, so it compresses chunks the test is about to count. Not a new break - the same test fails intermittently at the OLD worker settings (1 of 2 full runs measured), which is how it stayed green on CI. This PR makes it deterministic from both directions: more slots make the launch reliable, more parallel load widens the launch-to-execute gap. Fixed with the lever the product already pulls for retention policies (#1705): create and park in ONE transaction, so the bgw_job row stays invisible until it already reads scheduled = false and the scheduler, a separate backend, can never see it armed. Three consecutive full gated-live runs on fresh databases at the raised sizing went from 3 failures every time to 3983 passed / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ranch is stacked on it)
…rikdarlingdata#1902, batch 3 of 3) Closes erikdarlingdata#1902. The final 19 sites, all Darling engine classes: DarlingCollectorRunner (4), DarlingAnalysisStore and DarlingDeltaSeeder (2 each), and one each in DarlingAlertReadAdapter, DarlingAnalysisPipeline, DarlingAnomalyBaseline, DarlingCompose, DarlingFleetReader, DarlingModuleMap, DarlingWatermarkSeedLive, ExcludedDatabases, PgFactCollector, plus both of LiveCleanupBatchTests' own. Ten more helpers across eight classes had the cancellation-token gotcha and were threaded. The NpgsqlDataSource teardowns were saved for last, and reading them properly changes what the fix is FOR. A data source hands out a FRESH connection per call, so those four sites were never exposed to the half of the defect everything else was - the body's failure closing the very session the teardown then uses. What they lacked is the masking rule and a token that survives cancellation: they passed the body's ct, already signalled on that path, so the delete was skipped exactly when it mattered most. Through LiveStoreCleanup they also gain the explicit SET search_path the data source's connections never issued, so the move is a strict improvement rather than a lateral one. CleanServerRowsAsync keeps its data-source overload for the PRE-clean calls, which legitimately draw from the source the test already holds. Rewriting the ratchet into an invariant found a real hole in it. The detector scanned a fixed thirteen lines after each finally, which overruns the block: in LiveCleanupBatchTests it ran past the closing brace into the NEXT method's doc comment, which mentions <see cref="LiveStoreCleanup"/> in prose - so an unconverted teardown was marked compliant by a comment that merely NAMED the helper it was supposed to call. Same shape erikdarlingdata#1874 found in the erikdarlingdata#1776 rule. The block is brace-matched now. A matching false positive existed too (a converted teardown whose own twelve-line comment pushed its LiveStoreCleanup call out of the window), and the two cancelled numerically - so batches one and two counted right in total and wrong in composition. Count is zero, the ceiling constant is deleted, and the assertion states the invariant directly. Opening a fresh connection BY HAND is deliberately not compliant: half the fix, still throws from the finally, and an exemption shaped "correct by hand" is one a later incorrect site inherits. Verified: two full gated-live runs on fresh PostgreSQL 18.4 + TimescaleDB 2.28.1 databases with the product's worker sizing (max_worker_processes = 52, timescaledb.max_background_workers = 41, mirroring CI), 3999 passed / 0 failed / 10 skipped, exit 0, no residue; Lite 1876/0; both suites -t:Rebuild, 0 warnings. Invariant mutation-checked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #1846. Closes #1850.
#1846 — a dash where there was never a number
The alert-history
current_value/threshold_valuecolumns are NOT NULL doubles, and a family of alerts has no measurement to put in them: their value is a role (PRIMARY), a connection state (DISCONNECTED), a suspend reason, a failure reason, or the literalresolved. Producers hand the history store a display STRING, the store's parser yields 0 for text carrying no digit, and both grids rendered that sentinel as0.00— a number the alert never made. The read side now renders an em dash.Write side untouched. The column stays NOT NULL, 0 stays the stored sentinel. Rows already collected display correctly with no migration and no re-collection.
The list is 19 metrics, not the 9 the issue named
Enumerated from the fire sites rather than the report, as asked. Nine were known — the seven AG metrics plus Server Unreachable and Server Restored. The other ten are every resolution notice:
DarlingSelfAlertEvaluator.BuildResolutionRecordhardcodesCurrentValueText: "resolved"andThresholdValueText: ""with both numerics null, for BOTH Darling's own self-alert recoveries (Collection Resumed, Capture Restored, Agent Restarted, Store Disk Pressure Resolved, Compression Job Recovered) and the shared engine's resolution callback (CPU Resolved, Blocking Cleared, Blocking Wait Cleared, Deadlocks Cleared, Poison Waits Cleared, Long-Running Queries Cleared, tempdb Space Resolved, Volume Free Space Resolved, Long-Running Jobs Cleared). Those reach alert history only in Darling — Lite's resolution callback is toast-only — so Darling's grid carried most of the0.00rows.Resolutions match through the existing shared
AlertMetricClassifier.IsResolutionrather than being listed by name, so a future recovery metric renders correctly without an edit. The predicate lives inPerformanceMonitor.Commonbeside it rather than being hand-mirrored into the two formatters, which is the drift that class was created to stop.The 0-gate is load-bearing, not bookkeeping
AlertValueParser.ParseOrDefaultscans to the first digit anywhere, not to a leading number. So several of these do not reliably store 0:AG Sync Fell Behindspells its lag seconds into its prose, and any of them can pick a digit out of an object name (SQL01,Sales2024). Those rows keep rendering whatever was parsed, and a producer that starts supplying a real numeric is shown rather than hidden.Three Darling self-alerts are deliberately excluded for the same reason (
Collection Stopped,Store Runtime Upgrade,Compression Job Stuck— filed as #1881), andStore Disk Pressuremost of all: its parsed value is percent-free, where a genuine 0 means a full volume. A measurement that happens to be zero is not a missing value —Blocking Detectedat 0 still reads0, pinned separately in both suites.#1850 — the narrower key was dropping a replica, not de-duplicating
Four analysis-layer dedups predate #1841 tier 1 and kept
(database_name, query_id, plan_id, runtime_stats_interval_id, first_execution_time)with noreplica_role.sys.query_store_runtime_statsis keyed by (plan_id, interval, execution_type, replica_group), and on a SQL Server 2022+ AG with Query Store for secondary replicas enabled the primary holds ONE shared Query Store carrying every replica's rows — so two rows differing only inreplica_roleare distinct legitimate work, and thern = 1filter was DISCARDING one. An under-count, worse than the double-count the dedup exists to fix: a double-count is visible in the number, a dropped row is silent. #1845 used exactly this reasoning for the read-side key.Split, per the issue's option 3.
replica_roleis carried through the downstream grouping and the ranking, so a regression is measured WITHIN a replica (this replica's current plan against the best plan this replica has run, never a cross-replica comparison of two different workloads), and it is exposed in both drill-down row shapes. Adding it to the dedup alone would have made totals complete while blending primary and secondary workload into one indistinguishable number.The joins use
IS NOT DISTINCT FROM, never=This is the whole risk of the change.
replica_roleis NULL on every standalone server, every non-AG server and everything below SQL Server 2022, andNULL = NULLis UNKNOWN — an equi-join would have matched nothing and silently disabled plan-regression detection for the overwhelming majority of installs. Darling's drill-down also moved offJOIN ... USING, which is an equi-join. Both suites carry a NULL-replica arm for exactly that case.Fact-shape consequences
PgFactCollector/DuckDbFactCollector): no shape change.Fact.MetadataisDictionary<string, double>and cannot hold a replica dimension; the numbers simply become per-replica-correct.offender_countnow counts two distinct regressions where it counted one.replica_roleadded LAST in the row so existing reader ordinals are untouched. It flows to the MCP payload and to email/webhook/toast detail with no code change (AnalysisNotificationService.FlattenIntoenumerates every property). The drill-down is documented ephemeral and never persisted, so nothing has a pinned schema to break.FactRemediation.ExtractPlanRegressionTargetsreads fixed keys, so two rows for one query would have rendered twosp_query_store_force_plancalls naming the SAME query with DIFFERENT plan ids: mutually exclusive instructions where whichever the operator runs last silently wins. Fixed mechanically — keep the worst per(database, query_id), which theregression_factor DESCorder already provides. Which replica should drive the recommendation is a product question, filed as Force-plan recommendation is replica-blind now that plan regression splits by replica_role #1882.Testing
Watched RED first, on both stores, with the same seed shape: two replicas running the same two plans over the same two intervals, the secondary always collected one second later so the old partition drops the PRIMARY specifically.
offender_countworst_regression_factorThe factor is the point: dropping the primary did not merely lose a row, it reported the secondary's much milder regression as the server's worst. The remediation dedup was watched red separately (2 targets for one query).
-t:Rebuild: 0 warnings, 0 errors.search_pathtrapLiveStoreCleanupdocuments. It now SETs the path explicitly rather than inheriting it.One test-side fix that is a real fix rather than an accommodation: the Darling FROM/JOIN drift guard scanned SQL comments, so English prose about a join ("an equi-join here would…") parsed as a relation named
here, and it read theFROMinsideIS NOT DISTINCT FROMas one too. It now strips line comments and that operator before scanning, so the assertion itself stays exact.Deferred, filed before this PR
🤖 Generated with Claude Code
Follow-up: a fourth masked bug, found by this PR's own CI (#1889)
The first push turned the
darling-pgleg red onTimescaleSupportTests.EndToEnd_CompressionRun_OneUncompressibleChunkDoesNotBlockTheRest_AgainstDevPostgres(Expected: 1 / Actual: 0). It is pre-existing ondev, not caused by anything here: the same test failed on run 30588924852 at commit7e40c850, which is an ancestor of this branch's base, withExpected: 2 / Actual: 3.Root cause.
add_compression_policycreates its job SCHEDULED with noinitial_start, and TimescaleDB launches it within a second or two (the #1788 behaviour). Three tests added the policy AFTER inserting eligible chunks, so that background run had chunks to compress and competed with the deterministic foregroundrun_jobthey are built around. The background session carries nolock_timeout— the default is wait-forever — so in the isolation test it queued behind theACCESS EXCLUSIVElock the test takes on the middle chunk and compressed it the instant the test rolled its blocker back, landing directly on the assertions. Both CI failures are the two arms of that one race: 3 compressed if it beat the first assertion, a torn 2-then-0 if the chunk flipped between the two reads.Evidence. Replaying the test's exact two-session sequence in SQL:
3 compressed / 0 uncompressed, stable — dev's failure exactly2 compressed / 1 uncompressed, stable across every sampleSeparately: adding the policy to a hypertable holding three eligible chunks and then doing nothing at all compressed all three within six seconds,
run_jobnever called,total_runs = 1.Fix. The three tests that insert chunks now add the policy first, while there is nothing to compress, and park its job (
scheduled => false, next_start => 'infinity') before any row is inserted. The ordering is the load-bearing half — parking a job that has already launched does not recall the run in flight. Same idiom the file already used for the #1760 sentinel probe, and the same leverPayloadDimensionLiveTests.EnsureAggregatesWithoutPoliciesAsyncpulls against this behaviour on the aggregate side.run_jobstill executes a parked job, verified live.Why it never reproduced locally, and why that matters. Whether the job launches depends on a free background-worker slot, and the test cluster runs
max_worker_processes = 8against TimescaleDB's defaultmax_background_workers = 16, so launches routinely fail ("failed to start a background worker"). Twenty consecutive local runs passed for that reason alone. Raisingmax_worker_processeson the same rig made the background run fire every time — and all the verification below was then re-run under that raised setting, which is strictly more race-prone than CI. Filed as #1888, because the product's ownDarlingManagedPostgres.BuildConfAppendsizes these correctly and CI diverges from it.Re-verified after the fix, all with worker slots raised so the background job launches: the isolation test 10/10;
TimescaleSupportTestssolo on a fresh database; three more consecutive full gated-live runs on fresh databases at 3853 passed / 5 skipped / 0 failed.