Skip to content

v1.0.1

Choose a tag to compare

@github-actions github-actions released this 21 Oct 08:51
· 34 commits to main since this release

ONRouter 1.0.1 — three config-and-cache semantics gaps closed, each with the evidence bar set on 1.0.0: a disk cold tier for proxy_cache, conditional revalidation on expiry, and a REWRITE phase at server level.

What's new

proxy_cache on disk: proxy_cache_path <dir> … disk=on

Until now proxy_cache_path accepted a path and used only keys_zone, so a restart dropped every cached response and levels / max_size / inactive were decoration. 1.0.1 gives the response cache the same cold tier that lua_shared_dict disk= already has — the same store code, the same accounting, a separate metric family:

proxy_cache_path logs/cache_disk keys_zone=big:64m disk=on max_size=2g max_value=1m;
  • Whatever LRU pushes out of keys_zone while still fresh is written asynchronously: one writer thread per zone, temp+rename, self-describing entries with CRC. The request path never waits on the disk.
  • The next request for that key is served from the tier and promoted back into memory, so the second hit is memory-only. X-Cache-Status stays HIT — nginx's vocabulary has no "disk hit" and clients and front proxies parse that value — and the tier is reported by X-Cache-Tier: disk.
  • A restart rebuilds the index by scanning the directory. onrouter_proxy_cache_disk_operations_total{zone=…,op="warm_recovered"} is the count it recovered.
  • Opt-in. A zone without disk=on never creates its directory; behaviour is byte-identical to 1.0.0.
  • Only fresh entries, only entries worth keeping. spill_after (default 1) is the gate, and with proxy_cache_learning enabled it reads the same TinyLFU sketch as memory admission — so one-shot scan traffic can neither enter memory nor fill a disk. Responses above max_value (default 4 MiB) stay in memory.
  • Corruption never becomes a response. An entry failing magic / version / CRC / length is dropped and counted in corrupt, and the request goes to the origin. The payload format carries its own version and refuses a lying length field instead of trusting it into an allocation.
  • invalidate also forgets the cold copy, otherwise a purge would resurrect on the next read.
  • cache_governor_tier deep now spills proxy_cache victims into the tier when one exists, so reclaiming memory no longer has to mean throwing the cache away.
  • -t prints the per-zone tier (root, cap, levels, gate, blob cap) the way the split and schedule self-descriptions do, and names min_free_size= / use_temp_path= as unimplemented rather than ignoring them in silence. A disk=on that cannot open its directory fails the config load instead of quietly downgrading; hot reload drains the old store's queue before opening the new one, so a reload never leaves two stores on one directory.

This is a cold tier, not nginx's full write-through disk cache: an object that never left memory is not on disk. Size keys_zone for the hot set, don't lean on max_size.

proxy_cache_revalidate on; — ask, don't resend

An expired entry used to mean "fetch the whole object again". Now the gateway takes the cached entry's own ETag / Last-Modified to the origin; a 304 refreshes the expiry, replays the stored body and reports X-Cache-Status: REVALIDATED. What is saved is the second transfer of an object nobody changed — not the round trip, so this is not a substitute for staying at the origin (that would be proxy_cache_background_update, still open, see below).

Two properties are deliberate: the client's own If-* headers are replaced on this path, because "the copy in our cache is unchanged" is not "the copy in your browser is unchanged"; and headers absent from the 304 are kept, never dropped, so a content-type cannot vanish from a revalidated entry.

REWRITE at server level

return / rewrite / set / if belong to nginx's REWRITE phase, which runs before location matching. ONRouter executed that phase only inside a location, so a server-level one produced a -t warning and was skipped — which made the plainest HTTPS-redirect vhost in the world,

server {
    listen 80;
    server_name www.example.com;
    return 301 https://$host$request_uri;
}

a config that passes -t and then answers 404. It now returns 301. Server-level set/rewrite also change which location matches, as they do in nginx.

One asymmetry to know: server-level return runs before access control (nginx-consistent — a redirect-only vhost has no location to run it after), while a location-level return still runs after allow/deny/auth_basic/access_by_*. That difference is ONRouter's deliberate choice, documented in the capability matrix; the two levels are not interchangeable.

Also fixed on the way: return never expanded variables in its body or redirect URL, so return 301 https://$host$request_uri sent the literal $host. Variables are now interpolated, and unrecognised names stay literal.

Metrics

onrouter_proxy_cache_disk_{entries,bytes,value_bytes,capacity_bytes,pending_jobs,pending_bytes} and onrouter_proxy_cache_disk_operations_total{zone,op} with op in spill / spill_skipped / cold_hit / promote / evict / expire / corrupt / cold_error / never_read / warm_recovered. The family is absent when no zone declares disk=on. Shared-dictionary tiers keep their own onrouter_dict_disk_* family so response-cache bytes are never read as dictionary bytes.

