rustis 0.24.0

Redis async driver for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
# Changelog

All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

Versions up to and including `0.19.3` are documented in the
[GitHub releases](https://github.com/dahomey-technologies/rustis/releases).

## [0.24.0] - 2026-08-12

### BREAKING CHANGES

The upgrade checklist. Each item is stated fully, with the reason it moved, in the
section it belongs to below.

- **A text reply that is not entirely an integer is now an error.** Code reading a
  field into an integer target got the numeric prefix of whatever the field held
  -- `1.75` as `1`, `12abc` as `12` -- and now gets `CannotParseInteger`. Nothing
  changes for a value that is an integer; what changes is that a value that is not
  one stops being silently narrowed into one.

### Added

- **`i128` and `u128` serialize as command arguments.** A struct field of either
  type could be read out of a reply but not written into a command: the argument
  serializer had no 128-bit arm, so serde fell back to its default, which fails.
  `hset` on a struct holding a `u128` returned an error naming a type the
  deserializer accepts, and the round trip a caller expects to be symmetric was
  not. All three writers -- the serializer, the argument counter, and the
  fast-path builder -- now format them through `itoa` like every other width.

### Fixed

- **A text reply read as an integer is read whole or rejected.** The wire
  deserializer parsed integers with `atoi::atoi`, which stops at the first byte
  that is not a digit and returns what it read: `HGET` on a field holding `1.75`
  answered `1` for a `u32` target, `12abc` answered `12`, and `0x10` answered `0`
  -- a value the server never sent, indistinguishable from one it did. The crate
  rejects exactly this elsewhere, deliberately: `double_to_int` refuses to
  truncate a RESP3 double, and the `ValueDeserializer` parses through
  `str::parse`, so the two deserializers disagreed on every such reply and the
  path a caller took decided the answer. Integers now come from `int_from_text`,
  which requires every byte to be consumed and the last one to be a digit, so a
  leading remainder (` 12`), a trailing one (`12abc`) and a lone sign (`-`) are
  all `CannotParseInteger`. An explicit `+` stays accepted, as RESP3 allows on an
  integer reply, and overflow was already rejected. The same rule now covers the
  digits behind a `:`, where a malformed integer frame was read as its prefix too.

- **The test suite builds on Windows.** `keep_alive_and_no_delay_are_applied`
  read the keep-alive time back with `socket2::SockRef::tcp_keepalive_time`,
  which Windows has no equivalent for and socket2 therefore compiles only on the
  platforms that expose a getter. The whole `lib test` target failed to build
  with `E0599`, so no test ran at all -- `cargo test` on Windows was unusable,
  and every job in CI runs on `ubuntu-latest`, which is why nothing reported it.
  The assertion is now guarded by `#[cfg(not(windows))]`; the value is still set
  on Windows, and still asserted everywhere it can be read.

### Added

- **A `struct` maps onto a hash, and the tests say so.** `hset` takes any
  `Serialize` and `hgetall` returns any `Deserialize`, so a struct round-trips
  through a hash in two calls -- the argument serializer flattens it into
  field/value pairs, taking the field names from the struct's own, and the
  deserializer reads the reply back as a map. Nothing in the suite covered that
  path, which is the reason to write a hash from a struct at all, and nothing
  covered the details a caller trips on either: `rename`/`rename_all` decide the
  field names on the wire, an unknown field in the hash is skipped rather than
  fatal, a nested struct needs `#[serde(flatten)]` or a field of its own, and an
  `Option` field must carry `skip_serializing_if` -- a `None` serializes to no
  argument at all, leaving its field name paired with the next field's value.
  `hset_hgetall_struct_of_primitives` pins the wire text a hash actually holds
  (`1` for a `bool`, `1.75` for an `f32`), since another client reads that text,
  and `a_bulk_string_reads_into_every_primitive` pins the other direction: every
  integer width, `f32`/`f64` including `inf` and exponent notation, `bool`,
  `char`, `String`, `Option`, plus the eleven values that are rejected -- out of
  range, empty, or not that type -- because a hash field is a bulk string
  whatever it holds, and that single wire form has to reach every target.

## [0.23.0] - 2026-08-07

### BREAKING CHANGES

The upgrade checklist. Each item is stated fully, with the reason it moved, in the
section it belongs to below.

- **Blocking commands and `WATCH` are implemented for `ExclusiveClient` alone.**
  `Client::connect(cfg).await?` becomes `ExclusiveClient::connect(cfg).await?`, or
  `Client::connect(cfg).await?.into_exclusive()?`, wherever a blocking command or
  `watch`/`unwatch` is called. Nothing else moves.

- **`Error` is a struct rather than an enum.** Its variants live in `ErrorKind`, so
  a `match` on `Error::Timeout` becomes one on `e.kind()` -- or on `e.into_kind()`
  to match by value -- against `ErrorKind::Timeout`.

- **`PubSubMessage` is read through accessors.** `message.channel` becomes
  `message.channel()` and `String::from_utf8(message.payload)` becomes
  `std::str::from_utf8(message.payload())`. The public fields and the `Deserialize`
  impl are gone.

- **`rustis::Future<'_, T>` is now `rustis::client::CommandFuture<'_, T>`.** Only
  code naming the type is affected; awaiting a command is unchanged.

- **An empty collection reply decodes as `Some` of an empty collection.** Code using
  `Option<Vec<T>>` over an `LRANGE`, `SMEMBERS` or `ZRANGE` as an emptiness test must
  switch to `Vec<T>` and `.is_empty()`. Only a nil is `None` now.

- **`ts_get` returns `TsGetResult` instead of `Option<(u64, f64)>`.** It derefs to
  that option, so `*sample` keeps the existing patterns compiling; `.into()` converts
  it.

- **A textual reply read as a `bool` is an error outside `OK`/`1`/`true`/`0`/`false`**
  where it used to be `false`, and a bulk string `OK` is now `true`.

- **A one-element array read as an integer from a `resp::Value` now requires that
  element to be an integer**, as reading it off the wire already did.

### Added

- **The transport is open: Unix sockets, and a caller-supplied stream.**
  `ServerConfig::UnixSocket { path }` reaches a server listening on a Unix domain
  socket, spelled `unix:///var/run/redis.sock` in a URI — the socket path being the
  whole URI path, the database is a `db` query parameter there rather than the last
  path segment, and `keep_alive` / `no_delay`, which describe a TCP socket, are not
  applied. `ServerConfig::Custom` takes a `TransportFactory`, which hands the client
  any `AsyncRead` + `AsyncWrite` pair to speak RESP over: a `tokio::io::duplex` pipe
  driven by a server of your own, a tunnel, a TLS stack configured elsewhere. The
  trait is implemented for any closure returning a future, and is asked for a stream
  at every dial rather than handed one once, so a reconnection gets a fresh stream —
  the same reason `CredentialsProvider` is consulted at every handshake. Neither has
  a `Debug` or `Display` that reveals anything about the factory behind it.

- **`Error` classifies itself.** `is_connection_error()`, `is_timeout()`,
  `is_server_error()` and `is_retryable()` answer the questions every caller asks
  of a failure: whose fault is it, and is it worth trying again. `ErrorKind` and
  `ClientError` are `#[non_exhaustive]`, so this classification could not be
  written outside the crate — a downstream `match` must end in a catch-all arm and
  therefore silently misclassifies every variant added afterwards.
  `is_connection_error()` covers the RESP framing failures too, since a stream the
  parser lost track of costs the connection. `is_retryable()` reports a transient
  failure, not a command that certainly did not run: its documentation says so,
  because a timeout or a lost connection can follow a write the server applied.

### Changed

- **The connection-holding commands live on their own client type.** A `Client` is
  clonable, and every clone multiplexes over one connection; blocking commands and
  `WATCH` are incompatible with that — the first holds the connection until it
  returns, the second attaches state to the connection rather than to the handle
  that asked for it. Both were nonetheless implemented on every `Client`, so cloning
  is what turned a legal program illegal, and the failure was a stalled shared
  connection at run time. `BlockingCommands` and `TransactionCommands` are now
  implemented for `ExclusiveClient` alone — a client that is **not** `Clone` — so the
  mistake is a compile error. `ExclusiveClient::connect` opens a connection of its
  own, `Client::into_exclusive` converts an existing handle and returns
  `ClientError::NotExclusive` when another handle on the connection is alive (streams
  and transactions opened from the client hold one too), and
  `ExclusiveClient::into_multiplexed` goes back. It carries every other command
  family, and `PooledClientManager::Connection` is an `ExclusiveClient`: a borrowed
  connection is exclusive until it is given back, so the two families are legitimate
  there. **Migration**: `Client::connect(cfg).await?` becomes
  `ExclusiveClient::connect(cfg).await?`, or
  `Client::connect(cfg).await?.into_exclusive()?`, wherever a blocking command or
  `watch`/`unwatch` is called. Nothing else moves — `MULTI`/`EXEC` through
  `Client::create_transaction`, pub/sub, and the ~600 other commands are unchanged on
  a multiplexed client.

- **A pub/sub message is one block, read through accessors.** `PubSubMessage` held
  three public `Vec<u8>` fields, allocating two per delivered message (three for a
  `pmessage`). Its segments now share one exactly-sized block read through
  `pattern()`, `channel()` and `payload()`, built from the push frame without serde;
  the fields and the `Deserialize` impl are gone. They stay owned rather than
  borrowed: the read buffer is a 64 KiB block the network task recycles, which a
  retained view would pin. `ClientError::UnexpectedPubSubMessage` replaces the serde
  error for a push that is not a `message`, `smessage` or `pmessage`. **Migration**:
  `message.channel` becomes `message.channel()`;
  `String::from_utf8(message.payload)` becomes
  `std::str::from_utf8(message.payload())`.

  Worth less than it looks: `benches/pub_sub_decode` puts delivery at ~220–340 ns per
  message, dominated by the parse, so this buys 2–5 % up to 512-byte payloads and
  nothing measurable at 4 KiB. A 64-byte inline buffer removing *every* allocation
  was measured at 8–12 % **slower** and rejected.

- **An awaited command no longer allocates.** `client.get("key").await` — the form
  every example, the README and all built-in command methods use — went through
  `Box::pin(async move { … })`, so the documented path cost one heap allocation plus
  one virtual call per command while the generic `client.send(…)` cost neither, in a
  crate whose first philosophy point is low allocations. `IntoFuture for
  PreparedCommand<'_, &Client, R>` now resolves to `client::CommandFuture`, a
  hand-written state machine that lives in the caller's frame; building the future
  and dropping it still sends nothing, and `command_timeout` still applies. Awaiting
  is unchanged, but naming the old type is not: an `IntoFuture` associated type
  spelled `rustis::Future<'_, T>` becomes `rustis::client::CommandFuture<'_, T>`.
  Construction measures ~69 ns against ~80 ns boxed, with `client.send(…)` unmoved
  at ~55 ns in the same rounds (`benches/into_future.rs`).

- **Errors name the command they belong to.** `Error` is now a struct rather than an
  enum: its variants moved to `ErrorKind`, reachable through `Error::kind()` and
  `Error::into_kind()`, and it carries the command alongside them, reachable through
  `Error::command()`. A client multiplexes hundreds of commands over one connection,
  so `Err(Error::Timeout)` named nothing the application could act on, a shed command
  did not say what had been shed, and a cross-slot refusal did not say which command
  was refused. The command is attached wherever the client fails a command on its
  behalf — a `command_timeout`, a full send queue, a lost connection, a deferred
  serialization error, a mismatched-slot routing refusal — and is absent for the
  errors raised outside any command, a connection timeout in particular. `Display`
  appends it: `The I/O operation's timeout expired (while executing BLMPOP)`. Calling
  code matching on `Error::Timeout` matches on `e.kind()` against
  `ErrorKind::Timeout`, or on `e.into_kind()` to match by value.
- **An empty collection no longer decodes as `None`.** `Option<T>` treated an empty
  RESP array as a nil, so `Option<Vec<T>>` over an `LRANGE`, `SMEMBERS` or `ZRANGE`
  could never observe an empty vector: "the collection is empty" and "the key does
  not exist" collapsed into the same `None`. Only a nil — `*-1` in RESP2, `_` in
  RESP3 — is `None` now; an empty array, map or set yields `Some` of an empty
  collection. Blocking commands are unaffected: `BLPOP`, `BRPOP`, `BLMPOP`,
  `BZMPOP`, `BZPOPMIN`/`BZPOPMAX` and `ZMPOP` reply nil on timeout, not an empty
  array. Calling code that used `Option<Vec<T>>` as an emptiness test must switch
  to `Vec<T>` and `.is_empty()`.
- **`ts_get` returns `TsGetResult` instead of `Option<(u64, f64)>`.** The time
  series module reports an empty series as an empty array rather than a nil, so
  that command was the one relying on the conflation above. `TsGetResult` reads
  that shape itself and derefs to `Option<(u64, f64)>`, so `assert_eq!(None,
  *sample)` and `if let Some((ts, value)) = *sample` keep working; `.into()`
  converts it to the plain option.

### Fixed

- **Dropping a `PubSubStream` releases its subscriptions.** `Drop` named the
  channels it was cancelling as a bare `&[u8]`, which serde renders as a sequence
  of integers rather than as one bulk string: the client asked the server to
  unsubscribe from `49 49` -- the ASCII codes of the channel `11` -- so it left the
  real channel subscribed, and the wrong command being legal in itself, the server
  answered it without complaining. The fire-and-forget failure was assigned to
  `_result` and never logged, so nothing surfaced either. `close()` was unaffected,
  passing the `Bytes` itself, which is why the documented equivalence between the
  two -- "`drop` will achieve the same process but silently in background" -- did
  not hold. Every later `subscribe` on that channel was then refused with
  `AlreadySubscribed` for the life of the connection, which a long-polling handler
  reaches on every cancelled HTTP request: its stream is dropped, never closed. The
  names now go through `RefBulkString`, the error is logged, and
  `dropping_a_stream_releases_its_subscriptions` covers the drop-then-resubscribe
  cycle the existing `AlreadySubscribed` test did not.

- **A cluster subscription is cancellable.** `SUBSCRIBE`, `PSUBSCRIBE`,
  `UNSUBSCRIBE` and `PUNSUBSCRIBE` name no key, so the cluster connection served
  each of them on a node drawn at random: the unsubscription almost never reached
  the node holding the subscription, which kept publishing on the channel for the
  life of the connection — including through `PubSubStream::close()`. Each channel
  or pattern is now hashed like a key to pick its node, so a subscription and its
  cancellation always meet, and a command naming channels of different shards is
  split per node. A channel-less `UNSUBSCRIBE` still goes to a single node, since
  it names nothing to hash.

- **A subscription whose subscriber is gone is cleaned up.** When a pub/sub message
  could not be handed to its subscriber because the receiving half had been dropped,
  the client logged a warning and kept the subscription: the server went on
  publishing to a channel nobody could receive on, one warning per message, for the
  life of the connection. That state needs no bug to reach — a `command_timeout`
  cutting `subscribe()` short after the server accepted it is enough, and so is
  leaking the stream. The subscription is now removed and an `UNSUBSCRIBE`
  (`PUNSUBSCRIBE`, `SUNSUBSCRIBE`) is sent, so the failed delivery is reported once
  and the server stops publishing. On a cluster the `SUNSUBSCRIBE` is routed by the
  shard channel's hash slot, so it reaches the node actually holding the
  subscription.

- **The `bench` feature compiles again.** `resp::bench_support` still built `Err`
  from an `ErrorKind` rather than an `Error`, so `--features bench` failed with five
  errors. No CI job compiles that feature, which is why the `Error` restructuring
  missed it.

- **A textual reply read as a `bool` follows one rule.** Asking for a `bool`
  directly — `client.send(cmd).await?` typed as `bool` — and asking for a `Value`
  and converting it afterwards — `value.into::<bool>()?` — gave different answers
  for the same reply: a simple string other than `OK`, and a bulk string other than
  `0`/`false`/`1`/`true`, were `false` the first way and a `CannotParseBoolean`
  error the second. The reply's encoding mattered too, `+OK` being `true` where
  `$2\r\nOK\r\n` was not, so a server switching between RESP2 and RESP3 could flip
  the result. One rule now covers the reply's text whichever way it is read and
  whichever encoding carries it: `OK`, `1` and `true` are `true`, `0` and `false`
  are `false`, anything else is `CannotParseBoolean`. **Behaviour change**: text
  outside that list used to be `false` when the `bool` was asked for directly and
  is now an error — the server never said `false`, and the error names the problem
  where the `false` hid it — and a bulk string `OK` is now `true`, as the simple
  string `OK` already was. Integers, doubles, RESP booleans and nil are unchanged.

- **`Value` equality is total on doubles.** `Value` asserts `Eq` and is hashed as a
  `Value::Map` key, yet doubles were compared with `==`, under which a NaN is not
  even equal to itself. `,nan` is a legal RESP double — T-Digest and TimeSeries
  return it for an empty sketch or an empty bucket — so a `nan` key inserted in a
  map could never be looked up again, and two identical replies containing one
  compared unequal as `Array`, `Set` or `Push`. Doubles are now compared and hashed
  on a canonical bit pattern: all NaNs are equal to each other, and `-0.0` equals
  `0.0` as before. The only observable change is `Value::Double(f64::NAN)` now
  equalling itself.

- **The two deserializers agree on their coercions.** A reply read as a `Value` and
  the same reply read straight off the wire went through two different `Deserializer`
  implementations, which disagreed: `Value` rejected an integer or a boolean that the
  wire path renders as text, so `client.incr(k)` typed as a `String` succeeded while
  `Value::into::<String>()` on the same reply failed; a one-element array unwrapped to
  an integer for every width on the wire but only for `i64`/`u64` from a `Value`; and
  `i128`/`u128` were unimplemented on the `Value` side. The `Value` deserializer now
  applies the wire path's rules: numbers and booleans are readable as text through
  both `deserialize_str` and `deserialize_string`, the one-element-array unwrapping
  covers the twelve integer widths, and `i128`/`u128` are supported. That unwrapping
  now also requires the element to be an integer, as the wire path already did.

### Removed

- **`examples/loop.rs`.** It was not an example of anything — a CPU load for
  profiling, ten thousand `SET`/`GET` round trips in a loop — and it was the only
  such probe not gated behind the `bench` feature, so it built under the default
  features and shipped in the published `.crate` tarball. The remaining profiling
  probes (`pprof_*`, `head_to_head`, `strace_workload`, …) stay behind `bench`.

## [0.22.0] - 2026-08-01

### Added

- **`ClusterConfig::read_preference` routes read-only commands to the replicas.**
  A cluster client sent every keyed command to the shard's master, replicas being
  connected only to serve the broadcast policies, so a read-heavy deployment could
  not spread its reads at all. `ReadPreference::PreferReplica` — spelled
  `?read_preference=prefer_replica` in a URI — sends the commands the server reports
  as `readonly` to the replicas of their shard, in round-robin, and puts those
  connections in `READONLY` mode. Writes, blocking commands, transactions and
  redirections stay on the master, as does everything when a shard has no reachable
  replica. The default, `ReadPreference::Master`, keeps the previous behaviour: a
  replica lags behind its master, so reading from one trades consistency for
  throughput and has to be asked for.

- **`Config::credentials_provider` authenticates with credentials resolved at every
  handshake.** `Config::password` is fixed once and for all, so a client of a managed
  Redis whose password is a short-lived token (ElastiCache IAM, Memorystore IAM, Entra
  ID, Vault) reconnects for one token lifetime and then fails authentication for good.
  A `CredentialsProvider` — implemented for any closure returning
  `Result<Credentials>` — is asked again on each reconnection. It takes precedence over
  `username`/`password` and has no URI representation. `SentinelConfig::credentials_provider`
  does the same for the Sentinel instances themselves; the two are independent, a
  Sentinel being a different server with its own ACLs.

### Changed

- **The manifest declares `license = "MIT"` instead of `license-file`.** With
  `license-file`, `cargo metadata` reports `license: null`, so every tool that
  keys on the SPDX identifier — license scanners, dependency audits, policy
  checks — sees the crate as carrying no license. `LICENSE` is the verbatim MIT
  text and is still packaged, so the terms are unchanged.

- **A double no longer decodes as an integer unless the conversion is exact.**
  Every integer width converted a `Double` reply with a saturating cast, so a
  `ZSCORE` of `3.9` read as `i64` gave `3`, `1e300` gave `i64::MAX` and `NaN`
  gave `0` — a plausible number for a value the server never sent. A double now
  deserializes to an integer only when it is finite, has no fractional part and
  fits the target; anything else fails with `ClientError::CannotParseInteger`,
  as an out-of-range integer reply already did. Read such a reply as `f64` to
  keep the fractional value. A negative reply read as `u128` no longer wraps
  either. Both deserializers — RESP and `resp::Value` — are covered.

- **An array of more than one element no longer decodes as a single integer.**
  Every integer width (`i8``i128`, `u8``u128`) unwrapped an array reply to its
  first element and discarded the rest, so a mistyped response shape produced a
  plausible number instead of an error. Only a one-element array still unwraps;
  a longer one fails with `ClientError::CannotParseInteger`.

- **A connection URI now rejects a query parameter it does not understand.** An
  unknown key (`?commandtimeout=5000`, `?reconnection=constant`) and a value that
  does not parse (`?command_timeout=5s`) were both dropped silently, leaving the
  default in place — a mistyped `command_timeout` meant no timeout at all. Both
  now fail with `ClientError::InvalidUri`, whose message names the parameter.
  Code relying on a URI with an unknown parameter being accepted must drop it.
  The documented `reconnection` parameter never existed and has been removed from
  the list; `max_command_attempts`, which is parsed, has been added to it.

- **`keep_alive` now defaults to 30 seconds** instead of `None`. Paired with the
  default `command_timeout` of 0 (no timeout), the previous default left a
  half-open connection — one silently dropped by a NAT, a firewall or a load
  balancer — detected by nothing: no timeout, no keepalive, no socket error, so
  every awaiting caller parked forever and `on_reconnect` never fired. Set
  `keep_alive` to `None`, or `keep_alive=0` in a URL, to restore the old
  behaviour.

- **Dependencies updated to their latest releases**, including two major bumps:
  `rand` 0.9 → 0.10 and `atoi` 2.0 → 3.1. Both are internal; the public API is
  unchanged. `serial_test` stays on 3.x — its 4.0 requires Rust 1.93, above the
  crate's 1.88 MSRV, which is itself unchanged.

### Fixed

- **A Sentinel client rediscovers its master when the one it holds is demoted.**
  A failover turns the master into a replica without closing the connections it
  already serves, so nothing in the transport says the topology moved: the client
  kept writing to a replica, reading stale data and collecting `READONLY` on every
  write, for as long as the socket held — which is forever with the default absence
  of `command_timeout` and TCP keepalive. A `READONLY` received on a Sentinel
  connection now triggers a reconnection, and a Sentinel reconnection polls the
  sentinels for the master again and accepts a node only once `ROLE` confirms it.
  The caller who issued the refused write still receives the `READONLY` itself; the
  commands in flight follow the usual reconnection rules. Standalone and cluster
  connections are unchanged — neither has a master to look up.

- **`TRYAGAIN` and `CLUSTERDOWN` are now retried instead of reaching the
  caller.** Both were parsed and never consulted, so a routine resharding — a
  multi-key command whose keys straddle a slot in migration — or a failover
  produced an application-visible error the driver is meant to absorb. A cluster
  command answered `TRYAGAIN` is now replayed after 25 ms, and one answered
  `CLUSTERDOWN` after 250 ms and a topology reload, through the same retry path
  as `ASK`/`MOVED` and under the same `max_command_attempts` cap.

- **Dropping a command future does not cancel the command, and this is now
  documented.** The message is already queued when the future is awaited, so it
  is sent and executed by the server; only the reply is discarded. Every
  `tokio::time::timeout`, `select!`, aborted task and `command_timeout` therefore
  leaves a non-idempotent command applied, which nothing said. A new
  `Cancellation and timeouts` section in the `client` module states the contract
  and what to do about it; `Client::send` and `Config::command_timeout` point at
  it.

- **The silent `nil` coercion is now documented as a trap instead of a convenience.**
  A `nil` reply decodes as the neutral value of the response type — `0` for every
  integer width, `0.0` for floats, `false` for `bool`, `""` for `String` — so a
  missing counter reads as zero with no error. The behaviour is unchanged, but the
  `resp` module, `StringCommands::get` and `Client::send` now state it and point at
  `Option<R>`, which is honoured before any conversion and yields `None` on `nil`.
  The `get` example presented the `String` case as a convenience.

- **The generic-command API documentation no longer teaches a Cluster misroute.**
  The `MSET`/`MGET` examples in the crate documentation and in `Client::send` added
  their keys with `arg`, which does not mark an argument as a key: the command
  carried no slot and was sent to a random node. They now use `key` and same-slot
  hash tags, and the rule is stated in the `cmd`, `arg` and `key` documentation.

- **`keep_alive` and `no_delay` are now applied to TLS connections.** Both were set
  only on the plain TCP path: a TLS connection ran with Nagle's algorithm enabled
  (up to 40 ms added to a small command) and without TCP keepalive, so a half-open
  socket was detected by nothing. Both paths now share a single socket-setup step
  applied to the `TcpStream` before the TLS handshake.

- **Vector-set commands are usable in pipelines and transactions again.**
  `VectorSetCommands` was implemented for `&Pipeline` and `&Transaction` instead of
  `&mut`, so `.queue()` and `.forget()` did not resolve on any of them and the whole
  family was unreachable in batch mode.

- **Building without any runtime feature now fails with a message that says so.**
  `default-features = false` without `tokio-runtime` left every function of the
  runtime layer without a body, and the user got nine `cannot find … in this scope`
  errors pointing at internal items. A `compile_error!` now names the missing
  feature and how to enable it.

- **Enabling `rustls` or `native-tls` on its own is now a clear compile error.** The
  backend-only features gate the TLS configuration types, while the connection code
  reading them lives behind `tokio-rustls` / `tokio-native-tls`. Enabled alone they
  produced six `cannot find … in this scope` errors on internal stream types. A
  `compile_error!` now names the runtime feature to enable instead.

- **CI builds the feature combinations that were unbuildable.** No job compiled the
  crate without a runtime feature, which is why that break went unnoticed. The
  feature matrix gains `pool`, `json`, `client-cache` and the two TLS runtimes each
  on their own, and a new job asserts that the four rejected configurations — no
  runtime, both TLS runtimes, and each backend-only feature alone — fail with their
  own `compile_error!` rather than with a cascade of internal errors.

- **The `actix_long_polling_pubsub` and `axum_long_polling_pubsub` examples compile
  again.** They passed `lpop`'s count as a `usize` where the signature takes a `u32`.

## [0.21.0] - 2026-07-30

### BREAKING CHANGES

- **`FtHybridVectorQuery::Knn` has a third field, `shard_k_ratio`.** The variant's
  fields are public and `#[non_exhaustive]` on the enum does not cover them, so an
  existing `Knn { k, ef_runtime }` literal needs `shard_k_ratio: None` added. That
  is the whole break; behaviour is unchanged when it is `None`.

### Added

- **`BackpressureConfig` on `Config` bounds the client's memory.** `max_queued_bytes`
  (16 MiB) caps the send queue, `max_pubsub_bytes` (8 MiB) each subscription,
  `max_push_bytes` (8 MiB) each push sink. Over budget, a stream drops its **oldest**
  messages; `0` restores the previous unbounded behaviour.

- **`dropped_messages()` on `PubSubStream`, `MonitorStream` and
  `ClientTrackingInvalidationStream`**, reporting what a budget shed.

- **`ClientError::SendQueueFull`**, returned when a command is offered to a send queue
  that is over budget. Commands already accepted are never shed.

### Changed

- **`max_command_attempts` defaults to `5` instead of unlimited**, so a command that
  keeps failing ends with `ClientError::MaxCommandAttemptsReached`. `0` still means
  unlimited.

- **`ReconnectionConfig::max_attempts` documents that a non-zero value is a one-way
  door**: reaching the limit ends the network task for good, so it is not recommended
  for long-running services. Behaviour and the `0` default are unchanged.

### Fixed

- **A cache entry whose invalidation was lost is no longer served stale.** The cache
  (feature `client-cache`) now flushes when invalidations are dropped, and a fetch in
  flight across a flush no longer re-inserts its stale value — which also fixes the
  pre-existing flush on reconnection.

- **`FT.HYBRID`'s `KNN SHARD_K_RATIO` is now reachable**, through
  `FtHybridVectorQuery::Knn::shard_k_ratio`. It is a cluster-only knob — it scales
  the candidate count each shard returns — and Redis 8.8 accepts it, where 8.6
  rejected it as an unknown argument. It counts towards the `KNN` clause count:
  `KNN 6 K 2 EF_RUNTIME 30 SHARD_K_RATIO 0.5`. See the breaking-changes section.

- **A struct no longer fails to decode when the server changes its field list.**
  Commands that still answer a flat array under RESP3 (`XINFO`, `BF.INFO`,
  `FT.INFO`, `XAUTOCLAIM`…) are decoded by guessing whether the array holds
  field/value pairs or positional values, and that guess used to key on the struct's
  field count — so one field added by a newer `redis-server` broke it. An array is
  now read as pairs when its length is even and its first element names a field,
  positionally otherwise, with both deserializers sharing the one rule. Added fields
  and appended elements are ignored; a field the server stops sending gives a serde
  error naming it. One consequence: an even-length positional array whose first
  element happens to equal a field name is now read as pairs.

- **Structs decode from cluster-aggregated and cache-rebuilt replies**, which are
  synthesized rather than parsed off the wire and previously failed with
  `CannotParseStruct`.

## [0.20.0] - 2026-07-28

This release closes a large correctness and performance pass over the RESP
layer, the network task, the cluster client and the client-side cache. It
contains breaking changes; read that section before upgrading.

### BREAKING CHANGES

- **`ClientTrackingOptions::redirect` is removed.** Redirection sends client-side
  caching invalidations to *another* connection. It exists for RESP2, which cannot
  deliver an invalidation on a connection that is also answering commands, so the
  target subscribes to `__redis__:invalidate` and reads them as pub/sub messages.

  rustis always negotiates **RESP3**, where invalidations arrive as push frames on
  the very connection that enabled tracking — which is what
  `Client::create_client_tracking_invalidation_stream` and `Cache` consume. Setting
  a redirection therefore sent them somewhere else and **silently starved both**:
  they stayed alive, reported no error, and never fired again. It also could not
  work on a cluster client at all, a client id being a per-node counter.

  If you were relying on it, you were not receiving invalidations. The reasoning is
  documented on `ClientTrackingOptions`.

- **The `async-std-runtime` and `async-std-native-tls` features are removed.**
  async-std is no longer maintained; its authors point users to `smol`. Tokio is
  now the only supported runtime, and the `async-std` and `async-native-tls`
  dependencies are gone from the tree. Enabling either feature is now a Cargo
  error, so the break is loud rather than silent. Nothing changes for the default
  `tokio-runtime` build.

  Support for another runtime is not ruled out: the runtime-facing surface is a
  handful of primitives in one module (connect, spawn, sleep, timeout, join
  handle). If you need a non-tokio runtime, open an issue.

- **`json_set` takes a `JsonSetOptions` instead of a bare `SetCondition`.**
  `JSON.SET` gained the `FPHA` argument in 8.8, so the last parameter had to
  become extensible. Calls passing `None` are unaffected; a call passing a
  condition becomes
  `json_set(k, p, v, JsonSetOptions::default().condition(SetCondition::NX))`,
  or keeps working as it is through `From<SetCondition> for JsonSetOptions`.

- **`XPendingMessageResult::elapsed_millis` is an `i64`, not a `u64`.** The
  server can answer `-1`, so the old type could not represent every reply. See
  the fixes section.

- **Many public types are now `#[non_exhaustive]`, so that adding a variant or a
  field to them stops being a breaking change.** This is a one-time cost taken
  here, in a release that already breaks, rather than on each future addition.
  What changes for you:
  - Matching one of the affected enums now requires a `_ => …` arm.
  - Constructing one of the affected structs with a struct literal (including
    `Config { host, ..Default::default() }`) is no longer possible from outside
    the crate; use `Default::default()` and assign the fields, or the type's
    constructor.

  The types covered are the ones whose shape is dictated by something other than
  our own design: the error types (`Error`, `ClientError`, `RedisError`,
  `RedisErrorKind`, `RetryReason`), the configuration types (`Config`,
  `SentinelConfig`, `ClusterConfig`, `TlsConfig`, `ServerConfig`,
  `ReconnectionConfig`, `BufferConfig`, `RespLimits`), every command-option enum
  that follows Redis's own vocabulary (`SetCondition`, `BitOperation`,
  `ExpireOption`, `SortOrder`, `GeoUnit`, `FtLanguage`, `XTrimOperator`, … 66 in
  total), and every struct deserialized from a server reply (`ClusterInfo`,
  `ClientInfo`, `FtInfoResult`, `MemoryStats`, `SentinelMasterInfo`,
  `XStreamInfo`, … 71 in total), where Redis adds fields between versions.

  Deliberately **not** covered: `resp::Value` and the enums decoded from a server
  reply (`RoleResult`, `ReplicationState`, `RequestPolicy`, `ClusterState`, …).
  These describe the protocol and Redis's own closed vocabularies; matching them
  exhaustively is a legitimate thing to want, and a compile error on a new
  variant is information rather than a nuisance. The builder-style `*Options`
  structs are also untouched — their fields are already private, so they were
  never literal-constructible and gain nothing.

  Six of the 66 round-trip: `FtFieldType`, `FtIndexDataType`, `FtPhoneticMatcher`,
  `KeyType`, `TsAggregationType` and `TsDuplicatePolicy` are both written into a
  command and read back out of a reply. They are covered, because what a caller
  does with them is build an option — and Redis does extend them: this release
  alone adds `FtFieldType::Geoshape`, `TsAggregationType::CountNan` and
  `CountAll`.
