Skip to content

Propagate ComposeStatementTimeoutSeconds through ApplyToConfig, with a family-derived pin (#2917) - #2919

Merged
erikdarlingdata merged 3 commits into
devfrom
fix/2917-applytoconfig-compose-timeout
Sep 4, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
fix/2917-applytoconfig-compose-timeout

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Fixes #2917.

StoreConfigProvider.ApplyToConfig assigns every scalar in StoreConfigView that has a same-named DarlingConfig property — except ComposeStatementTimeoutSeconds, which was seeded to config_service, selected by the view read, clamped to [5,600], mapped into the view, given a doc comment promising the store was authoritative, and then never assigned. Six siblings on the same code path (CapturePlans, QueryStoreBackfillEnabled, QueryStoreTextBudgetMb, PlanContentRetentionDays, MaxConcurrentSweeps, PlanXmlCompression) were all assigned.

This does not change the effective ceiling, and that is the finding

I traced the consumer before writing the one-liner, and it changes what the fix claims. Nothing reads DarlingConfig.ComposeStatementTimeoutSeconds except the seed. DarlingManagedRoles.ReadComposeStatementTimeoutAsync reads config.config_service.compose_statement_timeout_seconds with its own SELECT and BuildProvisioningSql bakes it into ALTER ROLE viewer/mcp SET statement_timeout — a persistent role attribute, written once per process from DarlingWorker.cs:1025. ReloadFromStoreAsync never re-provisions.

So an operator's change was always delivered on the next managed start. That is #2357's design, and StoreConfigView.ComposeStatementTimeoutSeconds's own doc comment has always said so ("a change here reaches an existing install on its next restart"). The reload was never the delivery mechanism, so the missing assignment was not dropping an operator's value on the floor in any observable way.

What it was doing is leaving the held config permanently untruthful: config.ComposeStatementTimeoutSeconds reported the darling.json value for the life of the process while config_service said otherwise, and the DarlingConfig doc comment invited a reader to trust it — "the store is authoritative afterwards, like every other value here". A future consumer reading the held value would have silently gotten the file value. That comment now states the restart-scoped delivery, and says explicitly that the held property is the store's desired value rather than what the live roles are enforcing.

It is also an inconsistency in the seam's contract rather than an instance of a design. #2862's plan-capture cadence deliberately relies on "a property ABSENT from StoreConfigView survives a reload untouched"; this one is present in the view and dropped anyway, which is neither intended behaviour.

The delivery question is filed as #2918, not changed here — a reload observes the new value without re-asserting it onto the roles, unlike every other config_service knob. That issue carries the mechanism, four options, and what should decide between them. It is a behaviour change on a prod path (which connection, how often a pg_authid write is acceptable, the BYO split) and not this PR's call.

The pin is family-derived

Every existing test on this path names its own knob. That is exactly why the seventh was dropped and why the eighth would be, so StoreConfigViewPropagationTests reflects over the real types instead:

  • EveryMatchedScalar_IsPropagatedByApplyToConfig — round-trips every StoreConfigView scalar with a same-named, same-typed, settable DarlingConfig property through ApplyToConfig. Two probe values apiece, each pre-setting the held config to the other one, because several of these defaults are already equal across the two types and a single probe would pass against an assignment hardcoded to that value. Also catches cross-wiring (config.A = view.B).
  • TheUnmatchedScalars_AreExactlyTheOnesWithAWrittenReason — pins the deliberately-unmatched set with reasons: ConfigVersion and Paused are worker-held (_lastConfigVersion, _paused), and the four Mcp*/Web* are nested under config.Mcp/config.Web (assigned, just not name-matched at the top level). A new view scalar therefore either gets covered automatically or fails here until someone writes down why it does not belong.
  • It also asserts the matched set still equals the seven known names, so the class cannot pass vacuously if a rename or type change silently empties the match set — and an unprobeable scalar type throws rather than dropping out of the family.

ApplyToConfig takes no I/O, so both are pure. ComposeStatementTimeoutStoreTests gets the specific pin too, with a comment saying it pins propagation and not delivery.

Verification

Darling.Tests is net10.0-windows and cannot execute on macOS, so I compiled the actual StoreConfigViewPropagationTests.cs into a net10.0 console harness against the real PerformanceMonitor.Darling.Service build (plus a verbatim hand-copy of the compose method, whose own file also references the WPF ViewerDataService). Both files build clean in Darling.Tests itself — 0 errors.

Proven red first. With the one assignment removed:

FAIL  StoreConfigViewPropagationTests.EveryMatchedScalar_IsPropagatedByApplyToConfig
      ... Dropped: ComposeStatementTimeoutSeconds: view=137, config=268;
                   ComposeStatementTimeoutSeconds: view=268, config=137
PASS  StoreConfigViewPropagationTests.TheUnmatchedScalars_AreExactlyTheOnesWithAWrittenReason
ran 2, failed 1

Both probe directions fail, naming the knob. With the assignment restored, all 5 cases pass. CI is the arbiter for the suite as a whole.

It was the one scalar in StoreConfigView with a same-named DarlingConfig
property that ApplyToConfig never assigned, so the held config reported
the darling.json value for the life of the process while config_service
said otherwise. Six siblings on the same code path were assigned.

This does not change the effective ceiling. DarlingManagedRoles reads
config_service.compose_statement_timeout_seconds with its own SELECT and
bakes it into ALTER ROLE viewer/mcp SET statement_timeout at startup
provisioning, so an operator's change was always delivered on the next
managed start -- #2357's design, and what StoreConfigView's own doc
comment already said. What was broken was the held config's truthfulness,
and the DarlingConfig doc comment invited a reader to trust it. That
comment now states the restart-scoped delivery; the delivery question
itself is #2918, not changed here.

The pin is family-derived rather than another per-knob assertion: every
existing test on this path names its own knob, which is why the seventh
was dropped. StoreConfigViewPropagationTests reflects over the real types
and round-trips every matched view scalar, pinning the deliberately
unmatched ones as a set with written reasons.
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed. Verified the core claims directly against the source rather than taking them on faith:

  • config.ComposeStatementTimeoutSeconds (the DarlingConfig property) is otherwise only ever written by the store-seed path (StoreConfigProvider.cs:930) — nothing else reads it before this PR adds the assignment at line 1553, confirming it really was dead.
  • DarlingManagedRoles.ReadComposeStatementTimeoutAsync does its own independent SELECT compose_statement_timeout_seconds FROM config.config_service (DarlingManagedRoles.cs:317) rather than consulting the held config, which backs up the "delivery is restart-scoped, this only fixes truthfulness" framing — the fix doesn't change effective behavior, just what the held config reports.
  • Checked the new StoreConfigViewPropagationTests reflection logic against the real StoreConfigView/DarlingConfig definitions: the 7 KnownMatched names and 6 ExpectedUnmatched names together account for all 13 scalar properties on StoreConfigView (IsPrimitive || string), including PlanXmlCompression (a string) being picked up correctly by the type filter. No gaps.
  • No Lite/Darling parity concern — config_service/managed-role provisioning is a Darling-only concept; Lite has no equivalent seam (confirmed via grep, no hits).
  • No T-SQL in this diff, so the CONTRIBUTING.md T-SQL style section doesn't apply here.

One-line production fix, well-scoped regression tests, doc comment correctly updated to stop overpromising "authoritative." No issues found.

@@ -91,6 +91,14 @@ public sealed class DarlingConfig
/// composed query can never exceed (#2357). Seeds <c>config_service.compose_statement_timeout_seconds</c>;
/// the store is authoritative afterwards, like every other value here.

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 doc inconsistency: "the store is authoritative afterwards, like every other value here" reads as claiming this knob behaves the same as its siblings, but the paragraph you just added three lines below says the opposite — its delivery is restart-scoped "unlike the other store-backed knobs." A reader skimming just this first sentence gets the wrong mental model before reaching the correction. Consider softening line 92 (e.g. "the store is authoritative for the held value afterwards") so it doesn't imply live-authority parity with the other knobs.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correct, and it was my error rather than a wording preference — I appended the correcting paragraph instead of editing the sentence it corrects, so the summary now says two opposite things three lines apart. The first sentence is the one a reader skims, so that is the one that had to change.

Fixed at the source in 1b79c55: "the store is authoritative for this property afterwards, but NOT on the same terms as its siblings." That hands the reader straight to the paragraph below instead of contradicting it.

private static IEnumerable<PropertyInfo> ScalarViewProperties() =>
typeof(StoreConfigView)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.PropertyType.IsPrimitive || p.PropertyType == typeof(string))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ScalarViewProperties() only picks up IsPrimitive || string, so a future StoreConfigView scalar of a type that's neither (e.g. an enum, decimal, DateTime, Guid) is invisible to both tests here: it won't show up in EveryMatchedScalar_IsPropagatedByApplyToConfig (silently unchecked for propagation) and it won't fail TheUnmatchedScalars_AreExactlyTheOnesWithAWrittenReason either, since that test also starts from ScalarViewProperties(). That's exactly the "eighth" scenario this class's own doc comment (line 28: "covers knobs that do not exist yet") claims to guard against — ProbeValues's throw-on-unknown-type only fires for types that pass this filter in the first place, so it never triggers for a non-primitive/non-string scalar.

