Skip to content

Feature subscriptions - #123

Open
alowrydi wants to merge 11 commits into
mainfrom
feature-subscriptions
Open

Feature subscriptions#123
alowrydi wants to merge 11 commits into
mainfrom
feature-subscriptions

Conversation

@alowrydi

@alowrydi alowrydi commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Subscription management module, di.subscriptions

Extracts code/common/subscriptions.q from TorQ and packages it as a standalone kdb-x module. Manages tickerplant subscriptions for RDB/WDB/chained-TP consumers - subscribe with optional exactly-once log replay, resubscribe after a dropped connection, unsubscribe, and a live registry of what's currently held.

The modularisation plan places di.subscriptions in the FRAMEWORK tier with hard dependencies on di.servers and di.pubsub. getsubscriptionhandles (the legacy handle-resolution function TorQ consumers call before subscribing) is ported here rather than duplicated across every future di.proc.* module, and a new opt-in republish capability lets a chained/segmented tickerplant consumer hand its subscribed tables to di.pubsub for downstream fan-out - the actual job a chained TP exists to do. Closing the di.pubsub edge safely required three small, necessary changes to that module, included here rather than as a follow-up: its .z.pc was overwriting rather than chaining, meaning depending on it as shipped would silently destroy any other module's observer; it shipped no VERSION, so a version pin against it would have been unenforceable; and it had no read counterpart to setsubtables, which the additive handoff needs. A fourth, unrelated di.pubsub fix (a broken result-shape guard in subscribestr/subscribestrfilter) is bundled in as well - found during this due-diligence read, not required by anything here, worth knowing that distinction if this gets split later.

Trello ticket - https://trello.com/c/aUtlpkfA/133-kdb-x-subscriptions-module


Files created / modified

File Status Description
di/subscriptions/init.q new Loads di.servers/di.pubsub deps, loads subscriptions.q, defines export of 9 functions
di/subscriptions/subscriptions.q new Full implementation - module state, internal helpers, public API, init
di/subscriptions/subscriptions.md new Full module documentation
di/subscriptions/deps.q new Declares di.servers >=0.1.0 and di.pubsub >=0.2.0 as hard dependencies
di/subscriptions/test.csv new 534 k4unit unit assertions
di/subscriptions/test_integration.csv new 131 k4unit integration assertions, including the three-real-process republish path
di/pubsub/pubsub.q modified .z.pc now chains via di.handlers-style capture instead of overwriting; getsubtables added; subscribestr/subscribestrfilter's result-shape guard fixed
di/pubsub/init.q modified Exports getsubtables, version
di/pubsub/VERSION new 0.2.0
di/pubsub/test.csv modified 119 assertions; portable - no hardcoded port or path

How to test

k4unit:use`di.k4unit
k4unit.moduletest`di.subscriptions
===== di.subscriptions : test.csv =====
2026.08.13T00:53:08.477 start
2026.08.13T00:53:08.477 :.../di/subscriptions/test.csv 534 test(s)
2026.08.13T00:53:08.781 end
assertions : 534
failures   : 0
by action  : `s#`fail`run`true!70 250 214

===== di.subscriptions : test_integration.csv =====
2026.08.13T00:53:08.818 start
2026.08.13T00:53:08.818 :.../di/subscriptions/test_integration.csv 131 test(s)
2026.08.13T00:53:14.390 end
assertions : 131
failures   : 0
by action  : `s#`run`true!70 61

===== di.pubsub : test.csv =====
2026.08.13T00:53:14.418 start
2026.08.13T00:53:14.418 :.../di/pubsub/test.csv 119 test(s)
2026.08.13T00:53:15.550 end
assertions : 119
failures   : 0
by action  : `s#`fail`run`true!2 62 55

784 suite assertions, 0 failures. Plus 317 smoke/adversarial-harness assertions across 12 scratchpad suites (smoke.q/smoke2.q/smoke3.q, adversarial.q, iter3.q, probe7/probe8/probe10/probe11.q, gsh.q, repub.q, pssmoke.q) - 1101 assertions total, 0 failures, 0 flags.

The integration suite runs three real processes for the republish path specifically - an upstream tickerplant, a middle process using this module with republish on, and a downstream subscriber - since a subscriber that serves its own subscribers can't be tested in-process. Its tickerplant peer runs the real di.pubsub, and assertions read that module's own registry directly, so the coupling can't regress into a stand-in.

