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 /// **An archived tenant is never re-registered.** Archiving means the
77 /// database was dropped and the data keys destroyed
78 /// (`docs/TENANT-ONBOARDING.md` §2); re-registering the same id would
79 /// mint a tenant that the registry says is the old one and that no
80 /// export can reconstitute. The guard is the `WHERE` on the conflict
81 /// branch, so it is decided by the database and not by a read the
82 /// caller might race.
83 ///
84 /// # Errors
85 ///
86 /// [`DbError`] when the write fails, and [`DbError::Execute`] naming
87 /// the tenant when the row is archived.
88 pub async fn register_tenant(&self, tenant: &str, dsn: &str) -> Result<(), DbError> {
89 // 0 rows affected means the conflict branch was filtered out:
90 // the row exists and is archived. An insert or a permitted update
91 // both report 1.
92 let affected = self
93 .execute(&Statement::with_values(
94 "INSERT INTO harness_tenants (tenant, dsn, status) VALUES (?, ?, 'provisioning') \
95 ON CONFLICT (tenant) DO UPDATE SET dsn = excluded.dsn, status = 'provisioning' \
96 WHERE harness_tenants.status <> 'archived'"
97 .to_owned(),
98 vec![tenant.to_owned().into(), dsn.to_owned().into()],
99 ))
100 .await?;
101 if affected == 0 {
102 return Err(DbError::Execute(format!(
103 "tenant {tenant} is archived and cannot be re-registered"
104 )));
105 }
106 Ok(())
107 }
108
109 /// Every tenant in the registry, newest DSN wins. The values come
110 /// from the control database and include the DSN: callers log or
111 /// serve with them, never print them.
112 ///
113 /// # Errors
114 ///
115 /// [`DbError`] when the read fails.
116 pub async fn tenants(&self) -> Result<Vec<TenantRecord>, DbError> {
117 let rows = self
118 .query(&Statement::new(
119 "SELECT tenant, dsn, status FROM harness_tenants ORDER BY tenant".to_owned(),
120 ))
121 .await?;
122 Ok(rows
123 .rows
124 .iter()
125 .filter_map(|row| {
126 Some(TenantRecord {
127 tenant: row.get::<String>("tenant")?,
128 dsn: row.get::<String>("dsn")?,
129 status: TenantStatus::parse(&row.get::<String>("status")?),
130 })
131 })
132 .collect())
133 }
134
135 /// Records a tenant's status, if the lifecycle allows the move.
136 ///
137 /// Answers whether the row changed. Best-effort by design
138 /// (RECONCILIATION.md §6): a write that fails mid-boot still leaves
139 /// the tenant refused at request time, because its pool was never
140 /// registered. The answer is for the caller that wants to say so —
141 /// `false` covers both "the database would not take it" and "the
142 /// lifecycle does not allow it", which are the same thing to a
143 /// caller: the tenant is not in the status it just asked for.
144 ///
145 /// The legal previous statuses are [`TenantStatus::admits`], so
146 /// `archived` stays terminal (it admits nothing, itself included) and
147 /// an `offboarding` tenant cannot be flipped back to `active` by a
148 /// reconciler while its keys are being shredded.
149 pub async fn set_tenant_status(&self, tenant: &str, status: TenantStatus) -> bool {
150 // The legal previous values are part of the `WHERE`, not a read
151 // before the write: two writers moving one tenant at once would
152 // both read a legal status and both write, and the loser's
153 // transition would be checked against a state that no longer
154 // existed. `TenantStatus::admits` is the one place the rule
155 // lives — the reconciler, the offboarding tooling and #154's
156 // lifecycle API all move a tenant, and a rule written into this
157 // clause alone would be a rule the others did not have.
158 let admits = status.admits();
159 if admits.is_empty() {
160 // Unreachable for every status there is today — each admits
161 // at least one. Here because `IN ()` is a syntax error rather
162 // than a match on nothing, so a future status that no move
163 // reaches would surface as a database fault instead of as the
164 // refusal it is, and whoever added it would be debugging the
165 // wrong thing.
166 return false;
167 }
168 let placeholders = std::iter::repeat_n("?", admits.len())
169 .collect::<Vec<_>>()
170 .join(", ");
171 let mut values: Vec<sea_query::Value> = vec![status.as_str().to_owned().into()];
172 values.push(tenant.to_owned().into());
173 values.extend(
174 admits
175 .iter()
176 .map(|previous| previous.as_str().to_owned().into()),
177 );
178 let applied = self
179 .execute(&Statement::with_values(
180 format!(
181 "UPDATE harness_tenants SET status = ? WHERE tenant = ? AND status IN \
182 ({placeholders})"
183 ),
184 values,
185 ))
186 .await;
187 match applied {
188 Ok(rows) => rows > 0,
189 Err(error) => {
190 tracing::warn!(
191 tenant = %tenant,
192 status = status.as_str(),
193 %error,
194 "could not write the tenant status"
195 );
196 false
197 }
198 }
199 }
200
201 /// Reconciles one tenant: take the advisory lock, then apply every
202 /// module's migrations to the tenant's database in `depends_on`
203 /// order. On any failure the tenant is marked `degraded` in the
204 /// control database (best-effort) and the error is returned; the
205 /// caller decides whether that aborts the boot (`strict`).
206 ///
207 /// The tenant DSN is never part of any error message: connection
208 /// strings carry credentials.
209 ///
210 /// # Errors
211 ///
212 /// [`DbError`] when the tenant's database is unreachable or a
213 /// migration fails.
214 pub async fn reconcile_tenant(
215 &self,
216 record: &TenantRecord,
217 harness: &Harness,
218 ) -> Result<TenantReport, DbError> {
219 let started = std::time::Instant::now();
220 let mut report = TenantReport {
221 tenant: record.tenant.clone(),
222 status: TenantStatus::Active,
223 applied: 0,
224 skipped: 0,
225 error: None,
226 };
227 let outcome = self
228 .reconcile_tenant_inner(record, harness, &mut report)
229 .await;
230 if outcome.is_err() {
231 report.status = TenantStatus::Degraded;
232 }
233 if !self.set_tenant_status(&record.tenant, report.status).await {
234 // Not a boot failure: the pool is registered or it is not,
235 // and that is what serves requests. But a status the registry
236 // would not take means the tenant moved underneath this
237 // reconciliation — being offboarded, most likely — and a
238 // silent miss here is how a fleet and its registry drift.
239 tracing::warn!(
240 tenant = %record.tenant,
241 status = report.status.as_str(),
242 "the registry did not take the reconciled status; the tenant moved underneath it"
243 );
244 }
245 tracing::info!(
246 tenant = %record.tenant,
247 status = report.status.as_str(),
248 applied = report.applied,
249 skipped = report.skipped,
250 duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
251 "tenant reconciliation finished"
252 );
253 outcome.map(|()| report)
254 }
255
256 async fn reconcile_tenant_inner(
257 &self,
258 record: &TenantRecord,
259 harness: &Harness,
260 report: &mut TenantReport,
261 ) -> Result<(), DbError> {
262 // A dedicated connection holds the advisory lock for the whole
263 // reconciliation; the migration runner runs over its own pool.
264 let mut lock = <sqlx::PgConnection as sqlx::Connection>::connect(&record.dsn)
265 .await
266 .map_err(|err| {
267 DbError::Batch(format!(
268 "tenant {} database unreachable: {}",
269 record.tenant,
270 first_error_line(&err.to_string())
271 ))
272 })?;
273 sqlx::query("SELECT pg_advisory_lock($1)")
274 .bind(tenant_lock_key(&record.tenant))
275 .execute(&mut lock)
276 .await
277 .map_err(|err| DbError::Batch(err.to_string()))?;
278
279 let tenant = Postgres::connect(&record.dsn).await?;
280 // Bootstrap the tracking table before anything reads it: a fresh
281 // tenant database has none, and "what is missing" needs the read
282 // to succeed (the lock is already held, so this is safe).
283 tenant
284 .execute(&crate::migrate::tracking_table_ddl())
285 .await?;
286 tenant
287 .execute(&crate::migrate::tracking_table_backfill_ddl())
288 .await?;
289 for module in harness.modules() {
290 let set = select_set(&module.migrations())
291 .map_err(|reason| DbError::Batch(format!("module {}: {reason}", module.name())))?;
292 let before = tenant.applied_keys(module.name()).await?;
293 tenant.apply_migrations(module.name(), set).await?;
294 let after = tenant.applied_keys(module.name()).await?;
295 let applied = after.len() - after.intersection(&before).count();
296 report.applied += applied;
297 report.skipped += before.intersection(&after).count();
298 if applied > 0 {
299 tracing::info!(
300 tenant = %record.tenant,
301 module = module.name(),
302 applied,
303 "migrations applied"
304 );
305 }
306 }
307 Ok(())
308 }
309
310 /// Reconciles every tenant in the registry whose status is `active`
311 /// or `provisioning`, at most `parallelism` at a time (default 8;
312 /// #30 §10 keeps the right number a measured question). A `degraded`
313 /// tenant is left for the retry timer, not re-flown this boot; an
314 /// `offboarding` or `archived` one is never flown again at all.
315 ///
316 /// This is what boot calls. The control database drives everything:
317 /// if it cannot be bootstrapped or read, the error returns and the
318 /// boot aborts — there is nothing useful to serve without a registry.
319 /// A tenant that fails is marked `degraded` and every other tenant
320 /// serves; the same with `strict` semantics is the caller's
321 /// decision (any `Degraded` in the reports).
322 ///
323 /// # Errors
324 ///
325 /// [`DbError`] when the control database cannot be bootstrapped or
326 /// read. Per-tenant failures never abort the fleet.
327 pub async fn reconcile_fleet(
328 &self,
329 harness: &Harness,
330 parallelism: usize,
331 ) -> Result<Vec<TenantReport>, DbError> {
332 self.bootstrap_registry().await?;
333 // Positive filter, not `!= Degraded`: see
334 // `TenantStatus::is_reconciled`. The negation was correct only
335 // while three statuses existed, and flying an offboarding or
336 // archived tenant would reconnect to a database that is being
337 // shredded and flip it back to `active`.
338 let records: Vec<TenantRecord> = self
339 .tenants()
340 .await?
341 .into_iter()
342 .filter(|record| record.status.is_reconciled())
343 .collect();
344 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(parallelism.max(1)));
345 // One task, many in-flight futures: the per-tenant work is
346 // I/O-bound sqlx calls, and a semaphore future per tenant bounds
347 // the fan-out without `spawn`'s `Send` + `'static` requirements
348 // on the harness's module trait objects.
349 let plans: Vec<_> = records
350 .into_iter()
351 .map(|record| {
352 let db = self.clone();
353 let semaphore = std::sync::Arc::clone(&semaphore);
354 async move {
355 let _permit = semaphore.acquire_owned().await;
356 db.reconcile_tenant(&record, harness)
357 .await
358 .unwrap_or_else(|err| TenantReport {
359 tenant: record.tenant,
360 status: TenantStatus::Degraded,
361 applied: 0,
362 skipped: 0,
363 error: Some(err.to_string()),
364 })
365 }
366 })
367 .collect();
368 let mut reports = futures_util::future::join_all(plans).await;
369 reports.sort_by(|a, b| a.tenant.cmp(&b.tenant));
370 let degraded = reports
371 .iter()
372 .filter(|report| report.status == TenantStatus::Degraded)
373 .count();
374 tracing::info!(
375 tenants = reports.len(),
376 degraded,
377 "fleet reconciliation finished"
378 );
379 Ok(reports)
380 }
381
382 /// What *would* be applied, per module, for one tenant. Takes the
383 /// same advisory lock, so the answer is not a guess about a moving
384 /// target (RECONCILIATION.md §8), and applies nothing.
385 ///
386 /// # Errors
387 ///
388 /// [`DbError`] when the tenant's database is unreachable.
389 pub async fn reconcile_plan(
390 &self,
391 record: &TenantRecord,
392 harness: &Harness,
393 ) -> Result<TenantPlan, DbError> {
394 let mut lock = <sqlx::PgConnection as sqlx::Connection>::connect(&record.dsn)
395 .await
396 .map_err(|err| DbError::Batch(err.to_string()))?;
397 sqlx::query("SELECT pg_advisory_lock($1)")
398 .bind(tenant_lock_key(&record.tenant))
399 .execute(&mut lock)
400 .await
401 .map_err(|err| DbError::Batch(err.to_string()))?;
402 let tenant = Postgres::connect(&record.dsn).await?;
403
404 let mut plan = TenantPlan {
405 tenant: record.tenant.clone(),
406 modules: Vec::new(),
407 };
408 for module in harness.modules() {
409 let set = select_set(&module.migrations())
410 .map_err(|reason| DbError::Batch(format!("module {}: {reason}", module.name())))?;
411 let applied = tenant.applied_keys(module.name()).await?;
412 let pending: Vec<String> = set
413 .iter()
414 .map(|migration| migration.id)
415 .filter(|id| !applied.contains(&format!("{}/{}", module.name(), id)))
416 .map(ToOwned::to_owned)
417 .collect();
418 if !pending.is_empty() {
419 plan.modules.push(ModulePlan {
420 module: module.name().to_owned(),
421 migrations: pending,
422 });
423 }
424 }
425 Ok(plan)
426 }
427
428 async fn applied_keys(
429 &self,
430 module: &str,
431 ) -> Result<std::collections::HashSet<String>, DbError> {
432 let prefix = format!("{module}/");
433 let rows = self
434 .query(&Statement::with_values(
435 "SELECT id FROM harness_migrations WHERE id LIKE ?".to_owned(),
436 vec![format!("{prefix}%").into()],
437 ))
438 .await?;
439 Ok(rows
440 .rows
441 .iter()
442 .filter_map(|row| row.get::<String>("id"))
443 .filter(|id| id.starts_with(&prefix))
444 .collect())
445 }
446}
447
448/// One tenant in the control database's registry.
449#[derive(Debug, Clone)]
450pub struct TenantRecord {
451 /// The tenant id, e.g. `factory0`.
452 pub tenant: String,
453 /// The tenant database's connection string. Never log it.
454 pub dsn: String,
455 /// The status reconciliation last recorded.
456 pub status: TenantStatus,
457}
458
459/// The outcome of reconciling one tenant.
460#[derive(Debug, Clone)]
461pub struct TenantReport {
462 pub tenant: String,
463 pub status: TenantStatus,
464 /// Migrations applied this boot.
465 pub applied: usize,
466 /// Migrations already recorded, skipped.
467 pub skipped: usize,
468 /// Why the tenant went `degraded`, when it did. Names the migration
469 /// or the failure, never the DSN.
470 pub error: Option<String>,
471}
472
473/// What [`Postgres::reconcile_plan`] would apply for one tenant.
474#[derive(Debug, Clone)]
475pub struct TenantPlan {
476 pub tenant: String,
477 /// Only modules with something pending; a module fully applied is
478 /// not listed at all.
479 pub modules: Vec<ModulePlan>,
480}
481
482/// One module's pending migration ids, in apply order.
483#[derive(Debug, Clone)]
484pub struct ModulePlan {
485 pub module: String,
486 /// Zero-padded ids, e.g. `["0003", "0004"]`.
487 pub migrations: Vec<String>,
488}