kevy_rt/commands_trait.rs
1//! The [`Commands`] trait — the seam between the runtime and a command
2//! implementation. Split from `lib.rs` for the 500-LOC house rule.
3
4use crate::{
5 BlockHint, BlockKind, ExtensionReduced, GeoHits, LiveRuntimeConfig, NotifyClass,
6 ReplicaViewRow, ResolvedCmd, Route, Store, TxnKind,
7};
8use kevy_resp::{Argv, ArgvView, RespVersion};
9
10/// Command-set semantics injected into the runtime. Cloned to every core, so it
11/// must be cheap/stateless to clone.
12pub trait Commands: Clone + Send + 'static {
13 /// Classify how a command is routed across shards.
14 fn route<A: ArgvView + ?Sized>(&self, args: &A) -> Route;
15 /// Execute a full command against one shard's store, returning RESP bytes.
16 fn dispatch<A: ArgvView + ?Sized>(&self, store: &mut Store, args: &A) -> Vec<u8>;
17 /// Execute a command, appending the RESP reply to `out`. The in-order local
18 /// fast path uses this to write straight into the connection's output buffer
19 /// (no per-command reply `Vec`). Default: delegate to [`dispatch`](Self::dispatch).
20 fn dispatch_into<A: ArgvView + ?Sized>(&self, store: &mut Store, args: &A, out: &mut Vec<u8>) {
21 out.extend_from_slice(&self.dispatch(store, args));
22 }
23 /// RESP3 variant of [`Self::dispatch_into`] — called when the
24 /// connection has negotiated `HELLO 3`. Default: delegate to the
25 /// RESP2 path (so a server that hasn't migrated any replies still
26 /// works correctly with a RESP3 client, per spec). Override per
27 /// command to emit RESP3 shapes (Map / Set / Double / …).
28 fn dispatch_into_resp3<A: ArgvView + ?Sized>(
29 &self,
30 store: &mut Store,
31 args: &A,
32 out: &mut Vec<u8>,
33 ) {
34 self.dispatch_into(store, args, out);
35 }
36 /// Classify a command for keyspace notifications. Returns `Some`
37 /// for write commands that should fire a notification when the
38 /// corresponding flag is enabled; `None` for read-only / no-op /
39 /// not-yet-classified commands (those never publish). Default
40 /// `None` so non-kevy embedders pay nothing.
41 fn notify_class<A: ArgvView + ?Sized>(&self, _args: &A) -> Option<NotifyClass> {
42 None
43 }
44
45 /// Handle `HELLO` — return the new connection protocol version + the
46 /// reply bytes. The runtime applies the new version to the conn
47 /// before scheduling the reply, so a `HELLO 3` ack itself comes out
48 /// shaped as a RESP3 Map (the new protocol is in effect for its own
49 /// reply).
50 ///
51 /// Default: ignore the args, keep `current_proto`, emit a minimal
52 /// RESP2 +OK so embedders that don't care still see a sane reply.
53 /// kevy's own impl in `kevy::KevyCommands` parses the optional
54 /// protover and emits the full server-info shape.
55 fn hello_reply<A: ArgvView + ?Sized>(
56 &self,
57 _args: &A,
58 current_proto: RespVersion,
59 ) -> (RespVersion, Vec<u8>) {
60 (current_proto, b"+OK\r\n".to_vec())
61 }
62 /// Whether this command should close the connection (QUIT).
63 fn is_quit<A: ArgvView + ?Sized>(&self, args: &A) -> bool;
64 /// Whether this command mutates the keyspace (so it must be logged to the AOF).
65 fn is_write<A: ArgvView + ?Sized>(&self, args: &A) -> bool;
66 /// Transaction-control classification (MULTI/EXEC/DISCARD vs anything else).
67 fn txn_kind<A: ArgvView + ?Sized>(&self, args: &A) -> TxnKind;
68 /// Called once per shard, immediately after [`Store::new`], before the
69 /// reactor enters its event loop. Implementations install per-shard
70 /// configuration that the runtime doesn't know about — currently the
71 /// `maxmemory` + eviction-policy pair, which kevy ships via its own
72 /// process-wide config snapshot. Default: no-op so non-kevy embedders
73 /// aren't forced to override.
74 fn on_shard_init(&self, _store: &mut Store) {}
75
76 /// Called once on the shard's own thread, first thing in the reactor
77 /// entry (both reactors), before restore/replay. Implementations that
78 /// need per-shard identity at dispatch time (e.g. kevy's `CLUSTER MYID`
79 /// / `CLUSTER NODES` `myself` flag) stash `shard` in a thread-local here
80 /// — in a thread-per-core runtime the current thread *is* the shard.
81 /// Default: no-op.
82 fn on_shard_start(&self, _shard: usize) {}
83
84 /// The directory this runtime snapshots to and loads from — the one
85 /// `Runtime::builder().with_data_dir()` set.
86 ///
87 /// A `Commands` implementation carries its own configuration, and
88 /// nothing told it about this. A server built programmatically
89 /// therefore answered `CONFIG GET dir` from that configuration
90 /// while writing somewhere else entirely: one face reporting what
91 /// the other face is not doing. `kevy::serve` builds both from one
92 /// `Config` and never saw the gap, which is why it went unnoticed
93 /// until a test used `CONFIG GET dir` to identify its own server
94 /// and was handed `.`.
95 ///
96 /// Called once per shard, on the shard's thread, beside
97 /// [`Self::on_shard_start`]. Default: no-op, so an implementor that
98 /// has no configuration to correct is unaffected:
99 ///
100 /// ```
101 /// use kevy_rt::{ArgvView, Commands, Route, Store, TxnKind};
102 /// use std::path::Path;
103 ///
104 /// #[derive(Clone)]
105 /// struct Minimal;
106 /// impl Commands for Minimal {
107 /// fn route<A: ArgvView + ?Sized>(&self, _a: &A) -> Route { Route::Local }
108 /// fn dispatch<A: ArgvView + ?Sized>(&self, _s: &mut Store, _a: &A) -> Vec<u8> {
109 /// b"+OK\r\n".to_vec()
110 /// }
111 /// fn is_quit<A: ArgvView + ?Sized>(&self, _a: &A) -> bool { false }
112 /// fn is_write<A: ArgvView + ?Sized>(&self, _a: &A) -> bool { false }
113 /// fn txn_kind<A: ArgvView + ?Sized>(&self, _a: &A) -> TxnKind { TxnKind::Other }
114 /// }
115 ///
116 /// Minimal.on_data_dir(Path::new("/var/lib/kevy"));
117 /// ```
118 ///
119 /// An implementor that answers `CONFIG GET dir` overrides it and
120 /// points that answer here; kevy's own does exactly that.
121 fn on_data_dir(&self, _dir: &std::path::Path) {}
122
123 /// Per-tick persistence-stats publication: whether this shard has a
124 /// background save/rewrite in flight and how many AOF rewrites have
125 /// completed since open. Command layers that serve `INFO persistence`
126 /// stash these in a thread-local (thread-per-core: the answering
127 /// thread *is* the shard, same pattern as [`Self::on_shard_start`]).
128 /// Default: no-op.
129 fn on_persist_stats(&self, _in_flight: bool, _aof_rewrites_total: u64) {}
130
131 /// The shard tick fired `excess_us` microseconds later than its
132 /// interval asked — the reactor's own stall gauge (a long-blocking
133 /// iteration delays the tick by exactly its overrun). Called at
134 /// tick cadence (10 Hz), so implementations may do real work.
135 fn on_tick_gap(&self, _excess_us: u64) {}
136
137 /// A connection was closed because its accumulated unparsed input
138 /// crossed the query-buffer cap.
139 ///
140 /// The enforcement path printed a line and marked the conn closing,
141 /// and there was nothing a test or an operator could ask about it —
142 /// so an intermittent "the server did not close" could not be told
143 /// from "the server decided and the close had not landed yet".
144 /// Those are different defects. Redis exposes the same count as
145 /// `client_query_buffer_limit_disconnections`.
146 ///
147 /// Called on the closing decision, not on the close completing.
148 /// That is the whole point of the distinction: a decision that has
149 /// not reached the client yet is a different thing from a cap that
150 /// was never noticed, and only a counter taken here can tell them
151 /// apart.
152 ///
153 /// The default does nothing, so an existing implementor gains the
154 /// hook without changing:
155 ///
156 /// ```
157 /// use kevy_rt::{ArgvView, Commands, Route, Store, TxnKind};
158 ///
159 /// #[derive(Clone)]
160 /// struct Minimal;
161 /// impl Commands for Minimal {
162 /// fn route<A: ArgvView + ?Sized>(&self, _a: &A) -> Route { Route::Local }
163 /// fn dispatch<A: ArgvView + ?Sized>(&self, _s: &mut Store, _a: &A) -> Vec<u8> {
164 /// b"+OK\r\n".to_vec()
165 /// }
166 /// fn is_quit<A: ArgvView + ?Sized>(&self, _a: &A) -> bool { false }
167 /// fn is_write<A: ArgvView + ?Sized>(&self, _a: &A) -> bool { false }
168 /// fn txn_kind<A: ArgvView + ?Sized>(&self, _a: &A) -> TxnKind { TxnKind::Other }
169 /// }
170 ///
171 /// // The hook is optional; the default is a no-op.
172 /// Minimal.on_query_buffer_exceeded();
173 /// ```
174 ///
175 /// An implementor that wants the number overrides it and counts;
176 /// kevy's own does exactly that, and `INFO stats` reports the total
177 /// as `client_query_buffer_limit_disconnections`.
178 fn on_query_buffer_exceeded(&self) {}
179
180 /// Per-tick AOF on-disk format gauge (the embedder ask's
181 /// server twin): 0 = AOF off, 1 = a pre-4.0 v1 file still being
182 /// appended (a 3.x binary swap-back still works), 2 = v2. Follows
183 /// [`Self::on_persist_stats`]'s shard-gauge pattern.
184 fn on_aof_format(&self, _format: u8) {}
185
186 /// One-shot boot-replay verdict for this shard: bytes dropped past
187 /// the last replayable AOF frame (quarantined + truncated by the
188 /// repair) and whether the stop was a corrupt frame. Fires once,
189 /// after the shard's startup replay, before the listener accepts.
190 /// Non-zero drops mean the shard recovered less than its file held —
191 /// command layers surface it via `INFO persistence` so operators can
192 /// alert on it. Default: no-op.
193 fn on_replay_report(&self, _dropped_bytes: u64, _corrupt: bool) {}
194
195 /// Per-tick live-connection gauge: how many client conns this
196 /// shard currently holds (cluster-bus links excluded). Command
197 /// layers publish it to their cross-shard stats slots so `INFO`
198 /// `connected_clients` sums a real instance-wide value. Default:
199 /// no-op.
200 fn on_conn_gauge(&self, _live: u64) {}
201
202 /// Per-tick replication-view publication: the answering shard's
203 /// current `master_repl_offset` (== `ReplicationSource::next_offset()`)
204 /// plus a [`ReplicaViewRow`] for every handshake-complete replica
205 /// conn (in `AckSent`, `Streaming`, or `SnapshotShipping`); the
206 /// row's `ack` is `None` until the replica's first `REPLCONF ACK`.
207 /// Only called when this shard has a `ReplicationSource`
208 /// installed (i.e. `Runtime::with_replication(true, ...)` was
209 /// requested); standalone setups pay nothing. Command layers
210 /// that serve `ROLE` / `INFO replication` stash the values in a
211 /// thread-local (thread-per-core: the answering thread *is* the
212 /// shard, same pattern as [`Self::on_persist_stats`]) and may
213 /// additionally publish them to a shared slot for cross-shard
214 /// aggregation. Default no-op.
215 fn on_replication_view(&self, _master_repl_offset: u64, _replicas: Vec<ReplicaViewRow>) {}
216
217 /// Periodic shard housekeeping (the equivalent of Redis's `serverCron`).
218 /// kevy uses this to run [`Store::tick_expire`] at the configured
219 /// `[expiry].hz`. Default no-op so non-kevy embedders / runtimes can
220 /// ignore it.
221 fn on_shard_tick(&self, _store: &mut Store) {}
222
223 /// Polled once per shard as it leaves the reactor loop: `true` when
224 /// the operator requested a final snapshot before exit (`SHUTDOWN
225 /// SAVE`). The shard then runs one background save and drains it
226 /// before the process exits. Default `false` — plain stops (SIGTERM,
227 /// bare SHUTDOWN) drain in-flight persistence but don't force a new
228 /// snapshot.
229 fn shutdown_save_requested(&self) -> bool {
230 false
231 }
232
233 /// Per-shard half of an extension fan-out command (IDX.* /
234 /// future VIEW.* / FT.*): compute this shard's raw chunk for
235 /// `argv`. The payload encoding is the embedder's own — the
236 /// runtime treats it as opaque bytes and hands all chunks to
237 /// [`Commands::extension_reduce`] at the origin.
238 fn extension_op(&self, _store: &mut Store, _argv: &[Vec<u8>]) -> Vec<u8> {
239 Vec::new()
240 }
241
242 /// Search half of a geo `*STORE` (`GEOSEARCHSTORE` / `GEORADIUS…STORE`),
243 /// run on the SOURCE key's shard: match `argv`'s query against the source
244 /// zset and return the `(member, score)` pairs to write — the scores
245 /// already in their final form (geohash, or the STOREDIST distance in the
246 /// unit the command asked for). The runtime writes them at the
247 /// destination's own shard; see [`crate::exec_geostore`]. A command set
248 /// that doesn't route [`Route::GeoStore`] never sees this call.
249 fn geo_search(&self, _store: &mut Store, _argv: &[Vec<u8>]) -> GeoHits {
250 GeoHits::Error(b"-ERR unknown command\r\n".to_vec())
251 }
252
253 /// Pre-dispatch write gate. `Some(err_bytes)` rejects every
254 /// data-write client command with that RESP error before any
255 /// routing (replication apply does NOT pass through here, so a
256 /// read-only replica keeps applying its feed). Admin verbs
257 /// (REPLICAOF / CONFIG) are not classified as writes and stay
258 /// available as the operator escape hatch. Default: writes always
259 /// allowed.
260 fn write_denied(&self) -> Option<Vec<u8>> {
261 None
262 }
263
264 /// Read-availability gate: called before READ verbs; return
265 /// `Some(error_bytes)` to refuse the read (a replica whose feed is
266 /// staler than the configured bound answers `-STALE`; one mid-way
267 /// through a full-resync snapshot load answers `-LOADING`).
268 /// `args` lets implementations exempt health-check verbs (PING)
269 /// from the refusal. Default: reads always allowed.
270 fn read_denied<A: ArgvView + ?Sized>(&self, _args: &A) -> Option<Vec<u8>> {
271 None
272 }
273
274 /// Origin-side reduce of an extension fan-out — merge every
275 /// shard's chunk (produced by [`Self::extension_op`]) into either
276 /// the final RESP reply or a follow-up fan-out argv (see
277 /// [`ExtensionReduced`]). `proto` is the requesting connection's
278 /// negotiated RESP version so proto-aware reduces can shape the
279 /// reply (Map vs pair-array).
280 fn extension_reduce(
281 &self,
282 _argv: &[Vec<u8>],
283 _chunks: Vec<Vec<u8>>,
284 _proto: kevy_resp::RespVersion,
285 ) -> ExtensionReduced {
286 ExtensionReduced::Reply(b"-ERR extension commands not supported\r\n".to_vec())
287 }
288
289 /// Called after every applied write with the written key
290 /// (when the resolver knew one). Default no-op; kevy uses it for
291 /// synchronous secondary-index maintenance (derived-by-
292 /// construction). Runs on the shard thread with store access —
293 /// implementations must be cheap when their feature is off.
294 fn on_write(&self, _store: &mut Store, _key: &[u8]) {}
295
296 /// Keyspace-wide invalidation hook: called after FLUSHALL/FLUSHDB
297 /// has emptied this shard's store (both the client path and the
298 /// replica apply path execute the same op). Synchronous index
299 /// maintenance resets its derived structures here — a flushed
300 /// keyspace must not keep answering from stale index entries.
301 fn on_flush(&self, _store: &mut Store) {}
302
303 /// Called once per client command at dispatch entry (before routing /
304 /// fan-out, so a multi-key command counts once). kevy uses it for
305 /// `INFO stats: total_commands_processed`. Hot path — keep it to a single
306 /// thread-local bump. Default no-op so non-kevy embedders pay nothing.
307 fn on_command(&self) {}
308
309 /// Called once per accepted client connection. kevy uses it for
310 /// `INFO stats: total_connections_received`. Default no-op.
311 fn on_connection(&self) {}
312
313 /// Interval between [`Self::on_shard_tick`] calls. Default 100 ms
314 /// (matching Redis's `hz = 10`). `0` disables ticking entirely.
315 fn shard_tick_interval_ms(&self) -> u64 {
316 100
317 }
318
319 /// Snapshot of the runtime-owned knobs that can be hot-modified
320 /// (the kevy server wires this to `CONFIG SET`). Called once per
321 /// shard tick — each `Some` value is applied to the shard's live
322 /// state; each `None` keeps the existing setting untouched.
323 ///
324 /// Default returns all-None so embedders that never hot-swap config
325 /// pay nothing beyond one struct-build per tick. The cost lives in
326 /// the impl's read of its own config source.
327 fn live_runtime_config(&self) -> LiveRuntimeConfig {
328 LiveRuntimeConfig::default()
329 }
330
331 /// Classify a command for blocking semantics. `BlockHint::None`
332 /// (default) is the zero-cost answer for every non-blocking verb;
333 /// the dispatcher only registers a waiter when this returns
334 /// `BlockHint::Block` *and* the command's `dispatch_into` produced no
335 /// reply (i.e. it could not satisfy itself immediately — e.g. BLPOP
336 /// on an empty list). Concrete impls should fold this into their
337 /// override of [`Self::resolve`] so the verb-table lookup happens
338 /// once per command.
339 fn block_hint<A: ArgvView + ?Sized>(&self, _args: &A) -> BlockHint {
340 BlockHint::None
341 }
342
343 /// Rewrite `args` into the owned [`Argv`] that the dispatcher will
344 /// store as the parked waiter's command and replay on wake. Lets a
345 /// command set normalise positional ID / cursor arguments that would
346 /// otherwise re-resolve to a different value on retry — most notably
347 /// `XREAD BLOCK ... STREAMS k $`, where leaving `$` literal in the
348 /// retried argv causes a fresh re-resolve to the post-`XADD` last_id
349 /// and zero matching entries (the wake hangs).
350 ///
351 /// Default: just materialise the argv unchanged. Concrete impls only
352 /// need to override when a registered command carries an arg whose
353 /// meaning depends on store state at park time (`XREAD $`, the
354 /// classic case).
355 ///
356 /// For the cross-shard arbiter this runs on the **target** shard (the
357 /// one that owns the key) when the waiter is armed, so `$` snapshots
358 /// the target's real `last_id` — not the origin shard's (which may not
359 /// hold the stream at all).
360 fn resolve_block_argv<A: ArgvView + ?Sized>(
361 &self,
362 _store: &mut Store,
363 args: &A,
364 _kind: BlockKind,
365 ) -> Argv {
366 args.to_argv()
367 }
368
369 /// Build the **single-key** command the dispatcher will replay to
370 /// satisfy one watched `key` of a (possibly multi-key) blocking
371 /// command. `args` is the original command; `key` is one of its
372 /// watched keys. Returns an [`Argv`] that, when dispatched, pops /
373 /// reads only `key` — e.g. `BLPOP k1 k2 0` watching `k2` yields
374 /// `BLPOP k2 0`; `XREAD … STREAMS s1 s2 id1 id2` watching `s2`
375 /// yields `XREAD … STREAMS s2 id2`.
376 ///
377 /// Any state-dependent positional arg (`$`) is left **literal** here —
378 /// it's frozen later by [`Self::resolve_block_argv`] on the key's
379 /// owning shard. No store access needed (pure argv slicing). Default:
380 /// the unchanged argv (single-key blocking commands need no rewrite).
381 fn block_serve_argv<A: ArgvView + ?Sized>(
382 &self,
383 args: &A,
384 _kind: BlockKind,
385 _key: &[u8],
386 ) -> Argv {
387 args.to_argv()
388 }
389
390 /// The command that would put back whatever replaying `serve_argv` is
391 /// about to consume — read from the store **before** the serve runs.
392 ///
393 /// A cross-shard serve pops on the target and ships the reply to the
394 /// origin. If the origin's client disconnected in that window the
395 /// reply has nowhere to go, and the element would be lost: taken
396 /// from the list, delivered to nobody. The origin cannot put it back
397 /// (it holds a RESP frame whose shape differs per kind *and* per
398 /// negotiated protocol), so the target captures the undo first and
399 /// holds it until the origin confirms delivery.
400 ///
401 /// Read, not parse: the peek runs on the owning shard immediately
402 /// before the pop with nothing interleaved, so what it saw is what
403 /// the pop takes, in RESP2 and RESP3 alike.
404 ///
405 /// `None` = nothing to undo. That is the honest answer for kinds
406 /// that consume nothing (`XREAD` is non-destructive) and the safe
407 /// default for an embedder that has not implemented it.
408 fn block_restore_argv(
409 &self,
410 _store: &mut Store,
411 _kind: BlockKind,
412 _key: &[u8],
413 ) -> Option<Argv> {
414 None
415 }
416
417 /// Non-destructive readiness peek for a parked waiter: would replaying
418 /// `serve_argv` (built by [`Self::block_serve_argv`], `$` already
419 /// frozen) produce a reply right now? Runs on the key's owning shard
420 /// when arming and is the gate for emitting a cross-shard wake. Must
421 /// NOT mutate the store (no pop / no group-cursor advance). Default
422 /// `false` so non-blocking embedders never spuriously wake.
423 fn block_ready<A: ArgvView + ?Sized>(
424 &self,
425 _store: &mut Store,
426 _serve_argv: &A,
427 _kind: BlockKind,
428 ) -> bool {
429 false
430 }
431
432 /// Validate a command being queued inside `MULTI`. Returns an error
433 /// reply (already RESP-encoded, e.g. `-ERR unknown command …`) when
434 /// the command cannot be queued — an unknown verb or an arity
435 /// mismatch — in which case the caller answers with it instead of
436 /// `+QUEUED` and marks the transaction dirty so `EXEC` aborts with
437 /// `-EXECABORT`. `None` means "queue it". Default `None` keeps
438 /// embedders that don't model a verb table permissive.
439 fn queue_error<A: ArgvView + ?Sized>(&self, _args: &A) -> Option<Vec<u8>> {
440 None
441 }
442
443 /// Resolve all verb-dependent attributes in **one** verb-table lookup.
444 /// The default implementation calls the per-attribute methods above
445 /// (five upper_verb scans + matches); concrete impls SHOULD override
446 /// this with a single match so the reactor's hot path pays the verb-
447 /// resolution cost only once per command.
448 fn resolve<A: ArgvView + ?Sized>(&self, args: &A) -> ResolvedCmd {
449 ResolvedCmd {
450 txn_kind: self.txn_kind(args),
451 route: self.route(args),
452 is_quit: self.is_quit(args),
453 is_write: self.is_write(args),
454 block_hint: self.block_hint(args),
455 wake_idx: None,
456 }
457 }
458}