Skip to content

Make the compose statement_timeout a store setting instead of a constant (#2357) - #2369

Merged
erikdarlingdata merged 2 commits into
devfrom
feat/2357-compose-timeout
Aug 19, 2026
Merged

erikdarlingdata merged 2 commits into
devfrom
feat/2357-compose-timeout

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #2357. Reported by @carlei1978, who hit it on a 30-day get_query_store_top — a window that, as #2364 established, this tool genuinely has to serve from raw.

Why the constant could not just be raised

The value is applied in role provisioning DDL, deliberately not a versioned migration: a role's statement_timeout has no probeable schema footprint, so tying it to StorageVersion.SchemaVersion would break the viewer's connect-time version gate. An existing install therefore already has 15s baked into its roles, and a new build alone would appear to do nothing.

That is the part that would have made a naive fix look like it worked while changing nothing for the person who reported it.

Why no new machinery was needed

The same DDL is re-run on every managed start — "idempotent + self-healing: re-run every managed start, converging role state". Reading the knob there means a changed value reaches a running store on its next restart.

Ordering is what makes that safe: migrations run before provisioning at startup, so the column exists by the time it is read. The read is defensive anyway — a role-provisioning step that threw over a tuning knob would stop the service starting over something that has a perfectly good default.

Clamped in two places, on purpose

[5, 600], at the config read and in BuildProvisioningSql. The second is not redundant: that method is public, and a caller passing 0 would remove the ceiling entirely — the one outcome the whole design leans on not happening. A LIMIT bounds OUTPUT; a group-by scans and sorts before it; something has to bound WORK.

Default 15 reproduces the constant exactly, so an untouched install is byte-identical.

The rung

V78 carries config_service.compose_statement_timeout_seconds. The recipe is complete: migration + registration, StorageVersion → 78, probe sentinel, reader ordinal, newest-first map arm, and V77's "I am the top" claim demoted in ActivityDrivenPlanFetchStoreTests to the invariants that stay true forever (present, ordered, dense, version tracks the max).

Tests

The rung is registered and tops a dense ladder; the column is added idempotently with 15 as the default; the probe, reader ordinals and map arity agree (arity-driven, so a sentinel that forgot its ordinal fails); a fully migrated store maps to 78 while the previous arm still answers 77; the clamp across eight values including 0 and negatives; the DDL carrying the configured value and clamping independently; and both compose roles getting the ceiling, not just viewer — the mcp role has viewer's read surface and must have its bound.

Verification

Service, viewer and both test projects build clean — 0 errors, 0 warnings.

My doc-hygiene scan caught a stacked <summary> where the new helper had stranded BuildProvisioningSql's doc block; reattached to its own member rather than deleted, since the stranded block was the real documentation. That is the fifth time today, and the scan is now catching it before commit rather than CI catching it after.

It is the hard backstop a composed query can never exceed -- a LIMIT bounds OUTPUT, a
group-by scans and sorts before it, so something has to bound WORK -- and it shipped as a
bare 15s. Fifteen seconds is a judgement about how big a store is and how fast its disk is,
and the product knows neither for anyone else's deployment (#2357).

Raising the constant would not have helped anyone already running. The value is applied in
role PROVISIONING DDL, deliberately not a versioned migration, because a role's
statement_timeout has no probeable schema footprint and tying it to the schema version would
break the viewer's connect-time gate -- so an existing install has the old value baked into
its roles and a new build alone changes nothing.

What makes this deliverable without new machinery is that the same DDL is re-run on every
managed start ("idempotent + self-healing: re-run every managed start, converging role
state"). Reading the knob there means a changed value reaches a running store on its next
restart. Ordering is what makes that safe: migrations run before provisioning at startup, so
the column exists by the time it is read, and the read is defensive anyway -- a
role-provisioning step that threw over a tuning knob would stop the service starting over
something with a perfectly good default.

Clamped to [5,600] in TWO places on purpose. The config read clamps, and BuildProvisioningSql
clamps again because it is public and a caller passing 0 would remove the ceiling entirely --
the one outcome the whole design leans on not happening. Default 15 reproduces the constant
exactly, so an untouched install is byte-identical.

V78 carries the column. The rung recipe is complete: migration + StorageVersion + probe
sentinel + reader ordinal + newest-first arm, with V77's "I am the top" claim demoted to the
invariants that stay true forever.

Reported by @carlei1978, who hit it on a 30-day get_query_store_top -- a window that, as
#2364 established, this tool genuinely has to serve from raw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines 336 to 339
using var command = new NpgsqlCommand(@"
INSERT INTO config_service (id, paused, capture_plans, query_store_backfill_enabled, query_store_text_budget_mb, max_concurrent_sweeps, plan_xml_compression, mcp_enabled, mcp_port, web_enabled, web_port, plan_content_retention_days, config_version, updated_at, updated_by)
INSERT INTO config_service (id, paused, capture_plans, query_store_backfill_enabled, query_store_text_budget_mb, max_concurrent_sweeps, plan_xml_compression, mcp_enabled, mcp_port, web_enabled, web_port, plan_content_retention_days, compose_statement_timeout_seconds, config_version, updated_at, updated_by)
VALUES (1, FALSE, $1, $7, $8, $9, $10, $2, $3, $4, $5, $11, 0, $6, 'seed')
ON CONFLICT (id) DO NOTHING", connection);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column/value count mismatch — this breaks first-contact seeding on every fresh store.

The INSERT column list just grew from 15 to 16 columns (compose_statement_timeout_seconds was added), and a new AddWithValue(ClampComposeStatementTimeoutSeconds(...)) call was added below (making it parameter $12), but the VALUES clause itself was never updated — it still lists only 15 expressions and never references $12:

INSERT INTO config_service (..., plan_content_retention_days, compose_statement_timeout_seconds, config_version, updated_at, updated_by)
VALUES (1, FALSE, $1, $7, $8, $9, $10, $2, $3, $4, $5, $11, 0, $6, 'seed')

16 target columns, 15 expressions. Postgres will reject this with INSERT has more target columns than expressions at execution time — SeedServiceRowAsync (called from SeedIfEmptyAsync on first contact with a new store) will throw on every fresh install/store creation from this point on.

Even positionally, 0 and $6 and 'seed' are one slot short of where they need to land relative to the new column — the fix needs $12 inserted in the compose_statement_timeout_seconds slot, e.g.:

VALUES (1, FALSE, $1, $7, $8, $9, $10, $2, $3, $4, $5, $11, $12, 0, $6, 'seed')

None of the new unit tests in ComposeStatementTimeoutStoreTests.cs exercise this seeding path (they cover the migration, the probe, the clamp, and BuildProvisioningSql) — only a live-Postgres test (SeedIfEmptyAsync via ScratchPostgres) would catch it, which is presumably why this slipped through.

NpgsqlConnection connection, CancellationToken cancellationToken)
{
try
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this catch (Exception) also swallows OperationCanceledException, so a shutdown/cancellation signal arriving during startup provisioning is silently eaten and EnsureProvisionedAsync proceeds with the default 15s instead of unwinding. Elsewhere in this same file (TryDelete) and in DarlingCommandExecutor.cs, the established pattern is catch (Exception ex) when (ex is not OperationCanceledException) specifically so cancellation keeps propagating while other failures are absorbed. Worth using the same guard here for consistency with the rest of the codebase's cancellation handling — otherwise a fast-shutdown request can get delayed by a step that's about to write DDL anyway.

(Also a stray trailing space after catch (Exception) on this line.)

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Reviewed the V78 compose-statement_timeout change (Darling-only; correctly has no Lite counterpart since Lite has no Postgres roles / network compose surface).

Blocking: StoreConfigProvider.SeedServiceRowAsync's INSERT column list grew to 16 columns (compose_statement_timeout_seconds added) but the VALUES clause is still the old 15-expression list and never references the new $12 parameter that's now bound via AddWithValue. Postgres will reject this as INSERT has more target columns than expressions, so first-contact seeding (SeedIfEmptyAsync, called on every fresh store) breaks. Left an inline comment with the fix. None of the new ComposeStatementTimeoutStoreTests exercise this path — it needs a live-Postgres seed test (or at minimum a string-shape assertion like the other new tests) to catch this class of drift going forward.

Minor: DarlingManagedRoles.ReadComposeStatementTimeoutAsync uses a bare catch (Exception), which swallows OperationCanceledException too — inconsistent with the catch (Exception ex) when (ex is not OperationCanceledException) pattern used elsewhere in this file and in DarlingCommandExecutor.cs for the same "defensive but don't eat cancellation" intent.

Everything else checks out: the migration is schema-qualified (config.config_service), the version-probe/reader-ordinal/map-arity three-way agreement holds, the double-clamp in BuildProvisioningSql (public API surface) vs. the config-read clamp is deliberate and consistent (both [5,600], both treat <=0 as "use default"), and the default reproduces the old ComposeLimits.StatementTimeout constant byte-for-byte so untouched installs are unaffected once the seed bug above is fixed.

CI caught three rung tests failing "Expected: 54, Actual: 53" -- V74, V75 and V76 each build
their argument list by hand, appending one literal false per rung added after theirs, so
every new rung breaks all of them. V78 was the fourth to do so.

Padding is now computed from the method's arity, which keeps the intent exactly (later
sentinels FALSE, so each fact still exercises its own arm rather than a newer one) and
means the next rung does not have to edit these files at all.

The failure could not surface locally: the suites target net10.0-windows and the assertions
are runtime, not compile-time, so the projects built clean on macOS while three tests were
already wrong. CI is the only place this shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +337 to 338
INSERT INTO config_service (id, paused, capture_plans, query_store_backfill_enabled, query_store_text_budget_mb, max_concurrent_sweeps, plan_xml_compression, mcp_enabled, mcp_port, web_enabled, web_port, plan_content_retention_days, compose_statement_timeout_seconds, config_version, updated_at, updated_by)
VALUES (1, FALSE, $1, $7, $8, $9, $10, $2, $3, $4, $5, $11, 0, $6, 'seed')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column/value count mismatch — this INSERT will fail on every fresh store seed.

The column list grew from 15 to 16 entries (compose_statement_timeout_seconds was added), but the VALUES tuple was left at 15 — and the new ClampComposeStatementTimeoutSeconds(...) parameter added below (line 355) becomes $12 positionally, yet $12 is never referenced in the SQL text.

INSERT INTO config_service (id, paused, ..., plan_content_retention_days, compose_statement_timeout_seconds, config_version, updated_at, updated_by)
VALUES (1, FALSE, $1, $7, $8, $9, $10, $2, $3, $4, $5, $11, 0, $6, 'seed')

16 target columns, 15 expressions. PostgreSQL will reject this outright with INSERT has more target columns than expressions — so a brand-new store's first-contact seed of config_service breaks entirely, not just for this new column.

The unit tests in this PR are all string/reflection-based against PgMigrations/BuildProvisioningSql/ClampComposeStatementTimeoutSeconds and wouldn't exercise this path; only a live-Postgres integration test (SeedServiceRowAsync via LivePostgresStoreFixture) would catch it, and it doesn't look like one covers this specific insert.

Suggested change
INSERT INTO config_service (id, paused, capture_plans, query_store_backfill_enabled, query_store_text_budget_mb, max_concurrent_sweeps, plan_xml_compression, mcp_enabled, mcp_port, web_enabled, web_port, plan_content_retention_days, compose_statement_timeout_seconds, config_version, updated_at, updated_by)
VALUES (1, FALSE, $1, $7, $8, $9, $10, $2, $3, $4, $5, $11, 0, $6, 'seed')
VALUES (1, FALSE, $1, $7, $8, $9, $10, $2, $3, $4, $5, $11, $12, 0, $6, 'seed')

var value = await command.ExecuteScalarAsync(cancellationToken);
return value is int seconds ? seconds : 15;
}
catch (Exception)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bare catch (Exception) is broader and quieter than the other two catches in this same file:

  • TryHardenRoleCredential catches Exception ex but logs it loud (LogError).
  • TryDelete filters to ex is IOException or UnauthorizedAccessException and logs.
  • The caller of EnsureProvisionedAsync in DarlingWorker.cs explicitly does catch (Exception ex) when (ex is not OperationCanceledException) so shutdown cancellation isn't treated as an ordinary failure.

Here, any exception — including OperationCanceledException from the cancellationToken passed to ExecuteScalarAsync, and any genuine query/connection failure — is silently swallowed with no log line and downgraded to "use the default of 15". Given the stated intent ("a role-provisioning step that threw over a tuning knob would stop the service starting"), degrading to the default is the right call, but doing it silently makes a real problem (bad SQL, permissions, connection drop) invisible, and swallowing cancellation specifically fights the pattern the immediate caller already relies on elsewhere in the startup path.

Worth at least logging at a low level (like the other two helpers here do) and/or excluding OperationCanceledException from the catch.

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Reviewed. One correctness bug that looks release-blocking, plus two smaller cleanup items — inline comments on the specific lines. Summary:

Blocking — broken seed INSERT. StoreConfigProvider.SeedServiceRowAsync's INSERT INTO config_service column list grew to 16 columns (added compose_statement_timeout_seconds) but the VALUES tuple is still 15 expressions, and the new ClampComposeStatementTimeoutSeconds(...) parameter (positionally $12) is never referenced in the SQL text. PostgreSQL will reject this with INSERT has more target columns than expressions — a brand-new store fails to seed config_service on first contact. See inline comment for the fix (insert $12 into the VALUES tuple). This is a live-DB-only failure mode, which is presumably why dotnet build/the reflection-based unit tests in this PR didn't surface it.

Minor — silent catch in ReadComposeStatementTimeoutAsync. Its bare catch (Exception) (no logging, no when filter) is broader and quieter than every other catch in DarlingManagedRoles.cs, and it also swallows OperationCanceledException from the passed-in cancellationToken — inconsistent with DarlingWorker.cs's catch (Exception ex) when (ex is not OperationCanceledException) around the caller. Inline comment has detail.

Not inline-able (file untouched by this PR) — BYO-mode parity gap. Darling/tools/provision-roles.sql still hardcodes ALTER ROLE viewer SET statement_timeout = '15s'; and its comment says to "keep this value in step with ComposeLimits.StatementTimeout in the service." After this PR, managed-mode deployments get a configurable, store-driven timeout, but bring-your-own-Postgres deployments are stuck at a fixed, non-configurable 15s with a comment pointing at a constant that's now otherwise dead code. Given the PR's own stated motivation ("15 seconds ... is a judgement the product cannot make for someone else's deployment"), that argument applies at least as much to BYO operators as to managed ones. Might be worth a follow-up issue if it's intentionally out of scope here.

Also not inline-able — stale test. Darling.Tests/DarlingComposeTests.cs's Provisioning_SetsStatementTimeout_OnViewerAndMcp_NotAdmin still asserts against ComposeLimits.StatementTimeout, which BuildProvisioningSql no longer reads. It only still passes because the method's default (15) happens to match the frozen "15s" constant — it's testing a coincidence rather than the actual configurable path.

Everything else — the V78 migration, the clamp logic (read side and BuildProvisioningSql's independent clamp), the reader-ordinal/probe/map-arity three-way agreement, the padding fix in the other InvokeMap helpers, and the CHANGELOG entry — checks out and matches CONTRIBUTING.md's schema-qualification guidance for Darling's Postgres store.

@erikdarlingdata
erikdarlingdata merged commit f29f2dd into dev Aug 19, 2026
6 checks passed
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