- **`TsAggregationType`'s discriminants moved.** `CountNan` and `CountAll` were
  inserted before `First`, so every variant from `First` onwards shifted by two.
  Only code casting a variant to an integer (`aggregation as isize`) is affected;
  the wire form is the variant's name and is unchanged.
- **`ClientReplyMode` now implements `Copy`.** A non-`move` closure that used to
  take it by value now captures it by reference.
- `resp::Command::name()` now returns `&[u8]` instead of `Bytes`. It borrows from
  the command instead of bumping a reference count on every call. Callers that
  need an owned value can use `Bytes::copy_from_slice(command.name())`.
- `resp::FastPathCommandBuilder::arg` and `::key` are no longer public. They
  panicked on any non-primitive argument; they are now private, fallible, and
  every fast-path constructor falls back to the generic `cmd(NAME)` builder
  rather than panicking. Build commands through `resp::cmd` instead.
- `cache::Cache::zremrangebyscore` was removed. `ZREMRANGEBYSCORE` is a write
  command and had no place on the cached read surface; call it on the `Client`.
- `Error::Tls` now wraps `Arc<native_tls::Error>` instead of `native_tls::Error`,
  so `Error` stays `Clone` (a TLS failure has to be reported to every in-flight
  command). The `From<native_tls::Error>` conversion is gone.
