Skip to content

Commit 4ccdd12

Browse files
refactor(persistence): snapshot-based interface (5 procs, atomic per-op) (#72)
* feat: propagate persistence backend errors via Result The Persistence contract previously returned `Future[void]` for writes and `Future[ChannelSnapshot]` for the loader, with `raises: []`. Backends had no way to report a failure, so a failed write or a failed/partial read was silently swallowed — and on the read path a mid-scan failure could bootstrap a *truncated* channel snapshot, corrupting the rebuilt bloom filter and lamport clock across a restart. Make every contract field Result-returning: * mutating ops -> Future[Result[void, string]] * loadAllForChannel -> Future[Result[ChannelSnapshot, string]] The backend-supplied error string is mapped to a new `ReliabilityError.rePersistenceError` (logged once at the boundary via `reliabilityErr`) and threaded up through every persistence-touching proc to the public API, where the caller decides what to do. Request-driven paths (wrap/unwrap/markDependenciesMet/ensureChannel/removeChannel/reset) propagate the error; background maintenance loops (periodicBufferSweep, periodicRepairSweep) log and retry on the next tick, since they have no synchronous caller. Tests: in-memory backend gains a `failingOps` injection hook; new "Persistence: error propagation" suite asserts read/write/drop failures surface as `rePersistenceError`. Full suite passes (90 OK). BREAKING CHANGE: the `Persistence` contract signature changed; custom backends must return `Result` and `ok()` on success. Bumped to 0.3.0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(persistence): add snapshot types and codec (phase 0) Introduce atomic-snapshot persistence types that will replace the current fine-grained 13-proc Persistence interface. This commit is purely additive: no existing call site changes, no behaviour change. New types (sds/types/): - channel_meta.nim — ChannelMeta (atomic per-channel snapshot blob), ChannelData (bootstrap payload), OutgoingRepairKV / IncomingRepairKV (flattened map entries for protobuf wire shape). - history_update.nim — HistoryUpdate (combined append/evict payload for the message log). New codec (sds/snapshot_codec.nim): - Protobuf encode/decode for all new types, reusing the existing SdsMessage and HistoryEntry encoders from sds/protobuf.nim. - Explicit schemaVersion=1 on ChannelMeta; decoder rejects unknown versions loudly rather than silently truncating. - Time encoded as int64 unix milliseconds. Tests (tests/test_snapshot_codec.nim): - 13 round-trip cases covering empty, single-entry, full-buffer, and repair-heavy snapshots; ChannelData ordering; HistoryUpdate variants; schemaVersion rejection. Planning artefacts: - ANALYSIS_SDS_PERSISTENCE.md — problem statement (partial-write divergence, chatty call rate, non-fatal-error policy gap). - ANALYSIS_SNAPSHOT_SAVE_POINTS.md — exact save points per protocol op and projected call rates. - PLAN_SNAPSHOT_PERSISTENCE.md — phased refactor plan; this commit implements phase 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(persistence): add PersistenceV2 interface alongside legacy (phase 1) Introduce the 5-proc snapshot-based Persistence interface that will replace the legacy 13-proc one. Both coexist on `ReliabilityManager` so phase 2 can migrate protocol ops one at a time without breaking existing callers. New file: - sds/types/persistence_v2.nim — `PersistenceV2` type with saveChannelMeta / updateHistory / loadChannel / dropChannel / setRetrievalHint. `noOpPersistenceV2()` default. Doc-comments capture the atomicity pairing (meta save + history update issued back-to-back under the channel lock) and the non-fatal failure policy from PLAN §8. Modified: - sds/types/reliability_manager.nim — adds `persistenceV2: PersistenceV2` field alongside `persistence`; constructor takes both, both default to no-op. - sds.nim — `newReliabilityManager` plumbs the new optional parameter. - AGENTS.md / CLAUDE.md — GitNexus index re-indexed after phase 0 + phase 1 additions; symbol counts updated by `npx gitnexus analyze`. No call site uses the new interface yet — that's phase 2. All existing tests still pass against the legacy interface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(persistence): migrate runRepairSweep to PersistenceV2 (phase 2.1) Per-entry removeIncomingRepair / removeOutgoingRepair calls are replaced by a single trySaveMeta per *dirty* channel at the end of that channel's sweep. Failure is logged but does NOT abort the sweep — in-memory state is the source of truth (PLAN_SNAPSHOT_PERSISTENCE.md §8). Helpers added in sds/sds_utils.nim: - snapshotMeta(channel) — capture current ChannelContext as ChannelMeta blob (flattens Table-keyed buffers to seqs for the wire shape). - trySaveMeta(rm, channelId, channel) — best-effort meta snapshot save; logs on failure, never propagates. - tryUpdateHistory(rm, channelId, append, evict) — best-effort history update; skips the call entirely when both lists are empty (HistoryUpdate contract). Call-rate impact for runRepairSweep: - Before: N persistence calls per expired entry per channel. - After: at most 1 saveChannelMeta per dirty channel; 0 on idle channels (matches the dirty-flag floor in ANALYSIS_SNAPSHOT_SAVE_POINTS). All existing tests pass — including the 3 SDS-R Repair Sweep tests that directly exercise this proc. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(persistence): migrate checkUnacknowledgedMessages to PersistenceV2 (phase 2.2) Per-entry saveOutgoing / removeOutgoing calls are replaced by one trySaveMeta at the end of the pass, conditional on a dirty flag (resend attempt incremented, or entry expired). Pass succeeds even if the save fails — next tick reissues the snapshot. Call-rate impact: - Before: N persistence calls per affected entry per pass. - After: at most 1 saveChannelMeta per pass; 0 when nothing aged out. All existing tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(persistence): add V2 meta snapshot saves to foreground ops (phase 2A) Wires `trySaveMeta` into the three public protocol ops that mutate per-channel state — wrapOutgoingMessage, unwrapReceivedMessage, and markDependenciesMet — at the operation's end, under the channel lock. Legacy fine-grained persistence calls REMAIN in place; this commit is additive. Both interfaces persist the same state simultaneously, so all existing tests pass and a real backend wired to either interface continues to work. Phase 2B will strip the legacy calls. Save points match the §"Save Points" table in ANALYSIS_SNAPSHOT_SAVE_POINTS.md exactly: - wrapOutgoingMessage: 1 save (always) - unwrapReceivedMessage: 1 save on every path including duplicate (the duplicate path still mutates the repair buffers) - markDependenciesMet: 1 save after the processIncomingBuffer cascade Non-fatal failure policy (PLAN §8): trySaveMeta logs and continues; the protocol op never returns rePersistenceError for snapshot failures. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(persistence): strip legacy interface from protocol path; migrate tests to V2 (phase 2B+2C+2D) End-state of phase 2: the protocol code no longer issues any legacy fine-grained Persistence calls. All state survives via the snapshot-based PersistenceV2 interface — one trySaveMeta per op end, plus tryUpdateHistory batched inside addToHistory. The legacy Persistence field on ReliabilityManager remains for backwards compatibility; phase 3 deletes it. Protocol changes (sds.nim, sds/sds_utils.nim): - reviewAckStatus, processIncomingBuffer, updateLamportTimestamp → pure in-memory; no per-mutation persistence. - addToHistory: replaces appendLogEntry+removeLogEntry with a single tryUpdateHistory call carrying (append, evict) atomically. - getRecentHistoryEntries: setRetrievalHint switched to V2; non-fatal. - wrapOutgoingMessage, unwrapReceivedMessage, markDependenciesMet: all per-row saveOutgoing / removeOutgoing / saveIncoming / removeIncoming / saveOutgoingRepair / removeOutgoingRepair / saveIncomingRepair / removeIncomingRepair calls removed (16 call sites in total). State is captured by the op-end trySaveMeta added in phase 2A. - getOrCreateChannel: bootstraps from persistenceV2.loadChannel. - dropChannelFromPersistence: uses persistenceV2.dropChannel. Failure policy (PLAN_SNAPSHOT_PERSISTENCE.md §8): - Foreground ops (wrap, unwrap, markDeps, sweeps): non-fatal — trySaveMeta / tryUpdateHistory log and continue; the protocol op returns ok regardless of disk failure. In-memory state is the source of truth; the next op re-issues a complete snapshot and disk catches up automatically. - Durability-intent ops (removeChannel, resetReliabilityManager via dropChannelFromPersistence; getOrCreateChannel via loadChannel): still propagate rePersistenceError, because the caller asked us to confirm a disk operation and we cannot silently lie. Test infrastructure: - tests/in_memory_persistence_v2.nim: new V2 adapter mock that decomposes the meta blob into the existing InMemoryStore shape so test assertions on store.outgoing / store.incoming / etc. continue to work without change. - tests/test_persistence.nim: 17 tests, all rewritten against V2. - 13 state-survival tests carry over with identical assertions. - "loadChannel failure surfaces as err on bootstrap" — bootstrap keeps durability-intent semantics. - "saveChannelMeta failure during send does NOT surface" — deliberate inversion of the legacy "write failure surfaces as err" test. Asserts the new non-fatal policy: op returns ok, in-memory state correct, disk re-syncs on the next op. - "updateHistory failure during send does NOT surface" — same policy applied to the history path. - "dropChannel failure during removeChannel surfaces as err" — kept. - All 17 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(persistence): delete legacy interface; rename PersistenceV2 -> Persistence (phase 3) End-state of the snapshot-persistence refactor. The legacy 13-proc Persistence interface and its noOpPersistence are gone; the 5-proc snapshot-based interface (formerly PersistenceV2) takes their place under the canonical name. Source: - sds/types/persistence.nim: replaced 13-proc contract with the 5-proc snapshot interface (saveChannelMeta, updateHistory, loadChannel, dropChannel, setRetrievalHint). noOpPersistence returns ok everywhere and an empty ChannelData on load. - sds/types/persistence_v2.nim: removed. - sds/types/reliability_manager.nim: dropped the second persistenceV2 field; constructor takes a single `persistence: Persistence`. - sds/sds_utils.nim: rm.persistenceV2.X -> rm.persistence.X; doc-comments updated. - sds.nim: dropped the persistenceV2 parameter from newReliabilityManager. Tests: - tests/in_memory_persistence_v2.nim: removed; its content moved to... - tests/in_memory_persistence.nim: replaces the old legacy mock with the snapshot adapter under the canonical filename. Same InMemoryStore shape so test assertions stay unchanged. - tests/test_persistence.nim: ctor param renamed, suite name de-prefixed. FFI smoke (`nimble libsdsDynamicMac`, refc/threads:on): builds clean. All 4 test suites pass: - test_bloom - test_reliability - test_persistence (17 V2 tests) - test_snapshot_codec (13 codec round-trip tests) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Persisting persistence redesign plan for reference * refactor(persistence): R2 pending-write queue + per-op accumulator (PR #72 review fix) Addresses all three substantive review findings on PR #72 in one structural change: fold the per-op accumulator and the R2 retry buffer into a single queue on `ChannelContext`, flushed once at op end. Changes: - sds/types/channel_context.nim: add `pendingHistoryAppends` (`OrderedSet[SdsMessageID]`) and `pendingHistoryEvicts` (`HashSet[SdsMessageID]`) fields. Only ids are stored — the full SdsMessage is looked up from `messageHistory` at flush time. Documented invariant: every id in pendingHistoryAppends is also in messageHistory, upheld by the merge rule. - sds/sds_utils.nim: * `queueHistoryAppend(channel, msgId)` / `queueHistoryEvict(channel, msgId)` — "latest-wins" merge: append cancels any pending evict and vice versa. Symmetric, simple, handles the evict-then-re-add sequence correctly (SDS-R repair re-delivering an evicted message while the backend is unreachable). * `tryUpdateHistory(rm, channelId)` — no more list params; flushes the channel's pending queue. Dual role: per-op accumulator (multiple `addToHistory` calls within one op queue together and flush as one round-trip) AND R2 retry buffer (a failed flush leaves the queue populated for the next op to retry). * `addToHistory` queues via the helpers; does not call persistence. * Pending queue cleared on `cleanup` and `removeChannel`. - sds.nim: * `processIncomingBuffer` returns to its single-arg signature — the queue lives on the channel, no parameter threading needed. * `wrapOutgoingMessage`, `unwrapReceivedMessage` (all three paths), `markDependenciesMet` issue exactly one `trySaveMeta` + `tryUpdateHistory` pair at op end, under the lock, with no intervening `await`-of-other-work. Matches the Persistence atomicity contract documented in `sds/types/persistence.nim`. * Pending queue cleared in `resetReliabilityManager`. - tests/test_persistence.nim: * Direct `addToHistory` callers (state-survival setup) now follow with explicit `tryUpdateHistory(channelId)` to flush. Reflects the production op-end flush pattern. * New: `updateHistory failure is retried via R2 pending-write queue` — verifies that two failed sends leave both messages on the queue, and a third successful send drains the whole queue in one call. * New: `pending queue survives idle ops` — verifies that an op with no history changes of its own still flushes a previously-failed batch at op end. * New: `evict-then-re-add merge rule preserves the re-added message on disk` — regression for the "latest-wins" merge rule. The original "evict-wins" rule would silently drop the re-add and leave the message permanently absent from disk; this test would fail under that rule and passes under the corrected one. Resolves PR #72 review comments: - #1 (delta loss on failed updateHistory) — R2 retry queue. - #2 (cascade chattiness — N updateHistory calls per op) — queue collects cascaded entries, flushed as one batch. - #3 (atomicity contract mismatch) — implementation now matches the documented "saveChannelMeta then updateHistory back-to-back" pairing. Test summary: 50 tests pass (47 prior + 3 new R2/merge-rule tests). FFI dylib (`nimble libsdsDynamicMac`, refc + threads:on): clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 980c830 commit 4ccdd12

16 files changed

Lines changed: 1999 additions & 438 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!-- gitnexus:start -->
22
# GitNexus — Code Intelligence
33

4-
This project is indexed by GitNexus as **nim-sds** (889 symbols, 1437 relationships, 45 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
4+
This project is indexed by GitNexus as **nim-sds** (1100 symbols, 1820 relationships, 66 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
55

66
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
77
@@ -40,4 +40,4 @@ This project is indexed by GitNexus as **nim-sds** (889 symbols, 1437 relationsh
4040
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
4141
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
4242

43-
<!-- gitnexus:end -->
43+
<!-- gitnexus:end -->

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ If using Nix, also recalculate the fixed-output hash in `nix/deps.nix` after upd
166166
<!-- gitnexus:start -->
167167
# GitNexus — Code Intelligence
168168

169-
This project is indexed by GitNexus as **nim-sds** (889 symbols, 1437 relationships, 45 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
169+
This project is indexed by GitNexus as **nim-sds** (1100 symbols, 1820 relationships, 66 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
170170

171171
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
172172

doc/PLAN_SNAPSHOT_PERSISTENCE.md

Lines changed: 502 additions & 0 deletions
Large diffs are not rendered by default.

sds.nim

Lines changed: 139 additions & 66 deletions
Large diffs are not rendered by default.

sds.nimble

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import strutils, os
22

33
# Package
4-
version = "0.2.4"
4+
version = "0.3.0"
55
author = "Logos Messaging Team"
66
description = "E2E Scalable Data Sync API"
77
license = "MIT"
@@ -68,6 +68,7 @@ task test, "Run the test suite":
6868
exec "nim c -r --outdir:build tests/test_bloom.nim"
6969
exec "nim c -r --outdir:build tests/test_reliability.nim"
7070
exec "nim c -r --outdir:build tests/test_persistence.nim"
71+
exec "nim c -r --outdir:build tests/test_snapshot_codec.nim"
7172

7273
task libsdsDynamicWindows, "Generate bindings":
7374
let outLibNameAndExt = "libsds.dll"

sds/sds_utils.nim

Lines changed: 214 additions & 38 deletions
Large diffs are not rendered by default.

sds/snapshot_codec.nim

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
## Storage encoding for the snapshot persistence types.
2+
##
3+
## This is the codec nim-sds runs on its side of the persistence boundary
4+
## to turn a `ChannelMeta` (or `ChannelData`, or `HistoryUpdate`) into the
5+
## opaque `seq[byte]` blob the KV persistence backend stores. The KV
6+
## backend treats the blob as fully opaque. See PLAN_SNAPSHOT_PERSISTENCE.md
7+
## §1.5 for why this codec exists at all and §6 for the choice of protobuf.
8+
##
9+
## This is NOT the SDS network wire format — that lives in `sds/protobuf.nim`
10+
## and is unchanged. Encoders for `SdsMessage` and `HistoryEntry` are reused
11+
## from there to avoid maintaining two codecs for the same shape.
12+
13+
{.push raises: [].}
14+
15+
import std/[sets, times]
16+
import libp2p/protobuf/minprotobuf
17+
import ./types/[
18+
channel_meta, history_update, sds_message, sds_message_id, history_entry,
19+
unacknowledged_message, incoming_message, repair_entry, reliability_error,
20+
]
21+
import ./protobufutil
22+
import ./protobuf as wire
23+
24+
export channel_meta, history_update
25+
26+
# ---------------------------------------------------------------------------
27+
# Time <-> int64 unix milliseconds
28+
# ---------------------------------------------------------------------------
29+
# The protocol uses `getTime()` (wall clock). For wire stability we encode
30+
# as unix milliseconds in int64 (zigzag not needed — pre-1970 values do not
31+
# occur in practice). Sub-millisecond precision is intentionally dropped:
32+
# the protocol's repair backoff windows are seconds-scale.
33+
34+
proc toUnixMs(t: Time): int64 =
35+
t.toUnix * 1000'i64 + int64(t.nanosecond div 1_000_000)
36+
37+
proc fromUnixMs(ms: int64): Time =
38+
let secs = ms div 1000
39+
let nanos = (ms mod 1000).int * 1_000_000
40+
initTime(secs, nanos)
41+
42+
# ---------------------------------------------------------------------------
43+
# UnacknowledgedMessage
44+
# ---------------------------------------------------------------------------
45+
46+
proc encodeUnacked(u: UnacknowledgedMessage): ProtoBuffer =
47+
var pb = initProtoBuffer()
48+
let msgPb = wire.encode(u.message)
49+
pb.write(1, msgPb.buffer)
50+
pb.write(2, uint64(u.sendTime.toUnixMs))
51+
pb.write(3, uint32(u.resendAttempts))
52+
pb.finish()
53+
pb
54+
55+
proc decodeUnacked(buf: seq[byte]): ProtobufResult[UnacknowledgedMessage] =
56+
let pb = initProtoBuffer(buf)
57+
var msgBytes: seq[byte]
58+
if not ?pb.getField(1, msgBytes):
59+
return err(ProtobufError.missingRequiredField("UnacknowledgedMessage.message"))
60+
let msg = SdsMessage.decode(msgBytes).valueOr:
61+
return err(ProtobufError.missingRequiredField("UnacknowledgedMessage.message"))
62+
var sendMs: uint64
63+
if not ?pb.getField(2, sendMs):
64+
return err(ProtobufError.missingRequiredField("UnacknowledgedMessage.sendTime"))
65+
var attempts: uint32
66+
discard pb.getField(3, attempts)
67+
ok(
68+
UnacknowledgedMessage.init(
69+
message = msg,
70+
sendTime = fromUnixMs(int64(sendMs)),
71+
resendAttempts = int(attempts),
72+
)
73+
)
74+
75+
# ---------------------------------------------------------------------------
76+
# IncomingMessage
77+
# ---------------------------------------------------------------------------
78+
79+
proc encodeIncoming(m: IncomingMessage): ProtoBuffer =
80+
var pb = initProtoBuffer()
81+
let msgPb = wire.encode(m.message)
82+
pb.write(1, msgPb.buffer)
83+
for dep in m.missingDeps:
84+
pb.write(2, dep) # SdsMessageID is string
85+
pb.finish()
86+
pb
87+
88+
proc decodeIncoming(buf: seq[byte]): ProtobufResult[IncomingMessage] =
89+
let pb = initProtoBuffer(buf)
90+
var msgBytes: seq[byte]
91+
if not ?pb.getField(1, msgBytes):
92+
return err(ProtobufError.missingRequiredField("IncomingMessage.message"))
93+
let msg = SdsMessage.decode(msgBytes).valueOr:
94+
return err(ProtobufError.missingRequiredField("IncomingMessage.message"))
95+
var deps: seq[SdsMessageID]
96+
discard pb.getRepeatedField(2, deps)
97+
var depSet = initHashSet[SdsMessageID]()
98+
for d in deps:
99+
depSet.incl(d)
100+
ok(IncomingMessage.init(message = msg, missingDeps = depSet))
101+
102+
# ---------------------------------------------------------------------------
103+
# OutgoingRepairEntry / OutgoingRepairKV
104+
# ---------------------------------------------------------------------------
105+
106+
proc encodeOutRepairEntry(e: OutgoingRepairEntry): ProtoBuffer =
107+
var pb = initProtoBuffer()
108+
let histPb = wire.encodeHistoryEntry(e.outHistEntry)
109+
pb.write(1, histPb.buffer)
110+
pb.write(2, uint64(e.minTimeRepairReq.toUnixMs))
111+
pb.finish()
112+
pb
113+
114+
proc decodeOutRepairEntry(buf: seq[byte]): ProtobufResult[OutgoingRepairEntry] =
115+
let pb = initProtoBuffer(buf)
116+
var histBytes: seq[byte]
117+
if not ?pb.getField(1, histBytes):
118+
return err(ProtobufError.missingRequiredField("OutgoingRepairEntry.outHistEntry"))
119+
let histPb = initProtoBuffer(histBytes)
120+
let entry = ?wire.decodeHistoryEntry(histPb)
121+
var ms: uint64
122+
if not ?pb.getField(2, ms):
123+
return err(ProtobufError.missingRequiredField("OutgoingRepairEntry.minTimeRepairReq"))
124+
ok(
125+
OutgoingRepairEntry.init(
126+
outHistEntry = entry, minTimeRepairReq = fromUnixMs(int64(ms))
127+
)
128+
)
129+
130+
proc encodeOutRepairKV(kv: OutgoingRepairKV): ProtoBuffer =
131+
var pb = initProtoBuffer()
132+
pb.write(1, kv.messageId)
133+
let entryPb = encodeOutRepairEntry(kv.entry)
134+
pb.write(2, entryPb.buffer)
135+
pb.finish()
136+
pb
137+
138+
proc decodeOutRepairKV(buf: seq[byte]): ProtobufResult[OutgoingRepairKV] =
139+
let pb = initProtoBuffer(buf)
140+
var msgId: SdsMessageID
141+
if not ?pb.getField(1, msgId):
142+
return err(ProtobufError.missingRequiredField("OutgoingRepairKV.messageId"))
143+
var entryBytes: seq[byte]
144+
if not ?pb.getField(2, entryBytes):
145+
return err(ProtobufError.missingRequiredField("OutgoingRepairKV.entry"))
146+
let entry = ?decodeOutRepairEntry(entryBytes)
147+
ok(OutgoingRepairKV(messageId: msgId, entry: entry))
148+
149+
# ---------------------------------------------------------------------------
150+
# IncomingRepairEntry / IncomingRepairKV
151+
# ---------------------------------------------------------------------------
152+
153+
proc encodeInRepairEntry(e: IncomingRepairEntry): ProtoBuffer =
154+
var pb = initProtoBuffer()
155+
let histPb = wire.encodeHistoryEntry(e.inHistEntry)
156+
pb.write(1, histPb.buffer)
157+
pb.write(2, e.cachedMessage)
158+
pb.write(3, uint64(e.minTimeRepairResp.toUnixMs))
159+
pb.finish()
160+
pb
161+
162+
proc decodeInRepairEntry(buf: seq[byte]): ProtobufResult[IncomingRepairEntry] =
163+
let pb = initProtoBuffer(buf)
164+
var histBytes: seq[byte]
165+
if not ?pb.getField(1, histBytes):
166+
return err(ProtobufError.missingRequiredField("IncomingRepairEntry.inHistEntry"))
167+
let histPb = initProtoBuffer(histBytes)
168+
let entry = ?wire.decodeHistoryEntry(histPb)
169+
var cached: seq[byte]
170+
if not ?pb.getField(2, cached):
171+
return err(ProtobufError.missingRequiredField("IncomingRepairEntry.cachedMessage"))
172+
var ms: uint64
173+
if not ?pb.getField(3, ms):
174+
return err(ProtobufError.missingRequiredField("IncomingRepairEntry.minTimeRepairResp"))
175+
ok(
176+
IncomingRepairEntry.init(
177+
inHistEntry = entry,
178+
cachedMessage = cached,
179+
minTimeRepairResp = fromUnixMs(int64(ms)),
180+
)
181+
)
182+
183+
proc encodeInRepairKV(kv: IncomingRepairKV): ProtoBuffer =
184+
var pb = initProtoBuffer()
185+
pb.write(1, kv.messageId)
186+
let entryPb = encodeInRepairEntry(kv.entry)
187+
pb.write(2, entryPb.buffer)
188+
pb.finish()
189+
pb
190+
191+
proc decodeInRepairKV(buf: seq[byte]): ProtobufResult[IncomingRepairKV] =
192+
let pb = initProtoBuffer(buf)
193+
var msgId: SdsMessageID
194+
if not ?pb.getField(1, msgId):
195+
return err(ProtobufError.missingRequiredField("IncomingRepairKV.messageId"))
196+
var entryBytes: seq[byte]
197+
if not ?pb.getField(2, entryBytes):
198+
return err(ProtobufError.missingRequiredField("IncomingRepairKV.entry"))
199+
let entry = ?decodeInRepairEntry(entryBytes)
200+
ok(IncomingRepairKV(messageId: msgId, entry: entry))
201+
202+
# ---------------------------------------------------------------------------
203+
# ChannelMeta (top-level snapshot)
204+
# ---------------------------------------------------------------------------
205+
206+
proc encode*(meta: ChannelMeta): ProtoBuffer =
207+
var pb = initProtoBuffer()
208+
pb.write(1, meta.schemaVersion)
209+
pb.write(2, uint64(meta.lamportTimestamp))
210+
for u in meta.outgoingBuffer:
211+
let entryPb = encodeUnacked(u)
212+
pb.write(3, entryPb.buffer)
213+
for m in meta.incomingBuffer:
214+
let entryPb = encodeIncoming(m)
215+
pb.write(4, entryPb.buffer)
216+
for kv in meta.outgoingRepairBuffer:
217+
let entryPb = encodeOutRepairKV(kv)
218+
pb.write(5, entryPb.buffer)
219+
for kv in meta.incomingRepairBuffer:
220+
let entryPb = encodeInRepairKV(kv)
221+
pb.write(6, entryPb.buffer)
222+
pb.finish()
223+
pb
224+
225+
proc decode*(T: type ChannelMeta, buf: seq[byte]): ProtobufResult[T] =
226+
let pb = initProtoBuffer(buf)
227+
var meta = ChannelMeta.init()
228+
229+
var ver: uint32
230+
if not ?pb.getField(1, ver):
231+
return err(ProtobufError.missingRequiredField("ChannelMeta.schemaVersion"))
232+
if ver != ChannelMetaSchemaVersion:
233+
# Per the contract: refuse loudly rather than silently truncating.
234+
return err(ProtobufError.missingRequiredField(
235+
"ChannelMeta.schemaVersion(unsupported)"
236+
))
237+
meta.schemaVersion = ver
238+
239+
var lts: uint64
240+
if not ?pb.getField(2, lts):
241+
return err(ProtobufError.missingRequiredField("ChannelMeta.lamportTimestamp"))
242+
meta.lamportTimestamp = int64(lts)
243+
244+
var outBufs, inBufs, outRepBufs, inRepBufs: seq[seq[byte]]
245+
discard pb.getRepeatedField(3, outBufs)
246+
for b in outBufs:
247+
meta.outgoingBuffer.add(?decodeUnacked(b))
248+
discard pb.getRepeatedField(4, inBufs)
249+
for b in inBufs:
250+
meta.incomingBuffer.add(?decodeIncoming(b))
251+
discard pb.getRepeatedField(5, outRepBufs)
252+
for b in outRepBufs:
253+
meta.outgoingRepairBuffer.add(?decodeOutRepairKV(b))
254+
discard pb.getRepeatedField(6, inRepBufs)
255+
for b in inRepBufs:
256+
meta.incomingRepairBuffer.add(?decodeInRepairKV(b))
257+
ok(meta)
258+
259+
proc serialize*(meta: ChannelMeta): Result[seq[byte], ReliabilityError] =
260+
ok(encode(meta).buffer)
261+
262+
proc deserializeChannelMeta*(
263+
data: seq[byte]
264+
): Result[ChannelMeta, ReliabilityError] =
265+
let m = ChannelMeta.decode(data).valueOr:
266+
return err(ReliabilityError.reDeserializationError)
267+
ok(m)
268+
269+
# ---------------------------------------------------------------------------
270+
# ChannelData (bootstrap payload)
271+
# ---------------------------------------------------------------------------
272+
273+
proc encode*(d: ChannelData): ProtoBuffer =
274+
var pb = initProtoBuffer()
275+
let metaPb = encode(d.meta)
276+
pb.write(1, metaPb.buffer)
277+
for m in d.messageHistory:
278+
let msgPb = wire.encode(m)
279+
pb.write(2, msgPb.buffer)
280+
pb.finish()
281+
pb
282+
283+
proc decode*(T: type ChannelData, buf: seq[byte]): ProtobufResult[T] =
284+
let pb = initProtoBuffer(buf)
285+
var d = ChannelData.init()
286+
var metaBytes: seq[byte]
287+
if not ?pb.getField(1, metaBytes):
288+
return err(ProtobufError.missingRequiredField("ChannelData.meta"))
289+
d.meta = ?ChannelMeta.decode(metaBytes)
290+
var histBufs: seq[seq[byte]]
291+
discard pb.getRepeatedField(2, histBufs)
292+
for b in histBufs:
293+
let m = SdsMessage.decode(b).valueOr:
294+
return err(ProtobufError.missingRequiredField("ChannelData.messageHistory[i]"))
295+
d.messageHistory.add(m)
296+
ok(d)
297+
298+
# ---------------------------------------------------------------------------
299+
# HistoryUpdate
300+
# ---------------------------------------------------------------------------
301+
302+
proc encode*(u: HistoryUpdate): ProtoBuffer =
303+
var pb = initProtoBuffer()
304+
for m in u.append:
305+
let msgPb = wire.encode(m)
306+
pb.write(1, msgPb.buffer)
307+
for id in u.evict:
308+
pb.write(2, id)
309+
pb.finish()
310+
pb
311+
312+
proc decode*(T: type HistoryUpdate, buf: seq[byte]): ProtobufResult[T] =
313+
let pb = initProtoBuffer(buf)
314+
var u = HistoryUpdate.init()
315+
var appBufs: seq[seq[byte]]
316+
discard pb.getRepeatedField(1, appBufs)
317+
for b in appBufs:
318+
let m = SdsMessage.decode(b).valueOr:
319+
return err(ProtobufError.missingRequiredField("HistoryUpdate.append[i]"))
320+
u.append.add(m)
321+
var ev: seq[SdsMessageID]
322+
discard pb.getRepeatedField(2, ev)
323+
u.evict = ev
324+
ok(u)
325+
326+
{.pop.}

0 commit comments

Comments
 (0)