Feature config#113
Conversation
…on tests, removing hardcoded ports/paths
…og fix, 116 tests
…sed di.logging and di.torq required for full deployment
DIReview Summary2 critical | 4 warning(s) | 0 suggestion(s)
|
DIReview Summary1 critical | 3 warning(s) | 0 suggestion(s)
|
DIReview Summary0 critical | 1 warning(s) | 0 suggestion(s)
|
| / exists (di.toml lives only in the PoC today; the .q-only path needs it never). | ||
| fsym:`$":",path; | ||
| if[0=count key fsym; :()!()]; | ||
| if[path like "*.toml"; :(use`di.toml)[`parsefile] path]; |
There was a problem hiding this comment.
The /-comment filter lines like "/*" matches any line whose first character is /, but the glob pattern "/*" means 'slash followed by zero or more characters' — in q, * in a like pattern is a wildcard, so this correctly matches lines starting with /. However, the check uses not lines like "/*" which also drops lines whose first character is a forward-slash that is part of a valid value (e.g. a path string /opt/foo on the RHS of a name:value pair). More importantly, the filter is applied to the whole raw line, not to lines that lack a :, so a value line like path:/some/dir is kept (correctly), but a line like / comment with : colon would also be dropped only if it starts with / — that part is fine. The real bug is that blank-line and comment-line filtering happens BEFORE the split, but the split lambda {[ln] i:first where ln=":"; ...} will signal with a null i (and thus 0N#ln) on any line that survived filtering but contains no :. If a settings file has a line that is neither blank, nor a comment, nor a name:value pair (e.g. a continuation, a stray word, or a line whose only : is inside a string), first where ln=":" returns 0N, and 0N#ln / (0N+1)_ln will error or return unexpected results. Add a guard: lines:lines where ":" in' lines after the comment filter to skip non-pair lines safely.
| / sequence and RETURN the merged flat dict. pure - no init, no logger, no globals - so | ||
| / di.torq can call it directly after use`di.config, before the logger exists. precedence is | ||
| / name-major: a more specific NAME wins over the root layer (procname beats proctype beats | ||
| / default), and within a name the later (app) root overrides the builtin root - so a key set |
There was a problem hiding this comment.
cascade builds names as `default,proctype,procname — if proctype and procname happen to be the same symbol (e.g. both `rdb), two identical tier bases are generated and parsetier is called twice on the same files, causing those files to be parsed and merged twice. The later (duplicate) merge overwrites the earlier one's keys with identical values, so the result is technically correct, but parsefile is called redundantly. More critically, if proctype~default(a caller passes the literal name ``default `` as proctype), the default tier is processed twice with the same precedence slot, which is misleading and could produce unexpected results if `parsetier` or `parsefile` has side-effects in future. Deduplicate `names` after construction: `names:distinct names`.
| / name-major: a more specific NAME wins over the root layer (procname beats proctype beats | ||
| / default), and within a name the later (app) root overrides the builtin root - so a key set | ||
| / in both builtin/{proctype} and app/default resolves to the builtin proctype value. within a | ||
| / single tier, .toml wins over .q (see parsetier). the accumulator is seeded with a sentinel |
There was a problem hiding this comment.
dirs,\:"/",string nm concatenates each root with "/" and the string form of nm (e.g. "rdb"), but string on a symbol strips any leading backtick and returns the bare name. However if proctype or procname is the null symbol `, string[]returns"", producing paths like "/tmp/builtin/"— a directory, not a settings file base.parsefileon"/tmp/builtin/.q"would then attempt to read a file named literally.q(or.toml) in the root, which is almost certainly not intended. Validate that proctypeandprocnameare non-null symbols before use, or guard incascadewithif[(null proctype)|null procname; ...]`.
| ]; | ||
| / fold each defined override into the config dict; a skipped/rejected one leaves it unchanged | ||
| :{[config;params;name] | ||
| res:applyoverride[name;config name;params name]; |
There was a problem hiding this comment.
In the overrideconfig fold :{[config;params;name] res:applyoverride[...]; $[res 0; @[config;name;:;res 1]; config]}[;params;]/[config;defined], the fold is written as f/[seed;list] but the lambda is a 3-argument function projected as f[;params;] — making it effectively binary when partially applied. The seed (config) is the accumulator and defined is the list. This is the correct q f/[seed;list] idiom. However, applyoverride is called as applyoverride[name; config name; params name] where config is the current accumulator from the fold, which is correct. The real risk is that defined is derived from the original config (vars where vars in key config), but if an earlier iteration of the fold changes the keys of config (which @[config;name;:;res 1] does not — it only updates a value for an existing key), this is safe. Confirmed no key addition occurs. No bug here — this finding is withdrawn.
| 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 |
There was a problem hiding this comment.
The kx.log emission test seeds the config dict inline: enlist[kxrows]!enlist 5(the first argument tooverrideconfig). This dict has a single long value 5for key ``kxrows , and the override applies `enlist"7"` for the same key. The test then asserts `7~kxres`kxrows`. Because `applyoverride` uses `type cur` (which is `-7h` for a long scalar `5`) and `"J"$'enlist"7"` → `enlist 7j`, then `if[t<0; vals:first vals]` reduces to scalar `7`, the assert is correct. However `enlist 5` is a 1-element long list (type `7h`), not a scalar long (type `-7h`): `enlist 5` has type `7h`, so `t:type cur` is `7h` (positive, a list), and `if[t<0; vals:first vals]` is NOT triggered — `vals` stays as `enlist 7j`. The returned dict would have kxrows `` bound to enlist 7j(a 1-item list), so7~kxreskxrows`` would fail (`` 7~enlist 7j `` is 0b). Fix the seed to use a scalar: (enlistkxrows)!enlist 5j should be (enlistkxrows)!enlist[5j]... actually the issue isenlist 5produces a list. Change to(enlistkxrows)!5j (scalar value, no enlist on the value side) so t is -7h.
DIReview Summary1 critical | 4 warning(s) | 0 suggestion(s)
|
di.config — TorQ Modularisation PR
Summary
Extracts TorQ's in-process configuration handling (
torq.q:loadf,loadconfig,loadaddconfig,overrideconfig) into a standalone kdb-x module:di.config. The moduleloads a process's q settings files from disk, resolves the override cascade
(
default → proctype → procname, framework → app), and exposes the resolved config forquerying.
di.torqwill use it to partition config per module and inject each slice intothat module's
init. It satisfies the di.* contract: a required injected logger, a minimalexported API, no hard module dependencies, and a full k4unit suite.
Background
In TorQ,
torq.qreads a cascade of settings files at start-up and every module pulls itsown values out of global namespaces via
@[value;\var;default]`. Config resolution wasentangled with the monolithic bootstrap.
Per the modularisation plan, config is now a hybrid:
di.confighandles the mechanics(load files, resolve the cascade, expose a queryable store);
di.torqhandles distribution(partition by module, inject via each module's
init). Modules receive a config dictionaryand don't know or care where it came from.
Changes
New files
di/config/config.qinit,loadcascade,overrideconfig,getmodule, and internal helpersloadconfig,applyoverride,raiseerrordi/config/init.qconfig.q, declares the exportdi/config/test.csvdi/config/config.mdDifferences from the TorQ original
di.config.lg.o/.lg.elogdep, required atinit, no fallback and no adaptation@[value;\var;default]` per modulegetmodule; distributed bydi.torqtorq.qcode)getenv+process.csvinternallyloaddir(loads code dirs viaorder.txt)di.torq's concern, never used by the config cascade.proc.override(reads.z.x)overrideconfig[params]— caller passes the params; per-variable log-and-skipusesingleton,init[deps],export:listLogging contract (injected dependency)
Logging is an injected dependency, wired via
init— there is no default logger and themodule does no adaptation of the logger.
initmust be called before any otherfunction.
init[deps]takes a dict carrying the requiredlogdependency. It errors immediately(plain signal,
di.config:prefix) ifdepsis not a dict, is missing`log,logisnot a dict, or
loglacks any of`info`warn`error.logvalue must already be a binary`info`warn`error!{[c;m]}dict — eachfunction takes a context symbol
cand a message stringm. Build it fromdi.log(thestandard logger, which exports binary
info/warn/errordirectly) or hand-roll one:{[c;m]}(e.g. a raw monadickx.loginstance) must bewrapped by the caller first — di.config does not wrap it. (This is the settled no-
normlogdirection: adaptation belongs in
di.log, not in every consumer.)initfans the injectedlogdict out into three module-local functions —.z.m.loginfo/.z.m.logwarn/.z.m.logerr— and every call site uses.z.m.loginfo[`ctx;"msg"](the three-flat-var convention fromconsistency.mdanddi.compression; maps 1:1 with TorQ's.lg.o[`ctx;"msg"]).raiseerror[ctx;msg]that logs via.z.m.logerrthen signals
'"di.config: ",ctx,": ",msg— so every failure is observable in the log aswell as thrown.
Exported API
dirs— a settings-directory path or list of paths (low→high priority).names— a config name or list (least→most specific), e.g.`default`proctype`procname.namespace— a bare symbol, no leading dot (getmodule[\rdb]`); index the returned dict for one value.Typical caller (di.torq):
config.loadcascade[(kdbconfig;appconfig);\default,proctype,procname]→config.overrideconfig[cliparams]→config.getmodule[`rdb]→rdb.init[…]`.Key design decisions
dir/{name}.qfrom eachdirectory in turn, so a more specific name wins over the directory layer, and within a name the
higher-priority directory overrides. This matches the plan's documented file order and is a
deliberate departure from the actual
torq.qcode (which is dir-major).those namespaces are the store;
getmodulereads them (and reflectsoverrideconfigchanges). An explicit store with provenance / cross-source precedence (1b) is deferred until a
second config source (env vars, k8s) exists — the
getmodulecontract isstorage-independent, so that swap won't touch consumers. Flagged as an
EXTENSION POINTin code.di.torqresolves
KDBCONFIG/KDBAPPCONFIG→ dirs andproctype/procname→ names and passes themexplicitly. Deliberately no zero-arg
load[](config loads once; a blank call would only addhidden state and temporal coupling).
overrideconfigis the command-line layer, not a manual step. It ports TorQ'soverrideconfig/override[](torq.q), which TorQ applies at startup off.Q.opt .z.xsolaunch-time flags (
-loglevel,-tablelist, …) beat the settings files. di.torq owns thecommand-line parse and calls this after
loadcascade; di.config just does the type-aware apply(staying env-free). It improves on TorQ on error handling: per-variable problems (undefined
name, non-basic type, value that won't parse) are logged and skipped — a single bad override
never aborts the batch and a null is never written into config.
loaddirwas dropped (code-directory loading, not config).loadfilewas mergedinto the internal
loadconfig(its string-check was dead and its existence check duplicated oncethe loader was internal-only). A single-key
get[namespace;key;default]getter was also dropped —getmodulereturns the slice and callers index it inline, and every config boots with a default soa fallback arg was dead weight (a missing key is now a caller bug, surfaced as a q null). Public
surface is 4 functions;
loadconfig/applyoverride/raiseerrorstay internal.'assigntrap not listed in.Q.res): the cascade driveris
loadcascade(notload, which is reserved and throws'assignat parse time).Test coverage —
test.csv(k4unit), 45 assertions, all greeninit— dependency validationdeps, missinglog, non-dictlog,logdict missing a keyloadcascadeoverrideconfiggetmoduleoverrideconfigchanges; non-symbol-namespace rejectiondi.config'sinit/cascade/load messages via a file sink — proves the injected-logger path end to endRunning the tests
export QPATH=/path/to/kx/mod:/path/to/kdbx-modulesCaveats — not deployable in a live process until two things land
di.config is complete and tested as a standalone unit, and its logging path was verified
end-to-end against the real
di.logwith no code change. It cannot yet run inside a liveprocess because:
di.log(the standard logger) is not merged and not final. di.config requires a conformingbinary
`info`warn`errorlogger injected atinit;di.logprovides exactly that (verified),but it currently lives on the
feature-loggingbranch and its contract isn't approved. If thatcontract changes, re-verify — no di.config code change is expected (di.config only needs the
three binary functions). Interim callers can hand-roll a logger.
di.torqdoes not exist yet. The orchestrator that reads process identity, resolves theconfig dirs, calls
loadcascade, appliesoverrideconfig, and distributes config viagetmodulehasn't been built.
getmoduleis unit-tested for that role, but the real partition-and-inject flowis unproven until
di.torqlands.Deferred follow-ups (documented in
config.md)di.logintegration test oncedi.logmerges (verified manually for now; ause`di.logtest can't be committed on this branch yet).versionexport +deps.qwhendi.depcheckships (repo-wide versioning rollout — nomodule carries these yet).
config source (env vars, k8s) is added — behind the unchanged
getmodulecontract.Test results screenshot