Skip to content

Stop an unpreloaded TimescaleDB masking itself as 'Connection is not open' - #1930

Merged
erikdarlingdata merged 7 commits into
devfrom
fix/1922-timescale-deadconn
Jul 31, 2026
Merged

erikdarlingdata merged 7 commits into
devfrom
fix/1922-timescale-deadconn

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #1922.

The product path is safe, and that is why this is test-side only

Checked first, as instructed. DarlingWorker.cs is safe for two independent reasons:

await using var timescaleConnection = await postgres.OpenConnectionAsync(stoppingToken);
_timescaleAvailable = await TimescaleSupport.TryEnableAsync(timescaleConnection, _logger, stoppingToken);
if (_timescaleAvailable)
{
    await TimescaleSupport.ConvertToHypertablesAsync(timescaleConnection, ...);
    ...
}
  1. The connection is dedicated to the block and disposed at its end, so nothing downstream inherits it.
  2. Every subsequent call is gated on the returned flag. A false return — which is the only outcome when the backend died, since the catch fires before DetectAsync is reached — means nothing touches that connection again.

It is the only product caller; the rest of the repo's TryEnableAsync calls are all in Darling.Tests.

What was actually exposed

Two test call sites, not the one I filed:

  • LivePostgresStoreFixture.cs — takes the result, then runs three more calls on the same connection.
  • DarlingDeltaSeederTests.cs — discards the result entirely and keeps using the connection for the whole test. Wider blast radius, same shape.

Both now enable on a short-lived connection of their own via LiveTimescaleProbe, deliberately shaped after #1896's LiveStoreCleanup — the safest connection to risk is one nobody else is holding. Reopening the caller's would also work, but it makes correctness depend on every call site remembering, and a rule every call site must remember is one a new call site will not.

Extension creation is committed DDL, so the caller's connection sees the extension immediately afterwards; it was opened by a backend that already had the library preloaded, since shared_preload_libraries loads at postmaster start.

Measured, not argued

Against a deliberately unpreloaded PostgreSQL 18.4 (same cluster, shared_preload_libraries commented out, same database recreated):

Before — the whole collection dies, cause named nowhere:

Collection fixture type 'Darling.Tests.LivePostgresStoreFixture' threw in InitializeAsync
---- System.InvalidOperationException : Connection is not open
   at Darling.Tests.LivePostgresStoreFixture.RelationsAsync(...) LivePostgresStoreFixture.cs:line 257

After — the fixture survives; 15 passed, 1 skipped, and the single failure is the fixture's own assertion:

