Skip to content

Make cluster-origin table definitions additive-only so a peer's partial schema snapshot cannot destroy locally declared attributes - #2258

Draft
kriszyp wants to merge 5 commits into
mainfrom
fix/cluster-origin-schema-merge
Draft

Make cluster-origin table definitions additive-only so a peer's partial schema snapshot cannot destroy locally declared attributes#2258
kriszyp wants to merge 5 commits into
mainfrom
fix/cluster-origin-schema-merge

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 21, 2026

Copy link
Copy Markdown
Member

Make cluster-origin table definitions additive-only so a peer's partial schema snapshot cannot destroy locally declared attributes

What / why

table() treated every incoming attribute list as authoritative: the existing-table branch spliced Table.attributes down to the incoming list, and the catalog reconcile loop deleted the descriptors (and indexes) of any attribute the list omitted. That is correct for local schema authoring (create_table, @table, defineTable) — but replication also calls table() with definitions that are only a snapshot of a peer's eventually-consistent view: the DB_SCHEMA handshake announcement (harper-pro ensureTableIfChanged) and the replicated define_schema event (Table.ts). Such a snapshot can be captured mid-create on the peer (only the primary key registered yet) or applied by a worker whose thread-local databases map has not yet absorbed a concurrent local create_table.

When those two races lined up, a partial peer snapshot permanently destroyed attributes a local create_table had just successfully declared: the in-memory list was spliced down and the on-disk catalog descriptor deleted. Because the table was schemaDefined, the handshake's honor-local guard then refused to ever re-add the attribute from peers, so every later search_by_value failed with unknown attribute — the root cause of the harper-pro nightly replicationLoad failure (replicate across many databases, run 32457825567). The failing node's logs show create_table db3.test [id, name] on main/0 at 07:20:19.146 returning success, followed at 07:20:19.199 by http/1 (Re)creating { table:'test', schemaDefined:true, attributes:[{id}] } from a peer announcement, and from then on Schema for 'db3.test' is defined locally, but attribute 'name: String' from '127.0.0.5' does not match local attribute which does not exist until the search 500s.

The invariant this PR enforces: a definition with origin: 'cluster' is applied additively — peer attributes the local table lacks are added; locally declared attributes are never removed or redefined; catalog descriptors are never reconciled away; and the local schemaDefined declaration is never overridden, neither the live class flag nor the durable primary-key descriptor. The replicated define_schema path in Table.ts already merged additively and already passed origin: 'cluster' — this makes table() itself enforce the invariant that path assumed.

Companion PR (required to close the flake): harper-pro's ensureTableIfChanged must pass origin: 'cluster'HarperFast/harper-pro#750 carries that one-hunk change plus the submodule bump. Without it, the guard here is unreachable from the caller that caused the incident (the cross-model review's reachability finding — correct, and answered by the pairing).

For the human reviewer

  • Judgment call — additive-only vs. versioned schema: peer definitions carry no version, so "newer wins" is not implementable today; additive-only trades delete-convergence for data safety. A locally dropped attribute can be resurrected by a stale peer handshake, and a peer-side redefinition (e.g. indexed: true added remotely) is discarded by the name-only merge. Both are pre-existing properties of the handshake's honor-local policy (and of Table.ts's additive merge); the supported cluster-wide schema-change path remains deploying the schema to every node. Flagged in the review's decision ledger; a versioned schema exchange is the real long-term fix.
  • Judgment call — fix location: patching harper-pro's ensureTableIfChanged alone cannot close the race, because another thread's create_table can commit its catalog writes after the caller's existence check and before table()'s reconcile. Only table() (exclusive lock + disk reload) can apply the definition safely.
  • Judgment call — cluster origin skips the whole removal-reconcile scan, including removeIndex; stale index descriptors on this path persist until the next local authoring pass. Deliberately conservative.
  • The updateTable emit's second argument becomes false for cluster-origin calls; no listener consumes that argument (verified by grep of all 'updateTable' registrations), so this is inert.
  • Declined review suggestion — deriving index registration from the durable descriptor instead of the live attribute in the cluster-origin skip path: registration has always keyed off the caller's live attribute.indexed (the skip block mirrors the pre-existing registration lines exactly), and in both live-vs-disk disagreement directions the new path is strictly less destructive than before (no stale descriptor write, no stale reindex). Re-deriving registration from disk is a broader refactor of table()'s index handling, noted for follow-up.
  • Second commit's addition: cluster-origin callers now skip every write to an existing descriptor (primary-key settings update gated; per-attribute persist/reindex skipped, index registration preserved). This closes a second-order race the harper-pro-side review found: a worker whose in-memory attribute snapshot lags a newer durable declaration would otherwise write the stale value back (and could launch a stale reindex). The new test watched this fail unguarded (a cluster-origin call rewrote a newer durable descriptor).

