Skip to content

feat(mesh): gossip CRDT operation log over the wire (d-3a) - #1570

Merged
CatherineSue merged 2 commits into
mainfrom
chang/mesh-d3a-crdt-gossip
Jun 1, 2026
Merged

CatherineSue merged 2 commits into
mainfrom
chang/mesh-d3a-crdt-gossip

Conversation

@CatherineSue

@CatherineSue CatherineSue commented May 29, 2026 •

Copy link
Copy Markdown
Member

Description

Problem

The mesh-v2 gap analysis (.claude/docs/mesh/mesh-v2-gap-analysis.md) flagged the d-3 cutover as the single largest gap: CRDT operations never reached the wire. CrdtNamespace::put/delete only mutated the local op-log, CrdtOrMap::merge was reachable only from tests, and the gossip loop carried stream traffic (td:/tree:*) only. So the worker:, rl:, and config: CRDT namespaces were silent across nodes, and remote CRDT changes never reached local subscribers.

Solution

Wire the CRDT data path onto the existing sync_stream transport (this is d-3a; the adapter wiring in server.rs and rate-limit enforcement are follow-ups). Two halves, in one PR:

  1. CRDT op-log over the wire — state converges across nodes.
  2. Subscriber fan-out on remote merge + value-shape alignment (migration step 7) — remote changes reach subscribers with the same value shape get returns.

Changes

Wire format (crates/mesh/src/proto/gossip.proto)

  • CrdtOp { key, value, tombstone, timestamp, replica_id } + CrdtBatch { ops } + CRDT_BATCH StreamMessageType + a crdt_batch arm in the StreamMessage oneof. CrdtOp mirrors the in-crate Operation; replica_id is the ReplicaId UUID in text form. Field-by-field (not an opaque bincode blob) for a language-agnostic schema.

Codec (crates/mesh/src/transport/crdt_batch.rs, new)

  • build_crdt_batch (op-log → CrdtBatch), wrap_crdt_batch (envelope), dispatch_crdt_batch (decode → merge). Ops with an unparsable replica_id are dropped rather than poisoning the merge.

Producer / consumer

  • RoundBatch gains a crdt_ops snapshot, filled in collect_round_batch from the store's op-log. Both per-peer senders (gossip_controller dialed streams, gossip_service accepted streams) broadcast the snapshot each round alongside the stream batch. Merge is idempotent by op-id, so re-sending seen ops is a no-op — per-peer watermark filtering is a follow-up.
  • Both inbound dispatch blocks gain a CrdtBatch arm → dispatch_crdt_batch.

Fan-out + step 7 (engine/mod.rs, engine/lww.rs, engine/rate_limit.rs, crdt.rs, kv.rs)

  • NamespaceCrdtEngine::apply_remote_ops now returns Vec<CrdtChange>; CrdtOrMap::merge concatenates per-engine changes; MeshKV::merge_crdt_ops fires SubscriberRegistry::notify per change with the canonical post-merge value (matching get).
  • CrdtNamespace::put now notifies the canonical post-insert value instead of the raw caller payload, so local-write and remote-merge subscribers see one shape (the rl: adapter no longer needs to special-case the raw payload; its module doc is updated).
  • Change detection emits a CrdtChange iff the observable get(key) value actually changes (snapshot before/after the apply loop), not on mere op acceptance. This suppresses spurious events: a newer-version insert rewriting byte-identical bytes, and a tombstone for an already-absent key (Tombstone→Tombstone or vacant→Tombstone, both encoding to None). Generation/log semantics are unchanged.

Verification

An adversarial multi-agent pass reviewed the change-detection for missed/spurious/wrong-value events. It found no missed events and no convergence bugs, and two P2 spurious-event classes — both fixed by the observable-value diff above. (Two findings were correct-as-is: a frontier reshuffle that changes the encoded shard bytes correctly fires, and a benign concurrency TOCTOU where the emitted value is always a valid get snapshot and converges.)

