Skip to main content

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}