Skip to content

Verify what live-test cleanup removed, and fail the run when it did not (#1873) - #1897

Merged
erikdarlingdata merged 6 commits into
devfrom
fix/1873-live-cleanup-loudness
Jul 31, 2026
Merged

erikdarlingdata merged 6 commits into
devfrom
fix/1873-live-cleanup-loudness

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #1873.

Stacked on #1874. That PR is not merged yet and its LivePostgresStoreFixture is 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 touches TimescaleSupportTests alongside 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 VIEW concurrent with a refresh_continuous_aggregate of 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:

  • 40P01 deadlock detected — the drop is chosen as the victim while deleting from continuous_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: XX000 is internal_error, the class nobody would put on a retry list, yet it is what an ordinary refresh collision produces. So LiveCleanupBatch retries 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 BY has no IF EXISTS form and raises 42704 on 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 finally blocks, and a throw from a finally replaces 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, and LivePostgresStoreFixture compares 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 DisposeAsync surfaces 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 the trx and at detailed verbosity. Console.WriteLine from DisposeAsync was 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:

continuous aggregate collect.query_store_stats_interval_hourly survived 7 removal attempts
  — PostgresException (2BP01): cannot drop view query_store_stats_interval_daily
    because other objects depend on it
  statement: DROP MATERIALIZED VIEW IF EXISTS collect.query_store_stats_interval_hourly CASCADE
  test: TimescaleSupportTests.EndToEnd_RetentionPoliciesActuallyApply_AndAreCreatedPaused_AgainstDevPostgres

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_RunsCleanOnRaw asserts 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:

Site What it left behind
PayloadDimensionLiveTests.TryExecAsync aggregates, retention policies — reported to a console line nobody reads on a passing test
TimescaleSupportTests.TryExecAsync aggregates, retention policies, throwaway hypertables — empty catch, fully silent
DarlingSecuritySplitLiveTests.DropTestRolesAsync three CLUSTER-wide roles, which outlive even a DROP DATABASE

Both TryExecAsync helpers 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.

TimescaleSupportTests was the sharpest of the three: unlike its sibling it calls EnsureContinuousAggregatesAsync directly, so its finally raced 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.jobs reports a refresh policy's hypertable_schema/hypertable_name as the user-facing view, not the materialization hypertable the job writes. Joining continuous_aggregates.materialization_hypertable_name to 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_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 #1874 deliberately 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. 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.

  • Two full gated-live runs on brand-new databases: 3886 passed, 0 failed, 9 skipped, exit code 0, zero collection-cleanup failures. Exit codes read from dotnet test itself, not through a pipe.
  • Six consecutive runs against one reused database, all clean — the scenario the ticket is actually about. The branch without this change failed the compose test by its third run on the same rig.
  • Both mutation checks on the residue alarm. A test that leaks a relation and passes: the run fails, naming 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.
  • Mutation check on the hierarchy fix: with per-object retry restored, the three-level seam test fails with cagg_1873_raw_hourly as residue. It is three levels deep because the two-level version passed under mutation — plain CASCADE handles that one.
  • Live collision, four forced rounds against a 20-million-row aggregate with a refresh executing: residue 0 and the aggregate gone every round, one round taking 1268 ms against 30-70 ms uncontended. The losing-drop-then-winning-retry sequence itself was proven in the psql harness (XX000 on attempt 1, success on attempt 2), which is the same algorithm.
  • Lite suite 1743/0. Both suites -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

Test-suite only — no shipped code changed.

erikdarlingdata and others added 5 commits July 30, 2026 22:10
…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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review summary

Scope. 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 Lite.Tests — I checked and found no parity drift to flag; Lite has nothing comparable to touch.

What it does. Replaces best-effort, fully-silent TryExecAsync cleanup helpers in three live-Postgres test classes with LiveCleanupBatch, which retries a removal until a catalog probe confirms the object is actually gone (rather than trusting "no exception" as success), and reports anything it can't remove to a run-wide ledger that LivePostgresStoreFixture.DisposeAsync cross-checks against a relation-set diff at collection teardown — failing the run, not any individual test, so a losing race against refresh_continuous_aggregate/background jobs can no longer strand debris in the shared store silently. Design rationale (retry-on-any-failure + postcondition probe rather than a SQLSTATE allow-list, sweeping the whole aggregate set per round to handle multi-level hierarchies CASCADE can't, reporting from the collection fixture instead of the test's own finally to avoid replacing an in-flight exception) is well-reasoned and thoroughly justified in both the PR description and the code's own doc comments.

Correctness. One real defect, left as an inline comment on LiveCleanupBatch.cs: the backoff Task.Delay(BackoffFor(attempt), ct) calls in RemoveAsync (line 149) and DropContinuousAggregatesAsync (line 221) are not wrapped in try/catch, unlike every other operation in this class. If the caller's CancellationToken fires mid-delay, the delay throws and escapes the "never throws" helper — and two call sites (TimescaleSupportTests's finally block, DarlingSecuritySplitLiveTests.DropTestRolesAsync) pass TestContext.Current.CancellationToken straight into this batch from inside a finally, rather than routing through LiveStoreCleanup.RunAsync's CancellationToken.None swap. On a test timeout or aborted run, that reproduces the exact finally-replaces-the-real-failure masking (#1794) this PR was written to eliminate.

