tower-http-cache 0.6.0

Tower-compatible caching layer with pluggable backends (in-memory, Redis, and more)
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
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.6.0] - 2026-08-27

### Changed

- **The on-the-wire cache format changed, and `tags` are now part of it.**
  Entries stored in Redis are now written as a 21-byte versioned envelope
  (`"THC"` magic, format byte, codec byte, and the expiry/stale timestamps as
  little-endian `u64`) followed by a `postcard`-encoded payload. Previously the
  Redis and memcached backends used two *different* undocumented `bincode 1`
  layouts.

  **Rolling back to 0.5.x is safe.** A 0.5.x binary encountering a 0.6.0 entry
  gets a clean decode error, which the cache layer already treats as a miss.
  You get a cold cache, not corrupted responses. (This is exactly what the
  envelope header buys: without it, an old reader would have silently accepted
  the new bytes and ignored the trailing remainder.)

  **Upgrading is safe and does not cold-start your cache.** 0.6.0 reads
  0.5.x-written entries transparently via the `legacy-bincode1-read` feature,
  which is **on by default**. Entries are rewritten in the new format as they
  are refreshed. The legacy reader is removed in 0.7.0; by then every 0.5.x
  entry will long since have aged past its TTL.

  The envelope is documented in `tower_http_cache::codec::envelope`, and byte 4
  records which codec wrote the entry. Entries written by one codec are
  reported as a miss rather than handed to another.

- **`BincodeCodec` is renamed `PostcardCodec`.** A deprecated type alias keeps
  `BincodeCodec` working through 0.6.x; it is removed in 0.7.0. Because
  `RedisBackend<C = PostcardCodec>` resolves through the alias, most downstream
  code is untouched.

- **`CacheCodec` gained a defaulted `CODEC_ID` associated constant.** It names
  the codec in the envelope header. The default is `0x80`, the start of the
  range reserved for codecs implemented outside this crate, so existing
  downstream `impl CacheCodec` blocks keep compiling unchanged.

- Redis no longer double-encodes. 0.5.x encoded the entry into a `Vec<u8>`,
  then encoded that vector into a second `Vec<u8>`; the envelope removes one
  full copy of the body on every `set`.

- **BREAKING: `RedisBackend` now reports that it has no tag index.**
  `get_keys_by_tag` and `list_tags` return `Err(CacheError::Unsupported(..))`,
  and the trait's defaulted `invalidate_by_tag` propagates it. Previously all
  three inherited the trait defaults and answered `Ok(vec![])` / `Ok(0)`, so a
  caller could not tell "nothing carried that tag" from "this backend cannot do
  tags at all" — which is precisely the population being silently failed today.
  If you call `invalidate_by_tag` on Redis and ignore the count, you now get an
  error; handle it, or check `CacheError::is_unsupported`.

  `MultiTierBackend` deliberately tolerates this: a tier that reports
  `Unsupported` contributes nothing rather than failing the call, so the usual
  `InMemoryBackend` over `RedisBackend` arrangement keeps working off the L1
  index. Only if *neither* tier has an index does the error propagate.

- **BREAKING: `CacheError` is `#[non_exhaustive]` and gained an `Unsupported`
  variant.** Exhaustive matches on it need a `_` arm. Both changes are breaking
  and are made together, in the release that is breaking anyway, so future
  variants are additive. `CacheError::is_unsupported()` is provided so callers
  do not have to match at all.

- **`redis` `0.32.7` -> `1.6.0`.** No source changes were needed in this crate:
  the `ConnectionManager` path and construction, `AsyncCommands::{get, set_ex,
  del}`, `set_ex`'s `u64` TTL, `Option<Vec<u8>>` via `FromRedisValue`,
  `#[from] redis::RedisError`, and the `aio` / `tokio-comp` /
  `connection-manager` features are all unchanged. The documented 1.0 breaks
  miss this crate: it never iterates, implements no `FromRedisValue`, and uses
  `tokio-comp` rather than `async-std`.

  The `redis-backend` MSRV floor is **unchanged at 1.88**. It moved from being
  transitive (`url` -> `idna` -> `icu_*`) to being declared outright — redis
  1.6.0 sets `rust-version = "1.88"` — but the number and the split CI jobs are
  the same.

  **One behavioural change to be aware of, and it is the reason this is under
  Changed rather than a dependency-bump footnote.** redis 1.x changed
  `ConnectionManagerConfig`'s defaults from *no timeouts* to a **500 ms
  response timeout and a 1 s connection timeout**, and
  `Client::get_connection_manager()` uses those defaults. Verified in both
  crates' sources: 0.32.7 had `DEFAULT_RESPONSE_TIMEOUT = None` and
  `DEFAULT_CONNECTION_TIMEOUT = None`; 1.6.0 has `Some(500ms)` and `Some(1s)`.

  For an HTTP response cache the values are whole response bodies, so a large
  entry over a loaded or cross-AZ Redis can exceed 500 ms — and every such
  `get` or `set` then fails with `CacheError::Redis` instead of succeeding
  slowly. A slow cache silently becomes a broken one.

  `RedisBackend::new` takes an already-constructed `ConnectionManager`, so this
  crate cannot choose for you. Choose explicitly at construction:

  ```rust
  use redis::aio::ConnectionManagerConfig;

  let config = ConnectionManagerConfig::new()
      .set_response_timeout(Some(Duration::from_secs(10)))
      .set_connection_timeout(Some(Duration::from_secs(5)));
  let manager = client.get_connection_manager_with_config(config).await?;
  let backend = RedisBackend::new(manager);
  ```

  Pass `None` to restore the 0.5.x behaviour of no timeout at all. A generous
  bound is usually the better answer; pick it against your body sizes, not
  against the client library's default. `RedisBackend::new`'s documentation
  carries this note, and both Redis examples and the Redis integration tests
  now set the timeouts explicitly rather than inheriting them.