Not a problem for anything shipped today (all current scalars are bool/int/long/double/string), but worth either broadening the filter to catch any non-collection, non-reference-config scalar, or noting the limitation explicitly in the class doc so it isn't assumed to be exhaustive.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a real hole in the pin and the sharper of the two findings — thank you. Your reasoning is exactly right, including the part that makes it nasty: ProbeValues's throw-on-unknown-type only ever sees types the filter already admitted, so it could never have fired for an enum/decimal/DateTime/Guid knob. The safety net was downstream of the gap it was supposed to catch.

I took the broaden option rather than the document-the-limitation one, since the class doc claiming "covers knobs that do not exist yet" while silently excluding the most likely next type is worse than having no pin. In 1b79c55:

  • ScalarViewProperties now filters on IsValueType || string. Everything excluded is a reference type — the four sub-config objects (swapped wholesale by reference) and the two collections — so the rule is now stateable rather than incidental. Verified the widened filter admits exactly the same 13 properties today, so nothing about current coverage changed; only the future's.
  • Enums are handled generically (two distinct declared members) rather than left to throw, because a store-backed knob is far likelier to arrive as an enum than as a Guid. A one-member enum throws, since it cannot distinguish an assignment from an unchanged default — that would be a vacuous pass, which is the failure mode this class exists to avoid.
  • A nullable value type is now admitted and throws until someone gives it probe values, which is the intended outcome.
  • The enum arm and the throw are new machinery that no shipped StoreConfigView scalar exercises, so they would have rotted untested. ProbeValues_CoversEnums_AndRefusesWhatItCannotProbe covers both, and asserts IsPrimitive is false for all five of the types you named — the hole itself is now pinned, not just patched.