Test Plan

  • cargo test -p smg-mesh --lib — 165 passed, 0 failed (+15 over baseline)
    • codec: round-trip, envelope shape, empty-skip, bad-replica-id reject
    • end-to-end (no gRPC, tests/crdt_integration.rs): convergence, remote-merge subscriber fan-out, idempotent re-delivery fires nothing, rl: canonical-shard shape, tombstone→None
    • CrdtOrMap::merge change-detection contract: new value, byte-identical no-op, never-seen tombstone no-op, kill-live emits None
  • cargo build -p smg + cargo test -p smg --lib mesh::adapters — pass (downstream unaffected)
  • cargo clippy -p smg-mesh --all-targets + cargo fmt — clean

Net: +635 / −21 across 18 files.

Follow-ups (not in this PR)

  • Per-peer CRDT send watermark (bandwidth + at-least-once retry).
  • Initial CRDT snapshot-on-join / partition heal.
  • Wire the v2 adapters (worker:/rl:/tree) into server.rs and connect outbound change paths (d-3 PR 2/3); rate-limit middleware enforcement (d-3b).
Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes
  • (Optional) Documentation updated
  • (Optional) Please join us on Slack #sig-smg to discuss, review, and merge PRs

Summary by CodeRabbit

  • New Features

    • CRDT operation batches now synchronize across peers via gossip.
    • Subscribers are notified with the canonical post-merge value; notifications are only emitted when a key’s observable value actually changes.
    • Deletes propagate as notifications with a None payload.
  • Tests

    • Integration tests for remote CRDT delivery, convergence, idempotency, canonical shard shape, and tombstone propagation.
    • Unit tests for change-reporting and tombstone edge cases.

Review Change Stack

Wires the CRDT data path onto the existing sync_stream transport so the
`worker:`, `rl:`, and `config:` namespaces converge across nodes, and
delivers remote changes to local subscribers. Until now CRDT writes only
mutated the local op-log; `CrdtOrMap::merge` was reachable only from
tests and the gossip loop carried stream traffic only.

Wire format:
- New `CrdtOp { key, value, tombstone, timestamp, replica_id }` and
  `CrdtBatch { ops }` proto messages + `CRDT_BATCH` StreamMessageType.
  `CrdtOp` mirrors the in-crate `Operation`; `replica_id` is the
  ReplicaId UUID in text form. Field-by-field (not an opaque bincode
  blob) for a language-agnostic schema.
- `transport/crdt_batch.rs`: `build_crdt_batch` (op-log -> CrdtBatch),
  `wrap_crdt_batch` (StreamMessage envelope), `dispatch_crdt_batch`
  (decode -> merge). Ops with an unparsable replica_id are dropped.

Producer/consumer:
- `RoundBatch` gains a `crdt_ops` snapshot, filled in
  `collect_round_batch` from the store's op-log. Both per-peer senders
  (gossip_controller dialed streams, gossip_service accepted streams)
  broadcast the snapshot each round alongside the stream batch. Merge is
  idempotent by op-id, so re-sending seen ops is a no-op; per-peer
  watermark filtering is a follow-up.
- Both inbound dispatch blocks gain a `CrdtBatch` arm routing to
  `dispatch_crdt_batch`.

Subscriber fan-out + value-shape alignment (migration step 7):
- `NamespaceCrdtEngine::apply_remote_ops` now returns `Vec<CrdtChange>`;
  `CrdtOrMap::merge` concatenates per-engine changes; `MeshKV::merge_crdt_ops`
  fires `SubscriberRegistry::notify` per change with the canonical
  post-merge value (matching `get`). `CrdtNamespace::put` likewise now
  notifies the canonical post-insert value instead of the raw caller
  payload, so local-write and remote-merge subscribers see one shape
  (the `rl:` adapter no longer needs to special-case the raw payload).
- Change detection emits a `CrdtChange` iff the observable `get(key)`
  value actually changes (snapshot before/after the apply loop), not on
  mere op acceptance. This suppresses spurious events: a newer-version
  insert rewriting byte-identical bytes, and a tombstone for an
  already-absent key (Tombstone->Tombstone or vacant->Tombstone, both
  encoding to None). Generation/log semantics are unchanged.

Tests: codec round-trip + envelope shape; end-to-end (no gRPC)
convergence, remote-merge subscriber fan-out, idempotent re-delivery
fires nothing, `rl:` canonical-shard shape, and tombstone->None;
`CrdtOrMap::merge` change-detection contract (new value, byte-identical
no-op, never-seen tombstone no-op, kill-live emits None). 165 mesh tests
pass; gateway builds and its mesh-adapter tests pass; clippy + fmt clean.