- `Error::OneshotCanceled` now wraps `tokio::sync::oneshot::error::RecvError`
  instead of `futures::channel::oneshot::Canceled`, following the result channels
  moving to tokio.
- Variants were added to `ClientError` (`CrossSlot`, `InvalidConfig`,
  `MaxCommandAttemptsReached`, `MaxNestingDepthExceeded`, `BulkLengthTooLarge`,
  `CollectionLengthTooLarge`, `InconsistentRoutingState`, `InvalidCacheKey`,
  `UnexpectedSubscriptionConfirmation`), `SetCondition` (`IFNE`, `IFDEQ`) and
  `BitOperation` (`Diff`, `Diff1`, `AndOr`, `One`), and public fields to `Config`
  (`buffers`, `limits`, `max_command_attempts`, `max_messages_per_wave`),
  `SentinelConfig` (`max_discovery_rounds`) and `FtIndexAttribute` (`algorithm`,
  `data_type`, `dim`, `distance_metric`). All of these types are
  `#[non_exhaustive]` as of this release, so equivalent additions will not break
  again.
- `FtFlatVectorFieldAttributes::num_attributes` and
  `FtHnswVectorFieldAttributes::num_attributes` were removed, along with the
  unused `resp::SmallVecWithCounter`. The two `num_attributes` were
  hand-maintained mirrors of the fields their struct serializes, which a new
  field would have silently invalidated; the count now comes from the
  serialization itself.
