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
Draft
Conversation
… 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
Contributor
There was a problem hiding this comment.
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.
…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
Contributor
|
Reviewed; no blockers found. |
…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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PqKopnYwiL1DsT8oKRVU5Y
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.
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 splicedTable.attributesdown 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 callstable()with definitions that are only a snapshot of a peer's eventually-consistent view: the DB_SCHEMA handshake announcement (harper-proensureTableIfChanged) and the replicateddefine_schemaevent (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-localdatabasesmap has not yet absorbed a concurrent localcreate_table.When those two races lined up, a partial peer snapshot permanently destroyed attributes a local
create_tablehad just successfully declared: the in-memory list was spliced down and the on-disk catalog descriptor deleted. Because the table wasschemaDefined, the handshake's honor-local guard then refused to ever re-add the attribute from peers, so every latersearch_by_valuefailed withunknown attribute— the root cause of the harper-pro nightlyreplicationLoadfailure (replicate across many databases, run 32457825567). The failing node's logs showcreate_table db3.test [id, name]onmain/0at 07:20:19.146 returning success, followed at 07:20:19.199 byhttp/1(Re)creating { table:'test', schemaDefined:true, attributes:[{id}] }from a peer announcement, and from then onSchema for 'db3.test' is defined locally, but attribute 'name: String' from '127.0.0.5' does not match local attribute which does not existuntil 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 localschemaDefineddeclaration is never overridden, neither the live class flag nor the durable primary-key descriptor. The replicateddefine_schemapath inTable.tsalready merged additively and already passedorigin: 'cluster'— this makestable()itself enforce the invariant that path assumed.Companion PR (required to close the flake): harper-pro's
ensureTableIfChangedmust passorigin: '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
indexed: trueadded remotely) is discarded by the name-only merge. Both are pre-existing properties of the handshake's honor-local policy (and ofTable.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.ensureTableIfChangedalone cannot close the race, because another thread'screate_tablecan commit its catalog writes after the caller's existence check and beforetable()'s reconcile. Onlytable()(exclusive lock + disk reload) can apply the definition safely.removeIndex; stale index descriptors on this path persist until the next local authoring pass. Deliberately conservative.updateTableemit's second argument becomesfalsefor cluster-origin calls; no listener consumes that argument (verified by grep of all'updateTable'registrations), so this is inert.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 oftable()'s index handling, noted for follow-up.a cluster-origin call rewrote a newer durable descriptor).Verification
unitTests/resources/clusterSchemaMerge.test.js— deterministic repro of the destructive apply: on unpatchedmainit fails withattribute 'name' was removed by a cluster-origin definition: id; with this patch it passes, on both engines (RocksDB andHARPER_STORAGE_ENGINE=lmdb). It also pins: peer-only attributes are still added,schemaDefinedsurvives both live and in the durable descriptor, and local schema authoring still removes what it no longer declares.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.schemaDefinedstill 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:resourceslocally: 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