use std::sync::LazyLock;
use std::time::Duration;
use serde_json::{Value, json};
use tokio::sync::Notify;
use vti_common::audit::RoomStorageChangedData;
use vti_rooms::blobs::{Migration, MigrationState, MigrationStep, PauseCause};
use crate::rooms::storage_configs::state_of;
use crate::server::AppState;
pub const DEFAULT_MAX_BYTES_PER_SECOND: u64 = 8 * 1024 * 1024;
const IDLE: Duration = Duration::from_secs(30);
const AFTER_ERROR: Duration = Duration::from_secs(30);
static KICK: LazyLock<Notify> = LazyLock::new(Notify::new);
pub(crate) fn kick() {
KICK.notify_one();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wire {
V0_1,
V0_2,
}
fn rfc3339(secs: u64) -> Option<String> {
chrono::DateTime::from_timestamp(secs as i64, 0).map(|t| t.to_rfc3339())
}
fn now_secs() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
fn state_members(m: &Migration, wire: Wire, out: &mut Value) {
match wire {
Wire::V0_1 => {
let state = match m.state {
MigrationState::Queued => "running",
s => s.as_str(),
};
out["state"] = json!(state);
}
Wire::V0_2 => {
out["state"] = json!(m.state.as_str());
match m.state {
MigrationState::Paused => {
let cause = m.paused_cause.unwrap_or(PauseCause::Destination);
out["pausedCause"] = json!(cause.as_str());
out["lastError"] = json!(m.last_error.as_deref().unwrap_or("paused"));
if let Some(at) = m.next_attempt_at.and_then(rfc3339) {
out["nextAttemptAt"] = json!(at);
}
}
MigrationState::Running => {
if let Some(at) = m.next_attempt_at
&& let Some(e) = &m.last_error
{
out["lastError"] = json!(e);
if let Some(at) = rfc3339(at) {
out["nextAttemptAt"] = json!(at);
}
}
}
_ => {}
}
}
}
}
pub fn response_json(m: &Migration, wire: Wire) -> Value {
let mut out = json!({
"migrationId": m.migration_id.to_string(),
"roomId": m.room_id,
"toConfig": m.to_config,
"fromConfigs": m.from_configs,
"totalFiles": m.total_files,
"totalBytes": m.total_bytes,
"doneFiles": m.done_files,
"doneBytes": m.done_bytes,
});
state_members(m, wire, &mut out);
out
}
pub fn progress_json(m: &Migration, wire: Wire) -> Value {
let mut out = json!({
"migrationId": m.migration_id.to_string(),
"totalFiles": m.total_files,
"totalBytes": m.total_bytes,
"doneFiles": m.done_files,
"doneBytes": m.done_bytes,
});
if wire == Wire::V0_2 {
out["toConfig"] = json!(m.to_config);
}
state_members(m, wire, &mut out);
out
}
pub async fn audit_migration(state: &AppState, m: &Migration, event: &str, actor: &str) {
audit_with(state, m, event, actor, None).await;
}
async fn audit_with(
state: &AppState,
m: &Migration,
event: &str,
actor: &str,
extra: Option<(&str, Value)>,
) {
let mut current = response_json(m, Wire::V0_2);
if let Some(e) = &m.last_error
&& m.state == MigrationState::Failed
{
current["lastError"] = json!(e);
}
if let Some((k, v)) = extra {
current[k] = v;
}
crate::trust_tasks::storage_tasks::audit(
state,
actor,
RoomStorageChangedData {
event: event.to_string(),
config_id: m.to_config.clone(),
room_id: Some(m.room_id.clone()),
previous: Some(json!(m.from_configs)),
current,
approvers: m.approvers.clone(),
reason: m.reason.clone(),
},
)
.await;
}
pub async fn pause_after_restore(state: &AppState) {
match state
.room_files
.pause_running_migrations(
"restored from a backup: issue vtc/rooms/storage/migrate again to resume; the blob \
in flight is verified again first",
)
.await
{
Ok(paused) => {
for m in paused {
audit_migration(state, &m, "migrationPaused", &m.requested_by.clone()).await;
}
}
Err(e) => {
tracing::warn!(error = %e, "backup import: running migrations not paused");
}
}
}
pub fn spawn(state: AppState, rate_cap: u64, mut shutdown: tokio::sync::watch::Receiver<bool>) {
tokio::spawn(async move {
if let Err(e) = state.room_files.clear_migration_staging().await {
tracing::warn!(error = %e, "a migration's staging was not cleared");
}
loop {
let wait = tokio::select! {
_ = shutdown.changed() => return,
wait = run(&state, rate_cap) => wait,
};
tokio::select! {
_ = shutdown.changed() => return,
_ = KICK.notified() => {}
_ = tokio::time::sleep(wait) => {}
}
}
});
}
async fn target_unfit(state: &AppState, config_id: &str) -> Option<(PauseCause, String)> {
match state.room_storage.config(config_id).await {
Ok(Some(record)) if state_of(&record) != "active" => Some((
PauseCause::Destination,
format!("put to {config_id}: the config is {}", state_of(&record)),
)),
Ok(Some(record)) if !state.room_storage.usable(&record) => Some((
PauseCause::Credential,
format!("credential for {config_id}: none usable"),
)),
Ok(Some(_)) => None,
Ok(None) => Some((
PauseCause::Destination,
format!("put to {config_id}: no such config"),
)),
Err(_) => None,
}
}
pub async fn run(state: &AppState, rate_cap: u64) -> Duration {
loop {
let now = now_secs();
match state.room_files.resume_due(now).await {
Ok(resumed) => {
for m in resumed {
tracing::info!(migration = %m.migration_id, "room file migration resumed");
audit_with(
state,
&m,
"migrationResumed",
&m.requested_by.clone(),
Some(("automatic", json!(true))),
)
.await;
}
}
Err(e) => tracing::warn!(error = %e, "paused migrations not resumed"),
}
match state.room_files.promote_queued().await {
Ok(started) => {
for m in started {
tracing::info!(migration = %m.migration_id, "queued room file migration started");
}
}
Err(e) => tracing::warn!(error = %e, "queued migrations not started"),
}
let running = match state.room_files.running_migrations().await {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "migrations not read");
return AFTER_ERROR;
}
};
if running.is_empty() {
return IDLE;
}
let mut wait = IDLE;
let mut progressed = false;
for m in running {
if let Some(at) = m.next_attempt_at
&& at > now
{
wait = wait.min(until(Some(at)));
continue;
}
let step = match target_unfit(state, &m.to_config).await {
Some((cause, why)) => {
state
.room_files
.migration_step_failed(&m.room_id, m.migration_id, cause, &why)
.await
}
None => state.room_files.migrate_step(&m.room_id, rate_cap).await,
};
let step = match step {
Ok(s) => s,
Err(e) => {
tracing::warn!(migration = %m.migration_id, error = %e, "migration step failed");
wait = wait.min(AFTER_ERROR);
continue;
}
};
match step {
MigrationStep::Moved(_) | MigrationStep::Skipped(_) => progressed = true,
MigrationStep::Complete(m) => {
progressed = true;
tracing::info!(migration = %m.migration_id, files = m.done_files, "room files migrated");
audit_migration(state, &m, "migrationCompleted", &m.requested_by.clone()).await;
}
MigrationStep::Failed(m) => {
progressed = true;
tracing::warn!(migration = %m.migration_id, error = ?m.last_error, "room file migration failed");
audit_migration(state, &m, "migrationFailed", &m.requested_by.clone()).await;
}
MigrationStep::Paused(m) => {
progressed = true;
tracing::warn!(migration = %m.migration_id, error = ?m.last_error, "room file migration paused");
audit_migration(state, &m, "migrationPaused", &m.requested_by.clone()).await;
}
MigrationStep::Retrying(m) => {
tracing::info!(migration = %m.migration_id, attempts = m.attempts, error = ?m.last_error, "room file migration will retry");
wait = wait.min(until(m.next_attempt_at));
}
MigrationStep::Waiting { until: at } => wait = wait.min(until(Some(at))),
_ => wait = wait.min(Duration::from_secs(1)),
}
}
if !progressed {
return wait;
}
}
}
fn until(at: Option<u64>) -> Duration {
let now = now_secs();
let secs = at
.map_or(1, |a| a.saturating_sub(now))
.clamp(1, IDLE.as_secs());
Duration::from_secs(secs)
}