Everything else I checked came back clean:

  • Multi-statement DROP OWNED BY; DROP ROLE IF EXISTS ... batching in DropTestRolesAsync — a mid-batch failure aborts the rest of that statement string (implicit-transaction semantics), but this is pre-existing behavior carried over unchanged from the swallow it replaces, and the postcondition probe correctly catches anything left behind on retry.
  • DarlingModuleMap.EnsureTableAsync ordering in InitializeAsync (established before the baseline snapshot, so it doesn't read as test residue) — correct, and the reasoning in the doc comment matches what RelationsAsync's schema/relkind filter actually captures.
  • xUnit collection semantics — live-postgres classes serialize against each other (no DisableParallelization needed, confirmed via LivePostgresCollection's own doc comment), so the concurrent-refresh collision this PR handles is against TimescaleDB's background job scheduler, not test parallelism.
  • No SQL-injection-relevant input: all interpolated identifiers (view/table/relation names) are test-authored constants, not external input, consistent with the rest of the test suite's existing style.
  • No production/shipped-code or Lite-side changes.

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>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review

Went through the diff in detail (LiveCleanupBatch, the LivePostgresStoreFixture residue check, and the three converted call sites). This is a well-scoped, test-suite-only change and the reasoning is sound:

  • Retry-on-any-failure + postcondition probe is the right design given that XX000 (a generic internal_error) is one of the two real SQLSTATEs the collision produces — a SQLSTATE allow-list genuinely couldn't have covered it.
  • Sweeping the whole aggregate set per round (DropContinuousAggregatesAsync) correctly handles arbitrary-depth hierarchies: traced through the 3-level test case (Hourly → Daily → DayGrain offered root-first) and the round-robin removal converges in 3 rounds as claimed, well inside the 7-attempt budget.
  • Reporting residue at collection-fixture DisposeAsync instead of from the tests' own finally blocks correctly avoids the finally-replaces-in-flight-exception masking (Two live-Postgres test classes fail 'Connection is not open' on a reused database (lifecycle, not parallelism) #1794) — moving the alarm to a place that can't lie about a test's own result is the right call.
  • DROP OWNED BY role statements remain atomic (simple-query multi-statement implicit transaction), so the "already gone → 42704 → probe says gone → success" path can't leave the batch in a partially-dropped state on retry.
  • Traced RelationsAsync's baseline-diff logic and BuildResidueReport's four cases (clean / leaked-only / ledger-only / both) — all consistent with the seam tests in LiveStoreResidueReportTests.

Lite/Darling parity: not applicable here — Lite.Tests has no live-Postgres/Timescale test infrastructure at all (Lite targets SQL Server), so there's no counterpart that could have drifted out of sync.

Security: the SQL built via string interpolation in LiveCleanupBatch (view/table/role names) isn't parameterized, but every value passed in comes from compile-time constants or TimescaleSupport.RollupViews/RawTierCoverage — no external input reaches these strings, and it matches the pattern the TryExecAsync helpers it replaces already used. Not a regression.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@erikdarlingdata
erikdarlingdata merged commit 3ebc42f into dev Jul 31, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/1873-live-cleanup-loudness branch July 31, 2026 03:49
erikdarlingdata added a commit that referenced this pull request Jul 31, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant