Skip to content

Constrain replication mesh when the system database is replicated - #572

Merged
kriszyp merged 4 commits into
mainfrom
kris/systemdb-routing-repro
Jul 15, 2026
Merged

Constrain replication mesh when the system database is replicated#572
kriszyp merged 4 commits into
mainfrom
kris/systemdb-routing-repro

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

Lets the system database replicate (node discovery + users/roles/schema propagation) without every aggregation node opening direct connections to every discovered peer. A node with directional config routes now advertises a directional hdb_nodes self-record (derived from those routes via computeSelfReplicates, opt-in — a node with no directional routes keeps legacy replicates: true) instead of a blanket replicates: true. The existing controlled-flow gates (harper-pro#498 — shouldReplicateFromNode receive-side, the send-authority gate) then consult the discovered peer's advertised directions instead of a permissive boolean.

Purpose

Hierarchical topology: core(1) ← middle(3) ← roadside(30), tight transitive routing (data aggregates roadside→middle→core, no cross-cluster connections) and system replicated so node discovery + central config propagate without hand-configuring every node. Previously mutually exclusive — replicating system caused every discovered node to open a direct socket to every other (core would hold ~30 direct roadside sockets). Docs (companion PR below) currently say to disable system to get tight topology; this removes that tradeoff.

What changed

  • replication/subscriptionManager.tscomputeSelfReplicates(routeList) + getConfiguredRoutes() extracted/exported; ensureThisNode compares replicates structurally (not just url/shard) so a stale/legacy self-record is corrected on the next boot after a route change.
  • replication/setNode.tsselfReplicatesForNodeWrite() preserves an existing directional self-record instead of forcing true on add_node/set_node; both handlers drop the unconditional sends: true stamped on a directional PEER record (it was short-circuiting the receive gate's allow-list to "feeds every database").
  • replication/knownNodes.tsmergeReconstructedNode preserves a peer's last-known directional replicates through a transient hdb_nodes decode miss, applied at all three reconstruct/merge call sites (including the copyApply/base-copy reload scan path, harper-pro#489).
  • Tests: unit coverage for computeSelfReplicates/mergeReconstructedNode; integration tests for transitive 3-tier routing, per-database opposite directions, and a churn/hardening test proving no reconnect/socket churn to an excluded discovered peer under sustained system+data mutation.
  • replication/DESIGN.md documents the mechanism and its boundaries.

Where to look / open items

  • replication/replicationConnection.ts leading-duplicate-skip qualifies checks (~4318, ~4578) are not updated for the dropped sends: true: a directional peer ({sendsTo:[...]}, no .sends) now evaluates qualifies=false there, so it's omitted from the multi-hop dedup exclusion list. Effect is a subscriber may receive some records via more than one path — idempotent, not data loss (replication applies by sequence). Deliberately left as-is rather than adding || sendsTo?.length, which would be the unsafe direction (over-exclusion risks dropping records — cf. the perf(replication): fast-skip leading duplicates on resume #370/Replication: transitive/proxied re-delivery floods peers with already-applied out-of-order writes (reduce volume; complements harper#1310) #399 leading-dup-skip family). A correct fix needs a subscriber+database-aware check and is a separate change. Documented in DESIGN.md.
  • getConfiguredRoutes() reads a main-thread-only module global. setNode/addNodeBack can run on any worker; on a brand-new node whose main-thread self-record hasn't been written yet, a worker-handled add_node falls back to legacy true for this node's own row. Self-heals via ensureThisNode's structural-diff rewrite on next boot; never hit by the primary config-route topology (self-record is written from the main thread at boot). Documented in DESIGN.md.
  • Central visibility of every node is not guaranteed — an aggregation node may not list every distant leaf in its own hdb_nodes registry (the registry relay differs from data relay). Does not cause a connection either way; documented as an accepted tradeoff.
  • Cross-model review (Codex + Sonnet 5; Gemini agy was unavailable) surfaced one real bug (a third decode-recovery merge site I'd missed, now fixed) and the two open items above — no other material findings.

Docs

Companion PR: HarperFast/documentation#583

Testing

Unit tests (computeSelfReplicates/mergeReconstructedNode) 14/14 pass on a real tsc build. The churn/hardening integration test and the per-database repro pass on the real build. The transitive 3-tier repro is syntactically valid and converted from print-only to assertions, but could not get a clean local run due to contention from other test processes on the shared dev box — needs CI to confirm.

Generated by Claude (Opus 4.8), cross-reviewed by Codex and Claude Sonnet 5.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a directional self-record mechanism to allow the replication of the system database without collapsing hierarchical topologies into a full mesh. It derives directional routing rules from configuration routes, preserving constrained connections while allowing transitive schema and role propagation. The feedback recommends defensive programming enhancements: guarding against a falsy node name during early boot in setNode.ts to prevent potential runtime crashes, and using Array.isArray checks in subscriptionManager.ts to robustly handle potentially misconfigured non-array routing entries.

Comment thread replication/setNode.ts
Comment thread replication/subscriptionManager.ts Outdated
@claude

This comment has been minimized.

kriszyp added a commit that referenced this pull request Jul 14, 2026
- computeSelfReplicates: Array.isArray guard on rep.sendsTo/receivesFrom
  instead of `|| []` — route config comes from YAML and isn't schema-
  validated, so a misconfigured non-array value would throw in the
  for...of and crash boot. Matches the existing guard in
  routeEntriesIncludePeer. Per gemini-code-assist review on PR #572.
- systemDbPerDbDirectionRepro.test.mjs: remove unused nodeM destructure
  (lint failure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review July 14, 2026 00:28
@kriszyp
kriszyp requested a review from a team as a code owner July 14, 2026 00:28
Comment thread replication/subscriptionManager.ts
Comment thread replication/knownNodes.ts
KrAIs and others added 4 commits July 13, 2026 22:38
…em replication

Derive a directional replicates object (sendsTo/receivesFrom, per-database) for a
node's own hdb_nodes record from its config routes instead of a blanket replicates:true.
Lets the system db replicate for discovery/config propagation while user-db connections
stay on the configured topology, enforced by the existing #498 gates.

Includes two integration repros (3-tier chain; per-database opposite directions).
Validated by hot-patching dist; see repro output in session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Derive a directional hdb_nodes self-record from a node's config routes
(computeSelfReplicates) instead of a blanket replicates:true, so `system`
can replicate for discovery/config propagation without every aggregation
node opening direct connections to every discovered peer. The existing
#498 gates consult the propagated directional record; opt-in, so nodes
with no directional routes keep legacy full-mesh.

- computeSelfReplicates + getConfiguredRoutes extracted/module-scoped; opt-in
  (only when >=1 directional route), explicit-none yields empty (not true).
- ensureThisNode compares replicates structurally so config/deploy reloads refresh it.
- setNode/addNodeBack derive the self-record the same way and drop the blanket
  sends:true on directional peer records (was short-circuiting the allow-list).
- mergeReconstructedNode preserves a peer's last-known directional replicates
  through a transient decode miss (no topology widening).
- Unit tests (computeSelfReplicates/mergeReconstructedNode); integration tests
  for transitive 3-tier, per-db opposite directions, and excluded-peer churn.
- DESIGN.md documents the mechanism and its boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- computeSelfReplicates: Array.isArray guard on rep.sendsTo/receivesFrom
  instead of `|| []` — route config comes from YAML and isn't schema-
  validated, so a misconfigured non-array value would throw in the
  for...of and crash boot. Matches the existing guard in
  routeEntriesIncludePeer. Per gemini-code-assist review on PR #572.
- systemDbPerDbDirectionRepro.test.mjs: remove unused nodeM destructure
  (lint failure).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two directional-routing regressions found in review:

- knownNodes.ts scanNodesForSubscription: the reconstruct-merge guard was
  `!node.url || node.shard === undefined`, but on an UNSHARDED cluster every
  real decoded record has shard === undefined, so mergeReconstructedNode ran
  over real records and reverted a freshly-decoded `replicates` to a stale
  in-memory value during a copyApply base-copy reload (harper-pro#489) —
  dropping user-db records for a peer that widened, over-connecting to one
  that narrowed. Gate strictly on `!node.url`: a real record always has a
  url, so only true reconstruct descriptors are merged.

- replicationConnection.ts dynamic send-authority gate: used a strict
  `sub.source === thisNode && sub.database === databaseName`. A
  full-replication neighbor's directional self-record advertises
  `receivesFrom: [{ source }]` with NO database (wildcard), so once a node
  was opted-in, its full-replication neighbors' per-database subscriptions
  were rejected (close 1008) whenever the sender fell to the dynamic gate.
  Delegate to routeEntriesIncludePeer (absent source/database = wildcard),
  matching the receive-side gate.

- Adds an integration test driving an opted-in full-replication neighbor
  through the dynamic send path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/systemdb-routing-repro branch from 01fa1aa to 17e4943 Compare July 14, 2026 04:46
* gates. A directional route that authorizes nothing yields an empty record (NOT `true`), so a node
* configured to replicate nothing does not silently re-advertise a full mesh.
*/
export function computeSelfReplicates(routeList: Iterable<any>): true | { sendsTo: any[]; receivesFrom: any[] } {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding this function correctly, this isn't self replicating, it's computing the "replicates" object for an instance, in which case, this is a clever, but poor name.

@kriszyp
kriszyp merged commit 0b49587 into main Jul 15, 2026
38 checks passed
@kriszyp
kriszyp deleted the kris/systemdb-routing-repro branch July 15, 2026 02:01
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