Skip to main content

yggdrasil/
ssr_cache.rs

1//! 有容量和 TTL 上限的匿名公开页 HTML 缓存。
2//!
3//! 不依赖框架的磁盘 ISR:生产非 root 容器无须写 /app,写操作能真正失效内存。
4//! 透传响应流,在完整成功读完后保存;取消、超大响应和跨失效世代的响应不会回填。
5//! 写入后在阻塞线程池按已启用的算法高档压缩一次(`Effort::Cached`),命中时按
6//! `Accept-Encoding` 直接返回预压缩变体,不再每次现压。
7#![cfg(feature = "server")]
8
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, LazyLock, Mutex};
11use std::time::{Duration, Instant};
12
13use axum::body::{Body, BodyDataStream};
14use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri};
15use axum::response::Response;
16use bytes::Bytes;
17use futures::StreamExt;
18use sha2::{Digest, Sha256};
19
20use crate::compression::{self, CompressionAlgorithms, Effort, Encoding};
21
22/// 缓存总量。单页上限(`SsrCacheSettings::max_page_bytes`,后台「页面缓存」可调,默认 2 MiB)
23/// 不超过它,否则一页就会挤掉整个缓存。
24const MAX_CACHE_BYTES: u64 = 32 * 1024 * 1024;
25const _: () = assert!(crate::models::settings::MAX_SSR_CACHE_MAX_PAGE_BYTES <= MAX_CACHE_BYTES);
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct SsrGeneration(pub u64);
29
30#[derive(Clone)]
31struct CachedPage {
32    body: Bytes,
33    /// 写入后补齐的预压缩变体;补齐前为空,命中交给外层压缩层现压。
34    encoded: Arc<[(Encoding, Bytes)]>,
35    headers: HeaderMap,
36    created: Instant,
37    etag: HeaderValue,
38    generation: u64,
39}
40
41struct HtmlCache {
42    pages: moka::sync::Cache<String, CachedPage>,
43    generation: AtomicU64,
44    gate: Mutex<()>,
45    enabled: bool,
46    /// 单页上限:渲染结果更大的页面不缓存,每次请求完整 SSR 再现压。
47    max_page_bytes: usize,
48    /// 当前缓存命名空间(release×flavor,plan §6.7)。空串 = 尚未初始化。
49    namespace: Mutex<String>,
50}
51
52impl HtmlCache {
53    fn new(ttl: Duration, capacity: u64, max_page_bytes: usize) -> Self {
54        Self {
55            pages: moka::sync::Cache::builder()
56                .max_capacity(capacity)
57                .weigher(|key: &String, page: &CachedPage| {
58                    let encoded: usize = page.encoded.iter().map(|(_, bytes)| bytes.len()).sum();
59                    (key.len() + page.body.len() + encoded + 1024) as u32
60                })
61                .time_to_live(ttl)
62                .build(),
63            generation: AtomicU64::new(0),
64            gate: Mutex::new(()),
65            enabled: !ttl.is_zero(),
66            max_page_bytes,
67            namespace: Mutex::new(String::new()),
68        }
69    }
70
71    fn generation(&self) -> u64 {
72        self.generation.load(Ordering::Acquire)
73    }
74
75    fn invalidate(&self) -> u64 {
76        let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
77        let generation = self
78            .generation
79            .fetch_add(1, Ordering::AcqRel)
80            .wrapping_add(1);
81        self.pages.invalidate_all();
82        generation
83    }
84
85    /// 切换命名空间:内容不同则整表失效并推进世代,保证旧命名空间下的已缓存
86    /// 页面与在途回填都绝不返回给新命名空间(plan §6.7「release/flavor 纳入隔离」)。
87    /// 同一进程内一个缓存实例只服务一个 release×flavor 组合,切换只发生在
88    /// 启动/开发热重载重新组装路由时。返回是否真的发生了切换。
89    fn swap_namespace(&self, namespace: &str) -> bool {
90        let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
91        let mut current = self.namespace.lock().unwrap_or_else(|e| e.into_inner());
92        if *current == namespace {
93            return false;
94        }
95        *current = namespace.to_owned();
96        // 世代号与 invalidate() 同步推进;新值无需使用。
97        let _ = self
98            .generation
99            .fetch_add(1, Ordering::AcqRel)
100            .wrapping_add(1);
101        self.pages.invalidate_all();
102        true
103    }
104
105    fn get(&self, path: &str) -> Option<CachedPage> {
106        self.pages
107            .get(path)
108            .filter(|page| page.generation == self.generation())
109    }
110
111    /// 新写入时返回 ETag 与正文,供调用方预压缩;同世代已有相同内容(并发未命中的
112    /// 先到者已写入)时不覆盖,返回 None,避免重复预压缩并保留已补齐的变体。
113    fn insert(
114        &self,
115        path: String,
116        headers: HeaderMap,
117        body: Vec<u8>,
118        generation: u64,
119    ) -> Option<(HeaderValue, Bytes)> {
120        if !self.enabled || body.len() > self.max_page_bytes {
121            return None;
122        }
123        let etag = HeaderValue::from_str(&format!("\"{}\"", hex::encode(Sha256::digest(&body))))
124            .expect("SHA256 is an ASCII entity tag");
125        let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
126        if generation != self.generation()
127            || self
128                .pages
129                .get(&path)
130                .is_some_and(|page| page.generation == generation && page.etag == etag)
131        {
132            return None;
133        }
134        let body = Bytes::from(body);
135        self.pages.insert(
136            path,
137            CachedPage {
138                body: body.clone(),
139                encoded: Arc::new([]),
140                headers,
141                etag: etag.clone(),
142                generation,
143                created: Instant::now(),
144            },
145        );
146        Some((etag, body))
147    }
148
149    /// 补上预压缩变体;页面已失效、过期或被不同内容替换时丢弃。
150    fn attach_encoded(
151        &self,
152        path: &str,
153        etag: &HeaderValue,
154        generation: u64,
155        encoded: Vec<(Encoding, Bytes)>,
156    ) {
157        let _guard = self.gate.lock().unwrap_or_else(|e| e.into_inner());
158        let Some(page) = self
159            .get(path)
160            .filter(|page| page.generation == generation && page.etag == *etag)
161        else {
162            return;
163        };
164        self.pages.insert(
165            path.to_owned(),
166            CachedPage {
167                encoded: encoded.into(),
168                ..page
169            },
170        );
171    }
172}
173
174/// 在阻塞线程池按已启用的全部算法压缩一次再写回缓存。brotli 11 每 100 KB 约 0.1 s,
175/// 不占请求路径;补齐前的命中照旧由外层压缩层现压。
176fn precompress(path: String, etag: HeaderValue, generation: u64, body: Bytes) {
177    let Some(algorithms) = compression::algorithms() else {
178        return;
179    };
180    tokio::task::spawn_blocking(move || {
181        let mut encoded = Vec::new();
182        for encoding in algorithms.encodings() {
183            match compression::encode(encoding, Effort::Cached, &body) {
184                Some(Ok(bytes)) => encoded.push((encoding, Bytes::from(bytes))),
185                Some(Err(error)) => {
186                    tracing::warn!(%error, %path, "SSR 缓存预压缩失败,命中继续现压");
187                    return;
188                }
189                None => {}
190            }
191        }
192        HTML_CACHE.attach_encoded(&path, &etag, generation, encoded);
193    });
194}
195
196/// 命中响应。协商出的编码有预压缩变体时直接返回;否则给出原始 HTML,由外层压缩层
197/// 现压或回 406。启用压缩时一律带 `Vary: Accept-Encoding`(含 304)。
198fn hit_response(
199    page: CachedPage,
200    request: &HeaderMap,
201    algorithms: Option<CompressionAlgorithms>,
202) -> Response {
203    let unchanged = request
204        .get(header::IF_NONE_MATCH)
205        .and_then(|h| h.to_str().ok())
206        .is_some_and(|value| {
207            value.split(',').any(|tag| {
208                let tag = tag.trim().strip_prefix("W/").unwrap_or(tag.trim());
209                tag == "*" || tag == page.etag.to_str().unwrap_or("")
210            })
211        });
212    let variant = algorithms
213        .and_then(|algorithms| compression::negotiate(request, algorithms))
214        .and_then(|encoding| page.encoded.iter().find(|(e, _)| *e == encoding).cloned())
215        .filter(|_| !unchanged);
216    let mut response = Response::new(match (&variant, unchanged) {
217        (_, true) => Body::empty(),
218        (Some((_, bytes)), false) => Body::from(bytes.clone()),
219        (None, false) => Body::from(page.body),
220    });
221    *response.headers_mut() = page.headers;
222    if unchanged {
223        *response.status_mut() = StatusCode::NOT_MODIFIED;
224    }
225    let headers = response.headers_mut();
226    if let Some((encoding, _)) = variant {
227        headers.insert(header::CONTENT_ENCODING, encoding.header_value());
228    }
229    if algorithms.is_some() {
230        headers.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
231    }
232    headers.insert(header::ETAG, page.etag);
233    headers.insert(
234        header::AGE,
235        HeaderValue::from(page.created.elapsed().as_secs()),
236    );
237    headers.insert("x-ssr-cache", HeaderValue::from_static("HIT"));
238    headers.insert(header::CACHE_CONTROL, public_page_cache_control());
239    response
240}
241
242static HTML_CACHE: LazyLock<HtmlCache> = LazyLock::new(|| {
243    // 启动设置在路由装配前已写入 config;值随进程固定,改面板后重启生效。
244    HtmlCache::new(
245        Duration::from_secs(crate::utils::server::parse_ssr_cache_secs()),
246        MAX_CACHE_BYTES,
247        crate::config::ssr_cache().max_page_bytes as usize,
248    )
249});
250
251/// 采用白名单,查询参数、登录 Cookie、认证头和私有路由永远不进入共享 HTML 缓存。
252fn cacheable_request(method: &Method, uri: &Uri, headers: &HeaderMap) -> bool {
253    if method != Method::GET
254        || uri.query().is_some()
255        || headers.contains_key(header::COOKIE)
256        || headers.contains_key(header::AUTHORIZATION)
257        || uri.path().len() > 512
258    {
259        return false;
260    }
261    let path = uri.path();
262    CACHEABLE_PAGES.contains(&path)
263        || CACHEABLE_PREFIXES.iter().any(|prefix| {
264            path.strip_prefix(prefix)
265                .is_some_and(|tail| !tail.is_empty() && !tail.contains('/'))
266        })
267}
268
269/// 白名单内的固定页面。源站缓存与边缘缓存共用这份名单:`tasks::edge_purge` 按它清 EdgeOne,
270/// 新增公开路由要同时出现在两处,否则边缘缓存清不掉。
271pub(crate) const CACHEABLE_PAGES: [&str; 5] =
272    ["/", "/archives", "/friends", "/about", "/changelog"];
273/// 白名单内的前缀:只匹配其下一级路径(`/post/<slug>`)。
274pub(crate) const CACHEABLE_PREFIXES: [&str; 3] = ["/post/", "/page/", "/tags/"];
275
276/// 白名单匿名页的 `Cache-Control`。浏览器缓存清不掉,所以浏览器一律每次校验(`max-age=0`);
277/// 边缘缓存时长(`SsrCacheSettings::edge_ttl_secs`)大于 0 时用 `s-maxage` 让 CDN 缓存,
278/// 内容变更后由 `tasks::edge_purge` 清除。为 0 时 `no-cache`,EdgeOne 不缓存、每次回源。
279fn public_page_cache_control() -> HeaderValue {
280    static VALUE: LazyLock<HeaderValue> =
281        LazyLock::new(|| edge_cache_control(crate::config::ssr_cache().edge_ttl_secs));
282    VALUE.clone()
283}
284
285fn edge_cache_control(edge_ttl_secs: u64) -> HeaderValue {
286    if edge_ttl_secs == 0 {
287        return HeaderValue::from_static("public, no-cache");
288    }
289    HeaderValue::from_str(&format!("public, max-age=0, s-maxage={edge_ttl_secs}"))
290        .expect("数字组成的 Cache-Control 是合法头部值")
291}
292
293fn cacheable_response(response: &Response) -> bool {
294    let headers = response.headers();
295    response.status() == StatusCode::OK
296        && headers
297            .get(header::CONTENT_TYPE)
298            .and_then(|v| v.to_str().ok())
299            .is_some_and(|v| v.starts_with("text/html"))
300        && !headers.contains_key(header::SET_COOKIE)
301        && !headers.contains_key(header::CONTENT_ENCODING)
302        && !headers.contains_key(header::VARY)
303        && !headers
304            .get(header::CACHE_CONTROL)
305            .and_then(|v| v.to_str().ok())
306            .is_some_and(|v| {
307                v.split(',')
308                    .any(|p| matches!(p.trim(), "private" | "no-store"))
309            })
310}
311
312struct CaptureBody {
313    input: BodyDataStream,
314    buffer: Option<Vec<u8>>,
315    path: String,
316    headers: HeaderMap,
317    generation: u64,
318}
319
320/// 在压缩层内侧缓存原始 HTML,保持 SSR 流式响应和现有静态资源预压缩行为。
321pub async fn cache_public_html(
322    req: axum::extract::Request,
323    next: axum::middleware::Next,
324) -> Response {
325    let authenticated = req.headers().contains_key(header::COOKIE)
326        || req.headers().contains_key(header::AUTHORIZATION);
327    let cacheable = cacheable_request(req.method(), req.uri(), req.headers());
328    if !HTML_CACHE.enabled || !cacheable {
329        let mut response = next.run(req).await;
330        response
331            .headers_mut()
332            .insert("x-ssr-cache", HeaderValue::from_static("BYPASS"));
333        // 源站缓存关闭(`SSR_CACHE_SECS=0`)时白名单页照样按边缘策略发头:边缘失效不依赖源站缓存。
334        if cacheable && cacheable_response(&response) {
335            response
336                .headers_mut()
337                .insert(header::CACHE_CONTROL, public_page_cache_control());
338        }
339        if authenticated
340            && response
341                .headers()
342                .get(header::CONTENT_TYPE)
343                .and_then(|v| v.to_str().ok())
344                .is_some_and(|v| v.starts_with("text/html"))
345        {
346            response.headers_mut().insert(
347                header::CACHE_CONTROL,
348                HeaderValue::from_static("private, no-store"),
349            );
350        }
351        return response;
352    }
353    let path = req.uri().path().to_owned();
354    if let Some(page) = HTML_CACHE.get(&path) {
355        return hit_response(page, req.headers(), compression::algorithms());
356    }
357    let generation = HTML_CACHE.generation();
358    let mut response = next.run(req).await;
359    if !cacheable_response(&response) {
360        return response;
361    }
362    response
363        .headers_mut()
364        .insert("x-ssr-cache", HeaderValue::from_static("MISS"));
365    response
366        .headers_mut()
367        .insert(header::CACHE_CONTROL, public_page_cache_control());
368    let (parts, body) = response.into_parts();
369    let mut headers = parts.headers.clone();
370    for name in [
371        header::CONTENT_LENGTH,
372        header::TRANSFER_ENCODING,
373        header::AGE,
374    ] {
375        headers.remove(name);
376    }
377    let state = CaptureBody {
378        input: body.into_data_stream(),
379        buffer: Some(Vec::new()),
380        path,
381        headers,
382        generation,
383    };
384    let stream = futures::stream::unfold(state, |mut state| async move {
385        match state.input.next().await {
386            Some(Ok(chunk)) => {
387                if let Some(buffer) = state.buffer.as_mut() {
388                    if buffer.len() + chunk.len() <= HTML_CACHE.max_page_bytes {
389                        buffer.extend_from_slice(&chunk);
390                    } else {
391                        state.buffer = None;
392                    }
393                }
394                Some((Ok::<_, axum::Error>(chunk), state))
395            }
396            Some(Err(error)) => {
397                state.buffer = None;
398                Some((Err(error), state))
399            }
400            None => {
401                if let Some(buffer) = state.buffer.take() {
402                    if let Some((etag, body)) = HTML_CACHE.insert(
403                        state.path.clone(),
404                        state.headers,
405                        buffer,
406                        state.generation,
407                    ) {
408                        precompress(state.path, etag, state.generation, body);
409                    }
410                }
411                None
412            }
413        }
414    })
415    // 压缩层等下游在流结束后可能再次 poll;unfold 对此会 panic,中断该响应。
416    .fuse();
417    Response::from_parts(parts, Body::from_stream(stream))
418}
419
420// 内容写入通常同时影响详情、列表、归档与相邻导航;小型缓存统一失效避免遗漏。
421// 每次源站失效都同步请求清边缘缓存:`tasks::edge_purge` 防抖合并,未配置凭据时只是空信号。
422pub fn invalidate_ssr_route(_route: &str) {
423    HTML_CACHE.invalidate();
424    crate::tasks::edge_purge::request_purge();
425}
426pub fn invalidate_post_preview(_slug: &str) { /* 管理预览从不缓存。 */
427}
428pub fn invalidate_ssr_all_public() {
429    HTML_CACHE.invalidate();
430    crate::tasks::edge_purge::request_purge();
431}
432/// 设置发布×flavor 命名空间(plan §6.7),变化即整表失效。启动期与开发热重载
433/// 重组路由时调用;同一进程内 release/flavor 不变时是零成本空操作。
434pub fn set_release_namespace(namespace: &str) {
435    if HTML_CACHE.swap_namespace(namespace) {
436        tracing::info!(%namespace, "SSR 缓存命名空间切换,旧 release/flavor 页面已失效");
437        crate::tasks::edge_purge::request_purge();
438    }
439}
440pub fn bump_global_generation() -> u64 {
441    let generation = HTML_CACHE.invalidate();
442    crate::tasks::edge_purge::request_purge();
443    generation
444}
445pub fn current_global_generation() -> u64 {
446    HTML_CACHE.generation()
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn cache_excludes_auth_private_routes_and_query_strings() {
455        let mut headers = HeaderMap::new();
456        for path in [
457            "/admin",
458            "/login",
459            "/notes",
460            "/notes/example",
461            "/note-media/1",
462            "/api/test",
463            "/?page=1",
464            "/post/a?preview=1",
465        ] {
466            assert!(
467                !cacheable_request(&Method::GET, &path.parse().unwrap(), &headers),
468                "{path}"
469            );
470        }
471        assert!(cacheable_request(
472            &Method::GET,
473            &"/post/a".parse().unwrap(),
474            &headers
475        ));
476        headers.insert(header::COOKIE, HeaderValue::from_static("session=private"));
477        assert!(!cacheable_request(
478            &Method::GET,
479            &"/".parse().unwrap(),
480            &headers
481        ));
482        headers.clear();
483        headers.insert(
484            header::AUTHORIZATION,
485            HeaderValue::from_static("Bearer private"),
486        );
487        assert!(!cacheable_request(
488            &Method::GET,
489            &"/".parse().unwrap(),
490            &headers
491        ));
492    }
493
494    #[test]
495    fn edge_ttl_controls_shared_cache_only() {
496        assert_eq!(edge_cache_control(0), "public, no-cache");
497        // 浏览器每次校验(缓存清不掉),只有 CDN 按 s-maxage 缓存;不能带 no-cache,
498        // 否则共享缓存也不存。
499        assert_eq!(edge_cache_control(3600), "public, max-age=0, s-maxage=3600");
500        // 配置未加载(测试与 dev 默认)时不让 CDN 缓存。
501        assert_eq!(public_page_cache_control(), "public, no-cache");
502    }
503
504    #[test]
505    fn invalidation_rejects_inflight_old_responses() {
506        let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1024);
507        let generation = cache.generation();
508        cache.insert("/".into(), HeaderMap::new(), b"old".to_vec(), generation);
509        assert!(cache.get("/").is_some());
510        cache.invalidate();
511        cache.insert(
512            "/".into(),
513            HeaderMap::new(),
514            b"stale inflight".to_vec(),
515            generation,
516        );
517        assert!(cache.get("/").is_none());
518        cache.insert(
519            "/".into(),
520            HeaderMap::new(),
521            b"new".to_vec(),
522            cache.generation(),
523        );
524        assert_eq!(cache.get("/").unwrap().body.as_ref(), b"new");
525    }
526
527    #[test]
528    fn namespace_swap_isolates_release_and_flavor_variants() {
529        let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1024);
530        let generation = cache.generation();
531        cache.insert(
532            "/post/a".into(),
533            HeaderMap::new(),
534            b"dual page".to_vec(),
535            generation,
536        );
537        assert!(cache.get("/post/a").is_some());
538
539        // 切换命名空间(dual → 完整应用回退):旧页面不可见。
540        assert!(cache.swap_namespace("full/rel-2"));
541        assert!(cache.get("/post/a").is_none());
542        // 旧命名空间的在途回填同样被世代号拒绝。
543        cache.insert(
544            "/post/a".into(),
545            HeaderMap::new(),
546            b"stale dual page".to_vec(),
547            generation,
548        );
549        assert!(cache.get("/post/a").is_none());
550
551        // 新命名空间正常写入读取。
552        cache.insert(
553            "/post/a".into(),
554            HeaderMap::new(),
555            b"full page".to_vec(),
556            cache.generation(),
557        );
558        assert_eq!(cache.get("/post/a").unwrap().body.as_ref(), b"full page");
559
560        // 相同命名空间重复设置不失效(开发热重载零成本路径)。
561        assert!(!cache.swap_namespace("full/rel-2"));
562        assert_eq!(cache.get("/post/a").unwrap().body.as_ref(), b"full page");
563    }
564
565    #[test]
566    fn cached_pages_expire_and_oversize_pages_are_not_stored() {
567        let cache = HtmlCache::new(Duration::from_millis(20), MAX_CACHE_BYTES, 1024);
568        cache.insert("/".into(), HeaderMap::new(), b"html".to_vec(), 0);
569        assert!(cache.get("/").is_some());
570        std::thread::sleep(Duration::from_millis(40));
571        assert!(cache.get("/").is_none());
572        cache.insert("/limit".into(), HeaderMap::new(), vec![0; 1024], 0);
573        assert!(cache.get("/limit").is_some());
574        cache.insert("/large".into(), HeaderMap::new(), vec![0; 1025], 0);
575        assert!(cache.get("/large").is_none());
576    }
577
578    #[test]
579    fn sensitive_and_failed_responses_are_not_cached() {
580        let mut response = Response::builder()
581            .header(header::CONTENT_TYPE, "text/html")
582            .body(Body::empty())
583            .unwrap();
584        assert!(cacheable_response(&response));
585        response.headers_mut().insert(
586            header::SET_COOKIE,
587            HeaderValue::from_static("session=private"),
588        );
589        assert!(!cacheable_response(&response));
590        response.headers_mut().remove(header::SET_COOKIE);
591        *response.status_mut() = StatusCode::NOT_FOUND;
592        assert!(!cacheable_response(&response));
593    }
594
595    fn request(accept_encoding: &str) -> HeaderMap {
596        let mut headers = HeaderMap::new();
597        headers.insert(
598            header::ACCEPT_ENCODING,
599            HeaderValue::from_str(accept_encoding).unwrap(),
600        );
601        headers
602    }
603
604    async fn served(response: Response) -> (Option<Encoding>, Vec<u8>) {
605        let encoding = response
606            .headers()
607            .get(header::CONTENT_ENCODING)
608            .map(|value| match value.to_str().unwrap() {
609                "br" => Encoding::Brotli,
610                "gzip" => Encoding::Gzip,
611                other => panic!("unexpected encoding {other}"),
612            });
613        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
614            .await
615            .unwrap();
616        let html = compression::decode(encoding.unwrap_or(Encoding::Identity), &body);
617        (encoding, html)
618    }
619
620    #[tokio::test]
621    async fn hits_serve_negotiated_precompressed_variants() {
622        let algorithms = compression::parse_compression_algorithms("br, gzip");
623        let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1 << 20);
624        let html = "<article><p>预压缩命中 precompressed hit.</p></article>".repeat(500);
625        let (etag, body) = cache
626            .insert("/".into(), HeaderMap::new(), html.clone().into_bytes(), 0)
627            .unwrap();
628
629        // 变体补齐前:交给外层压缩层现压。
630        let response = hit_response(cache.get("/").unwrap(), &request("br"), algorithms);
631        assert_eq!(response.headers()[header::VARY], "accept-encoding");
632        assert_eq!(served(response).await, (None, html.clone().into_bytes()));
633
634        let encoded = algorithms
635            .unwrap()
636            .encodings()
637            .map(|encoding| {
638                let bytes = compression::encode(encoding, Effort::Cached, &body);
639                (encoding, Bytes::from(bytes.unwrap().unwrap()))
640            })
641            .collect();
642        cache.attach_encoded("/", &etag, 0, encoded);
643        for (accept, expected) in [
644            ("gzip, deflate, br, zstd", Some(Encoding::Brotli)),
645            ("gzip", Some(Encoding::Gzip)),
646            ("zstd", None),
647        ] {
648            let response = hit_response(cache.get("/").unwrap(), &request(accept), algorithms);
649            assert_eq!(response.headers()[header::ETAG], etag, "{accept}");
650            assert_eq!(
651                served(response).await,
652                (expected, html.clone().into_bytes()),
653                "{accept}"
654            );
655        }
656
657        let mut revalidate = request("br");
658        revalidate.insert(header::IF_NONE_MATCH, etag);
659        let response = hit_response(cache.get("/").unwrap(), &revalidate, algorithms);
660        assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
661        assert!(!response.headers().contains_key(header::CONTENT_ENCODING));
662        assert_eq!(served(response).await, (None, Vec::new()));
663
664        // 关闭压缩时只回原文,也不声明 Vary。
665        let response = hit_response(cache.get("/").unwrap(), &request("br"), None);
666        assert!(!response.headers().contains_key(header::VARY));
667        assert_eq!(served(response).await, (None, html.into_bytes()));
668    }
669
670    #[test]
671    fn variants_only_attach_to_the_page_they_were_encoded_from() {
672        let cache = HtmlCache::new(Duration::from_secs(60), MAX_CACHE_BYTES, 1024);
673        let variant = || vec![(Encoding::Brotli, Bytes::from_static(b"br"))];
674        let (old_etag, _) = cache
675            .insert("/".into(), HeaderMap::new(), b"old".to_vec(), 0)
676            .unwrap();
677        // 并发未命中的后到者内容相同:不覆盖、不重复预压缩。
678        cache.attach_encoded("/", &old_etag, 0, variant());
679        assert!(cache
680            .insert("/".into(), HeaderMap::new(), b"old".to_vec(), 0)
681            .is_none());
682        assert_eq!(cache.get("/").unwrap().encoded.len(), 1);
683
684        // 内容变了:新页面替换旧页面,旧页面的变体不能挂到新正文上。
685        assert!(cache
686            .insert("/".into(), HeaderMap::new(), b"new".to_vec(), 0)
687            .is_some());
688        cache.attach_encoded("/", &old_etag, 0, variant());
689        assert!(cache.get("/").unwrap().encoded.is_empty());
690
691        // 失效后在途的预压缩结果同样丢弃。
692        let generation = cache.invalidate();
693        let (etag, _) = cache
694            .insert("/".into(), HeaderMap::new(), b"new".to_vec(), generation)
695            .unwrap();
696        cache.invalidate();
697        cache.attach_encoded("/", &etag, generation, variant());
698        assert!(cache.get("/").is_none());
699    }
700}