Skip to content
This repository was archived by the owner on Aug 3, 2026. It is now read-only.

Commit 6d4e7e3

Browse files
committed
fix: config cascade applies on run_app path
run_app never called config::setup(), so the global cascade was empty and every from_cascade() subsystem (governor, worker pool, batch engine, scaling) silently took defaults regardless of the app's config -- the recurring "set a cascade value, nothing happens". Now run_app populates the cascade (guarded by try_get().is_none()), a --config file is ingested via the new ConfigOptions.config_file (the dormant to_config_options is fixed), and present-but-malformed sections WARN instead of silently defaulting. ServiceRuntime logs which platform sections were found vs defaulted. BEHAVIOUR: apps' worker_pool / self_regulation / batch_processing / scaling sections are now HONOURED (were silently defaulted) -- review those sections on the bump. Also adds ScalingSignalsCell::set_custom + a custom.<name> CEL namespace so apps push DOMAIN scaling signals (cloud-API backlog, ClickHouse backlog) for Tier-3 pressures; unknown-at-load custom refs warn + keep (runtime fallback), unknown top-level idents still hard-reject. Tests: config_cascade_honoured, run_app_populates_config, from_cascade_deser_error, + custom-signal end-to-end. Publish: true
1 parent d8993bb commit 6d4e7e3

16 files changed

Lines changed: 932 additions & 43 deletions

‎docs/MIGRATIONS.md‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,101 @@ six core DFE apps migrate in lockstep.
1010

1111
---
1212

13+
## 2.8.11 -- config cascade ACTUALLY applies on the run_app path
14+
15+
A `fix:`. Before this release, [`run_app`](../src/cli/app.rs) built the
16+
`ServiceRuntime` (governor, worker pool, batch engine, scaling) entirely
17+
from `*::from_cascade()` WITHOUT ever calling `config::setup()`, so the
18+
global config singleton was empty and every platform subsystem silently
19+
took its hard-coded defaults regardless of the app's config file. A
20+
`--config <file>` was also never ingested (the cascade discovers config
21+
in DIRECTORIES; a file path pushed into the directory list is never
22+
found). Both are now fixed.
23+
24+
### Platform sections are now HONOURED (BEHAVIOUR CHANGE)
25+
26+
Apps' platform sections in their config file -- `worker_pool`,
27+
`self_regulation`, `batch_processing`, `scaling`, `expression`, and any
28+
other `from_cascade()` section -- are now APPLIED. They were silently
29+
DEFAULTED before.
30+
31+
- **Action required on the bump:** REVIEW these sections in every app's
32+
config. Any stale or unintended value that was harmlessly ignored will
33+
now take effect. e.g. a leftover `worker_pool: { min_threads: 64 }`
34+
that previously did nothing will now actually size the pool.
35+
- No API removal, no signature change -- existing apps recompile
36+
unchanged. The change is purely that config you set now does what it
37+
says.
38+
39+
### `run_app` populates the cascade (guarded)
40+
41+
The `Run` arm of `run_app` now calls `config::setup()` with the app's
42+
`env_prefix`, `app_name`, and the `--config` file BEFORE `load_config()`
43+
and the `ServiceRuntime` build. Guarded by `config::try_get().is_none()`
44+
so apps that already call `setup()` / `setup_async()` themselves (e.g.
45+
for the Postgres config layer) are NOT double-initialised.
46+
47+
### New `ConfigOptions.config_file` (additive)
48+
49+
`ConfigOptions` gains `config_file: Option<PathBuf>` (default `None`).
50+
When set, the named YAML file merges ABOVE the discovered
51+
`defaults`/`settings`/`settings.{env}` files but BELOW the PostgreSQL
52+
layer (async path) and BELOW environment variables -- an explicit
53+
override that still yields to ENV. `CommonArgs::to_config_options` now
54+
sets this from `--config` instead of (wrongly) pushing it into
55+
`config_paths`.
56+
57+
### Observable startup + deser WARN
58+
59+
- `ServiceRuntime::build` now emits one startup `tracing::info!` line
60+
summarising which platform sections were found in the cascade vs
61+
defaulted (`cascade_initialised`, `self_regulation`, `worker_pool`,
62+
`batch_processing`, `scaling`, `expression`). The previously-silent
63+
"defaulted everything" failure is now visible.
64+
- New `Config::unmarshal_key_or_warn` /
65+
`unmarshal_key_registered_or_warn`: a config section that is PRESENT
66+
but malformed (typo / type mismatch) now logs a `tracing::warn!` and
67+
falls back to the default, instead of silently swallowing the error.
68+
Wired into the four `ServiceRuntime`-build readers: governor,
69+
worker-pool, batch-engine, scaling. An ABSENT key is unchanged --
70+
still silent default (the default-ON governor relies on this).
71+
(`ScalingEngineConfig::from_cascade` shares the `scaling` key with
72+
`ScalingPressureConfig`, which already warns for that section, so it
73+
was left as-is to avoid a double WARN.)
74+
75+
### Custom / domain scaling signals (additive)
76+
77+
The scaling engine's CEL pressures could previously reference ONLY the
78+
8 FIXED transport signals. Apps can now push DOMAIN signals so a
79+
`scaling.pressures` expression can scale on them -- essential for
80+
non-rustlib-inbound apps (e.g. the fetcher, whose smart default is
81+
otherwise CPU-only).
82+
83+
- **New `ScalingSignalsCell::set_custom(&self, name: &str, value: f64)`**
84+
-- insert/overwrite a named domain signal (e.g. cloud-API pending
85+
fetch backlog / API throttle, ClickHouse insert backlog). Pushed from
86+
the app at runtime; no setter per signal.
87+
- **New `TransportSignals.custom: BTreeMap<String, f64>`** (default
88+
empty), populated by `ScalingSignalsCell::snapshot()`. Existing
89+
`TransportSignals { .. }` struct literals that enumerate all fields
90+
without `..Default::default()` must add the `custom` field (or switch
91+
to `..Default::default()`); all setters and the typed fields are
92+
unchanged.
93+
- **CEL surface:** domain signals are exposed under a DEDICATED
94+
`custom.<name>` map, SEPARATE from the strict fixed-signal `metrics`
95+
map. Scale on them like `custom.clickhouse_backlog / params.ch_target`.
96+
- **Validation contract (no API change, behaviour note):** custom names
97+
are unknown at config-load, so the load-time dry-run cannot
98+
pre-populate them. Syntax errors and unknown TOP-LEVEL identifiers are
99+
still HARD-rejected at load. A reference to a `custom.<name>` (or any
100+
map key absent at load) is downgraded to a load `warn!` and KEPT; the
101+
runtime guard falls back to last-good / smart-default if it errors at
102+
tick time. Startup never fails for a `custom.*` reference.
103+
- No API removal, no signature change -- apps recompile unchanged unless
104+
they hit the `TransportSignals` struct-literal note above.
105+
106+
---
107+
13108
## 2.8.10 -- horizontal scaling pressure + metrics overhaul
14109

