Skip to content

Part of #3099 - #3108

Merged
erikdarlingdata merged 13 commits into
devfrom
fix/3099-store-write-retry
Sep 7, 2026
Merged

erikdarlingdata merged 13 commits into
devfrom
fix/3099-store-write-retry

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Implements the first of #3099's three suggested directions: a collector's binary COPY into the store that fails on a transport fault in the COPY's start phase gets one re-attempt on a fresh store connection. Before this there is no retry anywhere on the path — the retry/backoff hits in DarlingCollectorRunner are #2776's plan-fetch width backoff and #2673's budget-abandon, both meaning "the next scheduled cycle".

Part of #3099, not Fixes. This touches the collector COPY write path only. #3099 is evidenced across a wider population — its own report shows the same error text on store reads (Collection-health self-alert failed, Failed to check forced-plan failures), which this does not touch and which lose an alert evaluation rather than a sample. The closing condition belongs on the issue, not inherited from a title here.

The gate is the design, not a precaution

WriteBatchAsync splits into a wrapper and CopyBatchOnceAsync, which holds the COPY and (for the #1767 diverting collectors) the transaction wrapping it and its dimension flush. The split is load-bearing: the importer and the transaction must be disposed before a second attempt, or it re-enters with an aborted transaction and fails on 25P02 rather than on anything to do with the store.

StoreWriteReattempt.IsSafeToReattempt ANDs two independent axes, and both are required:

  • a transport faultSocketException / IOException / TimeoutException anywhere in the inner chain, else NpgsqlException at the top. PostgresException anywhere in the chain is the backend answering (duplicate key, missing relation, bad input, full disk, its own statement_timeout at 57014); an identical second attempt gets an identical answer, and re-attempting past a SQLSTATE turns a legible error into a silent one. The chain is walked with the PostgresException test first at every level, so wrapping cannot smuggle a SQLSTATE past the predicate.
  • COPY start phase still inherits Npgsql's 30s default; #2874 narrowed but did not close it, and both phases log the same string #3095's StoreCopyPhase.Start — required positively.

Why the phase axis is what makes this correct. Re-running the batch re-runs WritePayload, and WritePayload is not a pure function of its row: it derives deltas through CollectorDeltaCalculator, whose AddOrUpdate returns (currentValue, …) on every arm — so asking for a delta advances the baseline as a side effect. A second pass over rows the first attempt already rendered therefore computes currentValue - currentValue = 0 and commits a zero for every delta column. That affects 8 collectors (QueryStats 8 calls, FileIoStats 8, ProcedureStats 7, Spinlock 4, LatchStats 3, WaitStats 3, MemoryGrants 2, PerfmonStats 1) — three of them the null-watermark collectors this re-attempt exists for. A zero delta reads as a genuinely idle interval, carries no error, and never self-corrects; that is strictly worse than the lost sample.

StoreCopyPhase.Start is the one state where neither hazard exists. It is raised by BeginBinaryImportAsync itself, so the importer never returned: no row was started, hence a COPY … FROM STDIN cannot have committed, and WritePayload never ran, hence no baseline moved. #3095 puts the StartData transition inside the COPY block precisely so Start cannot mean anything looser.

So the re-attempt is exactly-once, not at-least-once. There is no lost-commit-ack window and no aggregate duplication to disclose, because the only phase it fires in cannot have committed.

Unknown declines, and that is deliberate. It is what an unstamped exception reads as, and it covers the dimension flush and the transaction commit after the COPY (#1767) — which genuinely can commit. Requiring Start positively means a missing stamp costs a sample instead of authorising a duplicate. A predicate written as "not Data" would re-attempt everything it failed to recognise; that variant is pinned red.

What it recovers, at its real size

Honest sizing, because only the cost was quantified before:

  • The population is start-phase transport faults of collector COPY writes, and only the five null-watermark collectors lose anything at all — a WatermarkColumn collector's watermark is MAX(column) over rows already stored, so a failed write cannot advance it and the next cycle re-reads the same range.
  • On the current regime that is order tens of failures per day fleet-wide before the phase and null-watermark narrowing, which takes it lower again by a factor I have not measured. The base rate is a coordinator measurement I did not take myself; I am carrying it with that provenance rather than restating it as mine.
  • The three-day 1,074 figure is not the right denominator and is not used here: it is regime-mixed and largely pre-fix, so sizing against it overstates by roughly an order of magnitude.

Smaller on both sides than the first draft of this description implied, and it should be argued at that size. What does not shrink is the character of the loss: on a null-watermark collector the sample is unrecoverable, and the data is sitting in memory when the write fails.

The base default in CollectorDefinitionBase is WatermarkColumn => null; the affected set is query_stats, procedure_stats, file_io_stats, plan_cache_stats, plan_correction.

Cancellation

Both attempts run on the caller's cancellationToken, and a token already cancelled suppresses the re-attempt entirely.

That token is the service's stopping token, not a deadline, so honouring it costs nothing in the population this exists for. What it prevents is a re-attempt outliving an orderly stop while holding a sweep permit and a store connection, against a bundled store the same process is shutting down — the one case where a second attempt is guaranteed useless. The suppression sits in the exception filter rather than as a throw inside the arm, so a write failing during a stop still reports its own transport fault instead of being relabelled a cancellation. Accepted consequence: a write that fails because of shutdown loses its sample exactly as today.

OperationCanceledException is caught and rethrown ahead of the filter rather than left to the predicate answering false — an arm whose correctness rests on a predicate not matching is one predicate edit away from re-attempting through a shutdown.

No delay, and why none is purchasable

The mechanism is the fresh connection, not a wait: the first attempt's connector is dead — the reasoning DarlingManagedPostgres's post-start loop already records — so a second COPY on the caller's handle would fail on the protocol rather than on the store.

A delay is not available here at any useful length. The sweep permit and the caller's borrowed store connection are both held for the duration, so a pause long enough to outlast the store's own hourly continuous-aggregate refresh (hundreds of seconds, per #3099's duration series) would hold both for minutes, while any pause short enough to be safe is noise against that window. The accepted cost is that a write's worst case is two command deadlines rather than one.

How it is recorded

The status stays SUCCESS. A new value would be read as a failure by every consumer of status IN ('SUCCESS', 'SKIPPED') — seven query clauses across five files (Lite's collection-health read, the self-alert evaluator's last_success and recent_success, both MCP readers, and the Darling viewer's two) — and would suppress last_success for a cycle that stored every row and advanced its watermark. That is #2673's defect with the sign flipped, and the same reasoning that put the whole-cycle-budget message in the note channel rather than inventing a sixth status.

The cycle is distinguished by two things:

  • a Warning log line naming the collector, the server, the row count and the first attempt's message, from a named template constant so a pin can assert the shipped string. Warning rather than Debug because a store that drops collector writes is a finding even when the row is recovered, and the level is what keeps it on the default filter — where the log-based measurement that found this reads. (Deliberate under Fixes #3102 #3103's new regime, which moved per-cycle timing to Debug.)
  • a count on the run's collection_log row, composed into the existing Enumerated collectors that yield zero items log SUCCESS indistinguishable from healthy #1837 note channel through EnumeratedCollectorDriver.MergeNotes. Merged rather than assigned, read once after every write on all three dispatch paths, so a re-attempt cannot displace the probe-failure or partial-database note it can co-occur with. A count rather than a flag because the fan-out paths write once per database or per item.

Recording it is not a courtesy: a transport fault currently writes a collection_log ERROR row, and those rows are the measurement behind #3099's in-window versus out-window rate ratio and the prediction registered against it. A silent retry would remove the lost sample and the instrument that checks whether the association it was diagnosed from is real.

Known cost

The caller's connection stays broken for the rest of the cycle, and on a fan-out it is shared across every batch (#2819 has the Query Store plan and text fetches borrowing the same one). Each batch after the faulting one detects it by state and sends its first attempt to a fresh connection — so there is no wasted first attempt and no second Warning line; a faulted cycle logs one Warning, for the batch that faulted. Deciding on the connection's own state rather than on a later exception's shape or stamping keeps those batches out of the phase question entirely.

The cost is connection holds. For the remainder of a faulted cycle that server holds two store connections at a time rather than one: the caller's broken handle, still checked out until its await using unwinds, plus the fresh one each batch borrows. #2819's re-derivation of MaxPoolSize = 24 sized the pool at the sweep width on peak concurrent holds being one per swept server, so this is the 2x multiplier that bound moved away from — reachable only if many swept servers fault in the same cycle. Repairing the caller's handle in place would need the callers to hold a re-openable connection: wider than this change.

plan_correction is why the state check matters rather than being tidiness: it enumerates on every non-Azure target and declares no WatermarkColumn, so it both fans out and cannot re-read a lost sample.

WriteBackfillBatchAsync inherits the re-attempt, since it routes through WriteBatchAsync. Its context is not attached to a CollectorRunResult, so its note reaches nothing; the Warning line still fires.

Scope

Direction 1 only. #3095's start-phase deadline is merged (#3110) and this consumes its phase axis rather than duplicating it. Refresh scheduling is untouched.

Tests

StoreWriteReattemptTests, 20 cases. The policy runs through StoreWriteReattempt.RunAsync with attempt delegates that move rows into a sink, because the COPY it wraps is reachable only through a live store and a constructed runner. What delegates cannot see — that the shipped write routes through it at all, and that the re-attempt takes a fresh connection rather than the caller's dead one — is pinned against the source. Neither half is sufficient alone.

Phase stamps in the tests are applied through the shipped producer (CollectorFaultCopyPhase.Stamp), not by writing Exception.Data directly, so the key asserted is the key the runner writes rather than a retyped string.

Every assertion is red-proofed against a mutation that keeps the tree compiling:

mutation red
phase conjunct removed ADataPhaseFaultIsNeverReattempted, AnUnstampedFaultIsNeverReattempted, IsSafeToReattemptRequiresBothAxes
gate written != Data instead of == Start AnUnstampedFaultIsNeverReattempted, IsSafeToReattemptRequiresBothAxes (the Data test stays green — the two declining phases are pinned separately)
transport conjunct removed all five AServerReplyIsNeverReattempted rows + AWrappedServerReplyIsNeverReattempted
re-attempt removed AFailedFirstWriteFollowedByASuccessfulReattempt_StoresTheRows_AndReportsSuccess
re-attempt swallows its own failure AFailureOnBothAttempts_ReportsTheError — "nothing was thrown"
write bypasses the helper TheShippedStoreWriteRoutesThroughTheReattemptOnAFreshConnection
helper reached through a same-signature shim same test (this variant defeated the pin's first form)
re-attempt reuses pgConnection same test, fresh-call count
broken-connection first-attempt fallback removed same test, fresh-call count
CopyOnAFreshConnectionAsync stops opening its own connection same test, helper-body assertion
note merge dropped TheReattemptCountReachesTheCollectionLogNote

The gate's other half lives in #3110, and both directions now say so

IsSafeToReattempt's Start conjunct means something only because Start is in force until BeginBinaryImportAsync returns and can never apply to a fault raised once the row loop has begun. That is a property of the COPY block, pinned in a third file, and an auditor reading the gate could find neither. So the gate's doc now names the stamping site and TheCopyWriteStampsTheStartPhaseUntilBeginReturns, and StoreCopyPhase.Start and Stamp now say a re-attempt decision is downstream — because whoever widens that try or adds a stamping call site is the person who needs to know.

A hole in that pin, closed

The pin constrained transitions to Data (exactly one, correctly positioned) and explicit Stamp(ex, Start) calls (exactly zero). It never constrained bare assignments to Start. I confirmed the counts in the merged runner — copyPhase = StoreCopyPhase.Start; occurs 1, Stamp(ex, StoreCopyPhase.Start) occurs 0 — then proved it exploitable: adding copyPhase = StoreCopyPhase.Start; below the row loop left all 75 tests green, while making a post-row-loop fault carry Start. Since the gate re-runs the batch on Start, that is a duplicated write and a delta column of zeros with nothing red.

A symmetric count assertion closes it, red-proofed with that same mutation (1→2). Both counts and both positional patterns now spell the assignment \s*=\s* rather than with literal single spaces — copyPhase=StoreCopyPhase.Start; is valid C# and evaded every literal form; that variant is red-proofed too.

And the instance pair, because the pin and the instance test fail differently

Not instead of the pin — neither covers the other's blind spot, and I have that in both directions rather than as an argument:

mutation source pin instance test
bare = Start; below the row loop RED (count 1→2) passes — the loop is never reached
Begin hoisted outside the stamped try passes — all six regexes still match REDStartUnknown

A source pin catches every edit of a shape it anticipated and is blind to constructions nobody thought of, which is precisely what the hole above was. The instance test runs the real path and reads the stamp off a real exception, so a construction producing the wrong phase fails even when every regex matches.

The two instance tests live in StoreCopyPhaseLivePostgresTests under [Collection("live-postgres")], not in StoreCopyPhaseTests. They reach the shared DARLING_TEST_PG store, which makes their class a live class — and LivePostgresCollectionHygieneTests prefers exactly this remedy: a mostly-pure class keeps its purity and the live tests move out, rather than serializing seven source-text pins against every other live class. The vacuity guards travel with the tests.

The start case needs no store — an unopened connection makes the real BeginBinaryImportAsync refuse before it reaches a socket, which is a start-phase fault — so it runs everywhere and I red-proofed it locally. Its definition's WritePayload throws a distinctive exception, so a reachable row loop would be named rather than silently changing what the test measures. The data case needs Begin to succeed, so it is gated on DARLING_TEST_PG and its first execution is CI's live job; I could not run it on macOS and am not claiming otherwise. It is the negative control that stops the start case passing under an unconditional Start stamp.

The server-reply rows are stamped Start on purpose: the phase axis would accept them, so their decline can only be the type axis doing its job. Unstamped they would pass for the wrong reason.

The rows-storing assertion checks the sink rather than the return value alone — a helper returning the right count while writing nothing would satisfy a count-only assertion, and "no sample is lost" is a claim about the store's contents.

CollectionSweepCommandTimeoutTests (the sweep's own deadline scanner) and DarlingPayloadProbeFailureTests run against the change and stay green. PayloadDimensionTests' exactly-one-occurrence-and-ordering pin on importer.CompleteAsync / PayloadDimensionWriter.FlushAsync / transaction.CommitAsync, and DarlingEmptyEnumerationNoteTests' whitespace-collapsed argument-list pin, were verified by hand against the shipped source — both classes need the WPF Viewer project and cannot compile in a macOS harness.

CHANGELOG entry

- Fixed: a collector's store write that fails on a transport fault in the COPY's start phase is re-attempted once on a fresh store connection instead of costing the cycle. A start-phase fault sent no rows and ran no payload writer, so the store is byte-identical and no delta baseline has moved, which makes the re-attempt exactly-once and lossless; collectors with no watermark column previously lost one sample outright. A data-phase fault, an unstamped fault and any server reply (any SQLSTATE) all decline, and a cancelled token suppresses the re-attempt. A cycle that stored its rows on the second attempt says so on its collection_log row and in a warning log line. (#3099, consuming #3095's phase axis)

A collector's binary COPY into the store that fails on a transport fault
gets one re-attempt on a fresh store connection. The batch is still the
method's own parameter and a failed COPY commits nothing, so the
re-attempt is lossless.

A server reply is never re-attempted: PostgresException means the backend
received the statement and answered, so an identical second attempt gets
an identical answer. The predicate is shared with the managed store's
post-start connection loop, which asks the same question of the
exception under a different retry policy.

The status stays SUCCESS. The cycle's re-attempt count reaches the
collection_log row through the existing note channel, so a write that
faulted and then succeeded is distinguishable from one that never
faulted.
The pin took the source between WriteBatchAsync and CopyBatchOnceAsync
and asserted the re-attempt call appeared in it. A variant where the
write called something else stayed green, because the slice swallowed
anything declared in the gap and matched that instead.

It now counts every call to the COPY and requires each one to sit inside
the re-attempt's own argument list, and reads the fresh-connection claim
off the rewrite delegate alone.
The both-attempts-fail test asserted an empty sink. Both of its delegates
throw before touching anything, so the sink was empty however RunAsync
behaved -- coverage that reads as a claim and checks nothing. The claim
that survives is that no value is returned at all, which is the throw.
…roken

A transport fault breaks the store connection the caller holds, and on a
fan-out that connection is shared across every batch. So each batch after
the first faulting one inherited a connection it could not use, and what
that throws need not be a transport fault the re-attempt recognises.

plan_correction is why it matters rather than being tidiness: it
enumerates on every non-Azure target and declares no WatermarkColumn, so
it both fans out and cannot re-read a sample it loses that way.

The decision reads the connection's own state instead of a later
exception's shape. Both fresh-connection paths route through one named
method so "fresh" has one body.
@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling-only: DarlingCollectorRunner.cs, DarlingManagedPostgres.cs, new PostgresTransportFault.cs / StoreWriteReattempt.cs, CollectorContext.cs, plus the new test file).

Main finding (posted inline on DarlingCollectorRunner.cs): the re-attempt replays CopyBatchOnceAsync over the same rows, which makes it lossless for row count, but WritePayload is not idempotent for every collector — query_stats, procedure_stats, file_io_stats, wait_stats, pg_wait_stats, pg_statement_stats, memory_grants, perfmon_stats, latch_stats, and spinlock_stats all call context.Deltas.Calculate* from inside WritePayload, against a CollectorDeltaCalculator cache that lives for the process lifetime. A fault late enough in the failed first attempt (plausibly during importer.CompleteAsync(), after every row already ran through WritePayload) advances that cache's baseline before the retry runs WritePayload again on the identical rows, so the successful second attempt computes and commits a delta of currentValue - currentValue = 0. That's a silent zero landing in the store — indistinguishable from a genuinely idle interval — which is a worse outcome than the pre-PR "lose the whole batch, get an ERROR row" behavior the PR is fixing, and it isn't covered by StoreWriteReattemptTests.cs since those tests exercise StoreWriteReattempt.RunAsync with trivial delegates rather than through WritePayload.

Other things checked, no issues found:

  • Style: T-SQL conventions don't apply (no SQL in this diff); C# looks consistent with the codebase (XML docs, internal visibility, naming).
  • Lite/Darling parity: this is Darling/Npgsql-specific (transport retry over a network connection to Postgres); Lite's DuckDB store is local/embedded and doesn't have the analogous failure mode, so I don't think this needs a Lite counterpart. CollectorContext.StoreWriteReattempts is a harmless no-op addition on the Lite side (defaults to 0, never incremented there).
  • PostgresTransportFault.IsTransportFault extraction from DarlingManagedPostgres.IsTransientConnectionFault is behavior-preserving — same walk-the-inner-exception-chain logic, PostgresException still checked first at every level so a wrapped server reply can't be misclassified as transport.
  • Cancellation handling in StoreWriteReattempt.RunAsync is correct: OperationCanceledException is re-thrown ahead of the transport-fault filter rather than relying on the filter to reject it, so a predicate change later can't accidentally start retrying through a shutdown.
  • Minor, already-acknowledged-in-comments tradeoff, not filing separately: once pgConnection breaks, every remaining per-database/per-item batch in the same fan-out (WriteBatchAsync calls at lines ~1376 and ~1920 both close over the same pgConnection) will also fault on attempt 1 before reattempting on its own fresh connection — worst case is proportional to remaining batch count × a command deadline, not just the two-attempt cost of one batch. The code comment already calls this "wasteful and bounded," so flagging only for visibility, not as a blocker.
  • No missing-index DMV suggestions applicable here (no T-SQL in this diff).

The re-attempt is at-least-once. A transport fault raised before the
commit acknowledgment is awaited leaves the store untouched, but a fault
raised while awaiting it can leave the server committed while the client
sees a retryable exception, and the second attempt lands the batch again.
IncludesCollectionId defaults true, so each attempt stamps its own row
ids and nothing downstream can collapse the copies.

collection_id is in no continuous aggregate's GROUP BY, so both copies
land in the same bucket and 18 sum() views double it, materialised, past
the four-day raw window, and inherited by each daily rollup off its
hourly view. sample_count doubles with it on five of the eighteen, which
is the signature to look for.
"Eight readers" counted three prose mentions of the predicate as readers.
The query clauses are seven, across five files: Lite's collection-health
read, the self-alert evaluator's last_success and recent_success, both
MCP readers, and the viewer's two.
@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown

Reviewed the diff (StoreWriteReattemptTests.cs, DarlingCollectorRunner.cs, DarlingManagedPostgres.cs, PostgresTransportFault.cs, StoreWriteReattempt.cs, CollectorContext.cs) against CONTRIBUTING.md's C# conventions and for correctness, parity, security, and performance.

Correctness — traced carefully, no bugs found:

  • CopyBatchOnceAsync being split out of WriteBatchAsync correctly ensures the importer and (for diverting collectors) the transaction are disposed before a re-attempt, avoiding a 25P02-on-aborted-transaction re-entry.
  • The pgConnection.State == ConnectionState.Open check on the first attempt (added in the 4th commit) correctly prevents a fan-out's later batches from hitting a non-transport-shaped exception on an already-dead shared connection — good catch, and it's exercised by TheShippedStoreWriteRoutesThroughTheReattemptOnAFreshConnection.
  • StoreWriteReattempt.RunAsync's cancellation handling (rethrow ahead of the filter, suppress via the filter rather than a throw inside the arm) is correct and matches the stated intent.
  • Verified the note-merge (StoreWriteReattemptNote) composes correctly with the existing probe-failure/partial-database note channel rather than clobbering it, and that it's wired into the one shared return path all three dispatch routes (plain, per-database fan-out, per-item enumeration) converge on.
  • The connection-count cost during a faulted cycle (shared broken connection + one transient fresh connection per remaining batch) stays within the existing MaxPoolSize = 24 / sweep-permit budget already established elsewhere in this file (2 concurrent connections per sweeping server), so no connection-pool exhaustion regression under the documented fan-out pattern.
  • The at-least-once duplicate window (documented at length in the PR body and in the CopyBatchOnceAsync doc comment) is an accepted, well-reasoned trade-off, not an oversight.

Lite/Darling parity: No parity gap — this is Npgsql/PostgreSQL-transport-specific (Lite writes to an embedded, in-process DuckDB with no network transport to fault on), so there's no Lite counterpart that should have received an equivalent change. The new CollectorContext.StoreWriteReattempts field is host-set and simply stays 0 on the Lite side, same pattern as the existing phase/drain diagnostic fields.

Minor scope note (not a bug): RdsPlanIngestor/RdsDeadlockIngestor/RdsCpuIngestor (the AWS-API ingestion routes) do their own independent BeginBinaryImportAsync and don't route through StoreWriteReattempt, so a transport fault there still costs a full cycle rather than getting the re-attempt. Lower severity in practice, though, since those three already commit their resume marker only after a successful write, so a failed write there just re-reads the same window next cycle rather than losing the sample outright — likely why it wasn't pulled into this PR's scope.

One nit posted inline: an unused using System.Net.Sockets; left in DarlingManagedPostgres.cs after IsTransientConnectionFault was refactored to delegate to PostgresTransportFault.

No security or T-SQL style issues — this PR touches no T-SQL and no user-facing input handling.

@erikdarlingdata
erikdarlingdata marked this pull request as draft September 6, 2026 18:14
Re-running the batch re-runs WritePayload, which is not a pure function
of its row: it derives deltas through CollectorDeltaCalculator, which
advances its baseline as a side effect of being asked. A second pass over
rows the first attempt already rendered therefore commits a zero for
every delta column -- on eight collectors, three of them the
null-watermark ones the re-attempt is for. A zero delta reads as an idle
interval, carries no error and never self-corrects, which is worse than
the lost sample.

#3095's StoreCopyPhase separates the two cases. A start-phase fault came
out of BeginBinaryImportAsync, so no row was started -- a COPY FROM STDIN
cannot have committed and no WritePayload ran. IsSafeToReattempt requires
that value positively, so a data-phase fault and an unstamped fault both
decline and cost a sample rather than authorising a duplicate or a
fabricated zero.

That also makes the re-attempt exactly-once, so the at-least-once window
and the aggregate-doubling it implied no longer exist.
@erikdarlingdata erikdarlingdata changed the title Fixes #3099 Part of #3099 Sep 7, 2026
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reviewed the full diff (all 7 commits) against the merged #3095/#3110 phase-gate infrastructure it builds on. Summary:

Correctness — traced the final state of StoreWriteReattempt.RunAsync / IsSafeToReattempt / CopyBatchOnceAsync / WriteBatchAsync end to end:

  • The Start-phase gate is applied positively (CollectorFaultCopyPhase.For(fault) == StoreCopyPhase.Start), so an unstamped fault or a data-phase fault both correctly decline rather than being accepted by an inverted != Data check.
  • copyPhase transitions to Data as the very first statement after BeginBinaryImportAsync returns, before the row loop starts — so the phase stamp can't under-report a fault that actually sent rows.
  • Exceptions raised by the post-COPY dimension flush / transaction commit (query_text/query_plan_xml stored inline per row: 94% of a field store — normalize into hash-keyed dimension tables (~135x measured) #1767 path) are outside the try/catch that stamps the phase, so they correctly surface as Unknown and decline re-attempt — matches the doc comment's claim.
  • EnumeratedCollectorDriver.RunAsync processes items in a plain sequential foreach, so context.StoreWriteReattempts++ in WriteBatchAsync has no concurrent-mutation hazard across fan-out batches.
  • Verified the collectionNote merge (StoreWriteReattemptNote(context.StoreWriteReattempts)) sits after the only return path that can reach a successful WriteBatchAsync call in RunAsync; the wall-clock-budget abandon return (Abandoned: true) executes before any write is attempted in that branch, so there's no scenario where a real reattempt count is silently dropped from the note.
  • WriteBackfillBatchAsync routes through WriteBatchAsync as described, so it inherits the same gate.
  • The pgConnection.State == ConnectionState.Open check for routing the first attempt to a fresh connection is orthogonal to the transport/phase safety gate and doesn't interact with it incorrectly — it only affects which connection the first attempt uses, not whether a failure earns a second attempt.
  • Confirmed DarlingManagedPostgres's own 6-attempt/2s-apart retry loop (EnsureDatabaseAsync) is scoped to startup bootstrap only and is not in the call path of _postgres.OpenConnectionAsync used by CopyOnAFreshConnectionAsync, so the PR body's "no delay is purchasable" claim holds — the fresh-connection open in the hot path is a single attempt, not a hidden multi-second retry loop.

Parity — this only touches Darling's Postgres COPY write path (transport-fault handling that has no Lite analogue, since Lite writes to a local embedded DuckDB with no equivalent network transport-fault surface). The CollectorContext.StoreWriteReattempts addition is a transient in-memory counter, not persisted store state, so it doesn't fall under the migration-rung/reader parity rule in CONTRIBUTING.md — Lite simply never increments it, which is correct, not a gap.

Security — no new external input handling, SQL construction, or secrets. Pure connection/retry orchestration.

Style — no T-SQL in this PR. C# additions have copyright headers and follow existing file conventions (named log/note templates, CompositeFormat.Parse for CA1863, etc.).

No correctness, parity, or security issues found. The test suite (StoreWriteReattemptTests) is unusually thorough — it pins the exact call-site wiring in DarlingCollectorRunner.cs by source inspection in addition to the policy unit tests, so most of the failure modes I went looking for (bypassing the helper, reusing the dead connection, an inverted phase check, a dropped note merge) already have a red test guarding them.

IsSafeToReattempt's second conjunct means something only because Start is
in force until BeginBinaryImportAsync returns and can never apply to a
fault raised once the row loop has begun. That is a property of the COPY
block in another file, and its pin lives in a third, so an auditor
reading the gate had no way to find either.

Both directions now name each other: the gate points at the stamping
site and the test that pins it, and StoreCopyPhase.Start and Stamp say
that a re-attempt decision is downstream, so anyone widening the try or
adding a stamping call site knows what it authorises.
…e pair

The stamping pin constrained transitions to Data and explicit
Stamp(ex, Start) calls, but never bare assignments to Start. A
`copyPhase = StoreCopyPhase.Start;` added below the row loop passed all
five assertions -- and since IsSafeToReattempt re-runs a batch on Start,
that made a post-row-loop fault re-attemptable: a duplicated write and a
delta column of zeros, with every test green. A symmetric count closes
it.

The instance pair covers the class a source pin cannot. A pin catches
edits of shapes it anticipated; the hole above was a shape nobody did.
These run the real path and read the stamp off a real exception, so a
construction that produces the wrong phase fails even when every regex
still matches. The start case needs no store -- an unopened connection
makes the real BeginBinaryImportAsync refuse before it reaches a socket.
The data case needs Begin to succeed and is gated on DARLING_TEST_PG.
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reviewed this against CONTRIBUTING.md's C# conventions, Lite/Darling parity, and the usual correctness/security/perf lenses. No .sql files are touched, so the T-SQL style section doesn't apply here.

Scope/parity: Darling-only, as expected — this is a binary-COPY transport-fault retry for the PostgreSQL store write path, which has no Lite analogue (Lite's DuckDB writes are local/embedded, not subject to the same network transport faults). CollectorContext.StoreWriteReattempts is an additive property in the shared PerformanceMonitor.Collectors library; Lite simply never sets it, which is harmless (defaults to 0, never read on that path). Not a parity gap.

Correctness: Traced the two-axis gate (PostgresTransportFault.IsTransportFault AND StoreCopyPhase.Start) carefully:

  • CollectorFaultCopyPhase.For reads off the exact exception instance stamped in CopyBatchOnceAsync's catch block, which is rethrown bare (throw;) — so the stamp reliably survives to StoreWriteReattempt.RunAsync's catch. No chain-walking mismatch between Stamp/For (single-frame) and PostgresTransportFault.IsTransportFault (chain-walked) — intentional and consistent, since Stamp always writes to the frame that caught the fault directly.
  • Verified the collectionNote = MergeNotes(..., StoreWriteReattemptNote(context.StoreWriteReattempts)) call site (DarlingCollectorRunner.cs:2335) is reached from all three WriteBatchAsync call sites in this method (per-database fan-out at :1376, the enumerated-driver delegate at :1920, and the single-write path at :2279) with no early return in between that would silently drop an already-successful reattempt's note. The two early returns in this method (:1628 empty-enumeration, :2236 budget-abandon) both occur before any write happens, so there's nothing to lose there.
  • CopyOnAFreshConnectionAsync/CopyBatchOnceAsync correctly isolate all per-attempt state (importer, transaction, PayloadDimensionBatch) to their own scope, so a retried attempt can't inherit an aborted transaction (the 25P02 case the docs call out) or double-count anything from the failed first attempt.
  • Cancellation handling is correct: OperationCanceledException is caught and rethrown ahead of the retry filter, so it can never be misclassified as a retryable transport fault, and a token cancelled between attempts suppresses the second attempt via the exception filter (not a throw inside the arm), which per the docstring keeps the fault reported as itself rather than relabeled as a cancellation.
  • The extraction of the transport-fault predicate into PostgresTransportFault.IsTransportFault is behavior-preserving — verified byte-for-byte against the code it replaced in DarlingManagedPostgres.IsTransientConnectionFault.

Minor/non-blocking: DarlingManagedPostgres.cs still has using System.Net.Sockets; (line 17) after the inline SocketException check moved out to PostgresTransportFault.cs — no other reference to SocketException remains in that file. This won't fail the build (the project doesn't set EnforceCodeStyleInBuild, so IDE0005 unused-using isn't enforced at build time), so it's just housekeeping, not a blocker.

Test coverage (StoreWriteReattemptTests.cs, the StoreCopyPhaseTests.cs additions) is unusually thorough — it exercises both attempt-outcome combinations, both axes of the gate independently, wrapped/unwrapped server replies, cancellation, and pins the actual call sites in the shipped source so the policy tests can't go green while the wiring silently regresses. Nothing to add there.

No correctness, parity, security, or performance issues found.

The two instance tests reach the shared DARLING_TEST_PG store, which made
StoreCopyPhaseTests a live class and tripped
LivePostgresCollectionHygieneTests. That pin's preferred remedy is this
shape: a mostly-pure class keeps its purity and the live tests move to
their own ...LivePostgresTests. Putting the attribute on
StoreCopyPhaseTests instead would serialize seven source-text pins
against every other live class for nothing.

The vacuity guards travel with the tests: the start case asserts the row
loop was not reached, the data case asserts Begin did return. The start
case needs no store and stays with the data case anyway -- they are a
test and its negative control, and they share the probe plumbing.

Both phase counts now spell the assignment \s*=\s* rather than with
literal single spaces. copyPhase=StoreCopyPhase.Start; is valid C# and
evaded every literal form.
LiveCleanupConversionRatchetTests flagged the probe helper's finally. Its
remedy is LiveStoreCleanup, which is for teardown that deletes rows on
the body's own connection -- and this block deleted nothing. The COPY
always faults, so no attempt commits a row and there is nothing to clean.

Removed the construct instead of wrapping it. The helper takes its
connection and data source, both callers hold them under `await using`,
and disposal is the language's job. That closes the hazard the ratchet
exists for rather than routing around it: a DisposeAsync in a finally can
throw and REPLACE the body's exception, which on this helper is the
entire result being measured.
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling-only: DarlingCollectorRunner.cs, StoreWriteReattempt.cs, PostgresTransportFault.cs, CollectorFaultCopyPhase.cs, DarlingManagedPostgres.cs, CollectorContext.cs, plus the three test files).

Scope / parity — No Lite counterpart is needed here. The retry targets a network binary COPY into Postgres; Lite writes straight to an embedded DuckDB with no equivalent transport-fault window. The one shared-library touch, CollectorContext.StoreWriteReattempts (PerformanceMonitor.Collectors/CollectorContext.cs), is an additive int defaulting to 0, set only by the Darling host — it doesn't change any interface Lite implements, and I checked the reflection-based CollectorContext inventory tests (ServerScopePhaseSplitTests, the empty-enumeration inventories) — none of them select on int properties, so this doesn't trip a parity/derivation pin either.

Correctness — Traced the gate end to end:

  • StoreWriteReattempt.IsSafeToReattempt correctly ANDs "transport fault" (PostgresTransportFault.IsTransportFault, chain-walked with PostgresException tested first at every level) with "phase == Start" (CollectorFaultCopyPhase). Data and Unknown both decline, matching the stated invariant that a re-attempt must be provably lossless (no row sent, no delta baseline advanced).
  • RunAsync's OperationCanceledException is re-thrown ahead of the when filter rather than relying on the filter alone — correct, since a filter is one edit away from swallowing a shutdown.
  • The fan-out/enumeration loops in EnumeratedCollectorDriver.RunAsync and the per-database branch in DarlingCollectorRunner are sequential foreach, not Task.WhenAll/Parallel.ForEach — so context.StoreWriteReattempts++ has no concurrent-write hazard despite being an unsynchronized field on a shared CollectorContext.
  • The note composition (StoreWriteReattemptNote merged via EnumeratedCollectorDriver.MergeNotes) sits at the single point where all three dispatch paths (per-database fan-out, per-item enumeration, single-query) converge before return new CollectorRunResult(...) — confirmed there's no early return after a write that would skip it.
  • CopyOnAFreshConnectionAsync opens/disposes its own connection via await using, and the first-attempt fallback to a fresh connection when pgConnection.State != Open is decided on connection state rather than exception shape, so batches after a fan-out's first fault correctly don't double-log or double-count.

Nothing here looks unsound, and the test suite (StoreWriteReattemptTests, the phase pins, the new live-Postgres instance tests) already closes several non-obvious gaps (wrapped PostgresException, a bare copyPhase = Start assignment below the row loop, Begin hoisted outside the stamped try) that a lighter test pass would have missed.

Minor nit (non-blocking)DarlingManagedPostgres.cs still has using System.Net.Sockets; (line 17), but IsTransientConnectionFault now just delegates to PostgresTransportFault.IsTransportFault, and nothing else in the file references anything from that namespace anymore. Dead using, harmless (not TreatWarningsAsErrors), but worth dropping in a follow-up.

No security, injection, or performance concerns — this is exception-classification and connection-management logic, no user input reaches it, and the added overhead (one ConnectionState check, one extra try/catch) is negligible against a network COPY.

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