Not in this PR (follow-ups): per-peer CRDT watermark (bandwidth/at-least-once
retry), initial snapshot-on-join, and wiring the adapters into server.rs.

Signed-off-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
@github-actions github-actions Bot added tests Test changes model-gateway Model gateway crate changes mesh Mesh crate changes labels May 29, 2026
@coderabbitai

coderabbitai Bot commented May 29, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 90b3b4d6-2997-47f2-8b8f-5d65064b8837

📥 Commits

Reviewing files that changed from the base of the PR and between 4f77e88 and 2d7ad42.

📒 Files selected for processing (6)
  • crates/mesh/src/crdt_kv/engine/lww.rs
  • crates/mesh/src/crdt_kv/engine/rate_limit.rs
  • crates/mesh/src/gossip_controller.rs
  • crates/mesh/src/gossip_service.rs
  • crates/mesh/src/tests/crdt_integration.rs
  • crates/mesh/src/transport/crdt_batch.rs

📝 Walkthrough

Walkthrough

Adds per-key change reporting to CRDT remote merges, introduces wire/batching for CRDT ops over gossip, and integrates merge delivery into MeshKV to notify subscribers with canonical post-merge values.

Changes

CRDT Operation Broadcasting and Merge Change Reporting

Layer / File(s) Summary
CrdtChange type and public API exports
crates/mesh/src/crdt_kv/operation.rs, crates/mesh/src/crdt_kv/mod.rs, crates/mesh/src/crdt_kv/crdt.rs
CrdtChange struct added (key: String, value: Option<Vec<u8>>), and public re-exports extended to include CrdtChange, Operation, and ReplicaId.
Engine trait + LWW/RateLimit apply_remote_ops -> Vec
crates/mesh/src/crdt_kv/engine/mod.rs, crates/mesh/src/crdt_kv/engine/lww.rs, crates/mesh/src/crdt_kv/engine/rate_limit.rs
NamespaceCrdtEngine::apply_remote_ops now returns Vec<CrdtChange>. Engines snapshot per-key pre-merge observable values, apply unseen ops, and emit one CrdtChange per key whose post-merge observable value differs.
CrdtOrMap::merge aggregates engine changes and unit tests
crates/mesh/src/crdt_kv/crdt.rs, crates/mesh/src/crdt_kv/tests.rs
CrdtOrMap::merge() collects per-engine Vec<CrdtChange> results into a single returned Vec<CrdtChange>. Unit tests added for inserts, byte-identical updates (no change), tombstones for unseen keys (no change), and tombstone killing live key (emit None).
Gossip protobuf schema for CRDT operations
crates/mesh/src/proto/gossip.proto
Adds CRDT_BATCH enum and crdt_batch payload; introduces CrdtOp and CrdtBatch messages for batched CRDT ops.
Wire format conversion and CRDT batch helpers
crates/mesh/src/transport/crdt_batch.rs, crates/mesh/src/transport/mod.rs
New module converts between Operation and wire CrdtOp, builds size-bounded CrdtBatch frames, wraps them into StreamMessage, and dispatches inbound batches into MeshKV::merge_crdt_ops. Includes unit tests for round-trips, batching, and malformed replica IDs.
Gossip controller and service CRDT batch send/receive
crates/mesh/src/gossip_controller.rs, crates/mesh/src/gossip_service.rs
Sender loops build CRDT frames from RoundBatch.crdt_ops, wrap and send with existing backpressure handling; inbound dispatcher handles StreamMessageType::CrdtBatch and calls dispatch_crdt_batch.
MeshKV round batch and merge integration
crates/mesh/src/kv.rs
RoundBatch gains crdt_ops: Vec<Operation>; collect_round_batch() snapshots the operation log. MeshKV::merge_crdt_ops() merges ops into the store and notifies subscribers per returned CrdtChange using canonical post-merge values; CrdtNamespace::put() notifies using the canonical stored value.
End-to-end CRDT-over-gossip integration tests
crates/mesh/src/tests/crdt_integration.rs, crates/mesh/src/tests/mod.rs
Adds integration tests that deliver encoded CRDT batches directly into receivers and assert store convergence, subscriber notifications (including canonical rl: shard normalization), idempotency, and tombstone behavior.
Test fixtures and minor docs/re-export adjustments
crates/mesh/src/transport/sync_stream.rs, crates/mesh/src/gossip_service.rs, crates/mesh/src/lib.rs, model_gateway/src/mesh/adapters/rate_limit_sync.rs
Test fixtures updated to initialize crdt_ops; minor lib re-export formatting and module docs clarified.