- **BREAKING: `CacheBackend` uses native `async fn` in traits (RPITIT) and no
  longer depends on `async-trait`.** Every method is now declared as
  `fn name(..) -> impl Future<Output = ..> + Send`. The `+ Send` is required
  because the cache layer boxes backend futures into a `Send` future.

  **If you implement `CacheBackend` yourself, the migration is one line per
  impl:** delete the `#[async_trait]` attribute. Your method bodies stay exactly
  as they are — `async fn` in an impl block is still `async fn`.

  ```diff
  -#[async_trait]
   impl CacheBackend for MyBackend {
       async fn get(&self, key: &str) -> Result<Option<CacheRead>, CacheError> {
           // unchanged
       }
   }
  ```

  Leaving `#[async_trait]` in place produces `error[E0195]: lifetime parameters
  or bounds on method 'get' do not match the trait declaration`, once per
  method. Verified both directions against a real downstream crate: the stale
  impl fails with exactly that, and deleting the attribute — changing nothing
  else — compiles. The same one-line change applies if you override the
  defaulted methods (`get_keys_by_tag`, `invalidate_by_tag`,
  `invalidate_by_tags`, `list_tags`).

  There are **no known downstream implementors**: `tower-http-cache` has zero
  reverse dependencies on crates.io.

  This removes a boxed allocation per backend call. `CacheBackend` was already
  non-dyn-compatible because of its `Clone` supertrait, so no working code used
  it as a trait object; if you somehow held one, you will need a concrete type
  or your own boxing wrapper.

  MSRV is unchanged — RPITIT stabilised in Rust 1.75, well below this crate's
  1.85 floor.

### Added

- **`legacy-bincode1-read` feature, on by default.** Reads cache entries
  written by 0.5.x so an upgrade does not cold-start a production cache. The
  reader is hand-written against the bincode 1 layout and pulls no dependency,
  so leaving it enabled costs only dead code; it exists so 0.7.0 can delete one
  module and one feature entry. Turning it off is safe at any time and only
  costs a cold cache. Bytes no decoder recognises read as a miss, never an
  error and never a panic, and are never deleted.

  Backed by golden fixtures under `tests/fixtures/v0_5_1/`: real bytes produced
  by the published 0.5.1 code path, decoded field by field.

### Removed

- **BREAKING: the `memcached-backend` feature and `MemcachedBackend` are
  removed.** Along with them go `MemcachedBackendBuilder`, `PoolState`,
  `MemcachedConnectionManager`, the `memcached_production` example, and the
  `async-memcached` and `bb8` dependencies.

  **This is breaking on paper and cannot break a working deployment, because
  there were none.** The backend never returned a cache hit in its entire
  existence. `MemcachedRecord` serialized `CacheEntry` whole, and
  `CacheEntry`'s `version_serde` helper wrote the version as a four-byte `i32`
  while its `deserialize` read a one-byte `u8`. Every
  `bincode::deserialize::<MemcachedRecord>` therefore failed on data the
  backend itself had just written, and the cache layer — which already treats a
  backend `Err` as a miss — served every single `get` as a miss. A deployment
  using it had a write-only cache with a 100% miss rate that looked like it was
  working. Confirmed by round-tripping a `MemcachedRecord` through the real
  published 0.5.1 crate against `bincode 1.3.3`, and reproduced independently.

  It is *removed*, not deprecated. There is no migration path to write, because
  there is no working behaviour to migrate: use `RedisBackend` or
  `InMemoryBackend`.

  Removing it takes a large advisory-bearing subtree with it. `async-memcached`
  declares `toxiproxy_rust` — a test fixture — as a normal dependency, which
  dragged in `reqwest 0.11` -> `hyper 0.14` -> `h2 0.3`, plus `native-tls` ->
  `openssl-sys`. Gone from the graph: `async-memcached`, `bb8`,
  `toxiproxy_rust`, `reqwest`, `h2`, `fxhash`, `rustls-pemfile`, `openssl-sys`.
  Building this crate no longer needs `libssl-dev` or `pkg-config`, and CI no
  longer installs them.

  With this and the `bincode` removal, all four suppressed advisories
  (RUSTSEC-2025-0141, RUSTSEC-2026-0258, RUSTSEC-2025-0057, RUSTSEC-2025-0134)
  now report `advisory-not-detected`. The `deny.toml` entries are deleted in a
  later pass.

  The legacy reader's memcached decoder is removed too: with the backend gone
  nothing can produce those bytes, so keeping a hand-rolled byte parser
  reachable from public API bought nothing. `LegacyShape` disappears with it and
  `codec::envelope::read_stored` now takes two arguments. **The Redis legacy
  path is untouched.**

- **`async-trait` is no longer a dependency.** See the `CacheBackend` RPITIT
  entry under *Changed*. It was the last blocker: `bb8` was the only other user
  of it in this crate, and `bb8` left with the memcached backend.
  `cargo tree -i async-trait` finds no match under any feature combination.

- **`bincode` is no longer a dependency.** RUSTSEC-2025-0141 marked it
  permanently unmaintained in December 2025 with no patched release.

  To be precise about what this is and is not: RUSTSEC-2025-0141 is
  `informational = "unmaintained"`, **not a vulnerability**. There is no known
  exploit in `bincode 1.3.3`. The reason to move is that a permanently-ignored
  advisory trains people to ignore advisories.

  The reader for 0.5.x-format entries is hand-written against the (fixed,
  simple) bincode 1 layout rather than calling `bincode`, which is what allowed
  the dependency to be dropped in the same release that keeps backward
  compatibility. `cargo tree -i bincode` finds no match under any feature
  combination, including `--all-features`.