Verification

  • cargo test --workspace → 503/503 (490 onrouter-server + 13 onrouter-config); clippy with no new findings.
  • New conf/proxy_disk.conf + tools/proxy_disk_e2e.py → 39/39 on real traffic with three zones of the same 8-entry keys_zone (disk=on spill_after=0, disk=on spill_after=3, no disk= control): evicted-fresh entries really land in the directory as ORDK records, a cold hit returns HIT + X-Cache-Tier: disk with the backend counter unchanged — the only evidence that matters — promotion then removes the tier marker, the control zone's directory is never created, the gated zone writes nothing for one-shot traffic, flipping a byte in every cold file yields correct bodies with 12 refetches and a corrupt count that moves, and after a stop/start warm_recovered equals the index size reported before shutdown.
  • No regression in the cache suites it could have broken: proxy_cache 13/13, cache_advanced 32/32, cache_header_gap 25/25, disk_cache 53/53, learning_cache 34/34, still_online 25/25, governor 32/32 — 398 assertions across 13 suites in total, all green.
  • tools/set_if_e2e.py is now 34/34 and tools/proxy_cache_e2e.py 21/21; the six CI feature suites
    total 184 assertions (canary 22, wasm 23, resilience 17, affinity 7, proxy_split 76, proxy_disk 39).
  • conf/tutorial_full.conf verifies end to end at 38/38, including its new /cold endpoint, so the tutorial snippet is the config that actually ran.
  • -t over all 90 shipped configs: unchanged (only the two known environment-bound cases, tls_legacy.conf which needs the native-tls rebuild and stream_ssl_preread_e2e.conf which is stream-only by design).
  • Full roll on the final binary: python run_regression.py reg_101_roll.txt → TOTAL 86 scripts, nonzero=0.
  • 21-minute soak: PASS — 2,231,814 requests, 0 failures, 1778.1 req/s, private memory
    18.58 → 19.29 MiB (+0.71 MiB, +0.021 MiB/min against a 1 MiB/min gate), peak 21.07 MiB; the cold
    tier churned the whole time (81,723 spills / 80,986 cold hits / 80,986 promotions, corrupt 0,
    never_read 0, pending_jobs 0). A cold-tier-only run agrees: +1.07 MiB over 2,175,742 requests.

Documentation

  • docs/OPERATIONS.md §9.1 — what the tier does and does not promise, the spill_after / max_value / max_size knobs, why a cold hit keeps the nginx status word, and the metric family.
  • docs/TUTORIAL.md §5 — a runnable 8-entry cold zone that shows the spill, the X-Cache-Tier: disk hit and the promotion in three curls; §11.2 adds the new suite.
  • docs/CAPABILITY_MATRIX.md — the "proxy_cache is memory-only" gap line, the server-level-REWRITE row in the ignored-directives table and the disk-tier roadmap item are all replaced by implemented rows with evidence; proxy_cache_background_update moves to the roadmap with the reason it is not shipped (a background refetch carries its own content-negotiation headers into an entry other clients read, and the cache key has no Vary dimension yet — that is a variant-keying task, not a threading task).
  • README.md — 1.0.1 in the feature map and in the cache section, replacing the old "the path is never written" sentence.

中文摘要

1.0.1 只做一件事,但按 1.0 的证据标准做完:proxy_cache_path <目录> … disk=on 让响应缓存拿到与 lua_shared_dict disk= 同一套磁盘冷层。被 keys_zone 挤出去且还没过期的响应异步落盘(每区一个写线程、temp+rename、条目自描述带 CRC),下次同 key 请求从磁盘读回并提升进内存,重启靠扫目录重建索引。是开关而非默认:没写 disk=on 的区连目录都不创建,行为与 1.0.0 逐字节一致。冷命中的 X-Cache-Status 仍是 HIT(不改 nginx 词表),层级由 X-Cache-Tier: disk 说明,账目在 onrouter_proxy_cache_disk_*。spill_after 决定谁值得写盘,开着缓存学习时与内存准入用同一把频率草图,一次性扫库流量既进不了内存也写不满磁盘;坏条目一律按未命中回源并计入 corrupt,绝不拼成响应发出去;invalidate 会连磁盘副本一起摘。深睡在配了冷层的区改为下沉而不是丢弃。min_free_size= / use_temp_path= 在 -t 里明说未实现。注意它是冷层不是 nginx 的全量写盘:没被挤出过内存的对象不在磁盘上。

Full Changelog: v1.0.0...v1.0.1