- `CommandBuilder::kill_connection_on_write` is no longer public. It is a
  failure-injection hook for the crate's own tests and is now gated behind
  `cfg(test)`, so it is absent from shipped builds instead of being part of the
  API.
- **Five items that were `pub` without being usable are now private.** Each was
  reachable in name only: no caller outside the crate could construct the
  argument it needed or the type itself.
  - `resp::RespDeserializer`, together with `resp::EnumAccess` and
    `resp::VariantAccess`. `RespDeserializer::new` takes a `RespView`, which is
    crate-private, so the type had no reachable constructor; the other two are
    serde plumbing it hands to a visitor, and their names collided with serde's
    own `EnumAccess` / `VariantAccess` traits under `use rustis::resp::*`.
    Deserialize a reply through `Response` / `PreparedCommand` as before.
  - `commands::deserialize_bzop_min_max_result`, a `#[serde(deserialize_with)]`
    helper that a glob re-export made public.
  - `cache::Cache::from_builder`. Its `builder` parameter is a
    `moka::future::CacheBuilder` over the cache's internal representation, a
    private type alias, so the method could not be called from outside. Use
    `Cache::new`. Configuring the underlying moka cache is not currently
    expressible in the public API; if you need it, open an issue.
- `Command`, `CommandBuilder`, `PreparedCommand`, `CommandArgsMut`,
  `SortOptions`, `MigrateOptions`, `JsonGetOptions` and `AclDryRunOptions` no
  longer implement `UnwindSafe` and `RefUnwindSafe`. `Command` now carries the
  deferred serialization error described below, and `Error` is not
  `RefUnwindSafe` (it holds an `Arc<std::io::Error>`, which can wrap a boxed
  `dyn Error`). This only affects code passing these types through
  `std::panic::catch_unwind`.
