cratefield_adapter_postgres/reconcile.rs
1//! Boot-time reconciliation (RECONCILIATION.md, issue #30): the one
2//! operation that touches every tenant database on every boot of every
3//! native replica.
4//!
5//! The flow on boot: connect to the **control database** (this `Postgres`
6//! instance), bootstrap its `harness_tenants` registry, then for every
7//! tenant whose status is `active` or `provisioning` apply the harness's
8//! module migrations to that tenant's own database, concurrently but
9//! bounded (default 8). A tenant that fails is marked `degraded` in the
10//! registry and every other tenant serves; only a control-database
11//! failure aborts the boot.
12//!
13//! # Locking
14//!
15//! Before reading what is missing, the reconciler takes a Postgres
16//! **advisory lock inside the tenant's own database**, keyed on a hash of
17//! the tenant id, held for the duration of that tenant's reconciliation
18//! on a dedicated connection. The lock lives in the tenant database so
19//! one tenant's reconciliation can never block another's, and it is
20//! released automatically when the connection dies — a replica that
21//! crashes holding one does not wedge the fleet.
22//!
23//! This is a **session-level** lock (`pg_advisory_lock`) rather than the
24//! `pg_advisory_xact_lock` RECONCILIATION.md §3 proposes: the design's
25//! one lock transaction cannot also hold the non-transactional
26//! migrations that must run in autocommit (`CREATE INDEX CONCURRENTLY`
27//! refuses a transaction block). The semantics the design wants — lock
28//! before reading, released on crash — are identical.
29//!
30//! # Ordering and checksums
31//!
32//! Modules apply in the harness's `depends_on` order ([`Harness::modules`]
33//! is already dependency-resolved), each set by zero-padded migration id;
34//! a recorded checksum that differs is fatal for that tenant and never
35//! auto-repaired. Both are the adapters' existing behaviour; the
36//! reconciler adds only the fleet behaviour around them.
37
38use crate::migrate::{first_error_line, select_set};
39use crate::{Postgres, Statement};
40use cratefield_core::{Database as _, DbError, Harness, TenantStatus};
41
42/// The advisory-lock key for a tenant: the first 60 bits of the sha256
43/// of its id, as [`i64`]. Stable across versions (it is a hash of the
44/// id, not of Rust's `DefaultHasher`) and never zero for a non-empty id.
45#[must_use]
46pub fn tenant_lock_key(tenant: &str) -> i64 {
47 let hex = cratefield_core::migration_checksum(tenant);
48 i64::from_str_radix(&hex[..15], 16).unwrap_or(1)
49}
50
51impl Postgres {
52 /// Creates the `harness_tenants` registry on the control database,
53 /// idempotently. Boot calls this first; a failure here aborts the
54 /// boot (there is no registry to read, no DSN to resolve).
55 ///
56 /// # Errors
57 ///
58 /// [`DbError`] when the DDL fails.
59 pub async fn bootstrap_registry(&self) -> Result<(), DbError> {
60 self.execute(&Statement::new(
61 "CREATE TABLE IF NOT EXISTS harness_tenants (
62 tenant TEXT PRIMARY KEY,
63 dsn TEXT NOT NULL,
64 status TEXT NOT NULL
65 );"
66 .to_owned(),
67 ))
68 .await?;
69 Ok(())
70 }
71
72 /// Registers (or re-points) a tenant: upsert the DSN and reset the
73 /// status to `provisioning` — the next boot's reconciliation is what
74 /// promotes it to `active`.
75 ///
76 /// # Errors
77 ///
78 /// [`DbError`] when the write fails.
79 pub async fn register_tenant(&self, tenant: &str, dsn: &str) -> Result<(), DbError> {
80 self.execute(&Statement::with_values(
81 "INSERT INTO harness_tenants (tenant, dsn, status) VALUES (?, ?, 'provisioning') \
82 ON CONFLICT (tenant) DO UPDATE SET dsn = excluded.dsn, status = 'provisioning'"
83 .to_owned(),
84 vec![tenant.to_owned().into(), dsn.to_owned().into()],
85 ))
86 .await?;
87 Ok(())
88 }
89
90 /// Every tenant in the registry, newest DSN wins. The values come
91 /// from the control database and include the DSN: callers log or
92 /// serve with them, never print them.
93 ///
94 /// # Errors
95 ///
96 /// [`DbError`] when the read fails.
97 pub async fn tenants(&self) -> Result<Vec<TenantRecord>, DbError> {
98 let rows = self
99 .query(&Statement::new(
100 "SELECT tenant, dsn, status FROM harness_tenants ORDER BY tenant".to_owned(),
101 ))
102 .await?;
103 Ok(rows
104 .rows
105 .iter()
106 .filter_map(|row| {
107 Some(TenantRecord {
108 tenant: row.get::<String>("tenant")?,
109 dsn: row.get::<String>("dsn")?,
110 status: TenantStatus::parse(&row.get::<String>("status")?),
111 })
112 })
113 .collect())
114 }
115
116 /// Records a tenant's status. Best-effort by design
117 /// (RECONCILIATION.md §6): a write that fails mid-boot still leaves
118 /// the tenant refused at request time, because its pool was never
119 /// registered.
120 pub async fn set_tenant_status(&self, tenant: &str, status: TenantStatus) {
121 let _ = self
122 .execute(&Statement::with_values(
123 "UPDATE harness_tenants SET status = ? WHERE tenant = ?".to_owned(),
124 vec![status.as_str().to_owned().into(), tenant.to_owned().into()],
125 ))
126 .await;
127 }
128
129 /// Reconciles one tenant: take the advisory lock, then apply every
130 /// module's migrations to the tenant's database in `depends_on`
131 /// order. On any failure the tenant is marked `degraded` in the
132 /// control database (best-effort) and the error is returned; the
133 /// caller decides whether that aborts the boot (`strict`).
134 ///
135 /// The tenant DSN is never part of any error message: connection
136 /// strings carry credentials.
137 ///
138 /// # Errors
139 ///
140 /// [`DbError`] when the tenant's database is unreachable or a
141 /// migration fails.
142 pub async fn reconcile_tenant(
143 &self,
144 record: &TenantRecord,
145 harness: &Harness,
146 ) -> Result<TenantReport, DbError> {
147 let started = std::time::Instant::now();
148 let mut report = TenantReport {
149 tenant: record.tenant.clone(),
150 status: TenantStatus::Active,
151 applied: 0,
152 skipped: 0,
153 error: None,
154 };
155 let outcome = self
156 .reconcile_tenant_inner(record, harness, &mut report)
157 .await;
158 if outcome.is_err() {
159 report.status = TenantStatus::Degraded;
160 }
161 self.set_tenant_status(&record.tenant, report.status).await;
162 tracing::info!(
163 tenant = %record.tenant,
164 status = report.status.as_str(),
165 applied = report.applied,
166 skipped = report.skipped,
167 duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
168 "tenant reconciliation finished"
169 );
170 outcome.map(|()| report)
171 }
172
173 async fn reconcile_tenant_inner(
174 &self,
175 record: &TenantRecord,
176 harness: &Harness,
177 report: &mut TenantReport,
178 ) -> Result<(), DbError> {
179 // A dedicated connection holds the advisory lock for the whole
180 // reconciliation; the migration runner runs over its own pool.
181 let mut lock = <sqlx::PgConnection as sqlx::Connection>::connect(&record.dsn)
182 .await
183 .map_err(|err| {
184 DbError::Batch(format!(
185 "tenant {} database unreachable: {}",
186 record.tenant,
187 first_error_line(&err.to_string())
188 ))
189 })?;
190 sqlx::query("SELECT pg_advisory_lock($1)")
191 .bind(tenant_lock_key(&record.tenant))
192 .execute(&mut lock)
193 .await
194 .map_err(|err| DbError::Batch(err.to_string()))?;
195
196 let tenant = Postgres::connect(&record.dsn).await?;
197 // Bootstrap the tracking table before anything reads it: a fresh
198 // tenant database has none, and "what is missing" needs the read
199 // to succeed (the lock is already held, so this is safe).
200 tenant
201 .execute(&crate::migrate::tracking_table_ddl())
202 .await?;
203 tenant
204 .execute(&crate::migrate::tracking_table_backfill_ddl())
205 .await?;
206 for module in harness.modules() {
207 let set = select_set(&module.migrations())
208 .map_err(|reason| DbError::Batch(format!("module {}: {reason}", module.name())))?;
209 let before = tenant.applied_keys(module.name()).await?;
210 tenant.apply_migrations(module.name(), set).await?;
211 let after = tenant.applied_keys(module.name()).await?;
212 let applied = after.len() - after.intersection(&before).count();
213 report.applied += applied;
214 report.skipped += before.intersection(&after).count();
215 if applied > 0 {
216 tracing::info!(
217 tenant = %record.tenant,
218 module = module.name(),
219 applied,
220 "migrations applied"
221 );
222 }
223 }
224 Ok(())
225 }
226
227 /// Reconciles every tenant in the registry whose status is `active`
228 /// or `provisioning`, at most `parallelism` at a time (default 8;
229 /// #30 §10 keeps the right number a measured question). A `degraded`
230 /// tenant is left for the retry timer, not re-flown this boot.
231 ///
232 /// This is what boot calls. The control database drives everything:
233 /// if it cannot be bootstrapped or read, the error returns and the
234 /// boot aborts — there is nothing useful to serve without a registry.
235 /// A tenant that fails is marked `degraded` and every other tenant
236 /// serves; the same with `strict` semantics is the caller's
237 /// decision (any `Degraded` in the reports).
238 ///
239 /// # Errors
240 ///
241 /// [`DbError`] when the control database cannot be bootstrapped or
242 /// read. Per-tenant failures never abort the fleet.
243 pub async fn reconcile_fleet(
244 &self,
245 harness: &Harness,
246 parallelism: usize,
247 ) -> Result<Vec<TenantReport>, DbError> {
248 self.bootstrap_registry().await?;
249 let records: Vec<TenantRecord> = self
250 .tenants()
251 .await?
252 .into_iter()
253 .filter(|record| record.status != TenantStatus::Degraded)
254 .collect();
255 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(parallelism.max(1)));
256 // One task, many in-flight futures: the per-tenant work is
257 // I/O-bound sqlx calls, and a semaphore future per tenant bounds
258 // the fan-out without `spawn`'s `Send` + `'static` requirements
259 // on the harness's module trait objects.
260 let plans: Vec<_> = records
261 .into_iter()
262 .map(|record| {
263 let db = self.clone();
264 let semaphore = std::sync::Arc::clone(&semaphore);
265 async move {
266 let _permit = semaphore.acquire_owned().await;
267 db.reconcile_tenant(&record, harness)
268 .await
269 .unwrap_or_else(|err| TenantReport {
270 tenant: record.tenant,
271 status: TenantStatus::Degraded,
272 applied: 0,
273 skipped: 0,
274 error: Some(err.to_string()),
275 })
276 }
277 })
278 .collect();
279 let mut reports = futures_util::future::join_all(plans).await;
280 reports.sort_by(|a, b| a.tenant.cmp(&b.tenant));
281 let degraded = reports
282 .iter()
283 .filter(|report| report.status == TenantStatus::Degraded)
284 .count();
285 tracing::info!(
286 tenants = reports.len(),
287 degraded,
288 "fleet reconciliation finished"
289 );
290 Ok(reports)
291 }
292
293 /// What *would* be applied, per module, for one tenant. Takes the
294 /// same advisory lock, so the answer is not a guess about a moving
295 /// target (RECONCILIATION.md §8), and applies nothing.
296 ///
297 /// # Errors
298 ///
299 /// [`DbError`] when the tenant's database is unreachable.
300 pub async fn reconcile_plan(
301 &self,
302 record: &TenantRecord,
303 harness: &Harness,
304 ) -> Result<TenantPlan, DbError> {
305 let mut lock = <sqlx::PgConnection as sqlx::Connection>::connect(&record.dsn)
306 .await
307 .map_err(|err| DbError::Batch(err.to_string()))?;
308 sqlx::query("SELECT pg_advisory_lock($1)")
309 .bind(tenant_lock_key(&record.tenant))
310 .execute(&mut lock)
311 .await
312 .map_err(|err| DbError::Batch(err.to_string()))?;
313 let tenant = Postgres::connect(&record.dsn).await?;
314
315 let mut plan = TenantPlan {
316 tenant: record.tenant.clone(),
317 modules: Vec::new(),
318 };
319 for module in harness.modules() {
320 let set = select_set(&module.migrations())
321 .map_err(|reason| DbError::Batch(format!("module {}: {reason}", module.name())))?;
322 let applied = tenant.applied_keys(module.name()).await?;
323 let pending: Vec<String> = set
324 .iter()
325 .map(|migration| migration.id)
326 .filter(|id| !applied.contains(&format!("{}/{}", module.name(), id)))
327 .map(ToOwned::to_owned)
328 .collect();
329 if !pending.is_empty() {
330 plan.modules.push(ModulePlan {
331 module: module.name().to_owned(),
332 migrations: pending,
333 });
334 }
335 }
336 Ok(plan)
337 }
338
339 async fn applied_keys(
340 &self,
341 module: &str,
342 ) -> Result<std::collections::HashSet<String>, DbError> {
343 let prefix = format!("{module}/");
344 let rows = self
345 .query(&Statement::with_values(
346 "SELECT id FROM harness_migrations WHERE id LIKE ?".to_owned(),
347 vec![format!("{prefix}%").into()],
348 ))
349 .await?;
350 Ok(rows
351 .rows
352 .iter()
353 .filter_map(|row| row.get::<String>("id"))
354 .filter(|id| id.starts_with(&prefix))
355 .collect())
356 }
357}
358
359/// One tenant in the control database's registry.
360#[derive(Debug, Clone)]
361pub struct TenantRecord {
362 /// The tenant id, e.g. `factory0`.
363 pub tenant: String,
364 /// The tenant database's connection string. Never log it.
365 pub dsn: String,
366 /// The status reconciliation last recorded.
367 pub status: TenantStatus,
368}
369
370/// The outcome of reconciling one tenant.
371#[derive(Debug, Clone)]
372pub struct TenantReport {
373 pub tenant: String,
374 pub status: TenantStatus,
375 /// Migrations applied this boot.
376 pub applied: usize,
377 /// Migrations already recorded, skipped.
378 pub skipped: usize,
379 /// Why the tenant went `degraded`, when it did. Names the migration
380 /// or the failure, never the DSN.
381 pub error: Option<String>,
382}
383
384/// What [`Postgres::reconcile_plan`] would apply for one tenant.
385#[derive(Debug, Clone)]
386pub struct TenantPlan {
387 pub tenant: String,
388 /// Only modules with something pending; a module fully applied is
389 /// not listed at all.
390 pub modules: Vec<ModulePlan>,
391}
392
393/// One module's pending migration ids, in apply order.
394#[derive(Debug, Clone)]
395pub struct ModulePlan {
396 pub module: String,
397 /// Zero-padded ids, e.g. `["0003", "0004"]`.
398 pub migrations: Vec<String>,
399}