diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6add11915..1006af012 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -115,6 +115,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **The server-scoped watermark read no longer runs for a collector whose own cycle throws the answer away** ([#2797]) - `RunAsync` dispatches down exactly one of three paths, and two of them assign `context.Watermark` from a PER-DATABASE read before any query is built: the Azure per-database connection loop, and the enumerated per-item loop. A collector taking either path still executed the server-scoped `GetLastCollectedTimeAsync` on every cycle and read its answer with nothing. It is now skipped on exactly those two paths. On the current catalog that is `query_store` on every target, plus `deadlocks` / `blocked_process_report` / `long_query_completions` on Azure SQL DB only - **7 of 168 (definition, target) pairs**, verified by invoking the shipped predicate over `CollectorCatalog.All` in the built assembly rather than by reading the source. **The gate the issue itself proposed would have shipped a data-correctness bug, and that is the whole reason this is a DISPATCH-PATH predicate rather than a watermark-column one.** [#2797] suggested `PerDatabaseWatermarkColumn is not null`, the signal the per-item paths already key on. Four collectors declare both watermark columns and all four declare `RunsPerDatabase => target.IsAzureSqlDb`, but only `query_store` overrides `BuildEnumerationQuery`; the other three inherit `CollectorDefinitionBase`'s `=> null`. So OFF Azure - the entire on-prem and RDS fleet - three of the four fall through to the plain server-scoped path and genuinely CONSUME the value, and gating on the column alone would have handed them a null watermark every cycle and made them re-collect their whole fallback window forever: [#2795]'s defect, arrived at from the other side. The fleet said the same thing before the change, because V108's phase columns are written only on the server-scoped path and so identify dispatch directly - over 90 minutes on 42 servers `blocked_process_report` populated `sql_open_ms` on 2,122 of 2,122 runs and `deadlocks` on 632 of 632, while `query_store` populated it on **0 of 633**. **What this is worth, with its provenance, because the figure is easy to misattribute.** The read removed is `MAX(last_execution_time) ... WHERE server_id = $1 AND collection_time > $2` over `collect.query_store_stats`, the store's largest table, once per `query_store` cycle per server. The only direct measurement of that read 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 before the bound). It cannot be confirmed from the fleet today, and that is a stated gap rather than an omission - `ServerWatermarkMs` reaches the phase log only under `ServerPhasesMeasured`, which the enumerated path leaves false, and V108's `watermark_ms` column is persisted on that same branch, so for `query_store` specifically this read's cost is recorded in neither the log nor the store ([#2860]). The `wm:` field that IS populated for `query_store` in the phase log belongs to the per-item line and measures the PER-DATABASE read (p50 41 ms over 822 samples): a different read, legitimate incremental work, and untouched here - so reading it as the server-scoped cost understates this change by whatever the ratio is between a whole-server `MAX` over the three-hour read-floor window and one database's. The `deadlocks` and `blocked_process_report` server-scoped reads visible in that same log (p50 9 ms and 5 ms) are also legitimate and deliberately stay. **Pinned over the FAMILY derived from `CollectorCatalog.All`**, not by naming collectors, the way [#2796]'s pins are derived from the runner's method family: a fifth collector that declares both columns and enumerates is covered the day it appears, and one that stops enumerating is un-covered automatically. The load-bearing assertion is the NEGATIVE one - that the three server-scoped members still get a non-null watermark off Azure - and it was verified red against the issue's own proposed gate before being left green. The enumeration half of the predicate is derived by CALLING the definition's `BuildEnumerationQuery` rather than mirrored into a second declared flag, so the two cannot drift apart; the probe context that makes that answerable before the cycle's own context exists (`HasCollectedBefore` is computed FROM the gated read, and it, `NumericWatermark` and `State` are init-only) is sound only while null-ness depends on `Target` alone, which a further pin asserts across **5,376** (definition, target, context) combinations instead of assuming. Skipping the read also had to gate `HasCollectedBefore`, whose branch keys off `watermark is null` and would otherwise have fired its own store query on precisely the cycles the change exists to make cheaper.
- **The IL-scan idiom behind five reachability pins is now a real instruction decoder, and can see generic callees** ([#2898]) - `ServerScopePhaseSplitTests`, `FetchProbeStampReachabilityTests`, `ProbeDenominatorTests`, `FetchStoreConnectionBorrowTests` and `ServerWatermarkDispatchGateTests` each carried their own copy of a walk that tested every byte offset for `call` (0x28) / `callvirt` (0x6F) and read the next four bytes as a metadata token. Two soundness problems, neither of which was producing a wrong answer yet - which is the reason to fix them before one does. First, two of the five advanced the cursor four bytes on a match; a byte inside another instruction's operand can look like a call, so a match is not necessarily an instruction boundary, and skipping from a non-boundary can land past a real call's own token. That is a **false negative**, the dangerous direction for these pins, because a stamp that stopped being called from its handler would read as still called - the #2816 defect they exist to prevent. On the built service assembly: **13,007 method bodies, 70,775 genuine call sites, 415 byte offsets that look like a call and are not, and 587 genuine call sites the skipping arithmetic can never visit.** It did not bite: with the tracked set widened to *every* MemberRef/MethodDef/MethodSpec token in the assembly the skipping form loses zero call sites, because no phantom match happens to carry a live token within four bytes of a real one - a property of one build's token VALUES, not of the scan. Second, all five built their token map from `MemberReferences` and `MethodDefinitions` only, so a call to a **generic** method - emitted against a `MethodSpec` token (table 0x2B) pointing at the underlying member - reported zero call sites. **4,356 of the assembly's genuine call sites carry a MethodSpec token and 51 distinct callee names are invisible without resolving it** - mostly BCL generics, but three of them ours: `WithGeminiCompatibleTools` (50 sites), `CollectAsync` (7) and `WriteBatchAsync` (4), the last defined in `DarlingCollectorRunner.cs`, the same class these pins scan. That gap had already cost [#2890]'s sibling pin a red on `ServerWatermarkIsDiscarded`, generic in `TRow` and called every cycle, and it is worse than a loud failure in `FetchStoreConnectionBorrowTests`, whose assertion is that a count is ZERO: a generic acquisition would have satisfied it vacuously. All five now share one `IlCallSiteScanner` that decodes instructions with an operand-length table **derived by reflection over `System.Reflection.Emit.OpCodes`** rather than hand-transcribed, resolves `MethodSpec` back to the underlying member so callers still ask by plain name, and **throws** on an unknown opcode or a body that does not decode to exactly its own length instead of quietly reporting offsets that cannot be trusted. The scanner carries its own witnesses, and the two defects needed different shapes of proof: the MethodSpec gap is reproducible against the shipped assembly (deleting the loop takes `WriteBatchAsync` to zero and fails the pin), while the skip is not, so it is pinned on a hand-built body where the coincidence is arranged rather than hoped for - substituting the skipping form for the decoder fails that test and only that test, leaving all twelve pre-existing assertions green, which is precisely the measurement above restated as a test. A third witness decodes every body in the service assembly and requires each to end exactly on its own length; deliberately mis-declaring one operand length turns it and the pins that depend on it red.
+- **Every command in the Darling viewer now carries an explicit deadline** ([#2874]) - all **193** of the project's command sites set no `CommandTimeout` and inherited Npgsql's undocumented 30 s default. That count is a correction: [#2874]'s census says 192, and the 193rd is a shape neither of its regexes can match - `ViewerDataService.Blocking.cs` handed `connection.CreateCommand` over as a bare METHOD GROUP to `PgBlockingPairRowQuery.AppendDmvSnapshotRowsAsync`, which builds the command and sets its `CommandText` in a different project entirely, so the site read as clean while inheriting the default; it now takes a factory lambda that stamps the deadline, and the pin has a second scan for that shape. **Three budget regimes, three constants - and the interesting result is how few.** Interactive and background-refresh reads are NOT separable here: the fleet timer and the per-tab auto-refresh timer call the same `ViewerDataService` methods a user gesture calls (`RefreshActiveInnerTabAsync` is literally the tab-activation method), and the set of reads reached ONLY by the unattended fleet fan-out is **empty** - all seven are also reached by a gesture or a visible-tab load - so splitting them needs a budget threaded through every call site, not a constant. An export regime was looked for and does not exist: `PerformanceMonitor.Ui.DataGridExport` is synchronous and store-unaware and iterates `grid.Items`, so all ten CSV/copy surfaces format rows a panel load already paid for. The 188 interactive/refresh sites take a new **15 s** `ViewerCommandDeadlines.InteractiveReadSeconds`, bounded below by measurement and above by a PERMIT rather than a budget. Below: a store stood up by the product's own `PgMigrations.MigrateAsync` at V109 with TimescaleDB and seeded to exact production per-server density (from `get_collector_cost` over 2 days x 42 servers - 189,414 `query_stats` rows/server/day) across the collector's full 30-day retention horizon, 5.68 M `query_stats` and 1.98 M `procedure_stats` rows for one server; the heaviest shipped per-server read, `TopQueriesSql` (dumped from the built assembly, not retyped), measured **cold** at 588 ms on the default 1-hour preset, 1.12 s on the widest 7-day preset and **3.01 s** on a 30-day custom range - the widest window any shipped read can ask for, since retention drops the data behind it. Above: **nothing encloses these reads at all** - no `CancelAfter`, no `SemaphoreSlim`, no `WaitAsync`, and no request timeout, because the viewer is a WPF `WinExe` that hosts no web endpoint - the same finding [#2882] and [#2888] both made, and the same reason this errs short. What they compete for is the ten-connection pool (`MaxPoolSize = 10`, set on the managed-derived string for [#1566]), and `CorrelatedTimelineLanesControl` awaits one `Task.WhenAll` over exactly TEN reads, so a single panel can hold every permit while the sidebar freshness dots, the alert poll and every other panel get nothing - and read eleven waits `ConnectionTimeoutSeconds` (default 5) for a slot and then throws a CONNECT error, misattributing a slow store to the network. Ten concurrent 30-day reads on that rig measured **24.4-64.1 s**, six of them past the silent default they used to inherit, so halving the ceiling returns permits sooner in exactly the state that produces the misdiagnosis. **This is not `StorageCommandDeadlines.McpReadSeconds` and 30 was re-derived, not copied**: the viewer's worst read is 4.4x slower than the MCP family's 685 ms worst, yet its permit is ten times scarcer and it has a fan-out that can take all ten, so a slower read against a scarcer permit lands BELOW 30 s rather than at it. The three command-plane sites take **5 s** `CommandPlaneSeconds`, pinned RELATIONALLY against the 45 s `DefaultCommandTimeout` poll loop they run inside rather than against a copied number - the poll is a single-row primary-key lookup at 3.9 ms cold, and 5 s keeps it an order of magnitude under the smaller of the two enclosing budgets. That regime also **narrows** a real overshoot rather than closing it: `PollCommandResultAsync` checks its budget only BETWEEN iterations, so with no deadline on the read a hung poll could exceed the 45 s a dialog promises by up to Npgsql's 30 s; it is now bounded at 5 s, but the loop still does not cancel an in-flight read, so the stated budget can still be exceeded by that much. The two connect-gate probes take **10 s** `ConnectGateSeconds` - `information_schema` and `has_table_privilege` only, so they do not grow with the store (all 85 schema sentinels measured 79 ms cold) - bounded above by the 60 s ceiling the viewer's own connection-timeout preference is clamped to, because these are the first two statements after connect, they run in `OnLoaded` before any timer starts and before the window is usable, and there is no splash to explain a wait. Their asymmetry is why they are separate: both CATCH their own failures - the schema probe fails OPEN and the read-only probe fails SAFE - so a blown deadline there raises nothing and instead silently MIS-CLASSIFIES the store, hiding every write affordance on a writable one. **What this does not do**: no user-reported viewer failure is closed by it - unlike [#2871] and [#2882] there is no observed production timeout on this surface, so the numbers come from measurement and from the permit arithmetic rather than from a failure distribution, and the change removes an inherited default nobody chose rather than fixing a known break. The timings are from the locally seeded V109 store, not from the production store itself, whose per-server density they are matched to. Pinned directory-scoped over both construction shapes plus the method-group scan, and RECURSIVELY where the [#2888] pin it is modelled on used `TopDirectoryOnly` (with `bin`/`obj` excluded by path segment, 190 source files from 234 enumerated), so a future viewer file is covered the day it appears even under a subdirectory - proven load-bearing by dropping an untimed command into the existing `Themes/` folder, which `TopDirectoryOnly` did not see; values are pinned as BANDS carrying their derivation rather than as equalities, its span walker terminates at `depth <= 0` (the depth-clamping bug that made two scanners miss real sites during [#2888]), its method-group scan strips comments and strings so the prose explaining the fix cannot fail the build, and it was proven red six ways, each failing a DIFFERENT assertion: removing one site's deadline, reverting the method-group fix, dropping an untimed command into a subdirectory, raising the interactive value to 30, dropping it to 3, and pushing the command-plane value past half its enclosing budget.
- **Every command in the Darling storage layer now carries an explicit deadline** ([#2874]) - seventy of the project's 124 command sites ran on Npgsql's undocumented 30 s inherited default, across five distinct budget regimes, and each regime now binds itself with its own deliberate constant rather than one blanket value: the MCP read surface (the `DarlingPg*Reader` family, trend reads and Query Store trend routing) at a new 30 s `StorageCommandDeadlines.McpReadSeconds` - bounded above every verified production read (97-685 ms across both stores, with a deliberately harder unfiltered superset at 35.2 s) and below the point where an unbudgeted interactive read holding a pooled connection should fail rather than hang; the migration session's six scaffolding statements (search_path, version table, version read, stamp, database search_path) at the existing 300 s `MigrationCommandTimeoutSeconds`, and its `pg_advisory_lock` acquire - a LOCK WAIT rather than a statement - at a new `MigrationLockWaitTimeoutSeconds` derived separately below; TimescaleDB setup/rearm at `SetupTimeoutSeconds` and its five catalog and rollup-coverage reads at `JobCatalogReadTimeoutSeconds` (the coverage probe's unbounded `min(collection_time)` measured 685 ms cold on the largest store - chunk exclusion answers it, unlike the #2795 shape); the startup self-test at its own per-layer 8 s budget; plan-dim maintenance at a hoisted 300 s (VACUUM FULL keeps its deliberate `CommandTimeout = 0`); and the slice repair's rail-lift at its session's 900 s. **The lock wait needed its own number rather than the statement bound's.** The advisory lock is taken ONCE and held while every pending rung applies in the same session, so a sibling waiting there waits for a whole multi-rung upgrade, not for one statement - at 300 s a several-rung upgrade could outlast it while every individual statement stayed inside its own limit, which is why this entry no longer claims to CLOSE that startup failure. `MigrationLockWaitTimeoutSeconds` is `5 x MigrationCommandTimeoutSeconds` (1500 s), floored on what the lock can legitimately be held for: exactly FOUR of the ladder's 108 rungs touch pre-existing data - V22's index across every chunk of the populated `index_object_stats`, V23's `migrate_data => true` conversion of `collection_log`, V39's two partial indexes over `query_stats` and `procedure_stats`, and V104's index on `pg_deadlocks` - while every other rung either creates the table it then indexes or is a metadata-only `ADD COLUMN`/`SET SCHEMA`/`DROP NOT NULL`/view refresh, and the whole 108-rung ladder measured **0.301 s end to end** on a fresh PostgreSQL 17.11 / TimescaleDB 2.29.2 store. Each of the four is one command already capped at the statement bound, so four multiples is the floor and the fifth is margin for the next data-moving rung (seeded on that store: V22 1.29 s over 907 MB/90 chunks, V23 9.37 s over a 608 MB heap, V39 1.44 s over 1.26 GB - against 2.72 GB, 0.69 GB and 24.5 GB in those same tables on the live 42-server store, so the real figures are larger and colder). **Nothing bounds the waiter from above at all**: `MigrateAsync` has exactly one production caller, `DarlingWorker.RunCollectionLoopAsync`, on the plain stopping token - no `CancelAfter` on the path, no configured `HostOptions.StartupTimeout` (so the framework default is infinite), no health check or readiness probe in the repo, no container `HEALTHCHECK`, no orchestrator manifest, and the installers' `WaitForStatus('Running')` returns before the first migration statement runs because the worker is a `BackgroundService`. So the value comes from the asymmetry instead: a waiter that dies mid-wait hits `LogCritical` and returns out of the collection loop, collecting NOTHING until an operator restarts it, while one that waits longer only delays its own first cycle, its MCP and web surfaces having started independently. Verified wired rather than merely declared - with the statement bound temporarily at 3 s and the lock wait at 12 s, a contended `MigrateAsync` against a held advisory lock failed at 12.02 s, and reverting only the acquire to the old constant moved that to 3.02 s, both surfacing as `Exception while reading from stream`. It NARROWS that startup failure rather than closing it: a fifth data-moving rung, or one needing longer than its own bound, still outlasts the wait. The directory-scoped pin sweeps every file in the project for both construction shapes, so a future file is covered the day it appears - and its span walker terminates at `depth <= 0`, pinned by a fixture reproducing the nested-`using` shape that made two depth-clamping scanners each miss a real site during this change.
- **The retention live tests now carry their evidence into the failure message** ([#2818]) - `EnsureRetentionPoliciesAsync`'s per-relation catch logs a warning and moves on, which is right for the service (one bad relation must not abort the other sixteen) - but both live tests passed `logger: null`, so when the nightly failed once with "expected 17, got 0" every one of the seventeen discarded warnings was the diagnosis and the bare count was all that survived. It was written off as a flake because it could not be anything else. The tests now pass the `CapturingTestLogger` that [#1564] added to the purge E2Es for exactly this shape (the issue assumed no such logger existed; it has since #2128) and fold `Joined` into every count assertion - including the `EnsureContinuousAggregatesAsync` call, since a swallowed CAGG failure is a plausible upstream cause of the same all-relations-throw symptom. Proven against a live TimescaleDB 2.29 store by running the SHIPPED methods both ways on a store with no hypertables: the old shape reports `applied=0` and nothing else, the new shape carries one warning per relation naming the actual Postgres error (`TS001: "query_stats" is not a hypertable or a continuous aggregate`), and the healthy path still applies and re-applies every policy with zero warnings captured. The shared logger's empty-case placeholder also stops claiming to be about purges. The next occurrence names its cause in the CI log on the first failure instead of costing a round.
- **A healthy server mid-sweep is no longer painted with the red Offline overlay** ([#2794]) - the display's freshness classifier banded a server `Offline - no recent collection` past a bare 15-minute constant, while the alert engine had deliberately decided collection is not "stopped" until 30 (`DarlingSelfAlertEvaluator.StaleWindow`, configurable as `collectionStaleMinutes`) - one condition, two definitions, and only the alert side configurable. The tighter one false-alarmed by design: the sweep skips relaunch while a server's collection body is still running, so one long `query_store` cycle holds the whole body and NOTHING writes to `collection_log` for the duration - a healthy server legitimately goes 12-19 minutes quiet with zero failures anywhere, and five production shards wore the red overlay at a snapshot while actively mid-cycle, which sent an investigation chasing phantom dark servers. **Measured before changing anything, per the issue's own instruction**: with [#2792] deployed, the worst legitimate inter-collection gap across the whole fleet in 24h is 12m12s and ZERO real servers cross 15 minutes (the sole >15m "server" is `server_id = 0`, the service's own bookkeeping rows, whose gaps end exactly at the deploy restarts) - but issue-day load produced 235 crossings on every server in 12 hours with legitimate stretch reaching 19m18s, so the margin between routine operation and the threshold was load-dependent and paper-thin, while genuinely dark servers run HOURS. `OfflineThreshold` now derives from a shared `CollectionStoppedMinutesDefault = 30` alongside every other spelling of the same claim - the evaluator's `StaleWindow`, `AlertsConfig.CollectionStaleMinutes`'s default, Lite's `AppAlertEngineSettings`, Lite's `AgentStatusRow.StaleWindow` (whose comment already said it "mirrors" the service's window, at a numerically-equal but independently-editable copy, the exact [#1562] drift shape), and `DarlingWorker`'s Postgres long-running-query recency bound, whose own doc comment derives it from the fleet staleness convention and which would otherwise have re-created the alert-blindness it warns about. A server between stretched sweeps now bands `Stale` - amber, "collection has lagged" - which is the honest reading; every surface follows because all four banding reads resolve through the one `ClassifyFreshness` ([#2473]), and `CollectionStoppedThresholdAgreementTests` pins the definitions together BY VALUE on both apps, proven red against the pre-change tree (15m != 30m, and the measured 19m18s stretch banding Offline). The Agent-header honesty suite's own window assertion needed the opposite treatment: with `AgentStatusRow.StaleWindow` now DERIVED, its bare `FromMinutes(30)` no longer said anything about the two surfaces agreeing, and a deliberate retune of the shared constant would have turned it red under a name (`StaleWindow_MatchesTheHeadlessServicesRefusalWindow`) that blames cross-surface drift for a definition that in fact still agreed. Symbolising the literal was not the fix either - that assertion becomes `FromMinutes(CONST) == FromMinutes(CONST)`, a tautology that can never fail - and neither was deleting it, because the suite does depend on the window's magnitude: its 1-minute and 45-minute fixture ages have to STRADDLE the window or the staleness cases quietly stop exercising staleness. That straddle is now the assertion (`TheStalenessFixtures_StraddleTheWindowTheyAreMeantToTest`), against the real symbol and against named fixture constants rather than retyped copies, so a widening past 45 minutes fails saying which fixture went out of date instead of implying the surfaces split.
@@ -3231,3 +3232,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#2490]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2490
[#2898]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2898
[#2890]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/2890
+[#2882]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2882
+[#2888]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2888
diff --git a/Darling/Darling.Tests/ViewerCommandTimeoutTests.cs b/Darling/Darling.Tests/ViewerCommandTimeoutTests.cs
new file mode 100644
index 000000000..d3cfbc8fc
--- /dev/null
+++ b/Darling/Darling.Tests/ViewerCommandTimeoutTests.cs
@@ -0,0 +1,491 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Every command in PerformanceMonitor.Darling.Viewer must carry an EXPLICIT deadline (#2874).
+/// All 193 of the project's command sites ran on Npgsql's undocumented 30 s default — a value nobody
+/// chose, and the defect class behind three production failures (#2810, #2871, #2796): exceeding the
+/// ceiling surfaces as Exception while reading from stream, which reads as a network fault
+/// rather than a deadline.
+///
+/// Directory-scoped, like the .Storage pin and unlike the alert-pass one.
+/// AlertPassCommandTimeoutTests enumerates six files because its claim is "runs inside
+/// EvaluateAlertsAsync", a budget boundary no filename expresses. This pin's claim is the same
+/// as StorageCommandTimeoutTests': every command this project creates must have had its
+/// deadline chosen on purpose, whichever regime's constant that is. That is a property of the
+/// project, so the sweep globs it and a future file is covered the day it appears rather than when
+/// someone remembers to enlist it.
+///
+/// Values are pinned as BANDS, never as equalities. Each band encodes the reasoning that
+/// produced the number — the measured floor below it and the shared budget above it — so a future
+/// re-derivation inside the band is free and a drift out of it has to argue with the derivation on
+/// . Freezing the numbers themselves would couple three
+/// deliberate values to one test, which is what the .Storage pin declined to do.
+///
+public sealed class ViewerCommandTimeoutTests
+{
+ ///
+ /// Both ways a command is constructed in this codebase — new NpgsqlCommand( and
+ /// .CreateCommand(. The second is the shape #2874's original census missed entirely, and
+ /// here it is 191 of the 193 sites.
+ ///
+ private static readonly Regex s_commandCtor = new(
+ @"new NpgsqlCommand\s*\(|\.CreateCommand\s*\(",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ private static readonly Regex s_setsTimeout = new(
+ @"CommandTimeout\s*=",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ ///
+ /// A CreateCommand handed over as a METHOD GROUP rather than called — the shape that is
+ /// invisible to because no ( follows it, and the one command
+ /// site in this project that survived the sweep of the other 192.
+ ///
+ private static readonly Regex s_commandFactoryHandoff = new(
+ @"\.CreateCommand\s*[,)]",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+
+ [Fact]
+ public void EveryViewerCommand_SetsAnExplicitDeadline()
+ {
+ var offenders = new List();
+ var total = 0;
+
+ foreach (var path in ViewerSources())
+ {
+ var text = File.ReadAllText(path);
+
+ foreach (Match ctor in s_commandCtor.Matches(text))
+ {
+ total++;
+
+ var span = StatementSpanFrom(text, ctor.Index, statements: 2);
+
+ if (!s_setsTimeout.IsMatch(span))
+ {
+ var line = text.Take(ctor.Index).Count(c => c == '\n') + 1;
+ offenders.Add($"{Path.GetFileName(path)}:{line}");
+ }
+ }
+ }
+
+ /* 193 sites at the time this pin landed; the floor guards against the sweep silently reading
+ an empty or wrong directory, not against refactors that change the count. */
+ Assert.True(total >= 150, $"the viewer scan matched only {total} command constructions — the sweep is not reading the project");
+
+ Assert.True(
+ offenders.Count == 0,
+ $"{offenders.Count} viewer command(s) inherit Npgsql's 30s default instead of an explicit deadline: "
+ + string.Join(", ", offenders));
+ }
+
+ ///
+ /// No command may be created by a delegate this project hands to someone else, because a deadline
+ /// set here is then the only one it can get.
+ ///
+ /// This is the hole #2874's census could not see. ViewerDataService.Blocking.cs passed
+ /// connection.CreateCommand as a bare method group into
+ /// PgBlockingPairRowQuery.AppendDmvSnapshotRowsAsync, which constructs the command in a
+ /// DIFFERENT project (PerformanceMonitor.Darling.Analysis) and sets its CommandText
+ /// there. Neither of #2874's census regexes matches a method group — there is no ( after it —
+ /// so that site read as clean while inheriting the default, and the fix is a factory lambda that
+ /// stamps the deadline before returning. Comments and string literals are stripped first, so the
+ /// prose explaining that fix cannot fail this test.
+ ///
+ [Fact]
+ public void NoViewerCommand_IsCreatedByABareMethodGroupHandoff()
+ {
+ var offenders = new List();
+
+ foreach (var path in ViewerSources())
+ {
+ var code = StripCommentsAndStrings(File.ReadAllText(path));
+
+ foreach (Match handoff in s_commandFactoryHandoff.Matches(code))
+ {
+ var line = code.Take(handoff.Index).Count(c => c == '\n') + 1;
+ offenders.Add($"{Path.GetFileName(path)}:{line}");
+ }
+ }
+
+ Assert.True(
+ offenders.Count == 0,
+ $"{offenders.Count} site(s) hand CreateCommand over as a method group, so the callee builds an "
+ + "untimed command this project cannot reach — pass a factory lambda that sets the deadline: "
+ + string.Join(", ", offenders));
+ }
+
+ ///
+ /// The interactive/refresh deadline, bounded on both sides — full derivation on
+ /// . Short form: the heaviest shipped
+ /// per-server read measured 3.01 s COLD on a V109 store seeded to production per-server density
+ /// across the full 30-day retention horizon, and nothing encloses these reads, so the deadline is
+ /// itself the budget and has to sit under the default it replaces rather than at it.
+ ///
+ [Fact]
+ public void TheInteractiveReadDeadline_StaysInsideItsJustifiedBand()
+ {
+ var seconds = ViewerCommandDeadlines.InteractiveReadSeconds;
+
+ Assert.True(
+ seconds > 3,
+ $"interactive read deadline {seconds}s is at or under the 3.01 s worst measured shipped read "
+ + "(TopQueriesSql over a 30-day custom range at production density) — it would fail a legitimate panel");
+
+ Assert.True(
+ seconds < 30,
+ $"interactive read deadline {seconds}s is not meaningfully under the inherited Npgsql default it "
+ + "replaces. These reads have no enclosing budget and compete for a ten-connection pool that one "
+ + "control can take entirely, so every second granted here is a second the whole viewer can spend "
+ + "holding permits while the user watches a spinner");
+ }
+
+ ///
+ /// The command-plane deadline, pinned RELATIONALLY against the budget it actually sits inside
+ /// rather than against a copied number: PollCommandResultAsync re-issues the poll until
+ /// , and checks that budget only BETWEEN
+ /// iterations — so a read deadline at or above the loop's budget puts the read back in charge of
+ /// how long the dialog waits, which is the defect the constant exists to close.
+ ///
+ [Fact]
+ public void TheCommandPlaneDeadline_StaysWellInsideItsEnclosingBudget()
+ {
+ var seconds = ViewerCommandDeadlines.CommandPlaneSeconds;
+ var enclosing = ViewerDataService.DefaultCommandTimeout.TotalSeconds;
+
+ Assert.True(
+ seconds >= 1,
+ $"command-plane deadline {seconds}s leaves no room over the 3.9 ms cold single-row poll, and the "
+ + "delete is what removes a credential-bearing args_json row");
+
+ Assert.True(
+ seconds * 2 < enclosing,
+ $"command-plane deadline {seconds}s is not comfortably inside the {enclosing}s poll-loop budget it "
+ + "runs under — the loop checks its budget only between iterations, so one read this long overshoots "
+ + "the wait the dialog promises");
+ }
+
+ ///
+ /// The connect-gate deadline. Bounded below by a catalog probe that does not grow with the store
+ /// (79 ms cold for all 85 schema sentinels) and above by the connect preference's own ceiling —
+ /// these are the first two statements after connect, they run before the window is usable, and
+ /// both swallow their own failures, so overshooting mis-classifies the store silently.
+ ///
+ [Fact]
+ public void TheConnectGateDeadline_StaysInsideItsJustifiedBand()
+ {
+ var seconds = ViewerCommandDeadlines.ConnectGateSeconds;
+
+ Assert.True(
+ seconds >= 1,
+ $"connect-gate deadline {seconds}s leaves no room over the 79 ms cold schema probe; a probe that "
+ + "times out fails OPEN or SAFE, so it hides write affordances rather than reporting anything");
+
+ Assert.True(
+ seconds <= 60,
+ $"connect-gate deadline {seconds}s exceeds the 60s ceiling the viewer's own connection-timeout "
+ + "preference is clamped to, so the gate could outlast the connect it follows");
+ }
+
+ ///
+ /// Scanner blind spots, pinned — a false positive here fails a green build on correct code.
+ ///
+ /// The fourth case is the shape this group's own tooling got wrong on .Storage: an
+ /// untimed command inside a using (...) { } STATEMENT, with the block's closing brace
+ /// between it and the next command's deadline. A scanner whose depth counter cannot go negative
+ /// treats that } as still depth-zero, keeps consuming past it, and reads the FOLLOWING
+ /// command's deadline — calling the untimed site clean. The depth <= 0 walker below
+ /// reports it. The fifth is this project's real shape: the timed factory lambda that replaced the
+ /// method-group hand-off, which must read as TIMED.
+ ///
+ [Theory]
+ [InlineData(
+ "var command = _dataSource.CreateCommand(Sql);\n"
+ + "command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;\n",
+ true)]
+ [InlineData(
+ "await using var command = databaseName == null\n"
+ + " ? _dataSource.CreateCommand(AllSql)\n"
+ + " : _dataSource.CreateCommand(ByDbSql);\n"
+ + "command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;\n",
+ true)]
+ [InlineData(
+ "var command = _dataSource.CreateCommand(Sql);\n"
+ + "await command.ExecuteNonQueryAsync();\n",
+ false)]
+ [InlineData(
+ "using (var untimed = new NpgsqlCommand(\"SELECT 1\", connection))\n"
+ + "{\n"
+ + " a = (int)await untimed.ExecuteScalarAsync();\n"
+ + " b = a + 1;\n"
+ + "}\n"
+ + "using var next = new NpgsqlCommand(OtherSql, connection) { CommandTimeout = 10 };\n",
+ false)]
+ [InlineData(
+ "() =>\n"
+ + "{\n"
+ + " var command = connection.CreateCommand();\n"
+ + " command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;\n"
+ + " return command;\n"
+ + "},\n",
+ true)]
+ public void TheScanner_JudgesTheSiteItself_NotItsNeighbours(string source, bool expectedTimed)
+ {
+ var ctor = s_commandCtor.Match(source);
+ Assert.True(ctor.Success, "the fixture did not contain a command construction");
+
+ var span = StatementSpanFrom(source, ctor.Index, statements: 2);
+
+ Assert.Equal(expectedTimed, s_setsTimeout.IsMatch(span));
+ }
+
+ ///
+ /// The method-group scan must read CODE, not prose: the comment explaining the factory fix names
+ /// connection.CreateCommand followed by a comma in running text, and a scan that did not
+ /// strip comments would fail the build on the very explanation of the fix.
+ ///
+ [Theory]
+ [InlineData(" Append(connection.CreateCommand, rows);\n", true)]
+ [InlineData(" /* not the bare connection.CreateCommand, but a factory. */\n", false)]
+ [InlineData(" // pass connection.CreateCommand) here? no.\n", false)]
+ [InlineData(" var s = \"connection.CreateCommand,\";\n", false)]
+ [InlineData(" var command = connection.CreateCommand();\n", false)]
+ public void TheFactoryHandoffScan_ReadsCodeNotProse(string source, bool expectedOffender)
+ {
+ var code = StripCommentsAndStrings(source);
+
+ Assert.Equal(expectedOffender, s_commandFactoryHandoff.IsMatch(code));
+ }
+
+ /* The span walker below is the CI-proven copy from StorageCommandTimeoutTests — string- and
+ comment-aware, terminating on the Nth semicolon at depth <= 0 so a span can never leak out of
+ the member it started in. Kept as a private copy the way the sibling pins keep theirs;
+ extracting a shared helper across four test files is a refactor those lanes should take
+ together or not at all. */
+
+ private static string StatementSpanFrom(string text, int start, int statements)
+ {
+ var depth = 0;
+ var seen = 0;
+ var i = start;
+
+ while (i < text.Length)
+ {
+ var c = text[i];
+
+ if (c == '@' && i + 1 < text.Length && text[i + 1] == '"')
+ {
+ i = SkipVerbatimString(text, i + 2);
+ continue;
+ }
+
+ if (c == '"')
+ {
+ i = SkipRegularString(text, i + 1);
+ continue;
+ }
+
+ if (c == '/' && i + 1 < text.Length && text[i + 1] == '/')
+ {
+ var nl = text.IndexOf('\n', i);
+ i = nl < 0 ? text.Length : nl + 1;
+ continue;
+ }
+
+ if (c == '/' && i + 1 < text.Length && text[i + 1] == '*')
+ {
+ var end = text.IndexOf("*/", i + 2, System.StringComparison.Ordinal);
+ i = end < 0 ? text.Length : end + 2;
+ continue;
+ }
+
+ if (c is '(' or '[' or '{')
+ {
+ depth++;
+ }
+ else if (c is ')' or ']' or '}')
+ {
+ depth--;
+ }
+ else if (c == ';' && depth <= 0 && ++seen >= statements)
+ {
+ return text[start..(i + 1)];
+ }
+
+ i++;
+ }
+
+ return text[start..];
+ }
+
+ ///
+ /// Blanks out comments and string literals while preserving newlines, so a regex meant for code
+ /// cannot match prose or a literal. Newlines survive because the offenders are reported by line.
+ ///
+ private static string StripCommentsAndStrings(string text)
+ {
+ var sb = new StringBuilder(text.Length);
+ var i = 0;
+
+ while (i < text.Length)
+ {
+ var c = text[i];
+
+ if (c == '@' && i + 1 < text.Length && text[i + 1] == '"')
+ {
+ var end = SkipVerbatimString(text, i + 2);
+ Blank(sb, text, i, end);
+ i = end;
+ continue;
+ }
+
+ if (c == '"')
+ {
+ var end = SkipRegularString(text, i + 1);
+ Blank(sb, text, i, end);
+ i = end;
+ continue;
+ }
+
+ if (c == '/' && i + 1 < text.Length && text[i + 1] == '/')
+ {
+ var nl = text.IndexOf('\n', i);
+ var end = nl < 0 ? text.Length : nl;
+ Blank(sb, text, i, end);
+ i = end;
+ continue;
+ }
+
+ if (c == '/' && i + 1 < text.Length && text[i + 1] == '*')
+ {
+ var close = text.IndexOf("*/", i + 2, System.StringComparison.Ordinal);
+ var end = close < 0 ? text.Length : close + 2;
+ Blank(sb, text, i, end);
+ i = end;
+ continue;
+ }
+
+ sb.Append(c);
+ i++;
+ }
+
+ return sb.ToString();
+ }
+
+ private static void Blank(StringBuilder sb, string text, int start, int end)
+ {
+ for (var j = start; j < end; j++)
+ {
+ sb.Append(text[j] == '\n' ? '\n' : ' ');
+ }
+ }
+
+ private static int SkipVerbatimString(string text, int i)
+ {
+ while (i < text.Length)
+ {
+ if (text[i] == '"')
+ {
+ if (i + 1 < text.Length && text[i + 1] == '"')
+ {
+ i += 2;
+ continue;
+ }
+
+ return i + 1;
+ }
+
+ i++;
+ }
+
+ return i;
+ }
+
+ private static int SkipRegularString(string text, int i)
+ {
+ while (i < text.Length)
+ {
+ if (text[i] == '\\')
+ {
+ i += 2;
+ continue;
+ }
+
+ if (text[i] == '"')
+ {
+ return i + 1;
+ }
+
+ i++;
+ }
+
+ return i;
+ }
+
+ private static IEnumerable ViewerSources()
+ {
+ var dir = Path.Combine(RepoRoot(), "Darling", "PerformanceMonitor.Darling.Viewer");
+
+ Assert.True(Directory.Exists(dir), $"viewer project directory not found: {dir}");
+
+ /* RECURSIVE, unlike the .Storage pin's TopDirectoryOnly, minus the build outputs. The claim
+ is "every command THIS PROJECT creates", and a file added under a future subdirectory is
+ still this project's - the viewer already carries a Themes/ folder, so subdirectories are
+ not hypothetical here. bin/ and obj/ are excluded by path segment rather than by name
+ match, because that is where the generated .AssemblyInfo.cs and .g.cs land during a CI
+ build and they are not source. */
+ var paths = Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories)
+ .Where(p => !IsBuildOutput(dir, p))
+ .OrderBy(p => p, System.StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.True(paths.Length >= 150, $"the viewer sweep found only {paths.Length} files — the project has moved");
+
+ return paths;
+ }
+
+ ///
+ /// True when a path sits under the project's bin or obj tree. Compared as PATH
+ /// SEGMENTS, so a source file that merely has "obj" in its name is not excluded.
+ ///
+ private static bool IsBuildOutput(string projectDir, string path)
+ {
+ var relative = Path.GetRelativePath(projectDir, path);
+ var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ return segments.Any(s =>
+ string.Equals(s, "bin", System.StringComparison.OrdinalIgnoreCase)
+ || string.Equals(s, "obj", System.StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static string RepoRoot([CallerFilePath] string thisFile = "")
+ {
+ var dir = Path.GetDirectoryName(thisFile)!;
+ while (dir is not null
+ && !File.Exists(Path.Combine(dir, "PerformanceMonitor.sln"))
+ && !Directory.Exists(Path.Combine(dir, ".git")))
+ {
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ Assert.NotNull(dir);
+ return dir!;
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs
new file mode 100644
index 000000000..0dca5466f
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs
@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+namespace PerformanceMonitor.Darling.Viewer;
+
+///
+/// The explicit command deadlines for the viewer's store reads (#2874). Every one of this project's
+/// 193 command sites previously set no CommandTimeout and so inherited Npgsql's undocumented
+/// 30 s default — a value nobody chose, and the defect class behind three production failures
+/// (#2810, #2871, #2796): exceeding the ceiling surfaces as Exception while reading from stream,
+/// which reads as a network fault rather than a deadline.
+///
+/// Three regimes, because this surface really has three. The bulk of the project is one
+/// regime and deliberately so: the fleet timer and the per-tab auto-refresh timer call the SAME
+/// ViewerDataService methods a user gesture calls — RefreshActiveInnerTabAsync is
+/// literally the method tab-activation invokes — so "interactive" and "background refresh" cannot be
+/// told apart at the command site, and the set of reads reached ONLY by the unattended fleet fan-out
+/// is empty. Splitting them would need a budget threaded through every call site, not a constant. The
+/// two regimes that ARE separable are separable because something else bounds them: the command plane
+/// sits inside a real orchestration budget, and the connect-time gate runs before the window is usable
+/// and swallows its own failures.
+///
+/// What is NOT a regime here. Export was the obvious fourth candidate and it does not
+/// exist: PerformanceMonitor.Ui.DataGridExport is synchronous, store-unaware, and iterates
+/// grid.Items, so every CSV/copy path formats rows a visible-tab load already paid for. The
+/// long-running operations a user knowingly waits minutes for (snapshot_now, analyze_now, purge_now,
+/// Get Actual Plan) are COMMANDS on the command plane below, not reads.
+///
+/// Why none of these is StorageCommandDeadlines.McpReadSeconds. That constant is
+/// 30 s for the MCP read surface and its derivation does not transfer. The MCP's worst verified read
+/// was 685 ms and its permit is an unbounded pool; the viewer's worst measured read is 3.0 s — 4.4x
+/// slower — yet its permit is ten times scarcer (MaxPoolSize = 10, set on the managed-derived
+/// string in ViewerSettings), a single control fans out to exactly ten concurrent reads and so
+/// can hold every one of them, and an unguarded fleet-timer read can re-fire every 10 s. Slower reads
+/// against a scarcer permit land BELOW 30 s, not at it.
+///
+public static class ViewerCommandDeadlines
+{
+ ///
+ /// The interactive/refresh store reads — 188 of the project's 193 command sites: everything
+ /// except the three on the command plane and the two connect-gate probes, which share
+ /// ViewerDataService.cs with two ordinary reads, so the regime is decided per SITE here
+ /// rather than per file.
+ ///
+ /// ABOVE the measured worst case. Timed against a store stood up by the product's own
+ /// PgMigrations.MigrateAsync at V109 with TimescaleDB and seeded to EXACT production
+ /// per-server density (get_collector_cost over 2 days x 42 servers: 189,414 query_stats
+ /// rows/server/day), across the collector's full 30-day retention horizon — 5.68 M query_stats
+ /// rows and 1.98 M procedure_stats rows for one server. The heaviest shipped per-server read,
+ /// TopQueriesSql (a windowed aggregate plus a LEFT JOIN LATERAL back through
+ /// v_query_stats and a ROW_NUMBER module join), measured COLD: 588 ms on the default
+ /// 1-hour preset, 1.12 s on the widest 7-day preset, and 3.01 s on a 30-day custom range — the
+ /// widest window any shipped read can be asked for, since retention drops the data behind it.
+ /// Fifteen seconds is 5x that worst case, 13x the widest preset, and 25x the default.
+ ///
+ /// BELOW the point where a stalled read is worse than a failed one, which here is a permit
+ /// argument rather than a budget one. Nothing encloses these reads — no CancelAfter, no
+ /// SemaphoreSlim, no WaitAsync, and no request timeout, because the viewer is a WPF
+ /// WinExe and hosts no web endpoint — so this deadline IS the budget, the same finding
+ /// #2882 and #2888 both made, and the same reason both erred short. What it competes for is the
+ /// ten-connection pool: CorrelatedTimelineLanesControl awaits one Task.WhenAll over
+ /// exactly ten reads, so a single panel can hold every permit, and while they are held the sidebar
+ /// freshness dots, the alert poll and every other panel get nothing — read eleven waits
+ /// ConnectionTimeoutSeconds (default 5) for a slot and then throws a CONNECT error, which
+ /// misattributes a slow store to the network. Halving the inherited 30 s halves that worst-case
+ /// hold. Ten concurrent 30-day reads on the rig above measured 24.4-64.1 s, six of them past the
+ /// silent default they used to inherit; cutting each at 15 s returns permits sooner, which is the
+ /// outcome to want in that state.
+ ///
+ /// The asymmetry, worked out for this surface rather than assumed: too short and one panel
+ /// shows an error the user can retry — and the auto-refresh timer retries it within 30 s anyway,
+ /// unprompted. Too long and the user watches a spinner while a pooled connection is held, which is
+ /// the failure mode that cannot be diagnosed from the UI. Erring short is right here.
+ ///
+ public const int InteractiveReadSeconds = 15;
+
+ ///
+ /// The store<->service command plane — the three commands in
+ /// ViewerDataService.Commands.cs (enqueue, poll, delete).
+ ///
+ /// ABOVE the measured worst case with room to spare: the poll is a single-row primary-key
+ /// lookup on config_command, measured at 3.9 ms cold and 0.1 ms warm; the enqueue and the
+ /// delete are single-row writes on that same table. Five seconds is three orders of magnitude over
+ /// the cold measurement, deliberately, because the delete is what removes a DPAPI
+ /// credential-bearing args_json row after a test_connect and should not be the thing
+ /// that gives up early.
+ ///
+ /// BELOW the budget it shares, which unlike the interactive regime is real and explicit:
+ /// PollCommandResultAsync loops until DefaultCommandTimeout (45 s) or
+ /// ImperativeCommandTimeout (3 min), re-issuing the poll every 400 ms. That budget is
+ /// checked only BETWEEN iterations, so with no deadline on the read itself one hung poll overshot
+ /// the stated 45 s by up to Npgsql's 30 s — the loop was bounded and the read inside it was not.
+ /// Five seconds puts the read an order of magnitude under the smaller of the two enclosing
+ /// budgets, which restores the loop's budget as the binding constraint. That is the point: too
+ /// short and the poll throws where the caller is written to receive null and show "still running /
+ /// try again"; too long and a dialog's stated 45 s silently becomes 75 s.
+ ///
+ public const int CommandPlaneSeconds = 5;
+
+ ///
+ /// The connect-time gate — ReadOnlyProbeSql in DetectReadOnlyAsync and
+ /// StoreSchemaProbeSql in GetStoreSchemaVersionAsync.
+ ///
+ /// ABOVE the measured worst case, and this one does not grow with the store: both read
+ /// information_schema / has_table_privilege only, touching no hypertable and scanning
+ /// no data. The 85-sentinel schema probe measured 79 ms cold and 49 ms warm on the seeded V109 rig
+ /// above; ten seconds is ~127x that. A store ten times larger does not move it, which is why this
+ /// regime can sit far tighter than the interactive one despite having no budget either.
+ ///
+ /// BELOW the point where startup hangs. These are the first two statements after connect, in
+ /// MainWindow.OnLoaded, before any timer starts and before the window is usable — and there
+ /// is no splash to explain the wait. Ten seconds is twice the default connect budget
+ /// (ConnectionTimeoutSeconds = 5) and bounded well under its 60 s ceiling, so a slow link
+ /// cannot turn the gate into a silent hang.
+ ///
+ /// The asymmetry here is different from the other two regimes and is the reason this is its
+ /// own constant: both probes CATCH their own failures. GetStoreSchemaVersionAsync fails open
+ /// (returns null, so a healthy store is never blocked by a probe hiccup) and
+ /// DetectReadOnlyAsync fails safe (records read-only, so the UI hides writes rather than
+ /// dead-clicking a permission error). So a blown deadline here raises nothing — it silently
+ /// MIS-CLASSIFIES the store, hiding every write affordance on a writable one. A visible,
+ /// reconnectable mis-classification is the better trade against a startup that never finishes.
+ ///
+ public const int ConnectGateSeconds = 10;
+}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs
index aa66fca94..f1581a4a7 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs
@@ -135,6 +135,7 @@ public async Task> GetAlertHistoryAsync(
var rows = new List();
await using var command = _dataSource.CreateCommand(serverId.HasValue ? AlertHistorySql : AlertHistoryAllServersSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter
{
TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified),
@@ -232,6 +233,7 @@ public async Task DismissAlertsAsync(IReadOnlyList alerts,
}
await using var command = _dataSource.CreateCommand(DismissAlertsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { Value = times });
command.Parameters.Add(new NpgsqlParameter { Value = ids });
command.Parameters.Add(new NpgsqlParameter { Value = metrics });
@@ -249,6 +251,7 @@ public async Task DismissAllVisibleAlertsAsync(
{
await using var command = _dataSource.CreateCommand(
serverId.HasValue ? DismissAllAlertsForServerSql : DismissAllAlertsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter
{
TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified),
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs
index 6b744fa77..b36028af0 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs
@@ -150,6 +150,7 @@ ON CONFLICT (id) DO UPDATE SET
public async Task GetAlertSettingsAsync(CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(AlertSettingsSelectSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
return await reader.ReadAsync(cancellationToken) ? ReadAlertSettingsRow(reader) : null;
}
@@ -161,6 +162,7 @@ public async Task UpsertAlertSettingsAsync(AlertSettingsRow row, CancellationTok
ArgumentNullException.ThrowIfNull(row);
await using var command = _dataSource.CreateCommand(AlertSettingsUpsertSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
BindAlertSettings(command, row);
await ExecuteWriteAsync(command, cancellationToken);
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AvailabilityGroups.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AvailabilityGroups.cs
index b0a1620c4..50b92e1e6 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AvailabilityGroups.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AvailabilityGroups.cs
@@ -113,6 +113,7 @@ private async Task> ReadAgReplicasAsync(CancellationT
{
var rows = new List();
await using var command = _dataSource.CreateCommand(AgReplicaStatesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
@@ -142,6 +143,7 @@ private async Task> ReadAgDatabasesAsync(Cancellatio
{
var rows = new List();
await using var command = _dataSource.CreateCommand(AgDatabaseReplicaStatesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs
index d85868bb5..159546406 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs
@@ -243,6 +243,7 @@ private async Task> ReadBlockedProcessRowsAsync(
var rows = new List();
await using var command = _dataSource.CreateCommand(BlockedProcessReportsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddBlockingParameters(command, serverId, startUtc, endUtc);
command.Parameters.Add(DatabaseFilterParameter(databaseNames));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -303,6 +304,7 @@ private async Task> ReadDmvBlockedProcessRowsAsync
var rows = new List();
await using var command = _dataSource.CreateCommand(DmvBlockingSnapshotsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddBlockingParameters(command, serverId, startUtc, endUtc);
command.Parameters.Add(DatabaseFilterParameter(databaseNames));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -358,6 +360,7 @@ internal async Task> GetBlockingPairRowsAsync(
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using (var command = connection.CreateCommand())
{
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.CommandText = BlockingPairRowsSql;
AddBlockingParameters(command, serverId, startUtc, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -369,8 +372,18 @@ internal async Task> GetBlockingPairRowsAsync(
// blocked-process-report XE captured nothing (threshold unset / AWS RDS). Same connection.
/* #2443: the viewer passes its own window token — this read serves a person waiting at a
grid, not an analysis pass, so there is no budget or service stop for it to abandon under. */
+ /* A FACTORY that stamps the deadline, not the bare `connection.CreateCommand` method group
+ (#2874). This command is constructed inside PgBlockingPairRowQuery, so a deadline set here
+ is the only one it can get - and a method-group hand-off is invisible to both of #2874's
+ census regexes, which is how this site survived the sweep of the other 192. */
await PgBlockingPairRowQuery.AppendDmvSnapshotRowsAsync(
- connection.CreateCommand, rows, serverId, startUtc, endUtc, cancellationToken);
+ () =>
+ {
+ var command = connection.CreateCommand();
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
+ return command;
+ },
+ rows, serverId, startUtc, endUtc, cancellationToken);
return rows;
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs
index dcb80ad90..25769a71c 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs
@@ -74,6 +74,7 @@ public async Task> GetBlockingSlicerDataAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(BlockingSlicerSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddBlockingParameters(command, serverId, startUtc, endUtc);
command.Parameters.Add(DatabaseFilterParameter(databaseNames));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -117,6 +118,7 @@ public async Task> GetDeadlockSlicerDataAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(DeadlockSlicerSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddBlockingParameters(command, serverId, startUtc, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs
index 0b614a5a5..6d7101ca8 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs
@@ -85,6 +85,7 @@ public async Task> GetBlockingDurationStatsAsyn
var items = new List();
await using var command = _dataSource.CreateCommand(BlockingDurationStatsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
@@ -148,6 +149,7 @@ public async Task> GetDeadlockSeverityStatsAsyn
var graphs = new List<(DateTime? DeadlockTime, string? Xml)>();
await using var command = _dataSource.CreateCommand(DeadlockSeverityGraphsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddBlockingParameters(command, serverId, startUtc, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs
index 92544c872..450b39d9f 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs
@@ -196,6 +196,7 @@ private async Task> ReadCountTrendAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(sql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
@@ -225,6 +226,7 @@ public async Task> GetLockWaitTrendAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(LockWaitTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
@@ -253,6 +255,7 @@ public async Task> GetWaitingTaskTrendAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(WaitingTaskTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
@@ -281,6 +284,7 @@ public async Task> GetBlockedSessionTrendAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(BlockedSessionTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs
index b521beb7c..e0618289e 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs
@@ -173,6 +173,7 @@ FROM v_collection_log
public async Task GetPermissionDeniedCollectorCountAsync(int serverId, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(PermissionDeniedCollectorCountSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-7), DateTimeKind.Unspecified) });
@@ -296,6 +297,7 @@ public async Task> GetCollectionHealthAsync(int serverI
var items = new List();
await using var command = _dataSource.CreateCommand(CollectionHealthSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
@@ -323,6 +325,7 @@ public async Task> GetFleetCollectionHealthAsync(Cancel
var items = new List();
await using var command = _dataSource.CreateCommand(FleetCollectionHealthSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter
{
TypedValue = DateTime.SpecifyKind(DateTime.UtcNow.AddDays(-7), DateTimeKind.Unspecified),
@@ -374,6 +377,7 @@ NULL placeholder on the fleet side — an abandoned cycle is data loss on either
public async Task> GetRecentCollectionLogAsync(int serverId, DateTime startUtc, DateTime endUtc, int maxRows = 500, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(RecentCollectionLogSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
@@ -396,6 +400,7 @@ public async Task> GetRecentCollectionLogAsync(int server
public async Task> GetCollectionLogByCollectorAsync(int serverId, string collectorName, int hoursBack = 168, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(CollectionLogByCollectorSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = collectorName });
command.Parameters.Add(new NpgsqlParameter
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs
index 1e74692a1..55e5ed9ad 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs
@@ -85,6 +85,7 @@ public async Task> GetCollectorSchedulesAsync(Cancell
var rows = new List();
await using var command = _dataSource.CreateCommand(CollectorSchedulesSelectSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
@@ -125,6 +126,7 @@ public Task ReplaceServerSchedulesAsync(int serverId, IEnumerable ResetAllServerSchedulesAsync(CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(CollectorScheduleDeleteAllServerScopesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
return await ExecuteWriteAsync(command, cancellationToken);
}
@@ -141,6 +143,7 @@ private async Task ReplaceScheduleScopeAsync(int? serverId, IEnumerable { TypedValue = sid });
@@ -153,6 +156,7 @@ private async Task ReplaceScheduleScopeAsync(int? serverId, IEnumerable { TypedValue = sid }); // $1 (server scope)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs
index 885abd747..aa65820c3 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs
@@ -80,6 +80,7 @@ public async Task EnqueueCommandAsync(
}
await using var command = _dataSource.CreateCommand(CommandEnqueueSql);
+ command.CommandTimeout = ViewerCommandDeadlines.CommandPlaneSeconds;
AddNullableText(command, requestedBy); // $1
command.Parameters.Add(new NpgsqlParameter { TypedValue = commandType }); // $2
command.Parameters.Add(new NpgsqlParameter // $3
@@ -104,6 +105,7 @@ public async Task EnqueueCommandAsync(
public async Task ReadCommandResultAsync(long commandId, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(CommandPollSql);
+ command.CommandTimeout = ViewerCommandDeadlines.CommandPlaneSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = commandId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
@@ -192,6 +194,7 @@ backstop. Never let cleanup hide the probe result the user is waiting on. */
public async Task DeleteCommandAsync(long commandId, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(CommandDeleteSql);
+ command.CommandTimeout = ViewerCommandDeadlines.CommandPlaneSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = commandId });
await ExecuteWriteAsync(command, cancellationToken);
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs
index 4544d98d6..c56f29cff 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs
@@ -87,6 +87,7 @@ public async Task> GetLatestServerConfigAsync(int serverId
var items = new List();
await using var command = _dataSource.CreateCommand(ServerConfigSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
@@ -110,6 +111,7 @@ public async Task> GetLatestDatabaseConfigAsync(int serv
var items = new List();
await using var command = _dataSource.CreateCommand(DatabaseConfigSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(DatabaseFilterParameter(databaseNames));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -160,6 +162,7 @@ public async Task> GetLatestDatabaseScopedConfigAs
var items = new List();
await using var command = _dataSource.CreateCommand(DatabaseScopedConfigSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(DatabaseFilterParameter(databaseNames));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -184,6 +187,7 @@ public async Task> GetLatestQueryStoreHealthAsync(int
var items = new List();
await using var command = _dataSource.CreateCommand(QueryStoreHealthSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(DatabaseFilterParameter(databaseNames));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -213,6 +217,7 @@ public async Task> GetLatestTraceFlagsAsync(int serverId, Can
var items = new List();
await using var command = _dataSource.CreateCommand(TraceFlagsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs
index bfd1c37f6..65f671656 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ConfigChanges.cs
@@ -171,6 +171,7 @@ public async Task> GetTraceFlagChangesAsync(
{
var rows = new List();
await using var command = _dataSource.CreateCommand(ServerConfigChangesSnapshotsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddServerIdAndWindowEnd(command, serverId, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
@@ -192,6 +193,7 @@ public async Task> GetTraceFlagChangesAsync(
{
var rows = new List();
await using var command = _dataSource.CreateCommand(DatabaseConfigChangesSnapshotsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddServerIdAndWindowEnd(command, serverId, endUtc);
command.Parameters.Add(DatabaseFilterParameter(databaseNames));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -218,6 +220,7 @@ public async Task> GetTraceFlagChangesAsync(
{
var rows = new List();
await using var command = _dataSource.CreateCommand(TraceFlagChangesSnapshotsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddServerIdAndWindowEnd(command, serverId, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs
index c79c1d174..57bd7a484 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs
@@ -87,6 +87,7 @@ public async Task> GetCpuUtilizationAsync(int serverI
var samples = new List();
await using var command = _dataSource.CreateCommand(CpuUtilizationSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs
index a86206d62..6735b1eec 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CpuScheduler.cs
@@ -136,6 +136,7 @@ public async Task> GetCpuSchedulerTrendAsync(
var result = new List();
await using var command = _dataSource.CreateCommand(CpuSchedulerTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddWindowParameters(command, serverId, startUtc, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
@@ -155,6 +156,7 @@ public async Task> GetCpuSchedulerTrendAsync(
int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(CpuSchedulerSnapshotSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddWindowParameters(command, serverId, startUtc, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
index 0ee9f0a8b..8478e34bf 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs
@@ -58,6 +58,7 @@ public async Task> GetDailySummaryRangeAsync(
coverage.For(TimescaleSupport.QueryStatsHourlyView, TimescaleSupport.QueryStatsDailyView));
await using var command = _dataSource.CreateCommand(DailySummaryRangeSqlFor(tier));
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(fromDate.Date, DateTimeKind.Unspecified) });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(toDate.Date, DateTimeKind.Unspecified) });
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseStates.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseStates.cs
index ac0b9edeb..6dcb19d59 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseStates.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DatabaseStates.cs
@@ -104,16 +104,19 @@ public async Task> GetDatabaseStateExpectationsAs
if (!IsReadOnly)
{
await using var seed = _dataSource.CreateCommand(DatabaseStateSeedSql);
+ seed.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
seed.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await seed.ExecuteNonQueryAsync(cancellationToken);
await using var heal = _dataSource.CreateCommand(DatabaseStateHealToOnlineSql);
+ heal.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
heal.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await heal.ExecuteNonQueryAsync(cancellationToken);
}
var rows = new List();
await using var command = _dataSource.CreateCommand(DatabaseStateExpectationsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
@@ -135,6 +138,7 @@ public async Task> GetDatabaseStateExpectationsAs
public async Task SetDatabaseStateExpectedAsync(int serverId, string databaseName, string expectedState, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(DatabaseStateSetExpectedSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName });
command.Parameters.Add(new NpgsqlParameter { TypedValue = expectedState });
@@ -145,6 +149,7 @@ public async Task SetDatabaseStateExpectedAsync(int serverId, string databaseNam
public async Task ResetDatabaseStateExpectedToCurrentAsync(int serverId, string databaseName, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(DatabaseStateResetToCurrentSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName });
await ExecuteWriteAsync(command, cancellationToken);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs
index db1c5df0b..3efd0dec3 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs
@@ -117,6 +117,7 @@ public async Task> GetRecentDeadlocksAsync(
var rows = new List();
await using var command = _dataSource.CreateCommand(RecentDeadlocksSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddBlockingParameters(command, serverId, startUtc, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs
index b2b9ccd60..3fa088e45 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs
@@ -62,6 +62,7 @@ public async Task> GetCollectedDatabaseNamesAsync(int serverId, Can
var names = new List();
await using var command = _dataSource.CreateCommand(CollectedDatabaseNamesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
@@ -84,6 +85,7 @@ public async Task> GetCollectedDatabaseNamesAsync(int serverId, Can
}
await using var command = _dataSource.CreateCommand(ServerIdByNameSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverName });
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is null or DBNull ? null : Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FileIo.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FileIo.cs
index 05e2064ad..97cbbf682 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FileIo.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FileIo.cs
@@ -151,6 +151,7 @@ public async Task> GetFileIoLatencyTrendAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(FileIoLatencyTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
@@ -186,6 +187,7 @@ public async Task> GetFileIoThroughputTrendAsync(
var items = new List();
await using var command = _dataSource.CreateCommand(FileIoThroughputTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.IndexAnalysis.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.IndexAnalysis.cs
index b90da5da7..1ddf72562 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.IndexAnalysis.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.IndexAnalysis.cs
@@ -333,6 +333,7 @@ public async Task> GetIndexCleanupInputsAsync(int s
var inputs = new List();
await using var command = _dataSource.CreateCommand(IndexObjectStatsLatestSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -352,6 +353,7 @@ public async Task GetIndexCleanupOptionsAsync(int serverId,
DateTime? startTime = null;
await using var command = _dataSource.CreateCommand(ServerCompressionInfoSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs
index 8d8a2227a..063c22b97 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs
@@ -125,6 +125,7 @@ LEFT JOIN idle_dbs id ON true
var idleCutoff = DateTime.UtcNow.AddDays(-7);
await using var command = _dataSource.CreateCommand(ServerMetricsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cpuCutoff, DateTimeKind.Unspecified) });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(idleCutoff, DateTimeKind.Unspecified) });
@@ -217,6 +218,7 @@ FROM server_properties
public async Task> GetServerInventoryAsync(CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(ServerInventorySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
var items = new List();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Locking.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Locking.cs
index 787bbd231..09f1d815f 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Locking.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Locking.cs
@@ -113,6 +113,7 @@ public async Task> GetIndexLockingAsync(int serverId, int
await using var command = databaseName == null
? _dataSource.CreateCommand(IndexLockingAllSql)
: _dataSource.CreateCommand(IndexLockingByDbSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
if (databaseName != null)
@@ -174,6 +175,7 @@ OR COALESCE(ios.index_lock_promotion_count, 0) > 0
public async Task> GetIndexLockingDatabasesAsync(int serverId, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(IndexLockingDatabasesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
var items = new List();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Pvs.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Pvs.cs
index bc3169b46..f3642cb06 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Pvs.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Pvs.cs
@@ -85,6 +85,7 @@ public async Task> GetPvsTrendAsync(
int serverId, DateTime sinceUtc, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(PvsTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(sinceUtc, DateTimeKind.Unspecified) });
@@ -105,6 +106,7 @@ public async Task> GetPvsTrendAsync(
public async Task> GetPvsStatsLatestAsync(int serverId, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(PvsStatsLatestSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
var items = new List();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs
index 8a430636d..7df4d785c 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs
@@ -137,6 +137,7 @@ public readonly record struct EditionFacts(
public async Task GetEditionFactsAsync(int serverId, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(RecommendationsEditionFactsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
@@ -543,6 +544,7 @@ public async Task> GetRecommendationsAsync(int serverId,
try
{
await using var command = _dataSource.CreateCommand(RecommendationsMaintenanceWindowSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = memoryCutoff });
@@ -586,6 +588,7 @@ public async Task> GetRecommendationsAsync(int serverId,
try
{
await using var cpuCommand = _dataSource.CreateCommand(RecommendationsCpuP95Sql);
+ cpuCommand.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
cpuCommand.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
cpuCommand.Parameters.Add(new NpgsqlParameter { TypedValue = memoryCutoff });
@@ -668,6 +671,7 @@ public async Task> GetRecommendationsAsync(int serverId,
await using (var command = _dataSource.CreateCommand(RecommendationsStorageTierSql))
{
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = memoryCutoff });
@@ -715,6 +719,7 @@ public async Task> GetRecommendationsAsync(int serverId,
try
{
await using var command = _dataSource.CreateCommand(RecommendationsReservedCapacitySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = memoryCutoff });
@@ -755,6 +760,7 @@ public async Task> GetRecommendationsAsync(int serverId,
private async Task<(int P95Mb, long SampleCount)> ReadMemoryP95Async(int serverId, DateTime cutoff, CancellationToken cancellationToken)
{
await using var command = _dataSource.CreateCommand(RecommendationsMemoryP95Sql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = cutoff });
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs
index c6ff7c541..8043f8c69 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs
@@ -59,6 +59,7 @@ FROM v_database_size_stats
public async Task> GetDatabaseSizeLatestAsync(int serverId, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(DatabaseSizeLatestSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
var items = new List();
@@ -103,6 +104,7 @@ ORDER BY total_mb DESC
public async Task> GetDatabaseSizeSummaryAsync(int serverId, int topN = 10, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(DatabaseSizeSummarySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = topN });
@@ -163,6 +165,7 @@ public async Task> GetIdleDatabasesAsync(int serverId, int
var cutoff = DateTime.UtcNow.AddDays(-daysBack);
await using var command = _dataSource.CreateCommand(IdleDatabasesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
@@ -228,6 +231,7 @@ public async Task> GetTempdbSummaryAsync(int serverId, Ca
var cutoff = DateTime.UtcNow.AddHours(-24);
await using var command = _dataSource.CreateCommand(TempdbSummarySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
@@ -320,6 +324,7 @@ public async Task> GetStorageGrowthAsync(int serverId, Ca
var cutoff30d = now.AddDays(-30);
await using var command = _dataSource.CreateCommand(StorageGrowthSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff7d, DateTimeKind.Unspecified) });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff30d, DateTimeKind.Unspecified) });
@@ -432,6 +437,7 @@ FROM v_index_object_stats ios
await using (var command = _dataSource.CreateCommand(ObjectGrowthSummarySql))
{
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName });
command.Parameters.Add(new NpgsqlParameter { TypedValue = windowStart });
@@ -461,6 +467,7 @@ FROM v_index_object_stats ios
await using (var command = _dataSource.CreateCommand(ObjectGrowthSeriesSql))
{
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName });
command.Parameters.Add(new NpgsqlParameter { TypedValue = windowStart });
@@ -516,6 +523,7 @@ SELECT MAX(collection_time) FROM v_index_object_stats WHERE server_id = $1 AND d
public async Task> GetObjectIndexDetailAsync(int serverId, string databaseName, string schemaName, string tableName, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(ObjectIndexDetailSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName });
command.Parameters.Add(new NpgsqlParameter { TypedValue = schemaName });
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs
index 6988afa59..012b6d440 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs
@@ -102,6 +102,7 @@ LEFT JOIN server_info s ON true
var cutoff = DateTime.UtcNow.AddHours(-24);
await using var command = _dataSource.CreateCommand(UtilizationEfficiencySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
@@ -210,6 +211,7 @@ public async Task> GetProvisioningTrendAsync(int serv
var cutoff = DateTime.UtcNow.AddDays(-7);
await using var command = _dataSource.CreateCommand(ProvisioningTrendSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
@@ -270,6 +272,7 @@ public async Task> GetMemoryGrantEfficiencyAsync(
var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
await using var command = _dataSource.CreateCommand(MemoryGrantEfficiencySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
index 18818efb4..7a18b8ed4 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
@@ -207,6 +207,7 @@ public async Task> GetDatabaseResourceUsageAsync(
coverage.For(TimescaleSupport.QueryStatsDbHourlyView, TimescaleSupport.QueryStatsDbDailyView));
await using var command = _dataSource.CreateCommand(DatabaseResourceUsageSqlFor(tier));
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
@@ -271,6 +272,7 @@ public async Task> GetApplicationConnectionsAsync
var cutoff = DateTime.UtcNow.AddHours(-24);
await using var command = _dataSource.CreateCommand(ApplicationConnectionsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
@@ -365,6 +367,7 @@ public async Task> GetTopResourceConsumersByTotalAs
coverage.For(TimescaleSupport.QueryStatsHourlyView, TimescaleSupport.QueryStatsDailyView));
await using var command = _dataSource.CreateCommand(TopResourceConsumersByTotalSqlFor(tier));
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
command.Parameters.Add(new NpgsqlParameter { TypedValue = topN });
@@ -431,6 +434,7 @@ public async Task> GetTopResourceConsumersByAvgAsyn
coverage.For(TimescaleSupport.QueryStatsHourlyView, TimescaleSupport.QueryStatsDailyView));
await using var command = _dataSource.CreateCommand(TopResourceConsumersByAvgSqlFor(tier));
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
command.Parameters.Add(new NpgsqlParameter { TypedValue = topN });
@@ -521,6 +525,7 @@ public async Task> GetWaitCategorySummaryAsync(int
var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
await using var command = _dataSource.CreateCommand(WaitCategorySummarySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
@@ -577,6 +582,7 @@ that horizon so the query states the range it can actually answer. The UI labels
var (cutoff, _) = RetentionTierRouter.ClampToTextHorizon(now, now.AddHours(-hoursBack));
await using var command = _dataSource.CreateCommand(ExpensiveQueriesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
command.Parameters.Add(new NpgsqlParameter { TypedValue = topN });
@@ -662,6 +668,7 @@ public async Task> GetHighImpactQueriesAsync(int server
var cutoff = DateTime.UtcNow.AddHours(-hoursBack);
await using var command = _dataSource.CreateCommand(HighImpactQueriesSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = DateTime.SpecifyKind(cutoff, DateTimeKind.Unspecified) });
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Findings.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Findings.cs
index bb0d12442..f16520e09 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Findings.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Findings.cs
@@ -95,6 +95,7 @@ FROM analysis_state
try
{
await using var command = _dataSource.CreateCommand(GetAnalysisStateSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
await using var reader = await command.ExecuteReaderAsync();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
index c11c80042..78253b513 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs
@@ -102,6 +102,7 @@ FROM v_deadlocks
public async Task GetFleetTotalsAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(FleetTotalsSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter
{
TypedValue = DateTime.SpecifyKind(startUtc, DateTimeKind.Unspecified),
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs
index 62bf7010b..c4bdf0d36 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs
@@ -114,6 +114,7 @@ public async Task> GetQueryStatsHistoryAsync(
var rows = new List();
await using var command = _dataSource.CreateCommand(QueryStatsHistorySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddItemWindowParameters(command, serverId, databaseName, queryHash, startUtc, endUtc);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
@@ -237,6 +238,7 @@ public async Task> GetProcedureStatsHistory
var rows = new List();
await using var command = _dataSource.CreateCommand(ProcStatsHistorySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName ?? "" });
command.Parameters.Add(new NpgsqlParameter { TypedValue = schemaName ?? "" });
@@ -375,6 +377,7 @@ public async Task> GetQueryStoreHistoryAsync(
var rows = new List();
await using var command = _dataSource.CreateCommand(QueryStoreHistorySql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName ?? "" });
command.Parameters.Add(new NpgsqlParameter { TypedValue = queryId });
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemTimeline.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemTimeline.cs
index e4ceda3dd..68e3d1188 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemTimeline.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemTimeline.cs
@@ -57,6 +57,7 @@ public async Task> GetQueryStatsItemTimelineAsync(
CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(QueryStatsItemTimelineSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
AddItemWindowParameters(command, serverId, databaseName, queryHash, startUtc, endUtc);
return await ReadItemTimelineAsync(command, cancellationToken);
}
@@ -85,6 +86,7 @@ public async Task> GetProcStatsItemTimelineAsync(
CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(ProcStatsItemTimelineSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName ?? "" });
command.Parameters.Add(new NpgsqlParameter { TypedValue = schemaName ?? "" });
@@ -186,6 +188,7 @@ public async Task> GetQueryStoreItemTimelineAsync(
CancellationToken cancellationToken = default)
{
await using var command = _dataSource.CreateCommand(QueryStoreItemTimelineSql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
command.Parameters.Add(new NpgsqlParameter { TypedValue = databaseName ?? "" });
command.Parameters.Add(new NpgsqlParameter { TypedValue = queryId });
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs
index 59d69b468..8aa3a7146 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs
@@ -179,6 +179,7 @@ FROM base
var rows = new List();
await using var command = _dataSource.CreateCommand(sql);
+ command.CommandTimeout = ViewerCommandDeadlines.InteractiveReadSeconds;
command.Parameters.Add(new NpgsqlParameter