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

            
22
use crate::db::{DBError, migrate_on};
23
use crate::provision::dsn_for;
24
use sqlx::types::Uuid;
25
use sqlx::types::chrono::{DateTime, Utc};
26
use sqlx::{Connection, PgConnection};
27
use std::time::Duration;
28

            
29
#[cfg(test)]
30
mod 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.
45
const TENANT_DEADLINE: Duration = Duration::from_secs(120);
46

            
47
/// Bound on reaching a tenant at all, so connect cannot eat the outer budget.
48
const 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.
53
const VISIT_DEADLINE: Duration = Duration::from_secs(200);
54

            
55
/// Connection attempts before a tenant is quarantined.
56
const CONNECT_ATTEMPTS: u32 = 2;
57

            
58
/// Pause between those attempts.
59
const 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.
66
const CLAIM_LOCK: i64 = 0x6e6f_6d69_0001;
67

            
68
/// How often to re-attempt a claim another worker currently holds.
69
const 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.
74
const 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.
79
const CLAIM_DEADLINE: Duration = Duration::from_secs(45);
80

            
81
/// What became of one tenant.
82
#[derive(Debug, Clone, PartialEq, Eq)]
83
pub 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)]
95
pub 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

            
106
impl 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
356
    pub const fn is_healthy(&self) -> bool {
116
356
        self.quarantined.is_empty() && self.unresolved.is_empty() && self.unpublished.is_empty()
117
356
    }
118

            
119
    #[must_use]
120
1
    pub fn summary(&self) -> String {
121
1
        format!(
122
            "migrated {}, quarantined {}, unresolved {}, unpublished {}",
123
            self.migrated,
124
1
            self.quarantined.len(),
125
1
            self.unresolved.len(),
126
1
            self.unpublished.len()
127
        )
128
1
    }
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.
138
429
async fn migrate_tenant(database_name: &str) -> Result<Outcome, DBError> {
139
429
    let dsn = dsn_for(database_name)?;
140
429
    let mut conn = connect(&dsn).await?;
141

            
142
390
    if !claim(&mut conn, CLAIM_DEADLINE).await? {
143
        let _ = conn.close().await;
144
        return Ok(Outcome::Unresolved);
145
390
    }
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
390
    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
390
    let _ = conn.close().await;
158

            
159
390
    match migrated {
160
390
        Ok(result) => result.map(|()| Outcome::Migrated),
161
        Err(_) => Ok(Outcome::Quarantined(format!(
162
            "migration exceeded {}s",
163
            TENANT_DEADLINE.as_secs()
164
        ))),
165
    }
166
429
}
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.
173
429
async fn connect(dsn: &str) -> Result<PgConnection, DBError> {
174
429
    let mut attempt = 1;
175
    loop {
176
468
        match tokio::time::timeout(CONNECT_DEADLINE, PgConnection::connect(dsn)).await {
177
390
            Ok(Ok(conn)) => return Ok(conn),
178
78
            _ if attempt < CONNECT_ATTEMPTS => {
179
39
                tokio::time::sleep(CONNECT_RETRY_DELAY).await;
180
39
                attempt += 1;
181
            }
182
39
            Ok(Err(e)) => return Err(DBError::Sqlx(e)),
183
            Err(_) => return Err(DBError::Config(crate::config::ConfigError::DB)),
184
        }
185
    }
186
429
}
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.
193
390
async fn claim(conn: &mut PgConnection, deadline: Duration) -> Result<bool, DBError> {
194
390
    let give_up_at = tokio::time::Instant::now() + deadline;
195

            
196
    loop {
197
429
        let taken: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
198
429
            .bind(CLAIM_LOCK)
199
429
            .fetch_one(&mut *conn)
200
429
            .await
201
429
            .map_err(DBError::Sqlx)?;
202

            
203
429
        if taken {
204
390
            return Ok(true);
205
39
        }
206

            
207
39
        if tokio::time::Instant::now() >= give_up_at {
208
            return Ok(false);
209
39
        }
210

            
211
39
        tokio::time::sleep(CLAIM_POLL).await;
212
    }
213
390
}
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.
221
429
async 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
429
    match tokio::time::timeout(VISIT_DEADLINE, migrate_tenant(database_name)).await {
225
390
        Ok(Ok(outcome)) => outcome,
226
39
        Ok(Err(e)) => Outcome::Quarantined(e.to_string()),
227
        Err(_) => Outcome::Quarantined(format!("tenant exceeded {}s", VISIT_DEADLINE.as_secs())),
228
    }
