From f20c8da718e0e08aaf3d690c70d36b370e74ff3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 18 Oct 2026 06:41:20 +0200 Subject: [PATCH] v0.34.0_1 --- CHANGELOG.md | 15 ++ README.md | 12 ++ ROADMAP.md | 10 + SHA256SUMS | 45 ++--- docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md | 132 +++++++++++++ docs/TEST_REPORT_0.34.0.md | 15 ++ scripts/performance/README.md | 18 ++ scripts/performance/ab_1kb_dev.py | 108 +++++++++++ scripts/tests/cache_hot_034_static_test.py | 14 +- scripts/tests/rps_hotpath_034_static_test.py | 13 ++ src/cache/dashboard.rs | 43 ++--- src/cache/lifecycle.rs | 2 + src/cache/lookup/hot.rs | 9 +- src/cache/lookup/insert.rs | 4 +- src/cache/mod.rs | 1 + src/cache/model/cache.rs | 22 --- src/cache/model/maintenance.rs | 1 + src/cache/stats.rs | 187 +++++++++++++++++++ src/cache/tests/dashboard_tests.rs | 51 +++++ src/metrics/vhost.rs | 21 ++- src/metrics/vhost/tests.rs | 25 ++- src/prometheus/docker_misc.rs | 21 +-- src/proxy/body.rs | 6 +- src/traffic.rs | 14 +- src/traffic/tests.rs | 22 +++ 25 files changed, 694 insertions(+), 117 deletions(-) create mode 100644 docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md create mode 100755 scripts/performance/ab_1kb_dev.py create mode 100644 src/cache/stats.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 24bf50b..2794e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 439037b..8a009d3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 32d91fa..b20b8e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/SHA256SUMS b/SHA256SUMS index 0d5cf56..f70ce15 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -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 diff --git a/docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md b/docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md new file mode 100644 index 0000000..5ef580a --- /dev/null +++ b/docs/PROXY_1KB_COUNTERS_DEV_0.34.0.md @@ -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**. diff --git a/docs/TEST_REPORT_0.34.0.md b/docs/TEST_REPORT_0.34.0.md index 803446a..541745a 100644 --- a/docs/TEST_REPORT_0.34.0.md +++ b/docs/TEST_REPORT_0.34.0.md @@ -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 diff --git a/scripts/performance/README.md b/scripts/performance/README.md index 6cc610c..148a9f3 100644 --- a/scripts/performance/README.md +++ b/scripts/performance/README.md @@ -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 diff --git a/scripts/performance/ab_1kb_dev.py b/scripts/performance/ab_1kb_dev.py new file mode 100755 index 0000000..91ac851 --- /dev/null +++ b/scripts/performance/ab_1kb_dev.py @@ -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()) diff --git a/scripts/tests/cache_hot_034_static_test.py b/scripts/tests/cache_hot_034_static_test.py index 74352c3..2eae9d5 100755 --- a/scripts/tests/cache_hot_034_static_test.py +++ b/scripts/tests/cache_hot_034_static_test.py @@ -12,17 +12,21 @@ assert 'headers: Arc' 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>' 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' 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': diff --git a/scripts/tests/rps_hotpath_034_static_test.py b/scripts/tests/rps_hotpath_034_static_test.py index c9499af..a10e9d5 100755 --- a/scripts/tests/rps_hotpath_034_static_test.py +++ b/scripts/tests/rps_hotpath_034_static_test.py @@ -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 diff --git a/src/cache/dashboard.rs b/src/cache/dashboard.rs index fe9364c..b5a15b8 100644 --- a/src/cache/dashboard.rs +++ b/src/cache/dashboard.rs @@ -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, }, ) }) diff --git a/src/cache/lifecycle.rs b/src/cache/lifecycle.rs index adf8536..b94db52 100644 --- a/src/cache/lifecycle.rs +++ b/src/cache/lifecycle.rs @@ -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), diff --git a/src/cache/lookup/hot.rs b/src/cache/lookup/hot.rs index 75cb017..ef0909c 100644 --- a/src/cache/lookup/hot.rs +++ b/src/cache/lookup/hot.rs @@ -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 diff --git a/src/cache/lookup/insert.rs b/src/cache/lookup/insert.rs index 8664f34..e9efeba 100644 --- a/src/cache/lookup/insert.rs +++ b/src/cache/lookup/insert.rs @@ -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(); diff --git a/src/cache/mod.rs b/src/cache/mod.rs index a024f23..d0f685e 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -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"); diff --git a/src/cache/model/cache.rs b/src/cache/model/cache.rs index b6b96bb..50413a4 100644 --- a/src/cache/model/cache.rs +++ b/src/cache/model/cache.rs @@ -336,8 +336,6 @@ struct Store { control_revision: u64, scope_generations: HashMap, key_generations: HashMap, - domains: BTreeMap>, - 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 { diff --git a/src/cache/model/maintenance.rs b/src/cache/model/maintenance.rs index d531161..b130bc9 100644 --- a/src/cache/model/maintenance.rs +++ b/src/cache/model/maintenance.rs @@ -52,6 +52,7 @@ impl Drop for TransientLeaseInner { pub struct Cache { store: Arc>, + domain_stats: DomainStatsStore, revision: AtomicU64, cold: Arc>, tier_settings: Mutex, diff --git a/src/cache/stats.rs b/src/cache/stats.rs new file mode 100644 index 0000000..b9493f9 --- /dev/null +++ b/src/cache/stats.rs @@ -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>; +type DomainIndex = BTreeMap>>; + +#[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, + 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, + 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 + } +} diff --git a/src/cache/tests/dashboard_tests.rs b/src/cache/tests/dashboard_tests.rs index 5fdbf3f..1ab4f1f 100644 --- a/src/cache/tests/dashboard_tests.rs +++ b/src/cache/tests/dashboard_tests.rs @@ -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); diff --git a/src/metrics/vhost.rs b/src/metrics/vhost.rs index 37af08e..27cde63 100644 --- a/src/metrics/vhost.rs +++ b/src/metrics/vhost.rs @@ -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 { diff --git a/src/metrics/vhost/tests.rs b/src/metrics/vhost/tests.rs index bc2d9fb..cd059f4 100644 --- a/src/metrics/vhost/tests.rs +++ b/src/metrics/vhost/tests.rs @@ -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); diff --git a/src/prometheus/docker_misc.rs b/src/prometheus/docker_misc.rs index e1c05aa..5430189 100644 --- a/src/prometheus/docker_misc.rs +++ b/src/prometheus/docker_misc.rs @@ -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}", diff --git a/src/proxy/body.rs b/src/proxy/body.rs index 63de889..3916b84 100644 --- a/src/proxy/body.rs +++ b/src/proxy/body.rs @@ -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(), diff --git a/src/traffic.rs b/src/traffic.rs index 5a127c0..1d5a0ab 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -243,8 +243,18 @@ impl Analytics { } pub fn push(&self, sample: Sample) { - if self.tx.try_send(sample).is_err() { - self.dropped.fetch_add(1, Ordering::Relaxed); + 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); + } } } diff --git a/src/traffic/tests.rs b/src/traffic/tests.rs index 7d0b745..b280612 100644 --- a/src/traffic/tests.rs +++ b/src/traffic/tests.rs @@ -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");