diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md new file mode 100644 index 00000000..a219f825 --- /dev/null +++ b/di/handlers/handlers.md @@ -0,0 +1,182 @@ +# di.handlers + +Central registry for a KDB-X process's `.z.*` connection-lifecycle callbacks — connection open and close, websocket open and close, process exit, and the message, auth and HTTP handlers. It lets multiple independent components hook the same event without silently overwriting one another, and is the single place in a process that assigns `.z.*` directly. + +--- + +## Features + +- Register any number of **simple** handlers on an event whose return value KDB-X discards (connection open/close, websocket open/close, process exit). Every handler runs, in ascending **priority** order, and each call is isolated — one handler throwing is logged and does not stop the rest. +- Register handlers on a **phased** event whose return value KDB-X uses as the outcome (query, async, console input, HTTP POST/GET, auth, websocket message). A phased event threads its request through three phases: **`pre`** (any number, priority order — each reshapes the request), **`exec`** (exactly one owner — produces the outcome), and **`post`** (any number, priority order — side-effect-only watchers of the result). +- A phased event has a single `exec` owner: a second `exec` under a different name is rejected rather than silently replacing the first. `pre` and `post` cannot attach until an `exec` owner exists. +- **Fault-isolation is asymmetric.** `pre` and `exec` run **unprotected** — a `pre` that throws vetoes the request and propagates to the caller (this is a deliberate channel, e.g. an auth rejection). `post` and simple handlers run **protected/isolated** — a throw is logged at `warn` and changes neither the outcome nor the other handlers. +- Whatever was bound to a simple event before the first registration is preserved and still runs, last, after every registered handler. +- Remove a handler by `event`, `phase` and `name`; removing an `exec` owner restores the KDB-X built-in default and clears the event's phased state. +- Inspect what is registered on any event with `list`. + +Managed events, by dispatch model: + +| Event | Model | Meaning | +|---|---|---| +| `.z.pc` | simple | connection closed | +| `.z.po` | simple | connection opened | +| `.z.exit` | simple | process exit | +| `.z.wo` | simple | websocket opened | +| `.z.wc` | simple | websocket closed | +| `.z.pg` | phased | synchronous message (query) | +| `.z.ps` | phased | asynchronous message | +| `.z.pi` | phased | console input | +| `.z.pp` | phased | HTTP POST | +| `.z.ph` | phased | HTTP GET (see notes) | +| `.z.ws` | phased | websocket message | +| `.z.pw` | phased | password / connection check (binary, `exec`+`post` only — no `pre`) | + +Simple events take a null phase (`` ` ``); phased events take one of `` `pre`exec`post ``. + +--- + +## Dependencies + +| Dependency | Key | Required | Description | +|---|---|---|---| +| logger | `` `log `` | yes | dict with `info`, `warn`, and `error`, each binary `{[c;m]}` where `c` is a symbol context and `m` is a string | + +**No hard dependencies** on other `di.*` modules — the module works standalone. + +The `log` dependency is passed to `init` keyed on `` `log ``. It must be a dict containing all three of `info`, `warn`, and `error`: `info` records register/remove activity, `warn` reports an isolated handler that threw during dispatch, and `error` reports a rejected call before it is signalled. `init` throws immediately if `log` is absent, is not a dict, or is missing any key. No adaptation is performed — pass a dict that already conforms to the `{[c;m]}` contract (for example one built from `di.log`). + +--- + +## Initialisation + +```q +handlers:use`di.handlers + +/ a conforming log dict (or one supplied by di.log) +logdep:`info`warn`error!( + {[c;m] -1 string[c],": INFO ",m;}; + {[c;m] -1 string[c],": WARN ",m;}; + {[c;m] -2 string[c],": ERROR ",m;}); + +handlers.init[enlist[`log]!enlist logdep] +``` + +`init` must be called before any other function — there is no default logger. It is idempotent: calling it again re-wires the log dependency and leaves existing registrations and installed dispatchers intact. + +--- + +## Exported Functions + +### `init[deps]` +Validate the required `log` dependency and set up the registries. `deps` is a dict with a `` `log `` key. Idempotent. +```q +handlers.init[enlist[`log]!enlist logdep] +``` + +### `register[event;phase;name;priority;func]` +Register `func` under `name` for a `.z.*` `event`. `phase` is `` ` `` (null) for a simple event, one of `` `pre`exec`post `` for a phased event. `priority` is an integer — **lower runs first**, and ties break by registration order. + +- **Simple event** (`phase` `` ` ``): `func` is added to the fan-out. Re-registering the same `[event;name]` replaces that entry in place. +- **`exec`**: `func` becomes the single owner and produces the outcome. Re-registering under the same `name` replaces it; a different `name` on an already-owned event throws. Registering the `exec` is what installs the event's dispatcher. +- **`pre` / `post`**: added to the pre- or post-chain (priority order). Both are rejected if the event has no `exec` owner yet. A `pre` receives the request and returns the (possibly reshaped) request; a `post` is called `post[result;args]` and its return is discarded. `args` is the argument list `exec` was invoked with, **with one deliberate exception — `.z.pw`'s password is redacted**. For the six unary phased events `args` is `enlist request`, exactly what `exec` ran on: the request *after* any `pre` reshaped it, not the original caller's input. For the binary `.z.pw`, `exec` sees the real `(user;password)` but `post` sees `(user;"***")`, so a `post` handler can never read a real password. `result` is what that request produced. +```q +handlers.register[`.z.pc;`;`mytracker;0;{[w] .track.onclose w}] / simple +handlers.register[`.z.pg;`exec;`gw;0;{[q] .gw.run q}] / exec owner +handlers.register[`.z.pg;`pre;`auth;10;{[q] .auth.check q; q}] / pre - reshape/veto +handlers.register[`.z.pg;`post;`querylog;0;{[result;args] .log.q args}] / post - observe +``` + +### `remove[event;phase;name]` +Remove a previously registered handler; `phase` mirrors `register`. Removing a simple handler or a `pre`/`post` deletes its entry (the dispatcher stays installed). Removing the `exec` owner relinquishes ownership, restores the KDB-X built-in default, and clears the event's `pre`/`post` state. Removing a name that is not currently registered is a harmless no-op, logged at `info` — except removing an `exec` under a name that does *not* own it, which is an error (the event has a single owner). +```q +handlers.remove[`.z.pc;`;`mytracker] +handlers.remove[`.z.pg;`exec;`gw] +``` + +### `list[event]` +Return the registrations for a single `event` as a `phase`/`name`/`priority` table. A simple event lists its handlers (null phase) in priority order; a phased event lists its `pre` rows, then the `exec` owner, then its `post` rows (empty if unowned). +```q +select name,priority from handlers.list[`.z.pg] where phase=`pre +``` + +### `version` +The module version string. +```q +handlers.version / "0.1.0" +``` + +--- + +## Usage Example + +```q +handlers:use`di.handlers + +/ a no-op log dep for illustration (di.log would supply a real one) +logdep:`info`warn`error!(3#{[c;m]}); +handlers.init[enlist[`log]!enlist logdep] + +/ simple event - two independent close handlers, priority orders them, both run +handlers.register[`.z.pc;`;`audit; 10;{[w] }] +handlers.register[`.z.pc;`;`metrics; 0;{[w] }] +exec name from handlers.list[`.z.pc] / `metrics`audit (priority 0 before 10) + +/ phased event - one exec owner produces the outcome +handlers.register[`.z.pg;`exec;`gw;0;{[q] value q}] +select name from handlers.list[`.z.pg] where phase=`exec / ,`gw + +/ a pre reshapes or vetoes the request (runs unprotected - a throw rejects the query) +handlers.register[`.z.pg;`pre;`auth;0;{[q] q}] + +/ a post observes the result after the owner (side-effect only, cannot change it) +handlers.register[`.z.pg;`post;`querylog;0;{[result;args] }] + +/ a different exec owner on an already-owned event is rejected +handlers.register[`.z.pg;`exec;`other;0;{[q] q}] / signals: already owned by gw + +/ remove them again - removing the exec owner restores the built-in default and clears pre/post +handlers.remove[`.z.pg;`pre;`auth] +handlers.remove[`.z.pg;`exec;`gw] +handlers.remove[`.z.pc;`;`audit] +handlers.remove[`.z.pc;`;`metrics] +``` + +--- + +## Running Tests + +**Unit suite** (`test.csv`) — needs no sockets; it drives dispatch by invoking the function bound to each `.z.*` event with synthetic arguments. It covers dependency validation, simple fan-out ordering and per-handler isolation, phased `pre`/`exec`/`post` dispatch, single-owner claim/reclaim/reject, `pre` veto and `post` isolation, `.z.pw` password redaction, `.z.ph` register/dispatch and default restoration, priority ordering, and state teardown on removal. `moduletest` loads and runs it: + +```q +k4unit:use`di.k4unit +k4unit.moduletest`di.handlers +``` + +**Integration suite** (`test_integration.csv`) — stands up a real child q process on an OS-assigned ephemeral port, opens and closes a connection, and confirms a registered `.z.pc` handler actually fired. It needs a q/kdb-x binary reachable via `QHOME`, needs no other configuration, and skips cleanly when no usable binary is available. `moduletest` only ever loads `test.csv`, so load and run this suite directly: + +```q +k4unit:use`di.k4unit +.m.di.0k4unit.KUltf .Q.dd[hsym`$.Q.m.mp`di.handlers;`test_integration.csv] +.m.di.0k4unit.KUrt[] +k4unit.getresults[] / one row per assertion; ok=1 is a pass +``` + +Run the integration suite in a fresh q session — running it after `moduletest` in the same session re-runs the still-loaded unit tests against dirty module state and reports spurious failures. + +--- + +## Notes + +- `init` must be called before any other function, and is idempotent. +- **Fault isolation is asymmetric.** `pre` and `exec` run unprotected: a throw propagates to the caller, and a `pre` throw is the sanctioned way to veto a request (e.g. an auth rejection). `post` and simple handlers run isolated: a throw is logged at `warn` and cannot change the outcome or stop the other handlers. A broken `pre` blocking its event is an accepted tradeoff — q cannot tell a deliberate veto from a bug, both being a throw. +- Priority is an integer, **lower runs first**; handlers at equal priority run in registration order. +- `.z.ph` is a normal phased event, but an `exec` owner on it **replaces the KDB-X built-in HTTP GET handler wholesale** — including the `.h`-namespace response formatting — so the owner becomes fully responsible for the GET response. GET **permissioning** is done via `.h.val` (a `.h` hook, not a `.z.*` callback) and is **not managed by this module**; a `pre` veto on `.z.ph` works but does not substitute for `.h.val`-based permissioning of the default handler. `.z.ph` is the only phased event KDB-X ships a default handler for, and `\x` expunges rather than restores it, so di.handlers captures that shipped handler when the `exec` is claimed and puts it back on `remove` (the other phased events have no shipped default and `\x`-restore correctly). +- `.z.pw` is binary and supports `exec` and `post` only — there is no `pre` phase. The `exec` owner sees the real `(user;password)`; `post` handlers see `(user;"***")`, so only the owner ever sees the password. +- A `post` handler's `args` is the argument list `exec` was invoked with, **except that `.z.pw`'s password is deliberately redacted** — `exec` sees the real `(user;password)`, `post` sees `(user;"***")`, so a `post` handler can never read a real password. That asymmetry is intentional, not an oversight: it is what lets an audit-log `post` handler exist on `.z.pw` at all. For the six unary phased events `args` is `enlist request` — the request *after* any `pre` reshaped it, not the original caller's input — paired with the `result` that request produced. `.z.pw` has no `pre` phase, so its request is never transformed. +- A `post` handler runs synchronously after the owner and before its result is returned, so it adds to that event's response time — unlike simple-event handlers, where nothing awaits the return. +- A `post` handler runs in the event's own execution context; under multithreaded input (a negative `\p` port) that is not the main thread, so a `post` that writes a global hits kdb's `'noupdate` restriction, as any `.z.pg` code would. di.handlers' own dispatch only reads its registries and is unaffected. +- `.z.ts` is not managed here — it belongs to `di.timer`. +- The handler bound to a simple event before its first registration is captured once and always runs last, after every registered handler. +- Removing the `exec` owner restores the KDB-X built-in default (via `\x` for every phased event except `.z.ph`, whose shipped handler is captured and put back — see the `.z.ph` note above), not any handler that happened to be bound before the module took ownership. +- Removing a simple handler drops it from the fan-out, but the event's dispatcher — installed once on first registration — stays installed permanently; removing the last handler does not revert the event, which keeps running an empty fan-out plus the captured original. Unlike a phased event (whose `exec` `remove` restores the KDB-X built-in default — see the removal bullet above), a simple event has no path back to its pre-di.handlers binding. +- All errors raised after `init` are logged at `error` before being signalled. diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q new file mode 100644 index 00000000..e34163b6 --- /dev/null +++ b/di/handlers/handlers.q @@ -0,0 +1,248 @@ +/ central registry for KDB-X .z.* connection-lifecycle callbacks +/ the single sanctioned place to hook .z.* events - replaces per-file hand-rolled closure wrapping +/ simple events fan out to any number of registrants; phased events thread pre -> exec -> post around one owner + +/ module version - placeholder only; no project-wide version / di.depcheck convention exists yet +version:"0.1.0"; + +/ ============================================================ +/ constants (load-time) +/ ============================================================ + +/ simple events - KDB-X discards the return value, so any number of side-effect-only handlers can coexist (phase must be `) +observerevents:`.z.pc`.z.po`.z.exit`.z.wo`.z.wc; + +/ phased events - pre (transform request) -> exec (single owner produces the outcome) -> post (side-effect watchers) +/ .z.pw is included but binary and pre-less (see register); .z.ph replaces the built-in HTTP GET handler wholesale (see notes) +phasedevents:`.z.pg`.z.ps`.z.pi`.z.pp`.z.ph`.z.ws`.z.pw; + +/ per-phase handler table - name, priority (lower runs first), func; ties break by registration order +phasetableschema:([]name:`symbol$();priority:`long$();func:()); + +/ empty list result shape +emptylist:([]phase:`symbol$();name:`symbol$();priority:`long$()); + +/ ============================================================ +/ internal helpers - shared +/ ============================================================ + +upsertphase:{[t;nm;pri;func] + / replace any existing row for this name, then append - keeps names unique within a phase + (delete from t where name=nm) upsert (nm;pri;func) + }; + +/ ============================================================ +/ internal helpers - simple (fan-out) events +/ ============================================================ + +runobserver:{[event;nm;func;arg] + / apply one observer under protection - a throw is logged at warn and swallowed so the chain continues + @[func;arg;{[event;nm;e] .z.m.logwarn[`handlers;"observer ",string[nm]," on ",string[event]," failed: ",e]}[event;nm]]; + }; + +runoriginal:{[event;arg] + / apply the captured pre-existing handler last, isolated the same way as registered observers + @[.z.m.original event;arg;{[event;e] .z.m.logwarn[`handlers;"original ",string[event]," handler failed: ",e]}[event]]; + }; + +dispatchsimple:{[event;arg] + / fan out to every registered observer in priority order, then run the captured original last, each isolated + t:`priority xasc .z.m.registry event; + runobserver[event;;;arg]'[t`name;t`func]; + runoriginal[event;arg]; + }; + +installsimple:{[event] + / on first registration for an event, capture whatever is currently bound (KDB-X default if unset) and install the dispatcher + .z.m.original[event]:@[value;event;{(::)}]; + .z.m.registry[event]:phasetableschema; + set[event;dispatchsimple[event;]]; + }; + +registersimple:{[event;nm;pri;func] + / lazily install on first use, then add or replace this handler; priority orders the fan-out + if[not event in key .z.m.original;installsimple event]; + .z.m.registry[event]:upsertphase[.z.m.registry event;nm;pri;func]; + .z.m.loginfo[`register;"registered observer ",string[nm]," on ",string[event]]; + }; + +removesimple:{[event;nm] + / drop the named handler; the dispatcher and captured original keep firing regardless of remaining count + if[not event in key .z.m.registry;.z.m.loginfo[`remove;"no observers registered on ",string[event],"; nothing to remove for ",string[nm]];:(::)]; + if[not nm in exec name from .z.m.registry event;.z.m.loginfo[`remove;"observer ",string[nm]," not registered on ",string[event],"; nothing to remove"];:(::)]; + .z.m.registry[event]:delete from .z.m.registry event where name=nm; + .z.m.loginfo[`remove;"removed observer ",string[nm]," on ",string[event]]; + }; + +simplelist:{[event] + / uniform ([]phase;name;priority) view - simple events carry a null phase, priority order + select phase:`,name,priority from `priority xasc $[event in key .z.m.registry;.z.m.registry event;phasetableschema] + }; + +/ ============================================================ +/ internal helpers - phased (pre -> exec -> post) events +/ ============================================================ + +runpost:{[event;nm;func;result;args] + / apply one post handler under protection - side-effect only, never alters the result; a throw is logged at warn and swallowed + .[func;(result;args);{[event;nm;e] .z.m.logwarn[`handlers;"post handler ",string[nm]," on ",string[event]," failed: ",e]}[event;nm]]; + }; + +runpostall:{[event;result;args] + / run every post handler for event in priority order, each isolated, with the uniform func[result;args] signature + t:`priority xasc .z.m.post event; + runpost[event;;;result;args]'[t`name;t`func]; + }; + +dispatchphased:{[event;req] + / thread the request through pre (transform, unprotected) -> exec owner (unprotected) -> post (observe, protected) + pres:exec func from `priority xasc .z.m.pre event; + req:{[acc;f] f acc}/[req;pres]; + r:.z.m.ownerfunc[event] req; + runpostall[event;r;enlist req]; + r + }; + +dispatchpw:{[u;p] + / .z.pw is the only binary phased event - the owner sees the real password; post handlers see it redacted to "***"; no pre + r:.z.m.ownerfunc[`.z.pw][u;p]; + runpostall[`.z.pw;r;(u;"***")]; + r + }; + +installphased:{[event] + / install the dispatcher on first exec registration - create the empty pre/post tables and bind the event + / .z.ph is the only phased event KDB-X ships a default handler for, and \x cannot restore it - capture it to put back on remove + if[event~`.z.ph;.z.m.original[event]:@[value;event;{(::)}]]; + .z.m.pre[event]:phasetableschema; + .z.m.post[event]:phasetableschema; + set[event;$[event~`.z.pw;dispatchpw;dispatchphased[event;]]]; + }; + +registerexec:{[event;nm;pri;func] + / single owner - a different name claiming an owned event is rejected; the same name reclaims (idempotent re-init) + if[event in key .z.m.owner; + if[not nm~.z.m.owner event; + .z.m.logerr[`register;err:"di.handlers: ",string[event]," already owned by ",string[.z.m.owner event]," - call remove first"];'err]]; + .z.m.owner[event]:nm; + / update the owner function in place if the dispatcher is installed (it reads ownerfunc fresh), else install on first exec + .z.m.ownerfunc[event]:func; + .z.m.ownerpri[event]:pri; + if[not event in key .z.m.pre;installphased event]; + .z.m.loginfo[`register;"registered exec owner ",string[nm]," on ",string[event]]; + }; + +registerprepost:{[event;phase;nm;pri;func] + / pre/post cannot attach before an exec owner exists - the dispatcher is only live once exec is registered + if[not event in key .z.m.ownerfunc; + .z.m.logerr[`register;err:"di.handlers: cannot register ",string[phase]," on ",string[event]," before an exec owner exists - register the exec phase first"];'err]; + $[phase~`pre;.z.m.pre[event]:upsertphase[.z.m.pre event;nm;pri;func]; + .z.m.post[event]:upsertphase[.z.m.post event;nm;pri;func]]; + .z.m.loginfo[`register;"registered ",string[phase]," handler ",string[nm]," on ",string[event]]; + }; + +removeprepost:{[event;phase;nm] + / drop a pre or post handler; the dispatcher stays installed regardless of remaining count + tbl:$[phase~`pre;.z.m.pre;.z.m.post]; + if[not event in key tbl;.z.m.loginfo[`remove;"no ",string[phase]," handlers on ",string[event],"; nothing to remove for ",string[nm]];:(::)]; + if[not nm in exec name from tbl event;.z.m.loginfo[`remove;string[phase]," handler ",string[nm]," not registered on ",string[event],"; nothing to remove"];:(::)]; + $[phase~`pre;.z.m.pre[event]:delete from .z.m.pre event where name=nm; + .z.m.post[event]:delete from .z.m.post event where name=nm]; + .z.m.loginfo[`remove;"removed ",string[phase]," handler ",string[nm]," on ",string[event]]; + }; + +removeexec:{[event;nm] + / relinquish ownership only if this name owns it, restore the KDB-X default, and clear all phased state for the event + if[not event in key .z.m.owner;.z.m.loginfo[`remove;"no exec owner on ",string[event],"; nothing to remove for ",string[nm]];:(::)]; + if[not nm~.z.m.owner event; + .z.m.logerr[`remove;err:"di.handlers: ",string[event]," is owned by ",string[.z.m.owner event],", not ",string[nm]];'err]; + / .z.ph ships a default handler that \x expunges rather than restores, so put the captured original back; every other phased event \x-restores its built-in default + $[(event in key .z.m.original) and not (::)~.z.m.original event;set[event;.z.m.original event];system"x ",string[event]]; + .z.m.owner:.z.m.owner _ event; + .z.m.ownerfunc:.z.m.ownerfunc _ event; + .z.m.ownerpri:.z.m.ownerpri _ event; + .z.m.original:.z.m.original _ event; + .z.m.pre:.z.m.pre _ event; + .z.m.post:.z.m.post _ event; + .z.m.loginfo[`remove;"removed exec owner ",string[nm]," on ",string[event],"; restored default"]; + }; + +phasedlist:{[event] + / uniform ([]phase;name;priority) view - pre rows (priority order), the exec owner, then post rows (priority order) + pre:select phase:`pre,name,priority from `priority xasc .z.m.pre event; + post:select phase:`post,name,priority from `priority xasc .z.m.post event; + owner:([]phase:enlist `exec;name:enlist .z.m.owner event;priority:enlist .z.m.ownerpri event); + pre,owner,post + }; + +/ ============================================================ +/ public api +/ ============================================================ + +register:{[event;phase;nm;pri;func] + / register a handler for a .z.* event - phase is ` for simple events, one of `pre`exec`post for phased events + if[not -11h=type event;.z.m.logerr[`register;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type phase;.z.m.logerr[`register;err:"di.handlers: phase must be a symbol (` for simple, `pre`exec`post for phased)"];'err]; + if[not -11h=type nm;.z.m.logerr[`register;err:"di.handlers: name must be a symbol"];'err]; + if[not type[pri] within -7 -5h;.z.m.logerr[`register;err:"di.handlers: priority must be an integer"];'err]; + if[not type[func] within 100 112h;.z.m.logerr[`register;err:"di.handlers: func must be a function"];'err]; + pri:"j"$pri; + $[event in observerevents; + [if[not null phase;.z.m.logerr[`register;err:"di.handlers: ",string[event]," is a simple event - phase must be ` (null)"];'err]; + registersimple[event;nm;pri;func]]; + event in phasedevents; + [if[not phase in `pre`exec`post;.z.m.logerr[`register;err:"di.handlers: ",string[event]," is a phased event - phase must be one of `pre`exec`post"];'err]; + if[(event~`.z.pw) and phase~`pre;.z.m.logerr[`register;err:"di.handlers: .z.pw has no pre phase - register exec (owner, real password) or post (redacted watcher)"];'err]; + $[phase~`exec;registerexec[event;nm;pri;func];registerprepost[event;phase;nm;pri;func]]]; + [.z.m.logerr[`register;err:"di.handlers: unsupported event ",string[event]];'err]]; + }; + +remove:{[event;phase;nm] + / remove a handler - phase mirrors register; removing an exec restores the KDB-X default and clears the event's phased state + if[not -11h=type event;.z.m.logerr[`remove;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type phase;.z.m.logerr[`remove;err:"di.handlers: phase must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`remove;err:"di.handlers: name must be a symbol"];'err]; + $[event in observerevents; + [if[not null phase;.z.m.logerr[`remove;err:"di.handlers: ",string[event]," is a simple event - phase must be ` (null)"];'err]; + removesimple[event;nm]]; + event in phasedevents; + [if[not phase in `pre`exec`post;.z.m.logerr[`remove;err:"di.handlers: ",string[event]," is a phased event - phase must be one of `pre`exec`post"];'err]; + $[phase~`exec;removeexec[event;nm];removeprepost[event;phase;nm]]]; + [.z.m.logerr[`remove;err:"di.handlers: unsupported event ",string[event]];'err]]; + }; + +list:{[event] + / return the registered handlers for a single event as a ([]phase;name;priority) table + if[not -11h=type event;.z.m.logerr[`list;err:"di.handlers: event must be a symbol"];'err]; + :$[event in observerevents; simplelist event; + event in phasedevents; $[event in key .z.m.owner;phasedlist event;emptylist]; + [.z.m.logerr[`list;err:"di.handlers: unsupported event ",string[event]];'err]]; + }; + +init:{[deps] + / initialise di.handlers - validate the required log dependency and set up empty registries (idempotent) + / deps: a dict with a `log key; log must be a dict of binary {[c;m]} functions with `info`warn`error keys + / example: handlers.init[enlist[`log]!enlist logdep] + if[99h<>type deps; + '"di.handlers: deps must be a dict with `log key"]; + if[not `log in key deps; + '"di.handlers: log dependency is required; pass `info`warn`error functions keyed on `log"]; + if[99h<>type deps`log; + '"di.handlers: log value must be a dict; pass `info`warn`error functions"]; + if[not all `info`warn`error in key deps`log; + '"di.handlers: 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; + / initialise the registries only on first init - a direct (module-rewritten) reference detects prior setup + if[not @[{.z.m.registry;1b};::;0b]; + .z.m.registry:(`$())!(); + .z.m.original:(`$())!(); + .z.m.owner:(`$())!(); + .z.m.ownerfunc:(`$())!(); + .z.m.ownerpri:(`$())!(); + .z.m.pre:(`$())!(); + .z.m.post:(`$())!(); + ]; + .z.m.loginfo[`init;"di.handlers initialised"]; + }; diff --git a/di/handlers/init.q b/di/handlers/init.q new file mode 100644 index 00000000..925fb717 --- /dev/null +++ b/di/handlers/init.q @@ -0,0 +1,5 @@ +/ di.handlers - central registry for KDB-X .z.* connection-lifecycle callbacks + +\l ::handlers.q + +export:([init;register;remove;list;version]) diff --git a/di/handlers/test.csv b/di/handlers/test.csv new file mode 100644 index 00000000..fa7ea1f5 --- /dev/null +++ b/di/handlers/test.csv @@ -0,0 +1,195 @@ +action,ms,bytes,lang,code,repeat,minver,comment +comment,,,,,,,setup - load module and init with a capturing logger +before,0,0,q,handlers:use`di.handlers,1,1,load di.handlers module +before,0,0,q,.hh.captbl:([]lvl:`symbol$();ctx:`symbol$();msg:()),1,1,log capture table for assertions +before,0,0,q,caplog:`info`warn`error!({[c;m] `.hh.captbl insert (`info;c;m)};{[c;m] `.hh.captbl insert (`warn;c;m)};{[c;m] `.hh.captbl insert (`error;c;m)}),1,1,capturing binary logger {[c;m]} +before,0,0,q,handlers.init[enlist[`log]!enlist caplog],1,1,init with the capturing logger +before,0,0,q,.hh.ev:([]who:`symbol$()),1,1,dispatch-order capture table + +comment,,,,,,,init - dependency validation +fail,0,0,q,handlers.init[(::)],1,1,init rejects a non-dict deps +fail,0,0,q,handlers.init[()!()],1,1,init rejects missing log key +fail,0,0,q,handlers.init[enlist[`log]!enlist 42],1,1,init rejects a non-dict log value +fail,0,0,q,handlers.init[enlist[`log]!enlist `info`warn!(caplog`info;caplog`warn)],1,1,init rejects a log dict missing the error key +run,0,0,q,.hh.errstr:@[{handlers.init[()!()]};(::);{x}],1,1,capture the error string from a bad init +true,0,0,q,.hh.errstr like "di.handlers:*",1,1,init error is prefixed di.handlers: + +comment,,,,,,,module metadata - exported version +true,0,0,q,10h=type handlers.version,1,1,version is a string +true,0,0,q,0/dev/null 2>&1 & echo $!"),1,1,launch a blank child q on the OS-assigned port and capture its PID +before,0,0,q,h:.it.wait[cport;20],1,1,connect to the child with a bounded retry +before,0,0,q,if[null h;@[{system "" sv ("kill -9 ";string cpid)};::;{x}];exit 0],1,1,if the child never came up kill it by PID and skip cleanly +before,0,0,q,@[{h ".z.pc:{exit 0}"};::;{x}],1,1,backstop - tell the child to self-exit whenever its connection to this process drops +before,0,0,q,.it.roundtrip:@[{4=h "2+2"};::;{0b}],1,1,capture a real synchronous round-trip while still connected +before,0,0,q,.it.closed:@[h;"exit 0";{x}],1,1,synchronous exit request - the child dies mid-reply so the parent detects the close and .z.pc fires +before,0,0,q,@[{system "" sv ("kill -9 ";string cpid)};::;{x}],1,1,forceful kill by PID as a belt in case graceful shutdown did not take +before,0,0,q,system"sleep 0.3",1,1,give the OS a moment to reap the child +before,0,0,q,.it.gone:0=count @[{system "" sv ("ps -p ";string cpid;" -o pid=")};::;{()}],1,1,verify the child process is actually gone (ps errors when absent so the probe is protected) + +comment,,,,,,,a real IPC round-trip works while di.handlers is loaded +true,0,0,q,.it.roundtrip,1,1,synchronous query to the real child process returned the expected result + +comment,,,,,,,closing the real connection drives the registered .z.pc observer +true,0,0,q,`closed in exec who from .hh.iev,1,1,the .z.pc observer fired on the real disconnect + +comment,,,,,,,the child process is fully cleaned up +true,0,0,q,.it.gone,1,1,the child process is gone after the test