Skip to content

The server-scoped watermark read is discarded entirely for collectors that declare a per-database watermark #2797

Closed

Description

@erikdarlingdata

Follow-up from the review on #2796, filed rather than folded in because it needs verification across four collectors and three dispatch paths.

The claim

For query_store, the value GetLastCollectedTimeAsync returns is never consumed. QueryStoreCollector declares PerDatabaseWatermarkColumn, and every execution path overwrites context.Watermark before a query is built:

  • Azure SQL DB (RunsPerDatabase): context.Watermark = await GetLastCollectedTimeForDatabaseAsync(...) in the per-database connection loop (DarlingCollectorRunner.cs:581)
  • on-prem / RDS / MI enumeration: context.Watermark = clamped inside the perItemWatermark delegate (:923)

The local is otherwise read only to compute hasCollectedBefore.

What I verified

HasCollectedBefore is consulted by exactly two collectors — DefaultTraceEventsCollector (:319) and JobHistoryCollector (:220) — and neither declares PerDatabaseWatermarkColumn. So for the four collectors that declare both watermark columns, hasCollectedBefore is computed from this read and then never consulted.

The four: QueryStoreCollector, DeadlocksCollector, BlockedProcessReportCollector, LongQueryCompletionsCollector.

What I did NOT verify, and why this is filed rather than fixed

The second half of the claim — that every dispatch path overwrites context.Watermark for all four of those collectors — is the load-bearing part, and it spans three paths (per-database Azure loop, enumeration, plain). If any path for deadlocks, blocked_process_report or long_query_completions does not overwrite, skipping the read silently destroys that collector's watermark and it re-collects its whole window forever — the same failure #2795 just fixed, arrived at from the other direction.

That is not a check to do by reading; it wants the live rig.

The prize

After #2796 the read costs ~2.9-5.9 s per cycle per server (down from 40-50 s and a cancellation). Skipping it where its answer is discarded takes that to zero, and makes the new 60 s CommandTimeout unreachable for a call nothing uses.

Gate would be definition.PerDatabaseWatermarkColumn is not null — the same signal the per-item paths already key on.

Suggested shape

  1. Assert, per collector and per dispatch path on the live rig, that context.Watermark is written before BuildQuery.
  2. Only then skip the server-scoped read when PerDatabaseWatermarkColumn is non-null.
  3. Pin it the way Bound the server-scoped watermark read on the partitioning column #2796 did — over the family, derived from the definitions rather than enumerating the four names, so a fifth collector declaring both columns is covered automatically.

