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    /// # 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}