Sequence Diagram(s)

sequenceDiagram
  participant SenderMeshKV as Sender MeshKV
  participant RoundBatch as RoundBatch
  participant Gossip as Gossip Sender
  participant Transport as crdt_batch (wrap/build)
  participant RecvGossip as Receiver Gossip
  participant ReceiverMeshKV as Receiver MeshKV
  participant Subscriber as Subscriber

  SenderMeshKV->>RoundBatch: collect_round_batch() (includes crdt_ops)
  RoundBatch-->>Gossip: RoundBatch
  Gossip->>Transport: build_crdt_batches(ops)
  Transport-->>Gossip: CrdtBatch frames
  Gossip->>RecvGossip: send StreamMessage(CrdtBatch)
  RecvGossip->>Transport: dispatch_crdt_batch()
  Transport->>ReceiverMeshKV: merge_crdt_ops(ops)
  ReceiverMeshKV->>ReceiverMeshKV: store.merge + snapshot per-key before/after
  ReceiverMeshKV->>Subscriber: notify Vec<CrdtChange>
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

  • lightseekorg/smg#1569: Modifies the same engine merge entrypoints; related to batch dedup/collision handling alongside change-reporting.
  • lightseekorg/smg#1549: Related RateLimitEngine work affecting remote-merge semantics that this PR builds upon.
  • lightseekorg/smg#1539: Prior engine-splitting refactor that this PR further modifies by adding CrdtChange returns.

Suggested labels

grpc

Suggested reviewers

  • tonyluj
  • llfl
  • slin1237
  • claude

Poem

