Repository navigation
A pass whose fact families could not be read says so: analyze_server and get_analysis_facts carry collection_caveats on both SKUs, so a blind pass stops reading as an all-clear (partial #3691) - #3763
Merged
Conversation
…ures ride the context and surface as collection_caveats on both SKUs (#3691) Every family read in the three fact collectors degrades to "no facts" and logs (#2826); the pass learned nothing from it, so a pass whose families failed scored an empty fact list and both analyze_server tools rendered the `empty` all-clear. Each ReportCollectionFailure now records the failure on AnalysisContext.CollectionFailures beside its log line (family, read, outcome, message), each collector stamps its family total from its own type, both services carry the list and the two fact counts out of the pass, and CollectAndScoreFactsAsync returns the state as a third tuple element. analyze_server and get_analysis_facts emit `collection_caveats` and the caveat sentence ONLY when a family failed: a clean pass's payload is the same object through the same serializer call, so its bytes are unchanged; the `empty` envelope additionally states fact_count and facts_scored. Partial for #3691 — checklist line "Per-family collector failures are logged, not carried on the context".
This was referenced Sep 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
A pass whose fact families could not be read now says so, on both SKUs. Every family read in the three fact collectors (Lite's
DuckDbFactCollector, Darling's SQL ServerPgFactCollector, Darling's PostgreSQL-targetPgTargetFactCollector) degrades to "no facts" and logs (#2826) — and until now that was where the knowledge stopped. EachReportCollectionFailurerecords the failure onAnalysisContext.CollectionFailuresbeside its log line, each collector stamps the number of families it runs, both analysis services carry the list and two fact counts out of the pass, andanalyze_server/get_analysis_factsrender acollection_caveatsblock plus a caveat sentence — only when a family failed. A clean pass's payload is the same object through the same serializer call, so its bytes are what they were.PARTIAL for #3691. Checklist line completed: Per-family collector failures are logged, not carried on the context. Pre-existing on both engines (exit-check §3 residue); twin edit, Lite first then Darling.
The lie
Every family read ends in
catch (Exception ex) when (!IsExpectedAbandon(...)) { ReportCollectionFailure(ex, context); }. That is the right posture — one unavailable table must not cost a server its other thirty facts — and since #2826 the catch writes a WARNING (timeout) / DEBUG (42P01 / 42703) / ERROR (else) line. But the pass received the same empty list a quiet server produces. A pass in which the write family timed out, or every family failed against a half-migrated store, scored nothing from them and bothanalyze_servertools rendered theemptyall-clear: "No significant findings. All metrics are within normal ranges." An operator (or the agent reading for them) trusted a clean card that was blind.get_analysis_facts' zero-facts arm said "No facts collected. The collector may not have run yet" — the same words for "no data" and for "every read failed". Andemptycould not distinguish "facts were scored and none fired" from "no fact was emitted" without a count it did not carry.The change
Shared (
PerformanceMonitor.Analysis/AnalysisContext.cs).AnalysisContext.CollectionFailures(list ofCollectionFailure(Family, Read, Outcome, Message)),CollectionFamilyCount,RecordCollectionFailure(...).CollectionFailureOutcomeis the reporters' three arms pluscancelled:timeout(57014 / wrappedTimeoutException, classified structurally throughPgBaselineProvider.IsCommandTimeoutas the log arm already is),missing_schema(42P01 / 42703 — the deploy-window skew the collectors log at Debug; still a family that was not read),cancelled(anOperationCanceledExceptionthat was NOT the pass's own abandonment — the only kind thewhenfilter lets through),error.CollectionCaveatsbuilds the sentence and the block once so both SKUs say the same thing (theWindowCoveragepattern):Describe→ "N of M fact families could not be read (family (outcome), …) — the absence of findings is not evidence: …",ToPayload→{ families_failed, families_total, entries: [{family, read, outcome, message}] },Composejoins it with the coverage caveat (null when both are null), andAttach(payload, …)— the byte rule — returns the caller's own payload object UNTOUCHED when nothing failed, and otherwise serializes it to aJsonObjectand appendscollection_caveatsas the LAST property.families_failedcounts DISTINCT families (a family whose two reads both failed is one missing family);entriesis one per failed read.CollectionCaveatState(Failures, FamiliesTotal)is what the facts read hands back.Family label. Derived, never tabled. The SQL Server collectors report from the collect method itself, so the label is the method's:
CollectionFailure.FamilyOf("CollectPlanRegressionFactsAsync")→plan_regression,CollectTraceFlagFactsAsync→trace_flag. The PostgreSQL-target collector splits several families across helper reads (vacuum is three methods), so its reporter takes[CallerFilePath]and the label is the partial FILE:PgTargetFactCollector.Write.cs→write,.Vacuum.cs→vacuum; the method name is kept as the entry'sread.CallerFilePathis a compile-time constant at each call site, so a content lane's catch names its family by being in its file; either directory separator is honoured.families_total.CollectionCaveats.CountFamilies(typeof(TCollector))reflects the collector's non-public instanceCollect*Async(AnalysisContext, List<Fact>)methods — 16 on the PostgreSQL target (the census list), 32 on either SQL Server collector (thirty-one families plus the coverage witness) — computed once per process into astatic readonlyand stamped onto the context BEFORE the coverage witness runs, so a pass that fails at its first read states "1 of 32", not "1 of 0". Not hard-coded anywhere; an unstamped total reads "an unknown number of".Collectors. Each
ReportCollectionFailurecallscontext.RecordCollectionFailure(...)as its FIRST statement, ahead of the log arms, so no level can skip the record; the log arms and levels are unchanged. Darling's classifier is oneinternal static PgFactCollector.ClassifyOutcome(Exception)shared with the PostgreSQL-target collector so the two store collectors cannot classify the same exception two ways; Lite's isOperationCanceledException ? Cancelled : Error, per its own doc's argument that no timeout can arrive there.Services (twins).
AnalyzeAsynctakesLastCollectionFailures/LastCollectionFamilyCountoff the context on the lines afterLastWindowCoverage, logs ONE Warning summary when any family failed (a scheduled pass has no payload to carry the block), and afterScoreAllrecordsLastFactCount(facts the scorer saw — collector's plus detector's) andLastFactsScored(of those, how many graded above zero) — before attribution appends its cards.CollectAndScoreFactsAsyncreturns(Facts, Coverage, Caveats); every return, the catch included, hands backCollectionCaveatState.From(context), so a read that recorded failures before a throw still reports them. The pass body'sAnalysisWindowEmptyRuleParityTestsslices are untouched: the new lines sit between the coverage stamp and the unobserved branch, and afterScoreAll.Tools (twins).
analyze_server: theunavailable(dead-collector),emptyandfindingsenvelopes all go throughAttach; theemptymessage and theunavailablemessage gainCOLLECTION CAVEAT: <sentence>when owed; thefindingscaveatisCompose(coverageCaveat, collectionCaveat)— null on a clean full-coverage pass exactly as before. Theemptyenvelope additionally carriesfact_countandfacts_scored(the v1 exit check's ask) — the ONE deliberate byte change to a clean payload, onemptyonly, disclosed here.get_analysis_facts: the zero-facts miss, the unobserved miss and the data result go throughAttach; the miss messages gain the sentence; the datacaveatis composed. No tool body spellscollection_caveats =as a property — that would serialize a null on every clean pass (McpHelpers.JsonOptionswrites nulls). Both SKUs'analyze_serverandget_analysis_factsdescriptions name the field, its shape, the absent-on-clean rule, and (onanalyze_server) the two counts.Why this shape
Emit-only-when-non-empty via
Attachrather than a nullable property. The SQL Server exit checks pin a clean pass byte-for-byte and the serializer writes nulls; acollection_caveats = nullproperty is a byte change on every clean pass for a caveat that was not owed.Attachreturning the same reference makes the clean path the OLD path by construction — the tests pinAssert.Sameand prefix-identity of the serialized string.A third tuple element rather than a property on the read.
CollectAndScoreFactsAsync's coverage is returned, not parked, because that path has noIsAnalyzingguard and two on-demand callers can overlap; the failures ride the same way for the same reason. One record (CollectionCaveatState) rather than two elements.File-derived family on the PostgreSQL target. The alternative — wrapping each family call in the root's emission list — would rewrite the exact lines
PgTargetFactCollectorTestspins, and the plan says that census must not change.CallerFilePathcosts one parameter on the reporter and no line in any partial.missing_schemaas its own outcome. The STEP listed timeout / cancelled / error; the collectors already hold a distinct third arm for 42P01 / 42703 (Debug, "pre-migration skew"), and folding it intoerrorwould have the payload call a fault what the log calls expected. Naming it keeps the payload and the log in agreement.What it does NOT do
InferenceEngine.cs(lane 23's file),FactScorer.cs,FactAdvice.cs, anyPgTarget*partial, thePgTargetFactCollectorTestscensus, orDarlingWorker.cs.findingsenvelope,unavailableenvelope, or anyget_analysis_factspayload by a byte. Theemptyenvelope gains exactlyfact_countandfacts_scored.deprecated/Dashboard— itsAnalysisServicehas the same collect+score shape; listed for the coordinator (frozen twin, bug-fix support only). ItsDashboard.Testsbuilds clean against the additiveAnalysisContextchange.Tests
All new / changed tests compile-verified here; execution noted per item.
Darling/Darling.Tests/CollectionCaveatsTests.cs(NEW, 10 tests) —FamilyOf/FamilyOfFilederivations on both separators and the deterministic-build/_/prefix; everyPgTargetFactCollector.*.csfile labels a family the collect surface declares (the four name mismatches spelled out);CountFamilies= 16 / 32;ClassifyOutcomeon all four arms (57014,TimeoutException, wrapped Npgsql, 42P01, 42703,OperationCanceledException, bare Npgsql,InvalidOperationException); the record lands on the context; the clean-pass rule (Assert.Sameon the payload, no sentence, no block,Composenulls); the failing-pass shape — serialized string is prefix-identical to the clean one with the block appended last, DISTINCTfamilies_failed(vacuum twice = 1), entry fields, sentence text; an unstamped total reads "an unknown number of". Executed here: 10/10.Darling/Darling.Tests/CollectionCaveatsParityTests.cs(NEW, 3 tests) — source pins over both SKUs (theAnalysisWindowEmptyRuleParityTestspattern): every reporter records FIRST and once, every collector stamps its total before the witness, file-vs-method labelling per collector; both passes take failures/total on the lines after coverage, log the summary before the unobserved branch, read the two counts betweenScoreAlland attribution; both reads return the state on all three returns and no old-shape return remains; both tool filesAttachthree envelopes per tool, compose the caveat once per tool, never spellcollection_caveats =as a property, and both descriptions carry the field. Together with the existingFactCollectorFailureReportingTests(every swallowing catch → a reporter) this is the census: every catch in a collect method reaches the recorder. Executed here: 3/3; red-first on the reporter pin by commenting the record out (below).Darling/Darling.Tests/PgTargetCollectionCaveatsLiveTests.cs(NEW, gated live e2e) — a server stampedengine_kind='postgres', 25 h ofpg_database_stats, the REALDarlingMcpTools.AnalyzeServer: the clean pass carries NOcollection_caveats(absent, not null) and stamps 16; thenpg_autovacuum_statsis renamed away — the one table read by exactly one target family (the STEP'spg_write_statsexample is also read by the buffer family and the WAL baseline) — and the pass returnscollection_caveats={1, 16, [{vacuum, ReadAutovacuumBacklogAsync, missing_schema, …}]}as the last property, with "1 of 16 fact families could not be read (vacuum (missing_schema)) — the absence of findings is not evidence" in the message (orcaveat),fact_count/facts_scoredonempty;get_analysis_factson the same store carries the block and the caveat. The rename is undone INSIDE theLiveStoreCleanup.RunAsyncbody ahead of the row deletes (The other ~124 live-test finally blocks still clean up on the body's connection, so a failing test can still report its teardown's error #1902 shape). Executed here against a throwawaytimescale/timescaledb:2.28.1-pg18(Docker contextdesktop-linux, torn down): green; red-first: with the PostgreSQL-target record line commented out the test fails on the missing block and the store is still restored (table back, rows gone). Also executed green on the same container:AnalyzeWindowEmptyLivePostgresTests,PgTargetFactCollectorTests(incl. e2e),AsOfWindowAnchorLivePostgresTests,DarlingAnalysisServiceEngineRoutingTests,PgTargetMcpSurfaceTests,PgTargetSharedSwitchRoutingTests,AnalysisWindowEmptyRuleParityTests,AnalysisFactsReadRunsDetectorParityTests,FactCollectorFailureReportingTests,RecurrenceLabelStoreReadTests,McpPayloadContractCensusTests,FactCollectorCommandTimeoutTests,AnalysisPassCommandTimeoutTests,AnalysisPassTokenThreadingTests,StoreSqlClockDisciplineTests,DocCommentHygieneTests,LiveCleanupConversionRatchetTests,LivePostgresCollectionHygieneTests,RepoFileAdoptionTests,CommentFilterAdoptionTests,AsOfWindowAnchorTests, and Lite'sMcpMissMessageParityPinTests— 400/400 in a net10.0 harness run from inside the repo tree.Lite.Tests/CollectionCaveatsTests.cs(NEW, 2 tests) — a privateDuckDbInitializeron a temp path (theAnalysisDataSpanTestsshape, because the fault must not reach the shared fixture) withv_trace_flagsdropped: (1) the collector records exactlytrace_flag/CollectTraceFlagFactsAsync/errorwith total 32 while the wait family still reads its benign wait over an observed window; (2) through the realMcpAnalysisTools.AnalyzeServeron a 25 h store — the cleanemptyall-clear carries nocollection_caveats(absent) and both counts, then with the view dropped the sameemptycarries the block (1 of 32,trace_flag, last property) and "All metrics are within normal ranges. COLLECTION CAVEAT: 1 of 32 fact families could not be read (trace_flag (error)) — …" in the message;get_analysis_factson the same store carries the block and caveat. First executed in CI —AnalysisServicelives in the WPF project and cannot load on this Mac. Right by construction: the seeds areAnalysisWindowEmptyTests' verbatim (the 25 h gate + fifteen-minute benign wait the all-clear control proves observed),v_trace_flagsis read by exactly one Lite family and that family's catch reports, and the wait fact's key is its wait type.Lite.Tests/McpMissMessageParityPinTests.cs(CHANGED) — three shared fragments joinSharedMissFragments: theCOLLECTION CAVEAT:label and the two description sentences. Executed here: 61+3 green.var (facts, coverage, _)):Lite.Tests/AnalysisWindowEmptyTests.cs,Lite.Tests/AnomalyDetectorTests.cs(×2),Darling/Darling.Tests/AnalyzeWindowEmptyLivePostgresTests.cs;AnalysisFactsReadRunsDetectorParityTests.ReadEntrynames the new signature.Census pre-checks:
AnalysisWindowEmptyRuleParityTests(observed-zero body untouched; new lines sit before the unobserved branch and afterScoreAll),PgTargetFactCollectorTests(census list, root emission strings and[CallerMemberName] string collectMethodall still present),FactCollectorFailureReportingTests(54-site floor unchanged; the SQLSTATE arm regex still hits the reporter first),DocCommentHygieneTests(crefs short-form under ausing),RepoFileAdoptionTests(new pins use the non-LF reader with single-line anchors — no roster entry owed),LiveCleanupConversionRatchetTests(the rename undo lives insideLiveStoreCleanup.RunAsync),McpPayloadContractCensusTests(total_facts = facts.Countuntouched),AsOfWindowAnchorTests(no clock added),StoreSqlClockDisciplineTests(no SQL added).Which component(s) does this affect?
How was this tested?
Builds (zero new warnings):
PerformanceMonitor.Analysis,PerformanceMonitor.Darling.Analysis,PerformanceMonitor.Darling.Service,PerformanceMonitorLite(-p:EnableWindowsTargeting=true),Darling.Tests,Lite.Tests(one pre-existing xUnit2000 warning inLogTailOverlapThresholdPinTests, untouched),deprecated/Dashboard.Tests. Live PostgreSQL execution as above against PG18 / TimescaleDB 2.28.1 in a throwaway container. Lite behavioural tests are CI-first.Checklist
dotnet build -c Debug)