Skip to content

Custom Views and custom alert rules scope server reads by server_id, not server name (#5525) - #5537

Merged
erikdarlingdata merged 8 commits into
devfrom
fix/5525-compose-server-scope-by-id
Oct 8, 2026
Merged

erikdarlingdata merged 8 commits into
devfrom
fix/5525-compose-server-scope-by-id

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Fixes #5525

Why

The Custom Views and custom alert rule compiler scoped every fact read with server_name = ANY($n). server_name is neither the compression segment column nor the leading index column (server_id is both), so every compressed chunk was read in full and the filter threw most of it away. Every Custom View panel, run_custom_view_panel call, custom alert rule pass and alert notebook chart cell runs this read.

What changes

  • ComposeCompiler.ServerScope is the one place the scope is spelled now: f.server_id = ANY(ARRAY(SELECT reg.server_id FROM collect.servers AS reg WHERE reg.server_name = ANY($n))). The text comes from ServerScopeSql.Predicate, which the hourly-edges count guard shares. The $n name list keeps its position. When the run has unregistered names (below) one more bound text[], $u, follows it. A run with none binds nothing extra, so its parameter list is unchanged.
  • It is used at every emitter that reads a server_id-segmented table: the raw fact read (every source table, including the hourly-raw-edges union and every rollup route, scoped once on the f alias), the Query Store raw dedupe and its partition restriction, the procedure_stats module CTE, the annotation query, the server clock read, and now the hourly-raw-edges count guard (IntervalRollupCountGuard.SqlFor, both its ledger and rollup sides).
  • collect.module_map (keyed server_name, sql_handle, no server_id, small) stays scoped by name.
  • Alert notebook charts (CPU, poison wait and the rest) are compose panels run through the same runner, so they take the new scope with no template change.
  • The InitPlan form was kept in SQL. The planner pushes it, so no ids are resolved in C# before the read.

Semantics

A scoped name means the server or servers the registry (collect.servers) holds under that name now, with all of their rows.

  • A fact row's server_name can differ from collect.servers.server_name for its server_id. server_id is a hash of host and database, so an edit that renames a server keeps the id. The registry row's name is rewritten on the next connect (DarlingObservability.UpsertServerSql, ON CONFLICT (server_id) DO UPDATE SET server_name), and fact rows keep the name they were written under. A removed server keeps its registry row (it is only flagged disabled, never deleted).
  • Behavior change: a scope that names a server's current registry name now includes that server's whole history, including rows written under an earlier name. The old filter matched only the exact spelling on each row. Those rows keep their earlier name, so a panel grouped by server shows a separate series for them. The Query Store wide-table route already resolves through the registry and shows the current name, so on a renamed server the two Query Store routes label the same rows differently.
  • A name reused by a new server after a rename means the new server: the old server's rows stored under that name are no longer in that name's scope (they are in the scope of the old server's current name). This is the one case where a scope returns fewer rows than before.
  • A name held by two registry rows means both servers, with every row of each. collect.servers has only its primary key on server_id; there is no unique server_name index, so a name can be held twice.
  • A scoped name with no registry row (Custom View names and alert rule scope.servers are free text) resolves to no id. The runners (RunComposedPanelAsync and CustomAlertEvaluator) call ComposeServerScope.FindUnregisteredAsync first, one read of the small registry table. Names with no registry row go in ComposeRunContext.UnregisteredServers, and the compiler adds OR server_name = ANY($u) for those names only, at every scope in the statement (a test counts them). When every name is registered (the normal case) the SQL and parameters carry nothing extra. If the lookup faults, all names are treated as unregistered and the fault is logged as a warning, so a fault costs the old read's price and never loses a server. The alert sweep looks the names up once per server, not once per rule.
  • The lookup runs outside the read's transaction. If a re-connect renames a registry row between the lookup and the read, that one run can miss the rows stored under the old name; the next run is right.
  • Known limit: the module_map arm (older procedure handles on the hourly-raw-edges route) is scoped by the scoped names, since the map has no server id and nothing records a server's earlier names. A renamed server's pre-rename handles that only the map holds read (ad hoc) on that route, while the raw route labels the same rows correctly. Totals improve against before (those rows were missing entirely); only the labels differ.

Measurements

Store numbers are the #5508 audit's reads, run with a hand-written server_id filter, not the shipped InitPlan SQL (cold / warm ms, by name vs by server_id):

Read By name By server_id
raw fact read, query_stats, 1 d 36,434 / 3,148 265 / 183
raw fact read, wait_stats, 30 d 46,952 / 2,291 852 / 814
module CTE over procedure_stats, 1 d 20,281 / 1,802 454 / 388
hourly-raw edges, raw edge arm over query_stats, 12 h 763 / 178 15 / 9
alert notebook poison wait, 24 h 409 / 394 36 / 32
alert notebook CPU, 24 h 60 / 7.8 12.5 / 1.9
pg_statement_stats, 30 d (50 PostgreSQL targets) 9,243 / 1,968 1,002 / 883
pg_wait_stats, 30 d 1,293 / 424 255 / 243

By name, the audit's 30 day statement read touched 871 K buffers and removed 124 M rows; with the hand-written id filter 122 K buffers and none removed.

Rig numbers for the shipped InitPlan form (PostgreSQL 18, TimescaleDB 2.30.1, 50 servers, segmentby server_id, a 3-server scope, 29 day window; EXPLAIN (ANALYZE, BUFFERS)):

Table shape Form Buffers Rows removed by filter
31 one-day chunks, 28 compressed, 1.7 M rows by name 5,236 1,546,354
same InitPlan (shipped) 1,241 2,733
same literal int[] ids 1,240 2,636
one 60-day compressed chunk, 2.2 M rows by name 1,676 5,924,375
same InitPlan (shipped) 116 2,175
same literal int[] ids 113 2,175
  • InitPlan and literal ids plan alike. On the single large compressed chunk both use the server_id segment index; by name it is a Parallel Seq Scan of every compressed row. On the many-chunk table the id list shows up as server_id = ANY((InitPlan 1).col1) in each compressed chunk's scan filter.
  • The qual also pushes through the Query Store dedupe window (partition by server_id, server_name, ...): 418 rows removed vs 279,730 by name, on the rig's stand-in table.

Test plan

  • ComposeServerScopeByIdTests (unit): no compiled statement filters a fact on server_name = ANY alone, over six emitters; the unregistered arm is asserted on EVERY by-id scope of EVERY compiled statement (count of scopes equals count of armed scopes); source pins that the panel runner and the alert sweep each look the names up before compiling and hand the answer on; the count guard's SQL scopes both sides by the shared predicate; fleet-wide runs carry no scope.
  • ComposeServerScopeByIdLiveTests (live, rig): by-id and by-name return identical rows for a two-of-three-server scope; a renamed server keeps its whole history by id; an unregistered name returns its rows through RunComposedPanelAsync and through EvaluateScalarNowAsync, and nothing when the runner's lookup is removed.
  • IntervalRollupCountGuardLiveTests: a renamed server whose rollup hour is short under its old name is now caught by the guard (it passed before); the ghost-server case needs the unregistered arm.
  • ComposeHourlyRawEdgesGuardLiveTests: the hourly-raw-edges statement scoped by id returns the rows it returned scoped by name, and the runner's payload.
  • RED-plants on the rig: the guard put back to server_name = ANY($3) fails both guard live tests; each runner's lookup deleted fails only that runner's own live test, and the alert sweep's per-server lookup deleted fails the source pin; ServerScope put back to the old shape (first round) failed 6 of 7 new tests.
  • Existing pins updated: DarlingComposeTests, ComposeAnnotationServerClockTests, ServerLocalReadFrameDisciplineTests, ComposeParameterCoverageTests, ComposeHourlyEdgesRunnerTests. Live compose tests and TuningReaderBudgetLiveTests register their fixture server in collect.servers, because a scope resolves its names through the registry.
  • Ran on the rig this round: *Compose*, *CustomAlert*, *IntervalRollupCountGuard*, *QueryStats*, *ReadScope*, *ServerLocal*, *TuningReader*, *AlertNotebook*, *RollupWindowEndBound*, *QueryStoreIntervalWide*, *DailyStitch*, *DailySummaryAndCompose*, then *Census*, *Ratchet*, *Hygiene*, *Pin*, *Guard*, *Scan*, RepoFileAdoptionTests, FleetIdentifierScrubTests, RegisteredServerSettingDriftTests, TsqlConventionGuardTests: 0 failed (a stale pin in ComposeHourlyEdgesRunnerTests and a stacked doc comment in my own new test were fixed on the way).
  • Full suite not run on this laptop; CI decides.
  • Big-store plan for the shipped SQL not run here (no access); the rig plan above stands in.
  • The Query Store dedupe, module CTE, annotation and clock-read statements are pinned on text only; the live equivalence cases cover the raw read and the hourly-raw-edges route.

Review round 1

  • The hourly-raw-edges count guard now takes the same scope as the read, from one shared predicate, with the unregistered-name arm when the run has one. A server renamed after its early hours were written is now compared hour by hour under its old name too. The FULL JOIN is unchanged.
  • The two runner lookups are now pinned by tests that fail if either call is removed, and the every-emitter test checks every scope in every statement.
  • The semantics above are now stated in the code comment and here, and the false claim of a unique server_name index is gone.
  • The alert sweep looks the names up once per server; a failed lookup is logged as a warning; the rename race is noted on the lookup; the PR text about bound parameters is corrected; a missing space in the annotation compiler is fixed.
  • The module-map label limit stays: the map holds no server id and nothing records a server's earlier names, so its scope cannot follow a rename without reading the facts. It is stated above.

Changelog

SECTION: Fixed
ENTRY:

…t server_name (#5525)

The composer filtered every raw fact read on server_name = ANY(\). server_name is neither the compression segment column nor the leading index column, so every compressed chunk was read in full. The scope is now server_id = ANY(ARRAY(SELECT server_id FROM collect.servers WHERE server_name = ANY(\))), an InitPlan the planner pushes into each chunk's segment filter. A scoped name with no registry row keeps matching on the stored server_name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_015ZGNj3cTaf9E9jYjdAa9vA
…takes the same scope as the read, the runner seams are pinned, the semantics are stated (#5525)
…ered name keeps matching what it matched before (#5525)
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 8, 2026 05:49
@erikdarlingdata
erikdarlingdata merged commit 750b09c into dev Oct 8, 2026
21 of 22 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/5525-compose-server-scope-by-id branch October 8, 2026 08:45
erikdarlingdata added a commit that referenced this pull request Oct 8, 2026
Adds the changelog entries for the 3.10.0 PRs that merged after the first splice: #5528, #5532, #5537, #5542, #5543, #5552 and #5553. Each full entry goes into docs/changelog/3.10.md and its title-only line into the CHANGELOG.md index. The archive census row for 3.10.0 moves from 257 to 265 entries.

Changelog entries for the 3.10.0 PRs merged after the first splice.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant