Repository navigation
Verify what live-test cleanup removed, and fail the run when it did not (#1873) - #1897
Conversation
…ot (#1873) A live class's best-effort cleanup could lose a race to a still-executing refresh job, leave a continuous aggregate standing in the shared store, and report success. The swallow was an empty catch in one class and a lone Console.WriteLine in another, which on a PASSING test nobody reads. Reproduced against PostgreSQL 18.4 + TimescaleDB 2.28.1: a DROP MATERIALIZED VIEW concurrent with refresh_continuous_aggregate fails and leaves the aggregate standing, as 40P01 (deadlock, the drop chosen as victim) and as XX000 "tuple concurrently deleted", from the identical setup. A SQLSTATE allow-list is therefore the wrong shape - XX000 is internal_error - so LiveCleanupBatch retries any failure and lets a catalog probe be the judge of whether the object is gone. That also makes DROP OWNED BY's unavoidable 42704 on a never-created role a success rather than a special case. It never throws: these run in finally blocks, where a throw would replace the body's exception (#1794). Residue is recorded with the test's name, and LivePostgresStoreFixture fails the RUN at collection teardown, after the last test has reported. Measured on xUnit v3 3.2.2: that surfaces as [Test Collection Cleanup Failure (live-postgres)] and exits non-zero while the tests themselves still pass, which is the right attribution. All three swallow sites converted, found by an audit of the whole project: PayloadDimensionLiveTests, TimescaleSupportTests (whose finally races an armed scheduler, since it calls EnsureContinuousAggregatesAsync directly), and DarlingSecuritySplitLiveTests' cluster-wide roles. The refresh-policy probe keys on the aggregate's own schema and name, measured rather than assumed: timescaledb_information.jobs reports it as the user-facing view, not the materialization hypertable the column names invite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…debris (#1873) Two corrections, both found by the residue check itself rather than by reasoning about it. A three-level aggregate hierarchy cannot be dropped one aggregate at a time. query_store_stats_interval_hourly -> interval_daily -> day_grain_daily (#1849, #1869): dropping the root fails 2BP01, and CASCADE does not rescue it because TimescaleDB refuses to cascade THROUGH a dependent aggregate that has dependents of its own. Retrying that one aggregate can never succeed, since what has to change is a different relation. The helper now sweeps the whole set per round, so the leaf goes in the round the root fails, to any depth and without knowing the hierarchy. Caught on the second run against a REUSED database, where the old swallow had been stranding interval_hourly silently - and where it was also the cause of RunComposedPanel_OldWindow failing, exactly the tier-routing consequence the ticket predicts. Six consecutive runs on one database are now clean; the same rig without this fix failed by the third. The seam test for it is three levels deep for cause: a two-level version passes with or without the fix, because plain CASCADE handles it. Mutation-checked both ways. The fixture also now establishes collect.module_map. No migration creates it and the service does (DarlingWorker's maintenance sweep), so it belongs with CREATE EXTENSION rather than with the hypertables and aggregates the fixture leaves to the tests. Without it the first full run read product infrastructure as test residue - DarlingModuleMapTests creates it through the product and correctly deletes only its rows. Verified: two full gated-live runs on fresh PostgreSQL 18.4 + TimescaleDB 2.28.1 databases, 3886 passed / 0 failed / 9 skipped, exit 0, no residue; six consecutive runs on one reused database; Lite suite 1743/0; both suites -t:Rebuild with 0 warnings. Deferred and filed: #1896. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
||
| if (attempt < _maxAttempts) | ||
| { | ||
| await Task.Delay(BackoffFor(attempt), ct); |
There was a problem hiding this comment.
The retry backoff can throw out of a finally — the exact #1794 shape this class exists to prevent.
The class-level doc says "It never throws, deliberately" because finally-block cleanup must not replace an in-flight test failure. ExecuteAsync/SurvivesAsync are both wrapped in try/catch here, but the backoff delay is not:
if (attempt < _maxAttempts)
{
await Task.Delay(BackoffFor(attempt), ct); // <-- unguarded
}If ct is cancelled while this delay is pending, Task.Delay throws TaskCanceledException, which propagates straight out of RemoveAsync/RemoveRetentionPolicyAsync/DropRolesAsync/RemoveRefreshPolicyAsync.
That's not a theoretical ct: TimescaleSupportTests.cs's finally block (around line 1156) and DarlingSecuritySplitLiveTests.DropTestRolesAsync both call into this batch with ct = TestContext.Current.CancellationToken directly, not through LiveStoreCleanup.RunAsync (which deliberately swaps in CancellationToken.None for exactly this reason — see its own doc comment). If a test in either of those classes times out or the run is aborted mid-cleanup, the live per-test token fires, the backoff throws from the finally, and it replaces the real test failure with a TaskCanceledException from cleanup — reproducing the masking bug #1873/#1794 were filed to kill, in the code written to kill it.
Same issue at line 221 in DropContinuousAggregatesAsync's round-level Task.Delay(BackoffFor(round), ct).
Suggest wrapping both delays in try/catch (swallow OperationCanceledException and fall through to Record), or passing CancellationToken.None to the delay specifically, matching the invariant the rest of the class already upholds.
Review summaryScope. Test-suite only (Darling.Tests), no shipped product code. This is Darling/Postgres-Timescale-specific infrastructure (continuous aggregates, retention policies, cluster-wide roles) with no analog in What it does. Replaces best-effort, fully-silent Correctness. One real defect, left as an inline comment on Everything else I checked came back clean:
Overall: solid, well-tested change (the PR description documents live collision repro + mutation testing on the residue/hierarchy logic). The one finding above is worth a fix before merge since it undermines the specific invariant the PR is built around, but it's narrow and easy to patch. |
… it (#1873) CI's darling-pg job failed the three-level hierarchy test with "42883: function by_range(unknown, interval) does not exist" while the same test passed locally. TimescaleSupport.CreateHypertableSql calls by_range UNQUALIFIED, and the class opened raw connections that set no search path - so it ran on whatever the pooled physical session happened to carry. This is the trap LiveStoreCleanup already documents: Npgsql pools sessions, and one opened before the store's first migration keeps the pre-ALTER default "$user", public for its whole life. Proven directly rather than guessed - on the live rig, SET search_path = "$user", public reproduces CI's exact 42883, and PgSchemaGenerator.SearchPath (collect, config, public) resolves it. It passed locally for a reason worth recording: this rig's CREATE EXTENSION landed timescaledb in `collect` rather than `public`, so by_range was found whatever the path, and a local connection string pinning its own SearchPath hides the whole thing. Removing the SET on a fresh database did NOT reproduce it, which is the point - the pooled session's path is luck, so the SET is mandatory rather than defensive. Every connection in the class now opens through one helper that sets it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewWent through the diff in detail (
Lite/Darling parity: not applicable here — Security: the SQL built via string interpolation in Two very minor, non-blocking nits left as inline comments (backoff comment vs. actual math, and a fully-silent swallow with no diagnostic). Nothing here blocks merging as far as I can tell. |
| /// <summary> | ||
| /// Attempts per object. The collision clears as soon as the in-flight refresh finishes, which in the test | ||
| /// suite's data volumes is sub-second; the repro above needed one retry with a 20-million-row refresh | ||
| /// running. Seven attempts spend at most ~9s of backoff before giving up, and only ever on the path that |
There was a problem hiding this comment.
Minor: the actual max backoff is ~7s, not ~9s — attempts 1-6 delay 200,400,800,1600,2000,2000ms (attempt 7 never delays since it's the last), summing to 7000ms. Not load-bearing, just a doc nit.
| } | ||
| catch (Exception ex) | ||
| { | ||
| _ = ex; |
There was a problem hiding this comment.
This remove_continuous_aggregate_policy failure is fully silent (no Console.WriteLine, unlike the give-up path in Record). Since it's best-effort by design and the following DROP is what's actually verified, that's probably fine — but if this call ever fails for a real reason (permissions, not "already gone"), there's currently no diagnostic trail pointing at it specifically, only at the eventual drop failure it may cause. Worth a one-line Console.WriteLine if this ever needs debugging in practice; not blocking.
…#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>
Closes #1873.
Stacked on #1874. That PR is not merged yet and its
LivePostgresStoreFixtureis the anchor this work hangs on, so its branch is merged into this one. Until #1874 lands, the diff below shows both; once it does, this collapses to its own three commits. Its branch moved twice while this was in progress and both moves are merged in, including the compression-policy parking that touchesTimescaleSupportTestsalongside these edits.The mechanism, reproduced rather than inferred
#1873 could name the mechanism but had no live instance. There is one now. Against PostgreSQL 18.4 + TimescaleDB 2.28.1, a
DROP MATERIALIZED VIEWconcurrent with arefresh_continuous_aggregateof the same aggregate fails and leaves the aggregate standing, every time it collides — seven collisions, seven survivals. It arrives as two different SQLSTATEs from the identical setup, depending on which phase of the refresh is in flight:40P01deadlock detected — the drop is chosen as the victim while deleting fromcontinuous_aggs_materialization_invalidation_log, which the refresh wants a lock on.XX000 tuple concurrently deleted— the refresh's own catalog maintenance beats it to a row.That second one decided the design. A SQLSTATE allow-list is the shape this obviously wants, and it is the wrong one:
XX000isinternal_error, the class nobody would put on a retry list, yet it is what an ordinary refresh collision produces. SoLiveCleanupBatchretries any failure and asks the catalog whether the object is actually gone. The postcondition is the only thing that has to be true, so nothing has to be right about which errors count — and the reverse case comes free:DROP OWNED BYhas noIF EXISTSform and raises42704on every pre-test call in the security-split class, where a probe that finds no such role simply reports success.Each aggregate's refresh policy comes off before its drop, which bounds the collision to the one run already executing rather than one the scheduler keeps relaunching (#1788); the retry outlasts that.
Where the alarm rings, and why not in the test
These cleanups live in
finallyblocks, and a throw from afinallyreplaces the body's in-flight exception — the masking #1794 exists to prevent. So the helper never throws. It records what it could not remove, with the owning test's name, andLivePostgresStoreFixturecompares the store's relations against the set captured when the run began and fails the run at collection teardown, after the last test has reported.Measured on xUnit v3 3.2.2 rather than assumed, because the whole fix rests on it: a throw from a collection fixture's
DisposeAsyncsurfaces as[Test Collection Cleanup Failure (live-postgres)]and exits non-zero, while the tests themselves still pass. That is the correct attribution — the run is broken, not any one test — and it is why this beats a hard assert in a test's own teardown. One limit worth stating: at--verbosity normal, which is what CI uses, the console shows the banner and the collection name but not the message; the full residue list is in thetrxand at detailed verbosity.Console.WriteLinefromDisposeAsyncwas measured too and reaches nothing, so it is not an alternative.Both halves accuse independently. An armed retention policy that could not be removed leaves no relation behind and will still drop another test's chunks on its own schedule.
The second bug, which the check found on its own
The first full run after the check landed came back with a real accusation, precisely attributed:
query_store_stats_interval_hourly→interval_daily→day_grain_daily(#1849, #1869) is three levels deep, and TimescaleDB refuses to cascade through a dependent aggregate that has dependents of its own. Retrying the root can never succeed, because what has to change is a different relation. The drop now sweeps the whole set per round, so the leaf goes in the round the root fails — to any depth, without the helper knowing the hierarchy.This was the cause of a failure nobody had connected to it.
RunComposedPanel_OldWindow_AgainstPlainPostgres_RunsCleanOnRawasserts a 10-day window lands on RAW, which only holds while no rollup exists. On a reused database the stranded aggregate changed the tier routing and failed it — exactly the consequence #1873 predicts in the abstract. The same rig on the branch without this change failed by the third run; with it, six consecutive runs on one database are clean.Audit
Every DB-touching swallow in the project, five in total. The three that target the shared store are converted:
PayloadDimensionLiveTests.TryExecAsyncTimescaleSupportTests.TryExecAsynccatch, fully silentDarlingSecuritySplitLiveTests.DropTestRolesAsyncDROP DATABASEBoth
TryExecAsynchelpers are deleted rather than fixed, so the shape cannot come back by copy. The two remaining swallows are own-store (ScratchPostgres,RollupBackfillLiveTests) and drop their whole database.TimescaleSupportTestswas the sharpest of the three: unlike its sibling it callsEnsureContinuousAggregatesAsyncdirectly, so itsfinallyraced an armed, immediately-firing scheduler and reported success either way.One catalog reading corrected
The refresh-policy probe keys on the aggregate's own schema and name, measured rather than assumed.
timescaledb_information.jobsreports a refresh policy'shypertable_schema/hypertable_nameas the user-facing view, not the materialization hypertable the job writes. Joiningcontinuous_aggregates.materialization_hypertable_nameto it — the reading the column names invite, and the one written first here — matches nothing, so the probe answers "gone" while the policy is still attached: a verification that always passes. All four probes were exercised against a live 2.28.1 store in both directions before being relied on.The fixture also establishes
collect.module_mapNo migration creates it and the service does (
DarlingWorker's maintenance sweep), so it belongs withCREATE EXTENSIONrather than with the hypertables and aggregates #1874 deliberately leaves to the tests. Without it the first full run read product infrastructure as test residue:DarlingModuleMapTestscreates it through the product and correctly deletes only its rows.CREATE TABLE IF NOT EXISTS, so that test's own call still returns true and still covers the path.Test plan
PostgreSQL 18.4 + TimescaleDB 2.28.1 from the repo's
pg-runtime.zip, throwaway cluster on port 5881.dotnet testitself, not through a pipe.collect.leaked_on_purpose_1873, while the leaking test still reports as passed. A ledger entry with nothing leaked: the run fails, naming the statement and the test. Restored and green.cagg_1873_raw_hourlyas residue. It is three levels deep because the two-level version passed under mutation — plainCASCADEhandles that one.XX000on attempt 1, success on attempt 2), which is the same algorithm.-t:Rebuild, 0 warnings, 0 errors. Installer.Tests not run.One infrastructure note, since it produced a scary intermediate number: a run reporting 489 failures was the PostgreSQL server dying (
could not reserve shared memory region, the Windows fork issue) when a background task was killed, not a code regression. Re-run after restarting the cluster: 3886/0.Filed, not fixed
finallyblocks (10 inTimescaleSupportTests, 6 inDarlingSecuritySplitLiveTests) still clean up on the body's connection and throw from the finally, so a failing test can report the cleanup's error instead of its own. That is the Two live-Postgres test classes fail 'Connection is not open' on a reused database (lifecycle, not parallelism) #1794 shape, not the Live tests can strand a continuous aggregate in the shared DARLING_TEST_PG store, and the best-effort DROP swallows the reason #1873 swallow, andLiveStoreCleanupalready solves it at ~20 other call sites. The residue check here catches the consequence — anything they abandon is named at teardown — so what remains is the masking, a diagnosis cost rather than a correctness one. Sixteen tests' failure semantics wants its own PR.Test-suite only — no shipped code changed.