Skip to content

Feature heartbeat - #122

Open
alowrydi wants to merge 6 commits into
mainfrom
feature-heartbeat
Open

Feature heartbeat#122
alowrydi wants to merge 6 commits into
mainfrom
feature-heartbeat

Conversation

@alowrydi

Copy link
Copy Markdown
Contributor

Extracts TorQ's code/common/heartbeat.q (the .hb namespace), di.heartbeat

Publishes a periodic liveness beat over pub/sub so downstream monitors can detect that a process has stalled or blocked even while its connection is still valid, and carries the monitor role in the same module: subscribing to other processes' beats, tracking the last one seen per process, and raising warning/error transitions when they stop arriving.

Builds on an initial implementation from PR #109 (Olly, June 2026, closed unmerged when he left the project) - the domain model (state machine, tolerance-multiplier grace periods, mode-2 timer scheduling with the catch-up-storm reasoning, single-arg init) carried through largely as designed. The integration layer is rewritten against contracts that didn't exist as merged code at the time it was written - di.log, di.handlers, di.config, di.api all merged in early August, after #109 was closed - and two further review passes since then found and fixed a blocking-availability hazard and a stale-handle bug in the async rewrite itself, detailed below.

Trello ticket - https://trello.com/c/093fht6Z/95-kdb-x-heartbeat


Files created

File Description
di/heartbeat/init.q Loads heartbeat.q, defines export of 11 functions
di/heartbeat/heartbeat.q Full implementation - module state, internal helpers, publisher/monitor logic, init
di/heartbeat/heartbeat.md Full module documentation - see below
di/heartbeat/VERSION Plain-text version string, read by init and resolved by di.depcheck
di/heartbeat/deps.q Empty - no hard module dependencies, everything injected
di/heartbeat/test.csv 469 k4unit unit tests
di/heartbeat/test.q Integration-suite fixture - spawns and drains a real child publisher process
di/heartbeat/test_integration.csv 50 k4unit integration tests against the spawned process

How to test

k4unit:use`di.k4unit
k4unit.moduletest`di.heartbeat

469/469 unit tests passing, 0 failures.

The integration suite needs a real second process and is a separate file, so moduletest (hardcoded to test.csv) doesn't pick it up:

.m.di.0k4unit.KUltf .Q.dd[hsym`$.Q.m.mp`di.heartbeat;`test_integration.csv]
.m.di.0k4unit.KUrt[]

50/50 integration tests passing, 0 failures. Exercises what a mock can't reach: the remote-subscribe handshake (.z.w resolving correctly inside an inbound async call), the root schema table di.pubsub discovers, a published row actually crossing the wire into the monitor's store, a genuinely stalled peer not blocking the sweep, and - wired against real di.log/di.timer/di.handlers/di.pubsub rather than mocks - the real di.timer scheduler actually firing a registered job, not just holding it in a registry.

qlint: 0 error-level findings.


Design decisions

1. The remote subscribe is asynchronous, not synchronous like legacy. A sync call blocks for as long as the peer takes to answer, and a peer that's alive but stalled - a GC pause, a heavy query, exactly the condition this module exists to detect - never answers promptly. Because the subscribe runs inside a di.timer job on a single thread, that block stalled publishheartbeat and checkheartbeat too: the monitor fell silent to its own monitors at precisely the moment a peer misbehaved. Measured on real KDB-X: 10s blocked against a 10s-hung peer, versus 36µs for the async send. hopen's timeout doesn't help here - it bounds connection establishment, not later requests on an already-open handle.

2. There is no cache of subscribed handles. An earlier version tracked which handles it had already subscribed, to skip them on later sweeps. Removed rather than repaired: kdb+ reissues the lowest free descriptor immediately (measured: handle 4 → close → reopen → handle 4), and hclose doesn't fire .z.pc at all (also measured), so a stale cache entry made the monitor skip a live peer permanently, with nothing in any log to explain it. di.pubsub already dedupes subscribers by .z.w, so the cache bought nothing. Removing it also removed this module's only reason to register .z.pc, and with it the handlers dependency entirely.

3. Async can't report a remote failure, so two checks watch the consequence instead. A subscribed peer that never beats, and a monitor that discovers no usable peer at all. Both warn once at a configurable sweep threshold, distinguish a cold start from genuine peer loss, and close out with a matching-severity recovery line so an alert can never dangle unresolved in a log.

4. A pre-existing root heartbeat table is never adopted, not even a column-identical one. init errors instead. This is a real, reachable collision, not a hypothetical: di.subscriptions installs subscribed tickerplant schemas at root, so a monitor watching a TP that carries heartbeat already has one. Column compatibility is deliberately not treated as a safety signal - a matching shape isn't evidence a table means the same thing, and adopting on that basis would silently co-mingle this module's liveness rows with whatever the real owner stores there.

5. Every timer entry point that can throw is isolated against di.timer's disableonfail:1b default. An unprotected throw in a client callback, a transient pub/sub outage, or a dependency error wouldn't just skip one beat - it would permanently disable the job, silently ending the exact monitoring this module exists to provide. onwarning/onerror/publish and the whole discovery sweep run isolated; state is always updated before an isolated callback fires, so a callback failure can never leave the store inconsistent.

6. Config defaults take the values TorQ actually shipped, not the fallbacks written inside heartbeat.q itself. Legacy carried two disagreeing sets - the in-file @[value;...] defaults (1.5f, 2f, ()) and what config/settings/default.q actually set (2f, 3f, `ALL). This module takes the shipped values, since they reflect what really ran; the in-file connections:() in particular could not have been used at all; it makes `ALL in () false and the entire monitor path silently monitors nothing.


Checklist

  • 469/469 unit tests passing
  • 50/50 integration tests passing
  • Follows consistency.md and style.md
  • Follows dependency injection guidelines
  • heartbeat.md documents all exported functions, config, usage examples and notes
  • No hard dependencies on other di.* modules - standalone (deps.q empty)

Documentation

See heartbeat.md for full reference including the dependency table, configuration options with shipped-vs-in-file defaults noted per key, exported function documentation with examples, the root-publication and remote-subscribe mechanics, and a dedicated section on deliberate departures from legacy.ection on deliberate departures from legacy.

Comment thread di/heartbeat/heartbeat.q
Comment thread di/heartbeat/heartbeat.q
Comment thread di/heartbeat/heartbeat.q
Comment thread di/heartbeat/heartbeat.q
Comment thread di/heartbeat/heartbeat.q
Comment thread di/heartbeat/heartbeat.q Outdated
Comment thread di/heartbeat/test.q Outdated
Comment thread di/heartbeat/test.q
@DI-Software-Engineering

Copy link
Copy Markdown

DIReview Summary

1 critical | 7 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