- **Note for anyone tracking dependabot: do not merge a `bincode 3.0` bump.**
  `bincode` 3.0.0 is a tombstone release. Its entire `src/lib.rs` is
  `compile_error!("https://xkcd.com/2347/");` — it was published only to signal
  the crate's status, since crates.io has no way to archive a crate. It has no
  features and no dependencies, and bumping to it does not compile. The last
  functional release is 2.0.1, which is covered by the same advisory (it has no
  version bound), so it was not a useful destination either.

### Fixed

- **`RedisBackend` serialized every cache operation through a single mutex.**
  The connection was held as `Arc<Mutex<ConnectionManager>>`, but
  `ConnectionManager` is `pub struct ConnectionManager(Arc<Internals>)` with
  `#[derive(Clone)]` and multiplexes internally — the mutex defeated the
  multiplexing it was wrapping, turning a pool into a queue. It is now cloned
  per operation, which is what the type is designed for.

  This is a pre-existing bug, not a consequence of the redis upgrade: the
  definition is identical in 0.32.7 and 1.6.0, so 0.5.x was affected too. The
  field is private and `RedisBackend::new` still takes a `ConnectionManager`,
  so there is no public API change, and `RedisBackend` remains
  `Send + Sync + Clone + 'static` (pinned by a test).

- **`CacheEntry`'s derived `Serialize`/`Deserialize` could not round-trip under
  a non-self-describing format.** `version_serde::serialize` wrote its
  discriminant as an `i32` — the match arms had no type annotation, so the
  literals defaulted to `i32` — while `version_serde::deserialize` read a `u8`.
  Four bytes written against one byte read. Fixed with `let v: u8 = ...`,
  pinned by a test that fails without it.

  This is the defect that made the memcached backend a no-op; see *Removed*.

- **`CachePolicy::with_tag_extractor` did nothing.** `CachePolicy::extract_tags`
  had no callers anywhere in the crate: both places where the layer builds a
  `CacheEntry` used `CacheEntry::new(..)` and never attached tags. Tags
  configured through the middleware — the mechanism the README documents —
  never reached any backend, including the in-memory one. The layer now calls
  `extract_tags` on both the store and the refresh path and attaches the
  result.

  This is inert unless you opted in: `TagPolicy::enabled` defaults to `false`,
  and `extract_tags` returns an empty vector when it is. There is a test for
  that inertness as well as for the fix.

- **Cache tags were silently dropped by the Redis codec.** `BincodeCodec::encode`
  serialized a private struct with no `tags` field, and `decode` rebuilt the entry
  through `CacheEntry::new`, which always sets `tags: None`. Tags never crossed the
  Redis wire. They now do.

- The `cache_benchmarks` bench now declares `serde` in its `required-features`.
  It uses the codec, which lives behind that feature, so
  `cargo bench --no-default-features --features in-memory` did not build.

### Known issues

- **Tag-based invalidation works only on `InMemoryBackend` (and
  `MultiTierBackend` over one).** `RedisBackend` implements `get`, `set` and
  `invalidate` only; it keeps no reverse tag index, so `invalidate_by_tag` has
  nothing to iterate. 0.6.0 puts tags *on the wire*, which is a prerequisite
  for fixing this and means a `CacheRead` from Redis now carries the tags the
  entry was stored with — but it does not add a distributed tag index.
  `TagIndex` also remains process-local (`Arc<DashMap<..>>`), so even on the
  in-memory backend, invalidating a tag clears only the calling process's
  index.

  As of 0.6.0 `RedisBackend` reports this explicitly rather than returning a
  silent `Ok(0)`. A Redis-native tag index (Redis sets, opt-in, with TTL-based
  garbage collection of stale members) is planned for 0.7.0.

## [0.5.2] - 2026-08-26

A dependency-reduction and edition release. No wire-format change, no public
API change, no cache invalidation -- an existing cache stays readable across
the upgrade.

Two of these were deferred to 0.6.0 in the 0.5.1 notes. On inspection both
turned out to be non-breaking for this crate, so they ship here instead and
0.6.0 keeps its scope: the `bincode` migration and the backend bumps.

### Removed

- **`chrono` is gone entirely.** It was pulled in for one job -- rendering a
  `SystemTime` as an RFC 3339 / ISO 8601 string -- across four call sites, with
  no parsing, no local time and no timezone handling anywhere in the crate.
  That is now a private `time_fmt` module (civil-from-days, ~90 lines) and one
  fewer dependency, along with its `iana-time-zone` and `num-traits` subtree.

  These strings go into ML training logs (`CacheEvent::log`) and admin API JSON
  responses (`/health`, hot keys, stats), so the bar was **byte-identical
  output**, not merely correct output. A change in shape would be a silent
  break for anything parsing them. The replacement was validated by
  differential-testing it against `chrono` 0.4.45 over **2,364,037 cases**
  spanning chrono's entire representable range (years -262143 to +262142),
  every fractional-second precision, leap days, year boundaries, pre-epoch
  instants and the range boundaries: zero mismatches.

  Three chrono behaviours turned out to be load-bearing, and are preserved
  deliberately rather than tidied up, each pinned by a regression test:

  - `to_rfc3339` spells the UTC offset `+00:00`, **not** `Z`. (The ML log
    timestamp is a different format string and does end in `Z`.)
  - Its fractional-second precision is *variable* -- the shortest lossless
    choice of zero, 3, 6 or 9 digits. A fixed-width fraction would have been
    wrong for most inputs.
  - Extended years pad the magnitude to four digits after the sign (`-0001`,
    `+10000`), not five.

  The admin stats serializer's `secs as i64` was an unchecked cast, so a `u64`
  above `i64::MAX` wrapped to a negative timestamp instead of saturating. That
  is reproduced exactly rather than "fixed", because fixing it would change
  output.