229
429
}
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)]
247
pub(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

            
256
impl SchemaDrift {
257
315
    fn is_aligned(&self) -> bool {
258
315
        self.missing.is_empty() && self.unknown.is_empty()
259
315
    }
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.
272
316
fn schema_drift(embedded: &[i64], applied: &[i64]) -> SchemaDrift {
273
    SchemaDrift {
274
316
        missing: embedded
275
316
            .iter()
276
1890
            .filter(|version| !applied.contains(version))
277
316
            .copied()
278
316
            .collect(),
279
316
        unknown: applied
280
316
            .iter()
281
1890
            .filter(|version| !embedded.contains(version))
282
316
            .copied()
283
316
            .collect(),
284
    }
285
316
}
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.
298
312
async fn assert_global_schema_current() -> Result<(), DBError> {
299
312
    let mut conn = crate::db::get_connection().await?;
300

            
301
312
    let applied: Vec<i64> =
302
312
        sqlx::query_scalar!(r#"SELECT version as "version!" FROM _sqlx_migrations WHERE success"#)
303
312
            .fetch_all(&mut *conn)
304
312
            .await
305
312
            .map_err(DBError::Sqlx)?;
306

            
307
312
    let embedded: Vec<i64> = sqlx::migrate!("../migrations")
308
312
        .iter()
309
312
        .map(|m| m.version)
310
312
        .collect();
311

            
312
312
    let drift = schema_drift(&embedded, &applied);
313
312
    if drift.is_aligned() {
314
312
        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
312
}
325

            
326
312
pub 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
312
    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
312
    let started = database_now().await?;
335
312
    let mut report = FleetReport::default();
336
312
    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
624
    for round in 1..=INVENTORY_ROUNDS {
350
624
        let pending: Vec<(Uuid, String)> = inventory(admin_user_id)
351
624
            .await?
352
624
            .into_iter()
353
858
            .filter(|(id, _)| !seen.contains(id))
354
624
            .collect();
355

            
356
624
        if pending.is_empty() {
357
312
            break;
358
312
        }
359

            
360
312
        log::info!(
361
            "fleet migration round {round}: {} tenant(s) to visit",
362
            pending.len()
363
        );
364

            
365
429
        for (id, database_name) in pending {
366
429
            seen.push(id);
367
429
            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
429
            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
429
            }
376

            
377
429
            match outcome {
378
390
                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
39
                Outcome::Quarantined(reason) => {
386
39
                    log::error!("quarantining {database_name}: {reason}");
387
39
                    report.quarantined.push((id, reason));
388
                }
389
            }
390
        }
391
    }
392

            
393
312
    log::info!("fleet migration finished: {}", report.summary());
394
312
    Ok(report)
395
312
}
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.
402
429
async fn publish(
403
429
    user_id: Uuid,
404
429
    outcome: &Outcome,
405
429
    run_started: DateTime<Utc>,
406
429
) -> Result<(), DBError> {
407
429
    match outcome {
408
390
        Outcome::Migrated => clear_quarantine(user_id, run_started).await,
409
39
        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
429
}
414

            
415
/// Reads the wall clock of the database that records quarantines.
416
///
417
312
async fn database_now() -> Result<DateTime<Utc>, DBError> {
418
312
    let mut conn = crate::db::get_connection().await?;
419

            
420
312
    sqlx::query_scalar!(r#"SELECT now() as "now!""#)
421
312
        .fetch_one(&mut *conn)
422
312
        .await
423
312
        .map_err(DBError::Sqlx)
424
312
}
425

            
426
624
async fn inventory(admin_user_id: Uuid) -> Result<Vec<(Uuid, String)>, DBError> {
427
624
    let mut conn = crate::db::get_connection().await?;
428

            
429
624
    let rows = sqlx::query_file!(
430
        "../server/sql/select/system/tenant_inventory.sql",
431
        admin_user_id
432
    )
433
624
    .fetch_all(&mut *conn)
434
624
    .await
435
624
    .map_err(DBError::Sqlx)?;
436

            
437
624
    Ok(rows
438
624
        .into_iter()
439
858
        .map(|row| (row.id, row.database_name))
440
624
        .collect())
441
624
}
442

            
443
39
async fn quarantine(
444
39
    user_id: Uuid,
445
39
    reason: &str,
446
39
    run_started: DateTime<Utc>,
447
39
) -> Result<(), DBError> {
448
39
    let mut conn = crate::db::get_connection().await?;
449

            
450
39
    let recorded = sqlx::query_file!(
451
        "../server/sql/update/users/quarantine.sql",
452
        user_id,
453
        reason,
454
        run_started
455
    )
456
39
    .execute(&mut *conn)
457
39
    .await
458
39
    .map_err(DBError::Sqlx)?;
459

            
460
39
    if recorded.rows_affected() == 0 {
461
        log::info!("{user_id} already carries a verdict newer than this run");
462
39
    }
463

            
464
39
    Ok(())
465
39
}
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.
474
390
async fn clear_quarantine(user_id: Uuid, run_started: DateTime<Utc>) -> Result<(), DBError> {
475
390
    let mut conn = crate::db::get_connection().await?;
476

            
477
390
    let cleared = sqlx::query_file!(
478
        "../server/sql/update/users/clear_quarantine.sql",
479
        user_id,
480
        run_started
481
    )
482
390
    .execute(&mut *conn)
483
390
    .await
484
390
    .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
390
    if cleared.rows_affected() == 0 {
489
        log::info!("{user_id} was not cleared: its quarantine is newer than this run");
490
390
    }
491

            
492
390
    Ok(())
493
390
}