TimescaleDB is not enabled on DARLING_TEST_PG. The live rig is expected to have it (the bundled pg-runtime and CI's throwaway cluster both preload it), and the live classes that read timescaledb_information assume the fixture created it.

Worth noting: that good diagnostic already existed. It was simply unreachable, because the fixture died three calls before anything could assert. The fix does not add a better message so much as let the one that was already written run.

Pinning both halves

The product's safety is a property of how that block is written, not something the compiler holds — move one call out from under the flag and the service acquires exactly the masking the tests just shed. So both directions are pinned in TimescaleEnableConnectionSafetyTests:

  • DarlingWorker_TouchesTheTimescaleConnectionOnlyBehindTheAvailabilityFlag — extracts the block, and fails if any TimescaleSupport.* call other than the enable itself appears before if (_timescaleAvailable), or if the connection stops being dedicated.
  • NoLiveTest_KeepsUsingAConnectionItHandedToTryEnableAsync — every test-side call must go through the probe or be asserted on the spot.

Both are source-parsed, and that is the honest way to hold them: reproducing the crash needs a PostgreSQL misconfigured in one specific way, which no CI rig is and none should be made to be. What actually regresses is a call site written the unsafe way, and that is textual.

The ~15 Assert.True(await TryEnableAsync(...)) sites are deliberately still allowed rather than swept. On a dead connection they fail immediately with their own message ("the rig is expected to have TimescaleDB"), which is a true and useful diagnosis rather than a masked one. What is banned is the shape that masks — taking the result and carrying on with the same connection.

TryEnableAsync also gains the warning its contract was missing: a false return may mean the connection is unusable, and DarlingWorker's gating is called out as load-bearing rather than incidental.

Testing

dotnet build PerformanceMonitor.sln -c Debug -t:Rebuild — Build succeeded, 0 Warning(s), 0 Error(s).

Suite Result
Darling.Tests 4030 passed, 0 failed, 10 skipped
Lite.Tests 1919 passed, 0 failed
Dashboard.Tests 779 passed, 0 failed

Run against live PostgreSQL 18.4 + TimescaleDB 2.28.1 (10 skipped, not 214 — the gated live suite genuinely ran). No Installer.Tests.

Watched-red, each mutation reddening only its own guard, with the message naming the exact offender:

Mutation Red
Fixture back to TryEnableAsync(connection, …) NoLiveTest_KeepsUsingAConnection… — "…: LivePostgresStoreFixture.cs:121"
Hoist ConvertToHypertablesAsync out from under the flag in DarlingWorker DarlingWorker_TouchesTheTimescaleConnection… — "…: ConvertToHypertablesAsync"

Plus the live before/after above, which is the end-to-end version of the same thing.

Deviations

None to file. The issue's option 2 — hardening TryEnableAsync to reopen on its failure path — is deliberately not taken: the product proves safe, so it would be a behavior change to shipped code with no exposed caller to justify it. The contract is documented and the invariant pinned instead, which keeps "the product is safe" a fact rather than something that was true once.

🤖 Generated with Claude Code

erikdarlingdata and others added 2 commits July 31, 2026 02:58
…open"

CREATE EXTENSION IF NOT EXISTS timescaledb does not merely ERROR when the
library is on disk but missing from shared_preload_libraries - it terminates
the backend. TryEnableAsync catches that and returns false exactly as its
contract says, so the caller reads "carry on in plain-PostgreSQL mode" while
the connection it handed over is dead. The live-postgres fixture then ran
three more calls on it and every test in the collection died with
InvalidOperationException: Connection is not open at RelationsAsync, naming
the cause in neither the message nor the stack - the #1794 masking signature
in setup rather than teardown.

The product was checked FIRST and is safe, verified not assumed:
DarlingWorker opens a DEDICATED connection for the TimescaleDB block and
gates every subsequent call on the returned flag, so a false return means
nothing touches it before disposal. That is why this fix is test-side only.

The enable now runs on its own short-lived connection, shaped after #1896's
LiveStoreCleanup: the safest connection to risk is one nobody else holds. A
second call site had the same shape and a wider blast radius - it discarded
the result entirely and kept using the connection for the whole test - and
is converted too.

Because the product's safety is a property of how the block is written
rather than something the compiler holds, both halves are now pinned: one
test fails if a call moves out from under the availability flag, another
fails if any test-side caller reuses a connection it handed to
TryEnableAsync. TryEnableAsync gains the warning its contract was missing.

Measured before and after against a deliberately unpreloaded PostgreSQL
18.4: before, the whole collection failed on the masked error; after, the
fixture survives and the single failure is its own assertion naming
TimescaleDB and the preload expectation, with the other fifteen passing.
That good diagnostic already existed - it was simply unreachable behind the
masking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +59 to +67
/* SET, never inherit — the same Npgsql pooling trap LiveStoreCleanup documents: a physical session
opened before the store's first migration keeps the pre-ALTER search_path for its whole life, and
the hypertable helpers downstream use unqualified names. */
await using (var setPath = new NpgsqlCommand("SET search_path = " + PgSchemaGenerator.SearchPath, connection))
{
await setPath.ExecuteNonQueryAsync(cancellationToken);
}

return await TimescaleSupport.TryEnableAsync(connection, null, cancellationToken);

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 nit, not blocking: unlike LiveStoreCleanup, the SET search_path here doesn't appear load-bearing for what this method actually does. TryEnableAsync only runs CREATE EXTENSION IF NOT EXISTS timescaledb and then checks pg_extension (a system catalog, resolved independent of search_path) — it never touches collect/config unqualified names the way the hypertable helpers do. If that's right, the comment ("the hypertable helpers downstream use unqualified names") doesn't quite apply to this probe, since it never calls those helpers. Harmless either way, just flagging in case it was copied from LiveStoreCleanup without re-checking whether it's needed here.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review summary

Verified this against the diff and the surrounding code (TimescaleSupport.cs, DarlingWorker.cs, the two live-test call sites). Findings:

Correctness — looks right.

  • The core claim (product path is safe) checks out: DarlingWorker.cs's TimescaleDB block opens a dedicated connection and every downstream TimescaleSupport.* call sits inside if (_timescaleAvailable), confirmed by reading the actual block (lines ~761–781), not just the PR description.
  • Both fixed call sites are correct:
    • LivePostgresStoreFixture.cs now assigns TimescaleAvailable from LiveTimescaleProbe.TryEnableAsync(connectionString, ...) and reuses the (already null-checked) connectionString correctly.
    • DarlingDeltaSeederTests.cs switches the enable call to the probe, then still runs ConvertToHypertablesAsync on the original connection — safe per the PR's own reasoning (extension creation is committed DDL visible to any connection afterward), and ConvertToHypertablesAsync is already failure-isolated per table, so this is unchanged pre-existing behavior, not a new bug.
  • TimescaleEnableConnectionSafetyTests.cs's two source-parsed guards are sound: I checked every existing TimescaleSupport.TryEnableAsync( call site in the test suite (grep -rn) and all of them keep Assert.True(...) on the same line, so the line-based scan won't false-positive on wrapped calls today. The DarlingWorker block-extraction test's IndexOf bounds land on the correct catch (verified there are multiple identical catch clauses later in the file, but the search starts at the timescaleConnection declaration, so it correctly finds the very next one).

Lite/Darling parity — not applicable here. All changed files are Darling-only (Darling.Tests, PerformanceMonitor.Darling.Storage) plus CHANGELOG.md. TimescaleDB/PostgreSQL is a Darling-specific centralized-store feature with no Lite equivalent, so there's nothing for Lite to mirror. No parity drift.

Security. No user input involved; table/column names in the touched code come from compile-time constants, matching the existing DarlingRetention.DeleteSqlFor precedent cited in the doc comments. No secrets, file, network, or process handling introduced.

Performance. Test-only changes plus a doc-comment addition to TimescaleSupport.cs. No production code path changed. (Not evaluating index recommendations per review scope.)

One minor, non-blocking nit left as an inline comment on LiveTimescaleProbe.cs about a SET search_path that may not be load-bearing for what that method does.

Overall: solid, well-scoped test-infrastructure fix with good verification (build + full suite + live before/after against an unpreloaded PostgreSQL). No blocking issues found.

erikdarlingdata and others added 2 commits July 31, 2026 03:10
Review flagged that the SET looked copied from LiveStoreCleanup without
re-checking, since TryEnableAsync only runs CREATE EXTENSION and then reads
pg_extension - a system catalog that search_path does not affect. The
comment was indeed wrong: it justified the line with the unqualified-name
trap, which this probe never reaches.

But the line itself is load-bearing, for a reason neither of us had stated.
timescaledb's control file is relocatable = false yet, as it says itself,
"relocatable during installation" - so CREATE EXTENSION with no SCHEMA
clause installs into the FIRST schema on the search path. Verified on
PostgreSQL 18.4 + TimescaleDB 2.28.1: on a database with no default
search_path, SET search_path = collect, public before CREATE lands the
extension in collect, and the live store's own extension is in collect for
exactly that reason.

So it stays and the comment now says why. Removing it would make which
schema the extension lands in depend on MigrateAsync having already set the
database default on this database - an unstated ordering dependency, which
is the shape #1862 fixed in this very fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
continue;
}