- **`futures-util` is no longer a runtime dependency.** The library imported it
  for exactly one thing: `BoxFuture` as `CacheService::Future`. That is now a
  local `Pin<Box<dyn Future<Output = T> + Send + 'static>>` alias -- the same
  concrete type, so the associated type is unchanged for callers and this is
  not an API break. It moves to `[dev-dependencies]` rather than being deleted,
  because `tests/integration_cache.rs` still uses `futures_util::stream::unfold`
  to build a chunked body. It remains in the tree transitively via `moka` and
  `tower`; what changes is that this crate no longer declares it.

### Changed

- `edition` `2021` -> `2024`. MSRV is already `1.85`, exactly the edition-2024
  floor, so this costs no compatibility. `cargo fix --edition` was run across
  the feature matrix and every hunk reviewed by hand; the only substantive
  changes were three `if let (Some(ref x), ...)` patterns over tuples of
  references dropping their now-rejected `ref`, plus rustfmt's 2024 import
  ordering. One suggested rewrite was **rejected**: `cargo fix` converted
  `InMemoryBackend::get`'s `if let`/`else` into a `match` on account of the
  changed `if let` temporary scope, but moka's `get` returns an owned
  `Option<StoredEntry>` -- no guard, no lock -- and the binding moves the value
  out, so the rescope has nothing to observe. Similarly,
  `clippy::let_and_return` began firing on `StampedeGuard::acquire_handle`
  because clippy suppresses that lint pre-2024 when a `let` affects drop order;
  the binding pins the drop of a `DashMap` shard write guard and is kept, with
  a scoped allow, rather than letting lock release depend on edition-specific
  tail-expression rules in a path that then awaits.
