Skip to main content

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}