kevy_rt/message.rs
1//! Internal cross-core message and aggregation types.
2//!
3//! These describe the work shipped between shards ([`Op`], [`Part`],
4//! [`Inbound`]) and how a command's (possibly multi-shard) result is
5//! accumulated on its origin shard ([`Agg`], [`PendingSlot`]). All crate-private.
6
7use crate::BlockKind;
8use kevy_resp::{Argv, RespVersion};
9use std::collections::HashMap;
10use std::sync::{Arc, RwLock};
11
12/// A list of key/value pairs (for MSET).
13pub(crate) type KvPairs = Vec<(Vec<u8>, Vec<u8>)>;
14
15/// Shared pub/sub channel registry: `channel → (global subscriber count, bitset
16/// of shard ids that have ≥1 subscriber)`. Written on SUBSCRIBE/UNSUBSCRIBE/conn
17/// close (rare); read on every PUBLISH (hot) so the publisher can reply with the
18/// receiver count **locally** (no cross-shard count aggregation) and fan the
19/// delivery out **only** to shards that hold a subscriber. The bitset is an
20/// over-approximation between a channel's first sub and its count reaching 0
21/// (cleared then) — safe, since a stray delivery just finds no local subscriber.
22pub(crate) type PubSubReg = Arc<RwLock<HashMap<Vec<u8>, (u32, u64)>>>;
23
24/// Shared pub/sub pattern registry: `pattern → (global subscriber count,
25/// bitset of shard ids that have ≥1 subscriber to this pattern)`. Like
26/// [`PubSubReg`] but for `PSUBSCRIBE` patterns. PUBLISH walks this Vec
27/// linearly running [`kevy_store::glob_match`] against each pattern;
28/// matchers contribute to the reply count and the union shard bitset that
29/// receives the publish delivery. A `Vec<(...)>` (not a HashMap) because
30/// the keyspace is patterns, not exact strings — we have to glob_match
31/// every entry no matter how it's stored. The pmessage fan-out plus the
32/// channel-precise path remain disjoint code paths so the channel-only
33/// PUBLISH hot path is undisturbed by the existence of pattern subscribers.
34pub(crate) type PubSubPatternReg = Arc<RwLock<Vec<(Vec<u8>, u32, u64)>>>;
35
36/// One pub/sub message `(channel, payload)`, shared (not cloned) across the
37/// shards it fans out to.
38pub(crate) type PubMsg = Arc<(Vec<u8>, Vec<u8>)>;
39
40/// What to fetch per key in a cross-shard gather.
41#[derive(Clone, Copy)]
42pub(crate) enum GatherKind {
43 /// String value (for MGET).
44 Str,
45 /// Set members (for SINTER/SUNION/SDIFF).
46 Set,
47 /// Scored members: zsets as-is, plain sets at score 1.0 (for the
48 /// zset algebra family — Redis lets sets participate).
49 Scored,
50}
51
52/// A single key's gathered payload.
53pub(crate) enum Gathered {
54 Str(Option<Vec<u8>>),
55 Members(Vec<Vec<u8>>),
56 /// `(member, score)` payload for [`GatherKind::Scored`].
57 Scored(Vec<(Vec<u8>, f64)>),
58 WrongType,
59}
60
61/// The multi-key reductions computed on the originating shard.
62#[derive(Clone, Copy)]
63pub(crate) enum MultiOp {
64 Mget,
65 SInter,
66 SUnion,
67 SDiff,
68 /// `ZINTERCARD numkeys key… [LIMIT n]` — reduce replies `:count`
69 /// (0 = unlimited).
70 ZInterCard(usize),
71}
72
73/// Which algebra combination a `*STORE` orchestrator runs after its
74/// gather completes (v2.2). Public: [`crate::Route::ZAlgebraStore`]
75/// carries it, and embedders' `route()` implementations construct it.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ZCombine {
78 /// `ZINTERSTORE`.
79 ZInter,
80 /// `ZUNIONSTORE`.
81 ZUnion,
82 /// `ZDIFFSTORE`.
83 ZDiff,
84 /// `SINTERSTORE`.
85 SInter,
86 /// `SUNIONSTORE`.
87 SUnion,
88 /// `SDIFFSTORE`.
89 SDiff,
90}
91
92/// Write-side facts the origin's `resolve()` already computed, carried
93/// with a dispatched command so the executing shard never re-parses the
94/// verb. Before this rode along, every forwarded write re-ran THREE
95/// full verb matches (`is_write` + `route` for the WATCH bump +
96/// `wake_idx`) on the owning shard — measurable at -c50 (SET trailed
97/// GET by the cost of those walks).
98#[derive(Clone, Copy)]
99pub(crate) struct DispatchMeta {
100 pub(crate) is_write: bool,
101 /// `Some(i)` = waking writes (LPUSH/RPUSH/XADD): argv[i] is the key
102 /// whose blocked waiters should be woken after the write.
103 pub(crate) wake_idx: Option<u8>,
104 /// `Some(i)` = argv[i] is the routed key (Route::Single) — the WATCH
105 /// version bump target. `None` for keyless `Route::Local` cmds.
106 pub(crate) key_idx: Option<u8>,
107}
108
109/// A unit of work shipped to the owning shard. Forwarded single-key
110/// commands don't ride here — they go through the batched
111/// [`Inbound::RequestBatch`] lane (one `(conn, seq, Argv, RespVersion,
112/// DispatchMeta)` entry each) and execute via `Shard::run_dispatch`.
113pub(crate) enum Op {
114 Del(Vec<Vec<u8>>),
115 Exists(Vec<Vec<u8>>),
116 Dbsize,
117 Flush,
118 Save,
119 /// Background snapshot: freeze a COW view now, persist off-thread.
120 BgSave,
121 /// Rebuild the AOF from this shard's in-memory state (BGREWRITEAOF),
122 /// serialized off-thread from a COW view.
123 RewriteAof,
124 /// Set these key/value pairs (MSET).
125 MSet(KvPairs),
126 /// Fetch per-key payloads (MGET / set algebra).
127 Gather(GatherKind, Vec<Vec<u8>>),
128 /// v2.2 step-2 of the zset-algebra orchestrator: materialize the
129 /// combined result at `dst` on its owning shard (overwrite; empty
130 /// deletes). Replies `Part::Int(cardinality)`.
131 ZStoreResult { dst: Vec<u8>, pairs: Vec<(Vec<u8>, f64)> },
132 /// Set-form step-2 (`SINTERSTORE` family).
133 SetStoreResult { dst: Vec<u8>, members: Vec<Vec<u8>> },
134 /// v2.3 FEED.READ executed on the target shard.
135 FeedRead {
136 cursor_gen: u64,
137 offset: u64,
138 count: usize,
139 prefixes: Vec<Vec<u8>>,
140 },
141 /// v2.3 FEED.TAIL executed on the target shard.
142 FeedTail,
143 /// v2.5 extension fan-out: run `Commands::extension_op` on this
144 /// shard with the original argv; reply is an opaque chunk.
145 Extension { argv: Vec<Vec<u8>> },
146 /// v2.3 `PREFIX.STATS <prefix>` — per-shard prefix walk, summed at
147 /// the origin.
148 PrefixStats(Vec<u8>),
149 /// Collect this shard's keys (optional glob + limit) — KEYS/SCAN/RANDOMKEY.
150 CollectKeys(Option<Vec<u8>>, Option<usize>),
151 /// `WATCH key [key ...]` — register each key in this shard's
152 /// version tracker and report its current version back. The origin
153 /// shard collates the (key, version) pairs into the conn's
154 /// `watched` set; `EXEC` later asks every owning shard whether
155 /// the version is still current via [`Op::CheckWatch`].
156 CollectWatchVersions(Vec<Vec<u8>>),
157 /// `EXEC`'s pre-execution fan-out: for each `(key, version)` pair,
158 /// compare against this shard's current `key_version(key)`. The
159 /// reply ([`Part::Int`]) is `1` if ANY key on this shard has been
160 /// modified since the recorded version, else `0`. The origin shard
161 /// ORs the partial replies and aborts EXEC on any `1`.
162 CheckWatch(Vec<(Vec<u8>, u64)>),
163 /// `RENAME` / `RENAMENX` — both keys on the same shard. Atomic on
164 /// that shard via [`kevy_store::Store::rename`]. Reply: `Part::Reply`
165 /// carrying `+OK\r\n` (RENAME ok), `:1\r\n` / `:0\r\n` (RENAMENX
166 /// ok / dst-exists), or `-ERR no such key\r\n`.
167 Rename {
168 src: Vec<u8>,
169 dst: Vec<u8>,
170 /// `true` for `RENAMENX` semantics (no overwrite — reply `:0`
171 /// if dst exists; reply `:1` on successful rename).
172 nx: bool,
173 },
174 /// Cross-shard RENAME step 1: atomically take `src` (entry + TTL)
175 /// off this shard. Reply `Part::RenameTaken` on success or
176 /// `Part::RenameNoSuchSrc` if the key doesn't exist. The
177 /// orchestrator on the origin shard chains the value into a
178 /// follow-up [`Op::RenamePut`] on the destination shard.
179 RenameTake(Vec<u8>),
180 /// Cross-shard RENAME step 2: store the just-taken value at `dst`
181 /// on this shard. If `nx` is set and dst already exists, the put
182 /// is refused — orchestrator must rollback (restore src) or accept
183 /// loss. Reply: `Part::RenamePutDone { stored: bool }`.
184 RenamePut {
185 dst: Vec<u8>,
186 value: kevy_store::Value,
187 ttl_ms: Option<u64>,
188 nx: bool,
189 },
190 /// `SLOWLOG GET` — collect this shard's ring buffer. Reply
191 /// [`Part::SlowlogEntries`] with a clone of the deque (origin
192 /// sorts + truncates after merging across shards).
193 SlowlogGet,
194 /// `SLOWLOG LEN` — this shard's ring length. Reply [`Part::Int`].
195 SlowlogLen,
196 /// `SLOWLOG RESET` — clear this shard's ring. Reply [`Part::Ok`].
197 SlowlogReset,
198 /// One stream of a multi-stream non-blocking `XREAD` / `XREADGROUP`
199 /// whose streams span shards. `argv` is a complete single-stream
200 /// rewrite (`XREAD [COUNT n] STREAMS key id` or `XREADGROUP GROUP g c
201 /// [COUNT n] [NOACK] STREAMS key id`) dispatched on the stream's owning
202 /// shard (so `$` resolves to that shard's `last_id`); `index` is the
203 /// stream's position in the original request, used to reassemble the
204 /// reply in request order. `write` marks the XREADGROUP form — it
205 /// mutates group state (PEL / last-delivered), so the owning shard runs
206 /// the post-write housekeeping (AOF log of the rewritten argv, WATCH
207 /// bump, keyspace notify) after dispatch. Reply: [`Part::XReadElement`].
208 XReadOne { index: u32, argv: Argv, write: bool },
209}
210
211/// How a KEYS-family reply is shaped.
212#[derive(Clone, Copy)]
213pub(crate) enum KeyShape {
214 /// `KEYS` — a flat array of keys.
215 Keys,
216 /// `SCAN` — `[cursor, [keys]]` (cursor always "0").
217 Scan,
218 /// `RANDOMKEY` — one key as a bulk string, or nil.
219 Random,
220}
221
222/// A RESP reply fragment with a 30-byte inline arm. The forwarded-dispatch
223/// hot path produces tiny replies (`+OK`, `:N`, a `$16` GET payload = 23 B)
224/// whose heap `Vec` round-trip (alloc on the owning shard, free after the
225/// origin's drain) dominated the data itself — ~19 % of 8-shard SET CPU sat
226/// in the allocator. `Inline` keeps those entirely on the stack across the
227/// ring; `Heap` carries anything bigger with the old one-alloc semantics.
228pub(crate) enum SmallReply {
229 Inline { len: u8, buf: [u8; 30] },
230 Heap(Vec<u8>),
231}
232
233impl SmallReply {
234 /// Copy `b` into the inline arm when it fits, else one heap alloc.
235 #[inline]
236 pub(crate) fn from_slice(b: &[u8]) -> Self {
237 if b.len() <= 30 {
238 let mut buf = [0u8; 30];
239 buf[..b.len()].copy_from_slice(b);
240 SmallReply::Inline { len: b.len() as u8, buf }
241 } else {
242 SmallReply::Heap(b.to_vec())
243 }
244 }
245
246 /// Wrap an already-owned `Vec` — zero-copy for the heap arm.
247 #[inline]
248 pub(crate) fn from_vec(v: Vec<u8>) -> Self {
249 SmallReply::Heap(v)
250 }
251
252 #[inline]
253 pub(crate) fn as_slice(&self) -> &[u8] {
254 match self {
255 SmallReply::Inline { len, buf } => &buf[..*len as usize],
256 SmallReply::Heap(v) => v,
257 }
258 }
259}
260
261/// A partial result shipped back to the originating shard.
262pub(crate) enum Part {
263 /// v2.3 PREFIX.STATS per-shard result.
264 PrefixStats { keys: u64, expires: u64 },
265 /// v2.5 extension fan-out per-shard chunk (opaque to the runtime).
266 ExtensionChunk(Vec<u8>),
267 Reply(SmallReply),
268 Int(i64),
269 Ok,
270 /// Per-key gathered payloads.
271 Gathered(Vec<(Vec<u8>, Gathered)>),
272 /// A shard's collected keys (KEYS/SCAN/RANDOMKEY).
273 Keys(Vec<Vec<u8>>),
274 /// `WATCH` partial reply: each key this shard owns paired with its
275 /// current version, in request order. The origin shard collates
276 /// these into the conn's watched set.
277 WatchVersions(Vec<(Vec<u8>, u64)>),
278 /// Cross-shard RENAME step 1 success: src removed; here's the
279 /// value + TTL for the orchestrator to ship into step 2.
280 RenameTaken {
281 value: kevy_store::Value,
282 ttl_ms: Option<u64>,
283 },
284 /// Cross-shard RENAME step 1 miss: src didn't exist.
285 RenameNoSuchSrc,
286 /// Cross-shard RENAME step 2 result. `refused` is `None` when the put
287 /// landed at dst; `Some((value, ttl))` when `RENAMENX` blocked because
288 /// dst already had an entry — the source value (taken in step 1) is
289 /// handed back so the orchestrator can put it back on its shard (no
290 /// data loss) before replying `:0`.
291 RenamePutDone {
292 refused: Option<(kevy_store::Value, Option<u64>)>,
293 },
294 /// `SLOWLOG GET` partial: this shard's ring buffer contents (in
295 /// FIFO order — oldest first). Origin sorts by timestamp DESC and
296 /// truncates per the `Get(count)` request.
297 SlowlogEntries(Vec<crate::exec_slowlog::SlowlogEntry>),
298 /// One stream's result for a cross-shard `XREAD` gather (see
299 /// [`Op::XReadOne`]). `element` is the encoded `*2 <key> <entries>`
300 /// reply element (the `*1\r\n` wrapper already stripped) when the
301 /// stream had data, or `None` when empty. `index` preserves request
302 /// order; an error reply is carried verbatim in `element` and detected
303 /// by the leading `-`.
304 XReadElement { index: u32, element: Option<Vec<u8>> },
305}
306
307/// A batch of single-key dispatches forwarded to one owning shard:
308/// `(conn, seq, argv, proto)` each. Batched per loop so a -c50 flood
309/// costs one cross-core send per target shard, not one per command.
310/// The per-entry `proto` lets a single batch carry cmds from V2 and V3
311/// conns to the same owning shard.
312pub(crate) type ReqBatch = Vec<(u64, u64, Argv, RespVersion, DispatchMeta)>;
313/// The matching replies `(conn, seq, part)` sent back as one message.
314/// Each reply carries the request's spent `Argv` husk back to the origin,
315/// which drops it into its own [`kevy_resp::ArgvPool`] — so every shard's
316/// pool level matches its own conn demand by construction, immune to
317/// accept skew (a conn-heavy shard forwards more than it receives, so
318/// recycle-at-the-owner starves its pool while overfilling quiet shards').
319pub(crate) type RespBatch = Vec<(u64, u64, Part, Argv)>;
320
321/// Inter-core message (each core has one inbound queue carrying both).
322pub(crate) enum Inbound {
323 Request {
324 origin: usize,
325 conn: u64,
326 seq: u64,
327 op: Op,
328 },
329 Response {
330 conn: u64,
331 seq: u64,
332 part: Part,
333 },
334 /// Batched single-key dispatches to this (owning) shard; replied as one
335 /// `ResponseBatch`. The hot -c50 path: amortizes the cross-core ring/fold
336 /// overhead that drags 16 shards below 1 (single-shard is 2.1M GET).
337 RequestBatch {
338 origin: usize,
339 reqs: ReqBatch,
340 },
341 /// Batched replies for a `RequestBatch`, folded by seq on the origin.
342 ResponseBatch(RespBatch),
343 /// A batch of pub/sub messages `(channel, payload)` to deliver to this
344 /// shard's subscribers — fire-and-forget (no reply; the publisher already
345 /// replied with the receiver count from the registry). Batched per drain so
346 /// a flood of PUBLISHes costs one cross-core send per target shard, not one
347 /// per message. `Arc` so the same payload fanned to many shards is shared,
348 /// not cloned per target.
349 DeliverPublish(Vec<PubMsg>),
350
351 // ── Cross-shard BLOCK arbiter (see [`crate::block_xshard`]) ──
352 // A conn parks on its origin shard; watch registrations fan out to the
353 // shards owning each watched key. The origin is the single arbiter that
354 // decides which ready key serves the conn, so no target ever pops
355 // speculatively (which would lose data when two keys go ready at once).
356 /// origin → target: "watch `key` for `(origin, conn)`; if a replay of
357 /// `serve_argv` would yield data now, send back [`Inbound::BlockReady`]".
358 /// Re-sent verbatim to re-arm after a raced-empty serve (idempotent —
359 /// the target dedups by `(origin, conn, key)`).
360 BlockArm {
361 origin: usize,
362 conn: u64,
363 key: Vec<u8>,
364 kind: BlockKind,
365 serve_argv: Argv,
366 /// The origin conn's RESP version, so the target shapes the served
367 /// reply (V2 array / V3 map) correctly without a round-trip.
368 proto: RespVersion,
369 },
370 /// target → origin: a watched `key` may now satisfy `conn`. The origin
371 /// arbitrates (ignores if `conn` already served / serving).
372 BlockReady { conn: u64, key: Vec<u8> },
373 /// origin → target: "serve `key` for `(origin, conn)` now" — the target
374 /// replays the armed `serve_argv` (popping / consuming) and returns the
375 /// reply via [`Inbound::BlockServeResp`].
376 BlockServeReq {
377 origin: usize,
378 conn: u64,
379 key: Vec<u8>,
380 },
381 /// target → origin: the serve result. Empty `reply` = raced (another
382 /// client drained the key between ready and serve) → the origin re-arms.
383 BlockServeResp {
384 conn: u64,
385 key: Vec<u8>,
386 reply: Vec<u8>,
387 },
388 /// origin → target: drop every waiter for `(origin, conn)` — sent on
389 /// successful serve, timeout, or disconnect.
390 BlockCancel { origin: usize, conn: u64 },
391}
392
393/// Accumulator for a command's (possibly multi-shard) result.
394pub(crate) enum Agg {
395 First(Option<SmallReply>),
396 SumInt(i64),
397 AllOk,
398 /// Gathered per-key payloads, reduced by `op` over `keys` (request order).
399 Gather {
400 op: MultiOp,
401 keys: Vec<Vec<u8>>,
402 got: HashMap<Vec<u8>, Gathered>,
403 },
404 /// v2.3 PREFIX.STATS accumulator (summed across shards).
405 PrefixStats { keys: u64, expires: u64 },
406 /// v2.5 extension fan-out accumulator; reduced by
407 /// `Commands::extension_reduce` when the last chunk lands.
408 ExtensionGather { argv: Vec<Vec<u8>>, chunks: Vec<Vec<u8>> },
409 /// v2.2 zset-algebra `*STORE` orchestrator, step 1: gather scored
410 /// (or set) members per source key; on completion the origin
411 /// computes the combination and ships `Op::ZStoreResult` /
412 /// `Op::SetStoreResult` to `dst`'s shard (step 2 folds through a
413 /// re-armed `Agg::SumInt`).
414 ZStoreGather {
415 combine: ZCombine,
416 weights: Option<Vec<f64>>,
417 aggregate: kevy_store::ZAggregate,
418 dst: Vec<u8>,
419 keys: Vec<Vec<u8>>,
420 got: HashMap<Vec<u8>, Gathered>,
421 },
422 /// Keys collected from all shards, shaped per `KeyShape`.
423 Keys {
424 shape: KeyShape,
425 acc: Vec<Vec<u8>>,
426 },
427 /// `WATCH` fan-out accumulator: each owning shard returns its
428 /// `(key, version)` pairs via [`Part::WatchVersions`]; the origin
429 /// shard appends them all and, when the last fan-out reply arrives,
430 /// moves the pairs into the connection's `watched` set + emits +OK.
431 WatchCollect {
432 pairs: Vec<(Vec<u8>, u64)>,
433 },
434 /// Cross-shard non-blocking `XREAD` gather: each watched stream's
435 /// owning shard returns its [`Part::XReadElement`], dropped into
436 /// `slots` by request index. Materialized in request order, empty
437 /// streams skipped (`*-1` if all empty), matching single-shard XREAD.
438 XReadGather {
439 slots: Vec<Option<Vec<u8>>>,
440 },
441 /// `EXEC` pre-execution accumulator: a non-empty WATCH set fans
442 /// `CheckWatch` out to every shard that owns a watched key. Each
443 /// reply ORs into `dirty`. When the last reply arrives, the origin
444 /// shard either aborts (dirty → header = `*-1\r\n`, every queued
445 /// placeholder slot emits 0 bytes) or commits (clean → header =
446 /// `*N\r\n`, then dispatches each `queued` cmd at its pre-allocated
447 /// seq via `start_command_at_seq`).
448 ExecPrep {
449 dirty: bool,
450 queued: Vec<Argv>,
451 header_seq: u64,
452 },
453 /// `SLOWLOG GET` accumulator. Each shard pushes its `Vec<SlowlogEntry>`
454 /// via [`Part::SlowlogEntries`]; once all replies land, materialize
455 /// sorts by timestamp DESC and truncates to `count`. `count = None`
456 /// means "default 10 (Redis default)"; `count = Some(n)` where `n < 0`
457 /// means "all entries".
458 SlowlogGet {
459 count: Option<i64>,
460 entries: Vec<crate::exec_slowlog::SlowlogEntry>,
461 },
462 /// Cross-shard RENAME / RENAMENX orchestrator. Two-step protocol:
463 /// step 1 emits `Op::RenameTake` to src_shard → fold receives
464 /// `Part::RenameTaken` (or `RenameNoSuchSrc`); step 2 emits
465 /// `Op::RenamePut` to dst_shard → fold receives `Part::RenamePutDone`.
466 /// On step transitions, `finalize_watch_agg`'s sibling
467 /// `finalize_rename_agg` re-arms `slot.remaining = 1` and ships
468 /// the next Op.
469 RenameOrchestrator {
470 /// Which step we're in (Take then Put). The taken value lives
471 /// in `taken` once step 1 lands.
472 step: RenameStep,
473 /// `true` for `RENAMENX` — modifies step 2's reply shape (`:1`
474 /// vs `+OK`) + would gate dst-overwrite (but the pre-check is
475 /// in the Put-side response since cross-shard race is
476 /// unavoidable without 2-phase commit; see comment in
477 /// `exec_rename::finalize_rename_agg`).
478 nx: bool,
479 src: Vec<u8>,
480 dst: Vec<u8>,
481 dst_shard: usize,
482 /// Value+TTL captured from step 1; populated when step
483 /// transitions to Put.
484 taken: Option<(kevy_store::Value, Option<u64>)>,
485 /// Step 2's result, populated by fold when
486 /// `Part::RenamePutDone` lands. `Some(true)` = stored,
487 /// `Some(false)` = NX-blocked, `None` = step 2 hasn't run yet
488 /// (we're still in Take phase).
489 put_stored: Option<bool>,
490 },
491}
492
493/// Phase of the cross-shard RENAME orchestrator. See [`Agg::RenameOrchestrator`].
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub(crate) enum RenameStep {
496 Take,
497 Put,
498 /// `RENAMENX` only: the Put was NX-refused (dst already existed), so
499 /// the source taken in step 1 is being put back on its shard before
500 /// the `:0` reply — a no-op `RENAMENX` must not lose the source.
501 Restore,
502}
503
504/// One outstanding command slot awaiting `remaining` sub-results, held in a
505/// per-connection seq-ordered ring.
506pub(crate) struct PendingSlot {
507 pub(crate) remaining: u32,
508 pub(crate) agg: Agg,
509 /// Materialized reply once `remaining == 0`; emitted in seq order.
510 /// `SmallReply` so the forwarded tiny-reply path (+OK / :N / small
511 /// GET) stays heap-free end to end.
512 pub(crate) done: Option<SmallReply>,
513 /// RESP version captured at dispatch time. Cross-shard gathers
514 /// (SINTER / SUNION / SDIFF) materialise on the origin shard long
515 /// after `start_multi` snapped this conn's proto; storing it here
516 /// (vs. re-reading `conn.proto` at fold time) keeps each in-flight
517 /// cmd shaped per the proto it was dispatched under — a HELLO 3
518 /// after `start_multi` doesn't retroactively reshape its reply.
519 /// 1 byte + alignment padding; not on any hot path.
520 pub(crate) proto: RespVersion,
521}