Your first note is fixed too.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed. This is a scoped, well-verified fix — the one-line config.ComposeStatementTimeoutSeconds = view.ComposeStatementTimeoutSeconds; addition in StoreConfigProvider.ApplyToConfig is correct and matches the pattern of its six siblings, and the PR is careful to distinguish propagation (fixed here) from delivery (correctly left as #2918, not smuggled into this diff).

Scope check: StoreConfigProvider, DarlingConfig, and StoreConfigView are Darling-only (Darling's PostgreSQL-store/managed-roles architecture) — no Lite counterpart exists, so this isn't a Lite/Darling parity gap. No T-SQL touched.

New test design (StoreConfigViewPropagationTests) is a genuine improvement over "one assertion per knob" — reflecting over the real types so a future dropped assignment fails loudly, with a family-membership check (KnownMatched) so the pin can't silently go vacuous. Verified the class's proven-red claim by inspection; the two-probe-per-property design (pre-setting the config to the other probe value) correctly rules out false negatives from a shared default value.

Left two inline notes, both minor / non-blocking:

  1. A doc-comment wording inconsistency in DarlingConfig.cs (the "like every other value here" line now reads as contradicting the very next paragraph, which says delivery is unlike the other knobs).
  2. A narrow gap in the new test's type filter (IsPrimitive || string) that would silently exclude a future non-primitive scalar type (enum, decimal, DateTime, ...) from both propagation and unmatched-set coverage — worth a note in the class doc if not worth broadening now, since current scalars are all covered.

No correctness, security, or performance concerns beyond those two nits.

…ilter

Both findings were correct.

1. The doc comment kept "the store is authoritative afterwards, like
   every other value here" and then contradicted it three lines later
   with "unlike the other store-backed knobs". I appended a correction
   instead of editing the sentence it corrected. Fixed at the source.

2. ScalarViewProperties filtered on IsPrimitive || string, which is
   false for enum, decimal, DateTime, Guid and TimeSpan. A future knob
   of any of those types would have been invisible to BOTH tests --
   unprobed for propagation, and absent from the unmatched set that is
   supposed to force a written reason -- and ProbeValues' throw could
   never fire for it, since it only sees types the filter admitted. So
   the class doc's "covers knobs that do not exist yet" overclaimed.

   Widened to IsValueType || string, which is what makes the throw
   load-bearing. Enums are handled generically rather than left to
   throw (two distinct declared members; a one-member enum throws,
   since it cannot distinguish an assignment from an unchanged
   default). Verified the widened filter admits exactly the same 13
   properties today, so current coverage is unchanged.

   The enum arm and the throw are new machinery that no shipped
   StoreConfigView scalar exercises, so they get their own test rather
   than shipping untested.
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Reviewed. This is a clean, well-scoped one-line fix — the missing config.ComposeStatementTimeoutSeconds = view.ComposeStatementTimeoutSeconds; assignment is the only change to production code, and it's consistent with its six siblings in ApplyToConfig.

Verified against the code (not just the PR description):

  • DarlingManagedRoles.ReadComposeStatementTimeoutAsync (DarlingManagedRoles.cs:311) does its own independent SELECT compose_statement_timeout_seconds FROM config.config_service and feeds it into BuildProvisioningSql's ALTER ROLE ... SET statement_timeout, confirming the PR's central claim that this fix changes truthfulness of the held config, not the effective timeout ceiling — delivery genuinely is restart-scoped and untouched here.
  • ApplyToConfig is called from both the initial config load and the periodic reload path in DarlingWorker.cs (lines 1168, 2457), so the bug was live on every reload, not just a theoretical path.
  • The updated DarlingConfig.cs doc comment accurately reflects the corrected semantics (store-desired vs. roles-enforced).

No Lite counterpart exists for this machinery (managed Postgres roles / statement_timeout composition is Darling-only), so there's no parity gap to flag.

Test coverage is solid: a direct regression test (ComposeStatementTimeoutStoreTests.AReload_CarriesTheKnobIntoTheHeldConfig) plus a reflection-based family pin (StoreConfigViewPropagationTests) that round-trips every StoreConfigView scalar against DarlingConfig, which should catch the next dropped knob automatically rather than requiring another one-off test. The widened "scalar" filter (IsValueType || string instead of IsPrimitive || string) closing the enum/decimal/DateTime/Guid/TimeSpan blind spot is a good catch and is itself exercised by ProbeValues_CoversEnums_AndRefusesWhatItCannotProbe.

No correctness, security, or performance issues found. No T-SQL in this diff.

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