tower_http_cache/codec/envelope.rs
1//! The versioned envelope wrapped around every value written to a shared
2//! backend by 0.6.0 and later.
3//!
4//! # Byte layout
5//!
6//! ```text
7//! offset size field encoding notes
8//! ------ ---- ---------------- ---------------------------- --------------------------------
9//! 0 3 MAGIC 0x54 0x48 0x43 ("THC") constant
10//! 3 1 FORMAT_VERSION 0x01 bumped only on envelope changes
11//! 4 1 CODEC_ID 0x01 = postcard 0x02..=0x7F reserved by this crate
12//! 0x80..=0xFF free for user codecs
13//! 5 8 expires_at_ms u64 little-endian ms since UNIX_EPOCH
14//! 13 8 stale_until_ms u64 little-endian ms since UNIX_EPOCH
15//! 21 N payload CacheCodec::encode(&entry) N = buf.len() - 21
16//! ------ ----
17//! 21 ENVELOPE_HEADER_LEN
18//! ```
19//!
20//! There is no payload length field: the transport frames the value exactly
21//! (both Redis `GET` and memcached `get` return the stored length), and the
22//! codec detects truncation on its own.
23//!
24//! Putting the timestamps in the header rather than in the codec payload keeps
25//! [`CacheCodec`]'s signature free of expiry concerns, makes the timings
26//! readable with `redis-cli` without running a codec, and removes the extra
27//! copy of the body that the 0.5.x Redis backend spent on double-encoding.
28
29use std::time::{Duration, SystemTime, UNIX_EPOCH};
30
31use super::CacheCodec;
32use crate::backend::CacheRead;
33use crate::error::CacheError;
34
35/// Magic prefix identifying a 0.6.0 envelope: `b"THC"`.
36pub const MAGIC: [u8; 3] = *b"THC";
37
38/// Envelope format version written by this release.
39pub const FORMAT_V1: u8 = 0x01;
40
41/// Codec id of [`PostcardCodec`](super::PostcardCodec).
42pub const CODEC_POSTCARD: u8 = 0x01;
43
44/// Default codec id for codecs implemented outside this crate.
45pub const CODEC_USER: u8 = 0x80;
46
47/// Size of the envelope header in bytes.
48pub const ENVELOPE_HEADER_LEN: usize = 21;
49
50/// Fixed overhead of the 0.5.x Redis outer record: an 8-byte `u64` length
51/// prefix for the inner payload plus two 8-byte `u64` timestamps.
52pub const LEGACY_REDIS_OVERHEAD: usize = 24;
53
54/// Wraps an encoded payload in an envelope header.
55pub fn wrap(codec_id: u8, expires_at_ms: u64, stale_until_ms: u64, payload: &[u8]) -> Vec<u8> {
56 let mut out = Vec::with_capacity(ENVELOPE_HEADER_LEN + payload.len());
57 out.extend_from_slice(&MAGIC);
58 out.push(FORMAT_V1);
59 out.push(codec_id);
60 out.extend_from_slice(&expires_at_ms.to_le_bytes());
61 out.extend_from_slice(&stale_until_ms.to_le_bytes());
62 out.extend_from_slice(payload);
63 out
64}
65
66/// Reports whether `bytes` carries an envelope header this release understands.
67pub fn looks_like_v2(bytes: &[u8]) -> bool {
68 bytes.len() >= ENVELOPE_HEADER_LEN && bytes[0..3] == MAGIC && bytes[3] == FORMAT_V1
69}
70
71/// Exact structural test for a 0.5.x Redis value.
72///
73/// The 0.5.x Redis value is `bincode1(RedisRecord)`, which begins with the
74/// inner payload's length as a little-endian `u64` and is exactly
75/// `payload_len + 24` bytes long. Every such value therefore satisfies the
76/// identity below, and it is used as a positive test for the legacy shape
77/// rather than relying on the magic prefix alone: a ~21 MB cached response
78/// could in principle begin with the magic bytes, and
79/// [`CachePolicy::max_body_size`](crate::policy::CachePolicy) defaults to
80/// unbounded.
81pub fn is_legacy_redis(bytes: &[u8]) -> bool {
82 bytes.len() >= LEGACY_REDIS_OVERHEAD
83 && u64::from_le_bytes(bytes[0..8].try_into().unwrap())
84 == (bytes.len() - LEGACY_REDIS_OVERHEAD) as u64
85}
86
87/// Decodes an envelope written by this release.
88///
89/// Fails if the header is absent, if the format version is unknown, or if byte
90/// 4 does not name `C`'s [`CacheCodec::CODEC_ID`] — an unrecognised codec id is
91/// reported rather than guessed at.
92pub fn decode_v2<C: CacheCodec>(bytes: &[u8], codec: &C) -> Result<CacheRead, CacheError> {
93 if bytes.len() < ENVELOPE_HEADER_LEN {
94 return Err(CacheError::Backend(format!(
95 "envelope too short: {} bytes",
96 bytes.len()
97 )));
98 }
99 if bytes[0..3] != MAGIC {
100 return Err(CacheError::Backend("missing envelope magic".to_string()));
101 }
102 if bytes[3] != FORMAT_V1 {
103 return Err(CacheError::Backend(format!(
104 "unsupported envelope format version {:#04x}",
105 bytes[3]
106 )));
107 }
108 if bytes[4] != C::CODEC_ID {
109 return Err(CacheError::Backend(format!(
110 "entry was written by codec id {:#04x}, this backend uses {:#04x}",
111 bytes[4],
112 C::CODEC_ID
113 )));
114 }
115
116 let expires_at_ms = u64::from_le_bytes(bytes[5..13].try_into().unwrap());
117 let stale_until_ms = u64::from_le_bytes(bytes[13..21].try_into().unwrap());
118 let entry = codec.decode(&bytes[ENVELOPE_HEADER_LEN..])?;
119
120 Ok(CacheRead {
121 entry,
122 expires_at: Some(unix_ms_to_system_time(expires_at_ms)),
123 stale_until: Some(unix_ms_to_system_time(stale_until_ms)),
124 })
125}
126
127/// Reads a stored value, transparently accepting 0.5.x Redis entries.
128///
129/// Both decoders are attempted before a miss is reported, so the dispatch
130/// order is an optimisation and not a correctness requirement. Bytes that
131/// neither decoder recognises are reported as `Ok(None)` — a cache miss —
132/// rather than an error: the cache layer already treats a backend `Err` as a
133/// miss, an unreadable entry is semantically identical to an absent one, and a
134/// value that belongs to another application sharing the namespace must not
135/// take a request down. The miss is observable through a `tracing::warn!` and
136/// the `tower_http_cache.decode_error` counter. The value is never deleted.
137pub fn read_stored<C: CacheCodec>(
138 bytes: &[u8],
139 codec: &C,
140) -> Result<Option<CacheRead>, CacheError> {
141 // The legacy test is exact (see `is_legacy_redis`), so it runs first when
142 // it matches; otherwise the envelope is tried first. Both decoders are
143 // attempted either way, so the order is an optimisation.
144 let legacy_first = is_legacy_redis(bytes);
145
146 if legacy_first {
147 if let Some(read) = try_legacy(bytes) {
148 return Ok(Some(read));
149 }
150 }
151
152 if looks_like_v2(bytes) {
153 match decode_v2(bytes, codec) {
154 Ok(read) => return Ok(Some(read)),
155 Err(err) => observe_decode_error("envelope", &err),
156 }
157 }
158
159 if !legacy_first {
160 if let Some(read) = try_legacy(bytes) {
161 return Ok(Some(read));
162 }
163 }
164
165 observe_decode_error(
166 "unrecognised",
167 &CacheError::Backend(format!(
168 "no decoder recognised the stored value ({} bytes)",
169 bytes.len()
170 )),
171 );
172 Ok(None)
173}
174
175#[cfg(feature = "legacy-bincode1-read")]
176fn try_legacy(bytes: &[u8]) -> Option<CacheRead> {
177 match super::legacy::decode_legacy_redis(bytes) {
178 Ok(read) => Some(read),
179 Err(err) => {
180 observe_decode_error("legacy-bincode1", &err);
181 None
182 }
183 }
184}
185
186/// Without the `legacy-bincode1-read` feature, 0.5.x entries simply read as a
187/// miss and are overwritten on the next store.
188#[cfg(not(feature = "legacy-bincode1-read"))]
189fn try_legacy(_bytes: &[u8]) -> Option<CacheRead> {
190 None
191}
192
193fn observe_decode_error(kind: &str, err: &CacheError) {
194 #[cfg(feature = "metrics")]
195 metrics::counter!("tower_http_cache.decode_error", "kind" => kind.to_string()).increment(1);
196
197 #[cfg(feature = "tracing")]
198 tracing::warn!(kind = %kind, error = %err, "cache_entry_decode_failed");
199
200 let _ = (kind, err);
201}
202
203/// Converts milliseconds since `UNIX_EPOCH` into a [`SystemTime`], the
204/// encoding used by the envelope's two timestamp fields.
205pub fn unix_ms_to_system_time(ms: u64) -> SystemTime {
206 UNIX_EPOCH + Duration::from_millis(ms)
207}
208
209/// Current time in milliseconds since `UNIX_EPOCH`.
210#[cfg(feature = "redis-backend")]
211pub(crate) fn current_millis() -> Result<u64, CacheError> {
212 Ok(SystemTime::now()
213 .duration_since(UNIX_EPOCH)
214 .map_err(|err| CacheError::Backend(err.to_string()))?
215 .as_millis() as u64)
216}
217
218/// Saturating conversion of a [`Duration`] to whole milliseconds.
219#[cfg(feature = "redis-backend")]
220pub(crate) fn duration_millis(duration: Duration) -> u64 {
221 duration.as_millis().min(u64::MAX as u128) as u64
222}