15110
A `fix:` (the existing scaling was not fit for purpose). Adds the

‎docs/deployment/KEDA.md‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,9 @@ trigger, and the job of `ScalingEngine`.
149149
`{ns}_scaling_pressure{name="default"}` = `max(CPU, inbound)` gated by
150150
circuit-open, on a periodic tick.
151151
- **Tier 3 -- your correlated composite (config):** define CEL
152-
expression(s) over the local context.
152+
expression(s) over the local context, including app-pushed DOMAIN
153+
signals (`custom.<name>`, see Wiring) -- e.g. a fetcher's cloud-API
154+
pending-fetch backlog, a loader's ClickHouse insert backlog.
153155

154156
### The smart default
155157

@@ -181,11 +183,21 @@ scaling:
181183
182184
CEL context: top-level `cpu_utilisation_ratio`, `circuit_open`,
183185
`transport_inbound_pressure_ratio`, `transport_outbound_pressure_ratio`,
184-
`memory_ratio`; `params.<key>`; `metrics.<signal>`
185-
(`kafka_assigned_lag`, `inflight`, `shed_rate`, ...). CEL has no `max()`
186-
-- use a ternary. Expressions are validated at LOAD (syntax + an
187-
unknown-identifier dry-run); a broken expression falls back to the smart
188-
default with a loud, operator-facing error rather than failing startup.
186+
`memory_ratio`; `params.<key>`; the FIXED transport `metrics.<signal>`
187+
(`kafka_assigned_lag`, `inflight`, `shed_rate`, ...); and app-pushed
188+
DOMAIN signals under a SEPARATE `custom.<name>` map (rustlib 2.8.11).
189+
CEL has no `max()` -- use a ternary.
190+
191+
Validation at LOAD: the expression is COMPILED (syntax errors and
192+
unknown TOP-LEVEL identifiers are hard-rejected -> fall back to the
193+
smart default with a loud, operator-facing error). A reference to a
194+
`custom.<name>` (or any map key not present at load) is NOT a hard
195+
error -- those domain signals are pushed at RUNTIME, so a load-time
196+
dry-run cannot pre-populate them. Such a reference is kept with a load
197+
`warn!`; if it still errors at tick time (signal never pushed), that
198+
single pressure falls back to its last-good value, then to the smart
199+
default. The runtime guard is the safety net -- startup never fails for
200+
a `custom.*` reference.
189201

