Feature subscriptions - #123
Conversation
…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
| / plan classifies di.pubsub as STANDALONE - it takes no injected dependencies, so it cannot reach | ||
| / di.handlers without contradicting its own tier | ||
| priorpc:@[value;`.z.pc;{[e] (::)}]; | ||
| .z.pc:{[w] |
There was a problem hiding this comment.
getsubtables always throws and never returns the table list: the trap @[{[x] t};::;{[e] symbol$()}]passes::as the argumentxto the lambda{[x] t}, but tis a bare name that — inside module code — is rewritten to.z.m.tat load. The argumentxis never used, so the lambda always tries to read.z.m.t. If .z.m.texists this works, but if the module is loaded in a context where the compile-time rewrite resolves differently, or ifthas not been set yet, the outer lambda throws and the trap always returns`symbol$(). More critically, the argument to @[f;x;g]is::, so fis called asf[::]— which is{[x] t}[::]— not as{[x] t}[]. For a zero-argument read the correct form is @[{[] .z.m.t};::;{[e] `symbol$()}](or simply reference.z.m.t` directly inside the trap).
| replaylogs:{[entries;wanted;syms;alltabs] | ||
| / replay every pre-subscription log the tickerplant reported. entries are the PREFLIGHTED triples | ||
| / from preflightlogs - shared logs already collapsed to one replay each, any 0W already resolved | ||
| if[0=count entries; |
There was a problem hiding this comment.
resubscribe removes dead rows only for the tables it successfully re-established (done), but the deletion predicate is not active, f'[tabs] — i.e. it deletes every inactive row whose full tabs list is a subset of done. A subscription to multiple tables (e.g. `trade`quote) is only deleted when both are in done; if one of them failed and only the other was re-established, the row is kept permanently, and every subsequent call to resubscribe will re-attempt the failed table and warn about it — exactly the timer-noise the comment above says was fixed. The deletion should match on individual table membership or on the handle, not on the full tabs list.
| / -11!(n;logfile) under protected apply, returning (1b;count) or (0b;error). n is the message count | ||
| / the tickerplant had logged when we subscribed, so messages that arrive after that - which also | ||
| / come down the live feed - are not replayed as well | ||
| :@[{(1b;-11!(x 0;x 1))};(nmsg;lf);{[e] (0b;e)}]; |
There was a problem hiding this comment.
requiretablesexist checks wanted where not wanted in tables[\.], but at the call site on line ~565 (if[not alltabs; if[not setschema; requiretablesexist[requested]]]) it is called with requested, which is the tablelist-resolved list — potentially larger than what schemalistwill actually return. The comment on the surrounding code explicitly acknowledges this for thesetschema:0bpost-reply call againstwanted(line ~575), but the pre-reply call still usesrequested. For an explicit request this means a table in tablelistbut absent fromschemalist(a valid case the code later handles withwarnmissing) will cause a spurious early failure even though setschema:0breplay would have succeeded. The pre-reply call should usetabs(the caller's explicit list, before narrowing), notrequested`.
| run,0,0,q,SH:sub.getsubscriptionhandles[`tickerplant;()],1,1,the rdb.q:163 call shape against a genuinely populated registry | ||
| true,0,0,q,1=count SH,1,1,exactly one tickerplant resolved | ||
| true,0,0,q,(enlist`peertp)~exec procname from SH,1,1,and it is the peer this suite spawned | ||
| true,0,0,q,`procname`proctype`w~cols SH,1,1,projected to the three columns a subscribe caller needs |
There was a problem hiding this comment.
The middle-process readiness check 1b~MH"MIDREADY" races with the upstream sub.subscribe inside the middle process. The middle script reports its OS port and then runs sub.subscribe, which opens a socket to the peer and replays the log. readport returns as soon as the port file exists — before MIDREADY is set — so midhandle[] and the MIDREADY check can run while the subscribe is still in flight. If the peer or the log is slow, MIDREADY will be 0b or undefined and the assertion fails spuriously. A while poll on MIDREADY (like readport uses for the port file) would close the window.
DIReview Summary0 critical | 4 warning(s) | 0 suggestion(s)
|
Subscription management module, di.subscriptions
Extracts
code/common/subscriptions.qfrom 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.subscriptionsin the FRAMEWORK tier with hard dependencies ondi.serversanddi.pubsub.getsubscriptionhandles(the legacy handle-resolution function TorQ consumers call before subscribing) is ported here rather than duplicated across every futuredi.proc.*module, and a new opt-inrepublishcapability lets a chained/segmented tickerplant consumer hand its subscribed tables todi.pubsubfor downstream fan-out - the actual job a chained TP exists to do. Closing thedi.pubsubedge safely required three small, necessary changes to that module, included here rather than as a follow-up: its.z.pcwas overwriting rather than chaining, meaning depending on it as shipped would silently destroy any other module's observer; it shipped noVERSION, so a version pin against it would have been unenforceable; and it had no read counterpart tosetsubtables, which the additive handoff needs. A fourth, unrelateddi.pubsubfix (a broken result-shape guard insubscribestr/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
di/subscriptions/init.qdi/subscriptions/subscriptions.qdi/subscriptions/subscriptions.mddi/subscriptions/deps.qdi/subscriptions/test.csvdi/subscriptions/test_integration.csvdi/pubsub/pubsub.q.z.pcnow chains via di.handlers-style capture instead of overwriting; getsubtables added; subscribestr/subscribestrfilter's result-shape guard fixeddi/pubsub/init.qdi/pubsub/VERSIONdi/pubsub/test.csvHow to test
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
republishon, and a downstream subscriber - since a subscriber that serves its own subscribers can't be tested in-process. Its tickerplant peer runs the realdi.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
raiseerrorproduces 7 failures (the module throws, the completed subscription is lost, nothing is logged atwarn); with the fix in place, 0.di.depcheck:Resolved against
deps.q =di.serversdi.pubsub!("0.1.0";"0.2.0"), withdi.serversat 0.1.0 anddi.pubsubat 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 underlyingsubdetailscall 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-updpresence, 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 atablelistround trip beforesubdetails- a segmented tickerplant'ssubdetailscan't accept the bare sentinel directly (throws'rank); legacy always resolved a concrete list first. Falls back to sending`directly if no usabletablelistis offered.3. A shared log (segmented
singular/periodicmultilog 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
0Wjsentinel) 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.
0Wjmeans "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.
getsubscriptionhandlesported rather than left to each caller - real TorQ callers acrossrdb.q,wdb.q,chainedtp.q,sctp.qall resolve a handle the same way before subscribing; centralising it here avoids four futuredi.proc.*modules each reimplementing legacy's`/()/intersection-vs-union selection semantics independently.7.
republishis opt-in, default off - realises the chained/segmented-tickerplant role (subscribe upstream, serve the same tables downstream) thatchainedtp.q/sctp.qsplit across themselves in TorQ. Off by default: a plain RDB must not silently become a publisher.8. The handoff to
di.pubsubis additive - unions with whatever the publisher already serves rather than replacing it, becausesetsubtablesreplaces its list andunsubscribedeletes 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 makesdi.pubsubpublish 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.pcnow chains - a bare.z.pc:{closesub[x]}at load silently destroyed any observer another module had already registered, withdi.handlerscontinuing to report it as live. Depending ondi.pubsubas a hard dependency without fixing this would import that risk.Checklist
Documentation
See
subscriptions.mdfor full reference, including thesubdetails/tablelistprotocol notes, the shared-log and whole-file replay behaviour,republishconfiguration, and known non-blocking limitations (live-feed filtering delegation is unverified end-to-end pendingdi.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 fordi.tickerplantto address upstream).