if (!line.Contains("Assert.True(", StringComparison.Ordinal))

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/nit: this guard only recognizes the safe shape when Assert.True( appears on the same line as TimescaleSupport.TryEnableAsync(. A perfectly safe two-line variant would false-positive as an "offender":

var enabled = await TimescaleSupport.TryEnableAsync(connection, null, ct);
Assert.True(enabled, "...");

Since the call line doesn't contain Assert.True(, this would flag it even though the result is checked before the connection is used again. Not a problem for any current call site (none use this shape), but a future contributor hitting this lint might be confused about why an apparently-safe pattern fails it. Worth a comment on the check itself, or loosening the scan to look a line or two ahead/behind, if you want the guard to be self-explanatory when it eventually fires on a new pattern.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review

What this does: Fixes #1922 — CREATE EXTENSION IF NOT EXISTS timescaledb terminates the backend (not an ordinary error) when the library is on disk but not in shared_preload_libraries. TimescaleSupport.TryEnableAsync correctly returns false for that case, but two test call sites kept using the now-dead connection afterward, masking the real cause behind InvalidOperationException: Connection is not open. This is test-side only — traced and confirmed via the product path, and pinned with two new source-parsed regression tests.

Correctness

  • Verified the product claim directly: DarlingWorker.cs:762-796 opens a dedicated connection for the TimescaleDB block and every subsequent TimescaleSupport.* call sits behind if (_timescaleAvailable) — matches what DarlingWorker_TouchesTheTimescaleConnectionOnlyBehindTheAvailabilityFlag asserts, so the product really was already safe.
  • LiveTimescaleProbe.cs correctly mirrors the existing LiveStoreCleanup pattern (own connection, own SET search_path, disposed regardless of outcome) — good reuse of a validated approach rather than inventing a new one.
  • DarlingDeltaSeederTests.cs still discards the probe's return value and unconditionally calls ConvertToHypertablesAsync afterward — but that's safe: ConvertToHypertablesAsync is failure-isolated per table (catches and warns, doesn't throw), and the test itself self-skips if the chunk-exclusion premise doesn't hold. Not a regression.
  • SET search_path = ... + PgSchemaGenerator.SearchPath in the new probe interpolates a const string, not user input — no injection concern, consistent with the existing LiveStoreCleanup reasoning it cites.

Lite/Darling parity

  • No parity concern here — TimescaleDB is Darling/Postgres-only; grepped Lite* for any Timescale reference and found none. Nothing for Lite to mirror.

Minor nit (left inline)

  • The new NoLiveTest_KeepsUsingAConnectionItHandedToTryEnableAsync guard only recognizes Assert.True( on the same line as the TryEnableAsync call; a var enabled = await TryEnableAsync(...); Assert.True(enabled, ...); two-line shape would false-positive as an offender even though it's safe. No current call site hits this, so it's not blocking — just worth a comment on the check for the next person who trips it.

Overall

Well-scoped and well-evidenced (measured before/after against a deliberately unpreloaded PostgreSQL, watched-red mutation testing on both new guards). No security, performance, or parity issues found.

Review: the guard only recognized the safe shape when Assert.True appeared
on the SAME line as the call, so this - which is safe - would have been
flagged as an offender:

    var enabled = await TimescaleSupport.TryEnableAsync(connection, null, ct);
    Assert.True(enabled, "...");

No current call site uses it, but a guard that fires on a safe shape is
worse than no guard: the next person routes around it instead of reading it.

The check now also accepts a capture asserted within the next three lines,
matched against the CAPTURED VARIABLE rather than any nearby Assert.True, so
an unrelated assertion cannot launder an unchecked call. Verified all three
ways: inline assert accepted, two-line capture accepted, and a capture that
is never checked still flagged with its file:line. The failure message names
both accepted shapes so it explains itself when it fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review

Overview. This closes #1922 by fixing a masking bug: on a PostgreSQL backend that has the TimescaleDB library on disk but not in shared_preload_libraries, CREATE EXTENSION IF NOT EXISTS timescaledb doesn't just error — it terminates the backend. TimescaleSupport.TryEnableAsync correctly catches that and returns false, but two test call sites (LivePostgresStoreFixture and DarlingDeltaSeederTests) kept using the now-dead connection afterwards, producing an uninformative InvalidOperationException: Connection is not open several frames downstream instead of the real diagnosis. Both are fixed by routing the enable call through a new LiveTimescaleProbe that runs it on its own short-lived connection (mirroring the existing LiveStoreCleanup/LiveStoreCleanup.RunOwnedAsync pattern). Two new source-parsed tests pin the invariant on both sides: that no test call site reuses a connection handed to TryEnableAsync without checking it on the spot, and that DarlingWorker's product-side gating (if (_timescaleAvailable)) still wraps every downstream TimescaleSupport.* call.

Correctness. I traced this independently rather than taking the PR description at face value:

  • Verified DarlingWorker.cs:762-782 — the dedicated timescaleConnection really is scoped to the try block, and every TimescaleSupport.* call other than the enable itself sits inside if (_timescaleAvailable). The product-side claim in the PR body holds.
  • Verified TimescaleSupport.ConvertToHypertablesAsync (Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs:2361) is per-table failure-isolated (catches and logs per iteration), so DarlingDeltaSeederTests.cs:242-243 calling it right after a discarded LiveTimescaleProbe.TryEnableAsync result is still safe — it degrades to a no-op per table rather than throwing, on the (now-safe) connection the rest of the test uses.
  • Grepped every other TryEnableAsync call site in the repo (~30, mostly Assert.True(await TimescaleSupport.TryEnableAsync(...)) in TimescaleSupportTests.cs, RollupBackfillLiveTests.cs, PayloadDimensionLiveTests.cs, etc.) — all fall into the "checked on the spot" shape the new guard test deliberately allows, so the new test shouldn't produce false positives against existing code.
  • The SET search_path = + PgSchemaGenerator.SearchPath pattern in the new LiveTimescaleProbe.cs is copy-consistent with the existing identical line in PgMigrations.cs:1370 and LiveStoreCleanup.cs:71 — not a new injection surface since SearchPath is a compile-time constant ("collect, config, public"), not runtime input.

Parity. No Lite-side counterpart needed — Lite has no TimescaleDB/Postgres dependency at all (it's DuckDB-backed), confirmed via grep across Lite/ and Lite.Tests/. This is Darling-only infrastructure with nothing to keep in parity.

Minor observations (non-blocking):

  1. TimescaleEnableConnectionSafetyTests.NoLiveTest_KeepsUsingAConnectionItHandedToTryEnableAsync detects offending call sites via a literal substring match on "TimescaleSupport.TryEnableAsync(" on a single line. A future call site that wraps or breaks the call across multiple lines (e.g. member access on its own line) would silently evade the guard rather than failing loudly. Given this codebase's consistent single-line-call style this is low risk, but worth knowing the guard's blind spot exists.
  2. The LiveTimescaleProbe.TryEnableAsync disposal path (await using var connection = ...) isn't wrapped in a try/catch the way LiveStoreCleanup.RunAsync wraps its own connection lifetime. In practice this is fine because TimescaleSupport.TryEnableAsync already swallows the exception from the failing CREATE EXTENSION internally (so nothing escapes from the inner call), and the PR's own live-cluster before/after run confirms the fixture survives cleanly — but it's worth noting the safety here rests partly on Npgsql's dispose-of-a-broken-connection behavior not throwing, which isn't asserted anywhere as explicitly as the rest of the invariants in this PR are.

Overall: thorough, well-verified fix with good regression coverage (both product-side and test-side invariants pinned via source parsing rather than trying to reproduce an environment-dependent crash in CI). No correctness bugs found, no parity gaps, no security concerns.

for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (!line.Contains("TimescaleSupport.TryEnableAsync(", StringComparison.Ordinal))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nitpick: this guard is a literal substring match on TimescaleSupport.TryEnableAsync( within a single line. A call site that splits the member access or arguments across multiple lines (e.g. TimescaleSupport\n .TryEnableAsync(...)) would silently evade this check instead of failing loudly, since IsCheckedOnTheSpot also operates strictly line-by-line. Given every existing call site in the repo keeps this call on one line, this is low risk today — just flagging the blind spot since the whole point of this test is to make future regressions impossible to miss.

/* #1922: on its OWN connection. This site discarded the result entirely and then kept using the same
connection for the whole test, so an unpreloaded TimescaleDB left every statement below failing
with "Connection is not open" instead of the real cause. */
await LiveTimescaleProbe.TryEnableAsync(connectionString!, TestContext.Current.CancellationToken);

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 return value is still discarded here (as it was before the fix) — confirmed safe because TimescaleSupport.ConvertToHypertablesAsync on the next line is per-table failure-isolated (catches and logs rather than throwing) even if TimescaleDB didn't actually enable, so this doesn't reintroduce the masking bug. Just noting for the reviewer that the discard is intentional/pre-existing behavior being preserved, not something the PR needed to fix — the connection-safety issue is what's being fixed, not the discard itself.

Review: the scan was a literal substring match within a single line, so a
call split as `TimescaleSupport\n    .TryEnableAsync(...)` would have slipped
past silently rather than failing loudly. No such site exists today, but a
guard a line break can evade is not a guard - and this test's whole purpose
is to make the regression impossible to miss.

Detection now runs over the whole file with a whitespace-tolerant pattern,
and the safety window is flattened before it is read, so a call OR an
assertion wrapped across lines reads the same as the one-line form. The
window still starts at the call's own line, so an assertion above it cannot
be mistaken for a check of it, and the capture form is still tied to its own
variable.

Verified: the split-call form is now caught (LivePostgresStoreFixture.cs:121
when introduced deliberately), while both safe shapes and every existing
call site still pass.

Also confirmed the reviewer's second point rather than relaying it: the
discarded result in DarlingDeltaSeederTests is safe, because
ConvertToHypertablesAsync catches per table inside its foreach and logs a
warning rather than throwing. Pre-existing and intentional; left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review: Stop an unpreloaded TimescaleDB masking itself as 'Connection is not open'

Overview. This is a test-infrastructure fix for #1922: CREATE EXTENSION IF NOT EXISTS timescaledb terminates the backend (not an ordinary error) when the library is on disk but missing from shared_preload_libraries. TimescaleSupport.TryEnableAsync correctly returns false in that case, but the connection it was handed is dead — so any caller that kept using it got a downstream InvalidOperationException: Connection is not open with the real cause named nowhere. The PR:

  • Adds LiveTimescaleProbe (test-only), which enables the extension on its own short-lived connection, mirroring LiveStoreCleanup's "risk the connection nobody else holds" pattern.
  • Rewires the two exposed call sites (LivePostgresStoreFixture, DarlingDeltaSeederTests) through the probe.
  • Adds TimescaleEnableConnectionSafetyTests, a pair of source-parsed regression tests: one bans future test code from calling TimescaleSupport.TryEnableAsync directly on a connection it keeps using, the other pins that DarlingWorker's dedicated-connection/gated-flag shape (which makes the product safe from this today) doesn't drift.
  • Documents the false-may-mean-dead-connection contract on TryEnableAsync itself.

Correctness. I traced the product path (DarlingWorker.cs around the timescaleConnection block) and confirm it's safe as claimed: the connection is dedicated, disposed at block end, and every subsequent TimescaleSupport.* call sits behind if (_timescaleAvailable). The new pin (DarlingWorker_TouchesTheTimescaleConnectionOnlyBehindTheAvailabilityFlag) correctly enforces that shape via text search between the try open and the first matching catch. LiveTimescaleProbe itself looks correct — separate connection, explicit SET search_path (matching LiveStoreCleanup's reasoning for why it's needed rather than relying on the database default), disposed via await using regardless of outcome.

Lite/Darling parity. No concern — TimescaleDB is Darling/Postgres-only; Lite has no counterpart surface to drift from.

Security. No new input handling, secrets, or SQL-injection surface. All interpolated identifiers in TimescaleSupport.cs come from compile-time constants/catalog metadata, not user input (pre-existing pattern, unchanged here).

Minor notes (non-blocking), left as inline comments:

  1. DarlingDeltaSeederTests.cs still discards the probe's boolean result, unlike the LivePostgresStoreFixture site which now captures it into TimescaleAvailable. It's not unsafe — the connection stays healthy either way — but it's a small inconsistency worth a look.
  2. The new "checked on the spot" static-analysis guard has a couple of soundness gaps common to text-window heuristics (order-dependence within the window, and the block-boundary search in the DarlingWorker pin relying on IndexOf finding the first matching catch text). Given the PR explicitly frames these as deliberately-heuristic, cheap tripwires rather than exhaustive guarantees, I think this is fine as shipped, just flagging for awareness.

Overall this is a well-scoped, well-verified fix — the before/after live repro against an unpreloaded PostgreSQL 18.4 is convincing evidence the actual masking bug is gone. Nice work.

/* #1922: on its OWN connection. This site discarded the result entirely and then kept using the same
connection for the whole test, so an unpreloaded TimescaleDB left every statement below failing
with "Connection is not open" instead of the real cause. */
await LiveTimescaleProbe.TryEnableAsync(connectionString!, TestContext.Current.CancellationToken);

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 inconsistency: this discards LiveTimescaleProbe.TryEnableAsync's result, while the LivePostgresStoreFixture site (line 121) now captures it into TimescaleAvailable. It's not unsafe here — the enable runs on its own connection, so connection below stays healthy regardless of outcome — and if TimescaleDB genuinely isn't enabled, ConvertToHypertablesAsync fails per-table into a swallowed warning (logger is null here) and the test's own Assert.SkipWhen(recentChunk == oldChunk, ...) catches the resulting unpartitioned-table case. So functionally this self-skips rather than masking. Still, capturing the bool and asserting/skipping on it explicitly (like the fixture does) would make the "TimescaleDB wasn't available" case diagnosable directly instead of via an inferred side effect two asserts later.

assertion wrapped across lines read the same as the one-line form — the blind spot a purely
line-by-line scan leaves. The window starts at the call's line, so an assertion ABOVE it is not
mistaken for a check of it. */
var window = string.Join(' ', lines.Skip(index).Take(4));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Heads-up on a soundness gap in this heuristic (not blocking, given the PR already frames these as cheap tripwires rather than exhaustive guarantees): the window is "the call's line + the next 3", flattened, with no check that nothing between the capture and the Assert.True touches the connection. e.g.

var enabled = await TimescaleSupport.TryEnableAsync(connection, null, ct);
await SomeOtherCall(connection, ct); // runs on a possibly-dead connection, unchecked
Assert.True(enabled, "...");

would pass IsCheckedOnTheSpot (the assert is within 4 lines and references the captured variable) even though the unsafe use happens before the check. Given the discipline this PR establishes (enable-then-immediately-assert), this is unlikely to occur in practice, but worth knowing the guard doesn't strictly prevent it.

… be next

Two more from review, both accepted.

The seeder site still discarded the result. Safe - the enable runs on its own
connection now - but without TimescaleDB the conversion fails per table into
a swallowed warning, the table stays plain, and the test lands on its own
"no per-day chunks to exclude" skip several asserts later. True, but arrived
at by inference, which is the exact thing this PR is about. It now captures
the bool and skips on it by name.

The guard's window also only required the check to appear within a few
lines, so this would have passed:

    var enabled = await TimescaleSupport.TryEnableAsync(connection, null, ct);
    await SomethingElse(connection, ct);   // possibly-dead connection, unchecked
    Assert.True(enabled, "...");

"Checked somewhere nearby" is not the property that matters - anything
between the capture and the check is itself running on a connection that may
be dead. The window is now split into statements and the check must be the
very NEXT one, which is the discipline every call site already follows.
Assert.SkipWhen(!x) counts alongside Assert.True(x): both stop before the
connection is used again, and a rig legitimately without TimescaleDB should
skip rather than fail.

Verified all four ways: inline accepted, capture-then-check accepted,
interleaved use rejected, split-across-lines call still caught. The failure
message now states the tightened rule rather than the old looser one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review

Scoped this as a C#/test-infra change (no .sql files touched, so the T-SQL style guide doesn't apply here), and traced it end to end rather than just reading the diff.

What it does: Fixes a masking bug in the Darling live-test rig — CREATE EXTENSION IF NOT EXISTS timescaledb kills the backend (not just an ordinary error) when the library is on disk but unpreloaded, so the old code's TryEnableAsync(connection, ...) calls left every subsequent statement on that connection throwing Connection is not open, hiding the real cause. Fix: run the enable on a dedicated, short-lived connection (LiveTimescaleProbe) so a dead connection from a failed CREATE EXTENSION never propagates to the caller's connection.

Verification performed:

  • Confirmed DarlingWorker.cs (the one product caller) really does open a dedicated connection and gate every subsequent TimescaleSupport.* call behind _timescaleAvailable — matches what DarlingWorker_TouchesTheTimescaleConnectionOnlyBehindTheAvailabilityFlag pins.
  • Confirmed both converted call sites (LivePostgresStoreFixture.cs, DarlingDeltaSeederTests.cs) preserve ordering — PgMigrations.MigrateAsync (which creates the collect schema) still runs before LiveTimescaleProbe.TryEnableAsync, so the probe's own SET search_path = collect, config, public lands the extension in the right schema on a fresh, unmigrated-by-it connection.
  • Checked connectionString/PgSchemaGenerator.SearchPath references resolve correctly in both edited files.
  • Parity: confirmed Lite has zero references to Timescale/Postgres (grep across Lite/Lite.Tests returns nothing) — this is legitimately Darling-only test infrastructure, not a parity drift.
  • Assert.SkipWhen is an established pattern (219 existing uses in this project on xunit.v3 3.2.2), so nothing novel there.

No correctness, security, or performance issues found. The regression-guard tests (TimescaleEnableConnectionSafetyTests) are source-parsed by design and reasonably scoped (excluding the probe/test-guard files themselves by name so they don't self-flag); the 4-line statement window in IsCheckedOnTheSpot is a heuristic but the PR's own watched-red mutation testing demonstrates it catches the two real regressions it's meant to catch. No missing-index DMV suggestions offered, per project convention.

Nice work tracing this down to the actual Postgres backend-termination behavior rather than papering over the downstream symptom.

@erikdarlingdata
erikdarlingdata merged commit cbd5d62 into dev Jul 31, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/1922-timescale-deadconn branch July 31, 2026 07:56
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