The handoff-failure warn-not-throw path was proven non-vacuous, not just exercised: the same suite run against a copy with the fix reverted to raiseerror produces 7 failures (the module throws, the completed subscription is lost, nothing is logged at warn); with the fix in place, 0.

di.depcheck:

2026.08.12D23:50:20.082403699 INFO [depcheck] dependency check complete: 0 failure(s), 0 warning(s)
depcheck: PASS

Resolved against deps.q = di.serversdi.pubsub!("0.1.0";"0.2.0"), with di.servers at 0.1.0 and di.pubsub at 0.2.0 on disk.


Design decisions

Protocol correctness (subscribe/replay against a real segmented tickerplant)

1. Guards that don't need the tickerplant's reply run before it - fetchdetails's underlying subdetails call is .u.sub, which registers the handle for live delivery as a side effect of just fetching schemas. Every check not needing that reply (root-upd presence, the already-held-table guard, the setschema-implies-exists guard) now runs first; only the log-integrity preflight is irreducible, since log filenames only exist in the reply itself.

2. ` (all tables) is resolved via a tablelist round trip before subdetails - a segmented tickerplant's subdetails can't accept the bare sentinel directly (throws 'rank); legacy always resolved a concrete list first. Falls back to sending ` directly if no usable tablelist is offered.

3. A shared log (segmented singular/periodic multilog mode) collapses to one whole-file replay - the same physical file is reported once per table with different counts; replaying each entry separately re-applies the file's head. Collapsing to one whole-file replay trades a bounded, diagnosable trailing duplicate for never silently under-replaying, which a naive per-table sum could do.

4. A whole-file replay is narrowed to the offered tables; a genuine per-table log is not - a segmented tickerplant logs more tables than it publishes, so a whole-file read (from the shared-log collapse or the 0Wj sentinel) can carry a table the caller never subscribed to. Narrowed via the existing table filter; the common per-table-log fast path is unaffected.

5. 0Wj means "replay everything", not "more than expected" - resolved to the log's own readable total rather than forwarded to -11!, which would otherwise replay a corrupt log's good prefix and only then throw - the exact half-populated state the preflight exists to prevent.

Framework-tier dependencies and republish

6. getsubscriptionhandles ported rather than left to each caller - real TorQ callers across rdb.q, wdb.q, chainedtp.q, sctp.q all resolve a handle the same way before subscribing; centralising it here avoids four future di.proc.* modules each reimplementing legacy's `/()/intersection-vs-union selection semantics independently.

7. republish is opt-in, default off - realises the chained/segmented-tickerplant role (subscribe upstream, serve the same tables downstream) that chainedtp.q/sctp.q split across themselves in TorQ. Off by default: a plain RDB must not silently become a publisher.

8. The handoff to di.pubsub is additive - unions with whatever the publisher already serves rather than replacing it, because setsubtables replaces its list and unsubscribe deletes registry rows; a registry-only union would drop an unsubscribed table at the next unrelated subscribe. Never shrinks toward empty either, since an empty list makes di.pubsub publish every root table.

9. A handoff failure warns, doesn't throw - it's the last step of subscribe, by which point the subscription has already fully succeeded (schemas defined, log replayed, registry committed). Throwing would discard completed work over an optional secondary step.

10. di.pubsub's .z.pc now chains - a bare .z.pc:{closesub[x]} at load silently destroyed any observer another module had already registered, with di.handlers continuing to report it as live. Depending on di.pubsub as a hard dependency without fixing this would import that risk.


Checklist

  • 534/534 unit + 131/131 integration assertions passing (di.subscriptions)
  • 119/119 assertions passing (di.pubsub)
  • di.depcheck clean
  • Follows consistency.md and style.md
  • Follows dependency injection guidelines
  • subscriptions.md documents all exported functions, config, usage examples and notes
  • di.servers PR Feature server #120 and di.permissions-equivalent version conventions confirmed compatible

Documentation

See subscriptions.md for full reference, including the subdetails/tablelist protocol notes, the shared-log and whole-file replay behaviour, republish configuration, and known non-blocking limitations (live-feed filtering delegation is unverified end-to-end pending di.tickerplant; a shared log with exactly-equal per-table counts is indistinguishable from an ordinary reply and silently under-replays - both carried forward as requirements for di.tickerplant to address upstream).

…ions