- **Behavior change** — an empty array now decodes to `Value::Array([])`, an
  empty map to `Value::Map({})` and an empty push to `Value::Push([])`, instead
  of all three collapsing to `Value::Null`. RESP's empty-versus-nil distinction
  is preserved: a nil reply (`_` / `*-1`) still decodes to `Value::Null`. Typed
  deserialization (`Vec<T>``[]`) is unaffected.
- **Behavior change** — a malformed numeric reply (`:a\r\n`, `,abc\r\n`) now fails
  the command that received it instead of tearing down the connection. RESP
  framing only needs the terminating `\r\n`, so the stream stays aligned and the
  other in-flight commands on the connection are unaffected. A malformed boolean
  (`#a\r\n`) still fails the connection: its frame length depends on the payload
  being `t` or `f`, so anything else leaves the frame boundary unknown.
- **Behavior change** — a numeric reply deserialized into a `String` now gives
  back the bytes the server sent rather than a re-rendering of the decoded value.
  A reply of `,12.50` used to come back as `"12.50"``f64``"12.5"`, and
  `,1e21` as twenty-two digits; both are now verbatim. Deserializing into a
  numeric type is unaffected.
- **Behavior change** — an integer reply that does not fit the requested type is
  now an error instead of a silent truncation. `Integer(300)` deserialized as
  `u8` used to yield `44`; it now fails. `i64::MIN` is accepted (it was
  previously rejected). This applies to both the RESP deserializer and the
  `Value` deserializer, which stay consistent with each other.
- Repairing the commands listed under *Fixed* changed five signatures:
  - `ClusterCommands::cluster_info` takes no argument (was `slot`, `count`).
  - `ClusterCommands::cluster_getkeysinslot` returns `R: Response` (was `()`).
  - `TimeSeriesCommands::ts_decrby` returns `u64` (was `()`).
  - `ClusterBumpEpochResult::Bumped` and `Still` now carry the config epoch the
    node ends up with (`Bumped(u64)` / `Still(u64)`), and the enum became
    `#[non_exhaustive]`. Match arms binding no field need updating; the new
    `ClusterBumpEpochResult::epoch()` reads the value whichever the outcome.
  - Eight methods lost a generic type parameter — `ClusterCommands::cluster_addslots`,
    `cluster_addslotsrange`, `cluster_count_failure_reports`, `cluster_delslots`,
    `cluster_delslotsrange`, `cluster_forget`, `SearchCommands::ft_profile_search`
    and `SentinelCommands::sentinel_failover`. Only a call site passing the
    parameter explicitly (`cluster_forget::<T>(…)`) needs changing.

- **Eight option builders changed shape because they could not express their
  command.** All eight are described in the fixes section; the API deltas are:
  - `HScanOptions::no_values` was removed, replaced by
    `HashCommands::hscan_no_values`, which returns `(u64, R)` and emits `NOVALUES`
    itself.
  - `SortOptions::store` was removed; use `sort_and_store`, which appends `STORE`
    itself.
  - `BfInfoResult::expansion_rate` is an `Option<usize>`, not a `usize`.
  - `TsInfoResult::key_self_name` is an `Option<String>`, not a `String`.
  - `FtSearchResultRow::score` is an `FtScore { value, explanation }`, not an
    `f64`.
  - `TsRangeOptions::bucket_timestamp` and `TsMRangeOptions::bucket_timestamp`
    take the new `TsBucketTimestamp` (`Low` / `High` / `Mid`), not a `u64`.
  - `TsRangeOptions::filter_by_ts` and `TsMRangeOptions::filter_by_ts` take
    `impl IntoIterator<Item = u64>`, not a single `&str`.
  - `FtSearchOptions::inkey` and `FtSearchOptions::infields` lost an
    unconstrained generic parameter, which had made them uncallable without a
    turbofish naming an unused type — so no working caller can break.
  - `RestoreOptions::frequency` takes a `u8`, not an `f64`. It emitted
    `FREQUENCY 10.0` where `RESTORE` takes `FREQ 10`, so every call failed; `u8`
    is the LFU counter's actual range.

- **`FtSearchResultRow::values` and `extra_attributes` hold an
  `FtAttributeValue`, not a `String`.** `FT.AGGREGATE`'s `TOLIST` and
  `RANDOM_SAMPLE` reducers return an array of strings per group, which a
  `Vec<(String, String)>` could not hold — both failed with
  `CannotParseString`.

  `FtAttributeValue` is `Text(String) | Array(Vec<String>)`. Read it with
  `as_str()` or `as_array()`, which return `Option`. `PartialEq` against `str`,
  `&str`, `String` and `[String]` is implemented in both directions, so
  `assert_eq!("40", value)` still compiles; code binding the value as a `String`
  needs `.as_str()` or a `match`.

- **`JsonRef` is removed; `Json<T>` serializes as well as it deserializes.** The
  JSON wrapper is now one name in both directions:
  `client.set(key, Json(&value))` and
  `let Json(value): Json<T> = client.get(key).await?`. `Json(&value)` borrows
  exactly as `JsonRef(&value)` did — `&T` is itself `Serialize` — so the
  migration is a rename, with `Json(value)` available when the value can be
  moved.

- **A value `serde_json` cannot encode now fails the command instead of being
  stored as an empty one.** The wrapper's `Serialize` swallowed the error and
  sent a unit argument in the value's place, so `client.set(key, Json(&value))`
  returned `Ok(())` having written an absent value under `key`. It now returns
  `Error::Client(ClientError::SerdeSerialize(_))` and sends nothing. Code that
  treated a JSON argument as infallible has an error to handle.

  Reading a nil reply as `Json<T>` also reports a different message — it now
  names `Option<Json<T>>`, which is what a possibly-missing key needs, instead
  of serde's `invalid type: Option`. Only the text changes.

- **`JsonArrIndexOptions::start` and `stop` are `isize`.** They were `u32` and
  `i32`, so a negative `start` — which `JSON.ARRINDEX` reads as an offset from
  the end of the array, like every other index in this family — could not be
  expressed. Literals still infer; a caller passing a `u32` or `i32` variable
  needs a cast.

### Security

- Passwords are no longer written in clear text by `Display for Config`. Both the
  main and the Sentinel credentials are masked as `:***@`, so a configuration
  logged at startup no longer leaks the password.
- The `native-tls` backends now request TLS 1.2 as their minimum version.
  `native-tls`'s own default allowed TLS 1.0.
- The RESP parser now bounds every quantity a server controls, so a hostile or
  malfunctioning peer cannot drive the client into a crash or an unbounded
  allocation: nesting depth is capped (a deeply nested reply used to overflow the
  stack), bulk and collection lengths are capped, and negative bulk, error and
  collection lengths are rejected rather than being used as sizes. `-1` remains
  the nil form. All limits are configurable through `Config::limits`.
- Decoding and logging server input no longer contain panic paths: the whole
  crate now denies the explicit-panic clippy family (`unwrap_used`,
  `expect_used`, `panic`, `unreachable`, `todo`, `unimplemented`), with
  `indexing_slicing` additionally denied in the `resp` and `network` modules,
  enforced in CI. A panic on the network task would take down every in-flight
  command along with the reconnection loop.

### Added

- **`client::ClientTrackingInvalidationStream` is now exported.**
  `Client::create_client_tracking_invalidation_stream` returns it, but a
  `pub(crate)` re-export kept the name out of reach: the value could be used
  inline and never stored in a field, named in a signature or boxed.

