Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions di/servers/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.1.0
12 changes: 12 additions & 0 deletions di/servers/init.q
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/ connection management and handle-by-type lookup for the modular torq world.
\l ::servers.q
/ module version, read from the VERSION file rather than hardcoded, so a release bump touches one
/ plain-text file. read module-relative at load (`:::` resolves to di/servers), and BEFORE the export
/ line since export:([...]) evaluates each name. NB `version` stays in the export: di.depcheck
/ resolves a dependency's version from the export dict.
/ trim so a trailing newline/CRLF cannot pad the semver; fail loud with a clear message if VERSION is
/ missing/unreadable/empty (it is a required module file - better than a raw OS error, a silent empty
/ value, or a misleading 0.0.0 that would corrupt depcheck's version comparison).
version:@[{trim first read0 x};`:::VERSION;{'"di.servers: VERSION file missing or unreadable"}];
if[0=count version;'"di.servers: VERSION file is empty"];
export:([init;startup;getservers;gethandlebytype;waitfortype;getapimeta;version])
Comment thread
ascottDI marked this conversation as resolved.
142 changes: 142 additions & 0 deletions di/servers/servers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# di.servers

Connection management and handle-by-type lookup for the modular TorQ world — the `di.*`
analogue of TorQ's `.servers` (`code/handlers/trackservers.q` + `servers.q`), scoped down
for v1: no discovery service, no password/access-list files, no non-TorQ process tracking,
no FinSpace. `process.csv` here is a static **phone book** (who to *dial*), **not** an
identity source — self-identity comes from config, injected by `di.torq`.

FRAMEWORK-tier module: no hard `di.*` dependencies; `log`, `timer` and `handlers` are all
**injected** (all required, no fallback).

## init and config

Standard **one-arg `init[deps]`**: `di.torq` merges this process's resolved config slice into
the same `deps` dict it passes the injectables in, so `deps` carries both the injectable
dependencies **and** the config keys. `init` wires the deps, records self-identity, and
installs two one-time process-global side effects — a `.z.pc` cleanup handler and a 10s retry
timer job. It is **idempotent** (guarded by an internal `registered` flag): `di.torq` calls
it once per process, but a second call refreshes the dep refs without re-registering (a
duplicate `di.timer.addjob` id would throw). `init` does **not** open connections.

`deps` keys:

| key | kind | meaning |
|---|---|---|
| `log` | injectable | binary `` `info`warn`error `` `{[c;m]}` logger dict — di.log's `logdict``log` satisfies this directly (it carries all six levels; the extra `trace`/`debug`/`fatal` are ignored) |
| `timer` | injectable | the di.timer export dict; di.servers calls `` timer[`addjob][`custom] `` — the 6-arg `{[id;func;params;period;mode;opts]}` variant (`addjob` is a variant dict of `custom`/`default`/`simple`) |
| `handlers` | injectable | di.handlers contract; `register[event;phase;nm;pri;func]` |
| `proctype`/`procname` | config | this process's own identity (required); used to exclude self from `process.csv` |
| `connections` | config | proctypes this process should dial (symbols, or strings from a `.toml` cascade — normalised). Optional; default = none |
| `processcsv` | config | **path** to `process.csv`; supplied by di.torq. Optional; required only once `connections` is non-empty |

```q
svc:use`di.servers
svc.init[deps] / deps = injectables + config, assembled by di.torq
svc.startup[] / open the configured connections (reads init config)
h:svc.gethandlebytype[`hdb;`any]
h "1+1"
```

## Exported functions

| Function | Signature | Description |
|---|---|---|
| `init` | `init[deps]` | Wire deps + config, record identity, install the `.z.pc` handler + retry job. Idempotent. |
| `startup` | `startup[]` | Read `process.csv` (`processcsv`), drop self, connect to each row whose proctype is in `connections`. A failed connection is logged (not raised) and left as `w:0Ni` for `retry`. No-op if no connections configured. **Idempotent**: skips procs already tracked in `SERVERS`, so a repeat call (or a grown `process.csv`) adds only new rows — never a duplicate or a leaked second handle. `process.csv` must be the strict v1 4-column `host,port,proctype,procname` layout — a reordered or wider header is **rejected loudly** (the reader is positional, so it would otherwise misparse silently). **Self-exclusion is an exact `(proctype;procname)` match against the identity from config**, so a `process.csv` that disagrees would leave this process dialling itself; any row carrying this process's `procname` **that survives the connections filter (i.e. one this process would otherwise dial)** is therefore skipped with a warning naming both proctypes. A drifted row whose proctype isn't a connection type is already filtered out and poses no self-connection risk, so it is not warned about. |
| `getservers` | ``getservers[proctype]`` | Live (`w` non-null) `SERVERS` rows. Accepts a **symbol**, a **symbol list** (rows for any of them), or `` ` `` for **every** proctype — the contract legacy TorQ's `.servers.getservers` (`trackservers.q:75`) and sibling `di.serverselect.getservers` both implement, so a consumer written against either works here unchanged. |
| `gethandlebytype` | `gethandlebytype[proctype;selection]` | One live handle via `` `any``/`roundrobin`/`last``; `0Ni` if none. Bumps usage stats. |
| `waitfortype` | `waitfortype[proctype;timeoutms;pollms]` | Block until a live connection exists or timeout; `1b`/`0b`. Caller decides if a timeout is fatal. `startup` must have run first. |
| `getapimeta` | `getapimeta[]` | This module's api metadata, one row per **callable** API function (`init`/`getapimeta`/`version` plumbing omitted), for `di.torq` to register with `di.api`. |
| `version` | `version` | The module's semver string (`"0.1.0"`) — metadata, not a function. Read at load from the plain-text `VERSION` file in the module folder; read by `di.depcheck` to satisfy other modules' declared minimum-version requirements. |

Export is deliberately conservative — only functions `di.torq` or a consumer actually calls
(so `di.api` lists exactly these), plus the `version` metadata string. The rest are **internal**: `retry` (the scheduled
`serversretry` job — passed to the timer *by value* at init, so it needs no export; it first
runs `cleanup` to sweep ungracefully-vanished handles, then reopens every dead handle),
`cleanup`, `formathp`, `opencon`, `readprocesscsv`, `retryrows`, `selector`, `updatestats`,
`signalfound`, `raiseerror`; plus state (`SERVERS`, `self`, `registered`, `HOPENTIMEOUT`,
`connections`, `processcsv`).

## The `SERVERS` table

```q
SERVERS:([]procname:`symbol$();proctype:`symbol$();hpup:`symbol$();w:`int$();hits:`int$();startp:`timestamp$();lastp:`timestamp$();endp:`timestamp$())
```

A direct analogue of legacy TorQ's `.servers.SERVERS`: `w` is the live handle (`0Ni` when
disconnected), `hits`/`lastp` drive handle selection, `startp`/`endp` track lifecycle.

## `.z.pc` registration via di.handlers

`.z.pc` (connection closed) is a **simple/observer** event in di.handlers — side-effect only,
fan-out — so di.servers registers its cleanup callback through the injected `handlers`
dependency rather than assigning `.z.pc` directly:

```q
(handlers[`register])[`.z.pc;`;`servers;0j;pcfunc]
```

`register`'s signature is `register[event;phase;nm;pri;func]`; for a simple event the `phase`
must be `` ` `` (null) — di.handlers rejects a non-null phase on an observer event. This lets
di.servers' disconnect hook coexist with every other `.z.pc` registrant in the same
priority-ordered fan-out.

## Conventions (learnings from di.config)

- **One-arg `init[deps]`** with config folded into `deps` (the project convention; matches
`di.eodtime`'s optional-config-in-deps pattern), not a two-arg `init[config;deps]`.
- **Three-flat-var logging** — `.z.m.loginfo`/`.z.m.logwarn`/`.z.m.logerr`, matching
`consistency.md`, `di.compression` and `di.config`. (The project hasn't globally frozen this
vs. the single-dict form — flag before changing.)
- **`raiseerror` (log-then-signal)** for all post-init domain errors (`selector` unknown
selection, missing or malformed `process.csv`). `init`'s own dependency validation is the one
exception (plain `'` — no logger yet).
- **`getapimeta`** exported; a test asserts it documents exactly the module's *callable*
exports — `init`/`getapimeta`/`version` are plumbing/metadata (di.torq calls or reads them by
convention) and are deliberately omitted from the registry rows, matching di.toml and the skill
convention.
- **`version` export** — a bare exported semver string (`"0.1.0"`, numeric `major.minor.patch`),
read by di.depcheck to satisfy other modules' declared minimum-version requirements. The source
of truth is **`version.txt`** in the module folder: `init.q` reads it module-relative at load
(`:::version.txt`) and it takes **priority** over the compiled-in fallback in `init.q`; a missing
or empty `version.txt` falls back to that default. Bump the release version by editing
`version.txt` alone.
- **Env-free** — di.servers reads no environment variable; the `process.csv` path arrives via
`config`processcsv` (di.torq resolves it), holding di.config's env-free boundary.

## Open items / not yet done

- **Live-peer integration tests are in place** (`test.q` + `test.csv`, 37 checks), and now wire the
**real merged `di.timer` and `di.log`** — only `di.handlers` (not yet merged) is mocked. They spawn
a genuinely separate `q` peer (a self-connect returns pseudo-handle `0`, not a real socket) and
cover: `startup` connecting to a live peer and logging a failed dial while excluding self,
`gethandlebytype` returning a live remote handle (`2=h"1+1"`), the retry cycle recovering an
ungraceful kill (`cleanup`+reopen), and `waitfortype` connected-vs-timeout — plus init validation,
dep-wiring, idempotency, input validation, and `getapimeta`. `init` schedules `serversretry` in the
real `di.timer` (asserted via `` timer.getalljobs[] ``); because `retry`/`cleanup` are internal, the
retry cycle is driven by invoking the exact func di.servers handed the timer (`` firejob `` reads it
back from `` getalljobs[] `` — the actually-wired path), not a direct export. Idempotent re-init is
a genuine test here: the real timer's `` addjob[`custom] `` throws on a duplicate id, so a
non-idempotent `init` would fail outright. A final check re-inits against di.log's real `logdict` to
prove the injected-log contract holds end-to-end.
- **`di.handlers` not in kdbx-modules yet**, so only that injected contract is mocked. The handlers
mock uses the real `register[event;phase;nm;pri;func]` shape from `handlers.q`; the `.z.pc` observer
path is therefore exercised only via the explicit `retry`→`cleanup` sweep, not a live auto-fired
`.z.pc` (which real di.handlers would install).
- **`config`processcsv` and the assembled `connections` list** depend on di.torq's config
wiring — coordinate when di.torq's servers dep is built.
- Scoped-out (v1): discovery service, password/access-list files, non-TorQ tracking, and
`tcps`/`unix` socket types. Only `tcp` is supported; `formathp` builds a `tcp` handle with no
socket-type arg — a future `SOCKETTYPE` config reintroduces that (with a test) when needed.

## Tests

Run in a fresh q session (spawns and kills a real peer process; don't interleave with other
modules' tests). Needs `QHOME` set (the peer is launched via `$QHOME/bin/q`) and `di.os` on
`QPATH` (the harness uses `os.abspath` to load `test.q`):

```q
k4unit:use`di.k4unit
k4unit.moduletest`di.servers
```
Loading