Releases a handle's subscriptions before close (the local-close case .z.pc misses); validates schema type, duplicate table names, logfilelist shape and sign, empty selectors, and setschema:0b with replay. Tests: 318, was 209.
…some general housekeeping too: chain .z.pc instead of replacing it, add VERSION/version and getsubtables, signal from the string subscribe entry points, remove hardcoded ports and paths from the suite
Comment thread di/pubsub/pubsub.q
Comment thread di/subscriptions/subscriptions.q
Comment thread di/subscriptions/subscriptions.q
Comment thread di/subscriptions/test_integration.csv
@DI-Software-Engineering

Copy link
Copy Markdown

DIReview Summary

0 critical | 4 warning(s) | 0 suggestion(s)

⚠️ Spec check skipped — tracker lookup failed (NO_REF_FOUND). Standards axis only.

Comment thread di/subscriptions/subscriptions.q
@DI-Software-Engineering

Copy link
Copy Markdown

DIReview Summary

0 critical | 1 warning(s) | 0 suggestion(s)

⚠️ Spec check skipped — tracker lookup failed (NO_REF_FOUND). Standards axis only.

TorQ's code/common/pubsub.q:19 broadcasts (`endofperiod;x;y;z) and both of
its subscribers - code/rdb/endofperiod.q and code/wdb/writedown.q:52 - are
{[currp;nextp;data]}. Ours was {(neg getallhandles[])@\:(`endofperiod;x)},
which failed two ways at once, both measured:

  callendofperiod[c;n;d]  ->  'rank, so a caller following that contract
                              could not call it at all
  callendofperiod[c]      ->  a ternary subscriber is left PARTIALLY
                              APPLIED. q returns a projection, the body
                              never runs, and nothing throws, logs or
                              comes back to say so.

Same defect class as the callendofday bug fixed in #118.

callendofday deliberately stays UNARY. TorQ sends (`endofday;x;y), but the
second argument is processdata, which legacy's own rdb never reads and the
shipped .u.end alias passes ()!() for - di.rdb's endofday is unary to
match. Fixing it "for symmetry" would projection-ise every unary
subscriber, so the asymmetry is documented in pubsub.md rather than left
looking like an oversight.

15 regression rows added. Negative control: against the unary version they
fail exactly 3 - the ternary call on 'rank, plus both subscriber
assertions - and nothing else.
Comment thread di/pubsub/test.csv
@DI-Software-Engineering

Copy link
Copy Markdown

DIReview Summary

0 critical | 1 warning(s) | 0 suggestion(s)

⚠️ Spec check skipped — tracker lookup failed (NO_REF_FOUND). Standards axis only.

di.servers now refuses every accessor until its own init has run
(requireinit, servers.q:50). That is deliberate on its side - a pre-init
getservers used to return an empty table, indistinguishable from "nothing
is connected" - but the signal it raises names only di.servers and reaches
only di.servers' logger, so it travelled straight through this module: a
di.subscriptions call handed back a di.servers error and nothing landed in
this module's log.

getsubscriptionhandles now catches it and re-raises through raiseerror, so
the caller learns which of the two modules is unwired and the failure is
observable here like every other domain error in the module.

Returning an empty table instead was considered and rejected: "I cannot
tell you" is not the same answer as "no handles", and a caller that treats
them alike waits for a tickerplant that was never going to appear.

Only the getservers[`] call is wrapped. The proctype call below it is
reachable only once the first has succeeded, so init is known to have run
by then.

8 unit rows added, placed ABOVE the di.servers init row - once that module
is initialised the pre-init path cannot be reached again in the same
process. They assert all three things that matter separately: the error
carries this module's context, it names di.servers as the module needing
wiring, and it was logged here rather than bypassing this log.

Driving the real getservers also means the suite must init di.servers
itself, then tear it down immediately: init registers a .z.pc observer and
a serversretry timer job, and this suite fires .z.pc by hand (markdeadfor)
against handles that overlap the seeded SERVERS rows. teardown releases
both while leaving the accessors callable, which is what di.servers'
separate registered/initialised flags are for.

Integration suite: di.servers also now requires `deletejobs on the timer
dep - teardown deletes its retry job through it, and it is validated as
callable at init rather than left to fail inside a protected apply - so
the mocktimer stub gained one. Without it svc.init threw and took the
whole 12-row di.servers end-to-end block with it.

Suites: 570 unit + 131 integration = 701, all green. Was 562 + 131 with
the integration block failing 12.
Comment thread di/subscriptions/subscriptions.q
@DI-Software-Engineering

Copy link
Copy Markdown

DIReview Summary

0 critical | 1 warning(s) | 0 suggestion(s)

⚠️ Spec check skipped — tracker lookup failed (NO_REF_FOUND). Standards axis only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants