feat(crdt): separate local reload from peer admission - #231
Merged
farhan-syah merged 3 commits intoAug 1, 2026
Conversation
`CrdtImportLimits` bounds how much work an untrusted peer can make a document do. Restore and compaction went through the same capped path, where the bound means something else entirely: how large a document this library may reload after writing it. `export_snapshot` has no bound at all, so the two are not symmetric — a document a healthy process wrote can exceed what the same binary will re-import. Past that point the document neither opens nor compacts, and compaction is the operation that would bring it back under the limit: `compact_history` and `compact_at_version` re-admit their own shallow snapshot, so the recovery path is gated by the ceiling it exists to escape. Raising `max_bytes` alone only moves the wall to `max_encoded_operations`, which counts over the whole snapshot's range. `import_local` keeps every structural check — Loro's authenticated metadata decode, rejection of regressing per-peer ranges, the pending dependency check — and drops the size ceilings. Restore, `compact_history` and `compact_at_version` use it. The peer path keeps its caps, and `import_with_limits` remains the knob for tuning them. It is a separate entry point rather than an unbounded `CrdtImportLimits` constructor so bytes off the wire cannot reach the exempt path by passing a value. The behaviour past the default ceilings — a gigabyte, or a million operations — is not covered by a unit test: a fixture that large does not belong in the suite. What is covered is that the peer path stays capped and that dropping the ceilings does not drop the structural checks.
Two accessors on the hot path treated a full document walk as free. `collection_names` called `get_deep_value`, which materialises every row and every field of every collection, to read the top-level keys — O(document) for a list whose length is the number of collections, and name resolution calls it per query. The shallow value has the same keys. `estimated_memory_bytes` exports a full snapshot as its size proxy. That is the honest proxy, but callers poll it to decide when to compact, and a document nobody is writing returns the same number every time. It is now computed once per frontier.
mkhairi
force-pushed
the
fix/crdt-local-import-admission
branch
from
August 1, 2026 15:42
42a5cf5 to
184114a
Compare
Rebuilding a CrdtState from a snapshot this process exported itself went through the plain constructor plus a raw import, which enforced the peer size ceilings meant for untrusted replicated deltas. Once a collection outgrew them, replicated-apply seeding and transaction rollback restore would both fail on data the process wrote itself: apply would misreport every subsequent delta as malformed, and rollback would leave the transaction driver with a failure it can't act on. Add CrdtState::from_local_snapshot, which imports through the same admit_local_import path already used by compaction, and route apply_validated's seeding and snapshot_restore's rollback through it. Share the compaction swap itself via a new compact_to_frontiers helper, and move the document plus its derived memory-estimate cache into a DocumentCell so replacing the document (compaction) can no longer leave a stale cache describing bytes that no longer exist.
This was referenced Aug 1, 2026
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
A document this library writes can grow past what it will read back.
export_snapshothas no size bound. Import has three, and until now every import went through them — including the ones where the bytes came from this process rather than from a peer: restoring a document from durable storage, folding a shallow snapshot back in during compaction, rolling a transaction back to a pre-image, and seeding the validation candidate on every authenticated apply. Once a document's snapshot exceedsDEFAULT_MAX_IMPORT_BYTESor its history exceedsDEFAULT_MAX_IMPORT_OPS, that document can no longer be opened. It also can no longer be compacted, becausecompact_historyandcompact_at_versionre-admit their own output through the same ceiling — so the one operation that would bring it back under the limit is gated by the limit. There is no recovery path from inside the library; raisingmax_bytesonly moves the wall tomax_encoded_operations.The limits themselves are correct for what they were written for: bounding how much work an untrusted peer can make a document do. They just answer a different question when applied to bytes we produced ourselves. This splits the two so each keeps its own answer — the peer path stays capped, and the local path keeps every structural check while dropping the size ceilings.
Context in #230. The embedded-side half of that investigation — flush rewriting a full snapshot per tick — is a
nodedb-litechange and is open as NodeDB-Lab/nodedb-lite#10; the two PRs share no files.Changes
feat(crdt): admit locally-generated imports without the peer ceilingsadmit_importsplits intoadmit_structure— the authenticated metadata decode and the rejection of regressing per-peer ranges — and the three ceiling checks layered on top of it.import_localruns the structure half and keeps the pending-dependency check; the compaction paths use it. The peer path is unchanged, andimport_with_limitsremains the knob for tuning it.It is a separate entry point rather than an unbounded
CrdtImportLimitsconstructor, so bytes off the wire cannot reach the exempt path by passing a value.perf(crdt): stop materialising the document to answer cheap questionscollection_namescalledget_deep_value, materialising every row and field of every collection to read the top-level keys — O(document) for a list whose length is the number of collections, called per query during name resolution. The shallow value carries the same keys.estimated_memory_bytesexports a full snapshot as its size proxy. That is the honest proxy, but callers poll it to decide when to compact and an unwritten document returns the same number every time, so it is now computed once per version.fix(crdt): admit local-snapshot reloads without the peer ceilingsTwo things the first two commits left unfinished.
The Origin callers were still capped.
import_localreached only the two compaction paths, so the failure this PR describes stayed live where it does the most damage.restore_collection_snapshotreloads a pre-image this process exported when the transaction opened: past the ceiling a collection could be written but never rolled back, and the transaction driver was handed a failure it has no way to act on.apply_committed_delta_authenticatedseeds its validation candidate fromcurrent.export_snapshot()on the line above, on every authenticated apply: past the ceiling every incoming delta returnsMalformed, leaving the collection silently unwritable and blaming the sender for it.Both now go through
CrdtState::from_local_snapshot, a constructor pairingnewwithimport_local. Both sites were assembling those two steps by hand, and hand-assembly is exactly where the wrong import gets picked — a caller reaching for "load a state from our own snapshot" now finds an API that is already correct.The memory estimate outlived the document it measured. Compaction does not mutate a document, it replaces one — and a shallow snapshot preserves the version vector, because peers have to keep delta-syncing across it. A cache keyed on the version alone therefore still looked current after the bytes it measured were gone. Measured on a document with 512 operations: 1791 bytes reported against 329 actual, and it never moves again. Since the documented use is "poll this to decide when to trigger
compact_history()", a caller would compact, see no change, and compact forever.Fixed by ownership rather than by a reset line.
DocumentCellholds the document together with everything derived from it; reads go throughDerefso no call site changes, butself.doc = new_docno longer type-checks andreplace— which drops the cache with the document — is the only way to swap it. A future compaction path cannot forget. A failed export is also no longer cached, which would otherwise have pinned a document at "empty" until its next write.compact_historyandcompact_at_versionwere duplicating the whole of compaction; they now sharecompact_to_frontiers.Tests
local_import_admits_what_the_peer_ceilings_would_reject— the peer path still rejects, the local path admits the same byteslocal_import_still_rejects_malformed_metadata— dropping the ceilings does not drop the structural checksfrom_local_snapshot_loads_without_the_peer_ceilings/from_local_snapshot_rejects_malformed_bytes— the constructor reloads our own snapshot and keeps every structural checkcollection_names_lists_top_level_keys_only— pins what the shallow read must returnestimated_memory_follows_compaction— fails on the pre-fix code with the 1791-vs-329 stale valueoplog_version_counts_uncommitted_operations— pins the Loro property the estimate's cache key depends on: the version advances when an operation is written, not when its transaction commits. The cache is wrong the day this stops holding, so it is asserted rather than assumed.estimated_memory_grows_with_data(existing) — covers cache invalidation on the write pathThe behaviour past the default ceilings is not covered. Reaching them needs a document over a gigabyte or over a million operations, and a fixture that size does not belong in the suite. A round-trip test against explicit tight limits was written and dropped: it passed with
import_localwired to the capped path, so it asserted nothing about the change.Validation
cargo nextest run -p nodedb-crdt— 161/161cargo nextest run -p nodedb --all-features— 7938/7938cargo nextest run -p nodedb-cluster-tests --all-features --retries 0— 322/323. The one failure,a4_placement_convergence::placement_converges_to_min_rf_when_node_count_exceeds_rf, is vShard placement with no CRDT involvement; it took 31.7s before failing under full-suite load and passes standalone in 2.4s, with the suite's usual single retry disabled.cargo clippy --all-targets -- -D warnings— clean across the workspacecargo fmt --all— cleancargo check --workspace --all-targets— cleanFollow-up
The embedded side does not benefit until its restore path calls
import_local— it currently goes throughCrdtState::importand stays capped. That is a one-line change innodedb-lite, blocked on a release carryingimport_local. The store preserved in #230 is the acceptance case for both and will be exercised once that lands; it is kept until then.