Skip to main content

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}