Restart-gated Decoy Fast-Track

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-09-06 18:51:13 +03:00
parent 106b26a5b7
commit 20a4d50524
19 changed files with 343 additions and 171 deletions
+35 -11
View File
@@ -15,20 +15,30 @@ use super::model::ApiFailure;
use crate::config::ProxyConfig;
use crate::config::hot_reload::classify_config_changes;
use crate::maestro::reload::{ReloadAccepted, ReloadRequest, ReloadSubmitError};
use crate::maestro::runtime_build::resolve_reload_config;
use crate::maestro::runtime_build::{
ResolvedReloadConfig, deferred_process_fields, resolve_reload_config,
};
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Result of one validated managed-config mutation.
#[derive(Debug, Serialize)]
pub(super) struct PatchConfigResponse {
/// Revision of the persisted desired configuration.
pub revision: String,
/// Whether any changed field is not hot-reloadable.
pub restart_required: bool,
/// Whether the effective runtime snapshot must be reloaded.
pub runtime_reload_required: bool,
/// Whether any desired field remains deferred until process restart.
pub process_restart_required: bool,
/// Stable paths of desired fields retained from the active process.
pub deferred_process_fields: Vec<String>,
/// Top-level managed sections changed by the mutation.
pub changed: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
/// Accepted runtime reload when one was requested and required.
pub reload: Option<ReloadAccepted>,
}
@@ -51,11 +61,11 @@ pub(super) async fn patch_config(
let active_config = shared.active_runtime.load_full().config();
let mut prepared =
prepare_patch_to_path(&shared.config_path, &patch_json, expected_revision).await?;
let resolved = resolve_reload_config(&active_config, &prepared.desired_config)
.map_err(ApiFailure::bad_request)?;
prepared.response.runtime_reload_required = resolved.runtime_changed;
prepared.response.process_restart_required = !resolved.deferred_process_fields.is_empty();
prepared.response.deferred_process_fields = resolved.deferred_process_fields;
let resolved = reconcile_runtime_effect(
&mut prepared.response,
&active_config,
&prepared.desired_config,
)?;
let reservation = if let Some(request) = reload_request.filter(|_| resolved.runtime_changed) {
Some(
shared
@@ -79,6 +89,19 @@ pub(super) async fn patch_config(
Ok(resp)
}
fn reconcile_runtime_effect(
response: &mut PatchConfigResponse,
active_config: &ProxyConfig,
desired_config: &ProxyConfig,
) -> Result<ResolvedReloadConfig, ApiFailure> {
let resolved =
resolve_reload_config(active_config, desired_config).map_err(ApiFailure::bad_request)?;
response.runtime_reload_required = resolved.runtime_changed;
response.process_restart_required = !resolved.deferred_process_fields.is_empty();
response.deferred_process_fields = resolved.deferred_process_fields.clone();
Ok(resolved)
}
/// Core patch logic, decoupled from hyper/shared-state so it is unit-testable
/// against a temp file. The route handler holds `mutation_lock` while calling this.
#[cfg(test)]
@@ -206,7 +229,8 @@ async fn prepare_patch_to_path(
let revision = compute_snapshot_revision(&candidate);
let new_cfg = candidate.config;
let class = classify_config_changes(&old_cfg, &new_cfg);
let resolved = resolve_reload_config(&old_cfg, &new_cfg).map_err(ApiFailure::bad_request)?;
let deferred_process_fields =
deferred_process_fields(&old_cfg, &new_cfg).map_err(ApiFailure::bad_request)?;
Ok(PreparedConfigPatch {
owner_path,
@@ -215,9 +239,9 @@ async fn prepare_patch_to_path(
response: PatchConfigResponse {
revision,
restart_required: class.restart_required,
runtime_reload_required: resolved.runtime_changed,
process_restart_required: !resolved.deferred_process_fields.is_empty(),
deferred_process_fields: resolved.deferred_process_fields,
runtime_reload_required: class.restart_required,
process_restart_required: !deferred_process_fields.is_empty(),
deferred_process_fields,
changed: class.changed,
reload: None,
},
@@ -239,7 +263,7 @@ fn reload_submit_failure(error: ReloadSubmitError) -> ApiFailure {
}
}
/// Return only the editable config sections + current revision.
/// Returns only the editable config sections and current revision.
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
let loaded = load_config_snapshot(config_path, false).await?;
let revision = compute_snapshot_revision(&loaded);
+4 -1
View File
@@ -134,11 +134,14 @@ async fn patch_web_debug_is_hot_and_limits_are_process_deferred() {
#[tokio::test]
async fn patch_web_decoy_fasttrack_requires_only_process_restart() {
let (path, _directory) = temp_config("[web]\nenabled = false\n");
let active = ProxyConfig::load(&path).unwrap();
let patch: Json = serde_json::json!({
"web": {"decoy_fasttrack_mode": "shadow"}
});
let response = apply_patch_to_path(&path, &patch, None).await.unwrap();
let mut prepared = prepare_patch_to_path(&path, &patch, None).await.unwrap();
reconcile_runtime_effect(&mut prepared.response, &active, &prepared.desired_config).unwrap();
let response = prepared.response;
assert!(response.restart_required);
assert!(!response.runtime_reload_required);
+3 -10
View File
@@ -127,7 +127,6 @@ impl WebDecoyUpstreamStatus {
pub(super) struct WebDecoyFastTrackStatus {
mode: WebDecoyFastTrackMode,
requests: Vec<WebDecoyFastTrackCounter>,
shadow_mismatches_total: u64,
}
impl WebDecoyFastTrackStatus {
@@ -136,9 +135,6 @@ impl WebDecoyFastTrackStatus {
Self {
mode: config.web.decoy_fasttrack_mode,
requests: publication.telemetry.decoy_fasttrack_counters(),
shadow_mismatches_total: publication
.telemetry
.decoy_fasttrack_shadow_mismatches(),
}
}
}
@@ -209,11 +205,9 @@ mod tests {
serde_json::to_value(super::WebCapacityStatus::new(&publication, None, &config))
.unwrap();
let decoy = serde_json::to_value(super::WebDecoyUpstreamStatus::new(&publication)).unwrap();
let fasttrack = serde_json::to_value(super::WebDecoyFastTrackStatus::new(
&publication,
&config,
))
.unwrap();
let fasttrack =
serde_json::to_value(super::WebDecoyFastTrackStatus::new(&publication, &config))
.unwrap();
let carrier =
serde_json::to_value(super::WebCarrierNegotiationStatus::new(&publication)).unwrap();
let lifecycle = serde_json::to_value(super::WebLifecycleCountersStatus::new(
@@ -242,7 +236,6 @@ mod tests {
fasttrack["requests"].as_array().unwrap().len(),
crate::web::telemetry::WebDecoyFastTrackDisposition::ALL.len()
);
assert_eq!(fasttrack["shadow_mismatches_total"], 0);
assert_eq!(capacity["partial"][0], "runtime");
assert_eq!(
carrier["selections"].as_array().unwrap().len(),