diff --git a/di/config/config.md b/di/config/config.md new file mode 100644 index 00000000..fdd6eeeb --- /dev/null +++ b/di/config/config.md @@ -0,0 +1,222 @@ +# di.config + +Configuration loading and cascade resolution for the modular TorQ world. This +replaces TorQ's in-process config handling (`torq.q`: `loadf`, `loadconfig`, +`loadaddconfig`, `overrideconfig`), where `di.torq` will later distribute the +resolved config to each module via that module's `init`. + +## Import and init + +`init` requires a `log` dependency — there is no silent fallback. Pass an +already-conforming binary `` `info`warn`error `` dict of `{[c;m]}` loggers (context +symbol, message string). The module performs **no** adaptation of the logger. + +The standard logger is **`di.log`**, which exports binary `info`/`warn`/`error` +functions directly — build the dict from its exports: + +```q +config:use`di.config + +/ option 1: di.log — the standard logger (exports binary info/warn/error) +logger:use`di.log +logdep:`info`warn`error!(logger.info;logger.warn;logger.error) +config.init[enlist[`log]!enlist logdep] + +/ option 2: any hand-rolled binary {[c;m]} logger +mylog:`info`warn`error!( + {[c;m] -1 string[c],": INFO ",m;}; + {[c;m] -1 string[c],": WARN ",m;}; + {[c;m] -2 string[c],": ERROR ",m;}); +config.init[enlist[`log]!enlist mylog] +``` + +A logger that is *not* already binary `{[c;m]}` (e.g. a raw monadic +[`kx.log`](https://github.com/KxSystems/logging) instance) must be wrapped by the +caller first — di.config does no wrapping: `` `info`warn`error!({[c;m]inst[`info][string[c],": ",m]};…) ``. + +`init` must be called before any other function — there is no default logger. + +## Exported functions + +| Function | Signature | Description | +|---|---|---| +| `init` | `init[deps]` | Wire the injected logger. `deps` is a dict with a required `log` key — an already-conforming binary `` `info`warn`error `` dict of `{[c;m]}` functions. Errors immediately if the log dependency is missing or malformed. | +| `loadcascade` | `loadcascade[dirs;names]` | Resolve the config cascade **name-major**: for each name (least→most specific) load `dir/{name}.q` from each directory in turn. `dirs` is a string path or list of paths (low→high priority); `names` is a symbol or symbol list (least→most specific). A more specific name wins over the directory layer; within a name the later directory overrides. Missing files are skipped (logged at *info*); already-loaded files are de-duplicated. Returns the flat list of constructed paths, in load order. | +| `overrideconfig` | `overrideconfig[params]` | Apply the **command-line-argument layer** on top of the file cascade — di.torq parses the process command line (`.Q.opt .z.x`) and calls this at startup, so launch-time flags (e.g. `-loglevel info`, `-tablelist trade quote`) win over the settings files. This is the automatic top layer of the cascade, **not** an interactive/by-hand step. `params` is a dict keyed by variable name (symbol) with string (or list-of-string) values, each parsed into that variable's **existing** type. Only already-defined, basic-typed variables can be overridden; undefined names, non-basic types, and unparseable values are logged and skipped. Returns the list of variables actually overridden. | +| `getmodule` | `getmodule[namespace]` | Return a namespace's whole resolved config as a bare-keyed value dict — the slice di.torq passes to a module's `init`. `namespace` is a bare symbol; an unconfigured namespace yields an empty dict. Callers read a single setting by indexing the returned dict inline (e.g. `getmodule[\`rdb]\`subscribeto`). | + +Internal helpers — `loadconfig` (the per-file loader behind `loadcascade`), +`applyoverride`, `parsefailed`, and the load-tracking state — are deliberately not exported. + +`loadcascade` is the top-level entry point. A caller (e.g. `di.torq`, which resolves the +process identity and directory paths) drives it as: + +```q +config.loadcascade[(kdbconfig;appconfig);`default,proctype,procname] +config.overrideconfig[`.myproc.enabled`.myproc.rows!(enlist"1";enlist"5000")] +/ then partition per module and hand each slice to that module's init: +rdbcfg:config.getmodule[`rdb] / -> `subscribeto`hdbtypes!(...) etc. +rdb.init[rdbcfg;`log`timer!(logdep;timerdep)] +/ read a single setting by indexing the slice inline (configs are booted with defaults, +/ so the key is always present — no fallback needed): +rdbcfg`subscribeto +``` + +`overrideconfig` runs after `loadcascade` so command-line values win over file config — it is the +top (command-line) layer of the cascade, applied automatically by di.torq at startup, not a manual +step. di.config is deliberately generic — it does not read environment variables or process +identity itself; the caller (di.torq) supplies the settings directories, the ordered +name sequence, and the override params, then uses `getmodule` to partition the resolved +store per module. + +### The config store + +`loadcascade` loads settings `.q` files that assign into root namespaces +(`.rdb.subscribeto:...`); those resolved namespaces **are** the config store, and +`getmodule` queries them (so anything `overrideconfig` changes is reflected too). +Additional config sources (env vars, k8s config maps) are out of scope for v1 but would +plug in by populating the same namespaces before the store is queried — the `getmodule` +contract stays unchanged. See the EXTENSION POINT comment in `config.q`. + +> **Note:** avoid `-` in config file paths — a source-level backtick symbol literal +> containing `-` parses as subtraction (`` `:/a-b.q `` → `` `:/a `` `- b.q`), not one +> symbol. Runtime string paths (built internally by `loadcascade`) are unaffected. + +## Injectable dependencies + +| Injectable | Required keys | Function signature | +|---|---|---| +| `log` | `` `info`warn`error `` | `{[c;m]}` (context symbol, message string); caller passes an already-conforming binary dict — built from `di.log` (the standard logger, which exports binary `info`/`warn`/`error`) or hand-rolled. No adaptation is done in the module — a non-binary logger (e.g. a raw monadic `kx.log` instance) must be wrapped first | + +## Hard dependencies + +None — `di.config` is a standalone module. + +## Design notes & open gaps + +- **Return values are informational.** `loadcascade` returns the path(s) it *considered* — including + files that were missing or skipped. The real effect is the side-effect load; use the return to see + what was attempted, not what definitely loaded. +- **Missing cascade files are skipped, not errored.** The internal `loadconfig` logs a missing + `dir/{name}.q` at *info* and skips it — absence is normal in the cascade (most `{proctype}`/ + `{procname}` files won't exist). +- **Dedup / idempotency.** `loadconfig` tracks loaded paths in `.z.m.loaded` and skips re-loads, so + the cascade is safe to re-run and `init` is safe to call again. +- **`overrideconfig` is the command-line layer, applied by di.torq — not a manual step.** It mirrors + TorQ's `overrideconfig`/`override[]` (`torq.q`), which TorQ runs at startup off `.Q.opt .z.x` so + launch-time flags beat the settings files. In the modular world di.torq owns that command-line parse + and calls this after `loadcascade`; di.config just does the type-aware apply (which is why it stays + env-free and never reads `.z.x` itself). It *deviates from TorQ deliberately* on error handling: + per-variable problems (undefined name, non-basic type, value that won't parse to the target type) + are logged and skipped — a single bad override never aborts the batch and a null is never written + into config. +- **Entry point is `loadcascade[dirs;names]`, NOT a zero-arg `load[]`.** The Plan sketches + `di.config.load[]`, but that predates the integration design. Config loading happens once at + startup, so a blank call buys nothing and would force the cascade inputs into hidden `.z.m` state + (temporal coupling with `init`). Explicit args keep di.config a pure, env-free resolver: di.torq + resolves `KDBCONFIG`/`KDBAPPCONFIG` → dirs and `proctype`/`procname` → names and passes them. (Also + `load` is a reserved word, so the Plan's literal name is unusable anyway.) If di.torq ever wants a + blank trigger it's a one-line wrapper on its side — not di.config's concern. +- **Config store is live namespace globals, not an explicit internal store (1a, deliberate).** Settings + files assign globals when executed, so those namespaces *are* the store; `getmodule` reads them. + An explicit store with provenance / cross-source precedence (1b) is deferred until a second config + source (env, k8s) exists — the `getmodule` contract is storage-independent, so that swap won't + touch consumers. See the `EXTENSION POINT` comment in `config.q`. +- **No single-key getter — `getmodule` is the only query.** An earlier draft exported a + `get[namespace;key;default]` convenience. It was removed: `getmodule` already returns the whole + namespace as a dict, and the intended flow fetches that slice once (di.torq → each module's `init`) + and indexes it inline (`rdbcfg\`subscribeto`), so a per-key getter added a second query path for no + gain. The `default` fallback was dropped with it — every config is booted with a default, so a + requested key is always present and a fallback arg is dead weight. A missing key is now a caller + bug (surfaced as a q null on the inline index), not something the config layer silently papers over. +- **`getmodule` returns leaf settings only — child namespaces are excluded.** `\v .rdb` lists any + child namespace (e.g. `.rdb.sub`) alongside the real settings, so `getmodule` filters them out — + a module's config slice should be flat setting→value pairs, not a nested namespace. Each name is + tested by asking kdb+ directly whether it is itself a namespace (`\v` on the qualified name succeeds + for a namespace and signals for a plain variable). This is deliberately used over the tempting + null-symbol-self-key heuristic (`` (`) in key value ``), which is fragile at the edges — a + dict-valued setting could coincidentally carry a null-symbol key (and be wrongly dropped), or a + namespace reassigned to a plain dict could lack one (and be wrongly kept). + +Open gaps / not implemented: + +- **No runtime reload.** TorQ's `reloadf` (force-reload an already-loaded file) is not ported — + `loadconfig` always dedups. Adequate for startup; revisit if runtime config reload is needed. +- **`overrideconfig` parse-failure detection is type-aware.** Most basic types parse to a null on a + bad value, which `applyoverride` rejects via a null check. Boolean (`1h`) and byte (`4h`) are the + only in-scope basic types with **no null** — `"B"$"bad"` yields `0b`, `"X"$"gg"` yields `0x00`, both + non-null — so a bad value would slip past a null check and silently corrupt config. `parsefailed` + handles these two explicitly (boolean must be `"0"`/`"1"`; byte must be even-length hex); anything + else is rejected and logged, never written. A string-typed (`10h`) config var still can't be + overridden (its per-element char cast is rejected as non-parsing) — this fails safe and is a rare edge. +- **No committed `di.log` integration test yet.** `di.log` (the standard logger) currently lives on + the `feature-logging` branch and isn't merged, so a committed `use\`di.log` test would fail on this + branch. di.config's contract match with `di.log` is **verified manually** — the real `di.log` was + wired end-to-end with no code change (`init`/`loadcascade`/`loadconfig` all emit through it). The + committed kx.log-wrapper test proves the same binary-`{[c;m]}`-dict contract `di.log` satisfies. Add + a `di.log` integration test once `di.log` merges. +- **Logging uses the three-flat-var convention** — `.z.m.loginfo`/`.z.m.logwarn`/`.z.m.logerr`, + called as `.z.m.loginfo[\`ctx;"msg"]` — matching `consistency.md` and `di.compression`. The injected + `log` value is still the same `` `info`warn`error `` dict; `init` just fans it out into the three + module-local vars. + +## Tests + +```q +k4unit:use`di.k4unit +k4unit.moduletest`di.config +``` + +## Follow-ups (deferred until other modules land) + +di.config is complete for v1 in isolation. The items below are intentionally deferred; +each is keyed to the module or milestone that unblocks it. Do these **when the trigger +lands**, not before. + +### ⚠️ Logging call convention — NOT set in stone (ask before changing) + +di.config uses the **three-flat-var** convention (`.z.m.loginfo`/`.z.m.logwarn`/`.z.m.logerr`), +matching `consistency.md` and `di.compression`. But the project hasn't finalised this versus the +**single-dict** form (`.z.m.log[\`info][…]`) that an earlier skill revision directed. **Before +changing di.config's logging — or wiring logging in another module — stop and ask the user which +convention is authoritative.** If the project settles on single-dict, switching di.config back is +mechanical (storage + call sites only; the injected input contract is identical either way). + +### When `di.log` merges to main + +- **Add a committed `di.log` integration test.** Today `di.log` lives on the + `feature-logging` branch (unmerged), so a committed `` use`di.log `` test would fail + here — the contract match is verified manually and stood in for by the kx.log-wrapper + emission test. Once `di.log` resolves on `QPATH`, add a test that builds + `` `info`warn`error!(logger.info;logger.warn;logger.error) `` from `di.log` and drives + `loadcascade` through it. +- **Re-verify only if `di.log`'s contract changes.** It isn't final/approved yet. No + di.config code change is expected — di.config needs only binary `info`/`warn`/`error`, + which `di.log` exports — but if those signatures change, re-run the integration drive. + +### When `di.torq` is built + +- **Prove the end-to-end flow.** `getmodule` is unit-tested here, but "partition per + module and inject via `init`" only runs for real once `di.torq` exists. Add a + multi-module integration test (under `di.torq`/`di.inttest`, not here) exercising: + di.torq resolves `KDBCONFIG`/`KDBAPPCONFIG` → `dirs` and `proctype`/`procname` → + `names`, calls `loadcascade`, then `overrideconfig` with the command-line params, then + `getmodule` per module → each module's `init`. +- **Hold the env-free boundary.** di.config reads no env vars or process identity — + di.torq owns that resolution and passes `dirs`/`names` explicitly. Do **not** add a + zero-arg `load[]` to di.config; if di.torq wants one it wraps `loadcascade` in a line + on its own side. + +### When `di.depcheck` is built (versioning rollout) + +- **Add a `version` export and `deps.q`.** No module ships these yet and `di.depcheck` + (which consumes them) doesn't exist. When it lands, add `version:"…"` to the export and + a `deps.q` (empty/minimal — di.config has no hard deps) as part of the coordinated + repo-wide rollout, not a di.config-only change. + +### When a second config source (env vars, k8s config maps) is needed + +- **Implement the 1b explicit store.** Replace the live-namespace-globals store with an + internal store carrying provenance / cross-source precedence, behind the unchanged + `getmodule` contract (so consumers don't change). See the `EXTENSION POINT` + comment in `config.q`. diff --git a/di/config/config.q b/di/config/config.q new file mode 100644 index 00000000..d70ec27a --- /dev/null +++ b/di/config/config.q @@ -0,0 +1,163 @@ +/ configuration loading and cascade resolution for the modular torq world - replaces +/ torq.q's loadf/loadconfig/loadaddconfig/overrideconfig. the logger is injected via init; +/ the module reads no env/identity - the caller (di.torq) supplies dirs, names and overrides. +/ public api: loadcascade (resolve cascade over dirs x names, name-major), overrideconfig +/ (command-line overrides, applied after), getmodule (query the store as a per-namespace +/ dict; di.torq partitions config per module with it). loadconfig is the internal per-file +/ loader behind loadcascade. + +init:{[deps] + / wire the injected logger - required, no silent fallback. deps: a dict with a `log key + / holding a binary `info`warn`error dict of {[c;m]} loggers (from di.log or hand-rolled). + / no adaptation here, so a monadic kx.log instance must be wrapped first. + / e.g. config.init[enlist[`log]!enlist logdep] + if[99h<>type deps; + '"di.config: deps must be a dict with `log key"]; + if[not `log in key deps; + '"di.config: log dependency is required; pass `info`warn`error functions keyed on `log"]; + if[99h<>type deps`log; + '"di.config: log value must be a dict; pass `info`warn`error functions"]; + if[not all (`info`warn`error) in key deps`log; + '"di.config: log dict must have `info`warn`error keys; got: ",(", " sv string key deps`log)]; + .z.m.loginfo:deps[`log]`info; + .z.m.logwarn:deps[`log]`warn; + .z.m.logerr:deps[`log]`error; + .z.m.loaded:enlist""; + .z.m.loginfo[`init;"di.config initialised"]; + }; + +loadconfig:{[dir;name] + / internal - load cascade file dir/{name}.q if present, tracking it so it is not + / re-loaded. a missing file is normal in the cascade, so logged at info and skipped. + / returns the file path. + if[not 10h=abs type dir; + .z.m.logerr[`loadconfig;err:"di.config: dir must be a string path"]; + 'err; + ]; + if[not -11h=type name; + .z.m.logerr[`loadconfig;err:"di.config: name must be a symbol"]; + 'err; + ]; + file:dir,"/",string[name],".q"; + if[file in .z.m.loaded; + .z.m.loginfo[`loadconfig;"already loaded ",file]; + :file; + ]; + if[()~key hsym `$file; + .z.m.loginfo[`loadconfig;"no config file (skipping): ",file]; + :file; + ]; + .z.m.loginfo[`loadconfig;"loading ",file]; + .[system;enlist"l ",file;{[f;e] .z.m.logerr[`loadconfig;err:"di.config: failed to load ",f,": ",e];'err}[file;]]; + .z.m.loaded,:enlist file; + :file; + }; + +loadcascade:{[dirs;names] + / resolve a cascade name-major: for each name (least->most specific) load it from every + / dir in turn, so a more specific name always wins and, within a name, the later (higher- + / priority) dir overrides. dirs is a path or list of paths (low->high priority); names a + / symbol or symbol list (least->most specific). missing files are skipped. returns the + / constructed paths in load order. e.g. loadcascade[(kdbconfig;appconfig);`default,proctype,procname] + dirs:$[10h=type dirs;enlist dirs;dirs]; + names:(),names; + if[not 0h=type dirs; + .z.m.logerr[`loadcascade;err:"di.config: dirs must be a string path or a list of string paths"]; + 'err; + ]; + if[not all 10h=type each dirs; + .z.m.logerr[`loadcascade;err:"di.config: dirs must be a string path or a list of string paths"]; + 'err; + ]; + if[not 11h=abs type names; + .z.m.logerr[`loadcascade;err:"di.config: names must be a symbol or a symbol list"]; + 'err; + ]; + .z.m.loginfo[`loadcascade;"resolving cascade: ",(string count dirs)," dir(s) x ",(string count names)," name(s)"]; + :raze {[ds;nm] loadconfig[;nm] each ds}[dirs;] each names; + }; + +hexchars:"0123456789abcdefABCDEF"; + +parsefailed:{[t;raw;vals] + / internal - true if any raw string failed to parse to type t. boolean (1h) and byte (4h) + / have no null, so a bad parse ("B"$"bad"->0b, "X"$"gg"->0x00) slips past a null check and + / would corrupt config - validate their string form explicitly. other types null on failure. + :$[1h=abs t;not all raw in (enlist"0";enlist"1"); + 4h=abs t;not all {(0=count[x] mod 2) and all x in hexchars} each raw; + any null vals]; + }; + +applyoverride:{[name;raw] + / internal - parse raw value(s) into name's current type and set it. raw is a string or + / list of strings. returns 1b if applied, 0b if name is not a basic type or a value failed + / to parse. name must already exist - its type drives the parse. + t:type value name; + if[not (abs t) within (1;-1+count .Q.t); + .z.m.logerr[`overrideconfig;"cannot override ",(string name),": not a basic type"]; + :0b; + ]; + raw:$[10h=type raw;enlist raw;raw]; + vals:(upper .Q.t abs t)$'raw; + if[parsefailed[t;raw;vals]; + .z.m.logerr[`overrideconfig;"cannot override ",(string name),": value did not parse"]; + :0b; + ]; + / reduce to scalar only after parsefailed (which checks the full per-element list); runs + / before the log and set, so both use the scalar form. + if[t<0;vals:first vals]; + .z.m.loginfo[`overrideconfig;"setting ",(string name)," to ",-3!vals]; + set[name;vals]; + :1b; + }; + +overrideconfig:{[params] + / apply command-line-style overrides to defined variables, parsing each value into the + / variable's existing type. params is a dict of variable name (symbol) -> string (or list + / of strings). only defined names are overridden; undefined ones are logged and skipped. + / returns the variables actually overridden. call after loadcascade so the command line wins. + if[99h<>type params; + .z.m.logerr[`overrideconfig;err:"di.config: params must be a dict keyed by variable name"]; + 'err; + ]; + vars:key params; + if[0value pairs. ask kdb+ whether each name is itself a namespace (\v succeeds on a + / namespace, signals on a plain variable) - more robust than checking for a null-symbol + / self-key, which a dict-valued setting could carry or a namespace lack. + issub:{[ns;v] @[{system"v ",x;1b};string ` sv (ns;v);0b]}; + vars:vars where not issub[ns;] each vars; + :vars!{[ns;v] value ` sv (ns;v)}[ns;] each vars; + }; diff --git a/di/config/init.q b/di/config/init.q new file mode 100644 index 00000000..d5534dc9 --- /dev/null +++ b/di/config/init.q @@ -0,0 +1,3 @@ +/ configuration loading and cascade resolution for the modular torq world. +\l ::config.q +export:([init;loadcascade;overrideconfig;getmodule]) diff --git a/di/config/test.csv b/di/config/test.csv new file mode 100644 index 00000000..ef09ba2c --- /dev/null +++ b/di/config/test.csv @@ -0,0 +1,89 @@ +action,ms,bytes,lang,code,repeat,minver,comment +before,0,0,q,cfg:use`di.config,1,1,load the module +before,0,0,q,"logtab:([]lvl:`symbol$();ctx:`symbol$();msg:())",1,1,capturing log table +before,0,0,q,"mocklog:`info`warn`error!({[c;m]`logtab upsert(`info;c;m)};{[c;m]`logtab upsert(`warn;c;m)};{[c;m]`logtab upsert(`error;c;m)})",1,1,define a capturing binary logger +before,0,0,q,cfg.init[enlist[`log]!enlist mocklog],1,1,init with the required log dependency +before,0,0,q,diconfigtrace:`symbol$(),1,1,tracer for cascade load order +before,0,0,q,"system""mkdir -p /tmp/diconfigc1""",1,1,cascade directory one +before,0,0,q,"system""mkdir -p /tmp/diconfigc2""",1,1,cascade directory two +before,0,0,q,"(`:/tmp/diconfigc1/default.q) 0: enlist ""diconfigcx:1;diconfigtrace,:`c1default;""",1,1,c1 default.q +before,0,0,q,"(`:/tmp/diconfigc1/rdb.q) 0: enlist ""diconfigcx:10;diconfigtrace,:`c1rdb;""",1,1,c1 rdb.q +before,0,0,q,"(`:/tmp/diconfigc2/default.q) 0: enlist ""diconfigcx:2;diconfigtrace,:`c2default;""",1,1,c2 default.q (no c2 rdb.q - a missing cascade file) +before,0,0,q,"system""mkdir -p /tmp/diconfigstore""",1,1,cascade dir for the config-store tests +before,0,0,q,"(`:/tmp/diconfigstore/default.q) 0: enlist "".diconfigq.subscribeto:`trade`quote;.diconfigq.rows:100;""",1,1,settings file populating the .diconfigq namespace +before,0,0,q,.diconfigtest.enabled:0b,1,1,override target - boolean +before,0,0,q,.diconfigtest.rows:100,1,1,override target - long +before,0,0,q,.diconfigtest.syms:`a`b,1,1,override target - symbol list +before,0,0,q,.diconfigtest.tab:([]a:`long$()),1,1,override target - non-basic (table) +before,0,0,q,.diconfigtest.byte:0x01,1,1,override target - byte (a no-null basic type) +before,0,0,q,.diconfiggm.rows:100,1,1,getmodule target - flat scalar setting +before,0,0,q,.diconfiggm.limits:`lo`hi!1 2,1,1,getmodule target - dict-valued setting (99h - must be kept) +before,0,0,q,.diconfiggm.sub.x:1,1,1,getmodule target - child namespace (must be excluded) +before,0,0,q,.diconfiggm.nullkey:(enlist `)!enlist 42,1,1,getmodule target - dict setting carrying a null-symbol key (must NOT be treated as a namespace) +before,0,0,q,"system""mkdir -p /tmp/diconfigkxdir""",1,1,dir for the kx.log emission test +before,0,0,q,"(`:/tmp/diconfigkxdir/default.q) 0: enlist ""diconfigkxvar:1;""",1,1,file loaded through the real logger +comment,,,,,,,init - dependency validation +fail,0,0,q,cfg.init[(::)],1,1,init rejects a non-dictionary deps +fail,0,0,q,cfg.init[enlist[`foo]!enlist 1],1,1,init rejects deps without a log key +fail,0,0,q,cfg.init[enlist[`log]!enlist 42],1,1,init rejects a non-dict log value +fail,0,0,q,cfg.init[enlist[`log]!enlist ((enlist`info)!enlist {[c;m]})],1,1,init rejects a log dict missing warn/error +comment,,,,,,,loadcascade - full cascade over directories and names +run,0,0,q,"cfg.loadcascade[(""/tmp/diconfigc1"";""/tmp/diconfigc2"");`default`rdb]",1,1,run the cascade +true,0,0,q,"4=count cfg.loadcascade[(""/tmp/diconfigc1"";""/tmp/diconfigc2"");`default`rdb]",1,1,one path per dir-name pair (skipped files included) +true,0,0,q,10=diconfigcx,1,1,most-specific name wins over dir layer (c1/rdb loads last) +run,0,0,q,cfg.init[enlist[`log]!enlist mocklog],1,1,re-init to reset the dedup set so the trace check is independent of any prior load (order/re-run safe) +run,0,0,q,diconfigtrace:`symbol$(),1,1,reset the tracer here in the assert phase - a before-row reset is batched once and cannot isolate this +run,0,0,q,"cfg.loadcascade[(""/tmp/diconfigc1"";""/tmp/diconfigc2"");`default`rdb]",1,1,load once - populates the tracer +run,0,0,q,"cfg.loadcascade[(""/tmp/diconfigc1"";""/tmp/diconfigc2"");`default`rdb]",1,1,re-run - dedup must NOT re-execute the files +true,0,0,q,`c1default`c2default`c1rdb~diconfigtrace,1,1,name-major order AND dedup (tracer unchanged by the re-run) +true,0,0,q,"any (cfg.loadcascade[(""/tmp/diconfigc1"";""/tmp/diconfigc2"");`default`rdb]) like ""*diconfigc2/rdb.q""",1,1,returned paths include skipped (missing) files +true,0,0,q,"any (exec msg from logtab where lvl=`info) like ""*no config file*""",1,1,a missing cascade file is skipped at info (internal loadconfig) +true,0,0,q,"1=count cfg.loadcascade[""/tmp/diconfigc1"";`default]",1,1,accepts a single string dir and single symbol name +fail,0,0,q,cfg.loadcascade[42;`default],1,1,loadcascade rejects non-string dirs +fail,0,0,q,"cfg.loadcascade[""/tmp/diconfigc1"";""default""]",1,1,loadcascade rejects non-symbol names +comment,,,,,,,overrideconfig - parse and apply command-line overrides +run,0,0,q,"cfg.overrideconfig[`.diconfigtest.enabled`.diconfigtest.rows!(enlist""1"";enlist""500"")]",1,1,override a bool and a long +true,0,0,q,1b~.diconfigtest.enabled,1,1,bool parsed and set from string +true,0,0,q,500=.diconfigtest.rows,1,1,long parsed and set from string +true,0,0,q,"0=count cfg.overrideconfig[enlist[`.diconfigtest.enabled]!enlist ""bad""]",1,1,unparseable boolean is rejected (no null - must not slip through) +true,0,0,q,1b~.diconfigtest.enabled,1,1,rejected boolean override leaves the variable unchanged (not silently set to 0b) +true,0,0,q,"any (exec msg from logtab) like ""*did not parse*""",1,1,rejected boolean override is logged +true,0,0,q,"(enlist`.diconfigtest.byte)~cfg.overrideconfig[enlist[`.diconfigtest.byte]!enlist ""ff""]",1,1,valid hex byte parsed and applied +true,0,0,q,0xff~.diconfigtest.byte,1,1,byte set from valid hex string +true,0,0,q,"0=count cfg.overrideconfig[enlist[`.diconfigtest.byte]!enlist ""gg""]",1,1,invalid hex byte is rejected (no null - must not slip through as 0x00) +true,0,0,q,0xff~.diconfigtest.byte,1,1,rejected byte override leaves the variable unchanged +run,0,0,q,"cfg.overrideconfig[enlist[`.diconfigtest.syms]!enlist (""xx"";""yy"")]",1,1,override a symbol list +true,0,0,q,`xx`yy~.diconfigtest.syms,1,1,symbol list parsed and set from strings +true,0,0,q,"(enlist`.diconfigtest.rows)~cfg.overrideconfig[enlist[`.diconfigtest.rows]!enlist enlist""7""]",1,1,returns the variables actually overridden +true,0,0,q,"0=count cfg.overrideconfig[enlist[`.diconfigtest.nope]!enlist enlist""1""]",1,1,undefined variable overrides nothing +true,0,0,q,"any (exec msg from logtab) like ""*undefined variable*""",1,1,undefined variable logged +true,0,0,q,"0=count cfg.overrideconfig[enlist[`.diconfigtest.tab]!enlist enlist""1""]",1,1,non-basic-type variable overrides nothing +true,0,0,q,"any (exec msg from logtab) like ""*not a basic type*""",1,1,non-basic-type variable logged +true,0,0,q,0=count cfg.overrideconfig[()!()],1,1,empty params is a no-op +fail,0,0,q,cfg.overrideconfig[42],1,1,overrideconfig rejects a non-dict +fail,0,0,q,"cfg.overrideconfig[enlist[1]!enlist enlist""x""]",1,1,overrideconfig rejects non-symbol keys +comment,,,,,,,getmodule - queryable config store +run,0,0,q,"cfg.loadcascade[""/tmp/diconfigstore"";`default]",1,1,load a settings file into the store +true,0,0,q,2=count cfg.getmodule[`diconfigq],1,1,getmodule returns the whole namespace slice +true,0,0,q,`trade`quote~cfg.getmodule[`diconfigq]`subscribeto,1,1,getmodule slice carries the values +true,0,0,q,100~cfg.getmodule[`diconfigq]`rows,1,1,getmodule slice carries the second value +true,0,0,q,0=count cfg.getmodule[`nosuchns],1,1,getmodule returns an empty dict for an unknown namespace +true,0,0,q,`limits`nullkey`rows~asc key cfg.getmodule[`diconfiggm],1,1,getmodule excludes child namespaces but keeps flat and dict-valued settings +true,0,0,q,not `sub in key cfg.getmodule[`diconfiggm],1,1,child namespace excluded from the config slice +true,0,0,q,(`lo`hi!1 2)~cfg.getmodule[`diconfiggm]`limits,1,1,dict-valued setting preserved (not misidentified as a namespace) +true,0,0,q,42~first cfg.getmodule[`diconfiggm]`nullkey,1,1,dict setting with a null-symbol key kept (namespace test is \v-based not self-key-based) +run,0,0,q,"cfg.overrideconfig[enlist[`.diconfigq.rows]!enlist enlist""500""]",1,1,override a stored value +true,0,0,q,500~cfg.getmodule[`diconfigq]`rows,1,1,getmodule reflects overrideconfig changes +fail,0,0,q,cfg.getmodule[42],1,1,getmodule rejects a non-symbol namespace +comment,,,,,,,logger emission - real logger via a caller-side binary wrapper (must run last; re-inits cfg) +run,0,0,q,kxlogger:use`kx.log,1,1,load the kx.log module +run,0,0,q,kxinst:kxlogger.createLog[],1,1,create a real kx.log instance +run,0,0,q,kxh:hopen `:/tmp/diconfigkxsink.txt,1,1,open a file sink to capture output +run,0,0,q,kxinst.add[kxh;`info],1,1,route info-level output to the file sink +run,0,0,q,"kxbinary:`info`warn`error!({[c;m]kxinst[`info][string[c],"": "",m]};{[c;m]kxinst[`warn][string[c],"": "",m]};{[c;m]kxinst[`error][string[c],"": "",m]})",1,1,wrap the monadic logger into the binary {[c;m]} contract +run,0,0,q,cfg.init[enlist[`log]!enlist kxbinary],1,1,re-init di.config with the conforming binary logger +run,0,0,q,"cfg.loadcascade[""/tmp/diconfigkxdir"";`default]",1,1,drive a real info-level log through the wrapped logger +run,0,0,q,hclose kxh,1,1,flush and close the file sink +true,0,0,q,1=diconfigkxvar,1,1,the cascade executed the file +true,0,0,q,"any (read0 `:/tmp/diconfigkxsink.txt) like ""*loadconfig: loading*""",1,1,the internal loader emitted through the real logger +true,0,0,q,"any (read0 `:/tmp/diconfigkxsink.txt) like ""*diconfigkxdir/default.q*""",1,1,emitted message includes the file path