Repository navigation
feat(solvers): liveness heartbeats with automatic offline detection - #574
Closed
benedictworks-home wants to merge 4 commits into
Closed
benedictworks-home wants to merge 4 commits into
benedictworks-home wants to merge 4 commits into
Conversation
A run of merge commits between 09:48 and 11:28 today left 55 tracked files at zero bytes on main, including package.json, prisma/schema.prisma, src/app.module.ts, src/intents/intents.gateway.ts and src/config/env.validation.ts. CI on main is red because of it. This restores each file from the newest commit in history where it still had content, and backfills env.validation.ts with the 29 keys that .env*.example gained after the schema was wiped, so check:env-drift passes again. Also drops a duplicated TREASURY_ADDRESS entry the restored schema carried.
The same merge run that emptied 55 files left several of their last-non-empty versions with interleaved content, so the restore in the previous commit brought back files that do not compile. This repairs every damaged site found: - intents.gateway.ts: drop the orphaned partial handleConnection stub, the duplicated const existing declaration, the twice-copied heartbeat body, and a duplicate connection-state import. - configuration.ts / solver-registry.service.spec.ts: add the missing closers for the new `secrets` config block (interface and factory). - stellar-signature.ts: remove the JSDoc fragment spliced into buildV2IntentMessage's parameter list and the duplicated buildUpdateSolverMessage declaration. - soroban.service.ts: drop the leftover old getLedger body glued inside the new JSON-RPC implementation. - Drop duplicate imports in fill-intent.dto, governance.module, tokens.service, solvers.controller, shadow.spec, and the SDK test mock; merge the two conflicting makeIntentIndex definitions in the gateway spec; remove the duplicated prisma declarations in the treasury spec. - package.json + package-lock.json: revert to 77bf137, the last pair that parses and satisfies each other — every later lock revision in history is unparseable JSON (spliced mid-string), so npm ci cannot work from them. The revert also drops the @nestjs/testing@12 bump, whose peer range conflicts with @nestjs/core@^11. Verified with Node's module.stripTypeScriptTypes (all 50 changed .ts files parse) plus a duplicate import/declaration scan and JSON.parse on the lockfile. Lint/typecheck/tests are not run locally per task constraints — CI runs them.
…tellar-vortex-protocol#445) Require authenticated solvers to heartbeat and drive liveness from it: - Cadence negotiated with clients (SOLVER_HEARTBEAT_INTERVAL_MS, default 10 s): the WS `connected` and `auth_ok` frames and the REST ack carry `heartbeatIntervalMs`. Beats arrive as `{ "type": "heartbeat" }` on the authenticated socket or via signed `POST /api/v1/solvers/:address/heartbeat` (canonical `heartbeat:<address>:<timestamp>`, freshness enforced so a replayed beat cannot keep a dead solver live). - Offline after 3 misses (SOLVER_HEARTBEAT_MISSES): a two-phase sweep on each interval compares this replica's in-memory expiry with a shared per-solver Redis key (TTL = interval x misses; process-local memory store fallback for single-replica setups). The sweep aborts with no transitions when the shared store is unreachable, and a fresh local beat always wins at re-check, so a partitioned replica cannot mass-flip solvers. A lastActiveAt inside one window (register/reactivate/fill) counts as proof of life and delays exclusion by at most one window. - Transitions flip isActive — RFQ (`POST /intents/quote`), `GET /solvers/:address/eligible-intents` and WS auth therefore exclude offline solvers — stamp lastActiveAt, and broadcast `solver_status_changed` on the WS/SSE feed. Capability predicates take an optional live gate so connected-but-offline solvers stop matching intent events without rebuilding predicates. - Auto-offline records heal on the next heartbeat or on re-authentication (auth is proof of life); deliberate deactivate/deregister clear the auto-offline flag so heartbeats can never undo them. `reactivate` now stamps lastActiveAt like every other status transition. - Metrics: vortex_solver_live_by_chain (live count per supported chain, "*" expanded) and vortex_solver_status_changes_total. New env vars: SOLVER_HEARTBEAT_INTERVAL_MS, SOLVER_HEARTBEAT_MISSES, SOLVER_HEARTBEAT_REDIS_URL (env.validation.ts + all four .env*.example). Tests: solver-liveness.service.spec.ts — fake-timer miss detection, boot grace, partition guard, store-flush protection, heal vs deliberate deactivation, concurrent-heartbeat single-emit, per-chain metrics, the capability-predicate gate, and a two-replica integration scenario over one shared store and repository. Docs: onboarding §3 heartbeats, runbook Scenario G, ADR 0007 (sweep vs keyspace notifications), README. Lint/typecheck/tests were not run locally (no node_modules); local checks: stripTypeScriptTypes parse (57 files), duplicate-binding scan, env-drift port — all clean.
Resolve all 40 conflicted paths against upstream main (c48de27). Resolution strategy: - Upstream main itself ships ~10 parse-broken files and 14 zero-byte files (incl. .github/workflows/ci.yml), so main's blob cannot be taken wholesale. Files that issue stellar-vortex-protocol#445 does not own are resynced to the simulation-harness tree (2a922ee from stellar-vortex-protocol#575: main plus those torn files repaired, independently verified green with tsc, eslint and 1746 passing unit tests). - Issue stellar-vortex-protocol#445-owned files keep the branch content merged with main's additions: heartbeat gateway/REST endpoints, liveness service and store, metrics counters, matcher isLive gate, config/env/docs touchpoints. - intents.gateway.ts keeps the harness structure and re-applies only stellar-vortex-protocol#445's own delta: solverForAuth at re-auth (touch heals auto-offline), isLive-gated buildMatchPredicate, heartbeatIntervalMs in the connected/auth_ok frames, the { type: "heartbeat" } ack path (backed by a small send helper), and an awaitable message dispatch so async auth settles deterministically for the existing spec. - prisma/schema.prisma is a hybrid: the harness schema plus main's @@index([solver]). - tools/* intentionally stays absent (harness-only files). Repairs applied on top: - solver-liveness.service.spec.ts: the three `feed.broadcast = makeFeed()` assignments stored a whole fake feed where a broadcast function is expected (a pre-existing type error in the branch); replaced with fresh jest.fn() spies. - solver-registry.service.spec.ts: the AppConfig literal gained the three stellar-vortex-protocol#445 heartbeat keys that configuration.ts now requires. - solver-liveness.service.ts: IntentFeedService is @optional() (the current IntentsModule registers no feed provider, exactly like the gateway's feed param) and boot grace keeps the optimistic live view so first-sweep metrics and predicate gating match the spec. - .env.example: backfilled the solver-reputation (stellar-vortex-protocol#444) variables from fd6bff1 so check:env-drift passes (the other three .env*.example files carry both that block and stellar-vortex-protocol#445's heartbeat block). Local verification: tsc --noEmit clean, eslint clean (warnings only), check:env-drift and check:quarantine pass, scripts jest config green (43 tests), full jest suite green in 4 shards (1727 passed, 97 skipped, 0 failed). check:migrations runs post-commit with --base c48de27 (Windows cmd cannot parse HEAD^1).
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Implements #445: solver liveness heartbeats with automatic offline detection. Dead solvers stopped being quoted —
markLive/markOfflineare now driven by heartbeats instead of explicit calls.What's new
Heartbeat protocol (negotiated cadence, default 10 s)
heartbeatIntervalMsin the WSconnectedframe, inauth_ok, and in every ack (that's the negotiation: server declares, client adapts).{ "type": "heartbeat" }on the authenticated socket →{ "type": "heartbeat_ack", "accepted": true, "heartbeatIntervalMs": 10000 }(unauthenticated connections getaccepted: falseso a bot notices it never completed auth).POST /api/v1/solvers/:address/heartbeatwith{ timestamp, signature }over the canonicalheartbeat:<address>:<timestamp>; the timestamp must be fresh withinmax(60 s, offline window)so a captured beat cannot keep a dead solver live.Offline after 3 misses (
SOLVER_HEARTBEAT_MISSES, window = interval × misses = 30 s)lastActiveAtwithin one window from register/reactivate/fill) give bots time to connect; a solver that is demonstrably filling is never withdrawn from quotes.SOLVER_HEARTBEAT_REDIS_URL(defaults toREDIS_URL) → shared Redis; empty → process-local memory (single replica only). Periodic scan instead of keyspace notifications — nonotify-keyspace-eventsconfig burden, fire-and-forget loss on subscriber reconnect, and it can distinguish "expired" from "unreachable" (the guard's precondition). Reasoning recorded indocs/adr/0007-solver-liveness-heartbeats.md.Exclusion + events
isActive=false, so existing gates exclude the solver for free:POST /api/v1/intents/quote(RFQ),GET /solvers/:address/eligible-intents, and WS re-authentication all checkisActive.buildMatchPredicate(solver, isLive?)gains an optional gate consulted on everymatches()— installed predicates go quiet without a rebuild (gateway + feed both pass the closure).solver_status_changedis broadcast on the WS/SSE feed (solver,status,lastActiveAt,reason,at);lastActiveAtis stamped on every status transition (reactivatenow stamps it too, like every other transition).deactivate/deregisterclear the flag so heartbeats can never undo an operator decision.Metrics
vortex_solver_live_by_chain{chain}— live (= active and beating) solver count per supported chain,*expanded to all chains, refreshed each sweep and on heal.vortex_solver_status_changes_total{status}— transition counter.New environment variables
SOLVER_HEARTBEAT_INTERVAL_MS10000SOLVER_HEARTBEAT_MISSES3SOLVER_HEARTBEAT_REDIS_URLREDIS_URLif set, else""""= process-local memoryAdded to
src/config/env.validation.tsand all four.env*.examplefiles (keepscheck:env-driftgreen).Files
src/solvers/solver-liveness.service.ts,src/solvers/liveness.store.ts(memory + Redis stores),src/solvers/dto/heartbeat.dto.ts,src/solvers/solver-liveness.service.spec.ts,docs/adr/0007-solver-liveness-heartbeats.mdsolver-intent-matcher(isLiveparam), solvers controller/module/service,stellar-signature(buildHeartbeatMessage), metrics, config trio + env examples, docs (solver-onboarding.md§3, runbook Scenario G + key config, README endpoint list)Verification
solver-liveness.service.spec.ts— fake-timer miss detection (live at 2 misses, offline at 3), boot grace, partition guard (whole-cycle abort, both solvers survive), store-flush protection (local beats win), auto-offline heal vs deliberate deactivation, concurrent-heartbeat single-emit, unknown-solver ack, per-chain metric counts (incl.*expansion), capability-predicate gate, and a two-replica integration scenario (two service instances over one shared store + one repository: beats only on A keep both online; after silence B transitions exactly once and A's sweep does not double-emit; reconnect on A heals globally).64e5a03(Node 24, Windows):tsc --noEmitclean;eslint src test scripts packages …0 errors (144 warnings, same baseline as main);check:env-driftpass;check:quarantinepass;check:migrations --base c48de27pass (every changed migration hasdown.sql, no unsafe DDL);jest --config scripts/jest.config.jspass (43 tests); full unit suite in 4 shards (--shard=n/4 --maxWorkers=3): 1727 passed, 97 skipped, 0 failed.psql-backed integration tests, gitleaks, semgrep, and coverage upload (no docker/psql in this environment) — CI is the authority for those.Merge with upstream/main (
64e5a03)The branch merges upstream
mainatc48de27. That tree itself ships ~10 parse-broken files (leaderboard-query.ts,in-memory-intents.repository.spec.ts,intents.types.ts,list-intents.dto.ts,configuration.ts,env.validation.ts,stats.service.ts,intents.controller.ts,intents.gateway.ts,intents.module.ts,stellar-signature.ts) plus 14 zero-byte files (incl..github/workflows/ci.yml,src/soroban/soroban.service.ts,src/treasury/treasury.service.ts), so conflicts were resolved conservatively:2a922ee— the simulation-harness tree from feat(tools): solver simulation harness and strategy backtesting tool #575, which isc48de27plus those torn/empty files repaired (independently verified green: tsc, eslint, 1746 passing unit tests).stellar-signature, config trio,.env*examples, README/docs) keep the branch content merged with main's additions.intents.gateway.tskeeps the harness structure and re-applies only [High] Solver Liveness Heartbeats with Automatic Offline Detection #445's own delta:solverForAuthat re-auth (touch heals auto-offline), theisLive-gated match predicate,heartbeatIntervalMsnegotiation in theconnected/auth_okframes, and the{ "type": "heartbeat" }ack path.prisma/schema.prisma= harness schema + main's@@index([solver]).package.json/package-lock.jsonare byte-identical to the feat(tools): solver simulation harness and strategy backtesting tool #575 tree — this PR adds no dependency of its own and the lockfile is not hand-edited (the@aws-sdk/client-s3entry present in it originates from feat(tools): solver simulation harness and strategy backtesting tool #575).Fixes made during verification (needed for a type-clean, green tree):
solver-liveness.service.spec.ts: threefeed.broadcast = makeFeed()assignments stored a whole fake feed where a broadcast function is expected — a pre-existing type error in this branch (the file never compiled under ts-jest diagnostics); replaced with freshjest.fn()spies.solver-registry.service.spec.ts: theAppConfigliteral gained the threeSOLVER_HEARTBEAT_*keys thatconfiguration.tsnow requires.solver-liveness.service.ts:IntentFeedServiceis now@Optional()(the currentIntentsModuleregisters no feed provider — the same treatment the gateway's feed param already applies), and boot grace keeps the optimistic live view so first-sweep metrics and predicate gating match the spec.intents.gateway.ts: the WS message listener returns the dispatch promise (failures catch-logged) so the async auth path settles deterministically for the existing gateway spec..env.example: backfilled the solver-reputation ([High] Solver Reputation Score v2 with Time Decay and Transparency #444) variables (fromfd6bff1, via the feat(tools): solver simulation harness and strategy backtesting tool #575 tree) socheck:env-driftstays green; the other three.env*.examplefiles carry both that block and this PR's heartbeat block.(The two
fix:commitsac129c1/8fb9a87that the branch started with remain in history; after the merge, shared files follow the repaired2a922eecontent.)Assumptions & edge cases
isActive(which this PR's sweeper now drives); capability filtering gets an explicit liveness gate for already-connected solvers.docs/solver-onboarding.md§3): a non-heartbeating solver goes offlineinterval × misses + ≤intervalafter the grace, which is exactly the issue's intent.Checklist
Scenario G) / ADR /docs/solver-onboarding.mdupdatedenv.validation.ts+ every.env*.examplenpm run lint/typecheck/testrun locally — tsc clean, eslint 0 errors, 1727 unit tests green on the merge commitCloses #445