Verification

  • New unit test unitTests/resources/clusterSchemaMerge.test.js — deterministic repro of the destructive apply: on unpatched main it fails with attribute 'name' was removed by a cluster-origin definition: id; with this patch it passes, on both engines (RocksDB and HARPER_STORAGE_ENGINE=lmdb). It also pins: peer-only attributes are still added, schemaDefined survives both live and in the durable descriptor, and local schema authoring still removes what it no longer declares.
  • End-to-end route: harper-pro integrationTests/cluster/replicationLoad.test.mjs (the failing nightly suite) passes 4/4 against a build embedding this change plus the companion harper-pro change. The triggering race itself is a millisecond-wide cross-thread window (first observed after months of nightlies), evidenced by the CI server-log forensics above rather than reproduced live.
  • Cross-model pre-push review ran (codex graded + gemini + cursor-composer + harper-domain): its major finding (peer schemaDefined still persisted into the durable descriptor) is fixed in the second commit with a regression assertion; the reachability major is answered by the companion PR; the delete-convergence trade is documented above.
  • test:unit:resources locally: 1675 passing; 3 failures are pre-existing in this environment (identical on unpatched base — Node v26.2 / fresh-install dependency drift, none touch this change). CI is the authoritative gate.

Complexity: moderate — a guarded behavior split on an existing parameter in one heavily-shared function, plus tests; risk is concentrated in table()'s existing-table branch semantics.

Review-Coverage: authored=unknown; ran=none; rounds=1 @ 55afa6b

Human-Review-Need: 4 @ 55afa6b

kriszyp and others added 2 commits August 21, 2026 06:06
… peer's partial snapshot cannot destroy locally declared attributes

A replication-driven table definition (the DB_SCHEMA handshake announcement, or a
replicated define_schema event) can be built from a mid-create or stale snapshot of the
peer's table — e.g. only the primary key had been registered when the snapshot was taken.
table() treated every incoming attribute list as authoritative: the existing-table branch
spliced Table.attributes down to the incoming list and the catalog reconcile deleted the
descriptors of any attribute the list omitted. A partial peer snapshot racing a local
create_table therefore permanently destroyed just-declared attributes (and their indexes)
after create_table had already returned success; with schemaDefined tables the handshake
guard then refused to ever re-add them from peers, and searches failed with
"unknown attribute" (harper-pro nightly replicationLoad flake).

Callers now declare peer-derived definitions with origin: 'cluster' (as the replicated
define_schema path in Table.ts already did), and table() applies them additively: peer
attributes the local table lacks are added, locally declared attributes are never removed
or redefined, catalog descriptors are never reconciled away, and the local schemaDefined
declaration is never flipped. Local schema authoring (create_table, @table, defineTable)
keeps its authoritative remove/redefine semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqKopnYwiL1DsT8oKRVU5Y
…criptor, warn on discarded peer redefinitions

The in-memory guard alone left the primary-key descriptor's schemaDefined
mismatch/persist path open to cluster-origin callers, so a peer's flag landed on
disk and overrode the local declaration on the next reload; the mismatch is now
gated on origin and the test asserts the flushed descriptor. A peer redefinition
of an existing attribute is discarded by the additive merge (local schema is
authoritative) — a type conflict now logs a warn so index/type drift between
nodes is observable. DESIGN.md states the delete-convergence cost of
additive-only, and the test suite no longer depends on case ordering.
(Pre-push review findings, rounds 1-2.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqKopnYwiL1DsT8oKRVU5Y

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

Copy link
Copy Markdown
Contributor

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 makes cluster-origin table definitions additive-only in databases.ts to prevent peer-derived definitions from removing locally declared attributes or overriding local schema definitions. It also adds corresponding unit tests and design documentation. The review feedback recommends updating the test file to import node:assert with the node: prefix to comply with the repository's style guide.

Comment thread unitTests/resources/clusterSchemaMerge.test.js Outdated
…iptors, never rewrite existing ones

A cluster-origin table() call works from this worker's attribute snapshot, which
can lag a newer durable declaration committed by another thread; the primary-key
settings update and the per-attribute persist/reindex paths would write that
stale snapshot back over the newer descriptor (and could launch a stale
reindex). Cluster-origin callers now skip every write to an existing descriptor
— they only create descriptors (and indexes) for genuinely new peer attributes.
(Pre-push review finding on the harper-pro side of the coordinated change.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqKopnYwiL1DsT8oKRVU5Y
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kriszyp and others added 2 commits August 21, 2026 07:15
…table

findDescriptor scanned the whole shared dbisDB for the first descriptor with a
matching attribute name. The 'test' database shares one physical store across
unit suites, so a same-named attribute created by any alphabetically-earlier
suite (here clusterSchemaMerge's 'tag') satisfied the scan first and the
assertions read a foreign table's descriptor. Scope the scan to the table's
key prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqKopnYwiL1DsT8oKRVU5Y
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.

1 participant