Files
telemt/src/maestro/reload.rs
T
2026-08-22 16:31:49 +03:00

497 lines
16 KiB
Rust

use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use crate::config::ProxyConfig;
const RELOAD_HISTORY_CAPACITY: usize = 32;
const RELOAD_COMMAND_CAPACITY: usize = 1;
const MAX_DRAIN_TIMEOUT_SECS: u64 = 3_600;
/// Session handling policy for an in-process runtime reload.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReloadMode {
#[default]
Instant,
Drain,
}
/// Failure policy applied during the activation barrier.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReloadFailurePolicy {
#[default]
KeepNew,
Rollback,
}
/// Request body accepted by the maestro reload endpoint.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ReloadRequest {
#[serde(default)]
pub(crate) mode: ReloadMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) timeout_secs: Option<u64>,
#[serde(default)]
pub(crate) failure_policy: ReloadFailurePolicy,
}
impl ReloadRequest {
/// Validates mode-specific request parameters.
pub(crate) fn validate(&self) -> Result<(), &'static str> {
match (self.mode, self.timeout_secs) {
(ReloadMode::Instant, None) => Ok(()),
(ReloadMode::Instant, Some(_)) => Err("timeout_secs is only valid when mode is drain"),
(ReloadMode::Drain, Some(1..=MAX_DRAIN_TIMEOUT_SECS)) => Ok(()),
(ReloadMode::Drain, Some(_)) => Err("timeout_secs must be within 1..=3600"),
(ReloadMode::Drain, None) => Err("timeout_secs is required when mode is drain"),
}
}
/// Parses optional PATCH query parameters into a reload request.
pub(crate) fn from_query(query: Option<&str>) -> Result<Option<Self>, String> {
let Some(query) = query.filter(|query| !query.is_empty()) else {
return Ok(None);
};
let mut mode = None;
let mut timeout_secs = None;
let mut failure_policy = None;
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"reload" if mode.is_none() => {
mode = Some(match value.as_ref() {
"instant" => ReloadMode::Instant,
"drain" => ReloadMode::Drain,
_ => return Err("reload must be instant or drain".to_string()),
});
}
"timeout_secs" if timeout_secs.is_none() => {
timeout_secs = Some(
value
.parse::<u64>()
.map_err(|_| "timeout_secs must be an integer".to_string())?,
);
}
"failure_policy" if failure_policy.is_none() => {
failure_policy = Some(match value.as_ref() {
"keep_new" => ReloadFailurePolicy::KeepNew,
"rollback" => ReloadFailurePolicy::Rollback,
_ => {
return Err("failure_policy must be keep_new or rollback".to_string());
}
});
}
"reload" | "timeout_secs" | "failure_policy" => {
return Err(format!("duplicate query parameter: {}", key));
}
_ => return Err(format!("unknown query parameter: {}", key)),
}
}
let mode = mode.ok_or_else(|| "reload query parameter is required".to_string())?;
let request = Self {
mode,
timeout_secs,
failure_policy: failure_policy.unwrap_or_default(),
};
request.validate().map_err(str::to_string)?;
Ok(Some(request))
}
}
/// Observable phase of one reload operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReloadPhase {
Accepted,
Preparing,
Activating,
Draining,
Succeeded,
RolledBack,
Failed,
}
impl ReloadPhase {
fn is_terminal(self) -> bool {
matches!(
self,
ReloadPhase::Succeeded | ReloadPhase::RolledBack | ReloadPhase::Failed
)
}
}
/// Bounded public status for one reload operation.
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ReloadStatus {
pub(crate) reload_id: u64,
pub(crate) target_generation: u64,
pub(crate) config_revision: String,
pub(crate) state: ReloadPhase,
pub(crate) mode: ReloadMode,
pub(crate) failure_policy: ReloadFailurePolicy,
pub(crate) requested_at_epoch_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) started_at_epoch_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) finished_at_epoch_secs: Option<u64>,
#[serde(
rename = "deferred_process_fields",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub(crate) deferred_fields: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) error: Option<String>,
}
/// Accepted operation metadata returned before asynchronous preparation starts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ReloadAccepted {
pub(crate) reload_id: u64,
pub(crate) target_generation: u64,
pub(crate) config_revision: String,
pub(crate) state: ReloadPhase,
pub(crate) mode: ReloadMode,
pub(crate) failure_policy: ReloadFailurePolicy,
}
pub(crate) struct ReloadCommand {
pub(crate) reload_id: u64,
pub(crate) target_generation: u64,
pub(crate) config: Arc<ProxyConfig>,
pub(crate) config_revision: String,
pub(crate) request: ReloadRequest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReloadSubmitError {
InProgress(u64),
MaestroUnavailable,
}
#[derive(Clone)]
pub(crate) struct ReloadControl {
command_tx: mpsc::Sender<ReloadCommand>,
status_store: Arc<ReloadStatusStore>,
active_generation: Arc<AtomicU64>,
}
pub(crate) struct ReloadCommandReceiver {
command_rx: mpsc::Receiver<ReloadCommand>,
}
/// Capacity and status reservation held while a config mutation is committed.
pub(crate) struct ReloadReservation {
permit: Option<mpsc::OwnedPermit<ReloadCommand>>,
status: ReloadStatus,
request: ReloadRequest,
status_store: Arc<ReloadStatusStore>,
}
struct ReloadStatusState {
next_reload_id: u64,
active_reload_id: Option<u64>,
statuses: VecDeque<ReloadStatus>,
accepting_commands: bool,
}
impl Default for ReloadStatusState {
fn default() -> Self {
Self {
next_reload_id: 0,
active_reload_id: None,
statuses: VecDeque::new(),
accepting_commands: true,
}
}
}
#[derive(Default)]
struct ReloadStatusStore {
state: Mutex<ReloadStatusState>,
}
impl ReloadControl {
/// Creates the process-scoped coordinator channel and status store.
pub(crate) fn channel(initial_generation: u64) -> (Self, ReloadCommandReceiver) {
let (command_tx, command_rx) = mpsc::channel(RELOAD_COMMAND_CAPACITY);
(
Self {
command_tx,
status_store: Arc::new(ReloadStatusStore::default()),
active_generation: Arc::new(AtomicU64::new(initial_generation)),
},
ReloadCommandReceiver { command_rx },
)
}
/// Atomically reserves and enqueues one reload operation.
pub(crate) async fn submit(
&self,
config: Arc<ProxyConfig>,
config_revision: String,
request: ReloadRequest,
) -> Result<ReloadAccepted, ReloadSubmitError> {
let reservation = self.reserve(config_revision, request).await?;
Ok(reservation.enqueue(config))
}
/// Reserves coordinator capacity before an external config mutation commits.
pub(crate) async fn reserve(
&self,
config_revision: String,
request: ReloadRequest,
) -> Result<ReloadReservation, ReloadSubmitError> {
let permit = self
.command_tx
.clone()
.try_reserve_owned()
.map_err(|_| ReloadSubmitError::MaestroUnavailable)?;
let target_generation = self
.active_generation
.load(Ordering::Acquire)
.saturating_add(1);
let status =
self.status_store
.reserve(target_generation, config_revision, request.clone())?;
Ok(ReloadReservation {
permit: Some(permit),
status,
request,
status_store: self.status_store.clone(),
})
}
/// Returns a retained reload status by identifier.
pub(crate) async fn status(&self, reload_id: u64) -> Option<ReloadStatus> {
self.status_store.get(reload_id)
}
/// Returns the identifier of the currently active reload.
pub(crate) async fn in_progress(&self) -> Option<u64> {
self.status_store.state.lock().active_reload_id
}
/// Rejects new commands while preserving an already accepted operation.
pub(crate) async fn begin_shutdown(&self) {
self.status_store.state.lock().accepting_commands = false;
}
/// Records a non-terminal lifecycle phase.
pub(crate) async fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
self.status_store.mark_phase(reload_id, phase);
}
/// Records process-owned fields deferred until the next process restart.
pub(crate) async fn set_deferred_fields(&self, reload_id: u64, fields: Vec<String>) {
self.status_store
.update(reload_id, |status| status.deferred_fields = fields);
}
/// Commits the active generation and completes the matching reload.
pub(crate) async fn succeed(&self, reload_id: u64, generation: u64) {
self.status_store
.finish_success(reload_id, generation, &self.active_generation);
}
/// Marks the matching reload as failed.
pub(crate) async fn fail(&self, reload_id: u64, error: impl Into<String>) {
self.status_store
.finish(reload_id, ReloadPhase::Failed, Some(error.into()));
}
/// Marks the matching reload as rolled back.
pub(crate) async fn rolled_back(&self, reload_id: u64, error: impl Into<String>) {
self.status_store
.finish(reload_id, ReloadPhase::RolledBack, Some(error.into()));
}
/// Appends a non-fatal warning to the matching reload status.
pub(crate) async fn add_warning(&self, reload_id: u64, warning: impl Into<String>) {
let warning = warning.into();
self.status_store
.update(reload_id, |status| status.warnings.push(warning));
}
}
impl ReloadReservation {
/// Enqueues the already reserved command without another fallible step.
pub(crate) fn enqueue(mut self, config: Arc<ProxyConfig>) -> ReloadAccepted {
let target_generation = self.status.target_generation;
let command = ReloadCommand {
reload_id: self.status.reload_id,
target_generation,
config,
config_revision: self.status.config_revision.clone(),
request: self.request.clone(),
};
// This consuming transition is the only path that removes the permit.
let permit = self
.permit
.take()
.expect("reload reservation always owns one channel permit");
permit.send(command);
ReloadAccepted {
reload_id: self.status.reload_id,
target_generation,
config_revision: self.status.config_revision.clone(),
state: ReloadPhase::Accepted,
mode: self.status.mode,
failure_policy: self.status.failure_policy,
}
}
}
impl Drop for ReloadReservation {
fn drop(&mut self) {
if self.permit.is_some() {
self.status_store.cancel_reservation(self.status.reload_id);
}
}
}
impl ReloadCommandReceiver {
/// Receives the next accepted reload command.
pub(crate) async fn recv(&mut self) -> Option<ReloadCommand> {
self.command_rx.recv().await
}
}
impl ReloadStatusStore {
fn reserve(
&self,
target_generation: u64,
config_revision: String,
request: ReloadRequest,
) -> Result<ReloadStatus, ReloadSubmitError> {
let mut state = self.state.lock();
if !state.accepting_commands {
return Err(ReloadSubmitError::MaestroUnavailable);
}
if let Some(reload_id) = state.active_reload_id {
return Err(ReloadSubmitError::InProgress(reload_id));
}
state.next_reload_id = state.next_reload_id.saturating_add(1).max(1);
let reload_id = state.next_reload_id;
let status = ReloadStatus {
reload_id,
target_generation,
config_revision,
state: ReloadPhase::Accepted,
mode: request.mode,
failure_policy: request.failure_policy,
requested_at_epoch_secs: now_epoch_secs(),
started_at_epoch_secs: None,
finished_at_epoch_secs: None,
deferred_fields: Vec::new(),
warnings: Vec::new(),
error: None,
};
state.active_reload_id = Some(reload_id);
state.statuses.push_back(status.clone());
while state.statuses.len() > RELOAD_HISTORY_CAPACITY {
state.statuses.pop_front();
}
Ok(status)
}
fn get(&self, reload_id: u64) -> Option<ReloadStatus> {
self.state
.lock()
.statuses
.iter()
.find(|status| status.reload_id == reload_id)
.cloned()
}
fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
self.update(reload_id, |status| {
status.state = phase;
if status.started_at_epoch_secs.is_none() && phase != ReloadPhase::Accepted {
status.started_at_epoch_secs = Some(now_epoch_secs());
}
});
}
fn finish(&self, reload_id: u64, phase: ReloadPhase, error: Option<String>) {
debug_assert!(phase.is_terminal());
let mut state = self.state.lock();
if let Some(status) = state
.statuses
.iter_mut()
.find(|status| status.reload_id == reload_id)
{
status.state = phase;
status.error = error;
status.finished_at_epoch_secs = Some(now_epoch_secs());
}
if state.active_reload_id == Some(reload_id) {
state.active_reload_id = None;
}
}
fn finish_success(&self, reload_id: u64, generation: u64, active_generation: &AtomicU64) {
let mut state = self.state.lock();
if state.active_reload_id != Some(reload_id) {
return;
}
let Some(status) = state
.statuses
.iter_mut()
.find(|status| status.reload_id == reload_id)
else {
return;
};
status.state = ReloadPhase::Succeeded;
status.error = None;
status.finished_at_epoch_secs = Some(now_epoch_secs());
active_generation.store(generation, Ordering::Release);
state.active_reload_id = None;
}
fn update(&self, reload_id: u64, update: impl FnOnce(&mut ReloadStatus)) {
let mut state = self.state.lock();
if let Some(status) = state
.statuses
.iter_mut()
.find(|status| status.reload_id == reload_id)
{
update(status);
}
}
fn cancel_reservation(&self, reload_id: u64) {
let mut state = self.state.lock();
if state.active_reload_id == Some(reload_id) {
state.active_reload_id = None;
}
if let Some(index) = state
.statuses
.iter()
.position(|status| status.reload_id == reload_id)
{
state.statuses.remove(index);
}
}
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
#[path = "reload_tests.rs"]
mod tests;