0.34 #2

Merged
gru merged 16 commits from 0.34 into master 2026-10-18 12:01:51 +02:00
25 changed files with 693 additions and 116 deletions
Showing only changes of commit f20c8da718 - Show all commits

No files matched your search

+15
View File
@@ -5,6 +5,21 @@
## 0.34.0
- DEV 1 KiB accounting follow-up: move per-domain HIT/MISS/BYPASS, reason,
stored-entry and saved-body statistics outside the HOT cache's shared mutex.
Publish bounded domain/reason indexes and update counters atomically while
preserving the 4,096-domain cap, scoped/global reset behavior and dashboard
labels. Avoid creating analytics samples when the existing bounded queue is
full, preserving its drop counter and downstream processing. Change the
per-vhost latency histogram from up to 11 cumulative atomic increments per
exchange to 1 exclusive bucket increment; reconstruct unchanged cumulative
Prometheus and Docker-vhost histograms when scraped.
- Extend Rust regressions for parallel BYPASS/HIT counting, reset semantics and
saturated analytics. The supplied **previous** DEV executable passes 151
actual HTTP checks; these newest source changes are uncompiled and their RPS
uplift unmeasured. See `docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md`.
- DEV 1 KiB reverse-proxy throughput follow-up: identify HTTP/1.1 upstream
idle-pool capacity as a bottleneck using 4-vs-64 live tuning trials on the
*supplied, unmodified executable*. Change the new-vhost default from 4 to 64
+12
View File
@@ -1,5 +1,17 @@
# RustProxy 0.34.0
## DEV: cache counter contention and saturated analytics (source candidate)
Per-domain cache outcome/reason accounting now uses bounded immutable indexes
and atomic counters rather than the HOT cache's global store mutex on every
request. The analytics producer reserves a queue slot before cloning fields;
full-queue/drop behavior and its counter remain unchanged. Per-vhost
latency histograms now update one exclusive atomic bin per completion and
reconstruct unchanged cumulative Prometheus buckets during scraping. The supplied
previous DEV binary passed 151 live HTTP assertions, but this newest code
**has not been built**. No version bump, no measured RPS claim. See
[`docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md`](docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md).
## 0.34.0: HOT HIT critical-section follow-up (source candidate)
Immutable response headers are shared until a HIT; the mutable per-response
+10
View File
@@ -2,6 +2,16 @@
## 0.34.0 - Scoped cache correctness and proxy body hot path
- [x] DEV 1 KiB: remove shared HOT store mutex from cache outcome/reason
accounting; preserve domain cap, exact quiescent totals and reset rules.
- [x] Reserve bounded analytics queue capacity before allocating dropped samples;
preserve queue-drop accounting and every downstream feature.
- [x] Move per-vhost histogram aggregation to scrape time; retain the same
cumulative Prometheus and Docker-vhost bucket semantics while one exclusive
atomic bin is updated per completed exchange.
- [ ] Build/test revised counters source; repeat 1 KiB source A/B and pinned
nginx benchmark (100k RPS / 80% target still open).
- [x] Share compiled route handles and reuse middleware routing in forwarding;
avoid exact-host wildcard allocation and full-route cloning.
- [x] Fast-path single-backend LB; share immutable member health settings
+24 -21
View File
@@ -2,12 +2,12 @@
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
46bab1d2c2d6e0a80d57a6b232b1ba4c220884b95cd45ddccbafce2aa160cddb AGENTS.md
364809f8807bb1fefcf1fdb80abf706e022d54415250b79178fcc9d7b68a3302 CHANGELOG.md
03d0a320df729cc98a119f4296e3b9805aece0c8591fa6b90003df2b58f8608c CHANGELOG.md
ca201e3cd3f9842eae0006d0b40a0a72bcd3d4da6e4e5ecaf200d1b9a1390db2 Cargo.lock
fec5f1bae9a59ef1fabd27c5e45d31448dd8f811736eebfa8826b3806a7e3349 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
646eeb8d0d421923010f14201de5b8864753511ef715e38af8aad1c78068c45d README.md
1119f4de537b90a6263d93eb1ce2b6b0f233fd7a398f32d758c892ba16ea7dd6 ROADMAP.md
5533499655d41f1faaf666dfa630375cf6770dff37f6cd7ef5a4e5bbd58cb258 README.md
aee9bbb367e061aeb79916e38d121e7767f6f9d0c16a2dcc4b28f091586a33b1 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
@@ -56,6 +56,7 @@ be53a58afbeddbd7deb4b5f6447f2dc74db0327839bd83407c20915df98dcfa0 docs/NOT_FOUND
e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md
bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md
ad6e3f003b820a4f45e30e39577d120a69d167c70910bda4891971759458c3dd docs/PROMETHEUS.md
9a19e57e30b2d2818322fcd5b490380281014e2ed43279c2251f09acbfd23130 docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md
f13ee0005d57d2576ccc970745f17069096df4afa23d712ea0f748da85433f7d docs/PROXY_1KB_HOTPATH_DEV_0.34.0.md
deea89aed410c0619bdbdb12437edc7aff9dca2fec7fdc059e918c649fc36b51 docs/PROXY_HOTPATH_0.34.0.md
a7d6189b0e8cb854918456e0063101ee77bfb2419a9ba0430eb0e3f8678c0f09 docs/PROXY_RPS_0.34.0.md
@@ -72,7 +73,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.
4dcf3243a13d9ae1827b5cefdbb9e27bd6310321c50d4d5934527d44437df6ef docs/SETTINGS.md
8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md
95148bfa1d2f8ff52b591c9acefc6c8992dadc4ebda43a195101d9b31d67a576 docs/TEST_REPORT.md
9bc103492b1ddeae5d5489a3e47a1c21ff14ec0e98372dc14c6ccb412dfb7846 docs/TEST_REPORT_0.34.0.md
47e3cf646b2884db18684385ca7ff18d929b6de6a5206ed5e7ad27a71d350052 docs/TEST_REPORT_0.34.0.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
e6d270894cafe96f8aa9cf58c9609223d03c9bcf6f872adfcbfb767a7b313f0a docs/TRANSPORT.md
@@ -132,7 +133,8 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
76009d786638720efb996401a8752340d00c21b93c81657fc2d5364431d7a1a4 scripts/performance/README.md
2f5206ddea7a0816924986bfe663734f9fee7771fb25c04e0374642329b06c35 scripts/performance/README.md
939287dadf7309689d29d2171757c2d80e91d898c3f9112454b943d08bd611fb scripts/performance/ab_1kb_dev.py
0380406a059ee9ec27473f8a46435f638f73a6dc729382ae1561c08dbf7ccb53 scripts/performance/benchmark.py
9ac220163256e08513597c397c016a48345b114d7876b439259cc310b36c83fc scripts/performance/checks_cache.py
f09125d081d0eed9a44fdca75de14e6bbf09dcb00fe1f027ba2270f89e3ff192 scripts/performance/checks_policy.py
@@ -161,7 +163,7 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim
f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py
fafd577ccbb58485c47de35fc125e5c64a3a7628ae40d01d4cd924fce2f414db scripts/tests/cache_age_policy_034_model.py
b75a381d1b41bfb2ea7d2ce09728249b5e1465f76d57e2c3f151af72a5ae4d03 scripts/tests/cache_age_transform_034_live.py
45a2f4c271b03f413fd96aa2e8360c7895b9a55d5d8f2d2a0fedf0118933086e scripts/tests/cache_hot_034_static_test.py
dbab8d5457546a1ffe8233514ee94225a73974fe5d6f3b1f5fadd06885fa228c scripts/tests/cache_hot_034_static_test.py
6cb79535f742d1a41685f486688dc2f1330ea362e3da1a15d4fa32561094282f scripts/tests/cache_scope_034_static_test.py
efb7a1fde2c74f30cd4a1abb88ae07c1cac7378bb33685dd09d52bb30849daac scripts/tests/dashboard_chart_headers_0335_browser_test.py
eb2c9e5c968938b6fbe4675b547ba1f537519878b9095e1fea133f4d83f96fc8 scripts/tests/dashboard_chart_typography_0337_browser_test.py
@@ -182,7 +184,7 @@ fa42ec0475e4398d647b3c094309b4332cd2b5649db223cae0b8e821d6b44a28 scripts/tests/
b0136544af717b75df2cb107900ea65c3f945fdd0040369dbb93919008fb954c scripts/tests/panel_html.py
8d1903e2b17b3d59cd0a5bec3e298e468b3d88ed2dda8b724239cc45d8353c59 scripts/tests/progressbar_0332_browser_test.py
2a6e75c05f3860acf65ea8132f0083904478b690145115ec017c120d70f26cca scripts/tests/proxy_hotpath_034_static_test.py
e2a698093c6e0125562eade1ce8098be11105ec24b8fdb0b120b831762072ccb scripts/tests/rps_hotpath_034_static_test.py
6fa2f22816996e989dad9dc7884adbf24f02f0ca6a6634b06e219eb08526c7fe scripts/tests/rps_hotpath_034_static_test.py
5be5cdc60324f4f0f6638640000b888a5babb0a9bbf29511c824a0ba38878c2a scripts/tests/sendfile_034_panel_browser_test.py
978789bfe89d43034dfebddba49b9b17daedb1be031732734f95375e56c78306 scripts/tests/sendfile_034_static_test.py
1bb99b148d0297235b707f16865ca077329246fc19ecbb20a143bdeb56a23f37 scripts/tests/sidebar_nav_0333_browser_test.py
@@ -334,32 +336,33 @@ a9eeb93519aae2de7a1282296ced91d2eb8307cc966f8fbf6085af0fd21b0ad2 src/cache/cold
2eaba3261da2a547e9af91330319a3abd3a8ad8b5b44a337a84040720209017d src/cache/cold/startup.rs
e7c7fd71727d542c2df3e03a4a1cba9d839c516963f66bbfde1c29ec81053500 src/cache/cold/worker.rs
d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs
0b9b3ff7ff6089bedf1a101c3a015a1a217b8221c6cac9c07a4a8df75f0fbf6b src/cache/dashboard.rs
36febf4d27a2f5bbd72dab7f45d8b8dd64fb4f7b95060b9e0ebd5709d19749fa src/cache/dashboard.rs
7eaf3947cfc89cc6ef55a64fef0b3fedf1d9a1c33322aba2b5170c0ea34a5f2c src/cache/fill.rs
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
3b8f77bde71253a3ca600be5438b7cc72f7da258f3afa5de2cf64553e49922d1 src/cache/invalidation/invalidate.rs
6764e2a20038fb2f75d1b821b5fbe683325d32ed78bc547f064e6e8a245ceec7 src/cache/lifecycle.rs
f3c5ec56f8d81a285877f6a07239081c2b1b064fc9143ba3bf36d616b0239d86 src/cache/lifecycle.rs
bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs
b52ba2f8a54fdff986bd9026639e7c6d084016046072b698d0278ddfd32e3ebb src/cache/lookup/hot.rs
7c5c7cd2788c09e801ef8aa9598b41d1a9c3991007801a06e962c4368a60a0ea src/cache/lookup/insert.rs
bbd5659a3197e443b3b5b726d39d7b7934bb9aeaedcdc04285bbf4321d1359d4 src/cache/lookup/hot.rs
5c3f749b3028e436a99614c22dbd05907b63c12a5c7c6baeda24c8f506db4f65 src/cache/lookup/insert.rs
b38f93fa2eac694aac4dd2f1d69b55688928b53b78bf095d479843956c78e605 src/cache/lookup/tiered.rs
58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs
5d4338ecccdbe9d78476e28491d92d4a1f96ab3c1d40590a7cfd1ba131b3ee12 src/cache/mod.rs
0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs
4862b330040664b663da7540a694da573010eba188b770d27a72bf2b35b327fe src/cache/model/cache.rs
9704265423d53e87c04cbac11effdcc3792439699494138dc86466ab50656993 src/cache/model/cache.rs
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
29bfce96b2ffd102d9c848c7b717a45fedb9aa44511f62f7a1f28a53dafd063a src/cache/model/entry.rs
88d74fc1b7b9ca15a54a8f4893ad3993105952148bcfee099e9bc9727b622aa3 src/cache/model/http_invalidation.rs
ff4dab5a6591b1afc359c6aa2b7e0f5f2be013836bb3c67a24db2eb418ce7bd5 src/cache/model/maintenance.rs
5ea7d8e72c714e5c5330dd81b5a6e2c8ed79e4367af85f133e63a756ad9788a3 src/cache/model/maintenance.rs
5441fa3a384add28b6fdd15300cf2648f8237dbfc211cad9117754203be76911 src/cache/model/settings.rs
55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs
d6051d55168fe1db97980a54c22c708f4f325ba72bfc36f35a1851eb5dcc051d src/cache/policy/response.rs
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs
6d7a4c22a7776a6538fb22d78806bec72efbe11aa5a26fb3ecf575ffd266ada0 src/cache/stats.rs
93bd3e9e5c1a045c784cee0e1fdb9e1c5d862ed70d5d91c54c5a769bca8fd949 src/cache/tests.rs
9183fa8e6086e92fb148fc278fd866d44e3dd988c848a071e32066e44a1cbf0a src/cache/tests/cold_tier_tests.rs
334893037a278ca2a6f0b8b1a32fe0e2d286665f24db78d787bfe9425dc5a88d src/cache/tests/dashboard_tests.rs
25ed7635bfd5e34de55b319bf163db9a0af26642664ce7685ebe1241bab52cac src/cache/tests/dashboard_tests.rs
a0a23e7ec1baa403524811f8096bbc026b1027b7b1b79357ad920b0a4be770b7 src/cache/tests/hot_hit_034_tests.rs
d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.rs
1659ffd8d227d2ec8b98e6a5e06b6143e6ac4ac06ab423c184980abcb47085da src/cache/tests/scope_tests.rs
@@ -421,21 +424,21 @@ fc8e99eac83c083d65101f6402aacbcc8392d66508c31566a50de3ebcb12e7da src/main/admin
e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks.rs
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
49b11df65b4fb0b5b727f9f867436584680b45e3f4fbd53f74bc0cab8b9a88ec src/metrics/vhost.rs
a54b5b34ea65f29723b89b81bf7968a238e8247b9c9760f3430d3504cb1017c9 src/metrics/vhost/tests.rs
78aa428a3ed8f9a11d69ec2a6783679fb27ea99a74bc18d866c65c5391d61293 src/metrics/vhost.rs
e9cd8d9aff1de7c92b3381f7646e5c577f857d960bbb276e0736b29973b97319 src/metrics/vhost/tests.rs
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs
c915d30bbd7032d867e7916b4cd45f3bb7e7604d3f4558f4383c59394b4300c6 src/not_found/tests.rs
d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
c46882ed26742d93806a976257674326e105b8ca1fc23f5ca81e317b268057aa src/prometheus/docker_misc.rs
74822d8293fe1ce64b18d8363a67e9f0ed2ee9d181e2668e5ca6bd258f98285c src/prometheus/docker_misc.rs
d3c6b08088df0003144544936a55e2a3661aac85292821b594f00adfd19a2951 src/prometheus/model.rs
064b2be7fed2f6acf526b1cce070c1bb1b943d36f08fa50bbfcbdf3cf1c9318c src/prometheus/tests.rs
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
fb2d8d45cd2e947f783eb981b37fa418c39fcb327a3af17a648fadea7f37f888 src/prometheus/web_php.rs
fc234886805226f744306c83df859805c444edf102dc4eb14f443afaa4b3362a src/proxy/body.rs
2c97367686b54989573ce7fb81b2c6dde72adb254df9522bc05ded74f1fbffb6 src/proxy/body.rs
c9b76abd91f0dfe81e07073e89d35faa033c19e11fa88b223f075428542284f1 src/proxy/body/tests.rs
b88d1d7a6837a2a470e5863f1f3247878daee62791fc3d8e72baeb66469bdfbf src/proxy/cache_helpers.rs
aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs
@@ -516,8 +519,8 @@ d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_sta
2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/tests/acme.rs
93b359e9defd62506729bc90e56c96783e2f14dd477dd67f337ca83149da7168 src/tls/tests/external_paths.rs
10c4a187e3aeb0c72a8c8968ea0cc8bfae4e3cb2f3d834ebd3da1056c4171ec5 src/tls/types.rs
c2f802d95da4332fb8480e83f0c7502334457b3408141c1c4516c9ba320a0463 src/traffic.rs
36301bcbd20b557dd3d58e77a9175f3581947f577c0bb0b972899cec6786b03f src/traffic/tests.rs
0d2ce0a7f5200437a1f965c62f72c91a681949a0bdec8ef4ccdb837a55626ed7 src/traffic.rs
aa6cea1b22cb5805934e1f819e9c5254a7b4f745025842144f6e6ae671eb170d src/traffic/tests.rs
d88c92f5a2a7773bc3e715d24039db35da4df53881f1a0859eec819b96f06d3b src/transform/apply.rs
87f8af5261d79072f8215f1fb8a90715a58e7626544fd8176ce840e3111bdb92 src/transform/compile.rs
b2c5236589093454ec60984b785ad6a4af6149d47f6db37d8e08c17fec7a2c1b src/transform/css.rs
+132
View File
@@ -0,0 +1,132 @@
# RustProxy 0.34.0 DEV — 1 KiB lock contention follow-up
## Goal and baseline
**Priority 1: reverse proxy, HTTP/1.1 keep-alive, uncompressed 1,040-byte
responses, cache OFF.** Aiming for approximately 100,000 RPS and at least 80%
of nginx under the same pinned-CPU benchmark. Priority 2: local static WWW,
measured separately. No feature removals and **no version bump**.
The user supplied a **new executable** (SHA-256
`1a84c58b20945bba9fdd1bc1a44d9e9d6ac7659d714343a46a1a8511a78331fa`,
`rust-proxy 0.34.0`), apparently built from the prior DEV revision. It passed
151 actual black-box HTTP tests with 0 failures and 1 skipped optional zstd
decoder. This runtime coverage belongs to the **supplied executable only**,
not to the freshly edited source below. Neither `cargo` nor `rustc` is
available in the editing environment; the new revision has NOT been compiled.
## Modified source
### `Cache::record` and `Cache::record_reason`
Before, every HIT, MISS and BYPASS acquired the same `Store` mutex used by the
HOT object store, exact LRU and cache inventory. With cache disabled, a 1 KiB
request still contested that mutex to increment `cache_disabled`.
`src/cache/stats.rs` now holds bounded per-(scope,domain) statistics separately:
- An `ArcSwap`-published immutable, borrowed-lookup BTreeMap of domain handles.
**The normal path does not lock or allocate**, even for BYPASS. New domains
alone use a registration mutex and a copy-on-first-visit registry update.
- Atomic counts for HIT, MISS, BYPASS, stored objects and saved body bytes.
The common static reasons (`cache_disabled`, `cache_hit`, `cache_miss`,
`request_cookie`) use direct atomic counters. Other reason names use another
immutable published index and atomic counters; they are registered once.
- Hard cardinality remains **4096 (scope, domain) pairs**. When full, new
unregistered domains are skipped as before. Scope purge preserves stats.
Only a revision-bearing global purge resets the per-domain registry.
- The dashboard still exposes the same `DomainCounters`, reason strings,
saved byte counts, ratio and per-vhost/object views. Counter snapshots are
quiescent-exact, but under concurrent requests individual fields can reflect
slightly different instants, consistent with other atomic metrics.
- HOT object and COLD representation/LRU access, barriers, memory accounting,
cache statuses, hit policy, retry and backend security paths are unchanged.
Trade-offs: a first-visit registration copies the bounded domain index and
allocates one counter set; this is not an adaptive, unbounded cache. The hot
path's ArcSwap guard and lookup still cost CPU. Measure that cost in profiles;
compare under high-cardinality traffic to identify any excessive registration
cost before production acceptance.
### Traffic analytics queue saturation
Previously `Exchange::drop` cloned the vhost, route, method, path, IP, user
agent and country before trying to enqueue the `Sample`. If the bounded
2,048-element MPSC queue was full, all those clones were discarded.
`Analytics::push_lazy` now acquires the same bounded Tokio queue permit with
`try_reserve` first and creates the sample only after a successful reservation.
Rejections increment `queue_dropped` exactly as before. Neither the analytics
worker, counters/top lists, reset behavior nor request log path is removed.
This improves allocation costs **on overflow** but does not turn dropped
analytics into delivered samples; monitor the existing drop counter.
### Per-vhost duration histogram atomics
Previously `VhostMetrics::complete` incremented every cumulative latency bucket
whose threshold covered the observation and the `+Inf` bucket. For an ordinary
short response this meant **11 shared atomic RMWs** for the histogram alone,
on top of existing exchange/status/byte/latency metrics.
It now increments **exactly one exclusive bucket** chosen from the same 10
latency boundaries. Both the regular vhost and Docker-vhost Prometheus exporters
compute the original cumulative `le` buckets, `+Inf` and `_count` from the 11
exclusive buckets **at scrape time**. The metric names, labels, boundaries,
body-lifetime timing and measurement collection remain intact. The cumulative
snapshot may be concurrent with request completions, as before. Added boundary
unit tests for exact thresholds and long-duration `+Inf`.
This is source-only: neither the throughput benefit nor compiled Rust behavior
has been verified in the editing environment. Review exporter parity and
under-load histogram correctness on the newly compiled executable.
## Verification and untested claims
- PASS: source preflight, cache/HOT/LB/RPS/histogram/static source guards and <=500-line
production Rust modules.
- PASS: SQLite migration + preset constraints, upgrade/lock and source
replacement tests.
- PASS: compiled user-supplied binary: 151 real HTTP checks, 0 failed,
1 optional zstd skip. This binary predates the source changes above.
- NOT RUN: Rust compile, Rust unit tests, native regressions or RPS after
rebuilding the new source. No numerical speedup attributed to this patch.
- Current diagnostic A/B on the user-supplied binary may use
`scripts/performance/ab_1kb_dev.py` and must remain labelled *old vs already
supplied binary*, **not** old vs newly edited source. The `wrk` 30 s x 7
pinned-CPU benchmark remains the production acceptance gate.
## Repeat on a compiler-enabled benchmark host
```bash
./dev.sh doctor
./dev.sh check
CARGO_INCREMENTAL=0 ./dev.sh test
CARGO_INCREMENTAL=0 cargo build --release --locked
python3 scripts/performance/regression.py \
--binary previous=/absolute/path/to/supplied-rust-proxy \
--binary edited="$(realpath target/release/rust-proxy)" \
--out /tmp/rustproxy034-counter-http
python3 scripts/performance/lb_regression.py \
--binary previous=/absolute/path/to/supplied-rust-proxy \
--binary edited="$(realpath target/release/rust-proxy)" \
--out /tmp/rustproxy034-counter-lb
python3 scripts/performance/transport_regression.py \
--binary previous=/absolute/path/to/supplied-rust-proxy \
--binary edited="$(realpath target/release/rust-proxy)" \
--out /tmp/rustproxy034-counter-transport
python3 scripts/performance/benchmark.py \
--binary previous=/absolute/path/to/supplied-rust-proxy \
--binary edited="$(realpath target/release/rust-proxy)" \
--nginx /usr/sbin/nginx --wrk /absolute/path/to/wrk \
--out /tmp/rustproxy034-counter-benchmark \
--modes proxy --sizes small --concurrency 16,64,128 \
--duration 30 --warmup 5 --repeats 7
```
Explicitly set `max_idle_per_host=64` on **both** before/after vhosts, with the
same TLS/H1/H2/health/cache/logging/settings. Verify 1,040 bytes and SHA-256,
no failures, latency percentiles, per-request CPU, RSS, file descriptors,
backend connects and all log/traffic drop counters. Test concurrent global
purges and per-vhost purges during traffic and verify the stats reset behavior
and cache HIT/MISS/BYPASS/reason distribution. Run long soaks and both cold/HOT
cache tests. The 100k RPS / nginx 80% goal is still **OPEN**.
+15
View File
@@ -9,6 +9,21 @@ compiled**, the new Rust tests were **not executed**, and no 0.34.0 RustProxy
binary or live throughput figures are supplied. Source inspection and simulated
UI/cache tests do not close the runtime acceptance gate.
## DEV 1 KiB counters follow-up (source remains uncompiled)
The newly supplied binary `rust-proxy(1)` identifies as 0.34.0 and has
SHA-256 `1a84c58b20945bba9fdd1bc1a44d9e9d6ac7659d714343a46a1a8511a78331fa`.
It completed **151 passing real HTTP tests, 0 failures, 1 optional zstd
skip**. These tests were run on the supplied binary, not on the subsequently
modified source. The latest source moves per-domain cache statistics outside
HOT store mutexes and lazily allocates analytics samples only after a queue
permit is available. Histogram completion now increments one exclusive
latency bin instead of up to 11 cumulative bins; both Prometheus exporters
convert them to the same cumulative format at scrape time. Static
source/SQL/upgrade checks pass; **Rust compile,
Rust unit tests and live new-source RPS remain OPEN**. See
`docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md` and the separate evidence archive.
## DEV 1 KiB reverse-proxy follow-up (October 2026; source only)
The supplied **old executable** passed loopback HTTP/1.1 1,040-byte content
+18
View File
@@ -6,6 +6,24 @@ controllable test **upstream**, used to verify streaming, non-replayable writes,
headers, cache policy and WebSocket frames. Performance measurements use nginx
as the shared upstream instead of Python.
## DEV 1 KiB histogram writes (source candidate)
`VhostMetrics::complete` now writes one exclusive histogram bin for each
completed exchange instead of up to 11 cumulative atomics. Both Prometheus
exporters reconstruct `le` and `+Inf` at scrape time. Unit tests cover boundary
semantics. This change has not been compiled or benchmarked: see
`docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md`.
## DEV 1 KiB cache counter follow-up
See `docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md`. Cache domain metrics no longer
lock the HOT store on BYPASS; analytics cloning is deferred until the queue
has capacity. The new **source** remains uncompiled in the editing environment.
For quick 1,040-byte loopback diagnostics with ApacheBench (not a production
acceptance benchmark), use `python3 scripts/performance/ab_1kb_dev.py --help`.
The authoritative long-run comparison remains `benchmark.py` with pinned
CPU sets, an explicitly equal upstream idle pool and multiple `wrk` repeats.
## DEV 1 KiB follow-up on 0.34.0 (newer source)
See `docs/PROXY_1KB_HOTPATH_DEV_0.34.0.md`. The previous executable's 4-vs-64
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Short, same-host ab diagnostic for 1,040-byte HTTP/1 proxy responses.
Requires an existing executable and nginx; keeps the same explicitly supplied
idle-pool limit across variants. Not a substitute for wrk/CPU-isolated acceptance.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
import sys
import time
from common import Fixture, http, write_json
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--binary', action='append', required=True, metavar='LABEL=/abs/path')
parser.add_argument('--nginx', default='/usr/sbin/nginx', type=Path)
parser.add_argument('--ab', default='/usr/bin/ab', type=Path)
parser.add_argument('--out', required=True, type=Path)
parser.add_argument('--concurrency', default='16,64')
parser.add_argument('--duration', type=int, default=4)
parser.add_argument('--repeats', type=int, default=2)
parser.add_argument('--workers', type=int, default=4)
parser.add_argument('--max-idle', type=int, default=64)
args = parser.parse_args()
binaries = dict(item.split('=', 1) for item in args.binary)
if len(binaries) != len(args.binary) or not all(Path(x).is_absolute() for x in binaries.values()):
parser.error('binary labels must be distinct and paths must be absolute')
if args.duration < 2 or args.repeats < 1:
parser.error('duration >=2 seconds and repeats >=1 required')
concurrency = [int(v) for v in args.concurrency.split(',')]
args.out.mkdir(parents=True, exist_ok=True)
records = []
write_json(args.out / 'metadata.json', {
'simulation': False, 'binary_labels': list(binaries), 'nginx': str(args.nginx),
'max_idle_for_every_rust_variant': args.max_idle, 'duration_s': args.duration,
'repeats': args.repeats, 'concurrency': concurrency, 'workers': args.workers,
'limitations': 'same-host shared CPU and closed-loop ab; not build-level before/after of uncompiled source'
})
with Fixture(args.out, workers=args.workers) as fixture:
nginx = fixture.nginx(args.nginx, frontend=True)
instances = {name: fixture.rust(Path(binary), name, max_idle=args.max_idle)
for name, binary in binaries.items()}
for name, instance in instances.items():
instance.login()
print('Waiting for quiescence:', name, flush=True)
instance.wait_quiescent(timeout=150, quiet_seconds=3)
sources = {**{name: r.reverse_port for name, r in instances.items()}, 'nginx': nginx['reverse_port']}
payload = (fixture.site / 'small.html').read_bytes()
for name, port in sources.items():
code, _, body = http(port, '/small.html', headers={'Accept-Encoding': 'identity'})
if code != 200 or body != payload:
raise AssertionError(f'{name} precheck failed: {code}, {len(body)} bytes')
write_json(args.out / 'payload-check.json', {
'status': 200, 'bytes': len(payload), 'sha256': hashlib.sha256(payload).hexdigest(),
'all_variants_verified': True
})
labels = list(sources)
for c in concurrency:
for repeat in range(args.repeats):
order = labels[repeat % len(labels):] + labels[:repeat % len(labels)]
if repeat % 2:
order.reverse()
for name in order:
port = sources[name]
cmd = [str(args.ab), '-k', '-t', str(args.duration), '-n', '1000000', '-c', str(c),
'-H', 'Accept-Encoding: identity', f'http://127.0.0.1:{port}/small.html']
prefix = f'c{c}-rep{repeat+1}-{name}'
subprocess.run([str(args.ab), '-k', '-n', '256', '-c', str(c),
f'http://127.0.0.1:{port}/small.html'],
capture_output=True, timeout=25, check=True)
p = subprocess.run(cmd, capture_output=True, text=True, timeout=args.duration + 45)
raw = p.stdout + '\n' + p.stderr
(args.out / f'{prefix}.txt').write_text(raw)
if p.returncode:
raise RuntimeError(f'{prefix} ab failed: {p.returncode} {p.stderr}')
def field(label):
m = re.search(r'^' + re.escape(label) + r':\s*(\d+(?:\.\d+)?)', raw, re.M)
return float(m.group(1)) if m else None
# ApacheBench tracks both network failures and bad body lengths.
if not re.search(r'^Failed requests:\s+0\s*$', raw, re.M):
raise RuntimeError(f'{prefix} failed requests: {raw[-1600:]}')
if not re.search(r'^Non-2xx responses:\s*0\s*$', raw, re.M) and 'Non-2xx responses:' in raw:
raise RuntimeError(f'{prefix} received non-2xx')
code, _, body = http(port, '/small.html', headers={'Accept-Encoding': 'identity'})
if code != 200 or body != payload:
raise AssertionError(f'{prefix} postcheck failed')
rps = field('Requests per second')
if rps is None:
raise RuntimeError(f'{prefix} missing RPS')
sample = {'variant': name, 'concurrency': c, 'repeat': repeat+1,
'rps': rps, 'completed': field('Complete requests'), 'failed': 0,
'expected_sha256': hashlib.sha256(payload).hexdigest(), 'status': 200}
records.append(sample)
write_json(args.out / 'samples.json', records)
print(sample, flush=True)
write_json(args.out / 'complete.json', {'complete': True, 'samples': len(records)})
return 0
if __name__ == '__main__':
sys.exit(main())
+9 -5
View File
@@ -12,17 +12,21 @@ assert 'headers: Arc<HeaderMap>' in entry
assert 'Arc::clone(&entry.headers)' in hot
assert 'drop(store);' in hot
assert hot.index('drop(store);\n\n let mut response') < hot.index('headers.as_ref().clone()')
assert 'store.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes)' in hot
assert 'self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes)' in hot
assert 'store.lru.remove(&old_sequence)' in hot and 'store.lru.insert(sequence, storage_key)' in hot
assert '.sweep()' not in hot
assert 'fn sweep(' not in entry
assert 'fn saved_body_hit(' in entry
assert 'domains: BTreeMap<String, BTreeMap<String, DomainCounters>>' in entry
stats = read('src/cache/stats.rs')
assert 'fn saved_body_hit(' in stats
assert 'const DOMAIN_STATS_LIMIT: usize = 4096' in stats
assert 'ArcSwap<PublishedDomains>' in stats
assert 'fn record_reason(&self, reason:' in stats
assert 'self.store.lock()' not in stats
assert 'if self.ban_rule_count.load(Ordering::Acquire) == 0' in inv
assert 'bans.iter().any(|rule| rule.matches_hot' in inv
assert 'fn matching_hot_key(' in model and 'entry.vary.is_empty()' in model
assert 'store.domain_count = 0' in dash
assert 'c.reasons.get_mut(reason)' in dash
assert 'self.domain_stats.clear()' in dash
assert 'self.domain_stats.record(scope, domain, outcome, reason);' in dash
assert 'include!("tests/hot_hit_034_tests.rs")' in read('src/cache/tests.rs')
for path in (ROOT / 'src').rglob('*.rs'):
if '/tests/' not in path.as_posix() and path.name != 'tests.rs':
@@ -107,6 +107,19 @@ assert "if !headers.contains_key(http::header::COOKIE)" in source("src/proxy/hea
assert "if !headers.contains_key(http::header::SET_COOKIE)" in source("src/proxy/headers.rs")
assert 'version = "0.34.0"' in source("Cargo.toml")
# Full exchange duration histogram still exports cumulative Prometheus series,
# but only one exclusive bucket is updated per request; per-vhost and Docker
# exporters must reconstruct bucket counts from exactly the same interface.
histogram = source("src/metrics/vhost.rs")
exporter = source("src/prometheus/docker_misc.rs")
assert "let bucket = DURATION_BUCKETS_US.partition_point" in histogram
assert "self.buckets[bucket].fetch_add(1, Ordering::Relaxed);" in histogram
assert "pub fn duration_histogram(&self)" in histogram
assert exporter.count("let (buckets, count) = m.duration_histogram();") == 2
assert "rust_proxy_vhost_exchange_duration_seconds_bucket" in exporter
assert "rust_proxy_docker_vhost_exchange_duration_seconds_bucket" in exporter
assert "exclusive_bins_reconstruct_exact_prometheus_cumulative_boundaries" in source("src/metrics/vhost/tests.rs")
for file in (ROOT / "src").rglob("*.rs"):
if "/tests/" in file.as_posix() or file.name == "tests.rs":
continue
+11 -32
View File
@@ -176,8 +176,7 @@ impl Cache {
if let Some(revision) = revision {
store.revision = revision;
self.revision.store(revision, Ordering::Relaxed);
store.domains.clear();
store.domain_count = 0;
self.domain_stats.clear();
}
drop(store);
self.cancel_all_fill_flights();
@@ -264,44 +263,24 @@ impl Cache {
view
}
pub fn record(&self, scope: &str, domain: &str, outcome: &str, reason: &'static str) {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = store.domain(scope, domain) {
match outcome {
"HIT" => c.hits += 1,
"MISS" => c.misses += 1,
_ => c.bypasses += 1,
}
if let Some(count) = c.reasons.get_mut(reason) {
*count = count.saturating_add(1);
} else {
c.reasons.insert(reason.to_owned(), 1);
}
}
self.domain_stats.record(scope, domain, outcome, reason);
}
pub fn record_reason(&self, scope: &str, domain: &str, reason: &'static str) {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = store.domain(scope, domain) {
if let Some(count) = c.reasons.get_mut(reason) {
*count = count.saturating_add(1);
} else {
c.reasons.insert(reason.to_owned(), 1);
}
}
self.domain_stats.record_reason(scope, domain, reason);
}
pub fn dashboard(&self, scope: Option<&str>, offset: usize, limit: usize) -> Dashboard {
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
let revision = store.revision;
let mut groups: BTreeMap<(String, String), DomainView> = store
.domains
.iter()
.filter(|(vhost, _)| scope.is_none_or(|s| s == vhost.as_str()))
.flat_map(|(vhost, domains)| domains.iter().map(move |(domain, counters)| (vhost, domain, counters)))
.map(|(vhost, domain, counters)| {
let mut groups: BTreeMap<(String, String), DomainView> = self
.domain_stats
.snapshot(scope)
.into_iter()
.map(|((vhost, domain), counters)| {
(
(vhost.clone(), domain.clone()),
DomainView {
vhost: vhost.clone(),
domain: domain.clone(),
vhost,
domain,
entries: 0,
logical_bytes: 0,
resident_bytes: 0,
@@ -313,7 +292,7 @@ impl Cache {
} else {
0.0
},
counters: counters.clone(),
counters,
},
)
})
+2
View File
@@ -6,6 +6,7 @@ impl Cache {
..Default::default()
})),
revision: AtomicU64::new(revision),
domain_stats: DomainStatsStore::default(),
cold: Arc::new(Mutex::new(ColdStore::default())),
tier_settings: Mutex::new(Settings::default()),
scope_revisions: Mutex::new(HashMap::new()),
@@ -57,6 +58,7 @@ impl Cache {
..Default::default()
})),
revision: AtomicU64::new(revision),
domain_stats: DomainStatsStore::default(),
cold: Arc::new(Mutex::new(ColdStore::default())),
tier_settings: Mutex::new(settings),
scope_revisions: Mutex::new(scope_revisions),
+2 -7
View File
@@ -61,7 +61,6 @@ impl Cache {
store.lru.insert(sequence, storage_key);
// Preserve the exact LRU and domain-byte counters, without
// allocating a replacement variant index or String keys.
store.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
self.counters.hits.fetch_add(1, Ordering::Relaxed);
drop(store);
@@ -77,6 +76,7 @@ impl Cache {
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, body_bytes);
return Some(Hit {
response,
body,
@@ -274,12 +274,7 @@ impl Cache {
"age",
HeaderValue::from_str(&age.to_string()).expect("integer header"),
);
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = store.domain(&ticket.scope, &ticket.domain) {
c.saved_body_bytes = c.saved_body_bytes.saturating_add(entry.body_bytes as u64);
}
}
self.domain_stats.saved_body_hit(&ticket.scope, &ticket.domain, entry.body_bytes);
self.counters.hits.fetch_add(1, Ordering::Relaxed);
self.counters.cold_hits.fetch_add(1, Ordering::Relaxed);
self.counters
+1 -3
View File
@@ -108,9 +108,7 @@ impl Cache {
return false;
}
if count_stored {
if let Some(c) = store.domain(&entry.scope, &entry.domain) {
c.stored += 1;
}
self.domain_stats.stored(&entry.scope, &entry.domain);
}
entry.last_access = Instant::now();
entry.sequence = store.next_sequence();
+1
View File
@@ -5,6 +5,7 @@
//! policy and streaming-fill code can be reviewed independently.
include!("model.rs");
include!("stats.rs");
include!("lifecycle.rs");
include!("cold.rs");
include!("dashboard.rs");
-22
View File
@@ -336,8 +336,6 @@ struct Store {
control_revision: u64,
scope_generations: HashMap<String, u64>,
key_generations: HashMap<Key, u64>,
domains: BTreeMap<String, BTreeMap<String, DomainCounters>>,
domain_count: usize,
}
impl Store {
fn index_variant(&mut self, base_key: Key, key: Key) {
@@ -362,26 +360,6 @@ impl Store {
}
Some(entry)
}
fn domain(&mut self, scope: &str, domain: &str) -> Option<&mut DomainCounters> {
// The common path is a borrowed lookup: no allocation of scope/domain
// strings for every cache hit or log-reason counter update.
if self.domains.get(scope).is_some_and(|d| d.contains_key(domain)) {
return self.domains.get_mut(scope)?.get_mut(domain);
}
if self.domain_count >= 4096 {
return None;
}
self.domain_count += 1;
Some(self.domains.entry(scope.to_owned()).or_default()
.entry(domain.to_owned()).or_default())
}
fn saved_body_hit(&mut self, scope: &str, domain: &str, bytes: usize) {
// Every admitted HOT insertion registered its domain. If the bounded
// stats table is full, skip accounting for an unregistered domain.
if let Some(c) = self.domains.get_mut(scope).and_then(|d| d.get_mut(domain)) {
c.saved_body_bytes = c.saved_body_bytes.saturating_add(bytes as u64);
}
}
fn next_sequence(&mut self) -> u64 {
// No practical wrapping, but a wrap must not corrupt LRU bookkeeping.
if self.sequence == u64::MAX {
+1
View File
@@ -52,6 +52,7 @@ impl Drop for TransientLeaseInner {
pub struct Cache {
store: Arc<Mutex<Store>>,
domain_stats: DomainStatsStore,
revision: AtomicU64,
cold: Arc<Mutex<ColdStore>>,
tier_settings: Mutex<Settings>,
+187
View File
@@ -0,0 +1,187 @@
// Lock-free read path for bounded, per-domain cache dashboard counters.
//
// Cache-disabled proxy requests still contribute to BYPASS and reason totals,
// but must never lock the HOT object/LRU store. Published domain/reason indexes
// are immutable; individual counters are atomics. Only first sight of a new
// domain/reason acquires the registration mutex.
use arc_swap::ArcSwap;
const DOMAIN_STATS_LIMIT: usize = 4096;
const FAST_REASONS: [&str; 4] = ["cache_disabled", "cache_hit", "cache_miss", "request_cookie"];
type ReasonIndex = BTreeMap<&'static str, Arc<AtomicU64>>;
type DomainIndex = BTreeMap<String, BTreeMap<String, Arc<DomainStats>>>;
#[derive(Clone, Default)]
struct PublishedDomains {
index: DomainIndex,
count: usize,
}
struct DomainStats {
hits: AtomicU64,
misses: AtomicU64,
bypasses: AtomicU64,
stored: AtomicU64,
saved_body_bytes: AtomicU64,
fast_reasons: [AtomicU64; FAST_REASONS.len()],
reasons: ArcSwap<ReasonIndex>,
register_reason: Mutex<()>,
}
impl Default for DomainStats {
fn default() -> Self {
Self {
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
bypasses: AtomicU64::new(0),
stored: AtomicU64::new(0),
saved_body_bytes: AtomicU64::new(0),
fast_reasons: std::array::from_fn(|_| AtomicU64::new(0)),
reasons: ArcSwap::from_pointee(ReasonIndex::new()),
register_reason: Mutex::new(()),
}
}
}
impl DomainStats {
fn record(&self, outcome: &str, reason: &'static str) {
match outcome {
"HIT" => &self.hits,
"MISS" => &self.misses,
_ => &self.bypasses,
}.fetch_add(1, Ordering::Relaxed);
self.record_reason(reason);
}
fn record_reason(&self, reason: &'static str) {
// Cache OFF is the highest-volume case; avoid reason map lookup and
// any shared lock/allocator activity for its dashboard reason.
if let Some(index) = FAST_REASONS.iter().position(|label| *label == reason) {
self.fast_reasons[index].fetch_add(1, Ordering::Relaxed);
return;
}
let published = self.reasons.load();
if let Some(counter) = published.get(reason) {
counter.fetch_add(1, Ordering::Relaxed);
return;
}
drop(published);
let _guard = self.register_reason.lock().unwrap_or_else(|e| e.into_inner());
let published = self.reasons.load_full();
if let Some(counter) = published.get(reason) {
counter.fetch_add(1, Ordering::Relaxed);
return;
}
let mut next = (*published).clone();
next.insert(reason, Arc::new(AtomicU64::new(1)));
self.reasons.store(Arc::new(next));
}
fn snapshot(&self) -> DomainCounters {
let load = |value: &AtomicU64| value.load(Ordering::Relaxed);
let mut reasons = BTreeMap::new();
for (label, counter) in FAST_REASONS.iter().zip(&self.fast_reasons) {
let value = load(counter);
if value > 0 {
reasons.insert((*label).to_owned(), value);
}
}
for (label, counter) in self.reasons.load().iter() {
reasons.insert((*label).to_owned(), load(counter));
}
DomainCounters {
hits: load(&self.hits),
misses: load(&self.misses),
bypasses: load(&self.bypasses),
stored: load(&self.stored),
saved_body_bytes: load(&self.saved_body_bytes),
reasons,
}
}
}
struct DomainStatsStore {
domains: ArcSwap<PublishedDomains>,
register: Mutex<()>,
}
impl Default for DomainStatsStore {
fn default() -> Self {
Self {
domains: ArcSwap::from_pointee(PublishedDomains::default()),
register: Mutex::new(()),
}
}
}
impl DomainStatsStore {
fn with(&self, scope: &str, domain: &str, action: impl FnOnce(&DomainStats)) {
let published = self.domains.load();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) {
action(counters);
return;
}
drop(published);
// Rare path: register only 4096 distinct (scope, domain) pairs. The
// published map is cloned on first insertion, never on a request hit.
let _guard = self.register.lock().unwrap_or_else(|e| e.into_inner());
let published = self.domains.load_full();
if let Some(counters) = published.index.get(scope).and_then(|hosts| hosts.get(domain)) {
action(counters);
return;
}
if published.count >= DOMAIN_STATS_LIMIT {
return;
}
let mut next = (*published).clone();
let counters = Arc::new(DomainStats::default());
next.index.entry(scope.to_owned()).or_default()
.insert(domain.to_owned(), Arc::clone(&counters));
next.count += 1;
self.domains.store(Arc::new(next));
action(&counters);
}
fn record(&self, scope: &str, domain: &str, outcome: &str, reason: &'static str) {
self.with(scope, domain, |counters| counters.record(outcome, reason));
}
fn record_reason(&self, scope: &str, domain: &str, reason: &'static str) {
self.with(scope, domain, |counters| counters.record_reason(reason));
}
fn stored(&self, scope: &str, domain: &str) {
self.with(scope, domain, |counters| {
counters.stored.fetch_add(1, Ordering::Relaxed);
});
}
fn saved_body_hit(&self, scope: &str, domain: &str, bytes: usize) {
// COLD entries loaded from persisted metadata may be served before a
// domain is registered. Preserve first-hit byte accounting as well.
self.with(scope, domain, |counters| {
counters.saved_body_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
});
}
fn clear(&self) {
let _guard = self.register.lock().unwrap_or_else(|e| e.into_inner());
self.domains.store(Arc::new(PublishedDomains::default()));
}
fn snapshot(&self, scope: Option<&str>) -> BTreeMap<(String, String), DomainCounters> {
let published = self.domains.load();
let mut counters = BTreeMap::new();
for (vhost, domains) in &published.index {
if !scope.is_none_or(|selected| selected == vhost) {
continue;
}
for (domain, metric) in domains {
counters.insert((vhost.clone(), domain.clone()), metric.snapshot());
}
}
counters
}
}
+51
View File
@@ -215,6 +215,57 @@ mod dashboard_tests {
}
}
#[test]
fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() {
let cache = Cache::new(7);
// A BYPASS must be recordable even while the HOT/LRU mutex is held.
let store = cache.store.lock().unwrap();
cache.record("alpha", "a.example", "BYPASS", "cache_disabled");
cache.record_reason("alpha", "a.example", "background_refresh_failure");
drop(store);
let workers = 8;
let each = 2000;
let mut tasks = Vec::new();
for _ in 0..workers {
let cache = Arc::clone(&cache);
tasks.push(std::thread::spawn(move || {
for _ in 0..each {
cache.record("alpha", "a.example", "BYPASS", "cache_disabled");
cache.record("alpha", "a.example", "HIT", "cache_hit");
}
}));
}
for task in tasks {
task.join().expect("domain counter worker");
}
let scoped = cache.dashboard(Some("alpha"), 0, 1);
assert_eq!(scoped.domains.len(), 1);
let counters = &scoped.domains[0].counters;
assert_eq!(counters.bypasses, 1 + workers * each);
assert_eq!(counters.hits, workers * each);
assert_eq!(counters.reasons["cache_disabled"], 1 + workers * each);
assert_eq!(counters.reasons["cache_hit"], workers * each);
assert_eq!(counters.reasons["background_refresh_failure"], 1);
assert_eq!(cache.purge_scope("alpha"), 0);
assert_eq!(cache.dashboard(Some("alpha"), 0, 1).domains[0].counters.hits, workers * each);
cache.purge(Some(8));
assert!(cache.dashboard(None, 0, 1).domains.is_empty());
cache.record("alpha", "a.example", "MISS", "cache_miss");
let after_reset = cache.dashboard(Some("alpha"), 0, 1);
assert_eq!(after_reset.domains[0].counters.misses, 1);
assert_eq!(after_reset.domains[0].counters.hits, 0);
assert_eq!(after_reset.statistics_revision, 8);
}
#[test]
fn first_hit_saved_bytes_registers_domain_even_without_prior_record() {
let cache = Cache::new(7);
cache.domain_stats.saved_body_hit("cold", "restored.example", 1040);
let result = cache.dashboard(Some("cold"), 0, 1);
assert_eq!(result.domains.len(), 1);
assert_eq!(result.domains[0].counters.saved_body_bytes, 1040);
}
#[test]
fn per_domain_counter_cardinality_is_bounded() {
let cache = Cache::new(7);
+17 -4
View File
@@ -62,12 +62,25 @@ impl VhostMetrics {
Err(observed) => current = observed,
}
}
for (index, threshold) in DURATION_BUCKETS_US.iter().enumerate() {
if micros <= *threshold {
self.buckets[index].fetch_add(1, Ordering::Relaxed);
// Write exactly one exclusive histogram bin per exchange. Prometheus
// cumulative le=... buckets are reconstructed only when scraped.
// This removes up to 11 contended atomics for short 1 KiB responses.
let bucket = DURATION_BUCKETS_US.partition_point(|threshold| micros > *threshold);
self.buckets[bucket].fetch_add(1, Ordering::Relaxed);
}
/// A scrape-consistent cumulative representation of exclusive bins. The
/// sum of atomic snapshots is monotonic and the final count includes +Inf.
pub fn duration_histogram(&self) -> ([u64; DURATION_BUCKETS_US.len()], u64) {
let mut cumulative = [0u64; DURATION_BUCKETS_US.len()];
let mut count = 0u64;
for (index, bin) in self.buckets.iter().enumerate() {
count = count.saturating_add(bin.load(Ordering::Relaxed));
if index < DURATION_BUCKETS_US.len() {
cumulative[index] = count;
}
}
self.buckets[DURATION_BUCKETS_US.len()].fetch_add(1, Ordering::Relaxed);
(cumulative, count)
}
pub fn view(&self) -> VhostMetricsView {
+24 -1
View File
@@ -1,5 +1,23 @@
mod vhost_tests {
use super::*;
#[test]
fn exclusive_bins_reconstruct_exact_prometheus_cumulative_boundaries() {
let m = VhostMetrics::default();
m.active.store(5, Ordering::Relaxed);
for micros in [1, 5_000, 5_001, 25_000, 5_000_001] {
m.complete(200, false, std::time::Duration::from_micros(micros), 0, 1040);
}
let (cumulative, total) = m.duration_histogram();
assert_eq!(total, 5);
assert_eq!(cumulative, [2, 3, 4, 4, 4, 4, 4, 4, 4, 4]);
assert_eq!(n(&m.buckets[0]), 2);
assert_eq!(n(&m.buckets[1]), 1);
assert_eq!(n(&m.buckets[2]), 1);
assert_eq!(n(&m.buckets[10]), 1);
assert_eq!(n(&m.completed), 5);
assert_eq!(n(&m.active), 0);
}
#[test]
fn streaming_completion_and_histogram() {
let m = VhostMetrics::default();
@@ -10,7 +28,12 @@ mod vhost_tests {
assert_eq!(n(&m.status[4]), 1);
assert_eq!(n(&m.buckets[1]), 0);
assert_eq!(n(&m.buckets[2]), 1);
assert_eq!(n(&m.buckets[10]), 1);
assert_eq!(n(&m.buckets[10]), 0); // exclusive +Inf bin
let (buckets, count) = m.duration_histogram();
assert_eq!(buckets[1], 0);
assert_eq!(buckets[2], 1);
assert_eq!(buckets[9], 1);
assert_eq!(count, 1);
let view = m.view();
assert_eq!(view.requests, 0);
assert_eq!(view.completed, 1);
+4 -17
View File
@@ -240,16 +240,9 @@ fn render_histograms(
"rust_proxy_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}",
m.duration_us.load(Ordering::Relaxed) as f64 / 1e6
);
// Read the cumulative last bucket once for +Inf and count.
let count =
m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed);
let mut previous = 0;
let (buckets, count) = m.duration_histogram();
for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() {
let value = m.buckets[i]
.load(Ordering::Relaxed)
.max(previous)
.min(count);
previous = value;
let value = buckets[i];
let _ = writeln!(
out,
"rust_proxy_vhost_exchange_duration_seconds_bucket{{{l},le=\"{}\"}} {value}",
@@ -275,15 +268,9 @@ fn render_histograms(
"rust_proxy_docker_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}",
m.duration_us.load(Ordering::Relaxed) as f64 / 1e6
);
let count =
m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed);
let mut previous = 0;
let (buckets, count) = m.duration_histogram();
for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() {
let value = m.buckets[i]
.load(Ordering::Relaxed)
.max(previous)
.min(count);
previous = value;
let value = buckets[i];
let _ = writeln!(
out,
"rust_proxy_docker_vhost_exchange_duration_seconds_bucket{{{l},le=\"{}\"}} {value}",
+3 -3
View File
@@ -218,9 +218,9 @@ impl Drop for Exchange {
} else {
"complete"
};
if let Some(vhost) = self.vhost.clone() {
self.traffic.push(crate::traffic::Sample {
vhost,
if let Some(vhost) = self.vhost.as_ref() {
self.traffic.push_lazy(|| crate::traffic::Sample {
vhost: vhost.clone(),
route: route.clone(),
method: self.method.clone(),
path: self.path.clone(),
+11 -1
View File
@@ -243,10 +243,20 @@ impl Analytics {
}
pub fn push(&self, sample: Sample) {
if self.tx.try_send(sample).is_err() {
self.push_lazy(|| sample);
}
/// Acquire queue capacity before constructing owned analytics fields.
/// Overflow accounting is identical to `push`, and the queue remains
/// bounded. In saturated traffic the rejected sample allocates nothing.
pub fn push_lazy(&self, make_sample: impl FnOnce() -> Sample) {
match self.tx.try_reserve() {
Ok(permit) => permit.send(make_sample()),
Err(_) => {
self.dropped.fetch_add(1, Ordering::Relaxed);
}
}
}
pub fn view(&self, scope: Option<&str>) -> View {
let state = self.state.lock().unwrap_or_else(|e| e.into_inner());
+22
View File
@@ -42,6 +42,28 @@ mod tests {
assert_eq!(view.client_ips[0].count, 1);
}
#[tokio::test]
async fn saturated_analytics_queue_does_not_build_discarded_samples() {
let analytics = Analytics::start(CancellationToken::new());
let made = AtomicU64::new(0);
// No .await until the queue is filled: on this current-thread test
// runtime the consumer has not removed a sample in the background.
for _ in 0..(QUEUE_CAPACITY + 5) {
analytics.push_lazy(|| {
made.fetch_add(1, Ordering::Relaxed);
Sample {
vhost: "site".into(), route: None, method: "GET".into(),
path: "/".into(), client_ip: None, status: 200,
duration_ms: 1, request_bytes: 0, response_bytes: 1040,
cache: "BYPASS", source: "reverse_proxy",
user_agent: None, country: None,
}
});
}
assert_eq!(made.load(Ordering::Relaxed), QUEUE_CAPACITY as u64);
assert_eq!(analytics.dropped.load(Ordering::Relaxed), 5);
}
#[test]
fn prometheus_dimensions_are_fixed_cardinality() {
assert_eq!(method_family("GET"), "GET");