-
Notifications
You must be signed in to change notification settings - Fork 7
Feature handlers #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
alowrydi
wants to merge
11
commits into
main
Choose a base branch
from
feature-handlers
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+667
−0
Open
Feature handlers #114
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
f5ef9f1
di.handlers initial draft
alowrydi 403c995
Add version export, standardise docs and terminology, and harden the …
alowrydi 4cdbb6f
Add observe/unobserve so logging and metrics functions can watch a ha…
alowrydi f060ee6
Addressing latest logging consistency standards
alowrydi fc47a08
Fitting internal error logging conventions
alowrydi cb5d452
Improving documentation around integration testing
alowrydi 8f6f1aa
Addressing AI comment - document that an observer event's dispatcher …
alowrydi c428e1d
Address PR review feedback
alowrydi 8371a5c
no-op guard when removing/unobserving a name that is not registered
alowrydi 2c6c031
Migrate to phased pre/exec/post model (register[event;phase;name;prio…
alowrydi d78f0b1
document post's args semantics (transformed request vs .z.pw's redact…
alowrydi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| # di.handlers | ||
|
|
||
| Central registry for a KDB-X process's `.z.*` connection-lifecycle callbacks — connection open and close, websocket open and close, process exit, and the message, auth and HTTP handlers. It lets multiple independent components hook the same event without silently overwriting one another, and is the single place in a process that assigns `.z.*` directly. | ||
|
|
||
| --- | ||
|
|
||
| ## Features | ||
|
|
||
| - Register any number of **simple** handlers on an event whose return value KDB-X discards (connection open/close, websocket open/close, process exit). Every handler runs, in ascending **priority** order, and each call is isolated — one handler throwing is logged and does not stop the rest. | ||
| - Register handlers on a **phased** event whose return value KDB-X uses as the outcome (query, async, console input, HTTP POST/GET, auth, websocket message). A phased event threads its request through three phases: **`pre`** (any number, priority order — each reshapes the request), **`exec`** (exactly one owner — produces the outcome), and **`post`** (any number, priority order — side-effect-only watchers of the result). | ||
| - A phased event has a single `exec` owner: a second `exec` under a different name is rejected rather than silently replacing the first. `pre` and `post` cannot attach until an `exec` owner exists. | ||
| - **Fault-isolation is asymmetric.** `pre` and `exec` run **unprotected** — a `pre` that throws vetoes the request and propagates to the caller (this is a deliberate channel, e.g. an auth rejection). `post` and simple handlers run **protected/isolated** — a throw is logged at `warn` and changes neither the outcome nor the other handlers. | ||
| - Whatever was bound to a simple event before the first registration is preserved and still runs, last, after every registered handler. | ||
| - Remove a handler by `event`, `phase` and `name`; removing an `exec` owner restores the KDB-X built-in default and clears the event's phased state. | ||
| - Inspect what is registered on any event with `list`. | ||
|
|
||
| Managed events, by dispatch model: | ||
|
|
||
| | Event | Model | Meaning | | ||
| |---|---|---| | ||
| | `.z.pc` | simple | connection closed | | ||
| | `.z.po` | simple | connection opened | | ||
| | `.z.exit` | simple | process exit | | ||
| | `.z.wo` | simple | websocket opened | | ||
| | `.z.wc` | simple | websocket closed | | ||
| | `.z.pg` | phased | synchronous message (query) | | ||
| | `.z.ps` | phased | asynchronous message | | ||
| | `.z.pi` | phased | console input | | ||
| | `.z.pp` | phased | HTTP POST | | ||
| | `.z.ph` | phased | HTTP GET (see notes) | | ||
| | `.z.ws` | phased | websocket message | | ||
| | `.z.pw` | phased | password / connection check (binary, `exec`+`post` only — no `pre`) | | ||
|
|
||
| Simple events take a null phase (`` ` ``); phased events take one of `` `pre`exec`post ``. | ||
|
|
||
| --- | ||
|
|
||
| ## Dependencies | ||
|
|
||
| | Dependency | Key | Required | Description | | ||
| |---|---|---|---| | ||
| | logger | `` `log `` | yes | dict with `info`, `warn`, and `error`, each binary `{[c;m]}` where `c` is a symbol context and `m` is a string | | ||
|
|
||
| **No hard dependencies** on other `di.*` modules — the module works standalone. | ||
|
|
||
| The `log` dependency is passed to `init` keyed on `` `log ``. It must be a dict containing all three of `info`, `warn`, and `error`: `info` records register/remove activity, `warn` reports an isolated handler that threw during dispatch, and `error` reports a rejected call before it is signalled. `init` throws immediately if `log` is absent, is not a dict, or is missing any key. No adaptation is performed — pass a dict that already conforms to the `{[c;m]}` contract (for example one built from `di.log`). | ||
|
|
||
| --- | ||
|
|
||
| ## Initialisation | ||
|
|
||
| ```q | ||
| handlers:use`di.handlers | ||
|
|
||
| / a conforming log dict (or one supplied by di.log) | ||
| logdep:`info`warn`error!( | ||
| {[c;m] -1 string[c],": INFO ",m;}; | ||
| {[c;m] -1 string[c],": WARN ",m;}; | ||
| {[c;m] -2 string[c],": ERROR ",m;}); | ||
|
|
||
| handlers.init[enlist[`log]!enlist logdep] | ||
| ``` | ||
|
|
||
| `init` must be called before any other function — there is no default logger. It is idempotent: calling it again re-wires the log dependency and leaves existing registrations and installed dispatchers intact. | ||
|
|
||
| --- | ||
|
|
||
| ## Exported Functions | ||
|
|
||
| ### `init[deps]` | ||
| Validate the required `log` dependency and set up the registries. `deps` is a dict with a `` `log `` key. Idempotent. | ||
| ```q | ||
| handlers.init[enlist[`log]!enlist logdep] | ||
| ``` | ||
|
|
||
| ### `register[event;phase;name;priority;func]` | ||
| Register `func` under `name` for a `.z.*` `event`. `phase` is `` ` `` (null) for a simple event, one of `` `pre`exec`post `` for a phased event. `priority` is an integer — **lower runs first**, and ties break by registration order. | ||
|
|
||
| - **Simple event** (`phase` `` ` ``): `func` is added to the fan-out. Re-registering the same `[event;name]` replaces that entry in place. | ||
| - **`exec`**: `func` becomes the single owner and produces the outcome. Re-registering under the same `name` replaces it; a different `name` on an already-owned event throws. Registering the `exec` is what installs the event's dispatcher. | ||
| - **`pre` / `post`**: added to the pre- or post-chain (priority order). Both are rejected if the event has no `exec` owner yet. A `pre` receives the request and returns the (possibly reshaped) request; a `post` is called `post[result;args]` and its return is discarded. `args` is the argument list `exec` was invoked with, **with one deliberate exception — `.z.pw`'s password is redacted**. For the six unary phased events `args` is `enlist request`, exactly what `exec` ran on: the request *after* any `pre` reshaped it, not the original caller's input. For the binary `.z.pw`, `exec` sees the real `(user;password)` but `post` sees `(user;"***")`, so a `post` handler can never read a real password. `result` is what that request produced. | ||
| ```q | ||
| handlers.register[`.z.pc;`;`mytracker;0;{[w] .track.onclose w}] / simple | ||
| handlers.register[`.z.pg;`exec;`gw;0;{[q] .gw.run q}] / exec owner | ||
| handlers.register[`.z.pg;`pre;`auth;10;{[q] .auth.check q; q}] / pre - reshape/veto | ||
| handlers.register[`.z.pg;`post;`querylog;0;{[result;args] .log.q args}] / post - observe | ||
| ``` | ||
|
|
||
| ### `remove[event;phase;name]` | ||
| Remove a previously registered handler; `phase` mirrors `register`. Removing a simple handler or a `pre`/`post` deletes its entry (the dispatcher stays installed). Removing the `exec` owner relinquishes ownership, restores the KDB-X built-in default, and clears the event's `pre`/`post` state. Removing a name that is not currently registered is a harmless no-op, logged at `info` — except removing an `exec` under a name that does *not* own it, which is an error (the event has a single owner). | ||
| ```q | ||
| handlers.remove[`.z.pc;`;`mytracker] | ||
| handlers.remove[`.z.pg;`exec;`gw] | ||
| ``` | ||
|
|
||
| ### `list[event]` | ||
| Return the registrations for a single `event` as a `phase`/`name`/`priority` table. A simple event lists its handlers (null phase) in priority order; a phased event lists its `pre` rows, then the `exec` owner, then its `post` rows (empty if unowned). | ||
| ```q | ||
| select name,priority from handlers.list[`.z.pg] where phase=`pre | ||
| ``` | ||
|
|
||
| ### `version` | ||
| The module version string. | ||
| ```q | ||
| handlers.version / "0.1.0" | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Usage Example | ||
|
|
||
| ```q | ||
| handlers:use`di.handlers | ||
|
|
||
| / a no-op log dep for illustration (di.log would supply a real one) | ||
| logdep:`info`warn`error!(3#{[c;m]}); | ||
| handlers.init[enlist[`log]!enlist logdep] | ||
|
|
||
| / simple event - two independent close handlers, priority orders them, both run | ||
| handlers.register[`.z.pc;`;`audit; 10;{[w] }] | ||
| handlers.register[`.z.pc;`;`metrics; 0;{[w] }] | ||
| exec name from handlers.list[`.z.pc] / `metrics`audit (priority 0 before 10) | ||
|
|
||
| / phased event - one exec owner produces the outcome | ||
| handlers.register[`.z.pg;`exec;`gw;0;{[q] value q}] | ||
| select name from handlers.list[`.z.pg] where phase=`exec / ,`gw | ||
|
|
||
| / a pre reshapes or vetoes the request (runs unprotected - a throw rejects the query) | ||
| handlers.register[`.z.pg;`pre;`auth;0;{[q] q}] | ||
|
|
||
| / a post observes the result after the owner (side-effect only, cannot change it) | ||
| handlers.register[`.z.pg;`post;`querylog;0;{[result;args] }] | ||
|
|
||
| / a different exec owner on an already-owned event is rejected | ||
| handlers.register[`.z.pg;`exec;`other;0;{[q] q}] / signals: already owned by gw | ||
|
|
||
| / remove them again - removing the exec owner restores the built-in default and clears pre/post | ||
| handlers.remove[`.z.pg;`pre;`auth] | ||
| handlers.remove[`.z.pg;`exec;`gw] | ||
| handlers.remove[`.z.pc;`;`audit] | ||
| handlers.remove[`.z.pc;`;`metrics] | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Running Tests | ||
|
|
||
| **Unit suite** (`test.csv`) — needs no sockets; it drives dispatch by invoking the function bound to each `.z.*` event with synthetic arguments. It covers dependency validation, simple fan-out ordering and per-handler isolation, phased `pre`/`exec`/`post` dispatch, single-owner claim/reclaim/reject, `pre` veto and `post` isolation, `.z.pw` password redaction, `.z.ph` register/dispatch and default restoration, priority ordering, and state teardown on removal. `moduletest` loads and runs it: | ||
|
|
||
| ```q | ||
| k4unit:use`di.k4unit | ||
| k4unit.moduletest`di.handlers | ||
| ``` | ||
|
|
||
| **Integration suite** (`test_integration.csv`) — stands up a real child q process on an OS-assigned ephemeral port, opens and closes a connection, and confirms a registered `.z.pc` handler actually fired. It needs a q/kdb-x binary reachable via `QHOME`, needs no other configuration, and skips cleanly when no usable binary is available. `moduletest` only ever loads `test.csv`, so load and run this suite directly: | ||
|
|
||
| ```q | ||
| k4unit:use`di.k4unit | ||
| .m.di.0k4unit.KUltf .Q.dd[hsym`$.Q.m.mp`di.handlers;`test_integration.csv] | ||
| .m.di.0k4unit.KUrt[] | ||
| k4unit.getresults[] / one row per assertion; ok=1 is a pass | ||
| ``` | ||
|
|
||
| Run the integration suite in a fresh q session — running it after `moduletest` in the same session re-runs the still-loaded unit tests against dirty module state and reports spurious failures. | ||
|
|
||
| --- | ||
|
|
||
| ## Notes | ||
|
|
||
| - `init` must be called before any other function, and is idempotent. | ||
| - **Fault isolation is asymmetric.** `pre` and `exec` run unprotected: a throw propagates to the caller, and a `pre` throw is the sanctioned way to veto a request (e.g. an auth rejection). `post` and simple handlers run isolated: a throw is logged at `warn` and cannot change the outcome or stop the other handlers. A broken `pre` blocking its event is an accepted tradeoff — q cannot tell a deliberate veto from a bug, both being a throw. | ||
| - Priority is an integer, **lower runs first**; handlers at equal priority run in registration order. | ||
| - `.z.ph` is a normal phased event, but an `exec` owner on it **replaces the KDB-X built-in HTTP GET handler wholesale** — including the `.h`-namespace response formatting — so the owner becomes fully responsible for the GET response. GET **permissioning** is done via `.h.val` (a `.h` hook, not a `.z.*` callback) and is **not managed by this module**; a `pre` veto on `.z.ph` works but does not substitute for `.h.val`-based permissioning of the default handler. `.z.ph` is the only phased event KDB-X ships a default handler for, and `\x` expunges rather than restores it, so di.handlers captures that shipped handler when the `exec` is claimed and puts it back on `remove` (the other phased events have no shipped default and `\x`-restore correctly). | ||
| - `.z.pw` is binary and supports `exec` and `post` only — there is no `pre` phase. The `exec` owner sees the real `(user;password)`; `post` handlers see `(user;"***")`, so only the owner ever sees the password. | ||
| - A `post` handler's `args` is the argument list `exec` was invoked with, **except that `.z.pw`'s password is deliberately redacted** — `exec` sees the real `(user;password)`, `post` sees `(user;"***")`, so a `post` handler can never read a real password. That asymmetry is intentional, not an oversight: it is what lets an audit-log `post` handler exist on `.z.pw` at all. For the six unary phased events `args` is `enlist request` — the request *after* any `pre` reshaped it, not the original caller's input — paired with the `result` that request produced. `.z.pw` has no `pre` phase, so its request is never transformed. | ||
| - A `post` handler runs synchronously after the owner and before its result is returned, so it adds to that event's response time — unlike simple-event handlers, where nothing awaits the return. | ||
| - A `post` handler runs in the event's own execution context; under multithreaded input (a negative `\p` port) that is not the main thread, so a `post` that writes a global hits kdb's `'noupdate` restriction, as any `.z.pg` code would. di.handlers' own dispatch only reads its registries and is unaffected. | ||
| - `.z.ts` is not managed here — it belongs to `di.timer`. | ||
| - The handler bound to a simple event before its first registration is captured once and always runs last, after every registered handler. | ||
| - Removing the `exec` owner restores the KDB-X built-in default (via `\x` for every phased event except `.z.ph`, whose shipped handler is captured and put back — see the `.z.ph` note above), not any handler that happened to be bound before the module took ownership. | ||
| - Removing a simple handler drops it from the fan-out, but the event's dispatcher — installed once on first registration — stays installed permanently; removing the last handler does not revert the event, which keeps running an empty fan-out plus the captured original. Unlike a phased event (whose `exec` `remove` restores the KDB-X built-in default — see the removal bullet above), a simple event has no path back to its pre-di.handlers binding. | ||
| - All errors raised after `init` are logged at `error` before being signalled. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.