Feature serverselect - #110
Conversation
…rgument validation, updates to test.csv and added integeration testing
…tly throughout. Fixed a couple errors
…han pretend, updated testing to cover this and added more robust logging to main script
DIReview Summary1 critical | 7 warning(s) | 0 suggestion(s)
|
DIReview Summary1 critical | 2 warning(s) | 0 suggestion(s)
|
DIReview Summary1 critical | 0 warning(s) | 0 suggestion(s)
|
di.depcheck resolves a dependency's version from the module's export dict
(checkdepversion) and classes a missing one as a FAILURE, not a warning - so
any process loading a module that declares di.serverselect as a hard dependency
could not start. di/dataaccess/deps.q already pins it at "0.1.0".
Follows the convention already used by di.eodtime, di.dataaccess and di.k4unit:
read with @[{trim first read0 x};`:::VERSION;...] rather than a bare
`first read0`. trim matters because read0 strips the line terminator but not a
trailing \r on a CRLF file, nor trailing spaces, and depcheck compares versions
as STRINGS - so a padded value would silently fail every dependent's check. A
missing, unreadable or empty file now fails loudly and names the module.
VERSION stripped to 5 bytes (no trailing newline) to match the sibling modules.
… inputs Three adversarial passes over the attribute-matching engine and the code added to it. The engine (getserverscross/getserversindependent/getserversinitial/ buildcross) was previously reached only INDIRECTLY, via getserverids' end-to-end behaviour, so the suite was green without ever unit-testing it. It is now driven directly through the internal .m.di.0serverselect. path, matching the convention di.dataaccess's suite already uses for getrouting/buildshardquery. The four classic cases all turned out CORRECT: empty requirements degrade to any server of the type, partial attribute coverage excludes only servers actually missing a key, besteffort 0b/1b diverge as intended, and best-match ranking really does prefer the widest server rather than the first candidate found. Defects fixed ------------- 1. Multi-servertype requests aborted entirely if ANY one type matched nothing, discarding good matches from the others. getserverids' own all-empty guard was unreachable dead code - evidence the fan-out was meant to tolerate misses. Per-type misses are now logged at warn; only an all-types miss errors. 2. An atom requirement value ((enlist`date)!enlist 2024.01.01) threw a raw, UNLOGGED 'rank. getservers/attributematch always accepted an atom, so the two entry points disagreed. Atom values are promoted at the boundary. 3. besteffort/attributetype were silently ignored when wrong-typed: besteffort:0 (int) kept the 1b default, an unknown attributetype fell back to cross. Both are now validated - at the boundary, deliberately, because the fan-out in (1) runs under a protected apply and would downgrade a deeper error into a "this servertype did not match" warning. 4. attributes was never validated. A non-dict registered happily and surfaced later, elsewhere, as a raw 'type; and if the FIRST registration was malformed the column took that value's type and every later valid registration failed. 5. removeinactive[0Wn] - infinite age, i.e. "never purge" - DELETED every inactive server: disconnecttime+0Wn overflows the timestamp range and wraps to the year 1734. 0Nn failed likewise via 0Np. Both are now handled. 6. A repeated servertype (`hdb`hdb) was resolved twice and returned the same serverids in two groups. The symbol path already deduped; these disagreed. 7. The single-servertype/`all path had been routed through the tolerant fan-out, losing the engine's specific wording and logging a spurious warn. 8. The nested attrs form (below) silently swallowed unknown top-level keys, so a requirement left outside attrs vanished and a mistyped besteffor was ignored - the flat form errors on both, making the new shape LESS safe than the old. 9. selectorarity gave up on a projection of a projection, accepting an arity-1 strategy that would still throw 'rank later. 10. A keyed table is type 99h too, so (4)'s check let one through; so did a dictionary keyed on non-symbols, reaching the matcher as a raw 'length. New capability -------------- - setselector: pluggable selection strategy. getserverbytype dispatches through a live pointer; selector itself is unchanged, still exported, still the default. Arity is checked at the setselector call site rather than left to throw 'rank at the next getserverbytype. - setserveridactive: retire ONE registration by serverid. setserveractive is unchanged (handle-level, bulk) and remains correct for a real disconnect, but updatestats already keyed on serverid "rather than handle, which may be shared" - activation was the one place that ignored that. - removeinactive + disconnecttime column: age-based purge, so a process that connected once and went away does not sit in the registry forever. Caller-invoked; di.torq schedules it with clearinactivetime. - requireinit on every public entry point, so a pre-init call names itself instead of leaking a raw .m.di.0serverselect.loginfo. version and getapimeta are deliberately exempt - di.torq collects api metadata at startup, possibly before init. - getapimeta: api metadata for all 16 callable exports, for di.torq to register with di.api. init/getapimeta omitted as plumbing. - Nested `attrs request shape: attrs holds the requirements explicitly, leaving the rest of the dict to the controls, so an attribute may be named servertype/ besteffort/attributetype without colliding. Purely additive - the flat form is untouched. - Optional config: cp (injected clock, makes the purge testable without sleeping), clearinactivetime, maxcrossproduct (default 1,000,000, 0W to disable) bounding the combinatorial cost of a client-supplied requirement. - raiseerror/getopt/checkopt factored out, matching di.dataaccess, so the two modules read alike before they sit side by side in a gateway. Two existing assertions had to change ------------------------------------- test.csv:9 and integration.q:129 both assert the exact column list of getserverstable[], and disconnecttime is new. Updated rather than loosened. getservers' columns are selected explicitly and are unaffected, so di.dataaccess (the only real consumer, calling getservers[`servertype;`;()!()]) is unaffected. Testing ------- test.csv: 408 rows, all pass (was 194). integration.q: 91 assertions against real child processes and real IPC, all pass (was 75). qlint clean.
| .z.m.selector:selector; | ||
| }; | ||
|
|
||
| nextserverid:{ |
There was a problem hiding this comment.
checkopt[deps;maxcrossproduct;{(-7h=type x) and 0<x};"a positive long"]rejects0as maxcrossproduct since0<0is false. The docs say0Wdisables the bound;0is not discussed. The error message says "a positive long" and0would be rejected. But the actual semantics ingetserveridsisif[.z.m.maxcrossproduct<sz:...]— if maxcrossproduct were0, every non-empty request would fail (since sz>=1). So 0` being rejected is arguably correct. No defect. Disregarding.
| / NB `version` must STAY in the export: di.depcheck resolves a dependency's version from the export | ||
| / dict (checkdepversion) and classes a missing one as a FAILURE - which makes di.depcheck.init throw | ||
| / for any process loading a module that declares this one as a hard dependency | ||
| / trim, and fail LOUD on a missing/unreadable/empty VERSION, rather than a bare `first read0`: a raw |
There was a problem hiding this comment.
VERSION file loses its trailing newline (as shown by the \ No newline at end of file in the diff). The read0 call in init.q reads the file and takes first, then trims. If the file has no trailing newline, read0 returns a one-element list with the version string (no trailing \r or space issues). trim first read0 handles this correctly. However, other tooling (e.g. cat, diff, some CI version-check scripts) that expects a newline-terminated file may be broken. This is a minor portability concern but not a code defect in the q module itself.
| / inactive row would compare as aged out - the exact opposite of an infinite retention. 0Nn is | ||
| / rejected above for the same reason: disconnecttime+0Nn is 0Np, and cp[]>0Np is true for everything | ||
| if[0Wn=age;:()]; | ||
| .z.m.servers:delete from servers where not active,not null disconnecttime,.z.m.cp[]>disconnecttime+age; |
There was a problem hiding this comment.
removeinactive does not call requireinit before checking not -16h=type age. If called before init, the age type check and subsequent checks may run, and if[0Wn=age;:()] would return without touching .z.m — but the delete at the end references .z.m.servers and .z.m.cp, which are unset, producing a cryptic '.m.di.0serverselect.servers error instead of the expected 'di.serverselect: removeinactive: init must be called first. Looking at the actual code: requireinit\removeinactiveis at line 200 (first line of the function body). Checking:removeinactive:{[age] requireinit`removeinactive; ...}. The test confirms this at the requireinit section. So requireinit` IS called first. No defect.
DIReview Summary1 critical | 2 warning(s) | 5 suggestion(s)
Suggestions
|
| live:pids where not null pids; | ||
| if[count live; @[system;"kill ",(" " sv string live)," 2>/dev/null; true";{}]]; | ||
| {@[system;"pkill -f \"[-]p ",string[x]," -q\" 2>/dev/null; true";{}]} each ports; | ||
| @[hclose;;{}] each handles where not null handles; |
There was a problem hiding this comment.
The connect function's retry loop 50 step/ 0Ni uses converge (/ without a count), not a counted loop. Because step returns the handle integer on success and 0Ni on failure, the converge will stop as soon as two successive calls return the same value — meaning it stops immediately on the first successful connect (correct), but it also stops if two consecutive retries both return 0Ni (i.e. after just two failed attempts, not fifty). The intent is to retry up to 50 times; use 50 step/ 0Ni as a counted iterate by making the count an integer: this already is an integer 50, so in q n f/ seed with integer n applies f exactly n times. Verify this is the intended semantics — if so it is correct as written and this note can be disregarded.
DIReview Summary0 critical | 1 warning(s) | 3 suggestion(s)
Suggestions
|
di.serverselect — TorQ Modularisation PR
Summary
Extracts the server-selection logic from TorQ's
.gwnamespace (gatewaylib.qandgateway.q) into a standalone kdb-x module:di.serverselect. The module maintains a registered pool of backend servers and selects from them by servertype or attribute requirements. It satisfies the di.* module contract: dependency injection viainit, a clean exported API, and no hard module dependencies beyond an injected logger.Background
TorQ's gateway uses a
.gwnamespace to track connected backend servers and route queries to them. Server registration, active-flag management, and selection logic were entangled with the gateway's query execution and connection-handling code. This PR extracts the server-selection layer — the pool of registered servers and the logic to pick from it — as an independently loadable and testable unit.This PR is part of the broader TorQ → kdb-x modularisation effort. The extracted module removes the dependency on TorQ's global process framework and can be used in any gateway or proxy that needs to maintain a backend server pool.
Changes
New files
di/serverselect/serverselect.qinit,addserverfull,addserverattr,addserver,setserveractive,getserverstable,addserversfromtable,getservers,selector,getserverbytype,gethandlebytype,gethpbytype,getserveridsand internal helpersdi/serverselect/init.qserverselect.qand declares the export listdi/serverselect/test.csvdi/serverselect/integration.qdi/serverselect/serverselect.mdDifferences from TorQ original
.gwdi.serverselect.lg.o/.lg.ecallslogdependency, required viainit(no fallback).gw.serverstable.z.m.serversmodule-local mutable statedi.serverselect:prefixusesingleton,init[deps]pattern,export:listattributematch(internal); exposed via thegetserversattribmatchcolumn.servers.SERVERShard-coded lookupaddserversfromtable[proctypes;conntable]accepting any connection tableLogging contract (injected dependency)
Logging is an injected dependency, wired via
init— there is no default logger and the module does not loadkx.logitself; initialising the logging framework is the job of the start-up script or the user.initmust be called before any other function.init[deps]takes a single dict carrying the requiredlogdependency (plus any future optional config). It errors immediately (plain signal) ifdepsis not a dict, is missing`log, orlogis not a dict exposing`info`warn`error.normlognormalises the injected logger to a binary`info`warn`error!{[c;m]}dict — each function takes a context symbolcand a message stringm. It accepts either:kx.loginstance ((use\kx.log)[`createLog][]) — detected by itsgetlvl/sinks/fmtskeys; its monadic functions are wrapped to{[c;m]}, folding context in as a"ctx: msg"` prefix; or`info`warn`error!({[c;m]};{[c;m]};{[c;m]})dict — passed through unchanged..z.m.log[\info][`ctx;"msg"](which maps 1:1 with TorQ's.lg.o[`ctx;"msg"]`).raiseerror[ctx;msg]helper that logs via.z.m.log[\error]**then** signals'"di.serverselect: ",ctx,": ",msg`, so every failure is observable in the log as well as thrown.Exported API
Hardening and fixes
updatestatskeyed onserverid, nothandle.handleis not unique (the table is keyed onserverid, and a freed handle can be reused after reconnect). Keying stats updates on the uniqueserveridprevents a single selection from skewinghits/lastpacross every server that happens to share a handle. Covered by a dedicated regression test.raiseerrorso the failure is logged:addserverfull/setserveractivecheck handle (int) and servertype/active-flag types;addserversfromtablechecks the connection table has the requiredw/proctype/attributescolumns.getserversvalidatesnameortype— it must be`servertypeor`procname(whenlookupsis not`); any other value errors rather than silently falling through to aprocnamelookup.selectorempty-table behaviour documented — it returns a null-valued row; the*bytypehelpers guard against this and return()when no active server matches.Test coverage
Two suites, both green.
Unit tests —
test.csv(k4unit), 128 assertionsinit— dependency injectiondeps, missinglog, non-dictlog, log dict missing a key;di.serverselect:error prefixinit— injected logger usedaddserver/addserverattr/addserverfull— row counts, handles, servertypes, procname/hpup population, serverid autoincrement, type-error rejectionsetserveractiveupdatestatsserverid, even when a handle is sharedgetserversattribmatch; servertype/procname filters; null lookups; per-attribute match scoring; invalidnameortyperejectedselectorroundrobin/last/anystrategies; single-row and empty-table behaviour; unknown strategy rejectedgetserverbytype/gethandlebytype/gethpbytype()for unknown type; round-robin rotation;hitsincrementgetserverids— symbol pathgetserverids— attribute pathbesteffortstrict modeaddserversfromtable`ALL; optionalprocname/hpupcolumns; missing-column rejectionIntegration test —
integration.q, 76 assertionsDrives every exported function through a realistic gateway lifecycle (register → activate/deactivate → query → select → bulk-register → error handling →
initre-wiring with a capturing logger), with aPASS/FAILsummary and a non-zero exit code equal to the number of failures.Running the tests
export QPATH=/path/to/kx/mod:/path/to/kdbx-modules/ integration test QPATH=/path/to/kx/mod:/path/to/kdbx-modules q integration.qIntegration notes
di.serverselecthas no hard module dependencies. The injectedlogis required —initthrows if it is not provided, and must be called before any other function.kx.logby passing the wholecreateLog[]instance (srvsel.init[enlist[\log]!enlist (use`kx.log)[`createLog][]]);normlogdetects and adapts it. A stripped 3-key dict of kx.log's monadic functions must **not** be passed (it would bypass detection and fail with'rank`).addserversfromtableaccepts a TorQ.servers.SERVERS-style table directly: columnsw(handle),proctype,attributesare required;procnameandhpupare optional.getserveridssupports the full TorQ gateway attribute-matching contract: cross-product matching (default), independent matching, per-servertype scoping, andbesteffortmode. Its result is consumed by the gateway viainter/:+first each(andrazefor emptiness checks), which is robust to its per-path return shape.di.serverselect:to identify the source in stack traces.Test results screenshot