From f5ef9f1e8358e3bf54d8cb88837374361e7ae6b5 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Tue, 7 Jul 2026 16:46:00 +0100 Subject: [PATCH 1/9] di.handlers initial draft --- di/handlers/handlers.md | 146 +++++++++++++++++++++++++++++++ di/handlers/handlers.q | 143 ++++++++++++++++++++++++++++++ di/handlers/init.q | 5 ++ di/handlers/test.csv | 86 ++++++++++++++++++ di/handlers/test_integration.csv | 27 ++++++ 5 files changed, 407 insertions(+) create mode 100644 di/handlers/handlers.md create mode 100644 di/handlers/handlers.q create mode 100644 di/handlers/init.q create mode 100644 di/handlers/test.csv create mode 100644 di/handlers/test_integration.csv diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md new file mode 100644 index 00000000..4c5df6e9 --- /dev/null +++ b/di/handlers/handlers.md @@ -0,0 +1,146 @@ +# di.handlers + +The single sanctioned place to hook kdb+ `.z.*` connection-lifecycle callbacks. It replaces the per-file, hand-rolled closure-wrapping that TorQ processes use (where load order silently decides who wins) with one explicit, testable registry. Once this module is in use, nothing else should assign `.z.*` directly. + +--- + +## The core distinction: observer vs decider + +A `.z.*` callback can only ever hold one function, so two things wanting to hook the same event conflict. How that conflict is resolved depends entirely on **what kdb+ does with the callback's return value**: + +- **Observer events** — kdb+ **discards** the return value. The callback runs purely for side effects (record a connection, drop a session on close). Because nobody reads the result, any number of registrants can coexist safely. `di.handlers` runs them all (fan-out). +- **Decider events** — kdb+ **uses** the return value as the outcome delivered to the caller (the query result, allow/deny). A return value is singular, so only one function can coherently own the decision. `di.handlers` enforces exactly one owner and rejects a competing claim. + +| Event | Mode | Meaning | +|---|---|---| +| `.z.pc` | observer | connection closed | +| `.z.po` | observer | connection opened | +| `.z.exit` | observer | process exit | +| `.z.wo` | observer | websocket opened | +| `.z.wc` | observer | websocket closed | +| `.z.pg` | decider | sync message (query) | +| `.z.ps` | decider | async message | +| `.z.pi` | decider | console input | +| `.z.pp` | decider | HTTP POST | +| `.z.pw` | decider | password / connection check | +| `.z.ws` | decider | websocket message | + +`.z.ts` is **not** managed here — it belongs to `di.timer`. `.z.ph` is **out of scope** — see below. + +--- + +## 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 | + +`di.handlers` has **no hard dependencies** on other `di.*` modules and works standalone. + +All three log keys are required: `info` (register/remove confirmations), `warn` (an isolated observer or original that threw during dispatch), and `error` (domain errors, logged before they are signalled). `init` throws immediately if `log` is absent, is not a dict, or is missing any of the three keys. The module performs no adaptation — pass a dict that already conforms to the `{[c;m]}` contract (e.g. built from `di.log`). + +--- + +## Initialisation + +```q +handlers:use`di.handlers + +/ build a conforming log dict (or take one from 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 but leaves any live registrations and installed dispatchers untouched, so a process can safely re-initialise. + +--- + +## Exported Functions + +### `init[deps]` +Validate the required `log` dependency and set up the empty registries. Idempotent. `deps` is a dict with a `` `log `` key. +```q +handlers.init[enlist[`log]!enlist logdep] +``` + +### `register[event;name;func]` +Register `func` under `name` for a `.z.*` `event`. Behaviour depends on the event's mode. + +**Observer** — `func` is added to the fan-out for `event` in registration order. On the first registration for an event, whatever is currently bound to it is captured as the *original* and installed to run **last**, after every registered handler. Re-registering the same `[event;name]` replaces that entry in place. Each handler (and the original) is called under protection: if one throws, the error is logged at `warn` tagged with its `name` and the rest of the chain still runs. +```q +handlers.register[`.z.pc;`mytracker;{[w] .track.onclose w}] +``` + +**Decider** — the first `register` for an event installs `func` directly as the sole owner. Re-registering under the **same** `name` replaces it (idempotent re-init). Registering under a **different** `name` while the event is already owned **throws**, naming the current owner: +```q +handlers.register[`.z.pw;`myauth;{[u;p] .auth.check[u;p]}] +handlers.register[`.z.pw;`other;{[u;p] 1b}] +/ 'di.handlers: register: .z.pw already owned by myauth - call remove first +``` + +### `remove[event;name]` +Remove a previously registered handler. + +- **Observer** — deletes the named entry from the fan-out. The dispatcher and the captured original keep firing regardless of how many registrants remain. +- **Decider** — if `name` owns the event, relinquishes ownership and restores the kdb+ built-in default via `\x`. Throws if `name` does not own the event. +```q +handlers.remove[`.z.pc;`mytracker] +handlers.remove[`.z.pw;`myauth] +``` + +### `list[event]` +Return the registrations for a single `event` (monadic only — there is no all-events overload). Observers return a `name`/`func` table in registration order; deciders return a one-row table naming the owner (empty if unowned). +```q +handlers.list[`.z.pc] +/ name func +/ --------------- +/ mytracker {[w]..} +``` + +--- + +## `.z.ph` / HTTP GET is out of scope + +On any current kdb+ (3.5+), HTTP GET permissioning is applied by setting **`.h.val`** — a hook in the `.h` namespace, entirely outside `.z.*`. `.z.ph` itself is not the real gate on a modern deployment. Because `di.handlers` manages `.z.*` events only, `register[`.z.ph;...]` **throws** with a clear message rather than silently installing a handler that would do nothing: + +```q +handlers.register[`.z.ph;`x;{[r] r}] +/ 'di.handlers: register: .z.ph is out of scope - HTTP GET permissioning uses .h.val (see di.permissions), not .z.* +``` + +A module that needs to gate HTTP GET should own `.h.val` directly (this is what a permissions module does). Folding `.h.val` into `di.handlers` was considered and rejected: it would drag the module outside its `.z.*` remit and hide a `.h`-namespace assignment behind a `.z.ph`-shaped call. + +--- + +## Known limitation: observers on decider events + +**This is a real, deliberately-deferred gap — not an oversight.** In production TorQ, a decider event such as `.z.pg` is not owned by exactly one thing: it is owned by one *decision-maker* (permissions) **and** wrapped by one or more pure *observers* — query logging and per-client byte-counting both layer themselves around the same `.z.pg`/`.z.ps` and pass the return value through untouched. `di.handlers` currently models decider events as **single-owner only**, so there is no slot for an observer that wants to watch a decider event without owning its outcome. + +**Consequence.** A future `di.querylog` or `di.clienttracking` cannot, through `di.handlers` alone, observe `.z.pg`/`.z.ps` while a permissions module owns them. The interim workaround is for the decider owner to invoke the logging/tracking itself — which reintroduces exactly the coupling `di.handlers` exists to remove, so it is a stopgap, not the intended end state. + +**Follow-up design task (for whoever builds `di.querylog` / `di.clienttracking`):** extend the decider model so an event keeps exactly one owner for the *outcome* but also supports an unlimited, side-effect-only observer layer around it (return value untouched). This deserves the same dedicated design scrutiny the observer/decider split itself required and should not be improvised at implementation time. + +--- + +## Running Tests + +```q +k4unit:use`di.k4unit +k4unit.moduletest`di.handlers +``` + +The unit suite (`test.csv`) injects no-op and capturing mock loggers and drives dispatch by invoking the function actually bound to each `.z.*` event with synthetic arguments — no sockets required. It covers observer fan-out order, throw-isolation, original-runs-last, and in-place replacement; decider claim / same-name reclaim / different-name rejection and default-restoring removal; the `.z.ph` and unknown-event rejections; and `init` dependency validation. + +A separate, environment-gated integration suite (`test_integration.csv`) stands up a second blank q process on a runtime-chosen ephemeral port, opens and closes a real connection, and confirms the registered `.z.po`/`.z.pc` observers actually fired. It skips cleanly when no q binary is available. + +--- + +## Notes + +- The captured *original* for an observer is whatever was bound the instant the first handler registers — capture is lazy and per-event, so `di.handlers` never installs a dispatcher on an event that has no registrants. +- Decider `remove` restores the kdb+ built-in default, not whatever happened to be bound before `di.handlers` took ownership — deciders capture no original by design. +- All post-`init` domain errors are logged at `error` before being signalled, so failures are observable in the log and not only as a thrown exception. diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q new file mode 100644 index 00000000..7e209808 --- /dev/null +++ b/di/handlers/handlers.q @@ -0,0 +1,143 @@ +/ central registry for kdb+ .z.* connection-lifecycle callbacks +/ the single sanctioned place to hook .z.* events - replaces per-file hand-rolled closure wrapping +/ observer events fan out to any number of registrants; decider events allow exactly one owner + +/ ============================================================ +/ constants (load-time) +/ ============================================================ + +/ observer events - kdb+ discards the return value, so any number of side-effect-only handlers can coexist +observerevents:`.z.pc`.z.po`.z.exit`.z.wo`.z.wc; + +/ decider events - kdb+ uses the return value as the outcome, so only one owner can coherently exist +/ .z.ph is deliberately absent - HTTP GET permissioning uses .h.val, not .z.*, and is out of scope (see register) +deciderevents:`.z.pg`.z.ps`.z.pi`.z.pp`.z.pw`.z.ws; + +/ per-event observer registry template - keyed by handler name in registration order +registryschema:([name:`symbol$()] func:()); + +/ ============================================================ +/ internal helpers +/ ============================================================ + +raiseerror:{[ctx;msg] + / log the error under ctx then signal it, so failures are observable in the log and not just thrown + .z.m.log[`error][ctx;msg]; + '"di.handlers: ",string[ctx],": ",msg; + }; + +initstate:{ + / one-time setup of the empty registries - separated so re-init never wipes live state + .z.m.registry:(`$())!(); + .z.m.original:(`$())!(); + .z.m.owner:(`$())!(); + }; + +runobserver:{[event;nm;func;arg] + / apply one observer under protection - a throw is logged at warn and swallowed so the chain continues + .[func;enlist arg;{[event;nm;e] .z.m.log[`warn][`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;enlist arg;{[event;e] .z.m.log[`warn][`handlers;"original ",(string event)," handler failed: ",e]}[event]]; + }; + +dispatchobs:{[event;arg] + / fan out to every registered observer in registration order, then run the captured original last, each isolated + t:0!.z.m.registry event; + runobserver[event;;;arg]'[t`name;t`func]; + runoriginal[event;arg]; + }; + +installobserver:{[event] + / on first registration for an event, capture whatever is currently bound (kdb+ default if unset) and install the dispatcher + .z.m.original[event]:@[value;event;{(::)}]; + .z.m.registry[event]:registryschema; + set[event;dispatchobs[event;]]; + }; + +registerobserver:{[event;nm;func] + / lazily install on first use, then add or replace this handler in registration order + if[not event in key .z.m.original;installobserver event]; + .z.m.registry[event]:.z.m.registry[event] upsert (nm;func); + .z.m.log[`info][`register;"registered observer ",string[nm]," on ",string event]; + }; + +registerdecider:{[event;nm;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; + raiseerror[`register;(string event)," already owned by ",string[.z.m.owner event]," - call remove first"]]]; + set[event;func]; + .z.m.owner[event]:nm; + .z.m.log[`info][`register;"registered decider ",string[nm]," on ",string event]; + }; + +removeobserver:{[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.registry[event]:.z.m.registry[event] _ nm; + .z.m.log[`info][`remove;"removed observer ",string[nm]," on ",string event]; + }; + +removedecider:{[event;nm] + / relinquish ownership only if this name owns it, then restore the kdb+ built-in default via \x + if[not event in key .z.m.owner;:(::)]; + if[not nm~.z.m.owner event; + raiseerror[`remove;(string event)," is owned by ",string[.z.m.owner event],", not ",string nm]]; + system"x ",string event; + .z.m.owner:.z.m.owner _ event; + .z.m.log[`info][`remove;"removed decider ",string[nm]," on ",(string event),"; restored default"]; + }; + +/ ============================================================ +/ public api +/ ============================================================ + +register:{[event;nm;func] + / register a handler for a .z.* event - fan-out for observers, single-owner for deciders + if[not -11h=type event;raiseerror[`register;"event must be a symbol"]]; + if[not -11h=type nm;raiseerror[`register;"name must be a symbol"]]; + if[not (type func) within 100 112h;raiseerror[`register;"func must be a function"]]; + if[event~`.z.ph; + raiseerror[`register;".z.ph is out of scope - HTTP GET permissioning uses .h.val (see di.permissions), not .z.*"]]; + $[event in observerevents; registerobserver[event;nm;func]; + event in deciderevents; registerdecider[event;nm;func]; + raiseerror[`register;"unsupported event ",string event]]; + }; + +remove:{[event;nm] + / remove a handler - observers drop from the fan-out, deciders relinquish ownership and restore the kdb+ default + if[not -11h=type event;raiseerror[`remove;"event must be a symbol"]]; + if[not -11h=type nm;raiseerror[`remove;"name must be a symbol"]]; + $[event in observerevents; removeobserver[event;nm]; + event in deciderevents; removedecider[event;nm]; + raiseerror[`remove;"unsupported event ",string event]]; + }; + +list:{[event] + / return the registered handlers for a single event - observers as a name/func table, deciders as their owner + if[not -11h=type event;raiseerror[`list;"event must be a symbol"]]; + :$[event in observerevents; $[event in key .z.m.registry;0!.z.m.registry event;0!registryschema]; + event in deciderevents; $[event in key .z.m.owner;([]name:enlist .z.m.owner event);([]name:`symbol$())]; + raiseerror[`list;"unsupported event ",string event]]; + }; + +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.log:deps`log; + / initialise the registries only on first init - a direct (module-rewritten) reference detects prior setup + if[not @[{.z.m.registry;1b};::;0b];initstate[]]; + .z.m.log[`info][`init;"di.handlers initialised"]; + }; diff --git a/di/handlers/init.q b/di/handlers/init.q new file mode 100644 index 00000000..f43e275d --- /dev/null +++ b/di/handlers/init.q @@ -0,0 +1,5 @@ +/ di.handlers - central registry for kdb+ .z.* connection-lifecycle callbacks + +\l ::handlers.q + +export:([init;register;remove;list]) diff --git a/di/handlers/test.csv b/di/handlers/test.csv new file mode 100644 index 00000000..9093bc82 --- /dev/null +++ b/di/handlers/test.csv @@ -0,0 +1,86 @@ +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,,,,,,,observer - fan-out order and the captured original runs last (.z.wo) +run,0,0,q,.hh.ev:0#.hh.ev,1,1,reset order capture +run,0,0,q,.z.wo:{[w] `.hh.ev insert `orig},1,1,bind a pre-existing .z.wo to become the captured original +run,0,0,q,handlers.register[`.z.wo;`a;{[w] `.hh.ev insert `a}],1,1,register first observer a +run,0,0,q,handlers.register[`.z.wo;`b;{[w] `.hh.ev insert `b}],1,1,register second observer b +run,0,0,q,(value `.z.wo) 999,1,1,invoke the installed dispatcher with a synthetic handle +true,0,0,q,(exec who from .hh.ev)~`a`b`orig,1,1,observers run in registration order then the original last +true,0,0,q,2=count handlers.list[`.z.wo],1,1,list shows the two registered observers + +comment,,,,,,,observer - duplicate name replaces in place (.z.wo) +run,0,0,q,.hh.ev:0#.hh.ev,1,1,reset order capture +run,0,0,q,handlers.register[`.z.wo;`a;{[w] `.hh.ev insert `a2}],1,1,re-register name a with a new body +run,0,0,q,(value `.z.wo) 1,1,1,invoke the dispatcher again +true,0,0,q,(exec who from .hh.ev)~`a2`b`orig,1,1,a replaced in place keeping its position +true,0,0,q,2=count handlers.list[`.z.wo],1,1,duplicate registration did not grow the registry + +comment,,,,,,,observer - remove drops a handler while the captured original keeps firing (.z.wo) +run,0,0,q,.hh.ev:0#.hh.ev,1,1,reset order capture +run,0,0,q,handlers.remove[`.z.wo;`b],1,1,remove observer b +run,0,0,q,(value `.z.wo) 7,1,1,invoke the dispatcher +true,0,0,q,`orig in exec who from .hh.ev,1,1,the captured original still fires after a removal +true,0,0,q,not `b in exec who from .hh.ev,1,1,the removed observer no longer runs +true,0,0,q,1=count handlers.list[`.z.wo],1,1,only observer a remains registered + +comment,,,,,,,observer - one handler throwing does not stop the rest and is logged at warn (.z.wc) +run,0,0,q,.hh.ev:0#.hh.ev,1,1,reset order capture +run,0,0,q,.hh.captbl:0#.hh.captbl,1,1,reset log capture +run,0,0,q,handlers.register[`.z.wc;`bad;{[w] '`boom}],1,1,register a throwing observer first +run,0,0,q,handlers.register[`.z.wc;`good;{[w] `.hh.ev insert `good}],1,1,register a well-behaved observer second +run,0,0,q,(value `.z.wc) 1,1,1,invoke the dispatcher +true,0,0,q,(exec who from .hh.ev)~enlist `good,1,1,the good observer still ran despite the earlier throw +true,0,0,q,any `warn = exec lvl from .hh.captbl,1,1,the isolated throw was logged at warn + +comment,,,,,,,decider - single owner claim reclaim and reject (.z.pg) +run,0,0,q,handlers.register[`.z.pg;`owner1;{x}],1,1,first claim installs the owner directly +true,0,0,q,`owner1~exec first name from handlers.list[`.z.pg],1,1,list names the current owner +true,0,0,q,42~(value `.z.pg) 42,1,1,the installed owner function is bound to the event +run,0,0,q,handlers.register[`.z.pg;`owner1;{2*x}],1,1,same-name reclaim replaces the function in place +true,0,0,q,42~(value `.z.pg) 21,1,1,the reclaimed function is now in effect +fail,0,0,q,handlers.register[`.z.pg;`owner2;{x}],1,1,a different name is rejected while the event is owned + +comment,,,,,,,decider - remove requires ownership and restores the kdb+ default (.z.ps) +run,0,0,q,handlers.register[`.z.ps;`only;{x}],1,1,claim the async decider +fail,0,0,q,handlers.remove[`.z.ps;`someoneelse],1,1,removing under a non-owning name is rejected +run,0,0,q,handlers.remove[`.z.ps;`only],1,1,the owner removes and restores the kdb+ default +true,0,0,q,0=count handlers.list[`.z.ps],1,1,ownership is cleared after removal + +comment,,,,,,,scope - .z.ph and unknown events are rejected loudly +fail,0,0,q,handlers.register[`.z.ph;`x;{x}],1,1,.z.ph HTTP GET is out of scope +fail,0,0,q,handlers.register[`.z.zz;`x;{x}],1,1,unknown event rejected by register +fail,0,0,q,handlers.list[`.z.zz],1,1,unknown event rejected by list +fail,0,0,q,handlers.remove[`.z.zz;`x],1,1,unknown event rejected by remove + +comment,,,,,,,input validation on register +fail,0,0,q,handlers.register[42;`x;{x}],1,1,event must be a symbol +fail,0,0,q,handlers.register[`.z.wo;"x";{x}],1,1,name must be a symbol +fail,0,0,q,handlers.register[`.z.wo;`x;42],1,1,func must be a function + +comment,,,,,,,domain errors are logged at error before being signalled +run,0,0,q,.hh.captbl:0#.hh.captbl,1,1,reset log capture +run,0,0,q,.hh.trap:@[{handlers.register[`.z.zz;`x;{x}]};(::);{x}],1,1,trap a rejected registration +true,0,0,q,any `error = exec lvl from .hh.captbl,1,1,the domain error was logged at error + +comment,,,,,,,init is idempotent and preserves live registrations +run,0,0,q,handlers.register[`.z.wo;`persist;{[w] `.hh.ev insert `persist}],1,1,register a handler before re-init +run,0,0,q,handlers.init[enlist[`log]!enlist caplog],1,1,re-init the module +true,0,0,q,`persist in exec name from handlers.list[`.z.wo],1,1,the registration survived re-initialisation + +comment,,,,,,,cleanup - relinquish the decider so the process is left with kdb+ defaults +run,0,0,q,handlers.remove[`.z.pg;`owner1],1,1,restore the default .z.pg handler diff --git a/di/handlers/test_integration.csv b/di/handlers/test_integration.csv new file mode 100644 index 00000000..7e1538fe --- /dev/null +++ b/di/handlers/test_integration.csv @@ -0,0 +1,27 @@ +action,ms,bytes,lang,code,repeat,minver,comment +comment,,,,,,,integration test - stands up a real child q process and drives a real connection close +comment,,,,,,,requires a q binary reachable via QHOME (or q on PATH); skips cleanly if the child never comes up +comment,,,,,,,port is chosen at runtime - nothing hardcoded +comment,,,,,,,note k4unit runs every before row first then the asserts - so results are captured in befores and checked in trues + +before,0,0,q,handlers:use`di.handlers,1,1,load di.handlers module +before,0,0,q,logdep:`info`warn`error!({[c;m]};{[c;m]};{[c;m]}),1,1,silent no-op log mock +before,0,0,q,handlers.init[enlist[`log]!enlist logdep],1,1,init the module +before,0,0,q,.hh.iev:([]who:`symbol$()),1,1,capture table for observer firings +before,0,0,q,handlers.register[`.z.pc;`itest;{[w] `.hh.iev insert `closed}],1,1,register a real .z.pc observer through di.handlers +before,0,0,q,qcands:$[count qh:getenv[`QHOME];(raze(qh;"/bin/q");raze(qh;"/";string .z.o;"/q"));()],1,1,candidate q binary locations for kdbx and classic layouts +before,0,0,q,qbin:$[count ex:qcands where {not ()~key hsym `$x} each qcands;first ex;"q"],1,1,pick the first candidate that exists else fall back to q on PATH +before,0,0,q,cport:9000+rand 2000,1,1,pick a runtime port for the child +before,0,0,q,system "" sv (qbin;" -q -p ";string cport;" /dev/null 2>&1 &"),1,1,launch a blank child q listening on the port +before,0,0,q,.it.tryopen:{[p] @[{hopen (`$"" sv (":localhost:";string x);500)};p;0Ni]},1,1,attempt one connection returning a null handle on failure +before,0,0,q,.it.wait:{[p;n] $[n<=0;0Ni;not null hh:.it.tryopen p;hh;[system"sleep 0.3";.it.wait[p;n-1]]]},1,1,poll for the child to accept connections +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;exit 0],1,1,skip the whole suite cleanly if the child never came up +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 + +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 From 403c995ae084e98dda8c36da6bbfb3b071e54701 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Thu, 9 Jul 2026 15:50:32 +0100 Subject: [PATCH 2/9] Add version export, standardise docs and terminology, and harden the integration test (OS-assigned port, self-cleaning) --- di/handlers/handlers.md | 108 ++++++++++++++++--------------- di/handlers/handlers.q | 17 +++-- di/handlers/init.q | 4 +- di/handlers/test.csv | 10 ++- di/handlers/test_integration.csv | 18 ++++-- 5 files changed, 88 insertions(+), 69 deletions(-) diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md index 4c5df6e9..ab5fba74 100644 --- a/di/handlers/handlers.md +++ b/di/handlers/handlers.md @@ -1,15 +1,18 @@ # di.handlers -The single sanctioned place to hook kdb+ `.z.*` connection-lifecycle callbacks. It replaces the per-file, hand-rolled closure-wrapping that TorQ processes use (where load order silently decides who wins) with one explicit, testable registry. Once this module is in use, nothing else should assign `.z.*` directly. +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 console 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. --- -## The core distinction: observer vs decider +## Features -A `.z.*` callback can only ever hold one function, so two things wanting to hook the same event conflict. How that conflict is resolved depends entirely on **what kdb+ does with the callback's return value**: +- Register any number of **observer** handlers on an event whose return value KDB-X discards (connection open/close, websocket open/close, process exit). Every handler runs, in registration order, and each call is isolated — one handler throwing is logged and does not stop the rest. +- Register a single **owner** for a **decider** event whose return value KDB-X uses as the outcome (query, async, console, HTTP POST, auth, websocket message). A second registrant under a different name is rejected rather than silently replacing the first. +- Whatever was bound to an observer event before the first registration is preserved and still runs, last, after every registered handler. +- Remove a handler by name; removing a decider restores the KDB-X built-in default. +- Inspect what is registered on any event with `list`. -- **Observer events** — kdb+ **discards** the return value. The callback runs purely for side effects (record a connection, drop a session on close). Because nobody reads the result, any number of registrants can coexist safely. `di.handlers` runs them all (fan-out). -- **Decider events** — kdb+ **uses** the return value as the outcome delivered to the caller (the query result, allow/deny). A return value is singular, so only one function can coherently own the decision. `di.handlers` enforces exactly one owner and rejects a competing claim. +Managed events, by whether KDB-X consumes the callback's return value: | Event | Mode | Meaning | |---|---|---| @@ -18,15 +21,13 @@ A `.z.*` callback can only ever hold one function, so two things wanting to hook | `.z.exit` | observer | process exit | | `.z.wo` | observer | websocket opened | | `.z.wc` | observer | websocket closed | -| `.z.pg` | decider | sync message (query) | -| `.z.ps` | decider | async message | +| `.z.pg` | decider | synchronous message (query) | +| `.z.ps` | decider | asynchronous message | | `.z.pi` | decider | console input | | `.z.pp` | decider | HTTP POST | | `.z.pw` | decider | password / connection check | | `.z.ws` | decider | websocket message | -`.z.ts` is **not** managed here — it belongs to `di.timer`. `.z.ph` is **out of scope** — see below. - --- ## Dependencies @@ -35,9 +36,9 @@ A `.z.*` callback can only ever hold one function, so two things wanting to hook |---|---|---|---| | logger | `` `log `` | yes | dict with `info`, `warn`, and `error`, each binary `{[c;m]}` where `c` is a symbol context and `m` is a string | -`di.handlers` has **no hard dependencies** on other `di.*` modules and works standalone. +**No hard dependencies** on other `di.*` modules — the module works standalone. -All three log keys are required: `info` (register/remove confirmations), `warn` (an isolated observer or original that threw during dispatch), and `error` (domain errors, logged before they are signalled). `init` throws immediately if `log` is absent, is not a dict, or is missing any of the three keys. The module performs no adaptation — pass a dict that already conforms to the `{[c;m]}` contract (e.g. built from `di.log`). +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`). --- @@ -46,7 +47,7 @@ All three log keys are required: `info` (register/remove confirmations), `warn` ```q handlers:use`di.handlers -/ build a conforming log dict (or take one from di.log) +/ 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;}; @@ -55,74 +56,73 @@ logdep:`info`warn`error!( 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 but leaves any live registrations and installed dispatchers untouched, so a process can safely re-initialise. +`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 empty registries. Idempotent. `deps` is a dict with a `` `log `` key. +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;name;func]` -Register `func` under `name` for a `.z.*` `event`. Behaviour depends on the event's mode. - -**Observer** — `func` is added to the fan-out for `event` in registration order. On the first registration for an event, whatever is currently bound to it is captured as the *original* and installed to run **last**, after every registered handler. Re-registering the same `[event;name]` replaces that entry in place. Each handler (and the original) is called under protection: if one throws, the error is logged at `warn` tagged with its `name` and the rest of the chain still runs. -```q -handlers.register[`.z.pc;`mytracker;{[w] .track.onclose w}] -``` +Register `func` under `name` for a `.z.*` `event`. -**Decider** — the first `register` for an event installs `func` directly as the sole owner. Re-registering under the **same** `name` replaces it (idempotent re-init). Registering under a **different** `name` while the event is already owned **throws**, naming the current owner: +For an **observer** event, `func` is added to the fan-out in registration order; re-registering the same `[event;name]` replaces that entry in place. For a **decider** event, `func` becomes the single owner; re-registering under the same `name` replaces it, while a different `name` on an already-owned event throws. ```q -handlers.register[`.z.pw;`myauth;{[u;p] .auth.check[u;p]}] -handlers.register[`.z.pw;`other;{[u;p] 1b}] -/ 'di.handlers: register: .z.pw already owned by myauth - call remove first +handlers.register[`.z.pc;`mytracker;{[w] .track.onclose w}] / observer +handlers.register[`.z.pw;`myauth;{[u;p] .auth.check[u;p]}] / decider ``` ### `remove[event;name]` -Remove a previously registered handler. - -- **Observer** — deletes the named entry from the fan-out. The dispatcher and the captured original keep firing regardless of how many registrants remain. -- **Decider** — if `name` owns the event, relinquishes ownership and restores the kdb+ built-in default via `\x`. Throws if `name` does not own the event. +Remove a previously registered handler. Removing an observer deletes its entry from the fan-out; removing a decider relinquishes ownership and restores the KDB-X built-in default. ```q handlers.remove[`.z.pc;`mytracker] -handlers.remove[`.z.pw;`myauth] ``` ### `list[event]` -Return the registrations for a single `event` (monadic only — there is no all-events overload). Observers return a `name`/`func` table in registration order; deciders return a one-row table naming the owner (empty if unowned). +Return the registrations for a single `event`: observers as a `name`/`func` table in registration order, a decider as a one-row table naming its owner (empty if unowned). ```q -handlers.list[`.z.pc] -/ name func -/ --------------- -/ mytracker {[w]..} +exec name from handlers.list[`.z.pc] ``` ---- +### `version` +The module version string. +```q +handlers.version / "0.1.0" +``` -## `.z.ph` / HTTP GET is out of scope +--- -On any current kdb+ (3.5+), HTTP GET permissioning is applied by setting **`.h.val`** — a hook in the `.h` namespace, entirely outside `.z.*`. `.z.ph` itself is not the real gate on a modern deployment. Because `di.handlers` manages `.z.*` events only, `register[`.z.ph;...]` **throws** with a clear message rather than silently installing a handler that would do nothing: +## Usage Example ```q -handlers.register[`.z.ph;`x;{[r] r}] -/ 'di.handlers: register: .z.ph is out of scope - HTTP GET permissioning uses .h.val (see di.permissions), not .z.* -``` - -A module that needs to gate HTTP GET should own `.h.val` directly (this is what a permissions module does). Folding `.h.val` into `di.handlers` was considered and rejected: it would drag the module outside its `.z.*` remit and hide a `.h`-namespace assignment behind a `.z.ph`-shaped call. +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] -## Known limitation: observers on decider events +/ observer event - two independent close handlers, both run in order +handlers.register[`.z.pc;`audit; {[w] }] +handlers.register[`.z.pc;`metrics;{[w] }] +exec name from handlers.list[`.z.pc] / `audit`metrics -**This is a real, deliberately-deferred gap — not an oversight.** In production TorQ, a decider event such as `.z.pg` is not owned by exactly one thing: it is owned by one *decision-maker* (permissions) **and** wrapped by one or more pure *observers* — query logging and per-client byte-counting both layer themselves around the same `.z.pg`/`.z.ps` and pass the return value through untouched. `di.handlers` currently models decider events as **single-owner only**, so there is no slot for an observer that wants to watch a decider event without owning its outcome. +/ decider event - a single owner for the connection auth check +handlers.register[`.z.pw;`auth;{[u;p] 1b}] +exec name from handlers.list[`.z.pw] / ,`auth -**Consequence.** A future `di.querylog` or `di.clienttracking` cannot, through `di.handlers` alone, observe `.z.pg`/`.z.ps` while a permissions module owns them. The interim workaround is for the decider owner to invoke the logging/tracking itself — which reintroduces exactly the coupling `di.handlers` exists to remove, so it is a stopgap, not the intended end state. +/ a different owner on an already-owned decider is rejected +handlers.register[`.z.pw;`other;{[u;p] 1b}] / signals: already owned by auth -**Follow-up design task (for whoever builds `di.querylog` / `di.clienttracking`):** extend the decider model so an event keeps exactly one owner for the *outcome* but also supports an unlimited, side-effect-only observer layer around it (return value untouched). This deserves the same dedicated design scrutiny the observer/decider split itself required and should not be improvised at implementation time. +/ remove them again - decider remove restores the built-in default +handlers.remove[`.z.pc;`audit] +handlers.remove[`.z.pc;`metrics] +handlers.remove[`.z.pw;`auth] +``` --- @@ -133,14 +133,16 @@ k4unit:use`di.k4unit k4unit.moduletest`di.handlers ``` -The unit suite (`test.csv`) injects no-op and capturing mock loggers and drives dispatch by invoking the function actually bound to each `.z.*` event with synthetic arguments — no sockets required. It covers observer fan-out order, throw-isolation, original-runs-last, and in-place replacement; decider claim / same-name reclaim / different-name rejection and default-restoring removal; the `.z.ph` and unknown-event rejections; and `init` dependency validation. - -A separate, environment-gated integration suite (`test_integration.csv`) stands up a second blank q process on a runtime-chosen ephemeral port, opens and closes a real connection, and confirms the registered `.z.po`/`.z.pc` observers actually fired. It skips cleanly when no q binary is available. +The unit suite needs no sockets — it drives dispatch by invoking the function bound to each `.z.*` event with synthetic arguments. A separate integration suite (`test_integration.csv`) stands up a real child q process, opens and closes a connection, and confirms a registered observer actually fired. It needs no configuration and skips cleanly when no usable q binary is available; the unit suite needs neither. --- ## Notes -- The captured *original* for an observer is whatever was bound the instant the first handler registers — capture is lazy and per-event, so `di.handlers` never installs a dispatcher on an event that has no registrants. -- Decider `remove` restores the kdb+ built-in default, not whatever happened to be bound before `di.handlers` took ownership — deciders capture no original by design. -- All post-`init` domain errors are logged at `error` before being signalled, so failures are observable in the log and not only as a thrown exception. +- `init` must be called before any other function, and is idempotent. +- `register` on `.z.ph` throws — HTTP GET permissioning uses `.h.val`, not `.z.*`, and is not managed by this module. +- Decider events have exactly one owner; there is currently no way to also observe one without owning it. +- `.z.ts` is not managed here — it belongs to `di.timer`. +- The handler bound to an observer event before its first registration is captured once and always runs last, after every registered handler. +- Removing a decider restores the KDB-X built-in default, not any handler that happened to be bound before the module took ownership. +- All errors raised after `init` are logged at `error` before being signalled. diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q index 7e209808..2a6dcda5 100644 --- a/di/handlers/handlers.q +++ b/di/handlers/handlers.q @@ -1,15 +1,18 @@ -/ central registry for kdb+ .z.* connection-lifecycle callbacks +/ central registry for KDB-X .z.* connection-lifecycle callbacks / the single sanctioned place to hook .z.* events - replaces per-file hand-rolled closure wrapping / observer events fan out to any number of registrants; decider events allow exactly one owner +/ module version - placeholder only; no project-wide version / di.depcheck convention exists yet +version:"0.1.0"; + / ============================================================ / constants (load-time) / ============================================================ -/ observer events - kdb+ discards the return value, so any number of side-effect-only handlers can coexist +/ observer events - KDB-X discards the return value, so any number of side-effect-only handlers can coexist observerevents:`.z.pc`.z.po`.z.exit`.z.wo`.z.wc; -/ decider events - kdb+ uses the return value as the outcome, so only one owner can coherently exist +/ decider events - KDB-X uses the return value as the outcome, so only one owner can coherently exist / .z.ph is deliberately absent - HTTP GET permissioning uses .h.val, not .z.*, and is out of scope (see register) deciderevents:`.z.pg`.z.ps`.z.pi`.z.pp`.z.pw`.z.ws; @@ -51,7 +54,7 @@ dispatchobs:{[event;arg] }; installobserver:{[event] - / on first registration for an event, capture whatever is currently bound (kdb+ default if unset) and install the dispatcher + / 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]:registryschema; set[event;dispatchobs[event;]]; @@ -82,7 +85,7 @@ removeobserver:{[event;nm] }; removedecider:{[event;nm] - / relinquish ownership only if this name owns it, then restore the kdb+ built-in default via \x + / relinquish ownership only if this name owns it, then restore the KDB-X built-in default via \x if[not event in key .z.m.owner;:(::)]; if[not nm~.z.m.owner event; raiseerror[`remove;(string event)," is owned by ",string[.z.m.owner event],", not ",string nm]]; @@ -101,14 +104,14 @@ register:{[event;nm;func] if[not -11h=type nm;raiseerror[`register;"name must be a symbol"]]; if[not (type func) within 100 112h;raiseerror[`register;"func must be a function"]]; if[event~`.z.ph; - raiseerror[`register;".z.ph is out of scope - HTTP GET permissioning uses .h.val (see di.permissions), not .z.*"]]; + raiseerror[`register;".z.ph is not managed by di.handlers - HTTP GET permissioning uses .h.val, not .z.*"]]; $[event in observerevents; registerobserver[event;nm;func]; event in deciderevents; registerdecider[event;nm;func]; raiseerror[`register;"unsupported event ",string event]]; }; remove:{[event;nm] - / remove a handler - observers drop from the fan-out, deciders relinquish ownership and restore the kdb+ default + / remove a handler - observers drop from the fan-out, deciders relinquish ownership and restore the KDB-X default if[not -11h=type event;raiseerror[`remove;"event must be a symbol"]]; if[not -11h=type nm;raiseerror[`remove;"name must be a symbol"]]; $[event in observerevents; removeobserver[event;nm]; diff --git a/di/handlers/init.q b/di/handlers/init.q index f43e275d..925fb717 100644 --- a/di/handlers/init.q +++ b/di/handlers/init.q @@ -1,5 +1,5 @@ -/ di.handlers - central registry for kdb+ .z.* connection-lifecycle callbacks +/ di.handlers - central registry for KDB-X .z.* connection-lifecycle callbacks \l ::handlers.q -export:([init;register;remove;list]) +export:([init;register;remove;list;version]) diff --git a/di/handlers/test.csv b/di/handlers/test.csv index 9093bc82..f268ec26 100644 --- a/di/handlers/test.csv +++ b/di/handlers/test.csv @@ -14,6 +14,10 @@ fail,0,0,q,handlers.init[enlist[`log]!enlist `info`warn!(caplog`info;caplog`warn 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 &"),1,1,launch a blank child q listening on the port before,0,0,q,.it.tryopen:{[p] @[{hopen (`$"" sv (":localhost:";string x);500)};p;0Ni]},1,1,attempt one connection returning a null handle on failure before,0,0,q,.it.wait:{[p;n] $[n<=0;0Ni;not null hh:.it.tryopen p;hh;[system"sleep 0.3";.it.wait[p;n-1]]]},1,1,poll for the child to accept connections +before,0,0,q,system"p 0W",1,1,ask the OS for a free ephemeral port on this process +before,0,0,q,cport:system"p",1,1,read the OS-assigned port +before,0,0,q,system"p 0",1,1,release the listener so the child can bind that port +before,0,0,q,cpid:"J"$last system "" sv (qbin;" -q -p ";string cport;" /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;exit 0],1,1,skip the whole suite cleanly if the child never came up +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 From 4cdbb6fa4ee3ee54b3555732994dc97d5646c0a8 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Thu, 9 Jul 2026 16:02:35 +0100 Subject: [PATCH 3/9] Add observe/unobserve so logging and metrics functions can watch a handler's result without changing it --- di/handlers/handlers.md | 30 +++++++++++++--- di/handlers/handlers.q | 80 +++++++++++++++++++++++++++++++++++++++-- di/handlers/init.q | 2 +- di/handlers/test.csv | 72 +++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 9 deletions(-) diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md index ab5fba74..78839859 100644 --- a/di/handlers/handlers.md +++ b/di/handlers/handlers.md @@ -8,6 +8,7 @@ Central registry for a KDB-X process's `.z.*` connection-lifecycle callbacks — - Register any number of **observer** handlers on an event whose return value KDB-X discards (connection open/close, websocket open/close, process exit). Every handler runs, in registration order, and each call is isolated — one handler throwing is logged and does not stop the rest. - Register a single **owner** for a **decider** event whose return value KDB-X uses as the outcome (query, async, console, HTTP POST, auth, websocket message). A second registrant under a different name is rejected rather than silently replacing the first. +- Attach any number of side-effect-only **observers** to a decider event (`observe`/`unobserve`) — they run after the single owner, receive its result and the call arguments, and cannot change the outcome. - Whatever was bound to an observer event before the first registration is preserved and still runs, last, after every registered handler. - Remove a handler by name; removing a decider restores the KDB-X built-in default. - Inspect what is registered on any event with `list`. @@ -78,13 +79,25 @@ handlers.register[`.z.pw;`myauth;{[u;p] .auth.check[u;p]}] / decider ``` ### `remove[event;name]` -Remove a previously registered handler. Removing an observer deletes its entry from the fan-out; removing a decider relinquishes ownership and restores the KDB-X built-in default. +Remove a previously registered handler. Removing an observer deletes its entry from the fan-out; removing a decider relinquishes ownership, restores the KDB-X built-in default, and drops any attached decider observers. ```q handlers.remove[`.z.pc;`mytracker] ``` +### `observe[event;name;func]` +Attach a side-effect-only observer to a **decider** `event` that already has an owner (`observe` before `register` throws). After the owner returns, `func` is called as `func[result;args]` — `result` is the owner's outcome and `args` is the call's arguments as a list. Observers never change the outcome, and each is isolated: one throwing is logged at `warn` and affects neither the result nor the other observers. Re-observing the same `[event;name]` replaces it in place. For `.z.pw`, `args` is `(user;"***")` — the password is redacted, so only the owner ever sees it. +```q +handlers.observe[`.z.pg;`querylog;{[result;args] .log.query[first args;result]}] +``` + +### `unobserve[event;name]` +Detach an observer from a decider event. The dispatcher stays installed even after the last observer is removed (the owner still runs). Detaching a name that was never attached is a silent no-op. +```q +handlers.unobserve[`.z.pg;`querylog] +``` + ### `list[event]` -Return the registrations for a single `event`: observers as a `name`/`func` table in registration order, a decider as a one-row table naming its owner (empty if unowned). +Return the registrations for a single `event`: observers as a `name`/`func` table in registration order; a decider as a `role`/`name` table — the owner row first, then any attached observers (empty if unowned). ```q exec name from handlers.list[`.z.pc] ``` @@ -113,12 +126,17 @@ exec name from handlers.list[`.z.pc] / `audit`metrics / decider event - a single owner for the connection auth check handlers.register[`.z.pw;`auth;{[u;p] 1b}] -exec name from handlers.list[`.z.pw] / ,`auth +exec name from handlers.list[`.z.pw] / owner: ,`auth + +/ attach a side-effect-only observer - runs after the owner, sees (result;args), never changes the outcome +handlers.observe[`.z.pw;`audit;{[result;args] }] +exec role from handlers.list[`.z.pw] / `owner`observer / a different owner on an already-owned decider is rejected handlers.register[`.z.pw;`other;{[u;p] 1b}] / signals: already owned by auth -/ remove them again - decider remove restores the built-in default +/ remove them again - decider remove restores the built-in default and drops observers +handlers.unobserve[`.z.pw;`audit] handlers.remove[`.z.pc;`audit] handlers.remove[`.z.pc;`metrics] handlers.remove[`.z.pw;`auth] @@ -141,7 +159,9 @@ The unit suite needs no sockets — it drives dispatch by invoking the function - `init` must be called before any other function, and is idempotent. - `register` on `.z.ph` throws — HTTP GET permissioning uses `.h.val`, not `.z.*`, and is not managed by this module. -- Decider events have exactly one owner; there is currently no way to also observe one without owning it. +- A decider event has exactly one owner that produces the outcome; any number of side-effect-only observers can be attached with `observe` to watch the result without owning it. +- Observers on a decider run synchronously after the owner and before its result is returned, so they add to that event's response time — unlike observer-event handlers, where nothing awaits the return. +- A decider observer runs in the decider's own execution context; under multithreaded input (a negative `\p` port) that is not the main thread, so an observer 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 an observer event before its first registration is captured once and always runs last, after every registered handler. - Removing a decider restores the KDB-X built-in default, not any handler that happened to be bound before the module took ownership. diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q index 2a6dcda5..75baad8d 100644 --- a/di/handlers/handlers.q +++ b/di/handlers/handlers.q @@ -34,6 +34,8 @@ initstate:{ .z.m.registry:(`$())!(); .z.m.original:(`$())!(); .z.m.owner:(`$())!(); + .z.m.ownerfunc:(`$())!(); + .z.m.deciderobs:(`$())!(); }; runobserver:{[event;nm;func;arg] @@ -72,7 +74,8 @@ registerdecider:{[event;nm;func] if[event in key .z.m.owner; if[not nm~.z.m.owner event; raiseerror[`register;(string event)," already owned by ",string[.z.m.owner event]," - call remove first"]]]; - set[event;func]; + / if a dispatcher is installed (event has observers), update the owner function in place - the dispatcher reads it fresh + $[event in key .z.m.ownerfunc;.z.m.ownerfunc[event]:func;set[event;func]]; .z.m.owner[event]:nm; .z.m.log[`info][`register;"registered decider ",string[nm]," on ",string event]; }; @@ -85,15 +88,60 @@ removeobserver:{[event;nm] }; removedecider:{[event;nm] - / relinquish ownership only if this name owns it, then restore the KDB-X built-in default via \x + / relinquish ownership only if this name owns it, restore the KDB-X built-in default via \x, and clear all decider state for the event if[not event in key .z.m.owner;:(::)]; if[not nm~.z.m.owner event; raiseerror[`remove;(string event)," is owned by ",string[.z.m.owner event],", not ",string nm]]; system"x ",string event; .z.m.owner:.z.m.owner _ event; + / clear the owner function and any attached observers so a later register on this event starts clean (direct set, no dispatcher) + .z.m.ownerfunc:.z.m.ownerfunc _ event; + .z.m.deciderobs:.z.m.deciderobs _ event; .z.m.log[`info][`remove;"removed decider ",string[nm]," on ",(string event),"; restored default"]; }; +/ ------------------------------------------------------------ +/ decider-observer machinery (side-effect-only watchers on a decider event's owner) +/ ------------------------------------------------------------ + +rundeciderobs:{[event;nm;func;result;args] + / apply one decider observer 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.log[`warn][`handlers;"decider observer ",string[nm]," on ",(string event)," failed: ",e]}[event;nm]]; + }; + +rundeciderobsall:{[event;result;args] + / run every attached observer for event in registration order, each isolated, with the uniform func[result;args] signature + t:0!.z.m.deciderobs event; + rundeciderobs[event;;;result;args]'[t`name;t`func]; + }; + +dispatchdeciderunary:{[event;x] + / unary decider dispatcher - call the single owner unprotected, then run side-effect observers, return the owner result + r:.z.m.ownerfunc[event] x; + rundeciderobsall[event;r;enlist x]; + r + }; + +dispatchdeciderpw:{[u;p] + / .z.pw is the only binary decider - the owner sees the real password; observers see it redacted to "***" + r:.z.m.ownerfunc[`.z.pw][u;p]; + rundeciderobsall[`.z.pw;r;(u;"***")]; + r + }; + +installdecider:{[event] + / on first observe for a decider, capture the owner function THEN install the dispatcher - order matters, installing before capturing loses the owner + .z.m.ownerfunc[event]:value event; + .z.m.deciderobs[event]:registryschema; + set[event;$[event~`.z.pw;dispatchdeciderpw;dispatchdeciderunary[event;]]]; + }; + +deciderlist:{[event] + / owner row first, then any attached observers in registration order + obs:$[event in key .z.m.deciderobs;exec name from .z.m.deciderobs event;`symbol$()]; + ([]role:`owner,(count obs)#`observer;name:(.z.m.owner event),obs) + }; + / ============================================================ / public api / ============================================================ @@ -123,10 +171,36 @@ list:{[event] / return the registered handlers for a single event - observers as a name/func table, deciders as their owner if[not -11h=type event;raiseerror[`list;"event must be a symbol"]]; :$[event in observerevents; $[event in key .z.m.registry;0!.z.m.registry event;0!registryschema]; - event in deciderevents; $[event in key .z.m.owner;([]name:enlist .z.m.owner event);([]name:`symbol$())]; + event in deciderevents; $[event in key .z.m.owner;deciderlist event;([]role:`symbol$();name:`symbol$())]; raiseerror[`list;"unsupported event ",string event]]; }; +observe:{[event;nm;func] + / attach a side-effect-only observer to a decider event's owner - it runs after a successful owner call and never alters the result + if[not -11h=type event;raiseerror[`observe;"event must be a symbol"]]; + if[not -11h=type nm;raiseerror[`observe;"name must be a symbol"]]; + if[not (type func) within 100 112h;raiseerror[`observe;"func must be a function"]]; + if[event~`.z.ph; + raiseerror[`observe;".z.ph is not managed by di.handlers - HTTP GET permissioning uses .h.val, not .z.*"]]; + if[not event in deciderevents; + raiseerror[`observe;"only decider events can be observed; ",(string event)," is not a decider event"]]; + if[not event in key .z.m.owner; + raiseerror[`observe;"cannot observe ",(string event)," before it has an owner - call register first"]]; + / capture the owner and install the dispatcher on first observe only, then add or replace this observer in registration order + if[not event in key .z.m.ownerfunc;installdecider event]; + .z.m.deciderobs[event]:.z.m.deciderobs[event] upsert (nm;func); + .z.m.log[`info][`observe;"attached observer ",string[nm]," to ",string event]; + }; + +unobserve:{[event;nm] + / detach an observer from a decider event - the dispatcher stays installed even after the last observer is removed + if[not -11h=type event;raiseerror[`unobserve;"event must be a symbol"]]; + if[not -11h=type nm;raiseerror[`unobserve;"name must be a symbol"]]; + if[not event in key .z.m.deciderobs;:(::)]; + .z.m.deciderobs[event]:.z.m.deciderobs[event] _ nm; + .z.m.log[`info][`unobserve;"detached observer ",string[nm]," from ",string event]; + }; + 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 diff --git a/di/handlers/init.q b/di/handlers/init.q index 925fb717..fa0b19d8 100644 --- a/di/handlers/init.q +++ b/di/handlers/init.q @@ -2,4 +2,4 @@ \l ::handlers.q -export:([init;register;remove;list;version]) +export:([init;register;remove;list;version;observe;unobserve]) diff --git a/di/handlers/test.csv b/di/handlers/test.csv index f268ec26..e1f7e36a 100644 --- a/di/handlers/test.csv +++ b/di/handlers/test.csv @@ -88,3 +88,75 @@ true,0,0,q,`persist in exec name from handlers.list[`.z.wo],1,1,the registration comment,,,,,,,cleanup - relinquish the decider so the process is left with KDB-X defaults run,0,0,q,handlers.remove[`.z.pg;`owner1],1,1,restore the default .z.pg handler + +comment,,,,,,,decider observers - observe requires the event to have an owner first +fail,0,0,q,handlers.observe[`.z.pg;`ql;{[r;a]}],1,1,observe before register throws + +comment,,,,,,,decider observers - .z.pw owner sees the real password observer sees *** +run,0,0,q,handlers.register[`.z.pw;`auth;{[u;p] .oo.ownerpw::p;1b}],1,1,owner records the password it receives and allows +run,0,0,q,handlers.observe[`.z.pw;`audit;{[r;a] .oo.obspw::a}],1,1,observer records the args it receives +run,0,0,q,.oo.pwres:(value `.z.pw)[`alice;"secret"],1,1,drive the installed .z.pw dispatcher +true,0,0,q,.oo.pwres~1b,1,1,the owner result flows back to the caller unchanged +true,0,0,q,.oo.ownerpw~"secret",1,1,the owner sees the real password +true,0,0,q,.oo.obspw~(`alice;"***"),1,1,the observer sees the redacted args never the password +true,0,0,q,`owner`observer~exec role from handlers.list[`.z.pw],1,1,list shows an owner row then an observer row +true,0,0,q,`auth`audit~exec name from handlers.list[`.z.pw],1,1,owner name first then observer name + +comment,,,,,,,decider observers - owner throw means observers never run and the error propagates +run,0,0,q,.oo.ran:0b,1,1,reset the observer-ran flag +run,0,0,q,handlers.register[`.z.pg;`dec;{[q] '`denied}],1,1,owner that always throws +run,0,0,q,handlers.observe[`.z.pg;`watch;{[r;a] .oo.ran::1b}],1,1,observer that records whether it ran +fail,0,0,q,(value `.z.pg)"2+2",1,1,the owner throw propagates through the dispatcher +true,0,0,q,not .oo.ran,1,1,the observer never ran because the owner threw + +comment,,,,,,,decider observers - register reclaim after observe swaps the owner and keeps the dispatcher +run,0,0,q,handlers.register[`.z.pg;`dec;{[q] 100}],1,1,reclaim the same owner name with a new function +true,0,0,q,100~(value `.z.pg)"ignored",1,1,the dispatcher now calls the reclaimed owner function +true,0,0,q,not {[q] 100}~value `.z.pg,1,1,reclaim updated the owner in place and left the dispatcher installed + +comment,,,,,,,decider observers - a decider never observed is bound directly with no dispatcher +run,0,0,q,handlers.register[`.z.pi;`raw;{x}],1,1,register a decider and never observe it +true,0,0,q,{x}~value `.z.pi,1,1,.z.pi is bound to the owner function itself not a dispatcher + +comment,,,,,,,decider observers - removing an observed owner clears all state and a fresh register starts clean +run,0,0,q,handlers.register[`.z.ws;`o1;{x}],1,1,claim a decider +run,0,0,q,handlers.observe[`.z.ws;`w1;{[r;a]}],1,1,attach an observer which installs the dispatcher +run,0,0,q,handlers.remove[`.z.ws;`o1],1,1,remove the owner +true,0,0,q,0=count handlers.list[`.z.ws],1,1,list is empty after owner removal +true,0,0,q,not `.z.ws in key .m.di.0handlers.ownerfunc,1,1,the owner function entry is cleared +true,0,0,q,not `.z.ws in key .m.di.0handlers.deciderobs,1,1,the observer registry entry is cleared +run,0,0,q,handlers.register[`.z.ws;`o2;{2*x}],1,1,register a fresh owner on the same event +true,0,0,q,{2*x}~value `.z.ws,1,1,fresh register is a direct set with no leftover dispatcher + +comment,,,,,,,decider observers - duplicate-name observe replaces in place +run,0,0,q,handlers.register[`.z.pp;`owner;{x}],1,1,claim a decider +run,0,0,q,handlers.observe[`.z.pp;`dup;{[r;a] `.oo.d insert `first}],1,1,attach observer dup +run,0,0,q,handlers.observe[`.z.pp;`dup;{[r;a] `.oo.d insert `second}],1,1,re-attach the same name with a new body +run,0,0,q,.oo.d:([]v:`symbol$()),1,1,reset the observer capture table +run,0,0,q,(value `.z.pp)"x",1,1,drive the dispatcher once +true,0,0,q,(exec v from .oo.d)~enlist `second,1,1,only the replacement body ran +true,0,0,q,1=count select from handlers.list[`.z.pp] where role=`observer,1,1,one observer row not two + +comment,,,,,,,decider observers - unobserve removes an observer and leaves the dispatcher installed +run,0,0,q,handlers.unobserve[`.z.pp;`neverattached],1,1,unobserve an unknown name is a silent no-op +true,0,0,q,1=count select from handlers.list[`.z.pp] where role=`observer,1,1,the real observer is unaffected +run,0,0,q,handlers.unobserve[`.z.pp;`dup],1,1,detach the real observer +true,0,0,q,0=count select from handlers.list[`.z.pp] where role=`observer,1,1,no observers remain +true,0,0,q,not {x}~value `.z.pp,1,1,the dispatcher stays installed after the last observer is removed +run,0,0,q,handlers.unobserve[`.z.pi;`anything],1,1,unobserve on a never-observed decider is a silent no-op + +comment,,,,,,,decider observers - observe and unobserve reject bad argument types +fail,0,0,q,handlers.observe[42;`x;{[r;a]}],1,1,observe event must be a symbol +fail,0,0,q,handlers.observe[`.z.pg;"x";{[r;a]}],1,1,observe name must be a symbol +fail,0,0,q,handlers.observe[`.z.pg;`x;42],1,1,observe func must be a function +fail,0,0,q,handlers.observe[`.z.ph;`x;{[r;a]}],1,1,observe rejects .z.ph +fail,0,0,q,handlers.observe[`.z.pc;`x;{[r;a]}],1,1,observe rejects observer events +fail,0,0,q,handlers.unobserve[42;`x],1,1,unobserve event must be a symbol +fail,0,0,q,handlers.unobserve[`.z.pg;"x"],1,1,unobserve name must be a symbol + +comment,,,,,,,decider observers - cleanup restores kdb-x defaults on the touched deciders +run,0,0,q,handlers.remove[`.z.pw;`auth],1,1,restore default .z.pw +run,0,0,q,handlers.remove[`.z.pg;`dec],1,1,restore default .z.pg +run,0,0,q,handlers.remove[`.z.pi;`raw],1,1,restore default .z.pi +run,0,0,q,handlers.remove[`.z.ws;`o2],1,1,restore default .z.ws +run,0,0,q,handlers.remove[`.z.pp;`owner],1,1,restore default .z.pp From f060ee6bb22dff2834ae0771f3669dcc812e31a8 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Wed, 15 Jul 2026 14:57:27 +0100 Subject: [PATCH 4/9] Addressing latest logging consistency standards --- di/handlers/handlers.q | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q index 75baad8d..89ee1788 100644 --- a/di/handlers/handlers.q +++ b/di/handlers/handlers.q @@ -25,7 +25,7 @@ registryschema:([name:`symbol$()] func:()); raiseerror:{[ctx;msg] / log the error under ctx then signal it, so failures are observable in the log and not just thrown - .z.m.log[`error][ctx;msg]; + .z.m.logerr[ctx;msg]; '"di.handlers: ",string[ctx],": ",msg; }; @@ -40,12 +40,12 @@ initstate:{ runobserver:{[event;nm;func;arg] / apply one observer under protection - a throw is logged at warn and swallowed so the chain continues - .[func;enlist arg;{[event;nm;e] .z.m.log[`warn][`handlers;"observer ",string[nm]," on ",(string event)," failed: ",e]}[event;nm]]; + .[func;enlist 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;enlist arg;{[event;e] .z.m.log[`warn][`handlers;"original ",(string event)," handler failed: ",e]}[event]]; + .[.z.m.original event;enlist arg;{[event;e] .z.m.logwarn[`handlers;"original ",(string event)," handler failed: ",e]}[event]]; }; dispatchobs:{[event;arg] @@ -66,7 +66,7 @@ registerobserver:{[event;nm;func] / lazily install on first use, then add or replace this handler in registration order if[not event in key .z.m.original;installobserver event]; .z.m.registry[event]:.z.m.registry[event] upsert (nm;func); - .z.m.log[`info][`register;"registered observer ",string[nm]," on ",string event]; + .z.m.loginfo[`register;"registered observer ",string[nm]," on ",string event]; }; registerdecider:{[event;nm;func] @@ -77,14 +77,14 @@ registerdecider:{[event;nm;func] / if a dispatcher is installed (event has observers), update the owner function in place - the dispatcher reads it fresh $[event in key .z.m.ownerfunc;.z.m.ownerfunc[event]:func;set[event;func]]; .z.m.owner[event]:nm; - .z.m.log[`info][`register;"registered decider ",string[nm]," on ",string event]; + .z.m.loginfo[`register;"registered decider ",string[nm]," on ",string event]; }; removeobserver:{[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.registry[event]:.z.m.registry[event] _ nm; - .z.m.log[`info][`remove;"removed observer ",string[nm]," on ",string event]; + .z.m.loginfo[`remove;"removed observer ",string[nm]," on ",string event]; }; removedecider:{[event;nm] @@ -97,7 +97,7 @@ removedecider:{[event;nm] / clear the owner function and any attached observers so a later register on this event starts clean (direct set, no dispatcher) .z.m.ownerfunc:.z.m.ownerfunc _ event; .z.m.deciderobs:.z.m.deciderobs _ event; - .z.m.log[`info][`remove;"removed decider ",string[nm]," on ",(string event),"; restored default"]; + .z.m.loginfo[`remove;"removed decider ",string[nm]," on ",(string event),"; restored default"]; }; / ------------------------------------------------------------ @@ -106,7 +106,7 @@ removedecider:{[event;nm] rundeciderobs:{[event;nm;func;result;args] / apply one decider observer 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.log[`warn][`handlers;"decider observer ",string[nm]," on ",(string event)," failed: ",e]}[event;nm]]; + .[func;(result;args);{[event;nm;e] .z.m.logwarn[`handlers;"decider observer ",string[nm]," on ",(string event)," failed: ",e]}[event;nm]]; }; rundeciderobsall:{[event;result;args] @@ -189,7 +189,7 @@ observe:{[event;nm;func] / capture the owner and install the dispatcher on first observe only, then add or replace this observer in registration order if[not event in key .z.m.ownerfunc;installdecider event]; .z.m.deciderobs[event]:.z.m.deciderobs[event] upsert (nm;func); - .z.m.log[`info][`observe;"attached observer ",string[nm]," to ",string event]; + .z.m.loginfo[`observe;"attached observer ",string[nm]," to ",string event]; }; unobserve:{[event;nm] @@ -198,7 +198,7 @@ unobserve:{[event;nm] if[not -11h=type nm;raiseerror[`unobserve;"name must be a symbol"]]; if[not event in key .z.m.deciderobs;:(::)]; .z.m.deciderobs[event]:.z.m.deciderobs[event] _ nm; - .z.m.log[`info][`unobserve;"detached observer ",string[nm]," from ",string event]; + .z.m.loginfo[`unobserve;"detached observer ",string[nm]," from ",string event]; }; init:{[deps] @@ -213,8 +213,10 @@ init:{[deps] '"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.log: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];initstate[]]; - .z.m.log[`info][`init;"di.handlers initialised"]; + .z.m.loginfo[`init;"di.handlers initialised"]; }; From fc47a08f1686706e26fad2258abeaa553a350113 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Wed, 15 Jul 2026 18:05:33 +0100 Subject: [PATCH 5/9] Fitting internal error logging conventions --- di/handlers/handlers.q | 46 ++++++++++++++++++------------------------ di/handlers/test.csv | 7 +++++++ 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q index 89ee1788..d7a5d7fd 100644 --- a/di/handlers/handlers.q +++ b/di/handlers/handlers.q @@ -23,12 +23,6 @@ registryschema:([name:`symbol$()] func:()); / internal helpers / ============================================================ -raiseerror:{[ctx;msg] - / log the error under ctx then signal it, so failures are observable in the log and not just thrown - .z.m.logerr[ctx;msg]; - '"di.handlers: ",string[ctx],": ",msg; - }; - initstate:{ / one-time setup of the empty registries - separated so re-init never wipes live state .z.m.registry:(`$())!(); @@ -73,7 +67,7 @@ registerdecider:{[event;nm;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; - raiseerror[`register;(string event)," already owned by ",string[.z.m.owner event]," - call remove first"]]]; + .z.m.logerr[`register;err:"di.handlers: ",(string event)," already owned by ",string[.z.m.owner event]," - call remove first"];'err]]; / if a dispatcher is installed (event has observers), update the owner function in place - the dispatcher reads it fresh $[event in key .z.m.ownerfunc;.z.m.ownerfunc[event]:func;set[event;func]]; .z.m.owner[event]:nm; @@ -91,7 +85,7 @@ removedecider:{[event;nm] / relinquish ownership only if this name owns it, restore the KDB-X built-in default via \x, and clear all decider state for the event if[not event in key .z.m.owner;:(::)]; if[not nm~.z.m.owner event; - raiseerror[`remove;(string event)," is owned by ",string[.z.m.owner event],", not ",string nm]]; + .z.m.logerr[`remove;err:"di.handlers: ",(string event)," is owned by ",string[.z.m.owner event],", not ",string nm];'err]; system"x ",string event; .z.m.owner:.z.m.owner _ event; / clear the owner function and any attached observers so a later register on this event starts clean (direct set, no dispatcher) @@ -148,44 +142,44 @@ deciderlist:{[event] register:{[event;nm;func] / register a handler for a .z.* event - fan-out for observers, single-owner for deciders - if[not -11h=type event;raiseerror[`register;"event must be a symbol"]]; - if[not -11h=type nm;raiseerror[`register;"name must be a symbol"]]; - if[not (type func) within 100 112h;raiseerror[`register;"func must be a function"]]; + if[not -11h=type event;.z.m.logerr[`register;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`register;err:"di.handlers: name must be a symbol"];'err]; + if[not (type func) within 100 112h;.z.m.logerr[`register;err:"di.handlers: func must be a function"];'err]; if[event~`.z.ph; - raiseerror[`register;".z.ph is not managed by di.handlers - HTTP GET permissioning uses .h.val, not .z.*"]]; + .z.m.logerr[`register;err:"di.handlers: .z.ph is not managed here - HTTP GET permissioning uses .h.val, not .z.*"];'err]; $[event in observerevents; registerobserver[event;nm;func]; event in deciderevents; registerdecider[event;nm;func]; - raiseerror[`register;"unsupported event ",string event]]; + [.z.m.logerr[`register;err:"di.handlers: unsupported event ",string event];'err]]; }; remove:{[event;nm] / remove a handler - observers drop from the fan-out, deciders relinquish ownership and restore the KDB-X default - if[not -11h=type event;raiseerror[`remove;"event must be a symbol"]]; - if[not -11h=type nm;raiseerror[`remove;"name must be a symbol"]]; + if[not -11h=type event;.z.m.logerr[`remove;err:"di.handlers: event 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; removeobserver[event;nm]; event in deciderevents; removedecider[event;nm]; - raiseerror[`remove;"unsupported event ",string event]]; + [.z.m.logerr[`remove;err:"di.handlers: unsupported event ",string event];'err]]; }; list:{[event] / return the registered handlers for a single event - observers as a name/func table, deciders as their owner - if[not -11h=type event;raiseerror[`list;"event must be a symbol"]]; + if[not -11h=type event;.z.m.logerr[`list;err:"di.handlers: event must be a symbol"];'err]; :$[event in observerevents; $[event in key .z.m.registry;0!.z.m.registry event;0!registryschema]; event in deciderevents; $[event in key .z.m.owner;deciderlist event;([]role:`symbol$();name:`symbol$())]; - raiseerror[`list;"unsupported event ",string event]]; + [.z.m.logerr[`list;err:"di.handlers: unsupported event ",string event];'err]]; }; observe:{[event;nm;func] / attach a side-effect-only observer to a decider event's owner - it runs after a successful owner call and never alters the result - if[not -11h=type event;raiseerror[`observe;"event must be a symbol"]]; - if[not -11h=type nm;raiseerror[`observe;"name must be a symbol"]]; - if[not (type func) within 100 112h;raiseerror[`observe;"func must be a function"]]; + if[not -11h=type event;.z.m.logerr[`observe;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`observe;err:"di.handlers: name must be a symbol"];'err]; + if[not (type func) within 100 112h;.z.m.logerr[`observe;err:"di.handlers: func must be a function"];'err]; if[event~`.z.ph; - raiseerror[`observe;".z.ph is not managed by di.handlers - HTTP GET permissioning uses .h.val, not .z.*"]]; + .z.m.logerr[`observe;err:"di.handlers: .z.ph is not managed here - HTTP GET permissioning uses .h.val, not .z.*"];'err]; if[not event in deciderevents; - raiseerror[`observe;"only decider events can be observed; ",(string event)," is not a decider event"]]; + .z.m.logerr[`observe;err:"di.handlers: only decider events can be observed; ",(string event)," is not a decider event"];'err]; if[not event in key .z.m.owner; - raiseerror[`observe;"cannot observe ",(string event)," before it has an owner - call register first"]]; + .z.m.logerr[`observe;err:"di.handlers: cannot observe ",(string event)," before it has an owner - call register first"];'err]; / capture the owner and install the dispatcher on first observe only, then add or replace this observer in registration order if[not event in key .z.m.ownerfunc;installdecider event]; .z.m.deciderobs[event]:.z.m.deciderobs[event] upsert (nm;func); @@ -194,8 +188,8 @@ observe:{[event;nm;func] unobserve:{[event;nm] / detach an observer from a decider event - the dispatcher stays installed even after the last observer is removed - if[not -11h=type event;raiseerror[`unobserve;"event must be a symbol"]]; - if[not -11h=type nm;raiseerror[`unobserve;"name must be a symbol"]]; + if[not -11h=type event;.z.m.logerr[`unobserve;err:"di.handlers: event must be a symbol"];'err]; + if[not -11h=type nm;.z.m.logerr[`unobserve;err:"di.handlers: name must be a symbol"];'err]; if[not event in key .z.m.deciderobs;:(::)]; .z.m.deciderobs[event]:.z.m.deciderobs[event] _ nm; .z.m.loginfo[`unobserve;"detached observer ",string[nm]," from ",string event]; diff --git a/di/handlers/test.csv b/di/handlers/test.csv index e1f7e36a..6fd00e48 100644 --- a/di/handlers/test.csv +++ b/di/handlers/test.csv @@ -81,6 +81,13 @@ run,0,0,q,.hh.captbl:0#.hh.captbl,1,1,reset log capture run,0,0,q,.hh.trap:@[{handlers.register[`.z.zz;`x;{x}]};(::);{x}],1,1,trap a rejected registration true,0,0,q,any `error = exec lvl from .hh.captbl,1,1,the domain error was logged at error +comment,,,,,,,the signalled string is the house idiom - di.handlers-prefixed with no per-function context tag and equal to the logged message +run,0,0,q,.hh.captbl:0#.hh.captbl,1,1,reset log capture +run,0,0,q,.hh.sig:@[{handlers.register[42;`x;{x}]};(::);{x}],1,1,capture the signal from a representative rejected register +true,0,0,q,.hh.sig like "di.handlers: event must be a symbol",1,1,signal is di.handlers-prefixed with no register: context tag +true,0,0,q,.hh.sig~last exec msg from .hh.captbl where lvl=`error,1,1,the signalled string equals the logged error message +true,0,0,q,`register=last exec ctx from .hh.captbl where lvl=`error,1,1,the per-function context is on the log symbol arg not in the signal + comment,,,,,,,init is idempotent and preserves live registrations run,0,0,q,handlers.register[`.z.wo;`persist;{[w] `.hh.ev insert `persist}],1,1,register a handler before re-init run,0,0,q,handlers.init[enlist[`log]!enlist caplog],1,1,re-init the module From cb5d452e3b2bbcc656afc0b14679b90c80f12d77 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Fri, 17 Jul 2026 10:38:34 +0100 Subject: [PATCH 6/9] Improving documentation around integration testing --- di/handlers/handlers.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md index 78839859..6e0cd850 100644 --- a/di/handlers/handlers.md +++ b/di/handlers/handlers.md @@ -146,12 +146,23 @@ handlers.remove[`.z.pw;`auth] ## Running Tests +**Unit suite** (`test.csv`) — needs no sockets; it drives dispatch by invoking the function bound to each `.z.*` event with synthetic arguments. `moduletest` loads and runs it: + ```q k4unit:use`di.k4unit k4unit.moduletest`di.handlers ``` -The unit suite needs no sockets — it drives dispatch by invoking the function bound to each `.z.*` event with synthetic arguments. A separate integration suite (`test_integration.csv`) stands up a real child q process, opens and closes a connection, and confirms a registered observer actually fired. It needs no configuration and skips cleanly when no usable q binary is available; the unit suite needs neither. +**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` observer 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. --- From 8f6f1aa8df8a360264b85d73671295dc0813ad70 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Fri, 17 Jul 2026 16:15:08 +0100 Subject: [PATCH 7/9] Addressing AI comment - document that an observer event's dispatcher isn't uninstalled once installed --- di/handlers/handlers.md | 1 + 1 file changed, 1 insertion(+) diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md index 6e0cd850..d86cd814 100644 --- a/di/handlers/handlers.md +++ b/di/handlers/handlers.md @@ -176,4 +176,5 @@ Run the integration suite in a fresh q session — running it after `moduletest` - `.z.ts` is not managed here — it belongs to `di.timer`. - The handler bound to an observer event before its first registration is captured once and always runs last, after every registered handler. - Removing a decider restores the KDB-X built-in default, not any handler that happened to be bound before the module took ownership. +- Removing an observer with `remove` drops it from the fan-out, but the event's dispatcher — installed once on first registration — stays installed permanently; removing the last observer does not revert the event, which keeps running an empty fan-out plus the captured original. Unlike a decider (whose `remove` restores the KDB-X built-in default via `\x`), an observer event has no path back to its pre-di.handlers binding. - All errors raised after `init` are logged at `error` before being signalled. From c428e1da8020ee88aa691fa8701bf0e24bae2cdd Mon Sep 17 00:00:00 2001 From: alowrydi Date: Mon, 20 Jul 2026 15:20:26 +0100 Subject: [PATCH 8/9] Address PR review feedback --- di/handlers/handlers.md | 2 +- di/handlers/handlers.q | 61 ++++++++++++++++++++--------------------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md index d86cd814..bff3617b 100644 --- a/di/handlers/handlers.md +++ b/di/handlers/handlers.md @@ -91,7 +91,7 @@ handlers.observe[`.z.pg;`querylog;{[result;args] .log.query[first args;result]}] ``` ### `unobserve[event;name]` -Detach an observer from a decider event. The dispatcher stays installed even after the last observer is removed (the owner still runs). Detaching a name that was never attached is a silent no-op. +Detach an observer from a decider event. The dispatcher stays installed even after the last observer is removed (the owner still runs). Detaching a name that was never attached is a no-op, logged at info. ```q handlers.unobserve[`.z.pg;`querylog] ``` diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q index d7a5d7fd..ad112272 100644 --- a/di/handlers/handlers.q +++ b/di/handlers/handlers.q @@ -23,23 +23,14 @@ registryschema:([name:`symbol$()] func:()); / internal helpers / ============================================================ -initstate:{ - / one-time setup of the empty registries - separated so re-init never wipes live state - .z.m.registry:(`$())!(); - .z.m.original:(`$())!(); - .z.m.owner:(`$())!(); - .z.m.ownerfunc:(`$())!(); - .z.m.deciderobs:(`$())!(); - }; - runobserver:{[event;nm;func;arg] / apply one observer under protection - a throw is logged at warn and swallowed so the chain continues - .[func;enlist arg;{[event;nm;e] .z.m.logwarn[`handlers;"observer ",string[nm]," on ",(string event)," failed: ",e]}[event;nm]]; + @[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;enlist arg;{[event;e] .z.m.logwarn[`handlers;"original ",(string event)," handler failed: ",e]}[event]]; + @[.z.m.original event;arg;{[event;e] .z.m.logwarn[`handlers;"original ",string[event]," handler failed: ",e]}[event]]; }; dispatchobs:{[event;arg] @@ -60,38 +51,38 @@ registerobserver:{[event;nm;func] / lazily install on first use, then add or replace this handler in registration order if[not event in key .z.m.original;installobserver event]; .z.m.registry[event]:.z.m.registry[event] upsert (nm;func); - .z.m.loginfo[`register;"registered observer ",string[nm]," on ",string event]; + .z.m.loginfo[`register;"registered observer ",string[nm]," on ",string[event]]; }; registerdecider:{[event;nm;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.logerr[`register;err:"di.handlers: ",string[event]," already owned by ",string[.z.m.owner event]," - call remove first"];'err]]; / if a dispatcher is installed (event has observers), update the owner function in place - the dispatcher reads it fresh $[event in key .z.m.ownerfunc;.z.m.ownerfunc[event]:func;set[event;func]]; .z.m.owner[event]:nm; - .z.m.loginfo[`register;"registered decider ",string[nm]," on ",string event]; + .z.m.loginfo[`register;"registered decider ",string[nm]," on ",string[event]]; }; removeobserver:{[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;:(::)]; + if[not event in key .z.m.registry;.z.m.loginfo[`remove;"no observers registered on ",string[event],"; nothing to remove for ",string[nm]];:(::)]; .z.m.registry[event]:.z.m.registry[event] _ nm; - .z.m.loginfo[`remove;"removed observer ",string[nm]," on ",string event]; + .z.m.loginfo[`remove;"removed observer ",string[nm]," on ",string[event]]; }; removedecider:{[event;nm] / relinquish ownership only if this name owns it, restore the KDB-X built-in default via \x, and clear all decider state for the event - if[not event in key .z.m.owner;:(::)]; + if[not event in key .z.m.owner;.z.m.loginfo[`remove;"no decider 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]; - system"x ",string event; + .z.m.logerr[`remove;err:"di.handlers: ",string[event]," is owned by ",string[.z.m.owner event],", not ",string[nm]];'err]; + system"x ",string[event]; .z.m.owner:.z.m.owner _ event; / clear the owner function and any attached observers so a later register on this event starts clean (direct set, no dispatcher) .z.m.ownerfunc:.z.m.ownerfunc _ event; .z.m.deciderobs:.z.m.deciderobs _ event; - .z.m.loginfo[`remove;"removed decider ",string[nm]," on ",(string event),"; restored default"]; + .z.m.loginfo[`remove;"removed decider ",string[nm]," on ",string[event],"; restored default"]; }; / ------------------------------------------------------------ @@ -100,7 +91,7 @@ removedecider:{[event;nm] rundeciderobs:{[event;nm;func;result;args] / apply one decider observer 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;"decider observer ",string[nm]," on ",(string event)," failed: ",e]}[event;nm]]; + .[func;(result;args);{[event;nm;e] .z.m.logwarn[`handlers;"decider observer ",string[nm]," on ",string[event]," failed: ",e]}[event;nm]]; }; rundeciderobsall:{[event;result;args] @@ -144,12 +135,12 @@ register:{[event;nm;func] / register a handler for a .z.* event - fan-out for observers, single-owner for deciders if[not -11h=type event;.z.m.logerr[`register;err:"di.handlers: event must be a symbol"];'err]; if[not -11h=type nm;.z.m.logerr[`register;err:"di.handlers: name must be a symbol"];'err]; - if[not (type func) within 100 112h;.z.m.logerr[`register;err:"di.handlers: func must be a function"];'err]; + if[not type[func] within 100 112h;.z.m.logerr[`register;err:"di.handlers: func must be a function"];'err]; if[event~`.z.ph; .z.m.logerr[`register;err:"di.handlers: .z.ph is not managed here - HTTP GET permissioning uses .h.val, not .z.*"];'err]; $[event in observerevents; registerobserver[event;nm;func]; event in deciderevents; registerdecider[event;nm;func]; - [.z.m.logerr[`register;err:"di.handlers: unsupported event ",string event];'err]]; + [.z.m.logerr[`register;err:"di.handlers: unsupported event ",string[event]];'err]]; }; remove:{[event;nm] @@ -158,7 +149,7 @@ remove:{[event;nm] if[not -11h=type nm;.z.m.logerr[`remove;err:"di.handlers: name must be a symbol"];'err]; $[event in observerevents; removeobserver[event;nm]; event in deciderevents; removedecider[event;nm]; - [.z.m.logerr[`remove;err:"di.handlers: unsupported event ",string event];'err]]; + [.z.m.logerr[`remove;err:"di.handlers: unsupported event ",string[event]];'err]]; }; list:{[event] @@ -166,33 +157,33 @@ list:{[event] if[not -11h=type event;.z.m.logerr[`list;err:"di.handlers: event must be a symbol"];'err]; :$[event in observerevents; $[event in key .z.m.registry;0!.z.m.registry event;0!registryschema]; event in deciderevents; $[event in key .z.m.owner;deciderlist event;([]role:`symbol$();name:`symbol$())]; - [.z.m.logerr[`list;err:"di.handlers: unsupported event ",string event];'err]]; + [.z.m.logerr[`list;err:"di.handlers: unsupported event ",string[event]];'err]]; }; observe:{[event;nm;func] / attach a side-effect-only observer to a decider event's owner - it runs after a successful owner call and never alters the result if[not -11h=type event;.z.m.logerr[`observe;err:"di.handlers: event must be a symbol"];'err]; if[not -11h=type nm;.z.m.logerr[`observe;err:"di.handlers: name must be a symbol"];'err]; - if[not (type func) within 100 112h;.z.m.logerr[`observe;err:"di.handlers: func must be a function"];'err]; + if[not type[func] within 100 112h;.z.m.logerr[`observe;err:"di.handlers: func must be a function"];'err]; if[event~`.z.ph; .z.m.logerr[`observe;err:"di.handlers: .z.ph is not managed here - HTTP GET permissioning uses .h.val, not .z.*"];'err]; if[not event in deciderevents; - .z.m.logerr[`observe;err:"di.handlers: only decider events can be observed; ",(string event)," is not a decider event"];'err]; + .z.m.logerr[`observe;err:"di.handlers: only decider events can be observed; ",string[event]," is not a decider event"];'err]; if[not event in key .z.m.owner; - .z.m.logerr[`observe;err:"di.handlers: cannot observe ",(string event)," before it has an owner - call register first"];'err]; + .z.m.logerr[`observe;err:"di.handlers: cannot observe ",string[event]," before it has an owner - call register first"];'err]; / capture the owner and install the dispatcher on first observe only, then add or replace this observer in registration order if[not event in key .z.m.ownerfunc;installdecider event]; .z.m.deciderobs[event]:.z.m.deciderobs[event] upsert (nm;func); - .z.m.loginfo[`observe;"attached observer ",string[nm]," to ",string event]; + .z.m.loginfo[`observe;"attached observer ",string[nm]," to ",string[event]]; }; unobserve:{[event;nm] / detach an observer from a decider event - the dispatcher stays installed even after the last observer is removed if[not -11h=type event;.z.m.logerr[`unobserve;err:"di.handlers: event must be a symbol"];'err]; if[not -11h=type nm;.z.m.logerr[`unobserve;err:"di.handlers: name must be a symbol"];'err]; - if[not event in key .z.m.deciderobs;:(::)]; + if[not event in key .z.m.deciderobs;.z.m.loginfo[`unobserve;"no observers attached to ",string[event],"; nothing to detach for ",string[nm]];:(::)]; .z.m.deciderobs[event]:.z.m.deciderobs[event] _ nm; - .z.m.loginfo[`unobserve;"detached observer ",string[nm]," from ",string event]; + .z.m.loginfo[`unobserve;"detached observer ",string[nm]," from ",string[event]]; }; init:{[deps] @@ -211,6 +202,12 @@ init:{[deps] .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];initstate[]]; + if[not @[{.z.m.registry;1b};::;0b]; + .z.m.registry:(`$())!(); + .z.m.original:(`$())!(); + .z.m.owner:(`$())!(); + .z.m.ownerfunc:(`$())!(); + .z.m.deciderobs:(`$())!(); + ]; .z.m.loginfo[`init;"di.handlers initialised"]; }; From 8371a5c44d026ec46abd99396b24ae8a8d234372 Mon Sep 17 00:00:00 2001 From: alowrydi Date: Tue, 21 Jul 2026 11:27:51 +0100 Subject: [PATCH 9/9] no-op guard when removing/unobserving a name that is not registered --- di/handlers/handlers.md | 2 +- di/handlers/handlers.q | 2 ++ di/handlers/test.csv | 7 +++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/di/handlers/handlers.md b/di/handlers/handlers.md index bff3617b..2c84c0e6 100644 --- a/di/handlers/handlers.md +++ b/di/handlers/handlers.md @@ -79,7 +79,7 @@ handlers.register[`.z.pw;`myauth;{[u;p] .auth.check[u;p]}] / decider ``` ### `remove[event;name]` -Remove a previously registered handler. Removing an observer deletes its entry from the fan-out; removing a decider relinquishes ownership, restores the KDB-X built-in default, and drops any attached decider observers. +Remove a previously registered handler. Removing an observer deletes its entry from the fan-out; removing a decider relinquishes ownership, restores the KDB-X built-in default, and drops any attached decider observers. Removing a name that is not currently registered is a harmless no-op, logged at `info` (removing a decider under a name that does *not* own it is an error, not a no-op — the event has a single owner). ```q handlers.remove[`.z.pc;`mytracker] ``` diff --git a/di/handlers/handlers.q b/di/handlers/handlers.q index ad112272..3b5f8f7c 100644 --- a/di/handlers/handlers.q +++ b/di/handlers/handlers.q @@ -68,6 +68,7 @@ registerdecider:{[event;nm;func] removeobserver:{[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]:.z.m.registry[event] _ nm; .z.m.loginfo[`remove;"removed observer ",string[nm]," on ",string[event]]; }; @@ -182,6 +183,7 @@ unobserve:{[event;nm] if[not -11h=type event;.z.m.logerr[`unobserve;err:"di.handlers: event must be a symbol"];'err]; if[not -11h=type nm;.z.m.logerr[`unobserve;err:"di.handlers: name must be a symbol"];'err]; if[not event in key .z.m.deciderobs;.z.m.loginfo[`unobserve;"no observers attached to ",string[event],"; nothing to detach for ",string[nm]];:(::)]; + if[not nm in exec name from .z.m.deciderobs event;.z.m.loginfo[`unobserve;"observer ",string[nm]," not attached to ",string[event],"; nothing to detach"];:(::)]; .z.m.deciderobs[event]:.z.m.deciderobs[event] _ nm; .z.m.loginfo[`unobserve;"detached observer ",string[nm]," from ",string[event]]; }; diff --git a/di/handlers/test.csv b/di/handlers/test.csv index 6fd00e48..1ea85fa8 100644 --- a/di/handlers/test.csv +++ b/di/handlers/test.csv @@ -41,6 +41,9 @@ run,0,0,q,(value `.z.wo) 7,1,1,invoke the dispatcher true,0,0,q,`orig in exec who from .hh.ev,1,1,the captured original still fires after a removal true,0,0,q,not `b in exec who from .hh.ev,1,1,the removed observer no longer runs true,0,0,q,1=count handlers.list[`.z.wo],1,1,only observer a remains registered +run,0,0,q,handlers.remove[`.z.wo;`neverreg],1,1,remove a name never registered while a remains is a no-op +true,0,0,q,1=count handlers.list[`.z.wo],1,1,the no-op left the registry unchanged +true,0,0,q,`a in exec name from handlers.list[`.z.wo],1,1,the existing observer a is still present comment,,,,,,,observer - one handler throwing does not stop the rest and is logged at warn (.z.wc) run,0,0,q,.hh.ev:0#.hh.ev,1,1,reset order capture @@ -145,12 +148,12 @@ true,0,0,q,(exec v from .oo.d)~enlist `second,1,1,only the replacement body ran true,0,0,q,1=count select from handlers.list[`.z.pp] where role=`observer,1,1,one observer row not two comment,,,,,,,decider observers - unobserve removes an observer and leaves the dispatcher installed -run,0,0,q,handlers.unobserve[`.z.pp;`neverattached],1,1,unobserve an unknown name is a silent no-op +run,0,0,q,handlers.unobserve[`.z.pp;`neverattached],1,1,unobserve an unknown name while an observer is attached is a no-op true,0,0,q,1=count select from handlers.list[`.z.pp] where role=`observer,1,1,the real observer is unaffected run,0,0,q,handlers.unobserve[`.z.pp;`dup],1,1,detach the real observer true,0,0,q,0=count select from handlers.list[`.z.pp] where role=`observer,1,1,no observers remain true,0,0,q,not {x}~value `.z.pp,1,1,the dispatcher stays installed after the last observer is removed -run,0,0,q,handlers.unobserve[`.z.pi;`anything],1,1,unobserve on a never-observed decider is a silent no-op +run,0,0,q,handlers.unobserve[`.z.pi;`anything],1,1,unobserve on a never-observed decider is a no-op comment,,,,,,,decider observers - observe and unobserve reject bad argument types fail,0,0,q,handlers.observe[42;`x;{[r;a]}],1,1,observe event must be a symbol