This commit is contained in:
Mateusz Gruszczyński committed 2026-10-10 08:53:55 +02:00
1 parent 1431dd47f0
commit f3dd34dae4
42 files changed
+950 -133

No files matched your search

+1
View File
@@ -137,6 +137,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"historical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md")),
),
reader_doc!("docs/MEMORY_STRESS.md", "technical"),
(
"docs/MIGRATION.md",
"guides",
+2 -5
View File
@@ -84,7 +84,7 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
.ok()
.is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if directory_safe {
let mut entries = std::fs::read_dir(&directory)
let entries = newest_log_files(std::fs::read_dir(&directory)
.ok()
.into_iter()
.flat_map(|iter| iter.filter_map(|entry| entry.ok()))
@@ -102,10 +102,7 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
}
let meta = entry.metadata().ok()?;
Some((name, path, file_modified_ms(&meta)))
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
entries.truncate(256);
}), 256);
for (name, path, _) in entries {
let id = if name.ends_with(".jsonl") {
format!("vhost:{}", name.trim_end_matches(".jsonl"))
+28 -4
View File
@@ -1,3 +1,29 @@
// Bound candidate retention while scanning potentially enormous rotation folders.
// The heap's top is the least desirable result: oldest timestamp, then
// lexicographically last name. Final output preserves newest/name order.
fn newest_log_files(
candidates: impl Iterator<Item = (String, std::path::PathBuf, u64)>,
limit: usize,
) -> Vec<(String, std::path::PathBuf, u64)> {
use std::{cmp::Reverse, collections::BinaryHeap};
if limit == 0 { return Vec::new(); }
let mut bounded = BinaryHeap::with_capacity(limit);
for (name, path, modified_ms) in candidates {
let item = (Reverse(modified_ms), name, path);
if bounded.len() < limit {
bounded.push(item);
} else if bounded.peek().is_some_and(|worst| item.cmp(worst).is_lt()) {
bounded.pop();
bounded.push(item);
}
}
let mut result = bounded.into_iter()
.map(|(Reverse(modified), name, path)| (name, path, modified))
.collect::<Vec<_>>();
result.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
result
}
// Bounded file-log inventory helpers and gzip tail reader.
fn rotated_gzip_sources(
active: &std::path::Path,
@@ -12,16 +38,14 @@ fn rotated_gzip_sources(
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe { return Vec::new(); }
let prefix = format!("{base}.");
let mut entries = std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
let entries = newest_log_files(std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; }
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() { return None; }
let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta)))
}).collect::<Vec<_>>();
entries.sort_by(|a,b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
entries.truncate(64);
}), 64);
entries.into_iter().map(|(name,path,_)| source_from_path(
format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"),
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.29.7",
"version": "0.30.0",
"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": [
+1
View File
@@ -48,6 +48,7 @@ fn admin_vhosts_javascript() -> &'static str {
[
"(() => {",
include_str!("web/vhosts/core.js"),
include_str!("web/vhosts/health.js"),
include_str!("web/vhosts/editor.js"),
include_str!("web/vhosts/render.js"),
"})();",
+1
View File
@@ -6,4 +6,5 @@ mod tests {
include!("tests/part_05.rs");
include!("tests/part_06.rs");
include!("tests/part_07.rs");
include!("tests/part_08.rs");
}
+12
View File
@@ -0,0 +1,12 @@
#[test]
fn directory_inventory_retains_newest_with_deterministic_ties() {
let candidates = (0..10_000).map(|index| {
let name = format!("{index:05}.jsonl.gz");
(name.clone(), std::path::PathBuf::from(name), (index / 10) as u64)
});
let files = newest_log_files(candidates, 5);
assert_eq!(files.len(), 5);
let names: Vec<_> = files.iter().map(|entry| entry.0.as_str()).collect();
assert_eq!(names, ["09990.jsonl.gz", "09991.jsonl.gz", "09992.jsonl.gz", "09993.jsonl.gz", "09994.jsonl.gz"]);
assert!(newest_log_files(std::iter::once(("x".into(), "x".into(), 1)), 0).is_empty());
}
+36
View File
@@ -5788,6 +5788,42 @@ dialog .window-heading {
padding: 4px 0 8px
}
.vh-backend-health-editor {
border: 1px solid var(--line);
border-radius: 8px;
padding: 15px;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 11px;
background: var(--surface, transparent)
}
.vh-backend-health-editor h3 {
margin: 0;
font-size: 14px
}
.vh-backend-health-editor .fine {
margin: 0
}
.vh-backend-health-editor>.vh-field {
max-width: 620px
}
.vh-backend-health-editor select {
width: 100%
}
.vh-backend-health-panel {
border-top: 1px solid var(--line);
padding-top: 12px
}
.vh-backend-health-fields {
margin-top: 4px
}
.vh-backend-actions {
display: flex;
align-items: center;
+23 -11
View File
@@ -1,7 +1,7 @@
"use strict";
// Vhost UI: language content comes from langs/*.json, never a locale switch.
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const vhostTrafficHistory = new Map();
@@ -137,7 +137,7 @@
const state = {
editing, revision, label: $("vhost-editing").textContent,
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor,
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor, backendHealthEditorControl,
stagedCertificates, uploading
};
const title = read("id")?.value.trim() || editing || t("draftUntitled");
@@ -147,7 +147,7 @@
function restoreDraft(state) {
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor;
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
}
@@ -194,7 +194,7 @@
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
@@ -222,9 +222,12 @@
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
note(extra, "vhBackendCapacityHelp");
const customHealth = field(extra, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
// The per-member settings live in the Health tab. Keep one persistent form
// per backend, so switching the selection never discards unsaved edits.
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
const health = value.health_check || {};
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); extra.append(healthFields);
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); healthPanel.append(healthFields);
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
@@ -236,11 +239,19 @@
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
const syncHealth = () => {
const editable = controls["member-health-enabled"].checked && customHealth.checked;
healthFields.hidden = !customHealth.checked;
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !customHealth.checked; });
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !editable; });
};
customHealth.addEventListener("change", syncHealth); syncHealth();
note(extra, "backendHealthOverridesHelp");
let customInitialized = value.health_check != null;
customHealth.addEventListener("change", () => {
if (customHealth.checked && !customInitialized) { inheritBackendHealthInputs(healthPanel); customInitialized = true; }
syncHealth();
});
controls["member-health-enabled"].addEventListener("change", () => { syncHealth(); backendHealthEditorControl?.refresh(); });
row.memberHealthPanel = healthPanel;
row.memberHealthEnabled = controls["member-health-enabled"];
syncHealth();
row.append(advanced);
const actions = el("div", undefined, "vh-backend-actions");
@@ -258,10 +269,11 @@
riskGate.hidden = show !== true;
if (clearWhenHidden && show === false) localRisk.checked = false;
};
id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
id.addEventListener("input", () => { syncIdentity(); backendHealthEditorControl?.refresh(); }); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); backendHealthEditorControl?.refresh(); }); url.addEventListener("change", () => syncRisk(true));
row.syncLocalRisk = () => syncRisk(false);
syncIdentity(); syncRisk(false); parent.append(row);
syncIdentity(); syncRisk(false); parent.append(row); backendHealthEditorControl?.refresh();
}
function countryRateEditor(parent, values = []) {
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
const add = (value = {}) => {
+8 -6
View File
@@ -34,7 +34,7 @@
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false;
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
@@ -205,6 +205,8 @@
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
backendHealthEditorControl = backendHealthEditor(g, members);
note(g, "backendHealthOverridesHelp");
const passive = settings.passive_health || {};
g = group("vhPassiveHealth");
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
@@ -391,11 +393,11 @@
id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row),
pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row),
health_check_enabled: yes("member-health-enabled", row),
health_check: yes("member-health-custom", row) ? {
mode: str("member-health-mode", row), path: str("member-health-path", row), host: nullable("member-health-host", row),
interval_ms: num("member-health-interval", row), timeout_ms: num("member-health-timeout", row), slow_response_ms: num("member-health-slow", row),
fall: num("member-health-fall", row), rise: num("member-health-rise", row),
expected_status_min: num("member-health-min", row), expected_status_max: num("member-health-max", row)
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
} : null,
allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row),
preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)),
+43
View File
@@ -0,0 +1,43 @@
"use strict";
// Per-member health editor shares the vhost form state from core.js.
// Starting a custom override should copy the CURRENT shared policy once,
// rather than surprising the operator with unrelated hardcoded defaults.
function inheritBackendHealthInputs(panel) {
for (const [local, shared] of [
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
["member-health-host", "health-host"], ["member-health-interval", "interval"],
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
["member-health-fall", "fall"], ["member-health-rise", "rise"],
["member-health-min", "status-min"], ["member-health-max", "status-max"]
]) {
const source = read(shared), target = read(local, panel);
if (source && target) target.value = source.value;
}
}
function backendHealthEditor(parent, members) {
const section = el("section", undefined, "vh-wide vh-backend-health-editor");
section.append(text("h3", "backendHealthPerBackend"));
note(section, "backendHealthSelectionHelp");
const selection = field(section, "health-backend-select", "backendHealthSelect", "", "select");
const empty = note(section, "backendHealthNoEnabled");
const panels = el("div", undefined, "vh-health-member-panels"); section.append(panels); parent.append(section);
let active = null, eligible = [];
const refresh = () => {
eligible = [...members.children].filter(row => row.memberHealthEnabled.checked);
if (!eligible.includes(active)) active = eligible[0] || null;
selection.replaceChildren(...eligible.map((row, index) => {
const id = str("member-id", row) || t("backend");
const address = str("member-url", row);
const option = el("option", address ? `${id} — ${address}` : id);
option.value = String(index); return option;
}));
selection.value = active ? String(eligible.indexOf(active)) : "";
selection.disabled = eligible.length === 0;
empty.hidden = eligible.length !== 0;
panels.replaceChildren(...[...members.children].map(row => row.memberHealthPanel));
for (const row of members.children) row.memberHealthPanel.hidden = row !== active;
};
selection.addEventListener("change", () => { active = eligible[Number(selection.value)] || null; refresh(); });
refresh();
return { refresh, reveal(row) { if (eligible.includes(row)) { active = row; refresh(); } } };
}
+8 -2
View File
@@ -340,7 +340,13 @@
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy || uploading) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; }
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) {
const healthPanel = invalid.closest(".vh-backend-health-panel");
if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel));
activateTab(invalid.closest(".vh-group")?.dataset.tab || "details");
const details = invalid.closest("details"); if (details) details.open = true;
invalid.reportValidity(); return;
}
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try {
const epoch = draftEpoch; uploading = true;
@@ -370,5 +376,5 @@
initPoolTableTools();
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
+14
View File
@@ -103,6 +103,20 @@ impl Cache {
{
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
store.generation = store.generation.wrapping_add(1);
// This global publication barrier invalidates every older
// ticket and pending COLD demotion. Per-key generations only
// need to cover the current purge, not every key ever
// purged over the lifetime of the process. Without clearing
// them, repeated PURGEs of distinct URLs grow this map
// indefinitely even after the objects have been evicted.
// Release an unusually large index's backing allocation
// after mass invalidations instead of retaining its peak
// bucket capacity for the rest of the process lifetime.
if store.key_generations.capacity() > 256 {
store.key_generations = Default::default();
} else {
store.key_generations.clear();
}
}
let affected = self.purge_matcher(&matcher);
self.counters.purges.fetch_add(1, Ordering::Relaxed);
+74
View File
@@ -395,3 +395,77 @@
confirmed_prefix.confirm_wide = true;
assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some());
}
#[tokio::test]
async fn many_distinct_selective_purges_do_not_retain_tombstones_forever() {
let cache = Cache::new(22);
store(
&cache,
"app",
"memory.example",
"/stable",
HeaderMap::new(),
&[],
"unchanged",
)
.await;
// A production cache may see an unbounded stream of PURGEs for URLs
// that will never be cached again. The invalidated-key index must
// not remember all of those removed objects indefinitely.
for index in 0..512 {
let target = format!("/unique-{index}");
let old = store(
&cache,
"app",
"memory.example",
&target,
HeaderMap::new(),
&[],
"x",
)
.await;
assert!(cache.ticket_current(&old));
let result = cache
.invalidate(invalidation(
InvalidationOperation::Purge,
InvalidationSelector::Exact,
&target,
Some("memory.example"),
Some("app"),
))
.unwrap();
assert_eq!(result.affected_entries, 1);
assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale");
let state = cache.store.lock().unwrap_or_else(|e| e.into_inner());
assert!(
state.key_generations.len() <= 1,
"key generations must not accumulate for already purged URLs"
);
}
// A new ticket after the invalidation barrier is valid and can fill
// the same URL again, preserving ordinary cache behavior.
let new = store(
&cache,
"app",
"memory.example",
"/unique-0",
HeaderMap::new(),
&[],
"new",
)
.await;
assert!(cache.ticket_current(&new));
assert!(cache.get(&new).is_some());
let stable = cache
.ticket_scoped(
22,
"app",
"http",
"memory.example",
"origin@127.0.0.1:8080",
"/stable",
&HeaderMap::new(),
)
.unwrap();
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
}
+20 -8
View File
@@ -122,12 +122,17 @@ async fn sink_loop(
let config = settings.load_full();
let mut payload = format_entry(&entry, &config.format);
redact_value(&mut payload);
// File and Logstash consume the same JSONL record. Serialize once and
// keep no JSONL buffer at all for Loki-only configurations.
let mut jsonl_line = Vec::new();
if config.file.enabled || config.logstash.enabled {
jsonl_line = serde_json::to_vec(&payload).unwrap_or_default();
jsonl_line.push(b'\n');
}
let mut attempted = false;
let mut failed = false;
if config.file.enabled {
attempted = true;
let mut line = serde_json::to_vec(&payload).unwrap_or_default();
line.push(b'\n');
if config.file.per_vhost {
file = None;
let directory = PathBuf::from(&config.file.directory);
@@ -157,7 +162,7 @@ async fn sink_loop(
}
}
let write_failed = match vhost_files.get_mut(&path) {
Some(opened) => opened.write_all(&line).await.is_err(),
Some(opened) => opened.write_all(&jsonl_line).await.is_err(),
None => true,
};
if write_failed {
@@ -182,7 +187,7 @@ async fn sink_loop(
};
}
if let Some(state) = file.as_mut() {
if state.file.write_all(&line).await.is_err() {
if state.file.write_all(&jsonl_line).await.is_err() {
failed = true;
file = None;
}
@@ -211,11 +216,9 @@ async fn sink_loop(
_ => None,
};
}
let mut line = serde_json::to_vec(&payload).unwrap_or_default();
line.push(b'\n');
if let Some(state) = logstash.as_mut() {
if !matches!(
tokio::time::timeout(SINK_TIMEOUT, state.stream.write_all(&line)).await,
tokio::time::timeout(SINK_TIMEOUT, state.stream.write_all(&jsonl_line)).await,
Ok(Ok(()))
) {
failed = true;
@@ -234,7 +237,16 @@ async fn sink_loop(
} else {
config.loki.labels.clone()
};
let body = json!({"streams":[{"stream":labels,"values":[[(entry.time_ms as u128 * 1_000_000).to_string(), payload.to_string()]]}]});
// Loki needs the same redacted JSON event as the file/Logstash
// sinks, only without the JSONL terminator. Reuse the already
// serialized record when another sink is active; avoid a second
// full serde_json pass for every high-volume access event.
let event_json = if jsonl_line.is_empty() {
payload.to_string()
} else {
String::from_utf8_lossy(&jsonl_line[..jsonl_line.len() - 1]).into_owned()
};
let body = json!({"streams":[{"stream":labels,"values":[[(entry.time_ms as u128 * 1_000_000).to_string(), event_json]]}]});
let result = async {
let uri: Uri = config.loki.url.parse()?;
let mut request = Request::post(uri)
+9 -6
View File
@@ -51,13 +51,16 @@ impl TopTable {
if raw.is_empty() || weight == 0 {
return;
}
// Probe with the borrowed label. Under production traffic most methods,
// statuses, routes and user agents repeat; allocating a fresh String
// for every hit creates avoidable allocator churn.
let value = truncate(raw, MAX_LABEL_BYTES);
if let Some(count) = self.values.get_mut(&value) {
if let Some(count) = self.values.get_mut(value) {
*count = count.saturating_add(weight);
return;
}
if self.values.len() < TOP_CAPACITY {
self.values.insert(value, weight);
self.values.insert(value.to_owned(), weight);
return;
}
let Some((smallest, minimum)) = self
@@ -69,7 +72,7 @@ impl TopTable {
return;
};
self.values.remove(&smallest);
self.values.insert(value, minimum.saturating_add(weight));
self.values.insert(value.to_owned(), minimum.saturating_add(weight));
}
fn merge(&mut self, other: &Self) {
for (value, count) in &other.values {
@@ -337,15 +340,15 @@ fn user_agent_family(value: &str) -> &'static str {
}
}
fn truncate(value: &str, max_bytes: usize) -> String {
fn truncate(value: &str, max_bytes: usize) -> &str {
if value.len() <= max_bytes {
return value.to_owned();
return value;
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_owned()
&value[..end]
}
#[cfg(test)]
+19 -17
View File
@@ -321,7 +321,7 @@ async fn serve_inner(
}
let service_for_list = service.clone();
let components_for_list = components.clone();
let entries = match tokio::task::spawn_blocking(move || {
let mut entries = match tokio::task::spawn_blocking(move || {
list_directory_sync(&service_for_list, &components_for_list)
})
.await
@@ -335,25 +335,27 @@ async fn serve_inner(
)
}
};
let entries: Vec<_> = entries
.into_iter()
.filter(|entry| {
if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) {
return false;
}
let child_path = if effective_path.ends_with('/') {
format!("{effective_path}{}", entry.name)
} else {
format!("{effective_path}/{}", entry.name)
};
!hardening_denied_path(service, &child_path)
})
.collect();
// Filtering in place avoids a second Vec allocation for large directories
// while retaining exactly the same visibility and hardening checks.
entries.retain(|entry| {
if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) {
return false;
}
let child_path = if effective_path.ends_with('/') {
format!("{effective_path}{}", entry.name)
} else {
format!("{effective_path}/{}", entry.name)
};
!hardening_denied_path(service, &child_path)
});
let listing = directory_listing(&effective_path, &entries);
let listing_bytes = listing.len();
let mut response = Response::new(if method == Method::HEAD {
Body::empty()
} else {
Body::from(listing.clone())
// Move the generated listing into the response. Copying it doubles
// peak HTML allocation for large directory listings under load.
Body::from(listing)
});
response.headers_mut().insert(
CONTENT_TYPE,
@@ -361,7 +363,7 @@ async fn serve_inner(
);
response.headers_mut().insert(
CONTENT_LENGTH,
HeaderValue::from_str(&listing.len().to_string()).unwrap(),
HeaderValue::from_str(&listing_bytes.to_string()).unwrap(),
);
response
.headers_mut()