Repository navigation
Fixes #3111 - #3117
Fixes #3111#3117
Conversation
… fix the data-phase test's pooled search_path dependency
Review summaryReviewed the diff (Darling-only: Lite/Darling parity: No gap. Verified that Correctness:
Security/Performance: No SQL, network, file, process, or secret handling touched. No T-SQL in this PR (pure C# exception-classification/diagnostics), so the T-SQL style guide doesn't apply. Performance impact is negligible (one local enum variable and a No issues found — LGTM. |
IsProvenStoreWrite's soundness is a biconditional and only one direction was held. Same sweep, plus the using-static route the qualified-call detector is blind to.
|
Reviewed the diff (Darling-only: Correctness — checks out. The three ingestors now wrap their COPY in the identical The new Lite/Darling parity — no drift. Security/performance — no concerns. No new input handling, no secrets, no new SQL surface (COPY commands are unchanged, still driven by existing collector definitions). The try/catch wrapping adds no measurable overhead on the collection path. The new No issues found. |
Fixes #3111.
Both follow-ups #3110 left behind, plus the two prose notes that were the reason the issue exists rather than being a closed thought.
1. The three sibling COPY sites now carry the phase axis
RdsPlanIngestor,RdsCpuIngestorandRdsDeadlockIngestoreach perform the identical binary COPY into the store and each carried the identical unclosed residual:BeginBinaryImportAsyncawaitsCopyInResponseunder the connection'sCommandTimeoutwhileimporter.Timeoutbounds only the data phase, and both surface asException while reading from stream. All three now declarevar copyPhase = StoreCopyPhase.Start, transition toDatainside the importer block before the first row, and stamp-and-rethrow-bare on the way out — the same shape and the same reasoning asDarlingCollectorRunner.CopyBatchOnceAsync, which stays the authority the three defer to rather than three copies of its prose.The pin over them is a scan, not a list:
EveryStoreCopyInTheServiceStampsTheCopyPhasediscovers the population by finding every file in the service project whose stripped code callsBeginBinaryImportAsync, asserts that set equals the four known sites, and applies the five structural assertions to each with the file name in every failure message. A fifth COPY site reddens it whether or not it is instrumented, which is deliberate — see item 2 for what the set equality is load-bearing for.The same sweep asserts the converse, which is the half the per-file loop structurally cannot reach: the set of files containing
CollectorFaultCopyPhase.Stamp(equals the set containingBeginBinaryImportAsync(.IsProvenStoreWrite's soundness is a biconditional — every COPY stamps, and only a COPY stamps — and a loop over discovered files can say nothing about a file it never visited. AddStamp(to any target-read path and that fault carries a phase, so the predicate answers true, so the PostgreSQL-target arm excludes it, and a genuine target read is reported as a store write: the same confident-wrong directionIsProvenStoreWrite's remarks argue against, arrived at from the other side. Equality rather than a subset, in one statement, because a COPY site that stopped stamping is also a defect.That detector matches a qualified call, so the sweep also collects any
using staticon the axis — which would makeStamp(legal unqualified and blind the scan — and asserts that set empty. An unqualified call from insideCollectorFaultCopyPhase.csitself is stated as out of scope: that file holds the declaration the pattern is named for, so a text scan cannot tell a call there from the definition, and it is the axis's own home rather than a fault path.2. A store write no longer receives the PostgreSQL-target read remedy
The target-timeout arm's filter was
Target.Engine == PostgreSql && ex is NpgsqlException && Classify(ex) == CommandTimeout. The engine term was standing in for "this fault came from reading the target", and for a store write that proxy is simply false: the store connection is Npgsql whatever the target's engine is, so a COPY timeout satisfies every term and lands there. The arm's remedy is read-specific — "Npgsql cancelled the read mid-stream … the work asked for does not fit the deadline; raising the deadline is the wrong half of that and shrinking the work is the right one."The filter gains
&& !CollectorFaultCopyPhase.IsProvenStoreWrite(ex). A recorded phase is written only by a COPY into the store, so it identifies a store write by construction. Those faults fall through to the general arm, which since #3110 already renders the phase into both the app log and thecollection_logrow — chosen over authoring a second store-side sentence so that one fault reports one way, and because that arm is already where this same fault lands on a SQL Server target. A second sentence would make an identical store fault read differently depending on the monitored target's engine, which the store write has nothing to do with.StartPhaseLabelandDataPhaseLabelnow lead withstore-write. Every arm those labels reach also reports faults from reading the target, and a bare "COPY" names the store only to a reader who already knows this product performs no COPY against one.IsProvenStoreWriteis named for what it can establish: false means "not proven", never "not a store write". The post-COPY dimension flush and commit (#1767) are genuine store writes that readUnknown, and they keep #2997's sentence. That residual is stated in the predicate's own doc rather than papered over, and it is the affordable direction — a store write this predicate cannot prove gets a vaguer message, where the converse hands a confident target-read instruction to something that never touched the target.The database question, settled by observation
The issue asked for this to be measured rather than argued from the call site, and the report it could not confirm is confirmed. Driving the shipped
CopyBatchOnceAsyncagainst a live store withACCESS EXCLUSIVEheld on the COPY target produces a real start-phase timeout, and on it:NpgsqlExceptionwrappingTimeoutException, classifiesCommandTimeout, and did reach the target-read arm;CollectorFaultDatabase.For(fault, runtime.ConnectedDatabase)returns the monitored target's probe database. The store's own database name never appears, because the store-write path stamps no database andFortherefore falls back toServerRuntime.ConnectedDatabase—current_database()on the monitored server, from a different instance entirely.So the arm named an unrelated database on a different server and gave read-specific advice. The author's guard against naming the wrong database is real but cannot help here: it protects the per-database fan-out case, and there is nothing to fall back to except the target. The fix makes the question moot for the proven population —
PostgresTimeoutExplanationis no longer called for it — which is why no change was made to how the database resolves.Measured before and after on that same real fault:
3.
IsSafeToReattempt's transport leg is now covered on the real pathThe gate is
IsTransportFault(fault) && For(fault) == StoreCopyPhase.Start, and the live test that exercised the real path covered only the phase leg. Measured rather than inferred: an unopened connection makes the realBeginBinaryImportAsyncraiseInvalidOperationException("Connection is not open"), which walks the chain to no socket/stream/timeout link and is not anNpgsqlExceptioneither — so it is start-phase, is not a transport fault, and the full gate declines it. Every accepting case in the suite was a hand-stamped, hand-constructed exception, which is the shape where a predicate passes its tests while being wrong about its subject.ARealStartPhaseTimeoutIsATransportFaultAndTheGateAcceptsItcloses it: theACCESS EXCLUSIVEblocker makes the backend wait on the lock manager before it can answerCopyInResponse, and the resulting fault is carried end to end into an accepted re-attempt decision — the first test here to make that claim about a fault the runner itself raised. It asserts the premise (NpgsqlExceptionwrappingTimeoutException) as well as the conclusion, so a pass cannot come from the chain walk'sNpgsqlExceptionfallback while the fault is really something else. Nothing is written: the lock is taken in a transaction that rolls back and the COPY never completes a row.Which leg each test covers is now stated in the class summary and asserted in the tests, not left to prose — the existing start-phase test asserts its own decline.
4. The
finallyjustification says the actual reasonCopyFaultAsyncrecorded that a throwingDisposeAsyncin afinallycould replace the body's exception. It could not: the helper returns the fault, so by the time afinallywould run thecatchhas already converted it into the return value and there is nothing in flight to replace. The real reason is ordering — what a throwing dispose would replace is the returned exception object, before any caller had read an assertion off it. Leaving disposal to each caller'sawait usingputs it after every assertion, so a dispose that throws can only fail a test whose measurement is already complete.Incidental, and both found by measurement rather than by reading
AFaultInsideTheRowLoopCarriesData_OnTheRealPathfailed on a store being migrated for the first time.PgMigrationssetssearch_pathat database level, which a session picks up only when its physical connection is opened; Npgsql pools, so the COPY connection could be the very one the migrate ran on, carrying the path from before theALTER. The definition'sTargetTableis bare, so the COPY failed42P01 relation "wait_stats" does not exist— Begin never returned, and the test failed claiming the data phase was unreachable. It survives in a suite where an earlier live class has already migrated, which is the worst version of that. Reproduced on fresh 16.15 and 17.11 stores, fixed by taking the COPY connection from a data source built after the migration, and re-verified fresh on both. Fixed here rather than filed because it is the same method this PR edits and it would have read as this PR's flake.Two neighbouring pins re-anchored, deliberately.
PostgresFaultOutcomeTests.OnlyAnNpgsqlFaultCanEarnTheAuthoredTimeoutNarrativerequired&& ex is NpgsqlExceptionto be adjacent to theClassifyterm; the new conjunct sits between them, which reddened it without touching anything it asserts. It now forbids an intervening brace instead of counting newlines — both terms must be conjuncts of the same filter, in order, which is the only failure adjacency was ever detecting.RdsLogUnavailableTests.TheIngestorRethrows_RatherThanReturningZeroRowsOnFailuretook the first catch of that signature in the file, which is now ambiguous in all three ingestors; it anchors onIngestAsyncfirst. Both were verified still to fire when their subject is removed, and the re-anchoring was shown to discriminate: with a decoy arm aboveIngestAsyncthe old form goes red while the new one stays correct.Deliberately not included
The unbounded start phase itself. #3095 closed
completedwhen #3110 merged, having made the phase distinguishable rather than bounded — so the residual is again recorded only in a comment pointing at a closed issue, which is exactly the recursionTheCopyDeadlineCommentPointsAtTheOpenIssueForTheStartPhasewas written to stop. That pin still passes, because it hardcodes#3095rather than asking whether the issue it names is open. Left untouched: the comment and the pin are #3110's, and whether the fix is a new tracker or a pin that can judge openness is a design call, not a drive-by edit. The three new ingestor comments deliberately point atDarlingCollectorRunnerrather than at any issue number, so they add no new instances of it.A
ConnectionFatalstore-write fault still forces a TARGET reprobe. A lost store socket during a COPY is anNpgsqlExceptionwrappingIOException, whichClassifyanswersConnectionFatal, and the general arm's reprobe branch then nullsserver.Runtimeover a store problem. Pre-existing and unchanged by this PR — aConnectionFatalstore fault already reached that arm — and now fixable with the same axis, but it is a reconnect-behaviour defect rather than a message-attribution one. No reprobe behaviour changes here: the faults this PR moves classifyCommandTimeout, which that branch does not match.The 190 repo-wide
xUnit2013warnings, per the issue.Verification
Darling.Testsisnet10.0-windowsand cannot run on macOS, so the pure and source-text tests were compiled into anet10.0console harness against the real build and executed:StoreCopyPhaseTests11/11,StoreWriteReattemptTests+PostgresFaultOutcomeTests57/58 (the one failure is the harness not supplying[InlineData(null)], not a code failure),RdsLogUnavailableTests16/16.StoreCopyPhaseLivePostgresTests3/3 against live PostgreSQL 17.11 and 16.15, on fresh stores.Every new pin was mutation-tested and each fires on exactly the mutation it is for: dropping a stamping arm, moving the
Datatransition below the row loop, adding a second bareStart, removing the filter term, moving it belowClassify, reverting the labels, weakeningIsProvenStoreWriteto== Start, adding a fifth COPY site, stamping a phase from a non-COPY file (PostgresTransportFault.cs, which reports the offending file by name), adding ausing staticon the axis, and a COPY site that keeps its catch arm but stops stamping. The two re-anchored pins were each verified still to fire when their subject is removed. The live transport-leg test goes red with the blocker neutered, reporting that the row loop was reached — so it cannot pass without the condition it induces.CHANGELOG entry text (not committed — every lane appends to the same
[Unreleased]block)