v0.24.7
This commit is contained in:
1 parent
5e23e9af02
commit
3883a21bc4
56 files changed
+1640
-403
No files matched your search
+49
-1
@@ -1246,9 +1246,32 @@ fn status_payload(state: &App) -> Value {
|
||||
"locked_listen": state.startup_locks.administration.map(|value| value.to_string())
|
||||
});
|
||||
payload["features"] = Value::Object(features);
|
||||
payload["cache"] = json!(state.cache.view());
|
||||
let cache_view = state.cache.view();
|
||||
let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform);
|
||||
payload["cache"] = json!(&cache_view);
|
||||
payload["transform_resources"] = json!(&transform_resources);
|
||||
payload["geoip"] = json!(state.geoip.view());
|
||||
let system = state.diagnostics.view();
|
||||
let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64)
|
||||
.saturating_add(cache_view.transient_bytes);
|
||||
let non_cache_rss_bytes = system
|
||||
.process_rss_bytes
|
||||
.map(|rss| rss.saturating_sub(cache_ram_bytes));
|
||||
payload["memory_accounting"] = json!({
|
||||
"process_rss_bytes": system.process_rss_bytes,
|
||||
"cache_hot_bytes": cache_view.hot_allocated_bytes,
|
||||
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
|
||||
"cache_transient_bytes": cache_view.transient_bytes,
|
||||
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
|
||||
"cache_total_bytes": cache_ram_bytes,
|
||||
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
|
||||
"transform_budget_bytes": transform_resources.memory_budget_bytes,
|
||||
"transform_inflight": transform_resources.inflight,
|
||||
"transform_max_inflight": transform_resources.max_inflight,
|
||||
"non_cache_rss_bytes": non_cache_rss_bytes,
|
||||
"non_cache_rss_includes_transform": true,
|
||||
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
|
||||
});
|
||||
payload["runtime"] = json!({
|
||||
"platform": system.platform,
|
||||
"statistics_source": system.source,
|
||||
@@ -2057,6 +2080,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_exposes_runtime_memory_accounting_and_cold_limits() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/status")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024);
|
||||
assert_eq!(body["transform_resources"]["max_inflight"], 32);
|
||||
assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024);
|
||||
assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024);
|
||||
assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024);
|
||||
assert_eq!(body["cache"]["cold_limit_entries"], 50_000);
|
||||
assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_legacy_token_rebinding_and_cross_origin() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
@@ -3418,6 +3465,7 @@ async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
.collect();
|
||||
Json(json!({
|
||||
"settings": global,
|
||||
"resources": crate::transform::resource_usage(global),
|
||||
"metrics": state.transform_metrics.view(),
|
||||
"vhosts": vhosts
|
||||
}))
|
||||
|
||||
+91
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.24.5",
|
||||
"version": "0.24.7",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
@@ -913,6 +913,35 @@
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"transform_resources": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_reserved_bytes": { "type": "integer", "format": "int64" },
|
||||
"memory_budget_bytes": { "type": "integer", "format": "int64" },
|
||||
"inflight": { "type": "integer", "format": "int64" },
|
||||
"max_inflight": { "type": "integer", "format": "int64" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"memory_accounting": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"process_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
|
||||
"cache_hot_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_hot_limit_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_transient_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_transient_limit_bytes": { "type": "integer", "format": "int64" },
|
||||
"cache_total_bytes": { "type": "integer", "format": "int64" },
|
||||
"transform_reserved_bytes": { "type": "integer", "format": "int64" },
|
||||
"transform_budget_bytes": { "type": "integer", "format": "int64" },
|
||||
"transform_inflight": { "type": "integer", "format": "int64" },
|
||||
"transform_max_inflight": { "type": "integer", "format": "int64" },
|
||||
"non_cache_rss_bytes": { "type": "integer", "format": "int64", "nullable": true },
|
||||
"non_cache_rss_includes_transform": { "type": "boolean" },
|
||||
"method": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"cache": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -1265,6 +1294,16 @@
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"memory_preset": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"enum": [
|
||||
"conservative",
|
||||
"recommended",
|
||||
"performance"
|
||||
],
|
||||
"description": "Last cache memory sizing preset explicitly selected in Settings. Numeric limits remain authoritative."
|
||||
},
|
||||
"max_bytes": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
@@ -1309,6 +1348,14 @@
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"cold_preset": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"enum": [
|
||||
"recommended"
|
||||
],
|
||||
"description": "Last COLD policy preset explicitly selected in Settings."
|
||||
},
|
||||
"cold_path": {
|
||||
"type": "string",
|
||||
"default": "cache/cold"
|
||||
@@ -2702,6 +2749,17 @@
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"preset": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"enum": [
|
||||
"conservative",
|
||||
"recommended",
|
||||
"performance",
|
||||
"uber"
|
||||
],
|
||||
"description": "Last resource preset selected in the administration UI. Numeric limits remain authoritative."
|
||||
},
|
||||
"javascript": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
@@ -2745,6 +2803,20 @@
|
||||
"minimum": 1024,
|
||||
"maximum": 33554432,
|
||||
"default": 2097152
|
||||
},
|
||||
"memory_budget_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1048576,
|
||||
"maximum": 2147483648,
|
||||
"default": 134217728,
|
||||
"description": "Process-wide ceiling for estimated temporary transform buffers. The budget is not pre-allocated."
|
||||
},
|
||||
"max_inflight": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1024,
|
||||
"default": 32,
|
||||
"description": "Process-wide maximum number of active transformation attempts. Excess work fails open unchanged."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3163,6 +3235,22 @@
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/TransformSettings"
|
||||
},
|
||||
"resources": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"memory_reserved_bytes": { "type": "integer", "format": "int64" },
|
||||
"memory_budget_bytes": { "type": "integer", "format": "int64" },
|
||||
"inflight": { "type": "integer", "format": "int64" },
|
||||
"max_inflight": { "type": "integer", "format": "int64" }
|
||||
},
|
||||
"required": [
|
||||
"memory_reserved_bytes",
|
||||
"memory_budget_bytes",
|
||||
"inflight",
|
||||
"max_inflight"
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -3210,6 +3298,7 @@
|
||||
},
|
||||
"required": [
|
||||
"settings",
|
||||
"resources",
|
||||
"metrics",
|
||||
"vhosts"
|
||||
]
|
||||
@@ -4243,7 +4332,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
|
||||
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
|
||||
"security": [],
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
+116
-4
@@ -70,6 +70,7 @@ async function loadLanguages() {
|
||||
$("language").value = language;
|
||||
}
|
||||
let latestStatus = null, certificateInventory = new Map();
|
||||
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
||||
function translate() {
|
||||
clearTimeout(toastTimer); hideToast();
|
||||
document.documentElement.lang = locale(); document.documentElement.dir = activePack()?.meta.direction || "ltr";
|
||||
@@ -633,6 +634,11 @@ function renderDiagnostics(status, appendSample = true) {
|
||||
$("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`;
|
||||
$("cache-memory-breakdown").textContent = `${fmtBytes(c.resident_bytes)} / ${fmtBytes(c.transient_bytes)}`;
|
||||
$("cache-transient-breakdown").textContent = `${fmtBytes(c.fill_bytes_active)} / ${fmtBytes(c.cold_queue_bytes)} / ${fmtBytes((Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0))}`;
|
||||
const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value));
|
||||
const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value));
|
||||
$("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true
|
||||
? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` · ${coldObjects} ${t("cacheColdStorageObjects")}`}`
|
||||
: t("notEnabled");
|
||||
$("cache-evictions").textContent = `${fmt(c.evictions)} / ${fmt(c.expired)}`;
|
||||
if (appendSample) {
|
||||
if (c.resident_bytes != null) { cacheMemorySamples.push(Number(c.resident_bytes)); cacheMemorySamples = cacheMemorySamples.slice(-60); }
|
||||
@@ -664,6 +670,16 @@ function renderDiagnostics(status, appendSample = true) {
|
||||
setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale()));
|
||||
setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
|
||||
setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
|
||||
const memory = status.memory_accounting || {};
|
||||
const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value));
|
||||
const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value));
|
||||
const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value));
|
||||
const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value));
|
||||
setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory);
|
||||
setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory);
|
||||
setOptionalMetric("memory-transform", transformMemory != null, transformMemory);
|
||||
setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight);
|
||||
setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
|
||||
setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
|
||||
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("process-rss-file", rssFile != null, rssFile);
|
||||
@@ -736,7 +752,7 @@ function renderSettings() {
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.5";
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.7";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
@@ -757,12 +773,15 @@ function renderSettings() {
|
||||
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
|
||||
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
|
||||
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age;
|
||||
setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null);
|
||||
setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null);
|
||||
syncColdCachePolicy();
|
||||
syncAdvancedCachePolicy();
|
||||
updateCacheMemorySizing();
|
||||
}
|
||||
if (!settingsDirty.has("transform-form")) {
|
||||
const tr = config.transform || {};
|
||||
setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null);
|
||||
$("transform-enabled").checked = !!tr.enabled;
|
||||
$("transform-javascript").checked = tr.javascript !== false;
|
||||
$("transform-css").checked = tr.css !== false;
|
||||
@@ -771,6 +790,9 @@ function renderSettings() {
|
||||
$("transform-json").checked = !!tr.json;
|
||||
$("transform-extensions").value = (tr.extensions || ["js", "mjs", "css", "html", "htm", "svg", "json"]).join("\n");
|
||||
$("transform-max-body").value = (tr.max_body_bytes ?? 2097152) / 1024;
|
||||
$("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576;
|
||||
$("transform-max-inflight").value = tr.max_inflight ?? 32;
|
||||
updateTransformResourceSizing();
|
||||
}
|
||||
if (!settingsDirty.has("logging-form")) {
|
||||
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
|
||||
@@ -814,6 +836,29 @@ function cachePresetValues(kind, bytes) {
|
||||
const entries = Math.max(1024, Math.min(16384, Math.round(hot * 16)));
|
||||
return { hot, transient, queue, objectKiB: objectMiB * 1024, entries, eviction: profile.eviction };
|
||||
}
|
||||
function cacheMemoryPresetName(kind) {
|
||||
return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom");
|
||||
}
|
||||
function setCacheMemoryPresetSelection(kind) {
|
||||
selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null;
|
||||
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => {
|
||||
const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset;
|
||||
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
const badge = $("cache-memory-preset-selected");
|
||||
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`;
|
||||
}
|
||||
function setCacheColdPresetSelection(kind) {
|
||||
selectedCacheColdPreset = kind === "recommended" ? "recommended" : null;
|
||||
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => {
|
||||
const active = button.dataset.cacheColdPreset === selectedCacheColdPreset;
|
||||
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
const badge = $("cache-cold-preset-selected");
|
||||
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`;
|
||||
}
|
||||
function updateCacheMemorySizing() {
|
||||
const detected = effectiveCacheMemory(), badge = $("cache-memory-detected"), warning = $("cache-memory-warning");
|
||||
if (!badge || !warning) return;
|
||||
@@ -854,8 +899,65 @@ function applyCacheMemoryPreset(kind) {
|
||||
$("cache-memory").value = preset.hot; $("cache-transient-memory").value = preset.transient;
|
||||
$("cache-object").value = preset.objectKiB; $("cache-entries").value = preset.entries;
|
||||
$("cache-eviction-limit").value = preset.eviction; $("cache-cold-queue-bytes").value = preset.queue;
|
||||
setCacheMemoryPresetSelection(kind);
|
||||
setSettingsDirty("cache-form"); updateCacheMemorySizing();
|
||||
}
|
||||
function applyRecommendedColdCachePreset() {
|
||||
const transient = Number($("cache-transient-memory").value);
|
||||
const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32;
|
||||
$("cache-cold-enabled").checked = true;
|
||||
if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold";
|
||||
$("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false;
|
||||
$("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true;
|
||||
$("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600;
|
||||
$("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256;
|
||||
$("cache-cold-queue-bytes").value = Math.min(queue, transient || queue);
|
||||
setCacheColdPresetSelection("recommended");
|
||||
syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing();
|
||||
}
|
||||
function transformPresetValues(kind) {
|
||||
return {
|
||||
conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 },
|
||||
recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 },
|
||||
performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 },
|
||||
uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 }
|
||||
}[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 };
|
||||
}
|
||||
function transformPresetName(kind) {
|
||||
return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom");
|
||||
}
|
||||
function setTransformPresetSelection(kind) {
|
||||
selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null;
|
||||
document.querySelectorAll("[data-transform-preset]").forEach(button => {
|
||||
const active = button.dataset.transformPreset === selectedTransformPreset;
|
||||
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
const badge = $("transform-preset-selected");
|
||||
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`;
|
||||
}
|
||||
function updateTransformResourceSizing() {
|
||||
const warning = $("transform-memory-warning"); if (!warning) return;
|
||||
const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value);
|
||||
const detected = effectiveCacheMemory();
|
||||
const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024;
|
||||
const worstEncodedEstimate = bodyBytes * 3;
|
||||
const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate;
|
||||
const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5;
|
||||
warning.hidden = !(budgetCannotFitMax || highHostShare);
|
||||
if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning");
|
||||
else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`;
|
||||
const summary = $("transform-preset-summary");
|
||||
if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB · ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB · ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`;
|
||||
}
|
||||
function applyTransformPreset(kind) {
|
||||
const preset = transformPresetValues(kind);
|
||||
$("transform-memory-budget").value = preset.memoryMiB;
|
||||
$("transform-max-body").value = preset.bodyKiB;
|
||||
$("transform-max-inflight").value = preset.inflight;
|
||||
setTransformPresetSelection(kind);
|
||||
setSettingsDirty("transform-form"); updateTransformResourceSizing();
|
||||
}
|
||||
function syncServerMode() { $("server-value").disabled = $("server-mode").value !== "custom"; $("server-value").required = !$("server-value").disabled; }
|
||||
function syncLoggingFileMode() {
|
||||
const enabled = $("log-file-enabled").checked, perVhost = $("log-file-per-vhost").checked;
|
||||
@@ -903,18 +1005,21 @@ bindSettingsForm("response-form", next => {
|
||||
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
|
||||
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
|
||||
bindSettingsForm("cache-form", next => {
|
||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
|
||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
|
||||
}, "cache");
|
||||
bindSettingsForm("transform-form", next => {
|
||||
next.transform = {
|
||||
enabled: $("transform-enabled").checked,
|
||||
preset: selectedTransformPreset,
|
||||
javascript: $("transform-javascript").checked,
|
||||
css: $("transform-css").checked,
|
||||
html: $("transform-html").checked,
|
||||
svg: $("transform-svg").checked,
|
||||
json: $("transform-json").checked,
|
||||
extensions: $("transform-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean),
|
||||
max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024)
|
||||
max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024),
|
||||
memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576),
|
||||
max_inflight: Number($("transform-max-inflight").value)
|
||||
};
|
||||
}, "transform");
|
||||
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
|
||||
@@ -995,7 +1100,14 @@ $("log-file-enabled").addEventListener("change", syncLoggingFileMode);
|
||||
$("log-file-per-vhost").addEventListener("change", syncLoggingFileMode);
|
||||
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy);
|
||||
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset)));
|
||||
for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-cold-queue-bytes"]) $(id).addEventListener("input", updateCacheMemorySizing);
|
||||
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset));
|
||||
document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset)));
|
||||
for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); });
|
||||
$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); });
|
||||
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) {
|
||||
for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null));
|
||||
}
|
||||
for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); });
|
||||
for (const id of ["cache-collapse-enabled", "cache-proactive-enabled"]) $(id).addEventListener("change", syncAdvancedCachePolicy);
|
||||
syncColdCachePolicy();
|
||||
syncAdvancedCachePolicy();
|
||||
|
||||
+78
-10
@@ -265,6 +265,10 @@
|
||||
<dt data-i18n="cacheTransientBreakdown">Fill / cold queue / cold I/O</dt>
|
||||
<dd id="cache-transient-breakdown">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="cacheColdStorageUsage">COLD storage / budget</dt>
|
||||
<dd id="cache-cold-storage">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="cacheEvictions">Evicted / expired</dt>
|
||||
<dd id="cache-evictions">-</dd>
|
||||
@@ -339,6 +343,26 @@
|
||||
<dt data-i18n="processRssTotal">RSS total</dt>
|
||||
<dd id="process-rss-total">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryCacheHot">HOT cache RAM / limit</dt>
|
||||
<dd id="memory-cache-hot">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryCacheTransient">Cache transient RAM / limit</dt>
|
||||
<dd id="memory-cache-transient">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryTransform">Transform reserved RAM / budget</dt>
|
||||
<dd id="memory-transform">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryTransformInflight">Active transforms / limit</dt>
|
||||
<dd id="memory-transform-inflight">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="memoryNonCacheRss">Non-cache process RSS</dt>
|
||||
<dd id="memory-non-cache-rss">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt data-i18n="processRssAnon">Anonymous RSS</dt>
|
||||
<dd id="process-rss-anon">-</dd>
|
||||
@@ -356,8 +380,7 @@
|
||||
<dd id="cgroup-memory">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="fine" data-i18n="memoryAccountingHint">OS RSS categories and RustProxy cache
|
||||
accounting are overlapping views; do not add them together.</p>
|
||||
<p class="fine" data-i18n="memoryAccountingHint">RSS is the real process footprint. HOT/transient cache values are RustProxy-accounted allocations; transform memory is a conservative active reservation. Non-cache RSS is RSS minus accounted cache and includes transforms, connections, TLS, logging, Tokio/runtime state, allocator overhead and other buffers, so these rows are not all additive.</p>
|
||||
<div class="mini-live-chart-stack">
|
||||
<div class="mini-live-chart">
|
||||
<div class="mini-live-chart-head"><span data-i18n="processCpuHistory">Process CPU
|
||||
@@ -1099,6 +1122,10 @@
|
||||
id="tp-skipped">-</strong><small id="tp-skip-top">-</small></article>
|
||||
<article class="stat"><span data-i18n="transformErrors">Body/transport errors</span><strong
|
||||
id="tp-errors">-</strong><small id="tp-duration">-</small></article>
|
||||
<article class="stat"><span data-i18n="transformMemoryUsage">Transform RAM reservation</span><strong
|
||||
id="tp-memory">-</strong><small id="tp-memory-detail">-</small></article>
|
||||
<article class="stat"><span data-i18n="transformConcurrencyUsage">Active transform slots</span><strong
|
||||
id="tp-inflight">-</strong><small id="tp-inflight-detail">-</small></article>
|
||||
</div>
|
||||
<div class="diagnostics-grid transform-diagnostics">
|
||||
<article class="card">
|
||||
@@ -1326,7 +1353,7 @@
|
||||
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
|
||||
</select>
|
||||
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.5"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.24.7"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -1377,8 +1404,9 @@
|
||||
multipart/unsupported ranges pass through.</p>
|
||||
<div class="card soft-card cache-memory-sizing">
|
||||
<div class="card-heading">
|
||||
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3><span class="pill"
|
||||
id="cache-memory-detected">-</span>
|
||||
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
|
||||
<span class="pill" id="cache-memory-preset-selected">-</span>
|
||||
<span class="pill" id="cache-memory-detected">-</span>
|
||||
</div>
|
||||
<p class="fine" data-i18n="cacheMemorySizingHelp">Presets use the effective
|
||||
memory available to RustProxy (host RAM capped by cgroup when present). They
|
||||
@@ -1387,7 +1415,7 @@
|
||||
<div class="form-actions">
|
||||
<button class="outline" data-cache-memory-preset="conservative"
|
||||
data-i18n="cachePresetConservative" type="button">Conservative</button>
|
||||
<button class="primary" data-cache-memory-preset="recommended"
|
||||
<button class="outline" data-cache-memory-preset="recommended"
|
||||
data-i18n="cachePresetRecommended" type="button">Recommended</button>
|
||||
<button class="outline" data-cache-memory-preset="performance"
|
||||
data-i18n="cachePresetPerformance" type="button">Performance</button>
|
||||
@@ -1480,6 +1508,17 @@
|
||||
deterministic sampling and a global concurrency cap to avoid refresh storms.</p>
|
||||
</div>
|
||||
<div data-cache-settings-page="cold" hidden>
|
||||
<div class="card soft-card cache-memory-sizing">
|
||||
<div class="card-heading">
|
||||
<h3 data-i18n="cacheColdPresetTitle">COLD policy preset</h3>
|
||||
<span class="pill" id="cache-cold-preset-selected">-</span>
|
||||
</div>
|
||||
<p class="fine" data-i18n="cacheColdPresetHelp">The recommended policy enables idle/LRU/shutdown demotion, uses a queue sized from transient RAM and conservative GC timings. Disk path, disk budget and object limit are left unchanged.</p>
|
||||
<div class="form-actions">
|
||||
<button class="outline" data-cache-cold-preset="recommended"
|
||||
data-i18n="cachePresetRecommended" type="button">Recommended</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="check"><input id="cache-cold-enabled" type="checkbox" /><span
|
||||
data-i18n="cacheColdEnable">Enable persistent disk tier</span></label>
|
||||
<p class="fine" data-i18n="cacheColdHelp">Idle and LRU hot objects are demoted to
|
||||
@@ -1569,6 +1608,38 @@
|
||||
<p class="fine" data-i18n="transformHelp">Transformation happens after upstream decoding
|
||||
and before cache storage and client compression. Each vhost must opt in separately.
|
||||
</p>
|
||||
<div class="card soft-card cache-memory-sizing">
|
||||
<div class="card-heading">
|
||||
<h3 data-i18n="transformResourcePresetTitle">Resource preset</h3>
|
||||
<span class="pill" id="transform-preset-selected">-</span>
|
||||
</div>
|
||||
<p class="fine" data-i18n="transformResourcePresetHelp">Presets tune the temporary
|
||||
memory ceiling, maximum transformable response size and global concurrent
|
||||
transformation limit. Content types and extension allow-lists are left unchanged.</p>
|
||||
<div class="form-actions">
|
||||
<button class="outline" data-i18n="transformPresetConservative"
|
||||
data-transform-preset="conservative" type="button">RAM saver</button>
|
||||
<button class="outline" data-i18n="transformPresetRecommended"
|
||||
data-transform-preset="recommended" type="button">Recommended</button>
|
||||
<button class="outline" data-i18n="transformPresetPerformance"
|
||||
data-transform-preset="performance" type="button">Performance</button>
|
||||
<button class="outline" data-i18n="transformPresetUber"
|
||||
data-transform-preset="uber" type="button">Uber performance</button>
|
||||
</div>
|
||||
<p class="fine" id="transform-preset-summary">-</p>
|
||||
</div>
|
||||
<p class="fine" id="transform-memory-warning" hidden></p>
|
||||
<div class="field-grid">
|
||||
<div><label data-i18n="transformMemoryBudget" for="transform-memory-budget">Temporary
|
||||
transform memory budget (MiB)</label><input id="transform-memory-budget"
|
||||
max="2048" min="1" required="" step="1" type="number" /></div>
|
||||
<div><label data-i18n="transformMaxBody" for="transform-max-body">Maximum transformable
|
||||
response (KiB)</label><input id="transform-max-body" min="1" max="32768"
|
||||
required="" type="number" /></div>
|
||||
<div><label data-i18n="transformMaxInflight" for="transform-max-inflight">Maximum
|
||||
concurrent transformations</label><input id="transform-max-inflight" min="1"
|
||||
max="1024" required="" type="number" /></div>
|
||||
</div>
|
||||
<div class="switch-grid">
|
||||
<label class="check"><input id="transform-javascript" type="checkbox" /><span
|
||||
data-i18n="transformJavascript">JavaScript minification</span></label>
|
||||
@@ -1584,9 +1655,6 @@
|
||||
<label data-i18n="transformExtensions" for="transform-extensions">Allowed file
|
||||
extensions, one per line</label>
|
||||
<textarea id="transform-extensions" rows="6" spellcheck="false"></textarea>
|
||||
<label data-i18n="transformMaxBody" for="transform-max-body">Maximum transformable
|
||||
response (KiB)</label>
|
||||
<input id="transform-max-body" min="1" max="65536" required="" type="number" />
|
||||
<p class="fine" data-i18n="transformSafety">Cache-Control: no-transform is always
|
||||
respected. Transformed representations receive a new ETag and are cached before
|
||||
gzip, Brotli or zstd encoding.</p>
|
||||
@@ -1992,7 +2060,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.24.5</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.24.7</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -134,6 +134,13 @@
|
||||
$("tp-skipped").textContent = number(counters.skipped);
|
||||
$("tp-errors").textContent = number(counters.errors);
|
||||
$("tp-duration").textContent = `${t("transformAvgTime")}: ${averageMs(counters)}`;
|
||||
const resources = data?.resources || {};
|
||||
const reserved = Number(resources.memory_reserved_bytes), budget = Number(resources.memory_budget_bytes);
|
||||
const inflight = Number(resources.inflight), maxInflight = Number(resources.max_inflight);
|
||||
$("tp-memory").textContent = Number.isFinite(reserved) ? fmtBytes(reserved) : "-";
|
||||
$("tp-memory-detail").textContent = Number.isFinite(budget) ? `${t("transformBudgetShort")}: ${fmtBytes(budget)}${budget > 0 && Number.isFinite(reserved) ? ` · ${fmt(reserved / budget * 100, 1)}%` : ""}` : "-";
|
||||
$("tp-inflight").textContent = Number.isFinite(inflight) ? number(inflight) : "-";
|
||||
$("tp-inflight-detail").textContent = Number.isFinite(maxInflight) ? `${t("cacheLimitShort")}: ${number(maxInflight)}` : "-";
|
||||
renderTypes(counters); renderReasons(counters); renderVhosts(data);
|
||||
$("transform-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
|
||||
}
|
||||
|
||||
@@ -600,7 +600,7 @@
|
||||
authMode.addEventListener("change", syncAuth); syncAuth();
|
||||
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
|
||||
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
|
||||
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.5", "text", { maxLength: 256 });
|
||||
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.7", "text", { maxLength: 256 });
|
||||
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
|
||||
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
|
||||
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
|
||||
|
||||
Vendored
+66
-30
@@ -720,22 +720,37 @@ impl Cache {
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_cold_delete(&self, paths: Vec<PathBuf>) {
|
||||
fn enqueue_cold_delete(&self, mut paths: Vec<PathBuf>) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(tx) = &self.cold_tx {
|
||||
if tx.try_send(ColdCommand::Delete(paths.clone())).is_ok() {
|
||||
return;
|
||||
match tx.try_send(ColdCommand::Delete(paths)) {
|
||||
Ok(()) => return,
|
||||
Err(error) => {
|
||||
paths = match error.into_inner() {
|
||||
ColdCommand::Delete(paths) => paths,
|
||||
_ => unreachable!("enqueue_cold_delete only sends delete commands"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Queue saturation must not block request processing. The index is already
|
||||
// updated; a best-effort detached delete keeps physical cleanup asynchronous.
|
||||
tokio::spawn(async move {
|
||||
// Do not create an unbounded number of detached delete tasks when the
|
||||
// writer queue is saturated. A tiny fallback pool absorbs short bursts;
|
||||
// sustained saturation falls back to synchronous removal so stale files
|
||||
// cannot be resurrected on restart at the cost of bounded backpressure.
|
||||
if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() {
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
for path in paths {
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for path in paths {
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_cold_candidates(
|
||||
@@ -748,8 +763,13 @@ impl Cache {
|
||||
const SNAPSHOT_BATCH: usize = 256;
|
||||
const DELETE_BATCH: usize = 32;
|
||||
|
||||
let requested_limit = request
|
||||
.and_then(|value| value.oldest)
|
||||
.unwrap_or(max_delete)
|
||||
.min(max_delete);
|
||||
let manual = request.is_some();
|
||||
let now = SystemTime::now();
|
||||
let mut candidates = Vec::new();
|
||||
let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1));
|
||||
let mut after_sequence = 0u64;
|
||||
|
||||
// Copy candidate metadata in bounded chunks. Request-path lookups only
|
||||
@@ -788,27 +808,32 @@ impl Cache {
|
||||
})
|
||||
});
|
||||
if matches {
|
||||
candidates.push((sequence, key, hits, last_access, stored_at));
|
||||
let priority = if manual {
|
||||
CleanupPriority::Manual(stored_at, last_access, hits)
|
||||
} else {
|
||||
CleanupPriority::Automatic(hits, last_access, stored_at)
|
||||
};
|
||||
let candidate = CleanupCandidate {
|
||||
priority,
|
||||
sequence,
|
||||
key,
|
||||
};
|
||||
if candidates.len() < requested_limit {
|
||||
candidates.push(candidate);
|
||||
} else if candidates
|
||||
.peek()
|
||||
.is_some_and(|worst| candidate.cmp(worst).is_lt())
|
||||
{
|
||||
candidates.pop();
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
if request.is_some() {
|
||||
candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| {
|
||||
(*stored_at, *last_access, *hits)
|
||||
});
|
||||
} else {
|
||||
// Automatic GC prefers rarely used, least recently used, oldest data.
|
||||
candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| {
|
||||
(*hits, *last_access, *stored_at)
|
||||
});
|
||||
}
|
||||
|
||||
let requested_limit = request
|
||||
.and_then(|value| value.oldest)
|
||||
.unwrap_or(max_delete)
|
||||
.min(max_delete);
|
||||
let mut candidates = candidates.into_vec();
|
||||
candidates.sort_unstable();
|
||||
let mut result = ColdCleanupResult::default();
|
||||
let mut delete = Vec::new();
|
||||
|
||||
@@ -818,7 +843,7 @@ impl Cache {
|
||||
}
|
||||
{
|
||||
let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (sequence, key, _, _, _) in chunk {
|
||||
for candidate in chunk {
|
||||
if result.removed_entries >= requested_limit {
|
||||
break;
|
||||
}
|
||||
@@ -835,18 +860,21 @@ impl Cache {
|
||||
}
|
||||
if !cold
|
||||
.entries
|
||||
.get(key)
|
||||
.is_some_and(|entry| entry.sequence == *sequence && entry.pinned_reads == 0)
|
||||
.get(&candidate.key)
|
||||
.is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = cold.remove(key) {
|
||||
if let Some(entry) = cold.remove(&candidate.key) {
|
||||
result.removed_entries += 1;
|
||||
result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes);
|
||||
delete.extend([entry.meta_path, entry.body_path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep pending deletion metadata bounded to one selection batch; a
|
||||
// 10k-entry manual cleanup must not build one large Vec<PathBuf>.
|
||||
self.enqueue_cold_delete(std::mem::take(&mut delete));
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
@@ -855,11 +883,13 @@ impl Cache {
|
||||
.cold_evictions
|
||||
.fetch_add(result.removed_entries as u64, Ordering::Relaxed);
|
||||
}
|
||||
self.enqueue_cold_delete(delete);
|
||||
result
|
||||
}
|
||||
|
||||
async fn cold_gc_pass(&self) -> ColdCleanupResult {
|
||||
let Ok(_cleanup_slot) = self.cleanup_slots.clone().try_acquire_owned() else {
|
||||
return ColdCleanupResult::default();
|
||||
};
|
||||
let settings = self
|
||||
.tier_settings
|
||||
.lock()
|
||||
@@ -903,6 +933,11 @@ impl Cache {
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
ensure!(settings.cold_enabled, "cold cache is disabled");
|
||||
let cleanup_slot = self
|
||||
.cleanup_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?;
|
||||
let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
let job = ColdCleanupJob {
|
||||
id,
|
||||
@@ -924,6 +959,7 @@ impl Cache {
|
||||
}
|
||||
let cache = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let _cleanup_slot = cleanup_slot;
|
||||
{
|
||||
let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(job) = jobs.get_mut(&id) {
|
||||
|
||||
Vendored
+13
@@ -210,6 +210,19 @@ impl Cache {
|
||||
.proactive_scheduled_keys
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
// Each scheduled key owns a sleeping Tokio task plus refresh context.
|
||||
// Bound the pending set independently from the cache entry count so a
|
||||
// large cache with long TTLs cannot turn into thousands of resident tasks.
|
||||
let pending_limit = settings
|
||||
.proactive_refresh_max_inflight
|
||||
.saturating_mul(64)
|
||||
.clamp(64, 256);
|
||||
if scheduled.len() >= pending_limit && !scheduled.contains(&ticket.key) {
|
||||
self.counters
|
||||
.proactive_skipped
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
if !scheduled.insert(ticket.key) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -38,6 +38,8 @@ impl Cache {
|
||||
cold_queue_limit_bytes: settings.cold_queue_max_bytes,
|
||||
cold_entries: cold.entries.len(),
|
||||
cold_bytes: cold.bytes,
|
||||
cold_limit_bytes: settings.cold_max_bytes,
|
||||
cold_limit_entries: settings.cold_max_entries,
|
||||
cold_hits: n(&self.counters.cold_hits),
|
||||
cold_promotions: n(&self.counters.cold_promotions),
|
||||
hot_demotions: n(&self.counters.hot_demotions),
|
||||
@@ -121,6 +123,8 @@ impl Cache {
|
||||
),
|
||||
("cold_entries", "gauge", view.cold_entries as u64),
|
||||
("cold_bytes", "gauge", view.cold_bytes),
|
||||
("cold_limit_bytes", "gauge", view.cold_limit_bytes),
|
||||
("cold_limit_entries", "gauge", view.cold_limit_entries as u64),
|
||||
("cold_hits_total", "counter", view.cold_hits),
|
||||
("cold_promotions_total", "counter", view.cold_promotions),
|
||||
("hot_demotions_total", "counter", view.hot_demotions),
|
||||
|
||||
Vendored
+4
@@ -10,6 +10,8 @@ impl Cache {
|
||||
tier_settings: Mutex::new(Settings::default()),
|
||||
data_root: None,
|
||||
cold_tx: None,
|
||||
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
|
||||
cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS)),
|
||||
fills: Arc::new(Semaphore::new(FILL_SLOTS)),
|
||||
fill_bytes_active: AtomicU64::new(0),
|
||||
transient_bytes_active: Arc::new(AtomicU64::new(0)),
|
||||
@@ -47,6 +49,8 @@ impl Cache {
|
||||
tier_settings: Mutex::new(settings),
|
||||
data_root,
|
||||
cold_tx: Some(tx),
|
||||
cold_delete_fallbacks: Arc::new(Semaphore::new(COLD_DELETE_FALLBACKS)),
|
||||
cleanup_slots: Arc::new(Semaphore::new(COLD_CLEANUP_SLOTS)),
|
||||
fills: Arc::new(Semaphore::new(FILL_SLOTS)),
|
||||
fill_bytes_active: AtomicU64::new(0),
|
||||
transient_bytes_active: Arc::new(AtomicU64::new(0)),
|
||||
|
||||
Vendored
+44
-1
@@ -6,7 +6,7 @@ use http_body::{Body as HttpBody, Frame, SizeHint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
collections::{BTreeMap, BinaryHeap, HashMap, HashSet},
|
||||
path::{Component, Path, PathBuf},
|
||||
pin::Pin,
|
||||
sync::{
|
||||
@@ -21,12 +21,28 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_HEADER_BYTES: usize = 32 * 1024;
|
||||
const FILL_SLOTS: usize = 8;
|
||||
const COLD_DELETE_FALLBACKS: usize = 2;
|
||||
const COLD_CLEANUP_SLOTS: usize = 1;
|
||||
const BACKGROUND_REVALIDATION_LIMIT: usize = 8;
|
||||
// Cold writes can contain complete response bodies. Keep the queue deliberately
|
||||
// small so demotion cannot become a second, hidden RAM cache while disk is slow.
|
||||
const COLD_QUEUE: usize = 16;
|
||||
const COLD_FORMAT_VERSION: u8 = 3;
|
||||
type Key = [u8; 32];
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
enum CleanupPriority {
|
||||
Automatic(u64, SystemTime, SystemTime),
|
||||
Manual(SystemTime, SystemTime, u64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct CleanupCandidate {
|
||||
priority: CleanupPriority,
|
||||
sequence: u64,
|
||||
key: Key,
|
||||
}
|
||||
|
||||
/// `Bytes` releases its allocation when the last reference is dropped, but
|
||||
/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges,
|
||||
/// return releasable pages to the OS instead of letting RSS stay artificially
|
||||
@@ -59,10 +75,28 @@ impl Default for CollapseTimeoutFallback {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryPreset {
|
||||
Conservative,
|
||||
Recommended,
|
||||
Performance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColdPreset {
|
||||
Recommended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub enabled: bool,
|
||||
/// UI sizing profile last explicitly selected by the administrator. The
|
||||
/// actual numeric limits remain authoritative; this only preserves intent
|
||||
/// across reloads so the dashboard can show which preset was applied.
|
||||
pub memory_preset: Option<MemoryPreset>,
|
||||
/// Hard RAM budget for published HOT entries, charged by backing allocation
|
||||
/// plus directly owned metadata rather than only logical body length.
|
||||
pub max_bytes: usize,
|
||||
@@ -80,6 +114,9 @@ pub struct Settings {
|
||||
pub hot_eviction_max_objects: usize,
|
||||
/// Persistent cold tier. Disabled by default to avoid implicit disk writes.
|
||||
pub cold_enabled: bool,
|
||||
/// Last explicitly selected COLD policy profile, persisted with the cache
|
||||
/// configuration for the same reason as `memory_preset`.
|
||||
pub cold_preset: Option<ColdPreset>,
|
||||
/// Relative to the RustProxy data directory.
|
||||
pub cold_path: String,
|
||||
pub cold_max_bytes: u64,
|
||||
@@ -135,6 +172,7 @@ impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
memory_preset: None,
|
||||
max_bytes: 256 * 1024 * 1024,
|
||||
max_object_bytes: 16 * 1024 * 1024,
|
||||
max_entries: 4096,
|
||||
@@ -142,6 +180,7 @@ impl Default for Settings {
|
||||
cold_queue_max_bytes: 32 * 1024 * 1024,
|
||||
hot_eviction_max_objects: 64,
|
||||
cold_enabled: false,
|
||||
cold_preset: None,
|
||||
cold_path: "cache/cold".into(),
|
||||
cold_max_bytes: 4 * 1024 * 1024 * 1024,
|
||||
cold_max_entries: 50_000,
|
||||
@@ -572,6 +611,8 @@ pub struct View {
|
||||
pub cold_queue_limit_bytes: usize,
|
||||
pub cold_entries: usize,
|
||||
pub cold_bytes: u64,
|
||||
pub cold_limit_bytes: u64,
|
||||
pub cold_limit_entries: usize,
|
||||
pub cold_hits: u64,
|
||||
pub cold_promotions: u64,
|
||||
pub hot_demotions: u64,
|
||||
@@ -1067,6 +1108,8 @@ pub struct Cache {
|
||||
tier_settings: Mutex<Settings>,
|
||||
data_root: Option<PathBuf>,
|
||||
cold_tx: Option<mpsc::Sender<ColdCommand>>,
|
||||
cold_delete_fallbacks: Arc<Semaphore>,
|
||||
cleanup_slots: Arc<Semaphore>,
|
||||
fills: Arc<Semaphore>,
|
||||
fill_bytes_active: AtomicU64,
|
||||
transient_bytes_active: Arc<AtomicU64>,
|
||||
|
||||
Vendored
+109
@@ -662,6 +662,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cache_memory_defaults_are_bounded_and_sane() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.memory_preset, None);
|
||||
assert_eq!(s.cold_preset, None);
|
||||
assert_eq!(s.max_bytes, 256 * 1024 * 1024);
|
||||
assert_eq!(s.max_object_bytes, 16 * 1024 * 1024);
|
||||
assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024);
|
||||
@@ -672,6 +674,19 @@ mod tests {
|
||||
assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_preset_intent_round_trips_with_configuration() {
|
||||
let mut settings = Settings::default();
|
||||
settings.memory_preset = Some(MemoryPreset::Recommended);
|
||||
settings.cold_preset = Some(ColdPreset::Recommended);
|
||||
let encoded = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(encoded["memory_preset"], "recommended");
|
||||
assert_eq!(encoded["cold_preset"], "recommended");
|
||||
let decoded: Settings = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended));
|
||||
assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_budget_is_hard_and_released_with_lease() {
|
||||
let cache = Cache::new(1);
|
||||
@@ -2142,6 +2157,84 @@ mod v022_cache_tests {
|
||||
assert_eq!(cache.view().proactive_inflight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proactive_refresh_pending_tasks_are_bounded() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.proactive_refresh_enabled = true;
|
||||
settings.proactive_refresh_percent = 100;
|
||||
settings.proactive_refresh_max_inflight = 1;
|
||||
let mut guards = Vec::new();
|
||||
for index in 0..64 {
|
||||
let ticket = cache
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"refresh.example",
|
||||
"origin",
|
||||
&format!("/asset-{index}"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
guards.push(
|
||||
cache
|
||||
.begin_proactive_schedule(&ticket, &settings)
|
||||
.expect("schedule below pending limit"),
|
||||
);
|
||||
}
|
||||
let overflow = cache
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"refresh.example",
|
||||
"origin",
|
||||
"/overflow",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
|
||||
assert!(cache.view().proactive_skipped > 0);
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_revalidation_set_has_a_hard_limit() {
|
||||
let cache = Cache::new(22);
|
||||
let base = Revalidation {
|
||||
headers: HeaderMap::new(),
|
||||
body: Bytes::new(),
|
||||
body_allocated: 0,
|
||||
_transient_leases: Vec::new(),
|
||||
hits: 0,
|
||||
transformed: false,
|
||||
storage_key: [0; 32],
|
||||
resident_age_at_lookup: Duration::ZERO,
|
||||
skip_compression: false,
|
||||
reusable_without_validation: false,
|
||||
age: 0,
|
||||
ttl: 0,
|
||||
stale_if_error: 0,
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
origin_etag: None,
|
||||
origin_last_modified: None,
|
||||
};
|
||||
let mut guards = Vec::new();
|
||||
for index in 0..BACKGROUND_REVALIDATION_LIMIT {
|
||||
let mut cached = base.clone();
|
||||
cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes());
|
||||
guards.push(
|
||||
cache
|
||||
.begin_background_revalidation(&cached)
|
||||
.expect("revalidation below hard limit"),
|
||||
);
|
||||
}
|
||||
let mut overflow = base;
|
||||
overflow.storage_key = [0xff; 32];
|
||||
assert!(cache.begin_background_revalidation(&overflow).is_none());
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
|
||||
let cache = Cache::new(22);
|
||||
@@ -3036,6 +3129,22 @@ mod cold_tier_tests {
|
||||
panic!("cold cleanup did not finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_cold_cleanup_is_single_flight() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
|
||||
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
|
||||
let result = cache.start_cold_cleanup(ColdCleanupRequest {
|
||||
unused_for_seconds: None,
|
||||
oldest: Some(1),
|
||||
hits_zero: false,
|
||||
});
|
||||
assert!(result.is_err());
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Vendored
+3
@@ -310,6 +310,9 @@ impl Cache {
|
||||
// representation so an SWR refresh for one variant cannot suppress another.
|
||||
let key = cached.storage_key;
|
||||
let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if active.len() >= BACKGROUND_REVALIDATION_LIMIT && !active.contains(&key) {
|
||||
return None;
|
||||
}
|
||||
if !active.insert(key) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -385,7 +385,12 @@ mod tests {
|
||||
let file = dir.path().join("test.sqlite3");
|
||||
let store = Store::open(&file, &Config::default()).await.unwrap();
|
||||
assert_eq!(store.load().await.unwrap().revision, 0);
|
||||
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended);
|
||||
config.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended);
|
||||
config.transform.preset = Some(crate::transform::Preset::Performance);
|
||||
config.transform.memory_budget_bytes = 512 * 1024 * 1024;
|
||||
config.transform.max_inflight = 64;
|
||||
assert!(store.replace(0, &config).await.unwrap());
|
||||
assert!(!store.replace(0, &Config::default()).await.unwrap());
|
||||
assert_eq!(store.recent_audit().await.unwrap().len(), 1);
|
||||
@@ -394,6 +399,20 @@ mod tests {
|
||||
let data = reopened.load().await.unwrap();
|
||||
assert_eq!(data.revision, 1);
|
||||
assert_eq!(data.config.vhosts[0].backends[0].id, "demo");
|
||||
assert_eq!(
|
||||
data.config.cache.memory_preset,
|
||||
Some(crate::cache::MemoryPreset::Recommended)
|
||||
);
|
||||
assert_eq!(
|
||||
data.config.cache.cold_preset,
|
||||
Some(crate::cache::ColdPreset::Recommended)
|
||||
);
|
||||
assert_eq!(
|
||||
data.config.transform.preset,
|
||||
Some(crate::transform::Preset::Performance)
|
||||
);
|
||||
assert_eq!(data.config.transform.memory_budget_bytes, 512 * 1024 * 1024);
|
||||
assert_eq!(data.config.transform.max_inflight, 64);
|
||||
reopened.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ use zeroize::Zeroizing;
|
||||
#[command(
|
||||
name = "rust-proxy",
|
||||
version,
|
||||
about = "RustProxy - independent Rust reverse proxy 0.24.5"
|
||||
about = "RustProxy - independent Rust reverse proxy 0.24.7"
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
@@ -1426,7 +1426,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
|
||||
}
|
||||
});
|
||||
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
|
||||
println!("RustProxy live logs 0.24.5 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
println!("RustProxy live logs 0.24.7 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
|
||||
if interactive { print!("> "); io::stdout().flush()?; }
|
||||
let mut filters = LiveLogFilters::default();
|
||||
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
|
||||
|
||||
+36
-1
@@ -83,8 +83,23 @@ enum CacheInvalidation {
|
||||
/// Keeping it stable across logging/TLS/admin saves lets the persistent cold
|
||||
/// tier survive a restart after unrelated control-plane edits.
|
||||
fn cache_revision(config: &config::Config) -> u64 {
|
||||
// Preset fields are UI intent metadata. Persist them, but do not rotate the
|
||||
// cache compatibility identity (and purge HOT/COLD) when only the label
|
||||
// changes while the effective cache limits stay identical.
|
||||
let mut cache = config.cache.clone();
|
||||
cache.memory_preset = None;
|
||||
cache.cold_preset = None;
|
||||
// Resource-only transform controls decide how much optimization work may
|
||||
// happen concurrently, but do not change the transformed representation.
|
||||
// Keep those out of the cache identity while including representation and
|
||||
// eligibility controls such as enabled kinds, extensions and max body.
|
||||
let mut transform = config.transform.clone();
|
||||
transform.preset = None;
|
||||
transform.memory_budget_bytes = 0;
|
||||
transform.max_inflight = 0;
|
||||
let encoded = serde_json::to_vec(&(
|
||||
&config.cache,
|
||||
&cache,
|
||||
&transform,
|
||||
&config.headers,
|
||||
&config.forwarding,
|
||||
&config.vhosts,
|
||||
@@ -605,6 +620,26 @@ mod cache_revision_tests {
|
||||
});
|
||||
assert_eq!(cache_revision(&docker), identity);
|
||||
|
||||
let mut preset = base.clone();
|
||||
preset.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended);
|
||||
preset.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended);
|
||||
preset.transform.preset = Some(crate::transform::Preset::Uber);
|
||||
preset.transform.memory_budget_bytes = 1024 * 1024 * 1024;
|
||||
preset.transform.max_inflight = 128;
|
||||
assert_eq!(cache_revision(&preset), identity);
|
||||
assert!(matches!(
|
||||
cache_invalidation(&base, &preset),
|
||||
CacheInvalidation::None
|
||||
));
|
||||
|
||||
let mut transform_policy = base.clone();
|
||||
transform_policy.transform.json = !transform_policy.transform.json;
|
||||
assert_ne!(cache_revision(&transform_policy), identity);
|
||||
|
||||
let mut transform_body_limit = base.clone();
|
||||
transform_body_limit.transform.max_body_bytes += 1024;
|
||||
assert_ne!(cache_revision(&transform_body_limit), identity);
|
||||
|
||||
let mut cache = base.clone();
|
||||
cache.cache.max_entries += 1;
|
||||
let cache_identity = cache_revision(&cache);
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
use super::{now_ms, platform_name, NetworkInterfaceView, View};
|
||||
use std::{collections::BTreeMap, process::Command};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Counters {
|
||||
cpu_total: Option<u64>,
|
||||
cpu_idle: Option<u64>,
|
||||
process_cpu_micros: Option<u64>,
|
||||
}
|
||||
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
fn sysctl(name: &str) -> Option<String> {
|
||||
command_output("sysctl", &["-n", name])
|
||||
}
|
||||
|
||||
fn sysctl_u64(names: &[&str]) -> Option<u64> {
|
||||
names.iter().find_map(|name| sysctl(name)?.split_whitespace().next()?.parse().ok())
|
||||
}
|
||||
|
||||
fn cpu_times() -> Option<(u64, u64)> {
|
||||
let values: Vec<u64> = sysctl("kern.cp_time")?
|
||||
.split_whitespace()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect();
|
||||
if values.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
Some((values.iter().sum(), values[4]))
|
||||
}
|
||||
|
||||
fn process_cpu_micros() -> Option<u64> {
|
||||
let mut usage = std::mem::MaybeUninit::<libc::rusage>::zeroed();
|
||||
// SAFETY: getrusage initializes the supplied rusage structure on success.
|
||||
let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: rc == 0 guarantees the structure was initialized by libc.
|
||||
let usage = unsafe { usage.assume_init() };
|
||||
let user = u64::try_from(usage.ru_utime.tv_sec).ok()?.saturating_mul(1_000_000)
|
||||
+ u64::try_from(usage.ru_utime.tv_usec).ok()?;
|
||||
let system = u64::try_from(usage.ru_stime.tv_sec).ok()?.saturating_mul(1_000_000)
|
||||
+ u64::try_from(usage.ru_stime.tv_usec).ok()?;
|
||||
Some(user.saturating_add(system))
|
||||
}
|
||||
|
||||
fn load_average() -> Option<[f64; 3]> {
|
||||
let raw = sysctl("vm.loadavg")?;
|
||||
let values: Vec<f64> = raw
|
||||
.trim_matches(|c: char| c == '{' || c == '}' || c.is_whitespace())
|
||||
.split_whitespace()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect();
|
||||
(values.len() >= 3).then(|| [values[0], values[1], values[2]])
|
||||
}
|
||||
|
||||
fn process_rss_bytes() -> Option<u64> {
|
||||
let pid = std::process::id().to_string();
|
||||
command_output("ps", &["-o", "rss=", "-p", &pid])?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kib| kib.saturating_mul(1024))
|
||||
}
|
||||
|
||||
fn network_interfaces() -> Vec<NetworkInterfaceView> {
|
||||
let Some(text) = command_output("netstat", &["-ibn"]) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut lines = text.lines();
|
||||
let Some(header) = lines.next() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let columns: Vec<_> = header.split_whitespace().collect();
|
||||
let name_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Name"));
|
||||
let ibytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Ibytes"));
|
||||
let obytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Obytes"));
|
||||
let (Some(name_index), Some(ibytes_index), Some(obytes_index)) =
|
||||
(name_index, ibytes_index, obytes_index)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut interfaces: BTreeMap<String, (u64, u64)> = BTreeMap::new();
|
||||
for line in lines {
|
||||
let fields: Vec<_> = line.split_whitespace().collect();
|
||||
if fields.len() <= name_index || fields.len() <= ibytes_index || fields.len() <= obytes_index {
|
||||
continue;
|
||||
}
|
||||
let name = fields[name_index].trim_end_matches('*');
|
||||
if name.is_empty() || name == "lo0" {
|
||||
continue;
|
||||
}
|
||||
let (Ok(rx), Ok(tx)) = (fields[ibytes_index].parse::<u64>(), fields[obytes_index].parse::<u64>()) else {
|
||||
continue;
|
||||
};
|
||||
let entry = interfaces.entry(name.to_owned()).or_default();
|
||||
entry.0 = entry.0.max(rx);
|
||||
entry.1 = entry.1.max(tx);
|
||||
}
|
||||
interfaces
|
||||
.into_iter()
|
||||
.take(64)
|
||||
.map(|(name, (rx_bytes, tx_bytes))| NetworkInterfaceView {
|
||||
name,
|
||||
rx_bytes,
|
||||
tx_bytes,
|
||||
rx_bytes_per_second: None,
|
||||
tx_bytes_per_second: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn sample(previous: Option<(View, Counters)>) -> (View, Counters) {
|
||||
let (cpu_total, cpu_idle) = cpu_times().map_or((None, None), |(total, idle)| (Some(total), Some(idle)));
|
||||
let counters = Counters {
|
||||
cpu_total,
|
||||
cpu_idle,
|
||||
process_cpu_micros: process_cpu_micros(),
|
||||
};
|
||||
let mut view = View {
|
||||
source: "bsd_native",
|
||||
platform: platform_name(),
|
||||
sampled_at_ms: now_ms(),
|
||||
logical_cpus: sysctl_u64(&["hw.ncpu", "hw.ncpuonline"])
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.or_else(|| std::thread::available_parallelism().ok().map(usize::from)),
|
||||
memory_total_bytes: sysctl_u64(&["hw.physmem64", "hw.physmem"]),
|
||||
process_rss_bytes: process_rss_bytes(),
|
||||
load_average: load_average(),
|
||||
network_interfaces: network_interfaces(),
|
||||
..Default::default()
|
||||
};
|
||||
if !view.network_interfaces.is_empty() {
|
||||
view.network_rx_bytes = Some(view.network_interfaces.iter().map(|item| item.rx_bytes).sum());
|
||||
view.network_tx_bytes = Some(view.network_interfaces.iter().map(|item| item.tx_bytes).sum());
|
||||
}
|
||||
if let Some((old, old_counters)) = previous {
|
||||
let system_total = counters
|
||||
.cpu_total
|
||||
.zip(old_counters.cpu_total)
|
||||
.and_then(|(new, old)| new.checked_sub(old))
|
||||
.filter(|value| *value > 0);
|
||||
if let Some(total) = system_total {
|
||||
view.cpu_percent = counters
|
||||
.cpu_idle
|
||||
.zip(old_counters.cpu_idle)
|
||||
.and_then(|(new, old)| new.checked_sub(old))
|
||||
.map(|idle| (100.0 * (1.0 - idle as f64 / total as f64)).clamp(0.0, 100.0));
|
||||
}
|
||||
let seconds = view.sampled_at_ms.saturating_sub(old.sampled_at_ms) as f64 / 1000.0;
|
||||
if seconds > 0.0 {
|
||||
view.process_cpu_percent = counters
|
||||
.process_cpu_micros
|
||||
.zip(old_counters.process_cpu_micros)
|
||||
.and_then(|(new, old)| new.checked_sub(old))
|
||||
.map(|micros| micros as f64 / (seconds * 10_000.0));
|
||||
view.network_rx_bytes_per_second = view
|
||||
.network_rx_bytes
|
||||
.zip(old.network_rx_bytes)
|
||||
.map(|(new, old)| new.saturating_sub(old) as f64 / seconds);
|
||||
view.network_tx_bytes_per_second = view
|
||||
.network_tx_bytes
|
||||
.zip(old.network_tx_bytes)
|
||||
.map(|(new, old)| new.saturating_sub(old) as f64 / seconds);
|
||||
for interface in &mut view.network_interfaces {
|
||||
if let Some(previous) = old.network_interfaces.iter().find(|old| old.name == interface.name) {
|
||||
interface.rx_bytes_per_second = Some(interface.rx_bytes.saturating_sub(previous.rx_bytes) as f64 / seconds);
|
||||
interface.tx_bytes_per_second = Some(interface.tx_bytes.saturating_sub(previous.tx_bytes) as f64 / seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep unknown/unsupported BSD metrics as None instead of substituting a different semantic.
|
||||
(view, counters)
|
||||
}
|
||||
+11
-3
@@ -6,12 +6,15 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut chars = input.chars().peekable();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut pending_space = false;
|
||||
let mut last_char: Option<char> = None;
|
||||
while let Some(ch) = chars.next() {
|
||||
if let Some(q) = quote {
|
||||
out.push(ch);
|
||||
last_char = Some(ch);
|
||||
if ch == '\\' {
|
||||
if let Some(next) = chars.next() {
|
||||
out.push(next);
|
||||
last_char = Some(next);
|
||||
}
|
||||
} else if ch == q {
|
||||
quote = None;
|
||||
@@ -19,12 +22,14 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
continue;
|
||||
}
|
||||
if matches!(ch, '\'' | '"') {
|
||||
if pending_space && needs_space(out.chars().last(), Some(ch)) {
|
||||
if pending_space && needs_space(last_char, Some(ch)) {
|
||||
out.push(' ');
|
||||
last_char = Some(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
quote = Some(ch);
|
||||
out.push(ch);
|
||||
last_char = Some(ch);
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
@@ -43,14 +48,17 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
pending_space = true;
|
||||
continue;
|
||||
}
|
||||
if pending_space && needs_space(out.chars().last(), Some(ch)) {
|
||||
if pending_space && needs_space(last_char, Some(ch)) {
|
||||
out.push(' ');
|
||||
last_char = Some(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && out.ends_with(' ') {
|
||||
if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && last_char == Some(' ') {
|
||||
out.pop();
|
||||
last_char = out.chars().last();
|
||||
}
|
||||
out.push(ch);
|
||||
last_char = Some(ch);
|
||||
}
|
||||
let trimmed_len = out.trim_end().len();
|
||||
out.truncate(trimmed_len);
|
||||
|
||||
+32
-13
@@ -1,5 +1,13 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RawElement {
|
||||
Script,
|
||||
Style,
|
||||
Textarea,
|
||||
Pre,
|
||||
}
|
||||
|
||||
/// Conservative HTML minification.
|
||||
///
|
||||
/// HTML whitespace can be semantically significant, especially between inline
|
||||
@@ -11,13 +19,15 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut cursor = 0usize;
|
||||
let mut raw_element: Option<String> = None;
|
||||
let mut raw_element: Option<RawElement> = None;
|
||||
|
||||
while cursor < bytes.len() {
|
||||
if bytes[cursor] == b'<' {
|
||||
if raw_element.is_none() && starts_with_ascii_ci(bytes, cursor, b"<!--[if") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
|
||||
return Ok(body.to_vec());
|
||||
out.clear();
|
||||
out.push_str(input);
|
||||
return Ok(out.into_bytes());
|
||||
};
|
||||
let end = end + 3;
|
||||
out.push_str(&input[cursor..end]);
|
||||
@@ -26,7 +36,9 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
}
|
||||
if raw_element.is_none() && bytes[cursor..].starts_with(b"<!--") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
|
||||
return Ok(body.to_vec());
|
||||
out.clear();
|
||||
out.push_str(input);
|
||||
return Ok(out.into_bytes());
|
||||
};
|
||||
cursor = end + 3;
|
||||
continue;
|
||||
@@ -35,15 +47,12 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
if let Some(end) = find_tag_end(bytes, cursor + 1) {
|
||||
let tag = &input[cursor..=end];
|
||||
out.push_str(tag);
|
||||
if let Some((name, closing, self_closing)) = tag_name(tag) {
|
||||
if let Some((name, closing, self_closing)) = raw_tag(tag) {
|
||||
if closing {
|
||||
if raw_element.as_deref() == Some(name.as_str()) {
|
||||
if raw_element == Some(name) {
|
||||
raw_element = None;
|
||||
}
|
||||
} else if !self_closing
|
||||
&& raw_element.is_none()
|
||||
&& matches!(name.as_str(), "script" | "style" | "textarea" | "pre")
|
||||
{
|
||||
} else if !self_closing && raw_element.is_none() {
|
||||
raw_element = Some(name);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +100,7 @@ fn find_tag_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn tag_name(tag: &str) -> Option<(String, bool, bool)> {
|
||||
fn raw_tag(tag: &str) -> Option<(RawElement, bool, bool)> {
|
||||
let inner = tag.strip_prefix('<')?.strip_suffix('>')?.trim();
|
||||
if inner.starts_with('!') || inner.starts_with('?') {
|
||||
return None;
|
||||
@@ -101,9 +110,19 @@ fn tag_name(tag: &str) -> Option<(String, bool, bool)> {
|
||||
let self_closing = inner.ends_with('/');
|
||||
let name = inner
|
||||
.split(|ch: char| ch.is_ascii_whitespace() || ch == '/' || ch == '>')
|
||||
.next()?
|
||||
.to_ascii_lowercase();
|
||||
(!name.is_empty()).then_some((name, closing, self_closing))
|
||||
.next()?;
|
||||
let name = if name.eq_ignore_ascii_case("script") {
|
||||
RawElement::Script
|
||||
} else if name.eq_ignore_ascii_case("style") {
|
||||
RawElement::Style
|
||||
} else if name.eq_ignore_ascii_case("textarea") {
|
||||
RawElement::Textarea
|
||||
} else if name.eq_ignore_ascii_case("pre") {
|
||||
RawElement::Pre
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((name, closing, self_closing))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+50
-2
@@ -1,6 +1,54 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{de::IgnoredAny, Deserialize};
|
||||
|
||||
/// Validate JSON without materializing a full `serde_json::Value`, then remove
|
||||
/// insignificant ASCII whitespace in one pass. This keeps peak memory close to
|
||||
/// input + output instead of input + parsed tree + serialized output.
|
||||
pub fn compact(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let value: serde_json::Value = serde_json::from_slice(body).context("parse JSON")?;
|
||||
serde_json::to_vec(&value).context("serialize compact JSON")
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(body);
|
||||
IgnoredAny::deserialize(&mut deserializer).context("parse JSON")?;
|
||||
deserializer.end().context("parse JSON trailing data")?;
|
||||
|
||||
let mut output = Vec::with_capacity(body.len());
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
for &byte in body {
|
||||
if in_string {
|
||||
output.push(byte);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if byte == b'"' {
|
||||
in_string = true;
|
||||
output.push(byte);
|
||||
} else if !matches!(byte, b' ' | b'\n' | b'\r' | b'\t') {
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compacts_without_rewriting_values_or_duplicate_keys() {
|
||||
let input = br#"{ "n": 1.2300e+02, "s": "a b\\n c", "k": 1, "k": 2 }"#;
|
||||
assert_eq!(
|
||||
compact(input).unwrap(),
|
||||
br#"{"n":1.2300e+02,"s":"a b\\n c","k":1,"k":2}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_json_instead_of_stripping_whitespace_blindly() {
|
||||
assert!(compact(br#"{"a": 1,}"#).is_err());
|
||||
}
|
||||
}
|
||||
+320
-14
@@ -3,13 +3,16 @@
|
||||
use anyhow::{ensure, Context as _, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use http_body_util::BodyExt;
|
||||
use http_body_util::{BodyExt, Limited};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Mutex,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
mod css;
|
||||
@@ -19,9 +22,23 @@ mod json;
|
||||
mod svg;
|
||||
|
||||
const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
|
||||
const DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024;
|
||||
const DEFAULT_MAX_INFLIGHT: usize = 32;
|
||||
const TRANSFORM_MEMORY_UNIT_BYTES: usize = 64 * 1024;
|
||||
static TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize = AtomicUsize::new(0);
|
||||
static TRANSFORM_INFLIGHT: AtomicUsize = AtomicUsize::new(0);
|
||||
#[cfg(all(target_os = "linux", target_env = "gnu"))]
|
||||
const ALLOCATOR_TRIM_MIN_BODY_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Preset {
|
||||
Conservative,
|
||||
Recommended,
|
||||
Performance,
|
||||
Uber,
|
||||
}
|
||||
|
||||
/// Large transformable responses can temporarily own the collected origin body,
|
||||
/// a decoded buffer and the transformed output at the same time. On glibc, freed
|
||||
/// pages may remain in malloc arenas for a long time. The systemd unit also caps
|
||||
@@ -65,6 +82,10 @@ fn trim_allocator_after_large_transform(released_bytes: usize) {
|
||||
pub struct Settings {
|
||||
/// Global kill switch. Per-vhost opt-in is still required.
|
||||
pub enabled: bool,
|
||||
/// Last resource profile explicitly selected in the administrator UI. The
|
||||
/// numeric limits below are authoritative; this field only preserves UI
|
||||
/// intent across reloads.
|
||||
pub preset: Option<Preset>,
|
||||
pub javascript: bool,
|
||||
pub css: bool,
|
||||
pub html: bool,
|
||||
@@ -75,11 +96,18 @@ pub struct Settings {
|
||||
pub extensions: Vec<String>,
|
||||
/// Hard aggregation/decode ceiling for transformable static responses.
|
||||
pub max_body_bytes: usize,
|
||||
/// Process-wide ceiling for estimated temporary response buffers owned by
|
||||
/// active transformations. This is a limit, not a pre-allocation.
|
||||
pub memory_budget_bytes: usize,
|
||||
/// Process-wide number of transformation attempts allowed to own an active
|
||||
/// transform slot at the same time. Excess work fails open unchanged.
|
||||
pub max_inflight: usize,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
preset: None,
|
||||
javascript: true,
|
||||
css: true,
|
||||
html: false,
|
||||
@@ -92,6 +120,8 @@ impl Default for Settings {
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
|
||||
memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
|
||||
max_inflight: DEFAULT_MAX_INFLIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +131,14 @@ impl Settings {
|
||||
(1024..=32 * 1024 * 1024).contains(&self.max_body_bytes),
|
||||
"transform.max_body_bytes must be 1 KiB..32 MiB"
|
||||
);
|
||||
ensure!(
|
||||
(1024 * 1024..=2 * 1024 * 1024 * 1024).contains(&self.memory_budget_bytes),
|
||||
"transform.memory_budget_bytes must be 1 MiB..2 GiB"
|
||||
);
|
||||
ensure!(
|
||||
(1..=1024).contains(&self.max_inflight),
|
||||
"transform.max_inflight must be 1..1024"
|
||||
);
|
||||
ensure!(self.extensions.len() <= 64, "at most 64 transform extensions");
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for extension in &self.extensions {
|
||||
@@ -296,6 +334,24 @@ pub struct MetricsView {
|
||||
pub scopes: Vec<ScopeCounters>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct ResourceUsage {
|
||||
/// Estimated temporary transform bytes currently reserved by active work.
|
||||
pub memory_reserved_bytes: usize,
|
||||
pub memory_budget_bytes: usize,
|
||||
pub inflight: usize,
|
||||
pub max_inflight: usize,
|
||||
}
|
||||
|
||||
pub fn resource_usage(settings: &Settings) -> ResourceUsage {
|
||||
ResourceUsage {
|
||||
memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load(Ordering::Acquire),
|
||||
memory_budget_bytes: settings.memory_budget_bytes,
|
||||
inflight: TRANSFORM_INFLIGHT.load(Ordering::Acquire),
|
||||
max_inflight: settings.max_inflight,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MetricsStore {
|
||||
total: Counters,
|
||||
@@ -505,14 +561,27 @@ fn supported_encoding(headers: &HeaderMap) -> Option<Option<&'static str>> {
|
||||
}
|
||||
|
||||
fn decode<'a>(body: &'a [u8], encoding: Option<&str>, limit: usize) -> Result<Cow<'a, [u8]>> {
|
||||
fn read_bounded(reader: impl Read, limit: usize, label: &'static str) -> Result<Vec<u8>> {
|
||||
fn read_bounded(mut reader: impl Read, limit: usize, label: &'static str) -> Result<Vec<u8>> {
|
||||
let mut output = Vec::with_capacity(limit.min(64 * 1024));
|
||||
let mut bounded = reader.take(limit.saturating_add(1) as u64);
|
||||
bounded.read_to_end(&mut output).with_context(|| format!("decode {label}"))?;
|
||||
ensure!(
|
||||
output.len() <= limit,
|
||||
"decoded transform body exceeds configured limit"
|
||||
);
|
||||
let mut chunk = [0u8; 16 * 1024];
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut chunk)
|
||||
.with_context(|| format!("decode {label}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
ensure!(
|
||||
output.len().saturating_add(read) <= limit,
|
||||
"decoded transform body exceeds configured limit"
|
||||
);
|
||||
if output.capacity().saturating_sub(output.len()) < read {
|
||||
output
|
||||
.try_reserve_exact(read)
|
||||
.map_err(|_| anyhow::anyhow!("decoded transform allocation failed"))?;
|
||||
}
|
||||
output.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -588,15 +657,123 @@ fn drop_vary_accept_encoding(headers: &mut HeaderMap) {
|
||||
|
||||
fn transformed_etag(body: &[u8]) -> HeaderValue {
|
||||
let digest = Sha256::digest(body);
|
||||
let mut hex = String::with_capacity(64);
|
||||
let mut value = String::with_capacity(76);
|
||||
value.push_str("\"rustproxy-");
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut hex, "{byte:02x}");
|
||||
let _ = write!(&mut value, "{byte:02x}");
|
||||
}
|
||||
HeaderValue::from_str(&format!("\"rustproxy-{hex}\""))
|
||||
value.push('"');
|
||||
HeaderValue::from_str(&value)
|
||||
.expect("sha256 validator is always a valid header")
|
||||
}
|
||||
|
||||
fn transform_memory_required_bytes(
|
||||
content_length: usize,
|
||||
encoding: Option<&str>,
|
||||
max_body_bytes: usize,
|
||||
) -> Option<usize> {
|
||||
let estimated = if encoding.is_none() {
|
||||
content_length.checked_mul(2)?
|
||||
} else {
|
||||
content_length.checked_add(max_body_bytes.checked_mul(2)?)?
|
||||
};
|
||||
let units = estimated.checked_add(TRANSFORM_MEMORY_UNIT_BYTES - 1)?
|
||||
/ TRANSFORM_MEMORY_UNIT_BYTES;
|
||||
units.max(1).checked_mul(TRANSFORM_MEMORY_UNIT_BYTES)
|
||||
}
|
||||
|
||||
struct TransformMemoryReservation<'a> {
|
||||
counter: &'a AtomicUsize,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Drop for TransformMemoryReservation<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(self.bytes, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_reserve_memory_from(
|
||||
counter: &AtomicUsize,
|
||||
required_bytes: usize,
|
||||
budget_bytes: usize,
|
||||
) -> Option<TransformMemoryReservation<'_>> {
|
||||
if required_bytes > budget_bytes {
|
||||
return None;
|
||||
}
|
||||
let mut current = counter.load(Ordering::Acquire);
|
||||
loop {
|
||||
let next = current.checked_add(required_bytes)?;
|
||||
if next > budget_bytes {
|
||||
return None;
|
||||
}
|
||||
match counter.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => {
|
||||
return Some(TransformMemoryReservation {
|
||||
counter,
|
||||
bytes: required_bytes,
|
||||
})
|
||||
}
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_reserve_transform_memory(
|
||||
required_bytes: usize,
|
||||
budget_bytes: usize,
|
||||
) -> Option<TransformMemoryReservation<'static>> {
|
||||
try_reserve_memory_from(
|
||||
&TRANSFORM_MEMORY_IN_USE_BYTES,
|
||||
required_bytes,
|
||||
budget_bytes,
|
||||
)
|
||||
}
|
||||
|
||||
struct TransformInflightReservation<'a> {
|
||||
counter: &'a AtomicUsize,
|
||||
}
|
||||
|
||||
impl Drop for TransformInflightReservation<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_reserve_slot_from(
|
||||
counter: &AtomicUsize,
|
||||
max_inflight: usize,
|
||||
) -> Option<TransformInflightReservation<'_>> {
|
||||
let mut current = counter.load(Ordering::Acquire);
|
||||
loop {
|
||||
if current >= max_inflight {
|
||||
return None;
|
||||
}
|
||||
let next = current.checked_add(1)?;
|
||||
match counter.compare_exchange_weak(
|
||||
current,
|
||||
next,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => return Some(TransformInflightReservation { counter }),
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_reserve_transform_slot(
|
||||
max_inflight: usize,
|
||||
) -> Option<TransformInflightReservation<'static>> {
|
||||
try_reserve_slot_from(&TRANSFORM_INFLIGHT, max_inflight)
|
||||
}
|
||||
|
||||
/// Transform an eligible response. The call is fail-open for unsupported media,
|
||||
/// encodings and malformed minifiable content; transport/body collection errors
|
||||
/// still propagate because the original stream can no longer be replayed safely.
|
||||
@@ -651,12 +828,33 @@ pub async fn apply(
|
||||
if content_length > settings.max_body_bytes {
|
||||
return Ok(skip(response, Some(kind), "too_large"));
|
||||
}
|
||||
let Some(required_memory) =
|
||||
transform_memory_required_bytes(content_length, encoding, settings.max_body_bytes)
|
||||
else {
|
||||
return Ok(skip(response, Some(kind), "memory_budget"));
|
||||
};
|
||||
let Some(_inflight_slot) = try_reserve_transform_slot(settings.max_inflight) else {
|
||||
return Ok(skip(response, Some(kind), "concurrency_pressure"));
|
||||
};
|
||||
let Some(_memory_reservation) =
|
||||
try_reserve_transform_memory(required_memory, settings.memory_budget_bytes)
|
||||
else {
|
||||
// Transformations are an optimization. Under concurrent allocation
|
||||
// pressure, preserve streaming/origin bytes instead of queueing more
|
||||
// full-response buffers behind the memory budget.
|
||||
return Ok(skip(response, Some(kind), "memory_pressure"));
|
||||
};
|
||||
|
||||
let (mut parts, body) = response.into_parts();
|
||||
let raw = body
|
||||
// Do not trust Content-Length as a memory bound. A broken or malicious
|
||||
// upstream can send more bytes than declared; Limited stops aggregation at
|
||||
// the configured ceiling instead of letting BodyExt::collect grow without
|
||||
// bound. Once aggregation starts an over-limit stream cannot be replayed
|
||||
// safely, so it is treated like the other body/transport collection errors.
|
||||
let raw = Limited::new(body, settings.max_body_bytes)
|
||||
.collect()
|
||||
.await
|
||||
.context("collect static response for transformation")?
|
||||
.context("collect bounded static response for transformation")?
|
||||
.to_bytes();
|
||||
if raw.len() > settings.max_body_bytes {
|
||||
let mut response = Response::from_parts(parts, Body::from(raw));
|
||||
@@ -751,6 +949,114 @@ mod tests {
|
||||
assert!(matches!(decoded, Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_memory_budget_accounts_for_identity_and_decoded_buffers() {
|
||||
assert_eq!(
|
||||
transform_memory_required_bytes(2 * 1024 * 1024, None, 2 * 1024 * 1024),
|
||||
Some(4 * 1024 * 1024)
|
||||
);
|
||||
assert_eq!(
|
||||
transform_memory_required_bytes(
|
||||
2 * 1024 * 1024,
|
||||
Some("gzip"),
|
||||
2 * 1024 * 1024
|
||||
),
|
||||
Some(6 * 1024 * 1024)
|
||||
);
|
||||
assert_eq!(
|
||||
transform_memory_required_bytes(
|
||||
DEFAULT_MEMORY_BUDGET_BYTES,
|
||||
Some("gzip"),
|
||||
DEFAULT_MEMORY_BUDGET_BYTES
|
||||
),
|
||||
Some(3 * DEFAULT_MEMORY_BUDGET_BYTES)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_memory_reservation_obeys_runtime_budget() {
|
||||
let counter = AtomicUsize::new(0);
|
||||
let required = 4 * 1024 * 1024;
|
||||
let reservation = try_reserve_memory_from(&counter, required, required).unwrap();
|
||||
assert!(try_reserve_memory_from(&counter, required, required).is_none());
|
||||
drop(reservation);
|
||||
assert!(try_reserve_memory_from(&counter, required, required).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_inflight_reservation_obeys_runtime_limit() {
|
||||
let counter = AtomicUsize::new(0);
|
||||
let slot = try_reserve_slot_from(&counter, 1).unwrap();
|
||||
assert!(try_reserve_slot_from(&counter, 1).is_none());
|
||||
drop(slot);
|
||||
assert!(try_reserve_slot_from(&counter, 1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_usage_exposes_configured_limits() {
|
||||
let settings = Settings {
|
||||
memory_budget_bytes: 512 * 1024 * 1024,
|
||||
max_inflight: 64,
|
||||
..Default::default()
|
||||
};
|
||||
let usage = resource_usage(&settings);
|
||||
assert_eq!(usage.memory_budget_bytes, 512 * 1024 * 1024);
|
||||
assert_eq!(usage.max_inflight, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_preset_metadata_round_trips() {
|
||||
let mut settings = Settings::default();
|
||||
settings.preset = Some(Preset::Uber);
|
||||
settings.memory_budget_bytes = 1024 * 1024 * 1024;
|
||||
settings.max_body_bytes = 32 * 1024 * 1024;
|
||||
settings.max_inflight = 128;
|
||||
let encoded = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(encoded["preset"], "uber");
|
||||
let decoded: Settings = serde_json::from_value(encoded).unwrap();
|
||||
assert_eq!(decoded.preset, Some(Preset::Uber));
|
||||
assert_eq!(decoded.memory_budget_bytes, 1024 * 1024 * 1024);
|
||||
assert_eq!(decoded.max_inflight, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_transform_settings_receive_resource_defaults() {
|
||||
let decoded: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
|
||||
assert!(decoded.enabled);
|
||||
assert_eq!(decoded.preset, None);
|
||||
assert_eq!(decoded.memory_budget_bytes, DEFAULT_MEMORY_BUDGET_BYTES);
|
||||
assert_eq!(decoded.max_body_bytes, DEFAULT_MAX_BODY_BYTES);
|
||||
assert_eq!(decoded.max_inflight, DEFAULT_MAX_INFLIGHT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_decoder_rejects_expansion_past_limit() {
|
||||
use std::io::Write as _;
|
||||
let identity = vec![b'x'; 128 * 1024];
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
encoder.write_all(&identity).unwrap();
|
||||
let encoded = encoder.finish().unwrap();
|
||||
assert!(decode(&encoded, Some("gzip"), 64 * 1024).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregation_is_bounded_even_when_content_length_is_false() {
|
||||
let settings = Settings {
|
||||
enabled: true,
|
||||
json: true,
|
||||
max_body_bytes: 1024,
|
||||
..Default::default()
|
||||
};
|
||||
let mut response = Response::new(Body::from(vec![b' '; 2048]));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-type", HeaderValue::from_static("application/json"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-length", HeaderValue::from_static("128"));
|
||||
assert!(apply(response, "/data.json", &settings, false).await.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_track_applied_skipped_and_cached_transformed_responses() {
|
||||
let metrics = Metrics::default();
|
||||
|
||||
@@ -12,7 +12,9 @@ pub fn optimize(body: &[u8]) -> Result<Vec<u8>> {
|
||||
while cursor < bytes.len() {
|
||||
if bytes[cursor..].starts_with(b"<![CDATA[") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 9, b"]]>") else {
|
||||
return Ok(body.to_vec());
|
||||
out.clear();
|
||||
out.push_str(input);
|
||||
return Ok(out.into_bytes());
|
||||
};
|
||||
let end = end + 3;
|
||||
out.push_str(&input[cursor..end]);
|
||||
@@ -21,7 +23,9 @@ pub fn optimize(body: &[u8]) -> Result<Vec<u8>> {
|
||||
}
|
||||
if bytes[cursor..].starts_with(b"<!--") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
|
||||
return Ok(body.to_vec());
|
||||
out.clear();
|
||||
out.push_str(input);
|
||||
return Ok(out.into_bytes());
|
||||
};
|
||||
cursor = end + 3;
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user