190202
### Multi-output
191203

@@ -224,9 +236,36 @@ runtime.scaling_signals.set_kafka_assigned_lag(lag as f64);
224236
runtime.scaling_signals.set_circuit_open(breaker.is_open());
225237
```
226238

239+
For the 8 FIXED transport signals there is a typed setter (above). For a
240+
DOMAIN signal -- anything rustlib cannot know (a cloud-API backlog, an
241+
upstream throttle, a downstream insert backlog) -- push it by name with
242+
`set_custom` and reference it in a pressure as `custom.<name>`:
243+
244+
```rust
245+
// fetcher: cloud-API pending-fetch backlog + provider throttle
246+
runtime.scaling_signals.set_custom("pending_fetch", queue.len() as f64);
247+
runtime.scaling_signals.set_custom("api_throttle", throttle_ratio);
248+
```
249+
250+
```yaml
251+
scaling:
252+
params:
253+
fetch_target: 500 # PER-POD backlog one pod tolerates
254+
pressures:
255+
- name: fetch
256+
# CEL has no max() -- ternary picks the worse of backlog vs throttle
257+
expression: >
258+
custom.pending_fetch / params.fetch_target > custom.api_throttle
259+
? custom.pending_fetch / params.fetch_target
260+
: custom.api_throttle
261+
```
262+
227263
If your inbound is NOT a rustlib transport (e.g. cloud-API polling), the
228264
compound inbound is 0 and the default reduces to CPU-only -- add a
229-
DOMAIN term (Tier 3) for your real backlog signal.
265+
DOMAIN term (Tier 3) via `set_custom` for your real backlog signal. A
266+
loader with a ClickHouse sink is the same story: push the insert backlog
267+
with `set_custom("clickhouse_backlog", n)` and scale on
268+
`custom.clickhouse_backlog / params.ch_target`.
230269

231270
### Emit your scaling signals as metrics
232271

‎src/cli/app.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,30 @@ pub async fn run_app<A: DfeApp>(app: A) -> Result<(), CliError> {
216216
"starting service"
217217
);
218218

219+
// Populate the global config cascade BEFORE load_config + the
220+
// ServiceRuntime build, so every `from_cascade()` subsystem
221+
// (governor, worker pool, batch engine, scaling) reads the app's
222+
// real config instead of silently defaulting. Guarded by the
223+
// try_get() check so apps that already call config::setup()
224+
// themselves (e.g. those needing setup_async for Postgres) are not
225+
// double-initialised -- setup() returns Err(AlreadyInitialised)
226+
// otherwise.
227+
#[cfg(feature = "config")]
228+
if crate::config::try_get().is_none() {
229+
let opts = crate::config::ConfigOptions {
230+
env_prefix: app.env_prefix().to_string(),
231+
app_name: Some(app.name().to_string()),
232+
config_file: args.config.as_deref().map(std::path::PathBuf::from),
233+
..Default::default()
234+
};
235+
if let Err(e) = crate::config::setup(opts) {
236+
tracing::warn!(
237+
error = %e,
238+
"config cascade setup failed; from_cascade subsystems will use defaults"
239+
);
240+
}
241+
}
242+
219243
let config_path = args.config.as_deref();
220244
let config = app.load_config(config_path)?;
221245

‎src/cli/args.rs‎

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,20 @@ impl CommonArgs {
109109
}
110110

111111
/// Convert to `ConfigOptions` for use with `config::setup()`.
112+
///
113+
/// The `--config <path>` flag names a FILE, so it populates
114+
/// [`ConfigOptions::config_file`](crate::config::ConfigOptions::config_file)
115+
/// -- NOT `config_paths`, which is a list of DIRECTORIES to search for the
116+
/// standard base names. (Before 2.8.11 this wrongly pushed the file path
117+
/// into `config_paths`, where directory discovery never found it.)
112118
#[cfg(feature = "config")]
113119
#[must_use]
114120
pub fn to_config_options(&self, env_prefix: &str) -> crate::config::ConfigOptions {
115-
let mut opts = crate::config::ConfigOptions {
121+
crate::config::ConfigOptions {
116122
env_prefix: env_prefix.to_string(),
123+
config_file: self.config.as_deref().map(std::path::PathBuf::from),
117124
..Default::default()
118-
};
119-
if let Some(ref path) = self.config {
120-
opts.config_paths.push(path.into());
121125
}
122-
opts
123126
}
124127
}
125128

@@ -166,6 +169,43 @@ mod tests {
166169
assert_eq!(args.effective_log_level(), "error");
167170
}
168171

172+
#[cfg(feature = "config")]
173+
#[test]
174+
fn test_to_config_options_sets_config_file_not_paths() {
175+
let args = CommonArgs {
176+
config: Some("/etc/svc/config.yaml".to_string()),
177+
log_level: "info".to_string(),
178+
log_format: "auto".to_string(),
179+
metrics_addr: "0.0.0.0:9090".to_string(),
180+
verbose: false,
181+
quiet: false,
182+
};
183+
let opts = args.to_config_options("MY_SVC");
184+
assert_eq!(opts.env_prefix, "MY_SVC");
185+
// The file path lands in config_file, NOT config_paths (the 2.8.11 fix).
186+
assert_eq!(
187+
opts.config_file,
188+
Some(std::path::PathBuf::from("/etc/svc/config.yaml"))
189+
);
190+
assert!(opts.config_paths.is_empty());
191+
}
192+
193+
#[cfg(feature = "config")]
194+
#[test]
195+
fn test_to_config_options_no_config_file_when_absent() {
196+
let args = CommonArgs {
197+
config: None,
198+
log_level: "info".to_string(),
199+
log_format: "auto".to_string(),
200+
metrics_addr: "0.0.0.0:9090".to_string(),
201+
verbose: false,
202+
quiet: false,
203+
};
204+
let opts = args.to_config_options("MY_SVC");
205+
assert!(opts.config_file.is_none());
206+
assert!(opts.config_paths.is_empty());
207+
}
208+
169209
#[test]
170210
fn test_effective_log_level_custom() {
171211
let args = CommonArgs {

‎src/cli/runtime.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,11 @@ impl ServiceRuntime {
281281
.check_on_startup();
282282
}
283283

284+
// Turns the previously-silent "from_cascade defaulted everything"
285+
// failure into one observable startup line.
286+
#[cfg(feature = "config")]
287+
log_cascade_section_summary();
288+
284289
// Log runtime context
285290
tracing::info!(
286291
environment = %ctx.environment,
@@ -357,6 +362,25 @@ impl ServiceRuntime {
357362
}
358363
}
359364

365+
/// Emit one startup line summarising which platform config sections were found
366+
/// in the cascade vs defaulted. Cheap (key-presence checks, no deserialisation).
367+
/// This is the observable counterpart to the silent pre-2.8.11 failure where
368+
/// `from_cascade` defaulted everything because the cascade was never populated.
369+
#[cfg(feature = "config")]
370+
fn log_cascade_section_summary() {
371+
let cfg = crate::config::try_get();
372+
let present = |key: &str| cfg.is_some_and(|c| c.contains(key));
373+
tracing::info!(
374+
cascade_initialised = cfg.is_some(),
375+
self_regulation = present("self_regulation"),
376+
worker_pool = present("worker_pool"),
377+
batch_processing = present("batch_processing"),
378+
scaling = present("scaling"),
379+
expression = present("expression"),
380+
"Config cascade sections (true = found in config, false = using defaults)"
381+
);
382+
}
383+
360384
/// Periodic scaling-pressure tick: sample CPU (rate of the cumulative counter
361385
/// over the wall window / cores), read the pushed transport signals, and let the
362386
/// engine evaluate + publish its gauges. Off the data hot-path (interval-driven).

0 commit comments

Comments
 (0)