🐰 I stitched the ops into gossip’s song,
Keys that changed now sing along,
Tombstones whisper, inserts gleam bright,
Subscribers wake to canonical light,
Hopping with joy at each merge’s sight.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately captures the main objective: adding CRDT operation log gossip over the wire, which is the central feature across all 18 files changed.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chang/mesh-d3a-crdt-gossip

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f77e884d5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/mesh/src/transport/crdt_batch.rs Outdated
return None;
}
Some(CrdtBatch {
ops: ops.iter().map(op_to_proto).collect(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Split CRDT batches before sending them over gRPC

When the CRDT op-log snapshot contains a large value or enough keys to exceed the 10 MiB MAX_MESSAGE_SIZE used by the tonic clients/servers, this constructs a single unbounded CrdtBatch frame. Unlike the stream path, which chunks values below the gRPC cap, this frame will be rejected during encode/decode and can close/fail sync_stream, leaving that peer unable to receive CRDT updates until the log shrinks. Please split/chunk CRDT batches or enforce the transport limit before wrapping them in a StreamMessage.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2d7ad42. build_crdt_batches now splits the op-log snapshot into frames each estimated to stay under MAX_STREAM_CHUNK_BYTES (= MAX_MESSAGE_SIZE − 64 KiB envelope margin, the same budget the stream path uses), and both senders loop over the returned batches. A single op larger than the budget is emitted alone (best-effort); worker:/rl:/config: values are far below the cap, so in practice this only bounds the op count per frame.

@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 implements the CRDT-over-gossip data path, enabling the broadcasting and merging of CRDT operation logs (CrdtBatch) between peers, and updating subscribers only when observable values change. The review feedback highlights several improvement opportunities: avoiding unnecessary heap allocations in the LWW and Rate Limit engines by checking contains_key with a borrowed string slice instead of using entry with an owned string; offloading the CPU-bound dispatch_crdt_batch operation to tokio::task::spawn_blocking to prevent blocking the async executor; and adding defensive checks to ensure large serialized batches do not exceed the gRPC MAX_MESSAGE_SIZE limit.

Comment on lines +403 to +409
let mut before: std::collections::HashMap<String, Option<Vec<u8>>> =
std::collections::HashMap::new();
for op in &unseen {
before
.entry(op.key().to_string())
.or_insert_with(|| self.store.get(op.key()));
}

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.

medium

In Rust, HashMap::entry takes an owned key, which means op.key().to_string() will allocate a new String on every single iteration of the loop, even if the key is already present in the before map. Since unseen can contain multiple operations for the same key (especially during bulk merges or heavy gossip rounds), this results in unnecessary heap allocations.

To avoid these allocations, check if the key is already present using contains_key with a borrowed &str before inserting.

Suggested change
let mut before: std::collections::HashMap<String, Option<Vec<u8>>> =
std::collections::HashMap::new();
for op in &unseen {
before
.entry(op.key().to_string())
.or_insert_with(|| self.store.get(op.key()));
}
let mut before: std::collections::HashMap<String, Option<Vec<u8>>> =
std::collections::HashMap::new();
for op in &unseen {
if !before.contains_key(op.key()) {
before.insert(op.key().to_string(), self.store.get(op.key()));
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2d7ad42 — switched to if !before.contains_key(op.key()) { before.insert(op.key().to_string(), self.store.get(op.key())) } so the String is only allocated for keys not yet snapshotted.

Comment on lines +386 to +392
let mut before: std::collections::HashMap<String, Option<Vec<u8>>> =
std::collections::HashMap::new();
for op in &ops {
before
.entry(op.key().to_string())
.or_insert_with(|| self.current_encoded(op.key()));
}

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.

medium

Similar to the LWW engine, calling before.entry(op.key().to_string()) allocates a new String on every iteration of the loop even if the key already exists in the map. Since ops can contain duplicate keys, we can avoid these unnecessary heap allocations by checking contains_key first.

Suggested change
let mut before: std::collections::HashMap<String, Option<Vec<u8>>> =
std::collections::HashMap::new();
for op in &ops {
before
.entry(op.key().to_string())
.or_insert_with(|| self.current_encoded(op.key()));
}
let mut before: std::collections::HashMap<String, Option<Vec<u8>>> =
std::collections::HashMap::new();
for op in &ops {
if !before.contains_key(op.key()) {
before.insert(op.key().to_string(), self.current_encoded(op.key()));
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2d7ad42, same contains_key guard as the LWW engine.

Comment on lines +93 to +99
pub fn dispatch_crdt_batch(mesh_kv: &MeshKV, batch: CrdtBatch) {
let ops: Vec<Operation> = batch.ops.into_iter().filter_map(proto_to_op).collect();
if ops.is_empty() {
return;
}
mesh_kv.merge_crdt_ops(ops);
}

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.

medium

The dispatch_crdt_batch function is called synchronously on the async executor thread. However, merging a CrdtBatch (which can contain up to AUTO_COMPACT_THRESHOLD of 10,000 operations) is a highly CPU-bound operation involving sorting, compaction, multiple map lookups, and lock acquisitions. Running this synchronously can block the async executor thread for tens of milliseconds, leading to latency spikes or heartbeat timeouts on other active connections.

Consider offloading this CPU-bound merge operation to tokio::task::spawn_blocking to keep the async executor responsive.

References
  1. Offload CPU-bound operations, such as large-scale bincode serialization or complex tree walks, to tokio::task::spawn_blocking to prevent blocking the async executor threads.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this one, with reasoning:

  1. Not CRDT-specific. dispatch_stream_batch (the sibling, already in production) also runs synchronously on the executor and handles larger traffic (tree pages up to 64 MB, chunked). If sync dispatch were a problem it would already be one there. The right fix is a single cross-cutting "non-blocking receive path" change covering both stream and CRDT dispatch, not a CRDT-only bolt-on.

  2. Cost only bites out-of-spec. The merge is O(batch + log). In the design's supported regime (~30 keys) the op-log is tiny and the merge is microseconds. The "tens of ms" only materializes at the ~10K-op regime, which is already out of the v2 design envelope.

  3. spawn_blocking isn't free. It requires the work to be Send + 'static (cloning an Arc of state into the closure — a real restructure), pulls a runtime dependency into a currently runtime-agnostic codec layer, and converts a serialized executor path into a concurrent one against the same CrdtOrMap (safe given the engines' internal locking, but a semantics change deserving its own review). For the common small-batch case it also adds latency.

  4. No measurement justifies it yet — it's a hypothesis for an out-of-spec regime, and the broadcast-full-op-log behavior is interim (the watermark follow-up shrinks batches to deltas anyway).

Noting it as a candidate for a future cross-cutting non-blocking-dispatch change, gated on an actual profile during the d-3 rollout.

Comment thread crates/mesh/src/transport/crdt_batch.rs Outdated
Comment on lines +70 to +77
pub fn build_crdt_batch(ops: &[Operation]) -> Option<CrdtBatch> {
if ops.is_empty() {
return None;
}
Some(CrdtBatch {
ops: ops.iter().map(op_to_proto).collect(),
})
}

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.

medium

Since CrdtBatch currently broadcasts the full op-log snapshot (which can grow up to AUTO_COMPACT_THRESHOLD of 10,000 operations) without any chunking or size validation, a large op-log could easily produce a serialized message that exceeds the gRPC MAX_MESSAGE_SIZE limit. If this happens, the gRPC stream will be abruptly terminated with a "message too large" error, disrupting gossip convergence.

While delta-based watermark filtering is planned as a follow-up, consider adding a defensive check or logging warning if the serialized batch size approaches MAX_MESSAGE_SIZE to prevent silent stream failures.

References
  1. When chunking data for a transport protocol, reserve a margin from the maximum message size for protocol envelope overhead (headers, metadata) to prevent the total serialized message from being rejected.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2d7ad42 — went with splitting rather than just a warning: build_crdt_batches bounds each frame below MAX_MESSAGE_SIZE (reusing MAX_STREAM_CHUNK_BYTES with its 64 KiB envelope margin), so a large full-op-log broadcast can never produce an oversized frame that tears down the stream. New test build_crdt_batches_splits_over_budget covers it.

Comment thread crates/mesh/src/transport/crdt_batch.rs Outdated
/// Receiver-side dispatch for a `CrdtBatch`: decode each op and merge the batch
/// into the local CRDT store. Ops with an unparsable `replica_id` are skipped.
/// Merge is idempotent by op-id, so a batch the node has already absorbed is a
/// no-op. (Subscriber notification on remote merge is a follow-up — d-3a-2.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: This parenthetical says subscriber notification is a follow-up (d-3a-2), but merge_crdt_ops (called via mesh_kv.merge_crdt_ops(ops) on line 98) already fires subscriber_registry.notify for every changed key. The comment is stale — this PR implements the fan-out.

Suggested change
/// no-op. (Subscriber notification on remote merge is a follow-up — d-3a-2.)
/// no-op.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 2d7ad42. The doc now states subscriber fan-out on remote merge is implemented here (via merge_crdt_ops), and that an already-absorbed batch is a no-op that fires no event. The d-3a-2 reference was stale leftover from before the fan-out landed in this PR.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-tested PR. The wire format, codec, change-detection, and gossip integration all look correct. One stale doc comment flagged (nit).

- Split the per-round CRDT op-log snapshot into size-bounded frames
  (`build_crdt_batches`, capped at MAX_STREAM_CHUNK_BYTES) instead of one
  unbounded `CrdtBatch`. A large op-log (broadcast in full each round
  until per-peer watermark filtering lands) could otherwise serialize to
  a frame above MAX_MESSAGE_SIZE, which tonic rejects on encode/decode
  and which would tear down the sync_stream. Both senders now loop over
  the returned batches. (codex P2 + gemini)
- Avoid a per-op `String` allocation in both engines' change-detection
  snapshot: check `contains_key(&str)` before inserting, since a batch
  may repeat a key. (gemini)
- Fix the stale `dispatch_crdt_batch` doc comment: subscriber fan-out on
  remote merge is implemented in this PR (via `merge_crdt_ops`), not a
  follow-up. (claude)

Tests: `build_crdt_batches` empty/single + over-budget split coverage.
166 mesh tests pass; clippy + fmt clean.

Signed-off-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
@CatherineSue
CatherineSue merged commit 80b64ce into main Jun 1, 2026
96 of 98 checks passed
@CatherineSue
CatherineSue deleted the chang/mesh-d3a-crdt-gossip branch June 1, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mesh Mesh crate changes model-gateway Model gateway crate changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant