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}