use crate::migrate::{first_error_line, select_set};
use crate::{Postgres, Statement};
use cratefield_core::{Database as _, DbError, Harness, TenantStatus};
#[must_use]
pub fn tenant_lock_key(tenant: &str) -> i64 {
let hex = cratefield_core::migration_checksum(tenant);
i64::from_str_radix(&hex[..15], 16).unwrap_or(1)
}
impl Postgres {
pub async fn bootstrap_registry(&self) -> Result<(), DbError> {
self.execute(&Statement::new(
"CREATE TABLE IF NOT EXISTS harness_tenants (
tenant TEXT PRIMARY KEY,
dsn TEXT NOT NULL,
status TEXT NOT NULL
);"
.to_owned(),
))
.await?;
Ok(())
}
pub async fn register_tenant(&self, tenant: &str, dsn: &str) -> Result<(), DbError> {
let affected = self
.execute(&Statement::with_values(
"INSERT INTO harness_tenants (tenant, dsn, status) VALUES (?, ?, 'provisioning') \
ON CONFLICT (tenant) DO UPDATE SET dsn = excluded.dsn, status = 'provisioning' \
WHERE harness_tenants.status <> 'archived'"
.to_owned(),
vec![tenant.to_owned().into(), dsn.to_owned().into()],
))
.await?;
if affected == 0 {
return Err(DbError::Execute(format!(
"tenant {tenant} is archived and cannot be re-registered"
)));
}
Ok(())
}
pub async fn tenants(&self) -> Result<Vec<TenantRecord>, DbError> {
let rows = self
.query(&Statement::new(
"SELECT tenant, dsn, status FROM harness_tenants ORDER BY tenant".to_owned(),
))
.await?;
Ok(rows
.rows
.iter()
.filter_map(|row| {
Some(TenantRecord {
tenant: row.get::<String>("tenant")?,
dsn: row.get::<String>("dsn")?,
status: TenantStatus::parse(&row.get::<String>("status")?),
})
})
.collect())
}
pub async fn set_tenant_status(&self, tenant: &str, status: TenantStatus) -> bool {
let admits = status.admits();
if admits.is_empty() {
return false;
}
let placeholders = std::iter::repeat_n("?", admits.len())
.collect::<Vec<_>>()
.join(", ");
let mut values: Vec<sea_query::Value> = vec![status.as_str().to_owned().into()];
values.push(tenant.to_owned().into());
values.extend(
admits
.iter()
.map(|previous| previous.as_str().to_owned().into()),
);
let applied = self
.execute(&Statement::with_values(
format!(
"UPDATE harness_tenants SET status = ? WHERE tenant = ? AND status IN \
({placeholders})"
),
values,
))
.await;
match applied {
Ok(rows) => rows > 0,
Err(error) => {
tracing::warn!(
tenant = %tenant,
status = status.as_str(),
%error,
"could not write the tenant status"
);
false
}
}
}
pub async fn reconcile_tenant(
&self,
record: &TenantRecord,
harness: &Harness,
) -> Result<TenantReport, DbError> {
let started = std::time::Instant::now();
let mut report = TenantReport {
tenant: record.tenant.clone(),
status: TenantStatus::Active,
applied: 0,
skipped: 0,
error: None,
};
let outcome = self
.reconcile_tenant_inner(record, harness, &mut report)
.await;
if outcome.is_err() {
report.status = TenantStatus::Degraded;
}
if !self.set_tenant_status(&record.tenant, report.status).await {
tracing::warn!(
tenant = %record.tenant,
status = report.status.as_str(),
"the registry did not take the reconciled status; the tenant moved underneath it"
);
}
tracing::info!(
tenant = %record.tenant,
status = report.status.as_str(),
applied = report.applied,
skipped = report.skipped,
duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
"tenant reconciliation finished"
);
outcome.map(|()| report)
}
async fn reconcile_tenant_inner(
&self,
record: &TenantRecord,
harness: &Harness,
report: &mut TenantReport,
) -> Result<(), DbError> {
let mut lock = <sqlx::PgConnection as sqlx::Connection>::connect(&record.dsn)
.await
.map_err(|err| {
DbError::Batch(format!(
"tenant {} database unreachable: {}",
record.tenant,
first_error_line(&err.to_string())
))
})?;
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(tenant_lock_key(&record.tenant))
.execute(&mut lock)
.await
.map_err(|err| DbError::Batch(err.to_string()))?;
let tenant = Postgres::connect(&record.dsn).await?;
tenant
.execute(&crate::migrate::tracking_table_ddl())
.await?;
tenant
.execute(&crate::migrate::tracking_table_backfill_ddl())
.await?;
for module in harness.modules() {
let set = select_set(&module.migrations())
.map_err(|reason| DbError::Batch(format!("module {}: {reason}", module.name())))?;
let before = tenant.applied_keys(module.name()).await?;
tenant.apply_migrations(module.name(), set).await?;
let after = tenant.applied_keys(module.name()).await?;
let applied = after.len() - after.intersection(&before).count();
report.applied += applied;
report.skipped += before.intersection(&after).count();
if applied > 0 {
tracing::info!(
tenant = %record.tenant,
module = module.name(),
applied,
"migrations applied"
);
}
}
Ok(())
}
pub async fn reconcile_fleet(
&self,
harness: &Harness,
parallelism: usize,
) -> Result<Vec<TenantReport>, DbError> {
self.bootstrap_registry().await?;
let records: Vec<TenantRecord> = self
.tenants()
.await?
.into_iter()
.filter(|record| record.status.is_reconciled())
.collect();
let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(parallelism.max(1)));
let plans: Vec<_> = records
.into_iter()
.map(|record| {
let db = self.clone();
let semaphore = std::sync::Arc::clone(&semaphore);
async move {
let _permit = semaphore.acquire_owned().await;
db.reconcile_tenant(&record, harness)
.await
.unwrap_or_else(|err| TenantReport {
tenant: record.tenant,
status: TenantStatus::Degraded,
applied: 0,
skipped: 0,
error: Some(err.to_string()),
})
}
})
.collect();
let mut reports = futures_util::future::join_all(plans).await;
reports.sort_by(|a, b| a.tenant.cmp(&b.tenant));
let degraded = reports
.iter()
.filter(|report| report.status == TenantStatus::Degraded)
.count();
tracing::info!(
tenants = reports.len(),
degraded,
"fleet reconciliation finished"
);
Ok(reports)
}
pub async fn reconcile_plan(
&self,
record: &TenantRecord,
harness: &Harness,
) -> Result<TenantPlan, DbError> {
let mut lock = <sqlx::PgConnection as sqlx::Connection>::connect(&record.dsn)
.await
.map_err(|err| DbError::Batch(err.to_string()))?;
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(tenant_lock_key(&record.tenant))
.execute(&mut lock)
.await
.map_err(|err| DbError::Batch(err.to_string()))?;
let tenant = Postgres::connect(&record.dsn).await?;
let mut plan = TenantPlan {
tenant: record.tenant.clone(),
modules: Vec::new(),
};
for module in harness.modules() {
let set = select_set(&module.migrations())
.map_err(|reason| DbError::Batch(format!("module {}: {reason}", module.name())))?;
let applied = tenant.applied_keys(module.name()).await?;
let pending: Vec<String> = set
.iter()
.map(|migration| migration.id)
.filter(|id| !applied.contains(&format!("{}/{}", module.name(), id)))
.map(ToOwned::to_owned)
.collect();
if !pending.is_empty() {
plan.modules.push(ModulePlan {
module: module.name().to_owned(),
migrations: pending,
});
}
}
Ok(plan)
}
async fn applied_keys(
&self,
module: &str,
) -> Result<std::collections::HashSet<String>, DbError> {
let prefix = format!("{module}/");
let rows = self
.query(&Statement::with_values(
"SELECT id FROM harness_migrations WHERE id LIKE ?".to_owned(),
vec![format!("{prefix}%").into()],
))
.await?;
Ok(rows
.rows
.iter()
.filter_map(|row| row.get::<String>("id"))
.filter(|id| id.starts_with(&prefix))
.collect())
}
}
#[derive(Debug, Clone)]
pub struct TenantRecord {
pub tenant: String,
pub dsn: String,
pub status: TenantStatus,
}
#[derive(Debug, Clone)]
pub struct TenantReport {
pub tenant: String,
pub status: TenantStatus,
pub applied: usize,
pub skipped: usize,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TenantPlan {
pub tenant: String,
pub modules: Vec<ModulePlan>,
}
#[derive(Debug, Clone)]
pub struct ModulePlan {
pub module: String,
pub migrations: Vec<String>,
}