Activity

  1. erikdarlingdata commented on Sep 4, 2026

    @erikdarlingdata
    OwnerAuthor

    The proposed gate would ship a data-correctness bug. Not implemented.

    The issue's own warning was right, and the load-bearing half is false: the suggested gate
    (definition.PerDatabaseWatermarkColumn is not null) skips a read that three of the four named
    collectors actually consume, on every non-Azure target — i.e. the entire production fleet.

    Derived from the built assembly, not grep

    Reflecting over CollectorCatalog.All and invoking the real RunsPerDatabase / BuildEnumerationQuery
    against a synthetic on-prem target:

    collector WatermarkColumn PerDbWatermark RunsPerDatabase(on-prem) BuildEnumerationQuery(on-prem) dispatch path
    query_store last_execution_time database_name False non-null 2 enumerated
    deadlocks deadlock_time database_name False null 3 PLAIN (server-scoped)
    blocked_process_report event_time database_name False null 3 PLAIN (server-scoped)
    long_query_completions event_time database_name False null 3 PLAIN (server-scoped)

    QueryStoreCollector is the only one of the four that overrides BuildEnumerationQuery; the other three
    inherit CollectorDefinitionBase's => null. All four declare RunsPerDatabase => target.IsAzureSqlDb, so
    off Azure they fall through to the plain path at DarlingCollectorRunner.cs:1424, where
    definition.BuildQuery(context) reads the server-scoped context.Watermark that was never overwritten.

    Skipping the read there sets it null on every cycle — the collector re-collects its whole first-run window
    forever. That is #2795's failure arrived at from the other direction, exactly as this issue predicted.

    Live confirmation on 42 production servers (90-minute window)

    The V108 phase columns are populated only on the server-scoped path, so they identify dispatch directly:

    collector runs sql_open_ms populated
    blocked_process_report 2,122 2,122 (100%)
    deadlocks 632 632 (100%)
    query_store 633 0 (0%)

    Reading and measurement agree.

    The correct gate

    The read is discarded iff the run will take path 1 or 2 — which is a dispatch-path predicate, not a
    watermark-column one:

    WatermarkColumn is not null
      && PerDatabaseWatermarkColumn is not null
      && (RunsPerDatabase(target) || BuildEnumerationQuery(context) is not null)
    

    On this fleet that reduces to query_store alone. Note the awkward part: BuildEnumerationQuery needs a
    CollectorContext, which today is constructed after the watermark read, so implementing this needs either
    a definition-level "enumerates" signal on ICollectorSchemaInfo or a reordering — a design decision, not a
    one-line gate. That is why this is filed rather than fixed.

    The prize is real but ~70x smaller than stated

    The issue quotes ~2.9-5.9 s per cycle per server. Measured from wm: in the phase line over ~2.6 h on the
    large store:

    collector n p50 p95 max total
    query_store 822 41 ms 817 ms 22,129 ms 198,484 ms
    deadlocks 1,075 9 ms 90 ms 2,718 ms 30,453 ms
    blocked_process_report 3,327 5 ms 15 ms 883 ms 24,566 ms

    So #2796's bound already took the common case to 41 ms. The remaining prize for query_store is
    ~198 s per 2.6 h (~76 s/hour, ~30 min/day of store time) that is genuinely discarded — worth having, driven
    by the tail rather than the median, and the 22.1 s max shows the tail is still live.

    The deadlocks / blocked_process_report figures are legitimate work, not waste.

    Instrumentation gap found on the way

    watermark_ms (V108) is NULL on all 636 query_store runs, because it is persisted only on the
    server-scoped branch. The one collector whose watermark read is worth optimising is the one whose read cost
    the store cannot report — it is only visible in the app-log wm: field. Same shape as #2860. Worth folding
    into that decision.

  2. erikdarlingdata commented on Sep 4, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed by #2890, merged to dev.

    The gate this issue proposed was not the one built, and that is deliberate. PerDatabaseWatermarkColumn is not null would have skipped the read for all four collectors that declare both watermark columns — but only query_store overrides BuildEnumerationQuery; the other three inherit => null. Off Azure, i.e. the entire on-prem and RDS fleet, those three fall through to the plain server-scoped path and genuinely consume the value, so the proposed gate would have handed them a null watermark every cycle and made them re-collect their whole fallback window forever — #2795's defect from the other side. The shipped predicate is therefore a dispatch-path one:

    WatermarkColumn is not null
      && PerDatabaseWatermarkColumn is not null
      && (RunsPerDatabase(target) || BuildEnumerationQuery(context) is not null)
    

    On the current catalog that skips 7 of 168 (definition, target) pairs: query_store on every target, plus the three XE collectors on Azure SQL DB only, where they really do run per database. Verified by invoking the shipped predicate over CollectorCatalog.All in the built assembly, not by reading source.

    The enumeration half calls the definition's own BuildEnumerationQuery rather than mirroring it into a second declared flag, so the two cannot drift apart. That needs a probe context, because HasCollectedBefore is computed from the gated read and it, NumericWatermark and State are init-only — so the alternative was widening a deliberately-immutable contract shared by both SKUs to buy an optimisation. The probe is sound only while enumeration null-ness depends on Target alone, and a pin asserts that across 5,376 (definition, target, context) combinations rather than assuming it.

    Pinned over the family derived from CollectorCatalog.All rather than by naming collectors, so a fifth collector declaring both columns and enumerating is covered the day it appears. The load-bearing assertion is the negative one — that the three server-scoped members still receive a non-null watermark off Azure — and it was proven red against this issue's own proposed gate before being left green.

    One correction to the record, because it is easy to misattribute. This issue estimated ~2.9–5.9 s per cycle per server; a later comment revised that down to ~76 s/hour on one collector. Both readings of the fleet data were wrong, in opposite directions, and the ~76 s/hour figure was measuring the wrong read. ServerWatermarkMs reaches the phase log only under ServerPhasesMeasured, which the enumerated path leaves false (ServerScopePhaseSplitTests pins exactly that), and V108's watermark_ms is persisted on the same branch — so for query_store this read's cost is recorded in neither the log nor the store. The wm: field that is populated for query_store belongs to the per-item line and measures the per-database read (p50 41 ms over 822 samples) — different read, legitimate incremental work, untouched here.

    So the only direct measurement of the read actually removed is #2796's, taken as it was being bounded: 2.9–5.9 s warm-to-cold against a 62.5 GB table (40.7 s and 50.6 s unbounded). Directionally that must exceed the per-database read, since it is a MAX over the whole read-floor window across all databases rather than one. The true value is therefore plausibly larger than this issue first claimed, not smaller — but it cannot be confirmed from the fleet until the instrumentation gap is closed, which is #2860's decision. That gap is stated in the changelog entry rather than papered over with either number.

    The deadlocks and blocked_process_report server-scoped reads (p50 9 ms and 5 ms) are legitimate and deliberately stay.

  3. added a commit that references this issue on Sep 4, 2026
    75c386d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions