mirror of
https://github.com/telemt/telemt.git
synced 2026-09-18 00:08:32 +03:00
497 lines
16 KiB
Rust
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;
|