Skip to main content

server/
fleet.rs

1//! Bringing every tenant database to the current schema.
2//!
3//! `boot()` migrates only the global database, and provisioning migrates a
4//! tenant once, at creation. Nothing else ever touched an existing tenant — so
5//! before this module the first tenant-facing migration after launch reached
6//! the global database and newly registered users, and no existing user, ever.
7//! CI stayed green (fresh databases), the deploy stayed green (global
8//! migrates), and each pre-existing user broke at runtime, one feature at a
9//! time.
10//!
11//! This runs as its own process, never inside a serving one. `boot()` is
12//! awaited before either binary serves and a boot error exits, so a fan-out
13//! there would let one unreachable tenant crash-loop web and sshd for every
14//! user.
15//!
16//! Reconciliation is against each tenant's own `_sqlx_migrations`, via the
17//! ordinary migrator. There is deliberately no central version table: applied
18//! state is already recorded per tenant, a mirror can drift, and under the
19//! shared DDL-only rule its `CREATE TABLE` would land in every tenant database
20//! anyway.
21
22use crate::db::{DBError, migrate_on};
23use crate::provision::dsn_for;
24use sqlx::types::Uuid;
25use sqlx::types::chrono::{DateTime, Utc};
26use sqlx::{Connection, PgConnection};
27use std::time::Duration;
28
29#[cfg(test)]
30mod tests;
31
32// Tenants are migrated ONE AT A TIME, deliberately.
33//
34// Every concurrent worker costs a tenant connection on top of the admin pool,
35// and ADR 0032 reserves five connections for this whole process; four workers
36// spent about thirteen. Sequential also keeps the migration on the very
37// connection that holds the claim, which a spawned worker cannot do — sqlx's
38// migrator future is not `Send` while it borrows a connection.
39//
40// The cost is wall-clock on a large fleet. Revisit by giving the migrator its
41// own capped pool and a `Send`-safe worker, not by adding threads here.
42
43/// Ceiling on one tenant's migration. Without it a single tenant holding a lock
44/// stalls the whole run.
45const TENANT_DEADLINE: Duration = Duration::from_secs(120);
46
47/// Bound on reaching a tenant at all, so connect cannot eat the outer budget.
48const CONNECT_DEADLINE: Duration = Duration::from_secs(10);
49
50/// Backstop around connect, claim and migration together. Looser than
51/// [`TENANT_DEADLINE`] so the inner deadline normally fires first and gets to
52/// close the connection.
53const VISIT_DEADLINE: Duration = Duration::from_secs(200);
54
55/// Connection attempts before a tenant is quarantined.
56const CONNECT_ATTEMPTS: u32 = 2;
57
58/// Pause between those attempts.
59const CONNECT_RETRY_DELAY: Duration = Duration::from_secs(3);
60
61/// Advisory-lock key, so two migrator processes cannot work one tenant at once.
62///
63/// Taken inside the *tenant's own* database rather than a shared table: there
64/// is no central state to drift, and a lock dies with the connection holding
65/// it, so a killed worker frees its claim without a lease to expire.
66const CLAIM_LOCK: i64 = 0x6e6f_6d69_0001;
67
68/// How often to re-attempt a claim another worker currently holds.
69const CLAIM_POLL: Duration = Duration::from_secs(2);
70
71/// How many times the directory is re-read looking for tenants that appeared
72/// mid-run. Bounded so a steady stream of registrations cannot keep the Job
73/// alive forever; anything still missed is caught by the next release's run.
74const INVENTORY_ROUNDS: usize = 3;
75
76/// How long to wait out a peer's claim before giving up on this tenant.
77/// Bounded well inside [`TENANT_DEADLINE`] so the migration itself still has
78/// room once the claim is won.
79const CLAIM_DEADLINE: Duration = Duration::from_secs(45);
80
81/// What became of one tenant.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum Outcome {
84    /// Verified at the current schema by *this* run.
85    Migrated,
86    /// Another worker held the claim for the whole deadline, so this run never
87    /// saw the tenant's schema. Not a success: the holder may since have timed
88    /// out, panicked or been killed, and nothing would say so.
89    Unresolved,
90    /// Failed, and recorded in the global directory.
91    Quarantined(String),
92}
93
94#[derive(Debug, Default)]
95pub struct FleetReport {
96    pub migrated: usize,
97    /// Tenants this run could not verify because another worker held the claim.
98    pub unresolved: Vec<Uuid>,
99    pub quarantined: Vec<(Uuid, String)>,
100    /// Tenants whose verdict could not be written to the directory. The
101    /// dangerous case: a tenant that failed but whose quarantine did not stick
102    /// is still being served.
103    pub unpublished: Vec<(Uuid, String)>,
104}
105
106impl FleetReport {
107    /// Whether every tenant was accounted for.
108    ///
109    /// A quarantined tenant does not stop the *fleet* — that is the whole point
110    /// of quarantine — but it does mean this run left work undone, and the exit
111    /// code is what a deployment gate reads. Unresolved and unpublished tenants
112    /// count too: in both cases the run finished without knowing, or without
113    /// recording, the state of a customer's database.
114    #[must_use]
115    pub const fn is_healthy(&self) -> bool {
116        self.quarantined.is_empty() && self.unresolved.is_empty() && self.unpublished.is_empty()
117    }
118
119    #[must_use]
120    pub fn summary(&self) -> String {
121        format!(
122            "migrated {}, quarantined {}, unresolved {}, unpublished {}",
123            self.migrated,
124            self.quarantined.len(),
125            self.unresolved.len(),
126            self.unpublished.len()
127        )
128    }
129}
130
131/// Migrates one tenant, holding a claim for the duration.
132///
133/// Waits for a claim another worker holds rather than declaring victory over a
134/// tenant it never looked at. Once the claim is won the migrator runs
135/// regardless of what the previous holder did: it is idempotent, so running it
136/// after a peer's success costs a no-op, and running it after a peer *died*
137/// mid-way is exactly the repair that is needed.
138async fn migrate_tenant(database_name: &str) -> Result<Outcome, DBError> {
139    let dsn = dsn_for(database_name)?;
140    let mut conn = connect(&dsn).await?;
141
142    if !claim(&mut conn, CLAIM_DEADLINE).await? {
143        let _ = conn.close().await;
144        return Ok(Outcome::Unresolved);
145    }
146
147    // Migrated on the claim connection itself. A second connection per worker
148    // would put the run at roughly thirteen against the five ADR 0032 reserves
149    // for it — a budget breach during exactly the operation that must not fail.
150    //
151    // The deadline is applied HERE rather than around the whole call: cancelling
152    // this future from outside would drop the connection without closing it.
153    let migrated = tokio::time::timeout(TENANT_DEADLINE, migrate_on(&mut conn)).await;
154
155    // Closing the connection drops the lock; do it whether or not we succeeded,
156    // and whether or not we ran out of time.
157    let _ = conn.close().await;
158
159    match migrated {
160        Ok(result) => result.map(|()| Outcome::Migrated),
161        Err(_) => Ok(Outcome::Quarantined(format!(
162            "migration exceeded {}s",
163            TENANT_DEADLINE.as_secs()
164        ))),
165    }
166}
167
168/// Connects to a tenant, retrying a refused or slow connection once.
169///
170/// The only retry in a visit, and deliberately before any claim is taken: a
171/// migration that failed may still hold its advisory lock, so retrying one
172/// would find its own claim and learn nothing.
173async fn connect(dsn: &str) -> Result<PgConnection, DBError> {
174    let mut attempt = 1;
175    loop {
176        match tokio::time::timeout(CONNECT_DEADLINE, PgConnection::connect(dsn)).await {
177            Ok(Ok(conn)) => return Ok(conn),
178            _ if attempt < CONNECT_ATTEMPTS => {
179                tokio::time::sleep(CONNECT_RETRY_DELAY).await;
180                attempt += 1;
181            }
182            Ok(Err(e)) => return Err(DBError::Sqlx(e)),
183            Err(_) => return Err(DBError::Config(crate::config::ConfigError::DB)),
184        }
185    }
186}
187
188/// Takes the tenant's claim, waiting out a peer that already holds it.
189///
190/// Polls rather than using the blocking `pg_advisory_lock`, so the surrounding
191/// timeout can actually interrupt it — a blocked lock wait inside Postgres
192/// would ignore a cancelled future and hold the connection open.
193async fn claim(conn: &mut PgConnection, deadline: Duration) -> Result<bool, DBError> {
194    let give_up_at = tokio::time::Instant::now() + deadline;
195
196    loop {
197        let taken: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
198            .bind(CLAIM_LOCK)
199            .fetch_one(&mut *conn)
200            .await
201            .map_err(DBError::Sqlx)?;
202
203        if taken {
204            return Ok(true);
205        }
206
207        if tokio::time::Instant::now() >= give_up_at {
208            return Ok(false);
209        }
210
211        tokio::time::sleep(CLAIM_POLL).await;
212    }
213}
214
215/// Runs one tenant to a verdict, converting any failure into a quarantine
216/// reason rather than propagating it — one sick tenant must not end the run.
217///
218/// A timeout quarantines rather than passing: the migration was cancelled
219/// mid-flight, so its transaction rolled back and the tenant is on whatever
220/// schema it had before. Unknown is not the same as fine.
221async fn visit(database_name: &str) -> Outcome {
222    // The backstop around connect, claim and migration together; looser than
223    // `TENANT_DEADLINE` so the inner deadline normally fires first.
224    match tokio::time::timeout(VISIT_DEADLINE, migrate_tenant(database_name)).await {
225        Ok(Ok(outcome)) => outcome,
226        Ok(Err(e)) => Outcome::Quarantined(e.to_string()),
227        Err(_) => Outcome::Quarantined(format!("tenant exceeded {}s", VISIT_DEADLINE.as_secs())),
228    }
229}
230
231/// Migrates every tenant in the directory.
232///
233/// Skips `admin_user_id`, whose row points at the global database that the
234/// ordinary boot migration already handled. Quarantined tenants are retried:
235/// quarantine records a failure, it is not a tombstone.
236///
237/// Never seeds. `server/seed.sql` must not run against a per-user database —
238/// an implementer reusing boot-shaped logic here would insert the admin user
239/// and global config into every tenant.
240///
241/// # Errors
242/// Only for failures that prevent the run itself: an unreachable *global*
243/// directory, or a missing `DATABASE_URL`. A tenant that cannot be migrated is
244/// quarantined and reported, not raised.
245/// How a database's applied migrations differ from this binary's.
246#[derive(Debug, PartialEq, Eq)]
247pub(crate) struct SchemaDrift {
248    /// Embedded here, not applied there: the directory is behind this binary.
249    pub(crate) missing: Vec<i64>,
250    /// Applied there, unknown here: the directory is AHEAD, so this binary is
251    /// the stale one — a rolled-back image, or one whose migration files were
252    /// edited in place after being applied.
253    pub(crate) unknown: Vec<i64>,
254}
255
256impl SchemaDrift {
257    fn is_aligned(&self) -> bool {
258        self.missing.is_empty() && self.unknown.is_empty()
259    }
260}
261
262/// Compares two migration sets by version, in BOTH directions.
263///
264/// Versions rather than counts, because the set has a deliberate gap
265/// (1, 2, 3, 5, 6) and "five applied" is not "the right five applied".
266///
267/// Both directions, because a one-way check is only half a gate. A binary older
268/// than the directory passes `embedded ⊆ applied` and then fails on every single
269/// tenant — sqlx refuses a version it does not know — turning a deployment
270/// mistake into a fleet-wide quarantine whose verdicts outrank the correct
271/// run's, because they were written later.
272fn schema_drift(embedded: &[i64], applied: &[i64]) -> SchemaDrift {
273    SchemaDrift {
274        missing: embedded
275            .iter()
276            .filter(|version| !applied.contains(version))
277            .copied()
278            .collect(),
279        unknown: applied
280            .iter()
281            .filter(|version| !embedded.contains(version))
282            .copied()
283            .collect(),
284    }
285}
286
287/// Refuses to run unless the GLOBAL database already carries this binary's
288/// migration set.
289///
290/// The Job and the app are applied by different Flux Kustomizations, and
291/// `dependsOn` waits for the app to be *Ready* — which it can be on a previous
292/// revision. So the Job can start before the release it belongs to has migrated
293/// the global directory, read a `users` table shaped by the older schema, and
294/// carry that misunderstanding into every tenant.
295///
296/// Checking the precondition directly is better than trusting the ordering:
297/// it holds however the Job came to be scheduled.
298async fn assert_global_schema_current() -> Result<(), DBError> {
299    let mut conn = crate::db::get_connection().await?;
300
301    let applied: Vec<i64> =
302        sqlx::query_scalar!(r#"SELECT version as "version!" FROM _sqlx_migrations WHERE success"#)
303            .fetch_all(&mut *conn)
304            .await
305            .map_err(DBError::Sqlx)?;
306
307    let embedded: Vec<i64> = sqlx::migrate!("../migrations")
308        .iter()
309        .map(|m| m.version)
310        .collect();
311
312    let drift = schema_drift(&embedded, &applied);
313    if drift.is_aligned() {
314        return Ok(());
315    }
316
317    Err(DBError::GlobalSchemaStale(format!(
318        "this binary and the global directory disagree: missing {:?}, unknown {:?}. \
319         Missing means the directory is behind this release; unknown means this \
320         binary is behind the directory. Either way, migrating tenants from here \
321         would quarantine them on a disagreement that is not theirs",
322        drift.missing, drift.unknown
323    )))
324}
325
326pub async fn migrate_fleet(admin_user_id: Uuid) -> Result<FleetReport, DBError> {
327    // Before anything: never fan out from a directory older than this binary.
328    assert_global_schema_current().await?;
329
330    // The database's clock, not this host's. The fence compares against
331    // `quarantined_at`, written by `now()` inside Postgres; comparing that to a
332    // migrator host's clock makes correctness a function of clock skew between
333    // two machines.
334    let started = database_now().await?;
335    let mut report = FleetReport::default();
336    let mut seen: Vec<Uuid> = Vec::new();
337
338    // Re-inventoried until it stops growing. A single snapshot would miss a
339    // tenant registered while the run was in progress — by an old pod still
340    // serving, whose provisioning carries *its* migration set — and the run
341    // would still exit 0 over a tenant nobody brought current.
342    //
343    // This NARROWS the window; it does not close it. A tenant registered after
344    // the last read is still missed, and the run still exits 0. Closing it needs
345    // a barrier between provisioning and this run — a shared lock, or a schema
346    // generation that provisioning stamps and this reconciles against. Until
347    // then the guarantee rests where ADR 0032 puts it: expand/contract, so the
348    // serving code works on both schemas across a release.
349    for round in 1..=INVENTORY_ROUNDS {
350        let pending: Vec<(Uuid, String)> = inventory(admin_user_id)
351            .await?
352            .into_iter()
353            .filter(|(id, _)| !seen.contains(id))
354            .collect();
355
356        if pending.is_empty() {
357            break;
358        }
359
360        log::info!(
361            "fleet migration round {round}: {} tenant(s) to visit",
362            pending.len()
363        );
364
365        for (id, database_name) in pending {
366            seen.push(id);
367            let outcome = visit(&database_name).await;
368
369            // Published as each tenant lands. Collecting verdicts first would
370            // leave a tenant that failed early still being served for as long
371            // as the rest of the fleet takes.
372            if let Err(e) = publish(id, &outcome, started).await {
373                log::error!("could not record the verdict for {database_name}: {e}");
374                report.unpublished.push((id, e.to_string()));
375            }
376
377            match outcome {
378                Outcome::Migrated => report.migrated += 1,
379                Outcome::Unresolved => {
380                    log::warn!(
381                        "{database_name} stayed claimed by another worker; this run did not verify it"
382                    );
383                    report.unresolved.push(id);
384                }
385                Outcome::Quarantined(reason) => {
386                    log::error!("quarantining {database_name}: {reason}");
387                    report.quarantined.push((id, reason));
388                }
389            }
390        }
391    }
392
393    log::info!("fleet migration finished: {}", report.summary());
394    Ok(report)
395}
396
397/// Writes one tenant's verdict to the global directory.
398///
399/// Errors are returned, never swallowed: a quarantine that fails to stick
400/// leaves a tenant on the wrong schema still being served, which is the exact
401/// state this module exists to prevent.
402async fn publish(
403    user_id: Uuid,
404    outcome: &Outcome,
405    run_started: DateTime<Utc>,
406) -> Result<(), DBError> {
407    match outcome {
408        Outcome::Migrated => clear_quarantine(user_id, run_started).await,
409        Outcome::Quarantined(reason) => quarantine(user_id, reason, run_started).await,
410        // Nothing was learned about this tenant, so nothing is claimed about it.
411        Outcome::Unresolved => Ok(()),
412    }
413}
414
415/// Reads the wall clock of the database that records quarantines.
416///
417async fn database_now() -> Result<DateTime<Utc>, DBError> {
418    let mut conn = crate::db::get_connection().await?;
419
420    sqlx::query_scalar!(r#"SELECT now() as "now!""#)
421        .fetch_one(&mut *conn)
422        .await
423        .map_err(DBError::Sqlx)
424}
425
426async fn inventory(admin_user_id: Uuid) -> Result<Vec<(Uuid, String)>, DBError> {
427    let mut conn = crate::db::get_connection().await?;
428
429    let rows = sqlx::query_file!(
430        "../server/sql/select/system/tenant_inventory.sql",
431        admin_user_id
432    )
433    .fetch_all(&mut *conn)
434    .await
435    .map_err(DBError::Sqlx)?;
436
437    Ok(rows
438        .into_iter()
439        .map(|row| (row.id, row.database_name))
440        .collect())
441}
442
443async fn quarantine(
444    user_id: Uuid,
445    reason: &str,
446    run_started: DateTime<Utc>,
447) -> Result<(), DBError> {
448    let mut conn = crate::db::get_connection().await?;
449
450    let recorded = sqlx::query_file!(
451        "../server/sql/update/users/quarantine.sql",
452        user_id,
453        reason,
454        run_started
455    )
456    .execute(&mut *conn)
457    .await
458    .map_err(DBError::Sqlx)?;
459
460    if recorded.rows_affected() == 0 {
461        log::info!("{user_id} already carries a verdict newer than this run");
462    }
463
464    Ok(())
465}
466
467/// Lifts a quarantine, but only one that predates this run.
468///
469/// The fence matters when two releases' migrators overlap: without it, an older
470/// migrator finishing successfully against its own (older) migration set would
471/// clear a quarantine a newer migrator had just written for failing on a newer
472/// one — and the newest application would then serve a tenant known not to have
473/// the schema it needs.
474async fn clear_quarantine(user_id: Uuid, run_started: DateTime<Utc>) -> Result<(), DBError> {
475    let mut conn = crate::db::get_connection().await?;
476
477    let cleared = sqlx::query_file!(
478        "../server/sql/update/users/clear_quarantine.sql",
479        user_id,
480        run_started
481    )
482    .execute(&mut *conn)
483    .await
484    .map_err(DBError::Sqlx)?;
485
486    // A tenant that was never quarantined still matches the fence, so this is
487    // not the ordinary case — it means someone newer has already ruled.
488    if cleared.rows_affected() == 0 {
489        log::info!("{user_id} was not cleared: its quarantine is newer than this run");
490    }
491
492    Ok(())
493}