- **Redis 8.8 support**, established the same way as the 8.6 pass below: the
  8.8 server's `COMMAND DOCS` diffed against a throwaway 8.6 server, so the
  delta is what the servers themselves disagree on rather than what a release
  note mentions.

  A new data type, with its own `ArrayCommands` trait: an **array** is
  sparse and index-addressed, so unlike a list it has no push or pop — you
  write at an index you choose (`arset`, `armset`) or at a cursor the array
  carries (`arinsert`, `arring`, moved with `arseek`). Gaps cost nothing, which
  is why `arlen` (highest index plus one) and `arcount` (slots that hold a
  value) never coincide. All eighteen commands are covered: `arcount`, `ardel`,
  `ardelrange`, `arget`, `argetrange`, `argrep`, `arinfo`, `arinsert`,
  `arlastitems`, `arlen`, `armget`, `armset`, `arnext`, `arop`, `arring`,
  `arscan`, `arseek`, `arset`, with `ArGrep`, `ArGrepPredicate`, `ArOperation`,
  `ArInfoOptions`, `ArLastItemsOptions` and `ArrayInfo`.

  Two more commands:
  - `increx` (+ `IncrExOptions`), a bounded increment that sets the expiration
    in the same atomic step. It returns both the new value and the increment
    that was actually applied, which is `0` when a bound stopped it. With
    `ubound_int` as the cap and `enx` to start the window only once, a window
    counter rate limiter no longer needs a Lua script.
  - `xnack` (+ `XNackMode`, `XNackOptions`), which releases pending messages
    back to the group's PEL without acknowledging them. The entries lose their
    owner and their idle time, so another consumer can claim them at once
    instead of waiting out `min-idle-time`.

  Arguments added to existing commands:
  - `ZAggregate::Count` on `zinter`, `zinterstore`, `zunion` and `zunionstore`
  - `FtFieldType::Geoshape` (+ `FtGeoShapeCoordSystem`) on `ft_create`
  - `JsonSetOptions::fpha` (+ `JsonFpType`) on `json_set`, which declares the
    storage type of a floating-point homogeneous array

- **Complete command coverage up to Redis 8.6**, established by diffing the
  server's own `COMMAND DOCS` against the crate rather than by reading release
  notes, and verified against a Redis 8.6.5 server.

  Redis 8.6 itself:
  - `HOTKEYS`: `hotkeys_start`, `hotkeys_stop`, `hotkeys_get`, `hotkeys_reset`,
    `hotkeys_help`, with `HotKeysMetric`, `HotKeysStartOptions` and
    `HotKeysInfo`. `HOTKEYS GET` replies with one entry per node; the fields
    tied to a metric are absent — not empty — when that metric was not tracked,
    so they are `Option`.
  - Stream idempotent production: `xcfgset` (+ `XCfgSetOptions`),
    `XAddOptions::idmp` and `XAddOptions::idmp_auto`, and the six IDMP fields on
    `XStreamInfo`.
  - `TsAggregationType::CountNan` and `CountAll`.

  Commands that pre-dated 8.6 and had been missed:
  - `xsetid` (+ `XSetIdOptions` for `ENTRIESADDED` / `MAXDELETEDID`)
  - `cluster_myshardid`
  - `module_loadex` (+ `ModuleLoadexOptions`) and `module_unload`
  - `json_merge`
  - `bf_card`, `topk_count`
  - `vismember`, `vrange`

  Arguments that existed on implemented commands but were not reachable:
  - `XClaimOptions::last_id` (`LASTID`)
  - `JsonGetOptions::format` with `JsonGetFormat::{String, Expand1, Expand}`
  - `FtFieldSchema::index_missing` / `index_empty`, and
    `FtCreateOptions::index_all` (+ `FtIndexAll`), which takes an explicit
    `ENABLE` / `DISABLE` value rather than being a flag

- **Rustis now emits [`tracing`]https://docs.rs/tracing events and spans**
  instead of plain `log` records.

  **If you use `log`, nothing changes and you need to do nothing.** The `log`
  feature of `tracing` is enabled, so every event also emits a `log` record and
  existing `env_logger`-style setups keep receiving the same output.

  What you gain by installing a `tracing` subscriber instead: every event from
  the network task is wrapped in a `connection` span carrying a `tag` field, so
  output from several clients stays attributable; reconnections open a nested
  `reconnect` span grouping the in-flight purge, the retries and the
  subscription replay; and in cluster mode, events about a specific node carry a
  `node` field. Connection identity is no longer duplicated into each message —
  it is a structured field a collector can index.

- **A declared minimum supported Rust version: 1.88.** `Cargo.toml` now carries
  `rust-version`, and a CI job compiles both runtimes with exactly that
  toolchain, so the number is verified rather than asserted. Let chains hold the
  floor there; edition 2024 on its own would allow 1.85. Raising the MSRV will be
  treated as a breaking change and announced here.

- Redis 8.4 command support: `FT.HYBRID` (including the advanced
  post-processing options), `CLUSTER SLOT-STATS`, `CLUSTER MIGRATION`, `DIGEST`
  and `DELEX`. Plus the options that were missing from various Redis 8.x
  commands, and `XADD`/`XTRIM`'s entries-deletion policy.
- `StreamCommands::xdelex` and `StreamCommands::xackdel` (Redis 8.2), which
  delete — and for `XACKDEL` acknowledge — stream entries under an explicit
  `StreamEntryDeletionPolicy`, and report per-id whether each entry was removed,
  was missing, or was kept because the policy forbade it.
- `resp::CommandBuilder::arg_counted`, which writes a labeled clause followed by
  the number of arguments it contains — the shape `SORTBY 2 field ASC` and
  `PARAMS 4 n1 v1 n2 v2` require. The count comes from a dry run of the same
  serialization, so it cannot drift from what is written.
- `resp::CommandBuilder::arg_with_count_and_step` and
  `resp::CommandBuilder::key_with_count_and_step`, which prefix a collection with
  the number of `step`-sized groups it holds rather than its raw argument count —
  the shapes `HSETEX key FIELDS numfields field value …` and `MSETEX numkeys key
  value …` require. The `key_` form additionally marks every `step`-th element as
  a routing key for the cluster client. Both derive their count the same way
  `arg_counted` does.
- The examples now declare `tokio-runtime` in their `required-features`, so a
  feature set that does not build them no longer fails the whole target set.
- `Config` now exposes the constants that were hardcoded, each defaulting to its
  previous value: `Config::buffers` (`BufferConfig` — read/write buffer initial
  and shrink-back capacities), `Config::limits` (`RespLimits` — maximum nesting
  depth, bulk length and collection length), `Config::max_messages_per_wave`,
  `Config::max_command_attempts` and `SentinelConfig::max_discovery_rounds`.
  `Config::validate()` runs at connection time and rejects a value that would
  disable a behavior. `RespLimits::DEFAULT` and `BufferConfig::DEFAULT` give the
  same defaults in a `const` context.
- The parser accepts RESP3 attribute (`|`) and big number (`(`) frames.
- The client-side cache now compacts an entry before storing it: the response's
  bytes are copied into freshly-sized buffers instead of pinning the larger
  recycled network block they were read from. A numeric reply is decoded on the
  spot rather than copied, so a cache entry read a thousand times is decoded
  once.
- `cargo-fuzz` targets over the RESP read path, and a `fuzz_api` module exposing
  the parser entry points they drive.
- `resp::bench_support`, the benchmark counterpart to `fuzz_api`: thin entry
  points into the decode-and-deserialize path, isolated from the network, so an
  external `benches/*.rs` crate can measure the parser on hand-built RESP
  buffers. Gated behind the `bench` feature and compiled out of shipped builds.

### Changed

- Three `if let` guards in the `Value` deserializer were rewritten as plain
  matches. They were the only construct in the crate requiring Rust 1.95, so
  removing them lowered the compiler floor by seven releases. Behavior is
  unchanged.
- Documentation only, no API change:
  - `BlockingCommands` and each of its methods now state prominently that a
    blocking command monopolizes its connection, that `command_timeout` bounds
    only the caller's wait and does not free the connection server-side, and
    that these commands belong on a dedicated client. The constraint was
    documented before, but only in the client module's *Limitations* paragraph.
  - The README gained a *Safety* section explaining `#![forbid(unsafe_code)]` as
    a deliberate position — what it costs on a length-delimited protocol, and
    what actually guards the hostile-input surface instead (the panic lint
    policy, the configurable RESP limits, the fuzz targets).
  - The `check_resp2_array` heuristic in the `Value` deserializer is now
    documented, including where it is looser than the equivalent rule in the
    RESP deserializer.
  - The crate-level documentation claimed command coverage "until Redis 8.0";
    it is 8.4, as the README already said.

- **RESP collection decoding now uses a flat parse tape.** A collection reply is
  parsed once into a sequence of fixed-width nodes (one per element, all nesting
  levels) held in a recycled buffer, and reading an element is an O(1) node
  lookup instead of re-parsing the collection from the start. This removes the
  double-parse that the previous 5-range frame cache fell back to beyond its
  fifth element, and makes descending into nested replies O(1) per subtree.
  Walking a collection allocates nothing per element, which makes a large reply
  about 10 % faster to decode end to end.
