Skip to content

CRDT document past DEFAULT_MAX_IMPORT_BYTES is unopenable, and unreadable once the ceiling is raised #230

Description

@mkhairi

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

  1. Write ~25k documents into one CRDT collection through nodedb-lite until the encoded snapshot exceeds 64 MiB.
  2. Close cleanly and reopen → fault 1.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions