Skip to content

The Azure per-database split is stamped on the fault path but only printed on the success path #2896

Closed

Description

@erikdarlingdata

Split out of #2855 rather than forced into it, because fixing it means picking between two shapes and neither belongs in the change that added the split.

#2855 gave the Azure per-database branch a connect: + open: + drain: + other: split, with every stamp and the PerDatabasePhasesMeasured flag set from a finally. So a database whose connect times out, or whose read is cut by the per-database wall-clock budget, does stamp its phases and does declare itself measured — the #2854 discipline holds.

It just never prints them. The log site sits on the success path of the per-database try, after the flush:

if (context.PerDatabasePhasesFrom(dbSqlMs) is { } dbPhases)
{
    _logger?.LogInformation(
        "  [{Server}] {Collector} [{Database}] sql:{SqlMs}ms = connect:{ConnectMs}ms + open:{OpenMs}ms + drain:{DrainMs}ms + other:{OtherMs}ms ({Rows} rows, pg:{PgMs}ms)",
        ...);
}

A fault reaches one of the three catch arms instead — the budget arm's WARNING, or the generic arm's Skipping database '{Database}' for {Collector}: {Error} at Debug — and neither carries a phase. So the case the split exists to attribute, "this database is bound by connect", is the one case that still logs nothing but an error string.

Why it was not just added to the catch arms

The line decomposes dbSqlMs, and that parent is out of scope in the catch: sqlSlice is declared inside the try, below the watermark read and BuildQuery. Hoisting it above them would silently widen dbSqlMs, and dbSqlMs is not a log-only number — it feeds sqlMs, fanout.Observe(...), and collection_log.sql_duration_ms. Widening a persisted metric to make a log line reachable is the wrong trade.

The two options, and the thing that should decide

  1. A second stopwatch, started beside connectWatch and hoisted above the try, measuring the same interval sqlSlice does. The catch arms then print the same line shape with a real parent. Cost: two stopwatches over one interval, and a reader has to be told why.
  2. A second, parentless line shape from the catch arms — connect:Nms + open:Nms + drain:Nms, no sql:N = prefix, no other:. Honest (the parent genuinely did not complete) and needs nothing hoisted. Cost: a second shape for the log parsers outside this repo to learn, which is the objection query_store: plan_fetch and text_fetch are whole methods spanning two databases, timed as one number #2811/Phase split (wm/open/drain) is not emitted for server-scoped collectors, so the largest collector on the fleet cannot be attributed #2851 kept deferring to.

What should decide it is how often the fault path is actually taken. If per-database failures on this branch are rare, option 2's parser cost buys little and option 1's second stopwatch is over-engineering for a line nobody reads. If they are routine — an offline database, a mid-restore one, a genuinely slow login on a large Azure database — then this is the common case rather than the edge, and option 1 is worth it. That is answerable from collection_log on a store that monitors Azure targets, by counting the notes the per-database partial-failure path composes (N of M database(s) failed) against total runs for the collectors that take this branch. Worth measuring before choosing.

Note also that the generic arm is Debug, so on a default log level a per-database failure is invisible whatever is added to it. Whether that arm should stay Debug is part of the same question — one database failing is genuinely routine, which is why it is Debug, but "routine and therefore silent" is how a database that has been failing every cycle for a week goes unnoticed.

Not this issue

Persisting the split is separate and already open as #2860 (N:1 against collection_log). This one is only about whether the fault path prints what it already measured.

Activity

  1. added 2 commits that reference this issue on Sep 4, 2026
  2. erikdarlingdata commented on Sep 4, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed by #2922, merged to dev.

    This issue's title understates its reach, and that changes the priority. The branch is not Azure-only: RunsPerDatabase is engine-unconditional (=> true;) for seven PostgreSQL collectors — pg_index_bloat, pg_index_usage_stats, pg_table_bloat_stats, pg_autovacuum_stats, pg_column_stats, pg_predicate_stats, pg_extension_availability. They reach this identical loop, with the same three catch arms and the same stamps, on every PostgreSQL target, every cycle. One connection per database per cycle is exactly what connect: exists to name.

    The Azure arm itself has run zero times: all 84 SQL Server targets are RDS, and RDS does not host Azure SQL DB — confirmed on probed members as engine_edition = 3 with service_objective NULL, which is the shipped collector's own witness since ServerPropertiesCollector populates that column only WHEN EngineEdition = 5. So the fault-path frequency is zero on the SQL Server fleet by construction rather than by sampling, and unmeasured on the PostgreSQL fleet.

    The obvious fix was unsound, not merely expensive

    A second stopwatch started above the try measures a wider interval than sqlSlice — and PerDatabaseOtherMsFrom's own contract states the watermark read and BuildQuery sit outside the parent. So its other: would mean something different from the success line's, under the same name. That is worse than the cost objection it was raised to avoid.

    What shipped is a third option: only the declaration moved above the try; the start stays on the same statement. Same interval, one stopwatch, and dbSqlMs is byte-identical — verified as exactly one sqlMs += dbSqlMs and one fanout.Observe(databaseName, dbSqlMs + dbStorageMs), both on the success path past the flush, each pinned at 1 with a positive control. Nothing persisted was widened to satisfy a log requirement.

    A latent hazard nobody asked about

    #2855's per-iteration clear sat below the stopwatch. A fault in the watermark read, the adaptive-shrink store write, or BuildQuery reaches the catch arms without passing it — holding the previous database's flag and stamps. Reading the split there would have attributed one database's connect: to another. Moved to the top of the try, which also makes the flag a sufficient gate rather than a necessary one.

    The review found a real defect that was missed

    sqlSlice was never stopped. It kept accruing past the dbSqlMs capture through WriteBatchAsync — which runs on cancellationToken and therefore can throw into these arms. A flush-time fault would print a sql: larger than the dbSqlMs already folded into sqlMs and fanout.Observe, with the entire difference landing in other:, because connect/open/drain are fixed by their finally blocks. That is storage latency reported as SQL-side residual — precisely the mis-attribution this instrumentation exists to prevent. Fixed with sqlSlice.Stop() before the read, and pinned three further ways.

    The Debug level stays, and the reasoning is the useful part

    Raising it would add no signal that is not already persisted. BuildPartialFailureNote is what makes a week-long failure visible — it lands "N of M database(s) failed" plus three names and the first error into collection_log.error_message, surfaced by get_collection_health as last_note / note_count. A total loss is already Warning plus rethrow, reaching Capture Down. Raising this arm would emit one Warning per database per cycle per collector, which is the flood #1875's one-capped-burst rule exists to stop. The split rides its arm instead: Warning beside the budget arm, Debug beside the skip.

    Verification

    Twelve mutations red, each failing a different assertion, each with its anchor count asserted at 1 before the result was trusted — and a thirteenth attempt is deliberately not counted because it failed to compile: a mutation that does not build proves nothing.

    3,938 string literals across Darling.Tests and Lite.Tests that matched the pre-change runner were checked against the post-change tree: zero disarmed, with a positive control proving the comparison detects a removal. CollectorContext.cs is untouched, so ServerScopePhaseSplitTests' derived set is byte-identical and neither MinimumPhaseStamps = 16 nor MinimumMeasuredFlags = 2 needed raising. Test count in Windows CI moved by exactly 13, matching the thirteen facts.

    Not measured

    The PostgreSQL fault-path rate. The Aurora store was unreachable from the working session, so how often these arms are actually taken in production is open — and given the branch runs every cycle there, that is the number that would size this. No live target has exercised the emission: the Azure arm has never run, and the PostgreSQL arm's line shape in production is unverified.

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