A CRDT document that grows past DEFAULT_MAX_IMPORT_BYTES becomes permanently unopenable, and raising the ceiling then exposes a second fault: every read on the reopened document spins at 100% CPU with no I/O.
Reproduced on da6f18377 (current main). Both faults are in nodedb-crdt; the callers are in nodedb-lite. pagedb is not involved.
1. The local restore path is bounded by a peer-facing DoS budget
admit_import rejects an encoded import larger than DEFAULT_MAX_IMPORT_BYTES (64 MiB):
// nodedb-crdt/src/state/import_admission.rs:8
pub const DEFAULT_MAX_IMPORT_BYTES: usize = 64 * 1024 * 1024;
// :71
if bytes.len() > limits.max_bytes {
return Err(CrdtError::ImportTooLarge { limit: limits.max_bytes, actual: bytes.len() });
}
That bound is correct against a hostile peer's encoded import. It is also reached from the snapshot restore path on open, where the bytes are the document's own durable state. There, "too large" is not an attack — it is a document that did its job.
The consequence is that a store crosses the threshold by succeeding at writes, and only finds out at the next open:
CRDT restore of 'knowledge_objects' failed:
snapshot import for 'knowledge_objects' failed:
CRDT import exceeds byte limit: 77427906 > 67108864
There is no escape hatch for an embedder. CrdtImportLimits is public, but nothing in nodedb-lite ever constructs one — grep -rn CrdtImportLimits nodedb-lite/src is empty — so the restore path always uses Default. An operator holding an unopenable store cannot raise the bound through configuration; only a recompile of nodedb helps.
Observed density in our workload is ~2.9 KB of encoded CRDT state per stored object, which puts the practical ceiling near 23k objects in one document.
2. With the ceiling raised, the document opens but cannot be read
Patching the constant to 1 GiB and rebuilding, the same store opens successfully. It is then unusable:
- an HTTP route that touches no CRDT state answers in 0.5 ms
- every route that reads the document never returns (observed >10 min)
- the process holds 100% CPU, and over 50 s of sampling: RSS exactly flat (1834172 → 1834172 kB),
write_bytes flat, read_bytes 0
No allocation and no I/O while pegging a core is a tight loop, not lazy index construction.
Graceful shutdown does not complete either — after SIGTERM the process held 99.8% CPU for 7.5 min with write_bytes frozen at exactly 307200. A shutdown timeout cannot help a flush that makes no progress. (SIGKILL was safe here precisely because nothing had been written since the signal.)
Fault 2 is the blocker. Fixing only the ceiling makes such a document openable but still unreadable.
Reproduction
- Write ~25k documents into one CRDT collection through nodedb-lite until the encoded snapshot exceeds 64 MiB.
- Close cleanly and reopen → fault 1.
- Raise
DEFAULT_MAX_IMPORT_BYTES, rebuild, reopen → opens, then fault 2 on the first read.
The write path itself was healthy throughout: 25,120 records with zero errors, no AEAD or corruption diagnostics over ~4 hours.
I have a 32 GB store in this state preserved and can run any instrumented build against it. Also worth noting separately: that 32 GB is not CRDT state — open reads only 4 KB — so the on-disk size looks like retired-segment accumulation and is probably its own issue.
Suggested direction
For fault 1, the two paths want different bounds. A peer import is untrusted and should stay capped; a local restore is bounded by what the store already committed, so either exempt it or give it its own limit. Threading CrdtImportLimits out to the embedding caller would also let an operator recover an existing store without recompiling.
I can send a PR once you indicate which shape you prefer — separating the paths versus making the limits configurable — since either touches the public surface.
For fault 2 I do not have a root cause. Diagnosing the spin needs a profiler with privileges I would rather not enable on this host; if there is an instrumented build or a specific counter you want sampled, I can run it against the preserved store.
A CRDT document that grows past
DEFAULT_MAX_IMPORT_BYTESbecomes permanently unopenable, and raising the ceiling then exposes a second fault: every read on the reopened document spins at 100% CPU with no I/O.Reproduced on
da6f18377(current main). Both faults are innodedb-crdt; the callers are in nodedb-lite. pagedb is not involved.1. The local restore path is bounded by a peer-facing DoS budget
admit_importrejects an encoded import larger thanDEFAULT_MAX_IMPORT_BYTES(64 MiB):That bound is correct against a hostile peer's encoded import. It is also reached from the snapshot restore path on open, where the bytes are the document's own durable state. There, "too large" is not an attack — it is a document that did its job.
The consequence is that a store crosses the threshold by succeeding at writes, and only finds out at the next open:
There is no escape hatch for an embedder.
CrdtImportLimitsis public, but nothing in nodedb-lite ever constructs one —grep -rn CrdtImportLimits nodedb-lite/srcis empty — so the restore path always usesDefault. An operator holding an unopenable store cannot raise the bound through configuration; only a recompile of nodedb helps.Observed density in our workload is ~2.9 KB of encoded CRDT state per stored object, which puts the practical ceiling near 23k objects in one document.
2. With the ceiling raised, the document opens but cannot be read
Patching the constant to 1 GiB and rebuilding, the same store opens successfully. It is then unusable:
write_bytesflat,read_bytes0No allocation and no I/O while pegging a core is a tight loop, not lazy index construction.
Graceful shutdown does not complete either — after SIGTERM the process held 99.8% CPU for 7.5 min with
write_bytesfrozen at exactly 307200. A shutdown timeout cannot help a flush that makes no progress. (SIGKILL was safe here precisely because nothing had been written since the signal.)Fault 2 is the blocker. Fixing only the ceiling makes such a document openable but still unreadable.
Reproduction
DEFAULT_MAX_IMPORT_BYTES, rebuild, reopen → opens, then fault 2 on the first read.The write path itself was healthy throughout: 25,120 records with zero errors, no AEAD or corruption diagnostics over ~4 hours.
I have a 32 GB store in this state preserved and can run any instrumented build against it. Also worth noting separately: that 32 GB is not CRDT state — open reads only 4 KB — so the on-disk size looks like retired-segment accumulation and is probably its own issue.
Suggested direction
For fault 1, the two paths want different bounds. A peer import is untrusted and should stay capped; a local restore is bounded by what the store already committed, so either exempt it or give it its own limit. Threading
CrdtImportLimitsout to the embedding caller would also let an operator recover an existing store without recompiling.I can send a PR once you indicate which shape you prefer — separating the paths versus making the limits configurable — since either touches the public surface.
For fault 2 I do not have a root cause. Diagnosing the spin needs a profiler with privileges I would rather not enable on this host; if there is an instrumented build or a specific counter you want sampled, I can run it against the preserved store.