- **A reply's value is produced when it is read, not when it is received.** The
  parser frames — it finds where a reply ends and indexes a collection's elements
  — and the value itself is decoded by whichever task asks for it. A connection's
  network task is shared by all of its callers, so the arithmetic of an integer or
  a double reply no longer runs there. End-to-end throughput is unchanged; what
  changes is where the work happens, not how much of it there is.
- **The streaming decoder now resumes across TCP chunks.** A reply split over
  several network reads is parsed incrementally — the partial tape and an
  explicit parse stack are carried forward — instead of re-parsing the whole
  accumulated buffer on each read. Decoding a large collection delivered in
  ~16 KB slices is now roughly on par with decoding it from a single slice
  (previously about 2.5× slower).
- **The parser no longer builds error values on the success path.** Its
  per-element (and per-digit) hot path used `Option::ok_or`, which eagerly
  constructs the (large) `Error` enum on every call and drops it again on
  success. Switching to `ok_or_else` builds an `Error` only when one is actually
  returned, cutting parse-and-deserialize time by roughly 15–30 % across reply
  shapes.
- **The network task was optimized for throughput**, cutting per-command
  overhead: the message and result channels moved to tokio's `mpsc`/`oneshot`,
  `Message` shrank from 2536 to 232 bytes, replies are dispatched straight to the
  waiting caller as they decode rather than after the batch completes, the send
  wave is capped so reading is never starved by writing, and the TCP stream is
  split without a `BiLock`. Measured against `0.19.3` on a single connection to a
  local server: **+65 % ops/s at 64 concurrent tasks, +111 % at 256 and +100 % at
  1024**, and −15 % wall-time on the latency-bound multiplexer benchmark. Below 32
  tasks the workload is round-trip-bound and unchanged.
- Routing work stays on the caller thread and off the network task: key hash
  slots are computed lazily by the caller, `ArgLayout` shrank to 12 bytes, and
  the cluster client indexes `MGET` reordering and shard-key lookups by hash set
  instead of scanning.
- The read buffer is reserved from the announced bulk length, so a large reply no
  longer grows the buffer by repeated doubling, and oversized read/write buffers
  shrink back to their target instead of staying at their peak for the
  connection's lifetime.

### Fixed

- **Connection-state commands now reach every node of a cluster instead of one
  random node.** A cluster client is one connection per node, and this state lives
  on the connection. Only `CLIENT SETNAME` and `CLIENT SETINFO` declared a routing
  policy; `AUTH`, `CLIENT TRACKING`, `CLIENT NO-EVICT`, `CLIENT NO-TOUCH` and
  `RESET` carried none, so each landed on an arbitrary shard and left the others
  in their previous state.

  The worst consequence was **client-side caching on a cluster client: it served
  stale values indefinitely.** Tracking armed on one node means the keys held by
  every other shard are cached and never invalidated, with no error and no log.

  A node that joins the topology later — through a `MOVED`-triggered refresh, or
  as a replica connected on demand — is now brought up to the same state before
  anything is sent on it, so a resharding no longer opts a shard out silently.

- **`CLIENT REPLY` now works on a cluster client instead of hanging it.** A cluster
  connection matches each reply against the sub-request it filed for the node it was
  sent to, so a silenced node used to leave that sub-request unresolvable and every
  caller queued behind it waited forever.

  `ON` and `OFF` are sent to every node — the mode has to be the same everywhere —
  and no in-flight bookkeeping is filed while the nodes are silent. This makes the
  use cases the Redis documentation names available to cluster clients too:
  fire-and-forget bursts, mass loading, constantly streamed cache writes.

  `SKIP` silences only the next command, so it is emitted on exactly the nodes that
  command is routed to — one for a key-routed command, every touched shard for a
  multi-shard one, all of them for a broadcast one. Previously it reached a single
  arbitrary node, which shifted the responses of everything that followed.

  `SELECT` and `READONLY` are unchanged and now document why: a cluster has one
  database, and rustis does not route slot reads to replicas.

- **A reconnection now restores the state the caller had attached to the
  connection, not only what the config describes.** Redis keeps a good deal of
  state on the connection itself, and a new socket starts without any of it. The
  handshake only ever restored `HELLO`, the config credentials, the config
  connection name and the config database, so a `SELECT`, an `AUTH`, a
  `CLIENT SETNAME`, a `CLIENT SETINFO`, a `CLIENT NO-EVICT` / `NO-TOUCH`, a
  `CLIENT TRACKING` issued directly or a `SCRIPT DEBUG` issued at
  runtime was silently lost the first time the connection blipped. Two of those
  are silent and damaging: the connection came back reading **database 0** and
  authenticated as **whoever the config names**, while answering normally.

  Each of these is now replayed as the command the caller actually issued, so
  option-carrying commands such as `CLIENT TRACKING` come back exactly as they
  were sent. A replay the server rejects is logged and dropped rather than
  failing the connection, so one bad `AUTH` cannot turn into a permanently dead
  client. `READONLY` is deliberately not among them: a cluster reconnection
  redials every node, so replaying it would grant the whole cluster a capability
  the send path never grants.

- **`CLIENT REPLY OFF` no longer desynchronizes every response after a
  reconnection.** The client mirrors the reply mode to know how many responses
  each command produces, and that mirror did not follow the socket: a
  reconnection taken while the connection was silent left the client expecting
  nothing while the server answered everything, shifting every subsequent
  response by one — permanently.

- **`CLIENT REPLY SKIP` silences one command again, instead of the connection.**
  It was treated as a sticky `OFF` until an explicit `ON`, so `SKIP` followed by
  more than one command made the client expect fewer replies than the server
  sends. `SKIP` before exactly one command — the only case the suite covered —
  behaved correctly and hid this.

- **`RESET` now clears the client's picture of the connection too.** It was
  recognised for a single one of its effects (leaving `MONITOR`). The reply mode
  stayed wrong afterwards, and a later reconnection restored subscriptions the
  caller had explicitly discarded.

- **A pooled client whose network task has ended is dropped from the pool.**
  `PooledClientManager::has_broken` returned `false` unconditionally, so a client
  that could no longer answer anything stayed in circulation and was handed to
  every subsequent borrower. Note that the pool still does **not** reset
  connection state between borrows — it hands out a multiplexed client, not a
  fresh connection; this is now documented on `PooledClientManager`, and callers
  needing a clean slate should issue `RESET` themselves.

- **Eighty-six option builders that nothing called got a test, and seventeen of
  them were broken.** Counting `*Options` / `*Schema` / `*Attribute` builder
  methods rather than commands shows that a tested command says nothing about its
  options: `ft_search` had a test all along and eleven of its options had never
  been sent once. Each of the 86 now has one, written from the syntax the server
  prints for itself and red before green. Thirteen of the seventeen defects were
  options that could not work in any call at all.

  Wrong token or wrong argument shape:
  - `ClientKillOptions::user` emitted `USERNAME` instead of `USER`, so any use
    answered `ERR syntax error`.
  - `ClientKillOptions::addr` and `laddr` sent `ADDR ip port` as two arguments
    where the server wants a single `ip:port`. Same error.
  - `ZAddOptions::change` emitted `CHANGE` instead of `CH`.
  - `FtSearchOptions::inkey` and `infields` suppressed their own field name
    through `#[serde(rename = "")]`, so the count and values went out with no
    `INKEYS` / `INFIELDS` token.
  - `FtAggregateOptions::load_all` emitted `LOAD 1 *`; `LOAD *` carries no count,
    and the counted form loads a field named `*`, which is to say nothing.
  - `FtAggregateOptions::add_scores` was serialized after `LOAD`, and the server
    accepts `ADDSCORES` only before it.

  Options no signature could express:
  - `TsGroupByOptions` had no `Default`, so `ts_mrange` and `ts_mrevrange` forced
    a `GROUPBY` the server makes optional — and grouping renames every returned
    series. It now defaults to emitting nothing.
  - `FtSearchSummarizeOptions` had neither `Default` nor a constructor, so
    `FtSearchOptions::summarize` had no argument that could be built.

  Replies the declared type could not hold:
  - `client_list` returned one phantom `ClientInfo` with `id: 0` on every call:
    the reply is newline-terminated and the trailing empty line was parsed as a
    client.
  - `ts_info(key, false)` always failed with `missing field keySelfName`; the
    existing test only ever passed `true`.
  - `bf_info_all` always failed on a `NONSCALING` filter — the filters the two
    `nonscaling()` options exist to create.
  - `FT.SEARCH ... EXPLAINSCORE` always failed with `CannotParseDouble`, so the
    option's own output was unreachable.

  See `RUSTIS_AUDIT.md` §3.2 for the full table and what the pass says about
  reviewing builders by reading them.

- **All 362 option builders in the commands layer are now covered by a test**,
  including the ones on schemas, reducers and query clauses (`FtReducer`,
  `FtSortBy`, `FtHnswVectorFieldAttributes`, `FtHybridSearch`, `FtHybridVsim`,
  `ArGrep`, `GeoSearchFrom`). Two were broken:
  - `FtReducer::first_value_by` and `first_value_by_order` never emitted the
    `BY` keyword: they sent `FIRST_VALUE 2 @name @age` and
    `FIRST_VALUE 3 @name @age DESC`, both rejected with
    `Unknown argument @age at position 1 for FIRST_VALUE`. Now
    `FIRST_VALUE 3 @name BY @age` and `FIRST_VALUE 4 @name BY @age DESC`.
  - `RestoreOptions::frequency` emitted `FREQUENCY 10.0` where `RESTORE` takes
    `FREQ 10`, so every call answered `syntax error`. See the breaking-changes
    section.

  `FT.HYBRID`'s per-clause `YIELD_SCORE_AS` is confirmed working on Redis 8.8,
  on both the `SEARCH` and the `VSIM` clause.