- `sha2` `0.10` -> `0.11` (closes #9). The `Digest` trait path,
  `new`/`update`/`finalize` and the `hex::encode` of the output all survived the
  `digest` 0.11 bump unchanged, so `logging::hash_key` needed no edits. **Digest
  values are identical, so no cache invalidation.** sha2 0.11 declares
  `rust-version = 1.85`, exactly our floor.
- Dev-dependency `criterion` `0.7` -> `0.8` (closes #15). No source changes
  were needed: the bench already used `std::hint::black_box` rather than the
  deprecated `criterion::black_box`. criterion 0.8 declares `rust-version 1.86`,
  above our 1.85 floor, which is fine because dev-dependencies are not built by
  the MSRV job's `cargo build` -- the same allowance the 1.85 job already
  documents.

### Known issues

Unchanged from 0.5.1, and all four `deny.toml` suppressions still apply:

- **`bincode 1.3.3` is unmaintained (RUSTSEC-2025-0141).** Still deferred to
  0.6.0 -- it defines the on-disk and on-wire encoding for the Redis and
  Memcached backends, so migrating invalidates every live cache entry, which is
  not a patch-release change. It is reachable from the default feature set; a
  `--no-default-features --features in-memory` build still does not pull it at
  all, which remains the recommended configuration for anyone who does not need
  a shared backend.
- **`memcached-backend` is still NOT recommended for production**, for the same
  three advisories reached through `async-memcached` -> `toxiproxy_rust` ->
  `reqwest 0.11` (RUSTSEC-2026-0258 h2 DoS, RUSTSEC-2025-0134, RUSTSEC-2025-0057),
  and it still drags in a second HTTP stack requiring `libssl-dev` and
  `pkg-config`.
- `tests/integration_cache.rs::concurrent_requests_share_refresh_work` remains
  timing-sensitive under heavy machine load. Measured over ~9,000 runs at
  6-way parallelism: **2.2% failure rate on this release against 2.8% on
  0.5.1**, so it is pre-existing and marginally improved, not a regression. The
  0.5.1 fix removed the ~50% flakiness; what is left is the deterministic
  coalescing assertion timing out when the 300 ms stale window is missed on a
  saturated host.

## [0.5.1] - 2026-08-25

### Fixed

- **`governor` was declared but never used.** The `admin-api` feature pulled
  `governor 0.6` and its entire rate-limiting subtree; nothing in `src/`, `tests/`,
  `examples/` or `benches/` referenced it. Removed. Same class of finding as the
  `cargo-udeps` sweeps in 0.4.2 / 0.4.3.
- **The `metrics` bench never compiled.** `benches/cache_benchmarks.rs` imported
  `metrics_exporter_null`, a crate that does not exist on crates.io, so
  `cargo bench --features metrics` failed with E0432. Now uses
  `metrics_util::debugging::DebuggingRecorder`.
- **Declared MSRV was wrong for the default build.** `rust-version` said `1.75.0`, but
  the non-optional `uuid = "1.0"` resolves to 1.25, which declares `1.85.0` -- and with
  no committed lockfile there was nothing holding it back. `axum` 0.8 (1.80), `redis`
  0.32 (1.80) and dev `criterion` 0.7 (1.80) compound it. Now **`1.85` for the core and
  `1.88` for the shared backends** -- `redis-backend` and `memcached-backend` both reach
  `url` -> `idna` -> `icu_*`, which declare 1.88. With no committed lockfile a consumer
  resolving fresh gets those versions too, so pinning them in our own lock would hide the
  constraint rather than fix it. Both floors are enforced by separate CI jobs. The split
  was found by the new MSRV job on the first push.
- **The crate did not build without the `serde` feature, and `serde` was not properly
  gated.** `codec.rs`, `logging.rs`, `request_id.rs`, `admin/routes.rs` and
  `admin/stats.rs` all `use serde` unconditionally, so `--no-default-features` failed with
  six unresolved imports. Rather than making `serde` mandatory, the serde-shaped surfaces
  are now gated on it properly:

  - `codec` (backend serialization) is behind `serde`
  - `admin` is behind `admin-api` -- **the module was not gated at all**, only its
    re-export was, so the two most serde-heavy files in the crate compiled into every build
  - `CacheEvent` and `log_cache_operation` are behind `serde`; they carry a
    `serde_json::Value` and emit JSON, so serde is genuinely load-bearing there.
    `MLLoggingConfig` and `CacheEventType` stay ungated -- `CachePolicy` embeds the former,
    and neither needs serialization to be useful.
  - `RequestId`'s derives are now `cfg_attr`'d; they were ornamental.
  - `redis-backend`, `memcached-backend` and `admin-api` each enable `serde`, because for
    those it really is load-bearing.

  **This takes `bincode` out of the default-adjacent dependency graph.** A build of
  `--no-default-features --features in-memory` no longer pulls `bincode` at all, so those
  users are not exposed to RUSTSEC-2025-0141 (see Known issues). Default builds are
  unchanged -- `serde` is still a default feature.
- **Every integration test, example and bench now declares the backend feature it needs.**
  Once `in-memory` and `serde` became real gates, `cargo test` on a reduced feature set
  failed to resolve `InMemoryBackend` / `CacheEvent` in four integration tests, five
  examples, the benches, and two internal `#[cfg(test)]` modules. All now declare
  `required-features` or are `#[cfg]`-gated. Found by the new CI job -- the earlier local
  sweep used `cargo check`, which does not compile test modules.
- **`backend::memory` was not gated on `in-memory`.** Its siblings `redis` and
  `memcached` were, but `memory` (which needs `moka`) was not, so `--no-default-features`
  failed on an unresolved `moka`. The module, its `prelude` re-export, and the
  `CacheLayer::new_in_memory` constructor are now behind `#[cfg(feature = "in-memory")]`,
  consistent with the other backends.
- **`concurrent_requests_share_refresh_work` was ~50% flaky.** Measured at 6/20 passes on
  the released code. It asserted that of two racing requests, one receives the stale body
  and the other the refreshed one -- but which body a given racer observes is not part of
  the stale-while-revalidate contract; it depends on how the tasks interleave with the
  background refresh. The test now asserts what the contract actually guarantees: each
  response is one of the two legitimate bodies, **and the origin is called exactly twice**,
  which is the real single-flight coalescing property. That second assertion is unchanged
  and still deterministic. Now 25/25.
- `examples/redis_smoke.rs` used the deprecated `Client::get_tokio_connection_manager`;
  switched to `get_connection_manager`. Cleared unused imports in
  `examples/chunk_cache_demo.rs` and clippy warnings in `src/admin/stats.rs`,
  `src/streaming.rs` and the benches so `clippy -D warnings` passes.

### Changed

- `dashmap` `5.5` -> `6.2`. Technically a major, shipped in a patch because `dashmap` is
  not part of this crate's public API -- `src/tags.rs` and `src/chunks.rs` use only
  `DashMap::new`, `get`, `insert` and `entry`, all unchanged across 5 -> 6. Staying on
  5.x forced a duplicate `dashmap` into any tree that also depended on a 6.x consumer.
- Dependency floors raised to current, all semver-compatible: `tokio` `1.40` -> `1.53`,
  `http` `1.3` -> `1.5`, `http-body` `1.0` -> `1.1`, `http-body-util` `0.1` -> `0.1.5`,
  `bytes` `1.7` -> `1.12`, `moka` `0.12` -> `0.12.16`, `tokio-util` `0.7` -> `0.7.19`,
  `flate2` `1.0` -> `1.1`, `uuid` `1.0` -> `1.25`, `chrono` `0.4` -> `0.4.45`,
  `futures-util` `0.3` -> `0.3.34`, `tower` `0.5` -> `0.5.3`, `axum` `0.8` -> `0.8.9`,
  `tracing-subscriber` `0.3` -> `0.3.23`.

### Added

- CI (`.github/workflows/ci.yml`): stable + beta tests across the feature matrix
  (including `--no-default-features`), MSRV jobs for 1.85 and 1.88, `fmt` +
  `clippy -D warnings`, `cargo doc -D warnings`, and `cargo deny check`.
- `deny.toml`, with the bincode advisory ignore documented inline.

### Known issues

- **`bincode 1.3.3` is unmaintained (RUSTSEC-2025-0141).** Filed 2025-12-16; upstream has
  ceased development permanently and there is no patched release. `bincode` is reachable
  from the default feature set, so downstream `cargo audit` runs will flag it -- though as
  of this release a `--no-default-features --features in-memory` build does not pull it at
  all, which is the recommended configuration for anyone who does not need a shared backend. It defines
  the on-disk and on-wire encoding for the Redis and Memcached backends, so migrating to
  bincode 3 or postcard invalidates every live cache entry -- scheduled for 0.6.0, with a
  documented ignore in `deny.toml` until then.
- **`memcached-backend` is NOT recommended for production in this release.** One
  dependency brings three advisories, one of them a live vulnerability:
  `async-memcached` declares `toxiproxy_rust` -- a *test fixture* -- as a normal
  dependency, which pulls `reqwest 0.11` -> `hyper 0.14` -> `h2`:
  - **RUSTSEC-2026-0258**`h2` unbounded empty DATA frames (**denial of service**)
  - RUSTSEC-2025-0134 — `rustls-pemfile` unmaintained
  - RUSTSEC-2025-0057 — `fxhash` unmaintained

  All three are suppressed in `deny.toml` with comments naming this cause, so the rest of
  the tree stays auditable; they are to be deleted the moment upstream moves
  `toxiproxy_rust` to `[dev-dependencies]`. The feature is opt-in and off by default, and
  no other feature is affected. **Confirmed still present in `async-memcached` 0.7.0**, so
  the planned bump does not resolve it.

- **`memcached-backend` also drags in a second HTTP stack.** `async-memcached` depends on
  `toxiproxy_rust` unconditionally, which pulls `reqwest 0.11` -> `hyper 0.14` ->
  `native-tls` -> `openssl-sys`. Enabling the feature therefore requires `libssl-dev` and
  `pkg-config` on the build host and duplicates the entire HTTP stack. Confirmed still
  present in `async-memcached 0.7.0`, so the planned bump does not resolve it; this needs
  an upstream fix (moving `toxiproxy_rust` to `[dev-dependencies]`) or a different client.
  The `memcached-backend` feature is excluded from CI for this reason.

### Notes

- Deferred to 0.6.0: the `bincode` migration above, `redis` `0.32.7` -> `1.6` (MSRV 1.88),
  `sha2` `0.10` -> `0.11` (used in `src/logging.rs`; moves to the `digest` 0.11 traits),
  `async-memcached` `0.5` -> `0.7` with `bb8` `0.8` -> `0.9`, and dev `criterion`
  `0.7` -> `0.8`.


## [0.5.0] - 2026-03-31

### Fixed
- **Content-type pattern matching used substring instead of exact match**`"pdf"` in exclusion list would match any MIME type containing "pdf". Now uses exact match with optional parameter suffix (e.g., `application/json; charset=utf-8` matches pattern `application/json`).
- **`force_cache_content_types` doc claimed "regardless of size"** — size limits always applied. Fixed doc to accurately describe behavior: bypasses content-type exclusions only.
- **`unsafe impl Sync for SyncBoxBody` safety comment was incorrect** — claimed "single-threaded" context which is wrong for Tower. Updated with correct safety justification.
- **README documented non-existent `admin_router()` and `AdminConfig::builder()` API** — updated to match actual `AdminConfig::new().with_*()` API.
- **README referenced non-existent examples and `middleware` feature flag** — updated to list actual examples.
- **README installation instructions referenced version `"0.3"`** — updated to `"0.5"`.
- **`._*` macOS resource fork files were included in crates.io package** — added to `exclude` in Cargo.toml and `.gitignore`.
- **CHANGELOG footer links missing for v0.4.0–v0.4.3**.

### Changed
- `StreamingDecision::StreamThrough` is now `#[doc(hidden)]` (reserved for future implementation).
- Bumped version to 0.5.0 due to content-type matching behavior change (may affect users relying on substring matching).

## [0.4.3] - 2025-11-10

### Removed
- **Unused dependencies identified by `cargo-udeps`**:
  - Removed `serde_bytes` from main dependencies (never used in codebase)
  - Removed `hyper` from dev-dependencies (never used in tests/benches/examples)
  - Reduces dependency count and compilation time
  - No functional changes - all 137 tests passing

### Note
- Dev dependencies `axum`, `redis`, and `tracing-subscriber` flagged by `cargo-udeps` are false positives - they are used in examples and tests

## [0.4.2] - 2025-11-10

### Fixed
- **Removed unused dependency**: Removed `sync_wrapper` crate that was added in v0.4.1 but not used
  - Reduces dependency bloat and compilation time
  - No functional changes - implementation uses manual `unsafe impl Sync` instead
  - Still uses existing `pin_project_lite` for safe pinning

## [0.4.1] - 2025-11-10

### Fixed
- **Axum compatibility**: Fixed `Sync` trait bound issue with response bodies
  - Implemented custom `SyncBoxBody` type that wraps `BoxBody` and manually implements `Sync`
  - Uses `pin_project_lite` for safe pinning and `HttpBody` trait delegation
  - Uses same pattern as Axum's own `Body` type (`unsafe impl Sync`)
  - Resolves compilation errors when using the cache layer with Axum routers
  - Zero-cost abstraction - same performance as underlying `BoxBody`
  - Updated `CacheEntry::into_response()` to return `Response<SyncBoxBody>`
  - All 137 tests passing with new body type

### Changed
- Response body type now implements both `HttpBody` and `Sync` for Axum compatibility
- Examples updated to demonstrate Axum integration patterns
- No new dependencies added - uses existing `pin_project_lite`

## [0.4.0] - 2025-11-10

### Added

#### Chunk Caching for Large Files
- **Memory-efficient range request handling**: Chunk-based caching system for large files
  - New `chunks` module with `ChunkCache` and `ChunkedEntry` types
  - Automatic file splitting into fixed-size chunks (default: 1MB)
  - Efficient range request serving from chunk cache
  - `ChunkMetadata` for storing HTTP metadata separately from chunks
  - Configurable via `StreamingPolicy::enable_chunk_cache`
  - Per-chunk storage and retrieval for minimal memory footprint
  - Support for partial file caching (only cache accessed ranges)
  - Coverage tracking to monitor chunk cache completeness
  - Integrated with `CacheLayer` and `CacheService`
  - Automatic 206 Partial Content response generation
  - Compatible with video streaming and large file downloads
  - 40+ comprehensive chunk caching tests
  - Production example: `chunk_cache_demo`

#### BB8 Connection Pooling for Memcached
- **Production-grade connection management**: BB8 async connection pooling
  - `MemcachedBackend::builder()` with pooling support
  - Configurable pool size (min/max connections)
  - Connection timeout and retry logic
  - Health checks and automatic reconnection
  - Pool state monitoring (connections, idle, etc.)
  - Graceful shutdown and connection cleanup
  - Async-safe with tokio integration
  - Production example: `memcached_production`

#### True Streaming Pass-Through (Zero-Copy)
- **BoxBody architecture**: Complete replacement of `Full<Bytes>` with `BoxBody<Bytes, BoxError>`
  - Eliminates unnecessary buffering for large responses
  - Zero-copy streaming for excluded content types
  - Preserves `Content-Length` headers during streaming
  - Memory efficient handling of multi-GB responses
  - Full backward compatibility with existing middleware

#### HTTP Range Request Support
- **RFC 7233 compliant range handling**: Proper support for partial content requests
  - New `range` module with `parse_range_header()` utilities
  - `RangeRequest` type for parsing "bytes=start-end" specifications
  - `RangeHandling` policy enum (PassThrough/CacheFullServeRanges/CacheChunks)
  - Automatic detection of 206 Partial Content responses
  - Configurable behavior via `StreamingPolicy::range_handling`
  - Content-Range header generation and parsing
  - 15+ comprehensive range request tests

#### Memcached Backend
- **High-performance distributed caching**: Production-ready Memcached support
  - Async `MemcachedBackend` implementation via `async-memcached`
  - Namespace support for multi-tenant deployments
  - TTL and stale-while-revalidate handling
  - Custom serialization for HTTP types (StatusCode, Version, Bytes)
  - Connection pooling with Arc<Mutex<Client>>
  - Optional feature flag: `memcached-backend`
  - Compatible with memcached protocol 1.6+

#### Enhanced Observability
- **Streaming-specific metrics**: Better visibility into cache behavior
  - `tower_http_cache.streaming_passthrough` counter
  - `tower_http_cache.range_request_passthrough` counter
  - Detailed tracing logs with size and content-type info
  - Body size histograms for performance analysis

#### Smart Streaming & Large File Handling
- **Intelligent body size detection**: Prevent large files from overwhelming cache
  - `StreamingPolicy` for configurable streaming behavior
  - Early detection via `Content-Length` header and `size_hint()`
  - Content-Type based filtering (PDFs, videos, archives excluded by default)
  - Configurable `max_cacheable_size` (default: 1MB)
  - Wildcard content-type matching (e.g., `video/*`, `audio/*`)
  - Force-cache lists for critical API responses
  - Multi-tier size protection (large entries excluded from L1)
  - 20+ unit tests with 100% branch coverage

#### Multi-Tier Size Protection
- **max_l1_entry_size**: Prevent large entries from polluting fast L1 cache
  - Configurable size limit for L1 promotion (default: 256KB)
  - Automatic size checking during write-through and promotion
  - Large entries stored only in L2 for capacity efficiency
  - Metrics tracking for skipped L1 writes and promotions
  - Zero performance impact on small entries

### Changed
- **BREAKING**: `CacheService` now returns `Response<BoxBody<Bytes, BoxError>>` instead of `Response<Full<Bytes>>`
  - This enables true streaming but requires downstream services to handle `BoxBody`
  - Migration: Use `.map_err(Into::into).boxed()` on bodies if needed
  - Most Tower middleware is compatible without changes
- **BREAKING**: Added `Sync` bound to `ResBody` in Service implementation
  - Required for BoxBody's Send + Sync + 'static constraint
  - Should not affect most use cases
- `CacheEntry` now has conditional Serde derives with custom serializers for HTTP types
- `StreamingPolicy` now includes `range_handling` field (defaults to `PassThrough`)
- Range requests pass through by default without caching
- Streaming policy enabled by default (can be disabled)
- Size limits now apply consistently to all content types (including forced-cache types)

### Performance
- **Chunk caching**: 90% memory reduction for large file workloads
  - Only cache accessed chunks (not entire file)
  - Instant seeking for video streaming (no re-download)
  - Range requests served directly from memory
  - Configurable chunk size for optimal throughput
- **Zero-copy streaming**: Eliminated buffering for excluded content types
- **BB8 connection pooling**: 10x throughput improvement for Memcached
  - Reduced connection overhead
  - Concurrent request handling
  - Automatic connection reuse
- Memory efficient: Handles multi-GB responses without collecting into memory
- Eliminates memory exhaustion from large file responses
- Prevents cache pollution from 5-20MB files
- Protects L1 cache from unnecessary large entry storage
- < 1% overhead on streaming decision path

### Fixed
- Conditional compilation for `extract_size_info` import (tracing feature)
- BoxBody compatibility with Tower service ecosystem
- Proper Sync bounds for concurrent body handling

## [0.3.0] - 2025-11-10

### Added

#### Cache Tags & Invalidation Groups
- **Tag-based cache invalidation**: Group related cache entries with tags and invalidate them together
  - `TagPolicy` for configuring tag behavior
  - `TagIndex` for efficient bidirectional tag→key and key→tag lookups
  - `invalidate_by_tag()` and `invalidate_by_tags()` methods
  - Automatic cleanup of orphaned tag entries
  - Thread-safe using `DashMap` for lock-free concurrent access
  - Integrated with both in-memory and Redis backends
  - 17 comprehensive unit tests

#### Multi-Tier Caching
- **L1 + L2 hybrid backend**: Combine fast in-memory cache with larger distributed storage
  - `MultiTierBackend<L1, L2>` generic over any two `CacheBackend` implementations
  - Automatic promotion from L2→L1 based on access patterns
  - Configurable `PromotionStrategy` (HitCount, HitRate)
  - Per-key access tracking with atomic operations
  - Write-through and write-back modes
  - Graceful tier failure handling
  - Tier-specific metrics and observability
  - < 2% performance overhead
  - 7 integration tests

#### ML-Ready Structured Logging
- **Request correlation and ML training data**: Comprehensive structured logging for analytics
  - `RequestId` type for request correlation (following X-Request-ID header)
  - `MLLoggingConfig` for configurable sampling, key hashing, and privacy controls
  - Rich JSON event format with 15+ metadata fields
  - SHA-256 key hashing option for privacy compliance
  - Integration with `tracing` crate for structured output
  - Cost and complexity tracking for ML model training
  - Configurable sampling rate to reduce overhead
  - 15 unit tests

#### Admin API & Observability
- **REST API for cache introspection**: Production-ready management endpoints
  - 7 REST endpoints for cache management:
    - `GET /health` - Health check
    - `GET /stats` - Overall statistics
    - `GET /hot-keys` - Most accessed keys
    - `GET /tags` - List all tags
    - `POST /invalidate` - Invalidate by key or tag
    - `GET /keys` - List cached keys (planned)
    - `GET /key/:key` - Inspect specific key (planned)
  - Token-based authentication (Bearer token)
  - Real-time statistics collection
  - Hot keys tracking with configurable limits
  - JSON response format for all endpoints
  - Optional feature flag: `admin-api`
  - 19 unit tests

### Changed

- Enhanced `CachePolicy` with `tag_policy`, `ml_logging`, and `tag_extractor` fields
- Updated `CacheEntry` to include optional `tags` field
- Extended `CacheBackend` trait with default implementations for tag operations
- Integrated tag support into `InMemoryBackend`

### Dependencies

- Added `uuid` 1.0 with v4 and serde features
- Added `sha2` 0.10 for key hashing
- Added `hex` 0.4 for hash encoding
- Added `chrono` 0.4 for timestamp handling
- Added optional `axum` 0.8 for admin API (behind `admin-api` feature)
- Added optional `governor` 0.6 for rate limiting (behind `admin-api` feature)

### Performance

- Tag indexing: < 1% overhead on cache set operations
- Multi-tier: < 2% total overhead (L1 hot path unchanged)
- ML logging: < 100µs per event with sampling
- Request ID extraction: Negligible (simple header lookup)

### Non-Breaking Changes

All v0.3.0 features are opt-in and backward compatible:
- Default behavior unchanged
- No breaking API changes
- All features disabled by default and require explicit configuration

## [0.2.0] - 2025-11-10

### Added

- **Auto-refresh functionality**: Proactively refreshes frequently-accessed cache entries before they expire
  - Lock-free frequency tracking using `AtomicU64` and `DashMap` for minimal performance overhead (< 1%)
  - Configurable hit rate thresholds with sliding time windows
  - Background task management with graceful shutdown via `Drop`
  - Concurrency control using semaphore-based limits
  - Request reconstruction from stored metadata
  - Full observability support with metrics and tracing
  - Comprehensive test coverage with 22 new tests
  - `AutoRefreshConfig` for fine-grained configuration
  - `init_auto_refresh()` method to enable proactive cache warming
- Added tokio features: `rt`, `time`, `macros` for background task support

### Changed

- Enhanced `CacheLayer` with auto-refresh capabilities
- Enhanced `CacheService` to track cache hits for frequency analysis
- Non-breaking change: auto-refresh is disabled by default and requires explicit configuration

## [0.1.2] - 2025-11-09

### Fixed

- Added `Clone` implementation to `CacheService` to resolve compatibility issues with Axum's `Router::layer` API

## [0.1.1] - 2025-11-09

### Fixed

- Corrected repository URL in Cargo.toml to point to `sadco-io/tower-http-cache`

## [0.1.0] - 2025-11-09

### Added

- Initial release of `tower-http-cache`
- Drop-in `CacheLayer` for Tower services
- Stampede protection with request deduplication
- Flexible TTL configuration (positive/negative TTL, refresh-before-expiry)
- Stale-while-revalidate support
- Pluggable storage backends:
  - In-memory backend powered by Moka
  - Redis backend with async pooling (optional `redis-backend` feature)
- Policy controls:
  - Min/max body size limits
  - Cache-Control header respect/override
  - Custom method and status code filters
  - Header allowlisting
- Custom cache key extraction
- Optional observability:
  - Metrics counters via `metrics` crate (optional `metrics` feature)
  - Tracing spans (optional `tracing` feature)
- Optional gzip compression (optional `compression` feature)
- Comprehensive test suite
- Benchmark suite with Criterion
- Examples for Axum and Redis integration

[Unreleased]: https://github.com/sadco-io/tower-http-cache/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/sadco-io/tower-http-cache/compare/v0.5.2...v0.6.0
[0.5.2]: https://github.com/sadco-io/tower-http-cache/compare/v0.5.1...v0.5.2
[0.5.1]: https://github.com/sadco-io/tower-http-cache/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/sadco-io/tower-http-cache/compare/v0.4.3...v0.5.0
[0.4.3]: https://github.com/sadco-io/tower-http-cache/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/sadco-io/tower-http-cache/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/sadco-io/tower-http-cache/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/sadco-io/tower-http-cache/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/sadco-io/tower-http-cache/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/sadco-io/tower-http-cache/compare/v0.1.2...v0.2.0
[0.1.2]: https://github.com/sadco-io/tower-http-cache/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/sadco-io/tower-http-cache/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/sadco-io/tower-http-cache/releases/tag/v0.1.0