- **`ZAggregate` was never introduced by its `AGGREGATE` token.** `zinter`,
  `zinterstore`, `zunion` and `zunionstore` appended the bare value, so
  `zinter(keys, None, ZAggregate::Sum)` sent `ZINTER 2 k1 k2 SUM` and failed
  with `syntax error`. The whole enum was therefore unusable, and no test
  passed one — the same blind spot as the two token bugs below. Found while
  adding `ZAggregate::Count`.

- **`XPendingMessageResult::elapsed_millis` could not hold the value the server
  sends.** It was a `u64`, but `XPENDING` answers `-1` for an entry with no
  delivery to measure from — the state `xnack` puts entries in — which failed
  to deserialize. It is now an `i64`. See the breaking-changes section.

- **Two option builders emitted a token the server rejects, so the options were
  unusable.** Both came from a `rename_all` rule silently producing the wrong
  spelling, and neither had a call site anywhere in the crate — which is why no
  test caught them:
  - `ZRangeOptions::reverse()` emitted `REVERSE` instead of `REV`, so any
    `zrange` or `zrangestore` using it failed with `syntax error`.
  - `VSimOptions::with_attributes()` emitted `WITHATTRIBUTES` instead of
    `WITHATTRIBS`, so any `vsim` requesting attributes failed the same way.

  Both now have a live integration test and a wire-form test pinning the exact
  token.

- **A cluster client deadlocked after any subscription.** A subscription is
  acknowledged by a push frame, which the cluster connection hands straight to
  the network task instead of filing it as the answer to the request it sent.
  That request stayed at the head of the pending queue forever, and since
  replies are reported in order, the first reply coming from any other node
  waited behind it — the connection stopped answering entirely. The
  acknowledgement now retires the request; an error reply such as `MOVED` is
  still filed as a result, so redirections keep working.
- **`spublish` was sent to an arbitrary node.** Its shard channel was passed as
  a plain argument rather than as a key, so the cluster client could not route
  it and relied on the server's `MOVED` to find the shard — one useless round
  trip per call, on the path that then hit the deadlock above. `ssubscribe` and
  `sunsubscribe` already routed by slot.
- Eleven broken links in the published documentation, which rendered as dead
  text on docs.rs: `resp::Args` (a trait that does not exist — arguments are any
  `serde::Serialize`), `Command::arg` (it is `CommandBuilder::arg`),
  `Client::send_batch` (removed from the public API, still linked from three
  places — use `Pipeline`), `FtAggregateOptions::reduce` (it is on `FtGroupBy`),
  `FtSugGetOptions::withpayload` (plural), `TsMGetOptions::selected_labels` (the
  method is `selected_label`), `Command::compute_slots` (private), a bare
  `Serialize`, and a doubled parenthesis swallowing a URL. `cargo doc` now runs
  in CI with warnings denied, so these cannot come back unnoticed.

- **`zadd_incr` never incremented anything.** It omitted the `INCR` keyword, so
  it sent a plain `ZADD` and answered the number of elements added — `Some(0.0)`
  for an existing member — instead of the member's new score.
- **`vlinks_with_score` never asked for the scores.** It emitted the same
  `VLINKS` command as `vlinks`, so it answered bare neighbour names and any
  attempt to deserialize the scores failed.
- **`cluster_info` could not succeed.** It sent two arguments `CLUSTER INFO` does
  not accept, and `ClusterInfo` was derived as if the reply were a map when the
  server answers a text blob of `field:value` lines. The type now parses that
  text, tolerating both the counters a server omits and the fields a newer one
  adds.
- **`cluster_getkeysinslot` and `ts_decrby` threw their reply away.** Both were
  typed `()` where the server does answer something — the names of the keys in
  the slot, and the timestamp of the upserted sample.
- **`cluster_bumpepoch` could not succeed.** `CLUSTER BUMPEPOCH` answers a single
  line holding both the outcome and the resulting epoch, as in `STILL 84`, while
  `ClusterBumpEpochResult` was derived as a plain lowercase enum tag. Every call
  failed with `unknown variant`. The type now parses that line and carries the
  epoch.
- **Eight command methods were effectively uncallable.** They carried a generic
  type parameter that appeared nowhere in their signature, so it could not be
  inferred and reaching them required a turbofish naming a type that was never
  used: `cluster_addslots`, `cluster_addslotsrange`,
  `cluster_count_failure_reports`, `cluster_delslots`, `cluster_delslotsrange`,
  `cluster_forget`, `ft_profile_search` and `sentinel_failover`.
- `ClusterInfo`, `ClusterState`, `ClusterBumpEpochResult`, `ClusterLinkInfo` and
  `ClusterLinkDirection` now derive `Debug`, and the two enums `PartialEq`, like
  the other types decoded from a cluster reply.
- **Concurrent pipelines could return each other's replies.** `pending_responses`
  was shared across batches instead of being scoped to one, corrupting results
  under concurrent pipelined use.
- **Iterating a collection past its fifth element could yield corrupted values**
  (the fallback re-parser produced ranges against the wrong buffer base). The
  tape indexes every element uniformly, removing that path by construction.
- **A large reply arriving in many TCP segments was re-parsed from the start on
  every segment**, making decode cost quadratic in the number of segments. The
  decoder now keeps resume state, so the cost is linear in the reply size.
- Cluster: a redirection now retries only the sub-requests that were redirected
  rather than the whole split command; an `ASK` redirection to a node absent from
  the topology is followed; a per-shard failure surfaces as an error on that
  request instead of reconnecting the entire cluster; requests that can never be
  fulfilled are purged from the in-flight set instead of hanging; aggregates over
  shards of unequal length are rejected; and a transaction spanning several slots
  is refused before being sent rather than failing server-side.
- Reconnection: pub/sub bookkeeping is rebuilt when in-flight messages are
  replayed, in-flight unsubscriptions are dropped rather than resubscribed,
  non-retryable in-flight messages are purged, protocol decode errors trigger a
  reconnect instead of being swallowed, the reconnect delay is capped, and a
  per-message retry counter fails a message past `Config::max_command_attempts`
  instead of replaying it forever.
- Pub/sub: local subscription tracking is kept until the server confirms the
  unsubscribe, an undecodable event no longer terminates the push stream, and a
  subscription confirmation that does not match what was requested is surfaced as
  an error.
- Client cache: client-side tracking is re-armed and the cache purged on
  reconnection; the `MONITOR` parser is quote-aware; an insert racing an
  invalidation can no longer store a stale entry; and a zero-argument key
  returns an error instead of panicking.
- `command_timeout` now applies to `subscribe` and `monitor`.
- Pipelines: an empty pipeline resolves as an empty batch instead of surfacing an
  opaque channel-canceled error, and a single forgotten command has its response
  dropped as it would in a multi-command batch.
- Configuration URLs: IPv6 addresses and percent-encoded credentials are parsed
  correctly, `MOVED`/`ASK` addresses are split at the last colon (IPv6), and
  Sentinel discovery is bounded and resilient.
- Commands: `MSETEX` and `HPERSIST` send their mandatory count argument;
  `HSETEX` no longer panics on an odd field/value list; the `CLIENT REPLY SKIP`
  typo in command-kind detection is corrected; and a command-builder
  serialization error is deferred to send time instead of panicking during the
  build.
- **Search: several clauses declared the wrong argument count and were rejected
  by the server.** `FT.AGGREGATE`'s `LOAD` and `FT.SEARCH`'s `RETURN` announced
  the number of attributes where Redis counts arguments, so renaming an attribute
  (`FtAttribute::new("a").r#as("b")`) produced `LOAD 1 a AS b` and failed with
  `Unknown argument AS`. `PARAMS` announced the number of pairs instead of twice
  that, so any query with more than one parameter failed. `FT.SPELLCHECK`'s
  `TERMS` was prefixed with a count the syntax does not take. These counts are
  now derived from what is actually written rather than from the collection's
  length, so they cannot disagree with it.
- `resp::Value`'s `Boolean` compares by value rather than by discriminant.
- Closing the last `Client` clone closes the connection race-free.
- A collection element that fails to parse mid-iteration now surfaces an error
  instead of silently truncating the iteration.
- `Debug` on a response renders the decoded reply rather than the internal tape.
- The deserializer's two string entry points agree. A reply readable as a `String`
  is now equally readable where serde asks for a borrowed string — a struct field
  name or an enum variant name — instead of the target type deciding whether the
  command succeeds.

[0.24.0]: https://github.com/dahomey-technologies/rustis/compare/0.23.0...0.24.0
[0.23.0]: https://github.com/dahomey-technologies/rustis/compare/0.22.0...0.23.0
[0.22.0]: https://github.com/dahomey-technologies/rustis/compare/0.21.0...0.22.0
[0.21.0]: https://github.com/dahomey-technologies/rustis/compare/0.20.0...0.21.0
[0.20.0]: https://github.com/dahomey-technologies/rustis/compare/0.19.3...0.20.0