autumn_web/derivation.rs
1//! Maintained derived read models: `#[derivation]` (issue #1769).
2//!
3//! A derivation is a denormalised value on a parent row that the framework
4//! keeps correct by construction: `posts.published_comment_count`,
5//! `posts.visible_score`. Declaring it on the child:
6//!
7//! ```rust,ignore
8//! #[autumn_web::model(table = "comments")]
9//! #[belongs_to(Post, fk = post_id)]
10//! #[derivation(Post, column = "published_comment_count", filter = published)]
11//! #[derivation(Post, column = "visible_score", transform = sum(score), filter = published && score > 0)]
12//! pub struct Comment { /* … */ }
13//! ```
14//!
15//! Every generated mutation path then maintains both columns inside the same
16//! transaction as the row mutation.
17//!
18//! # Why this is the counter cache
19//!
20//! A derivation is a counter cache (`counter_cache`, #1325) with two extra
21//! pieces: a per-row *contribution* (1 for a count, the field for a sum, 0 for a
22//! row the filter rejects) and a *filter* lowered to SQL. `#[model]` emits both
23//! into the same [`CounterCacheSpec`](crate::repository::CounterCacheSpec),
24//! so the fifteen mutation paths the repository macro already dispatches to keep
25//! derivations current with no new dispatch point. A plain counter cache is the
26//! unfiltered special case, and its generated SQL is unchanged.
27//!
28//! # What this module adds
29//!
30//! A counter cache is correct from its first row, because the column and the
31//! code that maintains it ship together. A derivation is usually declared over
32//! a table that already holds data, so the existing rows have to be repaired.
33//! This module owns that part:
34//!
35//! * **Content addressing.** [`DerivationDef::definition_hash`](crate::derivation::DerivationDef::definition_hash) hashes the
36//! lowered shape: tables, columns, transform, filter SQL. A changed filter
37//! changes the hash. A rename or a reformat does not.
38//! * **Reconciliation.** [`ensure_derivations`](crate::derivation::ensure_derivations) compares each registered
39//! derivation's hash against `_autumn_derivations` and enqueues a backfill for
40//! the ones that changed. It leaves the rest alone.
41//! * **Resumable repair.** [`run_backfill`](crate::derivation::run_backfill) rebuilds parents in checkpointed
42//! batches. Each batch is one transaction that locks the state row, pages from
43//! the checkpoint it finds there, repairs the page and advances the
44//! checkpoint. A killed process resumes, and several replicas cooperate on one
45//! sweep instead of racing.
46//! * **Observability.** [`derivation_status`](crate::derivation::derivation_status) reports each derivation's state
47//! and its drift from the source of truth. `/actuator/derivations` serves it.
48
49use std::collections::HashMap;
50
51use diesel::sql_types::{BigInt, Nullable, Text};
52use diesel_async::RunQueryDsl as _;
53use scoped_futures::ScopedFutureExt as _;
54use serde::{Deserialize, Serialize};
55
56use crate::counter_cache::{SqlView, is_lock_contention};
57use crate::db::{RuntimeConnection, scoped_immediate_transaction};
58use crate::{AutumnError, AutumnResult};
59
60/// Narrow framework migration set that creates `_autumn_derivations`.
61///
62/// Backend-forked exactly like
63/// [`VERSION_HISTORY_MIGRATIONS`](crate::version_history::VERSION_HISTORY_MIGRATIONS):
64/// the Postgres DDL (`TIMESTAMPTZ`/`NOW()`) is not valid `SQLite`, so the
65/// `SQLite` build embeds a parallel set under the same version dir name, keeping
66/// `__diesel_schema_migrations` bookkeeping identical across backends.
67#[cfg(not(feature = "sqlite"))]
68pub const DERIVATION_MIGRATIONS: diesel_migrations::EmbeddedMigrations =
69 diesel_migrations::embed_migrations!("derivation_migrations");
70
71/// `SQLite` variant of [`DERIVATION_MIGRATIONS`]. See that item for the
72/// backend-fork rationale.
73#[cfg(feature = "sqlite")]
74pub const DERIVATION_MIGRATIONS: diesel_migrations::EmbeddedMigrations =
75 diesel_migrations::embed_migrations!("derivation_migrations_sqlite");
76
77/// The state table. A framework table, hence the `_autumn_` prefix.
78const STATE_TABLE: &str = "_autumn_derivations";
79
80/// `CURRENT_TIMESTAMP` rather than `NOW()`: both backends spell it that way, so
81/// one statement serves both.
82const NOW: &str = "CURRENT_TIMESTAMP";
83
84// Backend-forked placeholder. Postgres numbers its binds, `SQLite` does not;
85// binds are pushed in the same order on both, so one template with swapped
86// placeholder text serves both.
87#[cfg(not(feature = "sqlite"))]
88fn ph(n: usize) -> String {
89 format!("${n}")
90}
91#[cfg(feature = "sqlite")]
92fn ph(_n: usize) -> String {
93 "?".to_owned()
94}
95
96/// NULL-safe inequality, as `counter_cache` spells it. Only the SQL
97/// builder assertions need it here; the statements themselves come from there.
98#[cfg(all(test, not(feature = "sqlite")))]
99const IS_DISTINCT_FROM: &str = "IS DISTINCT FROM";
100#[cfg(all(test, feature = "sqlite"))]
101const IS_DISTINCT_FROM: &str = "IS NOT";
102
103// Row lock on the state row. On Postgres `FOR UPDATE` blocks any other
104// transaction that wants the same row. On `SQLite` the enclosing
105// `BEGIN IMMEDIATE` already excludes every other writer in the database, so the
106// clause degrades to nothing and the read is a cheap indexed lookup.
107#[cfg(not(feature = "sqlite"))]
108const FOR_UPDATE: &str = " FOR UPDATE";
109#[cfg(feature = "sqlite")]
110const FOR_UPDATE: &str = "";
111
112/// The cap on a drift scan, in parent rows.
113///
114/// A drift figure equal to this value means "at least this many", not "exactly
115/// this many". See [`DerivationStatus::drift`].
116pub const DRIFT_SCAN_LIMIT: i64 = 10_000;
117
118// ── Definition ───────────────────────────────────────────────────────────────
119
120/// One `#[derivation]`, produced at compile time by `#[model]`.
121///
122/// Framework plumbing; not constructed by hand. Every field is `pub` and
123/// const-constructible because `#[model]` emits a `static` of this type.
124#[derive(Debug)]
125pub struct DerivationDef {
126 /// Stable identity, `"{parent_table}.{column}"` unless overridden. This is
127 /// the `_autumn_derivations` primary key and the name the actuator reports.
128 pub name: &'static str,
129 /// The child model's type name, for diagnostics.
130 pub model: &'static str,
131 /// The child's table.
132 pub child_table: &'static str,
133 /// The child's primary-key column.
134 pub child_pk: &'static str,
135 /// Whether the child carries a `deleted_at` column, so the derivation
136 /// reflects live rows only.
137 pub child_soft_delete: bool,
138 /// The child's foreign-key column naming the parent.
139 pub fk_column: &'static str,
140 /// The parent's table.
141 pub parent_table: &'static str,
142 /// The parent's primary-key column.
143 pub parent_pk: &'static str,
144 /// The maintained column on the parent.
145 pub column: &'static str,
146 /// The aggregate as declared: `"count"` or `"sum(<field>)"`.
147 pub transform: &'static str,
148 /// The filter's source text, `""` when there is none. Reported, never
149 /// executed. [`Self::filter_sql`] is what runs.
150 pub filter: &'static str,
151 /// The filter lowered to SQL: `""`, or ` AND (<pred>)` using `{c}` for the
152 /// child alias.
153 pub filter_sql: &'static str,
154 /// The per-row contribution in SQL: `"1"`, or a child column reference.
155 pub contrib_sql: &'static str,
156 /// The tenant-discriminator column, from `tenant = "<column>"`.
157 pub tenant_column: Option<&'static str>,
158 /// The module the derivation was declared in, for diagnostics.
159 pub module_path: &'static str,
160 /// The source file, for diagnostics.
161 pub file: &'static str,
162 /// The source line, for diagnostics.
163 pub line: u32,
164}
165
166impl DerivationDef {
167 /// A content address for the derivation's *shape*.
168 ///
169 /// Lowercase hex SHA-256 over the length-prefixed, labelled fields that
170 /// decide what the maintained value is: tables, keys, columns, transform,
171 /// lowered filter, contribution and tenant column. Deliberately **not** the
172 /// name, model, module path, file, line or filter source, so renaming a
173 /// derivation or reformatting its filter does not enqueue a backfill of an
174 /// unchanged value. Changing the filter, the transform or the column always
175 /// does.
176 #[must_use]
177 pub fn definition_hash(&self) -> String {
178 use sha2::Digest as _;
179
180 let mut hasher = sha2::Sha256::new();
181 push_component(&mut hasher, "child_table", self.child_table.as_bytes());
182 push_component(&mut hasher, "child_pk", self.child_pk.as_bytes());
183 push_component(
184 &mut hasher,
185 "child_soft_delete",
186 if self.child_soft_delete { b"1" } else { b"0" },
187 );
188 push_component(&mut hasher, "fk_column", self.fk_column.as_bytes());
189 push_component(&mut hasher, "parent_table", self.parent_table.as_bytes());
190 push_component(&mut hasher, "parent_pk", self.parent_pk.as_bytes());
191 push_component(&mut hasher, "column", self.column.as_bytes());
192 push_component(&mut hasher, "transform", self.transform.as_bytes());
193 push_component(&mut hasher, "filter_sql", self.filter_sql.as_bytes());
194 push_component(&mut hasher, "contrib_sql", self.contrib_sql.as_bytes());
195 push_component(
196 &mut hasher,
197 "tenant_column",
198 self.tenant_column.unwrap_or("").as_bytes(),
199 );
200 hex::encode(hasher.finalize())
201 }
202
203 /// The derivation as the SQL builders in `counter_cache` see it.
204 ///
205 /// The repair paths (recompute, backfill, drift) are set-based and need no
206 /// model type, so they run the *same* builders the delta paths do. One
207 /// definition of the ground truth, not two that can disagree.
208 pub(crate) const fn sql_view(&self) -> SqlView {
209 SqlView {
210 child_table: self.child_table,
211 child_pk: self.child_pk,
212 child_soft_delete: self.child_soft_delete,
213 fk_column: self.fk_column,
214 parent_table: self.parent_table,
215 parent_pk: self.parent_pk,
216 counter_column: self.column,
217 contrib_sql: self.contrib_sql,
218 filter_sql: self.filter_sql,
219 tenant_column: self.tenant_column,
220 }
221 }
222}
223
224/// Length-prefix one labelled component into the hash.
225///
226/// The length prefix is what stops two different field splits from hashing the
227/// same, so `column = "ab"` + `transform = "c"` cannot collide with
228/// `column = "a"` + `transform = "bc"`.
229fn push_component(hasher: &mut sha2::Sha256, label: &str, value: &[u8]) {
230 use sha2::Digest as _;
231
232 hasher.update(label.as_bytes());
233 hasher.update(b":");
234 hasher.update((value.len() as u64).to_be_bytes());
235 hasher.update(b":");
236 hasher.update(value);
237 hasher.update(b";");
238}
239
240// ── Registry ────────────────────────────────────────────────────────────────
241
242/// Link-time registration of one [`DerivationDef`], emitted by `#[model]`.
243#[doc(hidden)]
244pub struct DerivationDescriptor {
245 /// The registered definition.
246 pub def: &'static DerivationDef,
247}
248
249inventory::collect!(DerivationDescriptor);
250
251/// Link-time registration of a column something other than a `#[derivation]`
252/// maintains: a plain `counter_cache` (#1325), emitted by `#[model]` for every
253/// `#[belongs_to(..., counter_cache)]`, the aggregate column a `#[votable]`
254/// model keeps from its reaction edges, the ordering column a
255/// `#[repository(..., position(...))]` assigns and reorders, and a model's
256/// `#[lock_version]` optimistic-concurrency token. (A
257/// `#[commentable(counter_cache = ...)]` parent's column is read from its own
258/// descriptor instead.)
259///
260/// None of these has a state row or a backfill, so they are not
261/// [`DerivationDef`]s; they are registered only so [`check_registered_derivations`]
262/// can see the columns they maintain. A `#[derivation]` on another model
263/// claiming such a column would count it twice on every mutation and then
264/// have its backfill overwrite the other maintainer's rows with a total over
265/// the derivation's source alone, so the pair is rejected at boot like two
266/// derivations on one column.
267#[doc(hidden)]
268#[derive(Debug, Clone, Copy)]
269pub struct CounterCacheClaim {
270 /// The child model declaring the counter cache.
271 pub model: &'static str,
272 /// The child's table.
273 pub child_table: &'static str,
274 /// The parent table and the column maintained on it.
275 pub parent_table: &'static str,
276 /// The maintained column.
277 pub column: &'static str,
278 /// Whether the column is maintained by direct SQL that runs no repository
279 /// hook (a counter cache, a vote tally, an ordering position), as opposed
280 /// to a column only the repository's own hooked paths write (the
281 /// `#[lock_version]` token, the tenant discriminator). A derivation may
282 /// not read the former as a source: its own contribution would then
283 /// change without the delta that carries the change up.
284 pub direct_sql: bool,
285 /// Where the declaration lives, for the boot error.
286 pub module_path: &'static str,
287}
288
289inventory::collect!(CounterCacheClaim);
290
291/// Every parent column something other than a `#[derivation]` maintains in
292/// this binary, in a stable order: the plain `counter_cache`, `#[votable]`
293/// aggregate and `position(...)` ordering claims, and the `comment_count`-style column a
294/// `#[commentable(counter_cache = ...)]` parent keeps, which is registered
295/// through the commentable descriptor and builds its counter spec at run time
296/// rather than through `#[model]`.
297fn registered_column_claims() -> Vec<CounterCacheClaim> {
298 let mut claims: Vec<CounterCacheClaim> = inventory::iter::<CounterCacheClaim>
299 .into_iter()
300 .copied()
301 .collect();
302 claims.extend(
303 inventory::iter::<crate::commentable::CommentableDescriptor>
304 .into_iter()
305 .filter_map(|descriptor| {
306 let column = descriptor.spec.counter_column?;
307 Some(CounterCacheClaim {
308 model: (descriptor.model)(),
309 child_table: descriptor.spec.comments_table,
310 parent_table: descriptor.spec.parent_table,
311 column,
312 direct_sql: true,
313 module_path: "#[commentable]",
314 })
315 }),
316 );
317 claims.sort_unstable_by_key(|claim| {
318 (
319 claim.parent_table,
320 claim.column,
321 claim.module_path,
322 claim.model,
323 )
324 });
325 claims
326}
327
328/// Every `#[derivation]` linked into this binary, sorted by name.
329///
330/// Sorted so the reconciliation order, the backfill order and the actuator
331/// listing are the same on every process and every boot.
332#[must_use]
333pub fn registered_derivations() -> Vec<&'static DerivationDef> {
334 let mut defs: Vec<&'static DerivationDef> = inventory::iter::<DerivationDescriptor>
335 .into_iter()
336 .map(|descriptor| descriptor.def)
337 .collect();
338 defs.sort_unstable_by_key(|def| def.name);
339 defs
340}
341
342/// A fingerprint of the registered derivations: each name and definition
343/// hash. It changes when a derivation is added, removed, renamed or changed.
344/// `None` when this binary registers none. The fleet sweeper's.
345#[cfg(feature = "sqlite")]
346pub(crate) fn registry_fingerprint() -> Option<String> {
347 use sha2::Digest as _;
348
349 let defs = registered_derivations();
350 if defs.is_empty() {
351 return None;
352 }
353 let mut hasher = sha2::Sha256::new();
354 for def in defs {
355 push_component(&mut hasher, "name", def.name.as_bytes());
356 push_component(&mut hasher, "hash", def.definition_hash().as_bytes());
357 }
358 Some(hex::encode(hasher.finalize()))
359}
360
361/// Whether the backfill of every registered derivation is complete on this
362/// database, under the definition this binary has. Reads only.
363///
364/// # Errors
365///
366/// Returns an error when the state table cannot be read for a reason other
367/// than its absence.
368#[cfg(feature = "sqlite")]
369pub(crate) async fn derivations_settled(conn: &mut RuntimeConnection) -> AutumnResult<bool> {
370 let defs = registered_derivations();
371 if defs.is_empty() {
372 return Ok(true);
373 }
374 let state = match load_state(conn).await {
375 Ok(state) => state,
376 Err(error) if is_missing_state_table(&error) => return Ok(false),
377 Err(error) => return Err(error),
378 };
379 Ok(defs.iter().all(|def| {
380 state.get(def.name).is_some_and(|row| {
381 row.definition_hash == def.definition_hash()
382 && BackfillState::parse(&row.backfill_state) == Some(BackfillState::Complete)
383 })
384 }))
385}
386
387/// Whether this binary links any `#[derivation]` at all.
388///
389/// Startup uses it to decide whether to apply the state-table migration and run
390/// the reconciliation, so an app with no derivation pays for none of it.
391pub(crate) fn has_derivation_descriptors() -> bool {
392 inventory::iter::<DerivationDescriptor>
393 .into_iter()
394 .next()
395 .is_some()
396}
397
398/// The definition `name` selects from `defs`, after the whole set has passed
399/// the registry check.
400///
401/// The repair and resweep entry points take a name, but they act on a column
402/// the rest of the registry may also claim: a `recompute` that skipped the
403/// check would assign a shared parent column from one definition's source
404/// alone and overwrite what the colliding one maintains. So they refuse the
405/// same registries boot refuses, before touching the database.
406fn select_checked<'a>(defs: &[&'a DerivationDef], name: &str) -> AutumnResult<&'a DerivationDef> {
407 check_registry(defs)?;
408 defs.iter()
409 .copied()
410 .find(|def| def.name == name)
411 .ok_or_else(|| {
412 AutumnError::from(std::io::Error::other(format!(
413 "`{name}` is not a derivation registered in this binary"
414 )))
415 })
416}
417
418/// Reject a registry that cannot be reconciled.
419///
420/// Both collisions below are programming errors, like a duplicate route, so
421/// every entry point checks them rather than only the boot path.
422fn check_registry(defs: &[&DerivationDef]) -> AutumnResult<()> {
423 check_unique_names(defs)?;
424 check_primary_key_columns(defs)?;
425 let claims = registered_column_claims();
426 check_unique_columns(defs, &claims)?;
427 check_source_columns(defs, &claims)
428}
429
430/// Reject a derivation that maintains the parent's primary key.
431///
432/// The macro already refuses `column = "id"` by spelling. The primary key is
433/// not a registered claim, so nothing in [`check_unique_columns`] covers it,
434/// and under [`ident_key`] a spelling the macro let through (`"ID"` on
435/// `SQLite`, where quoted identifiers fold case) is still the same column: the
436/// first qualifying mutation would renumber the parent, and a backfill would
437/// write duplicate aggregate values into primary keys.
438fn check_primary_key_columns(defs: &[&DerivationDef]) -> AutumnResult<()> {
439 let primary_key = ident_key("id");
440 for def in defs {
441 if ident_key(def.column) == primary_key {
442 return Err(AutumnError::from(std::io::Error::other(format!(
443 "derivation `{}` on {}::{} maintains `{}.{}`, which is the parent's primary \
444 key under this backend's identifier rules. A maintained value would rewrite \
445 the parent's identity, so name a dedicated aggregate column",
446 def.name, def.module_path, def.model, def.parent_table, def.column,
447 ))));
448 }
449 }
450 Ok(())
451}
452
453/// The key an identifier is compared under in the registry checks.
454///
455/// Every statement quotes its identifiers, and on Postgres a quoted identifier
456/// is case-sensitive: `"Score"` and `"score"` are two columns, so the spelling
457/// is the key. `SQLite` folds ASCII case even inside quotes, so there the two
458/// spellings are one column and must collide.
459#[cfg(not(feature = "sqlite"))]
460fn ident_key(ident: &str) -> String {
461 postgres_ident_key(ident)
462}
463
464/// The Postgres spelling of an identifier: the full spelling truncated to
465/// `NAMEDATALEN - 1` bytes (63 on a stock build; a `configure
466/// --with-namedatalen` build can move it).
467///
468/// Postgres truncates every identifier past that limit — quoting preserves
469/// case and punctuation but does not exempt an overlong name — so two
470/// maintained-column claims that agree on their first 63 bytes are one column
471/// in the database. The registry checks must compare the physical spelling,
472/// or they accept both maintainers and mutations double-apply deltas to a
473/// single column while backfills overwrite each other (issue #2664).
474/// Truncation stops on a char boundary, so a multi-byte identifier is never
475/// split.
476#[cfg(not(feature = "sqlite"))]
477fn postgres_ident_key(ident: &str) -> String {
478 const MAX_IDENTIFIER_BYTES: usize = 63;
479 if ident.len() <= MAX_IDENTIFIER_BYTES {
480 return ident.to_owned();
481 }
482 let mut end = MAX_IDENTIFIER_BYTES;
483 while !ident.is_char_boundary(end) {
484 end -= 1;
485 }
486 ident[..end].to_owned()
487}
488#[cfg(feature = "sqlite")]
489fn ident_key(ident: &str) -> String {
490 ident.to_ascii_lowercase()
491}
492
493/// The child columns a definition reads: the grouping key and the tenant
494/// column every aggregate reads implicitly, then every `{c}."<column>"` in
495/// its contribution and its lowered filter.
496fn source_columns(def: &DerivationDef) -> Vec<&'static str> {
497 let mut columns = vec![def.fk_column];
498 columns.extend(def.tenant_column);
499 for sql in [def.contrib_sql, def.filter_sql] {
500 let mut rest = sql;
501 while let Some(start) = rest.find("{c}.\"") {
502 let after = &rest[start + 5..];
503 let Some(end) = after.find('"') else { break };
504 columns.push(&after[..end]);
505 rest = &after[end + 1..];
506 }
507 }
508 columns
509}
510
511/// Reject a derivation whose source is a column something maintains by direct
512/// SQL on the child's table.
513///
514/// A parent-side update runs no repository hook, so when the column it writes
515/// is another derivation's source, that derivation's contribution changes with
516/// no delta carrying the change up: a comment's `child_score` moves and the
517/// post's `sum(child_score)` never hears of it. The same holds for a plain
518/// counter cache, a vote tally or an ordering position on the child's table.
519/// Columns only the repository's hooked paths write (the `#[lock_version]`
520/// token, the tenant discriminator) are fine to read.
521fn check_source_columns(defs: &[&DerivationDef], claims: &[CounterCacheClaim]) -> AutumnResult<()> {
522 for def in defs {
523 let sources: Vec<String> = source_columns(def).into_iter().map(ident_key).collect();
524 // The table whose columns are this derivation's sources: the child's.
525 let source_table = ident_key(def.child_table);
526 // Onto its own table, the column a derivation maintains is a column of
527 // its source rows. The macro refuses the spelled-out case; this is the
528 // same rule under the backend's identifier semantics, where `Nodes`
529 // and `nodes` (or `Score` and `score`) are one name on `SQLite`.
530 if ident_key(def.parent_table) == source_table && sources.contains(&ident_key(def.column)) {
531 return Err(AutumnError::from(std::io::Error::other(format!(
532 "derivation `{}` on {}::{} onto its own table reads the column it maintains, \
533 `{}.{}` (as its sum, in its filter, as its grouping key or as its tenant \
534 column). The parent-side update runs no repository hook, so a row's new \
535 value would change what it contributes to its own parent without that \
536 parent being maintained. Read another column, or maintain another one",
537 def.name, def.module_path, def.model, def.parent_table, def.column,
538 ))));
539 }
540 if let Some(other) = defs.iter().find(|other| {
541 other.name != def.name
542 && ident_key(other.parent_table) == source_table
543 && sources.contains(&ident_key(other.column))
544 }) {
545 return Err(AutumnError::from(std::io::Error::other(format!(
546 "derivation `{}` on {}::{} reads `{}.{}`, which derivation `{}` on {}::{} \
547 maintains. The parent-side update runs no repository hook, so `{}`'s \
548 contribution would change without the delta that carries it up. Read a \
549 column nothing maintains, or maintain the aggregate one level at a time",
550 def.name,
551 def.module_path,
552 def.model,
553 def.child_table,
554 other.column,
555 other.name,
556 other.module_path,
557 other.model,
558 def.name,
559 ))));
560 }
561 if let Some(claim) = claims.iter().find(|claim| {
562 claim.direct_sql
563 && ident_key(claim.parent_table) == source_table
564 && sources.contains(&ident_key(claim.column))
565 }) {
566 return Err(AutumnError::from(std::io::Error::other(format!(
567 "derivation `{}` on {}::{} reads `{}.{}`, which {}::{} (child table `{}`) \
568 maintains by direct SQL. That update runs no repository hook, so `{}`'s \
569 contribution would change without the delta that carries it up. Read a \
570 column nothing maintains, or maintain the aggregate one level at a time",
571 def.name,
572 def.module_path,
573 def.model,
574 def.child_table,
575 claim.column,
576 claim.module_path,
577 claim.model,
578 claim.child_table,
579 def.name,
580 ))));
581 }
582 }
583 Ok(())
584}
585
586/// Reject two derivations claiming one name.
587///
588/// They would share a `_autumn_derivations` row, so each boot would see the
589/// other's hash and enqueue a backfill forever. Naming both module paths is what
590/// makes the collision fixable.
591fn check_unique_names(defs: &[&DerivationDef]) -> AutumnResult<()> {
592 for pair in defs.windows(2) {
593 if pair[0].name == pair[1].name {
594 return Err(AutumnError::from(std::io::Error::other(format!(
595 "two derivations are both named `{}`: {}::{} and {}::{}. Give one \
596 a `name = \"...\"` so each has its own backfill state",
597 pair[0].name,
598 pair[0].module_path,
599 pair[0].model,
600 pair[1].module_path,
601 pair[1].model,
602 ))));
603 }
604 }
605 Ok(())
606}
607
608/// Reject two derivations maintaining one parent column.
609///
610/// Every mutation path applies each derivation's own delta, so one column with
611/// two derivations counts twice. No repair can fix that: the two definitions
612/// disagree on what the column means, so each sweep would undo the other.
613fn check_unique_columns(defs: &[&DerivationDef], claims: &[CounterCacheClaim]) -> AutumnResult<()> {
614 let mut seen: HashMap<(String, String), &DerivationDef> = HashMap::new();
615 for def in defs {
616 let key = (ident_key(def.parent_table), ident_key(def.column));
617 // A plain counter cache on the same column is the same double count,
618 // and worse: the derivation's backfill would then overwrite every
619 // parent with a total over its own source alone.
620 if let Some(claim) = claims
621 .iter()
622 .find(|claim| (ident_key(claim.parent_table), ident_key(claim.column)) == key)
623 {
624 return Err(AutumnError::from(std::io::Error::other(format!(
625 "derivation `{}` on {}::{} maintains `{}.{}`, which the counter cache \
626 declared by {}::{} (child table `{}`) already maintains. The column would \
627 count twice, and the derivation's backfill would overwrite the counter \
628 cache's rows, so remove one or point it at another column",
629 def.name,
630 def.module_path,
631 def.model,
632 def.parent_table,
633 def.column,
634 claim.module_path,
635 claim.model,
636 claim.child_table,
637 ))));
638 }
639 if let Some(first) = seen.insert(key, def) {
640 return Err(AutumnError::from(std::io::Error::other(format!(
641 "two derivations both maintain `{}.{}`: `{}` on {}::{} and `{}` on \
642 {}::{}. The column would count twice, so remove one or point it \
643 at another column",
644 def.parent_table,
645 def.column,
646 first.name,
647 first.module_path,
648 first.model,
649 def.name,
650 def.module_path,
651 def.model,
652 ))));
653 }
654 }
655 Ok(())
656}
657
658/// Check the linked registry without touching a database.
659///
660/// The boot path calls this before it opens a connection, so a duplicate name
661/// or two derivations on one parent column stop the process rather than reach
662/// the data. Both are programming errors.
663///
664/// # Errors
665///
666/// Returns an error naming both offenders and their module paths.
667pub fn check_registered_derivations() -> AutumnResult<()> {
668 check_registry(®istered_derivations())
669}
670
671// ── State ───────────────────────────────────────────────────────────────────
672
673/// How far a derivation's backfill has got.
674#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
675#[serde(rename_all = "lowercase")]
676pub enum BackfillState {
677 /// Enqueued, not started. No parent has been repaired yet.
678 Pending,
679 /// Part-way through: `checkpoint` names the last repaired parent.
680 Running,
681 /// Every parent has been repaired at least once. The delta paths keep it
682 /// current from here.
683 Complete,
684 /// A `_autumn_derivations` row whose derivation this binary does not
685 /// declare, left behind by a removed or renamed definition. Never stored:
686 /// the state table's `CHECK` does not admit the spelling.
687 Unregistered,
688}
689
690impl BackfillState {
691 /// The spelling stored in `_autumn_derivations.backfill_state`.
692 #[must_use]
693 pub const fn as_str(self) -> &'static str {
694 match self {
695 Self::Pending => "pending",
696 Self::Running => "running",
697 Self::Complete => "complete",
698 Self::Unregistered => "unregistered",
699 }
700 }
701
702 /// Parse a stored spelling. `unregistered` is deliberately not accepted: it
703 /// is a report-only state, so a row carrying it is a corrupt row.
704 fn parse(value: &str) -> Option<Self> {
705 match value {
706 "pending" => Some(Self::Pending),
707 "running" => Some(Self::Running),
708 "complete" => Some(Self::Complete),
709 _ => None,
710 }
711 }
712
713 /// Whether a backfill sweep still has work to do for this state.
714 const fn is_sweepable(self) -> bool {
715 matches!(self, Self::Pending | Self::Running)
716 }
717}
718
719impl std::fmt::Display for BackfillState {
720 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
721 f.write_str(self.as_str())
722 }
723}
724
725/// One derivation as `/actuator/derivations` reports it.
726#[derive(Debug, Clone, Serialize)]
727pub struct DerivationStatus {
728 /// The derivation's name.
729 pub name: String,
730 /// The hash of the definition this process has linked, or `None` for a
731 /// state row this binary declares no derivation for
732 /// ([`BackfillState::Unregistered`]).
733 pub definition_hash: Option<String>,
734 /// The hash recorded in `_autumn_derivations`, or `None` when no row exists
735 /// yet. A value different from `definition_hash` means a backfill is due.
736 pub stored_hash: Option<String>,
737 /// The recorded backfill state, or `None` when no row exists yet.
738 pub backfill_state: Option<BackfillState>,
739 /// The last repaired parent primary key. It stays populated after the
740 /// backfill completes, because it records the last position the sweep
741 /// applied rather than an in-flight cursor.
742 pub checkpoint: Option<i64>,
743 /// How many parent rows the backfill has visited. The checkpoint pages the
744 /// parents and the repair assigns the ground truth to each, so this counts
745 /// visits, not writes: a page that already agreed is visited and not
746 /// written.
747 pub backfilled_rows: i64,
748 /// When the row last changed, as the database rendered it.
749 pub updated_at: Option<String>,
750 /// How many parent rows disagree with the source of truth right now. `0` is
751 /// the healthy value; anything else is drift [`recompute`] repairs.
752 ///
753 /// The scan examines at most [`DRIFT_SCAN_LIMIT`] parents, newest ids
754 /// first, so it is bounded on a table of any size: a value equal to that
755 /// limit means "every parent examined", and drift confined to older rows
756 /// beyond the window is not seen by it. `None` when the scan could not run
757 /// (see [`Self::drift_error`]) or when the derivation is unregistered.
758 pub drift: Option<i64>,
759 /// Why the drift scan did not run, when it did not.
760 ///
761 /// A missing derived column is the common case: the migration that adds it
762 /// has not been applied yet. The other derivations are still reported, so
763 /// one broken derivation cannot hide the rest.
764 pub drift_error: Option<String>,
765}
766
767#[derive(diesel::QueryableByName)]
768struct StateRow {
769 #[diesel(sql_type = Text)]
770 name: String,
771 #[diesel(sql_type = Text)]
772 definition_hash: String,
773 #[diesel(sql_type = Text)]
774 backfill_state: String,
775 #[diesel(sql_type = Nullable<BigInt>)]
776 checkpoint: Option<i64>,
777 #[diesel(sql_type = BigInt)]
778 backfilled_rows: i64,
779 #[diesel(sql_type = Nullable<Text>)]
780 updated_at: Option<String>,
781}
782
783#[derive(diesel::QueryableByName)]
784struct CountRow {
785 #[diesel(sql_type = BigInt)]
786 count: i64,
787}
788
789/// Read every state row, keyed by name.
790///
791/// `updated_at` is cast to text in SQL rather than decoded as a timestamp: the
792/// column is `TIMESTAMPTZ` on Postgres and `TEXT` on `SQLite`, and this is a
793/// status field for a human, so one portable statement beats two decoders.
794async fn load_state(conn: &mut RuntimeConnection) -> AutumnResult<HashMap<String, StateRow>> {
795 let sql = format!(
796 "SELECT name, definition_hash, backfill_state, checkpoint, backfilled_rows, \
797 CAST(updated_at AS TEXT) AS updated_at FROM {STATE_TABLE} ORDER BY name"
798 );
799 let rows: Vec<StateRow> = diesel::sql_query(sql)
800 .load::<StateRow>(conn)
801 .await
802 .map_err(AutumnError::from)?;
803 Ok(rows
804 .into_iter()
805 .map(|row| (row.name.clone(), row))
806 .collect())
807}
808
809/// Enqueue `def` for a backfill from the start.
810///
811/// One upsert, so a first boot and a definition change take the same path. It
812/// resets the checkpoint and the repaired-row count: a changed definition
813/// invalidates every parent the previous definition repaired.
814async fn enqueue(conn: &mut RuntimeConnection, def: &DerivationDef) -> AutumnResult<()> {
815 let sql = format!(
816 "INSERT INTO {STATE_TABLE} \
817 (name, definition_hash, backfill_state, checkpoint, backfilled_rows, updated_at) \
818 VALUES ({}, {}, 'pending', NULL, 0, {NOW}) \
819 ON CONFLICT (name) DO UPDATE SET \
820 definition_hash = excluded.definition_hash, \
821 backfill_state = 'pending', checkpoint = NULL, backfilled_rows = 0, \
822 updated_at = {NOW}",
823 ph(1),
824 ph(2)
825 );
826 diesel::sql_query(sql)
827 .bind::<Text, _>(def.name)
828 .bind::<Text, _>(def.definition_hash())
829 .execute(conn)
830 .await
831 .map_err(AutumnError::from)?;
832 Ok(())
833}
834
835/// Reconcile the registered derivations against `_autumn_derivations`.
836///
837/// A derivation with no row, or with a stored hash different from the one this
838/// binary computes, is enqueued as `pending` with its checkpoint cleared. A
839/// derivation whose hash matches is left exactly as it is, which is what keeps a
840/// boot from re-backfilling everything it already backfilled.
841///
842/// A derivation whose name has no row carrying its hash, when exactly one
843/// other row does and that row's own name does not claim it (no derivation is
844/// registered under that name, or one is with a different hash), has been
845/// renamed: `definition_hash` leaves the name out on purpose, so the old row's
846/// state (a finished backfill included) is carried over under the new name
847/// rather than rebuilt from the start. Rows are matched by hash first and
848/// moved in two passes, so two derivations that only exchanged names both keep
849/// their state, and a row under the destination name with a hash nothing
850/// registered carries is dropped in favour of the adopted one. The whole
851/// reconciliation runs in one transaction that holds the state table, so
852/// replicas booting together take turns rather than racing each other's
853/// renames.
854///
855/// # Changing a definition under a rolling deployment
856///
857/// The delta paths do not consult the state row: a replica applies the
858/// definition compiled into it. While a rollout replaces replicas one by one,
859/// the old ones keep maintaining the column under the old definition and the
860/// new binary's backfill assigns the new one, so a parent an old replica
861/// touches after the sweep has passed it is wrong until it is swept again. The
862/// framework cannot see the fleet, so it does not wait; the contract is one
863/// [`resweep`] (or [`recompute`])
864/// once no old replica writes, which the guide's deployment section spells out.
865/// A derivation removed for a deployment and reinstated later is the same
866/// contract: its row survives with a matching hash, nothing maintained the
867/// column in between, and this reconciliation cannot tell the two apart (a
868/// binary that links only some of an app's models must not orphan the rest),
869/// so reinstating it calls for the same `resweep`.
870///
871/// Returns the names enqueued, in name order.
872///
873/// # Errors
874///
875/// Returns an error when two registered derivations share a name, when two
876/// maintain the same parent column, or when the state table cannot be read or
877/// written. The first two are programming errors, and the boot path treats them
878/// as fatal: a column with two derivations double counts, which is data
879/// corruption rather than staleness.
880pub async fn ensure_derivations(conn: &mut RuntimeConnection) -> AutumnResult<Vec<&'static str>> {
881 let defs = registered_derivations();
882 check_registry(&defs)?;
883 // One transaction holding the state table for the whole reconciliation:
884 // replicas booting together take turns, so each one's snapshot is the
885 // truth for as long as it acts on it. Without that, two replicas parking
886 // the same swapped rows could leave one enqueuing fresh rows into the
887 // names the other had just emptied, and the finished backfills the winner
888 // then discards in favour of the loser's pending ones.
889 scoped_immediate_transaction::<Vec<&'static str>, AutumnError, _>(conn, move |conn| {
890 async move {
891 lock_state_table(conn).await?;
892 reconcile(conn, &defs).await
893 }
894 .scope_boxed()
895 })
896 .await
897}
898
899/// Exclude every other reconciliation (and writer) for the rest of the
900/// transaction.
901///
902/// `EXCLUSIVE` conflicts with every mode but `ACCESS SHARE`, so a second
903/// replica's `ensure_derivations` waits at its own lock, a backfill batch
904/// waits at its opening `FOR UPDATE`, and plain reads (the actuator's status)
905/// go through. It has to conflict with `ROW SHARE` in particular: a batch
906/// already past its `FOR UPDATE` holds that mode plus the state row, and a
907/// weaker lock here (`SHARE ROW EXCLUSIVE`) would let reconciliation in, then
908/// block it on that row while the batch's `UPDATE` blocked on this lock, a
909/// deadlock the database would resolve by aborting one of the two. With
910/// `EXCLUSIVE`, reconciliation instead waits for the batch to commit.
911#[cfg(not(feature = "sqlite"))]
912async fn lock_state_table(conn: &mut RuntimeConnection) -> AutumnResult<()> {
913 diesel::sql_query(format!("LOCK TABLE {STATE_TABLE} IN EXCLUSIVE MODE"))
914 .execute(conn)
915 .await
916 .map_err(AutumnError::from)?;
917 Ok(())
918}
919
920/// The enclosing `BEGIN IMMEDIATE` already excludes every other writer in the
921/// database, so there is nothing further to take.
922#[cfg(feature = "sqlite")]
923#[allow(clippy::unused_async, reason = "one call shape for both backends")]
924async fn lock_state_table(_conn: &mut RuntimeConnection) -> AutumnResult<()> {
925 Ok(())
926}
927
928/// The body of [`ensure_derivations`], under the table lock.
929async fn reconcile(
930 conn: &mut RuntimeConnection,
931 defs: &[&'static DerivationDef],
932) -> AutumnResult<Vec<&'static str>> {
933 let state = load_state(conn).await?;
934 let hashes: Vec<(&DerivationDef, String)> = defs
935 .iter()
936 .map(|def| (*def, def.definition_hash()))
937 .collect();
938 let registered: HashMap<&str, &str> = hashes
939 .iter()
940 .map(|(def, hash)| (def.name, hash.as_str()))
941 .collect();
942 // A row belongs to the derivation it is named after only while their
943 // hashes agree; any other row is up for adoption by the derivation whose
944 // hash it carries, whatever its name.
945 let unclaimed =
946 |row: &StateRow| registered.get(row.name.as_str()) != Some(&row.definition_hash.as_str());
947
948 // Pass one parks every row a renamed derivation can adopt under a name no
949 // derivation uses. Two derivations that only exchanged names each find
950 // the other's row under their own name; moving both out of the way first
951 // is what lets the second pass put each where it now belongs.
952 let mut pending = Vec::new();
953 for (def, hash) in &hashes {
954 if state
955 .get(def.name)
956 .is_some_and(|row| row.definition_hash == *hash)
957 {
958 continue;
959 }
960 // Two adoptable rows with one hash cannot both be this derivation, so
961 // only an unambiguous match is adopted.
962 let mut candidates = state
963 .values()
964 .filter(|row| row.name != def.name && row.definition_hash == *hash)
965 .filter(|row| unclaimed(row));
966 let parked = match (candidates.next(), candidates.next()) {
967 (Some(row), None) => {
968 rename_state(conn, &row.name, &parking_name(def.name), hash).await?
969 }
970 _ => false,
971 };
972 pending.push((def, hash.as_str(), parked));
973 }
974
975 let mut enqueued = Vec::new();
976 for (def, hash, parked) in pending {
977 if parked {
978 // Whatever still sits under the destination carries a definition
979 // this derivation no longer has (a row parked by another
980 // derivation is already gone); enqueuing would have overwritten
981 // it anyway.
982 delete_stale_state(conn, def.name, hash).await?;
983 if rename_state(conn, &parking_name(def.name), def.name, hash).await? {
984 continue;
985 }
986 }
987 enqueue(conn, def).await?;
988 enqueued.push(def.name);
989 }
990 Ok(enqueued)
991}
992
993/// The name a row being adopted by `name` is parked under between the two
994/// passes of [`ensure_derivations`].
995///
996/// No derivation is registered under it (the macro reserves the prefix, and a
997/// generated `table.column` name cannot contain `::`), so a boot interrupted
998/// between the passes leaves a row the next boot adopts the same way, hash
999/// first.
1000fn parking_name(name: &str) -> String {
1001 format!("{PARKING_PREFIX}{name}")
1002}
1003
1004/// Must match `DERIVATION_PARKING_PREFIX` in `autumn-macros`.
1005const PARKING_PREFIX: &str = "parked::";
1006
1007/// Delete the state row `name` unless it carries `hash`.
1008async fn delete_stale_state(
1009 conn: &mut RuntimeConnection,
1010 name: &str,
1011 hash: &str,
1012) -> AutumnResult<()> {
1013 let sql = format!(
1014 "DELETE FROM {STATE_TABLE} WHERE name = {} AND definition_hash <> {}",
1015 ph(1),
1016 ph(2)
1017 );
1018 diesel::sql_query(sql)
1019 .bind::<Text, _>(name)
1020 .bind::<Text, _>(hash)
1021 .execute(conn)
1022 .await
1023 .map_err(AutumnError::from)?;
1024 Ok(())
1025}
1026
1027/// Carry the state row `from` over to the derivation now named `to`.
1028///
1029/// Guarded by the hash, and reported as `false` when no row moved, in which
1030/// case the caller enqueues rather than trusting a rename that did not happen.
1031async fn rename_state(
1032 conn: &mut RuntimeConnection,
1033 from: &str,
1034 to: &str,
1035 hash: &str,
1036) -> AutumnResult<bool> {
1037 let sql = format!(
1038 "UPDATE {STATE_TABLE} SET name = {}, updated_at = {NOW} \
1039 WHERE name = {} AND definition_hash = {}",
1040 ph(1),
1041 ph(2),
1042 ph(3)
1043 );
1044 let moved = diesel::sql_query(sql)
1045 .bind::<Text, _>(to)
1046 .bind::<Text, _>(from)
1047 .bind::<Text, _>(hash)
1048 .execute(conn)
1049 .await
1050 .map_err(AutumnError::from)?;
1051 Ok(moved == 1)
1052}
1053
1054// ── Backfill ────────────────────────────────────────────────────────────────
1055
1056/// How a backfill sweep is paced.
1057#[derive(Debug, Clone, Copy)]
1058pub struct BackfillOptions {
1059 /// Parents repaired per transaction. Each batch takes a row lock on every
1060 /// parent it rebuilds and holds it until commit, so this bounds how long
1061 /// concurrent writers to those rows can be blocked.
1062 pub batch_size: i64,
1063 /// Stop after this many batches across all derivations, leaving the rest for
1064 /// the next call. `None` runs to completion.
1065 pub max_batches: Option<usize>,
1066}
1067
1068impl Default for BackfillOptions {
1069 fn default() -> Self {
1070 Self {
1071 batch_size: 1000,
1072 max_batches: None,
1073 }
1074 }
1075}
1076
1077/// What one [`run_backfill`] call did.
1078#[derive(Debug, Clone, Default, Serialize)]
1079pub struct BackfillReport {
1080 /// Derivations that reached `complete` in this call.
1081 pub completed: Vec<String>,
1082 /// Every derivation still pending or running when the call returned,
1083 /// because [`BackfillOptions::max_batches`] stopped it. Each keeps its
1084 /// committed checkpoint, so the next call resumes rather than restarts.
1085 pub in_progress: Vec<String>,
1086 /// Parent rows actually repaired. A value that already agreed with the
1087 /// source of truth is neither counted here nor written.
1088 pub rows_repaired: usize,
1089 /// Batches that advanced a checkpoint in this call. A call that returns
1090 /// with work `in_progress` and `0` here made no progress, which is how a
1091 /// caller looping on the budget tells "more to do" from "stuck".
1092 pub batches_run: usize,
1093}
1094
1095/// Advance one derivation's checkpoint. Runs inside the batch's transaction.
1096///
1097/// Guarded by name **and** hash. Another replica may have re-enqueued this
1098/// derivation under a new definition between the lock and this write only if the
1099/// lock was released, which cannot happen inside the transaction; the guard is
1100/// what makes that reasoning independent of the lock, so a checkpoint can never
1101/// describe a definition other than the one that produced it.
1102async fn advance_checkpoint(
1103 conn: &mut RuntimeConnection,
1104 name: &str,
1105 hash: &str,
1106 checkpoint: i64,
1107 rows: i64,
1108) -> AutumnResult<()> {
1109 let sql = format!(
1110 "UPDATE {STATE_TABLE} SET checkpoint = {checkpoint}, \
1111 backfilled_rows = backfilled_rows + {rows}, backfill_state = 'running', \
1112 updated_at = {NOW} WHERE name = {} AND definition_hash = {}",
1113 ph(1),
1114 ph(2)
1115 );
1116 diesel::sql_query(sql)
1117 .bind::<Text, _>(name.to_owned())
1118 .bind::<Text, _>(hash.to_owned())
1119 .execute(conn)
1120 .await
1121 .map_err(AutumnError::from)?;
1122 Ok(())
1123}
1124
1125/// Mark one derivation's backfill finished, guarded by name and hash.
1126///
1127/// The hash guard is the important half: marking a derivation complete records
1128/// "every parent now matches this definition". Writing that against a row that
1129/// carries a different definition would declare the new definition complete with
1130/// values the old one produced.
1131async fn mark_complete(conn: &mut RuntimeConnection, name: &str, hash: &str) -> AutumnResult<()> {
1132 let sql = format!(
1133 "UPDATE {STATE_TABLE} SET backfill_state = 'complete', updated_at = {NOW} \
1134 WHERE name = {} AND definition_hash = {}",
1135 ph(1),
1136 ph(2)
1137 );
1138 diesel::sql_query(sql)
1139 .bind::<Text, _>(name.to_owned())
1140 .bind::<Text, _>(hash.to_owned())
1141 .execute(conn)
1142 .await
1143 .map_err(AutumnError::from)?;
1144 Ok(())
1145}
1146
1147/// The state row as one batch transaction re-reads it under its lock.
1148#[derive(diesel::QueryableByName)]
1149struct LockedRow {
1150 #[diesel(sql_type = Text)]
1151 definition_hash: String,
1152 #[diesel(sql_type = Text)]
1153 backfill_state: String,
1154 #[diesel(sql_type = Nullable<BigInt>)]
1155 checkpoint: Option<i64>,
1156}
1157
1158/// What one batch transaction did.
1159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1160enum Batch {
1161 /// The state row no longer asks this process to sweep. It is gone, it
1162 /// carries another definition, or it is already complete.
1163 Stopped,
1164 /// The page was empty, so the sweep reached the end of the parent table and
1165 /// the row is now `complete`.
1166 Completed,
1167 /// One page was repaired and the checkpoint moved past it.
1168 Advanced {
1169 /// Parent rows this page actually wrote.
1170 repaired: usize,
1171 },
1172}
1173
1174/// One backfill batch, as one transaction.
1175///
1176/// The transaction is the whole design. It locks the state row first, so the row
1177/// is both the cursor and the mutex: any number of replicas can call this
1178/// concurrently and they take turns on one sweep instead of each running their
1179/// own. Every step after the lock reads the state the lock protects:
1180///
1181/// 1. lock the state row and re-read hash, state and checkpoint;
1182/// 2. stop when the row is gone, carries another definition, or is complete;
1183/// 3. page parent ids after the checkpoint the row carries, inside this
1184/// transaction;
1185/// 4. an empty page means the end of the table: mark complete and stop;
1186/// 5. otherwise lock the parents in ascending id order, assign the ground truth,
1187/// then advance the checkpoint and the visited-row count.
1188///
1189/// Steps 4 and 5 guard their writes by name and hash, so a definition that
1190/// changed under this process cannot be marked complete or advanced with values
1191/// the previous definition produced.
1192///
1193/// Lock order is state row, then parents in ascending id order. The delta paths
1194/// take parent locks and never the state row, so the two cannot deadlock
1195/// against each other.
1196async fn run_one_batch(
1197 conn: &mut RuntimeConnection,
1198 def: &'static DerivationDef,
1199 batch_size: i64,
1200) -> AutumnResult<Batch> {
1201 let view = def.sql_view();
1202 let name = def.name;
1203 let hash = def.definition_hash();
1204 scoped_immediate_transaction::<Batch, AutumnError, _>(conn, move |conn| {
1205 async move {
1206 let lock_sql = format!(
1207 "SELECT definition_hash, backfill_state, checkpoint FROM {STATE_TABLE} \
1208 WHERE name = {}{FOR_UPDATE}",
1209 ph(1)
1210 );
1211 let locked = diesel::sql_query(lock_sql)
1212 .bind::<Text, _>(name)
1213 .load::<LockedRow>(&mut *conn)
1214 .await
1215 .map_err(AutumnError::from)?
1216 .into_iter()
1217 .next();
1218
1219 let Some(row) = locked else {
1220 return Ok(Batch::Stopped);
1221 };
1222 if row.definition_hash != hash {
1223 return Ok(Batch::Stopped);
1224 }
1225 if !BackfillState::parse(&row.backfill_state).is_some_and(BackfillState::is_sweepable) {
1226 return Ok(Batch::Stopped);
1227 }
1228
1229 let ids =
1230 crate::counter_cache::parent_id_page(&mut *conn, &view, row.checkpoint, batch_size)
1231 .await?;
1232 let Some(&last) = ids.last() else {
1233 mark_complete(&mut *conn, name, &hash).await?;
1234 return Ok(Batch::Completed);
1235 };
1236 let visited = i64::try_from(ids.len()).unwrap_or(i64::MAX);
1237 let repaired =
1238 crate::counter_cache::recompute_batch_statements(&mut *conn, &view, &ids).await?;
1239 advance_checkpoint(&mut *conn, name, &hash, last, visited).await?;
1240 Ok(Batch::Advanced { repaired })
1241 }
1242 .scope_boxed()
1243 })
1244 .await
1245}
1246
1247/// Repair every parent of every enqueued derivation, in resumable batches.
1248///
1249/// Each batch is **one** transaction that locks the derivation's state row,
1250/// re-reads its hash, state and checkpoint, pages the parents after that
1251/// checkpoint, repairs them and advances the checkpoint. See `run_one_batch`
1252/// for the exact sequence.
1253///
1254/// The state-row lock is the cross-process mutex. Several replicas booting a new
1255/// definition therefore cooperate on one sweep: each takes the row in turn, sees
1256/// the checkpoint the previous one committed, and repairs the next page. No
1257/// advisory lock is involved, `backfilled_rows` stays exact, and no page is
1258/// repaired twice.
1259///
1260/// A definition that changed under this process is dropped rather than repaired.
1261/// Every state write is guarded by name **and** hash, so the process that
1262/// re-enqueued it owns the sweep and this one cannot mark the new definition
1263/// complete with values the old one produced.
1264///
1265/// [`BackfillOptions::max_batches`] bounds the call, not the sweep. When the
1266/// budget runs out, every derivation still pending or running is reported in
1267/// [`BackfillReport::in_progress`] and the next call resumes from the committed
1268/// checkpoints.
1269///
1270/// # Errors
1271///
1272/// Propagates any database error from the paging, repair or checkpoint
1273/// statements, and returns an error when the registry carries a duplicate name
1274/// or two derivations on one parent column.
1275pub async fn run_backfill(
1276 conn: &mut RuntimeConnection,
1277 options: &BackfillOptions,
1278) -> AutumnResult<BackfillReport> {
1279 // A runtime check, not an assertion: `LIMIT 0` returns an empty page, and
1280 // an empty page is how a sweep learns it has reached the end of the table,
1281 // so a zero batch would mark every derivation complete having repaired
1282 // nothing.
1283 if options.batch_size <= 0 {
1284 return Err(AutumnError::from(std::io::Error::other(format!(
1285 "a backfill batch must hold at least one parent row; `batch_size` is {}",
1286 options.batch_size
1287 ))));
1288 }
1289 let defs = registered_derivations();
1290 check_registry(&defs)?;
1291
1292 let mut report = BackfillReport::default();
1293 let mut batches = 0usize;
1294
1295 // One read outside the transactions, only to pick the candidates. Each batch
1296 // re-reads its row under the row lock, so this snapshot never decides a
1297 // write.
1298 let state = load_state(conn).await?;
1299 let candidates: Vec<&'static DerivationDef> = defs
1300 .into_iter()
1301 .filter(|def| {
1302 state.get(def.name).is_some_and(|row| {
1303 row.definition_hash == def.definition_hash()
1304 && BackfillState::parse(&row.backfill_state)
1305 .is_some_and(BackfillState::is_sweepable)
1306 })
1307 })
1308 .collect();
1309
1310 let mut budget_spent = false;
1311 for def in candidates {
1312 // The budget stops the call, not the report: a derivation this call
1313 // never reached is still pending, so it belongs in `in_progress`.
1314 if budget_spent {
1315 report.in_progress.push(def.name.to_owned());
1316 continue;
1317 }
1318 loop {
1319 if options.max_batches.is_some_and(|max| batches >= max) {
1320 budget_spent = true;
1321 report.in_progress.push(def.name.to_owned());
1322 break;
1323 }
1324 // A self-referential derivation (a comment's `reply_count`) has
1325 // rows that are children and parents at once. A mutation holds
1326 // its child row and then wants the parent; a batch of several
1327 // parents holds the first and then wants the next, which may be
1328 // that child. One parent per batch takes one lock and never a
1329 // second, so the sweep cannot be one side of that cycle.
1330 let batch_size = if def.child_table == def.parent_table {
1331 1
1332 } else {
1333 options.batch_size
1334 };
1335 match run_one_batch_retrying(conn, def, batch_size).await? {
1336 Batch::Stopped => break,
1337 Batch::Completed => {
1338 report.completed.push(def.name.to_owned());
1339 break;
1340 }
1341 Batch::Advanced { repaired } => {
1342 report.rows_repaired += repaired;
1343 report.batches_run += 1;
1344 batches += 1;
1345 }
1346 }
1347 }
1348 }
1349 Ok(report)
1350}
1351
1352/// How many times a batch that lost a lock race is retried before the error
1353/// surfaces. Each batch is its own transaction, rolled back on the error and
1354/// resumed from the committed checkpoint, so a retry repeats no work.
1355const LOCK_CONTENTION_RETRIES: u32 = 5;
1356
1357/// [`run_one_batch`], retried when the database aborted the batch to break a
1358/// lock cycle or a serialisation conflict with a concurrent writer.
1359///
1360/// A sweep that stopped on the first deadlock would stay stopped until the
1361/// next boot, leaving the derivation `running` and stale. The batch is the
1362/// unit of retry: it committed nothing, so running it again is exactly what
1363/// the next boot would have done, only sooner.
1364async fn run_one_batch_retrying(
1365 conn: &mut RuntimeConnection,
1366 def: &'static DerivationDef,
1367 batch_size: i64,
1368) -> AutumnResult<Batch> {
1369 let mut attempt = 0;
1370 loop {
1371 match run_one_batch(conn, def, batch_size).await {
1372 Err(error) if attempt < LOCK_CONTENTION_RETRIES && is_lock_contention(&error) => {
1373 attempt += 1;
1374 tracing::warn!(
1375 derivation = def.name,
1376 attempt,
1377 error = %error,
1378 "backfill batch lost a lock race; retrying"
1379 );
1380 tokio::time::sleep(std::time::Duration::from_millis(25 * u64::from(attempt))).await;
1381 }
1382 outcome => return outcome,
1383 }
1384 }
1385}
1386
1387/// Whether `error` is the database reporting that `_autumn_derivations` does
1388/// not exist: Postgres `42P01` (`relation "..." does not exist`) or `SQLite`'s
1389/// `no such table`.
1390fn is_missing_state_table(error: &AutumnError) -> bool {
1391 let message = error.to_string();
1392 message.contains(STATE_TABLE)
1393 && (message.contains("does not exist") || message.contains("no such table"))
1394}
1395
1396/// Put one derivation back on the backfill queue under its current definition.
1397///
1398/// The row keeps its hash and drops its checkpoint, so the next
1399/// [`run_backfill`] (or the next boot) sweeps
1400/// the whole parent table again. This is the operator's step after a **rolling
1401/// deployment that changed a definition**: replicas still on the old binary
1402/// keep applying deltas under the old filter or transform while the new
1403/// binary's backfill runs, and a parent such a replica touches after the sweep
1404/// has passed it stays wrong until it is swept again. Once no old replica
1405/// writes, one re-sweep settles every parent. The sweep is idempotent, so
1406/// calling this on a healthy derivation costs one pass and changes nothing.
1407///
1408/// Prefer [`recompute`] when the table is small enough to repair in one call;
1409/// this is the checkpointed, resumable form for a large one.
1410///
1411/// # Errors
1412///
1413/// Returns an error when `name` is not a registered derivation, when the
1414/// registered set cannot be reconciled (the check boot runs, so a colliding
1415/// registry is refused here too) or when the state row cannot be written. A
1416/// derivation with no state row yet (a first boot has not run) is enqueued.
1417pub async fn resweep(conn: &mut RuntimeConnection, name: &str) -> AutumnResult<()> {
1418 let def = select_checked(®istered_derivations(), name)?;
1419 enqueue(conn, def).await
1420}
1421
1422// ── Repair and status ───────────────────────────────────────────────────────
1423
1424/// Rebuild one derivation's column from the source of truth, everywhere.
1425///
1426/// The same batched, lock-then-assign sweep `recompute_counter_caches` runs, so
1427/// it is idempotent and safe against live traffic. Returns the number of parent
1428/// rows actually repaired. A healthy derivation returns `0` and writes nothing.
1429///
1430/// # Errors
1431///
1432/// Returns an error when `name` is not a registered derivation or when the
1433/// registered set cannot be reconciled (the check boot runs, so a colliding
1434/// registry is refused here rather than swept from one side), and propagates
1435/// any database error from the sweep.
1436pub async fn recompute(conn: &mut RuntimeConnection, name: &str) -> AutumnResult<usize> {
1437 let def = select_checked(®istered_derivations(), name)?;
1438 crate::counter_cache::recompute_view(conn, &def.sql_view(), None).await
1439}
1440
1441/// How many parent rows disagree with the source of truth, up to
1442/// [`DRIFT_SCAN_LIMIT`].
1443///
1444/// One aggregate statement. The cap is what keeps it usable on a large table: a
1445/// figure equal to the limit means "at least that many", which is all an
1446/// operator needs to decide to recompute. Still an operator measurement rather
1447/// than a request-path one.
1448///
1449/// # Errors
1450///
1451/// Propagates any database error from the `SELECT`. A missing derived column is
1452/// the common case, and it is reported per derivation rather than failing the
1453/// whole status read (see [`derivation_status`]).
1454pub async fn drift(conn: &mut RuntimeConnection, def: &DerivationDef) -> AutumnResult<i64> {
1455 let sql = crate::counter_cache::drift_sql(&def.sql_view(), DRIFT_SCAN_LIMIT);
1456 Ok(diesel::sql_query(sql)
1457 .get_result::<CountRow>(conn)
1458 .await
1459 .map_err(AutumnError::from)?
1460 .count)
1461}
1462
1463/// Report every registered derivation: its definition, its recorded backfill
1464/// state, and its current drift. Then report every state row this binary
1465/// declares no derivation for.
1466///
1467/// A derivation with no state row reports `stored_hash: None` and
1468/// `backfill_state: None`, which is the shape a binary that has not booted
1469/// against this database yet produces. A state row with no derivation reports
1470/// [`BackfillState::Unregistered`] and `definition_hash: None`, which is what a
1471/// removed or renamed definition leaves behind. Such a row is reported rather
1472/// than deleted, because only an operator can tell a removed derivation apart
1473/// from a rolling deploy that has not finished.
1474///
1475/// A failing drift scan is reported on its own row in
1476/// [`DerivationStatus::drift_error`] and does not stop the others. A derived
1477/// column that is not there yet is the common case, and it must not hide the
1478/// derivations that are healthy.
1479///
1480/// Rows come back sorted by name.
1481///
1482/// # Errors
1483///
1484/// Propagates any database error from the state read, and returns an error when
1485/// the registry carries a duplicate name or two derivations on one parent
1486/// column.
1487pub async fn derivation_status(
1488 conn: &mut RuntimeConnection,
1489) -> AutumnResult<Vec<DerivationStatus>> {
1490 let defs = registered_derivations();
1491 check_registry(&defs)?;
1492
1493 // A binary with no derivation never applies the state-table migration
1494 // (see `has_derivations`), so an app that never had one has no table to
1495 // read: that is an empty report. An app whose last derivation was removed
1496 // still has the table, and its leftover rows are exactly what an operator
1497 // needs to see, so only the missing table is forgiven, and only then.
1498 let state = match load_state(conn).await {
1499 Ok(state) => state,
1500 Err(error) if defs.is_empty() && is_missing_state_table(&error) => return Ok(Vec::new()),
1501 Err(error) => return Err(error),
1502 };
1503 let mut out = Vec::with_capacity(defs.len() + state.len());
1504 for def in &defs {
1505 let row = state.get(def.name);
1506 let (drifted, drift_error) = match drift(conn, def).await {
1507 Ok(count) => (Some(count), None),
1508 Err(error) => (None, Some(error.to_string())),
1509 };
1510 out.push(DerivationStatus {
1511 name: def.name.to_owned(),
1512 definition_hash: Some(def.definition_hash()),
1513 stored_hash: row.map(|row| row.definition_hash.clone()),
1514 backfill_state: row.and_then(|row| BackfillState::parse(&row.backfill_state)),
1515 checkpoint: row.and_then(|row| row.checkpoint),
1516 backfilled_rows: row.map_or(0, |row| row.backfilled_rows),
1517 updated_at: row.and_then(|row| row.updated_at.clone()),
1518 drift: drifted,
1519 drift_error,
1520 });
1521 }
1522 for (name, row) in &state {
1523 if defs.iter().any(|def| def.name == name.as_str()) {
1524 continue;
1525 }
1526 out.push(DerivationStatus {
1527 name: name.clone(),
1528 definition_hash: None,
1529 stored_hash: Some(row.definition_hash.clone()),
1530 backfill_state: Some(BackfillState::Unregistered),
1531 checkpoint: row.checkpoint,
1532 backfilled_rows: row.backfilled_rows,
1533 updated_at: row.updated_at.clone(),
1534 drift: None,
1535 drift_error: None,
1536 });
1537 }
1538 out.sort_by(|left, right| left.name.cmp(&right.name));
1539 Ok(out)
1540}
1541
1542#[cfg(test)]
1543mod tests {
1544 use super::*;
1545
1546 fn count_def() -> DerivationDef {
1547 DerivationDef {
1548 name: "dv_posts.published_comment_count",
1549 model: "DvComment",
1550 child_table: "dv_comments",
1551 child_pk: "id",
1552 child_soft_delete: false,
1553 fk_column: "post_id",
1554 parent_table: "dv_posts",
1555 parent_pk: "id",
1556 column: "published_comment_count",
1557 transform: "count",
1558 filter: "published",
1559 filter_sql: " AND ({c}.\"published\" = TRUE)",
1560 contrib_sql: "1",
1561 tenant_column: None,
1562 module_path: "tests::model_derivation",
1563 file: "model_derivation.rs",
1564 line: 42,
1565 }
1566 }
1567
1568 fn sum_def() -> DerivationDef {
1569 DerivationDef {
1570 name: "dv_posts.visible_score",
1571 column: "visible_score",
1572 transform: "sum(score)",
1573 filter: "published && score > 0",
1574 filter_sql: " AND ({c}.\"published\" = TRUE) AND ({c}.\"score\" > 0)",
1575 contrib_sql: "{c}.\"score\"",
1576 ..count_def()
1577 }
1578 }
1579
1580 #[test]
1581 fn the_definition_hash_is_stable_for_identical_definitions() {
1582 assert_eq!(count_def().definition_hash(), count_def().definition_hash());
1583 assert_eq!(
1584 count_def().definition_hash().len(),
1585 64,
1586 "sha256 renders as 64 hex characters"
1587 );
1588 assert!(
1589 count_def()
1590 .definition_hash()
1591 .chars()
1592 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
1593 "the hash is lowercase hex"
1594 );
1595 assert_ne!(count_def().definition_hash(), sum_def().definition_hash());
1596 }
1597
1598 /// Characterization for the Echo clone class: `definition_hash` renders
1599 /// its digest through this file's own `hex_lower`, one of six
1600 /// byte-identical copies across the crate. Pinned before `hex_lower` is
1601 /// replaced by the `hex` crate (already a dependency) at its call site.
1602 #[test]
1603 fn definition_hash_is_pinned_hex() {
1604 assert_eq!(
1605 count_def().definition_hash(),
1606 "ec7890d9c13320917bd7ed3fddc72dafa9e7bd265dd598014bc4236895b53875"
1607 );
1608 }
1609
1610 #[test]
1611 fn the_definition_hash_tracks_every_part_of_the_lowered_shape() {
1612 let base = count_def().definition_hash();
1613
1614 // The filter is the whole point: dropping it changes which rows count.
1615 let mut unfiltered = count_def();
1616 unfiltered.filter_sql = "";
1617 assert_ne!(unfiltered.definition_hash(), base, "filter_sql");
1618
1619 // The contribution decides the weight.
1620 let mut weighted = count_def();
1621 weighted.contrib_sql = "{c}.\"score\"";
1622 assert_ne!(weighted.definition_hash(), base, "contrib_sql");
1623
1624 // The output column and the aggregate are both part of the shape.
1625 let mut renamed_column = count_def();
1626 renamed_column.column = "other_count";
1627 assert_ne!(renamed_column.definition_hash(), base, "column");
1628
1629 let mut summed = count_def();
1630 summed.transform = "sum(score)";
1631 assert_ne!(summed.definition_hash(), base, "transform");
1632
1633 // So are the tables it reads and the tenant it is confined to.
1634 let mut tenanted = count_def();
1635 tenanted.tenant_column = Some("tenant_id");
1636 assert_ne!(tenanted.definition_hash(), base, "tenant_column");
1637
1638 let mut soft = count_def();
1639 soft.child_soft_delete = true;
1640 assert_ne!(soft.definition_hash(), base, "child_soft_delete");
1641 }
1642
1643 #[test]
1644 fn the_definition_hash_ignores_where_the_derivation_was_written() {
1645 // A rename, a move to another file, or a reformatted filter must not
1646 // enqueue a backfill of a value that did not change.
1647 let base = count_def().definition_hash();
1648 let mut moved = count_def();
1649 moved.name = "renamed";
1650 moved.model = "OtherComment";
1651 moved.module_path = "elsewhere";
1652 moved.file = "elsewhere.rs";
1653 moved.line = 9001;
1654 moved.filter = "published /* reformatted */";
1655 assert_eq!(moved.definition_hash(), base);
1656 }
1657
1658 #[test]
1659 fn a_filtered_count_recompute_counts_only_matching_rows() {
1660 let sql = crate::counter_cache::recompute_update_sql(&count_def().sql_view(), "1,2");
1661 assert_eq!(
1662 sql,
1663 "UPDATE \"dv_posts\" SET \"published_comment_count\" = \
1664 (SELECT COUNT(*) FROM \"dv_comments\" AS __autumn_cc_child \
1665 WHERE __autumn_cc_child.\"post_id\" = \"dv_posts\".\"id\" \
1666 AND (__autumn_cc_child.\"published\" = TRUE)) \
1667 WHERE \"dv_posts\".\"id\" IN (1,2) \
1668 AND \"dv_posts\".\"published_comment_count\" "
1669 .to_owned()
1670 + IS_DISTINCT_FROM
1671 + " (SELECT COUNT(*) FROM \"dv_comments\" AS __autumn_cc_child \
1672 WHERE __autumn_cc_child.\"post_id\" = \"dv_posts\".\"id\" \
1673 AND (__autumn_cc_child.\"published\" = TRUE))"
1674 );
1675 }
1676
1677 #[test]
1678 fn a_sum_recompute_sums_the_contribution() {
1679 let sql = crate::counter_cache::recompute_update_sql(&sum_def().sql_view(), "5");
1680 let score_sum = crate::counter_cache::sum_expr("__autumn_cc_child.\"score\"");
1681 assert!(
1682 sql.starts_with(&format!(
1683 "UPDATE \"dv_posts\" SET \"visible_score\" = \
1684 (SELECT {score_sum} \
1685 FROM \"dv_comments\" AS __autumn_cc_child"
1686 )),
1687 "{sql}"
1688 );
1689 assert!(
1690 sql.contains(
1691 "AND (__autumn_cc_child.\"published\" = TRUE) \
1692 AND (__autumn_cc_child.\"score\" > 0))"
1693 ),
1694 "both conjuncts of the filter must survive: {sql}"
1695 );
1696 assert!(sql.contains("\"dv_posts\".\"id\" IN (5)"), "{sql}");
1697 }
1698
1699 #[test]
1700 fn drift_is_one_aggregate_over_the_parent_table() {
1701 let sql = crate::counter_cache::drift_sql(&sum_def().sql_view(), DRIFT_SCAN_LIMIT);
1702 assert!(
1703 sql.starts_with("SELECT COUNT(*) AS count FROM (SELECT 1 AS drifted FROM "),
1704 "{sql}"
1705 );
1706 assert!(
1707 sql.contains(&format!("\"visible_score\" {IS_DISTINCT_FROM}")),
1708 "{sql}"
1709 );
1710 assert!(
1711 sql.contains(&format!(
1712 "(SELECT * FROM \"dv_posts\" ORDER BY \"dv_posts\".\"id\" DESC LIMIT {DRIFT_SCAN_LIMIT}) AS \"dv_posts\""
1713 )),
1714 "the parents examined are capped, so the actuator cannot hang on a huge table: {sql}"
1715 );
1716 assert!(sql.ends_with(") AS __autumn_cc_drift"), "{sql}");
1717 }
1718
1719 #[test]
1720 fn a_backfill_pages_a_thousand_parents_at_a_time_by_default() {
1721 let options = BackfillOptions::default();
1722 assert_eq!(options.batch_size, 1000);
1723 assert!(options.max_batches.is_none(), "the default runs to the end");
1724 }
1725
1726 #[test]
1727 fn a_backfill_state_round_trips_through_its_stored_spelling() {
1728 for state in [
1729 BackfillState::Pending,
1730 BackfillState::Running,
1731 BackfillState::Complete,
1732 ] {
1733 assert_eq!(BackfillState::parse(state.as_str()), Some(state));
1734 assert_eq!(
1735 serde_json::to_string(&state).expect("serialize"),
1736 format!("\"{state}\"")
1737 );
1738 assert_ne!(state.is_sweepable(), state == BackfillState::Complete);
1739 }
1740 assert_eq!(BackfillState::parse("done"), None);
1741
1742 // `unregistered` is reported, never stored: the state table's `CHECK`
1743 // does not admit it, so a row carrying it is a corrupt row.
1744 assert_eq!(BackfillState::Unregistered.as_str(), "unregistered");
1745 assert_eq!(BackfillState::parse("unregistered"), None);
1746 assert!(!BackfillState::Unregistered.is_sweepable());
1747 assert_eq!(
1748 serde_json::to_string(&BackfillState::Unregistered).expect("serialize"),
1749 "\"unregistered\""
1750 );
1751 }
1752
1753 #[test]
1754 fn two_derivations_on_one_parent_column_are_rejected() {
1755 // Both would apply their own delta on every mutation, so the column
1756 // would count twice. That is data corruption, not staleness, which is
1757 // why the boot path refuses to start on it.
1758 let first = count_def();
1759 let mut second = count_def();
1760 second.name = "dv_posts.published_comment_count_again";
1761 second.model = "DvOtherComment";
1762 second.module_path = "other::module";
1763 second.filter_sql = "";
1764 let err = check_unique_columns(&[&first, &second], &[])
1765 .expect_err("one column cannot carry two derivations");
1766 let message = err.to_string();
1767 assert!(
1768 message.contains("dv_posts.published_comment_count"),
1769 "{message}"
1770 );
1771 assert!(message.contains("other::module"), "{message}");
1772 assert!(message.contains("count twice"), "{message}");
1773
1774 // A second derivation on another column of the same parent is fine.
1775 let mut sibling = count_def();
1776 sibling.name = "dv_posts.visible_score";
1777 sibling.column = "visible_score";
1778 check_unique_columns(&[&first, &sibling], &[]).expect("two columns, two derivations");
1779
1780 // `check_registry` runs both checks, so it catches this one too.
1781 check_registry(&[&first, &second]).expect_err("the registry check covers columns");
1782 }
1783
1784 #[cfg(not(feature = "sqlite"))]
1785 #[test]
1786 fn postgres_ident_key_truncates_to_63_bytes_on_a_char_boundary() {
1787 // Postgres truncates every identifier to `NAMEDATALEN - 1` (63 on a
1788 // stock build) bytes; quoting does not exempt it. The registry key
1789 // must be the physical spelling, or two names that agree on their
1790 // first 63 bytes look distinct here and collide in the database.
1791 let long_a = format!("{}{}", "a".repeat(63), "x");
1792 let long_b = format!("{}{}", "a".repeat(63), "y");
1793 assert_eq!(postgres_ident_key(&long_a), "a".repeat(63));
1794 assert_eq!(postgres_ident_key(&long_a), postgres_ident_key(&long_b));
1795 // Short names and names exactly at the bound are untouched.
1796 assert_eq!(postgres_ident_key("score"), "score");
1797 assert_eq!(postgres_ident_key(&"a".repeat(63)), "a".repeat(63));
1798 // A multi-byte character at the boundary is not split: the key keeps
1799 // whole characters up to the byte limit.
1800 let multi = format!("{}{}", "é".repeat(31), "éé"); // 33 é = 66 bytes
1801 let key = postgres_ident_key(&multi);
1802 assert!(key.len() <= 63, "key is {key:?}");
1803 assert!(key.chars().all(|c| c == 'é'), "key is {key:?}");
1804 assert_eq!(key, "é".repeat(31));
1805 }
1806
1807 #[cfg(not(feature = "sqlite"))]
1808 #[test]
1809 fn overlong_derivation_columns_that_share_63_bytes_are_rejected() {
1810 // Issue #2664: on Postgres these two spellings are one column, so the
1811 // boot-time guard must refuse the pair instead of letting both
1812 // maintainers double-apply deltas to the same physical column.
1813 let mut first = count_def();
1814 first.column = Box::leak(format!("{}{}", "a".repeat(63), "x").into_boxed_str());
1815 let mut second = count_def();
1816 second.name = "dv_posts.another_long_derivation";
1817 second.model = "DvOtherComment";
1818 second.module_path = "other::module";
1819 second.column = Box::leak(format!("{}{}", "a".repeat(63), "y").into_boxed_str());
1820 second.filter_sql = "";
1821 let err = check_unique_columns(&[&first, &second], &[])
1822 .expect_err("two names truncating to one column cannot both maintain it");
1823 let message = err.to_string();
1824 assert!(message.contains("count twice"), "{message}");
1825
1826 // Two distinct names that are each exactly 63 bytes are two columns.
1827 let mut third = count_def();
1828 third.column = Box::leak(format!("{}{}", "a".repeat(62), "x").into_boxed_str());
1829 check_unique_columns(&[&first, &third], &[])
1830 .expect("distinct 63-byte names are distinct columns");
1831 }
1832
1833 #[cfg(feature = "sqlite")]
1834 #[test]
1835 fn sqlite_has_no_63_byte_identifier_limit_so_long_names_coexist() {
1836 // The mirror of the Postgres truncation above: SQLite identifiers have
1837 // no physical length cap, so the full spellings are compared and two
1838 // names that differ only past byte 63 are two columns there.
1839 let mut first = count_def();
1840 first.column = Box::leak(format!("{}{}", "a".repeat(63), "x").into_boxed_str());
1841 let mut second = count_def();
1842 second.column = Box::leak(format!("{}{}", "a".repeat(63), "y").into_boxed_str());
1843 check_unique_columns(&[&first, &second], &[])
1844 .expect("on SQLite the full spellings are distinct columns");
1845 }
1846
1847 #[test]
1848 fn lock_contention_is_recognised_and_other_errors_are_not() {
1849 let contention = [
1850 "deadlock detected",
1851 "ERROR: could not serialize access due to concurrent update",
1852 "database is locked",
1853 ];
1854 for message in contention {
1855 let error = AutumnError::from(std::io::Error::other(message));
1856 assert!(is_lock_contention(&error), "{message}");
1857 }
1858 let other = AutumnError::from(std::io::Error::other(
1859 "column \"published_comment_count\" does not exist",
1860 ));
1861 assert!(!is_lock_contention(&other));
1862 }
1863
1864 /// `posts.score` and `posts.Score`: one column on `SQLite`, two on Postgres.
1865 fn case_variant_pair() -> (DerivationDef, DerivationDef) {
1866 let lower = DerivationDef {
1867 name: "dv_posts.score",
1868 column: "score",
1869 ..count_def()
1870 };
1871 let upper = DerivationDef {
1872 name: "dv_posts.Score",
1873 column: "Score",
1874 ..count_def()
1875 };
1876 (lower, upper)
1877 }
1878
1879 /// The macro refuses `column = "id"` by spelling; the registry refuses it
1880 /// under the backend's identifier rules, so on `SQLite` `"ID"` is the
1881 /// primary key too, while on Postgres it is a distinct quoted column.
1882 #[test]
1883 fn a_derivation_cannot_maintain_the_parent_primary_key_under_any_spelling() {
1884 let exact = DerivationDef {
1885 name: "dv_posts.id",
1886 column: "id",
1887 ..count_def()
1888 };
1889 let message = check_primary_key_columns(&[&exact])
1890 .expect_err("the primary key is never a derivation column")
1891 .to_string();
1892 assert!(message.contains("primary"), "{message}");
1893 let upper = DerivationDef {
1894 name: "dv_posts.ID",
1895 column: "ID",
1896 ..count_def()
1897 };
1898 let folded = check_primary_key_columns(&[&upper]);
1899 if cfg!(feature = "sqlite") {
1900 folded.expect_err("`\"ID\"` is `id` on SQLite");
1901 } else {
1902 folded.expect("`\"ID\"` is its own quoted column on Postgres");
1903 }
1904 }
1905
1906 /// `recompute` and `resweep` select their definition through the same
1907 /// registry check boot runs, so a registry with a column collision is
1908 /// refused before any sweep, and a clean one selects by name.
1909 #[test]
1910 fn a_repair_selects_its_definition_only_from_a_checked_registry() {
1911 let first = DerivationDef {
1912 name: "dv_posts.count_a",
1913 ..count_def()
1914 };
1915 let second = DerivationDef {
1916 name: "dv_posts.count_b",
1917 ..count_def()
1918 };
1919 let message = select_checked(&[&first, &second], "dv_posts.count_a")
1920 .expect_err("two derivations on one column are refused before the sweep")
1921 .to_string();
1922 assert!(message.contains("count twice"), "{message}");
1923
1924 let only = count_def();
1925 let selected = select_checked(&[&only], only.name).expect("a clean registry selects");
1926 assert_eq!(selected.name, only.name);
1927 let missing = select_checked(&[&only], "dv_posts.nowhere")
1928 .expect_err("an unknown name is refused")
1929 .to_string();
1930 assert!(missing.contains("not a derivation registered"), "{missing}");
1931 }
1932
1933 #[cfg(feature = "sqlite")]
1934 #[test]
1935 fn sqlite_folds_identifier_case_in_the_registry_checks() {
1936 // Quoted identifiers stay case-insensitive on SQLite, so two spellings
1937 // of one column are one column: both would update it, and their
1938 // backfills would overwrite each other.
1939 let (lower, upper) = case_variant_pair();
1940 let message = check_unique_columns(&[&lower, &upper], &[])
1941 .expect_err("two spellings of one column collide")
1942 .to_string();
1943 assert!(message.contains("dv_posts.Score"), "{message}");
1944 let claim = CounterCacheClaim {
1945 model: "DvLike",
1946 child_table: "DV_LIKES",
1947 parent_table: "DV_POSTS",
1948 column: "SCORE",
1949 direct_sql: true,
1950 module_path: "likes::module",
1951 };
1952 check_unique_columns(&[&lower], &[claim]).expect_err("a claim collides across case too");
1953 // A source read under another spelling is the same source.
1954 let reads_upper = DerivationDef {
1955 name: "dv_posts.sum_of_score",
1956 column: "sum_of_score",
1957 child_table: "DV_COMMENTS",
1958 transform: "sum(Score)",
1959 contrib_sql: "{c}.\"Score\"",
1960 ..count_def()
1961 };
1962 let maintains_lower = DerivationDef {
1963 name: "dv_comments.score",
1964 parent_table: "dv_comments",
1965 column: "score",
1966 ..count_def()
1967 };
1968 check_source_columns(&[&maintains_lower, &reads_upper], &[])
1969 .expect_err("a source matched across case is still maintained");
1970 }
1971
1972 #[cfg(not(feature = "sqlite"))]
1973 #[test]
1974 fn postgres_keeps_identifier_case_in_the_registry_checks() {
1975 // Every statement quotes its identifiers, and quoted identifiers are
1976 // case-sensitive on Postgres: `"Score"` and `"score"` are two columns.
1977 let (lower, upper) = case_variant_pair();
1978 check_unique_columns(&[&lower, &upper], &[]).expect("two columns coexist");
1979 }
1980
1981 #[test]
1982 fn a_self_referential_derivation_cannot_read_its_own_column_at_boot_either() {
1983 // The macro refuses the spelled-out case; the registry repeats it so a
1984 // binary the macro did not see (or a backend whose identifiers fold
1985 // case) cannot get one past the boot.
1986 let self_read = DerivationDef {
1987 name: "dv_comments.score",
1988 parent_table: "dv_comments",
1989 column: "score",
1990 transform: "sum(score)",
1991 filter: "",
1992 filter_sql: "",
1993 contrib_sql: "{c}.\"score\"",
1994 ..count_def()
1995 };
1996 let message = check_source_columns(&[&self_read], &[])
1997 .expect_err("a derivation onto its own table cannot read its column")
1998 .to_string();
1999 assert!(
2000 message.contains("reads the column it maintains"),
2001 "{message}"
2002 );
2003 // The grouping key and the tenant column are read implicitly too.
2004 let own_fk = DerivationDef {
2005 column: "post_id",
2006 transform: "count",
2007 contrib_sql: "1",
2008 ..self_read
2009 };
2010 check_source_columns(&[&own_fk], &[]).expect_err("the grouping key is a source");
2011 // Reading another column of its own table is the documented shape.
2012 let reply_count = DerivationDef {
2013 column: "reply_count",
2014 transform: "count",
2015 contrib_sql: "1",
2016 filter: "published",
2017 filter_sql: " AND ({c}.\"published\" = TRUE)",
2018 ..self_read
2019 };
2020 check_source_columns(&[&reply_count], &[]).expect("a self-referential count is fine");
2021 }
2022
2023 #[cfg(feature = "sqlite")]
2024 #[test]
2025 fn sqlite_folds_identifier_case_in_the_self_reference_check() {
2026 // `Nodes`/`nodes` and `Score`/`score` are one table and one column on
2027 // SQLite, so a definition the macro saw as targeting another table
2028 // reads its own maintained column after all.
2029 let folded = DerivationDef {
2030 name: "Nodes.Score",
2031 child_table: "nodes",
2032 parent_table: "Nodes",
2033 column: "Score",
2034 transform: "sum(score)",
2035 filter: "",
2036 filter_sql: "",
2037 contrib_sql: "{c}.\"score\"",
2038 ..count_def()
2039 };
2040 check_source_columns(&[&folded], &[]).expect_err("case variants are one column on SQLite");
2041 }
2042
2043 /// The control set carries a copy of the state-table migration so
2044 /// `autumn migrate` creates it; the copy must not drift from the set the
2045 /// runtime and the shard migrator apply.
2046 #[test]
2047 fn the_control_copy_of_the_state_migration_matches_the_standalone_set() {
2048 assert_eq!(
2049 include_str!("../migrations/20260907101530_create_derivations/up.sql"),
2050 include_str!("../derivation_migrations/20260907101530_create_derivations/up.sql"),
2051 );
2052 assert_eq!(
2053 include_str!("../migrations/20260907101530_create_derivations/down.sql"),
2054 include_str!("../derivation_migrations/20260907101530_create_derivations/down.sql"),
2055 );
2056 }
2057
2058 #[test]
2059 fn source_columns_are_read_off_the_lowered_sql() {
2060 // The grouping key first, then the SQL's columns.
2061 assert_eq!(source_columns(&count_def()), ["post_id", "published"]);
2062 assert_eq!(
2063 source_columns(&sum_def()),
2064 ["post_id", "score", "published", "score"]
2065 );
2066 let cast = DerivationDef {
2067 filter_sql: " AND (CAST({c}.\"status\" AS TEXT) = 'featured' {bin})",
2068 tenant_column: Some("org_id"),
2069 ..count_def()
2070 };
2071 assert_eq!(source_columns(&cast), ["post_id", "org_id", "status"]);
2072 }
2073
2074 #[test]
2075 fn a_derivation_cannot_group_or_scope_by_a_column_another_derivation_maintains() {
2076 // A leaf maintaining `dv_comments.post_id` re-parents comments under
2077 // direct SQL, so a derivation grouping by it would never see the move.
2078 let reparenting = DerivationDef {
2079 name: "dv_comments.post_id",
2080 parent_table: "dv_comments",
2081 column: "post_id",
2082 fk_column: "thread_id",
2083 filter: "",
2084 filter_sql: "",
2085 ..count_def()
2086 };
2087 let message = check_source_columns(&[&reparenting, &count_def()], &[])
2088 .expect_err("the grouping key is a source")
2089 .to_string();
2090 assert!(message.contains("dv_comments.post_id"), "{message}");
2091
2092 let retenanting = DerivationDef {
2093 name: "dv_comments.org_id",
2094 parent_table: "dv_comments",
2095 column: "org_id",
2096 filter: "",
2097 filter_sql: "",
2098 ..count_def()
2099 };
2100 let scoped = DerivationDef {
2101 tenant_column: Some("org_id"),
2102 ..count_def()
2103 };
2104 check_source_columns(&[&retenanting, &scoped], &[])
2105 .expect_err("the tenant column is a source");
2106 check_source_columns(&[&retenanting, &count_def()], &[])
2107 .expect("an unscoped derivation does not read the tenant column");
2108 }
2109
2110 #[test]
2111 fn a_derivation_cannot_read_a_column_another_derivation_maintains_on_its_table() {
2112 // `dv_comments.child_score` moves under the leaf derivation's direct
2113 // SQL, so the post's `sum(child_score)` would never hear of a change.
2114 let leaf = DerivationDef {
2115 name: "dv_comments.child_score",
2116 parent_table: "dv_comments",
2117 column: "child_score",
2118 transform: "sum(score)",
2119 filter: "",
2120 filter_sql: "",
2121 contrib_sql: "{c}.\"score\"",
2122 ..count_def()
2123 };
2124 let grand = DerivationDef {
2125 name: "dv_posts.grand_score",
2126 column: "grand_score",
2127 transform: "sum(child_score)",
2128 filter: "",
2129 filter_sql: "",
2130 contrib_sql: "{c}.\"child_score\"",
2131 ..count_def()
2132 };
2133 let message = check_source_columns(&[&leaf, &grand], &[])
2134 .expect_err("a maintained column is not a source")
2135 .to_string();
2136 assert!(message.contains("dv_posts.grand_score"), "{message}");
2137 assert!(message.contains("dv_comments.child_score"), "{message}");
2138
2139 // A filter naming it is the same read.
2140 let hot = DerivationDef {
2141 name: "dv_posts.hot_comment_count",
2142 column: "hot_comment_count",
2143 filter: "child_score > 0",
2144 filter_sql: " AND ({c}.\"child_score\" > 0)",
2145 ..count_def()
2146 };
2147 check_source_columns(&[&leaf, &hot], &[]).expect_err("a filter read is a read");
2148
2149 // A derivation over a column nothing maintains coexists with the leaf.
2150 check_source_columns(&[&leaf, &sum_def()], &[]).expect("unmaintained sources are fine");
2151 }
2152
2153 #[test]
2154 fn a_derivation_cannot_read_a_column_a_counter_cache_maintains_but_may_read_a_hooked_one() {
2155 let def = sum_def();
2156 let tally = CounterCacheClaim {
2157 model: "DvVote",
2158 child_table: "dv_votes",
2159 parent_table: "dv_comments",
2160 column: "score",
2161 direct_sql: true,
2162 module_path: "votes::module",
2163 };
2164 let message = check_source_columns(&[&def], &[tally])
2165 .expect_err("a counter cache's column is not a source")
2166 .to_string();
2167 assert!(message.contains("votes::module"), "{message}");
2168 assert!(message.contains("dv_posts.visible_score"), "{message}");
2169
2170 // The `#[lock_version]` token and the tenant discriminator move only
2171 // under the repository's hooked paths, so reading one is fine.
2172 let hooked = CounterCacheClaim {
2173 direct_sql: false,
2174 ..tally
2175 };
2176 check_source_columns(&[&def], &[hooked]).expect("hooked columns may be read");
2177
2178 // The same column name on another table is unrelated.
2179 let elsewhere = CounterCacheClaim {
2180 parent_table: "dv_posts",
2181 ..tally
2182 };
2183 check_source_columns(&[&def], &[elsewhere]).expect("another table's column is unrelated");
2184 }
2185
2186 #[test]
2187 fn a_derivation_on_a_plain_counter_cache_column_is_rejected() {
2188 // A plain `counter_cache` has no `DerivationDef`, so without its claim
2189 // the column check would see one derivation and pass. Both would then
2190 // apply their own delta, and the derivation's backfill would overwrite
2191 // the counter cache's rows with a total over the derivation's source
2192 // alone: corruption on every boot, not a stale figure.
2193 let def = count_def();
2194 let claim = CounterCacheClaim {
2195 model: "DvLike",
2196 child_table: "dv_likes",
2197 parent_table: def.parent_table,
2198 column: def.column,
2199 direct_sql: true,
2200 module_path: "likes::module",
2201 };
2202 let err = check_unique_columns(&[&def], &[claim])
2203 .expect_err("a counter cache and a derivation cannot share a column");
2204 let message = err.to_string();
2205 assert!(
2206 message.contains("dv_posts.published_comment_count"),
2207 "{message}"
2208 );
2209 assert!(message.contains("likes::module"), "{message}");
2210 assert!(message.contains("dv_likes"), "{message}");
2211 assert!(message.contains("counter cache"), "{message}");
2212
2213 // A counter cache on another column of the same parent is fine.
2214 let other = CounterCacheClaim {
2215 column: "like_count",
2216 ..claim
2217 };
2218 check_unique_columns(&[&def], &[other]).expect("different columns coexist");
2219
2220 // A `#[commentable(counter_cache = ...)]` parent's column is a claim of
2221 // the same kind, spelled by its descriptor rather than by `#[model]`.
2222 let commentable = CounterCacheClaim {
2223 model: "app::models::DvPost",
2224 child_table: "comments",
2225 module_path: "#[commentable]",
2226 ..claim
2227 };
2228 let message = check_unique_columns(&[&def], &[commentable])
2229 .expect_err("a commentable counter and a derivation cannot share a column")
2230 .to_string();
2231 assert!(
2232 message.contains("#[commentable]::app::models::DvPost"),
2233 "{message}"
2234 );
2235 }
2236
2237 #[test]
2238 fn a_missing_state_table_is_recognised_and_other_errors_are_not() {
2239 for message in [
2240 "relation \"_autumn_derivations\" does not exist",
2241 "no such table: _autumn_derivations",
2242 ] {
2243 let error = AutumnError::from(std::io::Error::other(message));
2244 assert!(is_missing_state_table(&error), "{message}");
2245 }
2246 for message in ["relation \"posts\" does not exist", "deadlock detected"] {
2247 let error = AutumnError::from(std::io::Error::other(message));
2248 assert!(!is_missing_state_table(&error), "{message}");
2249 }
2250 }
2251
2252 #[test]
2253 fn a_status_row_serializes_every_field_the_actuator_documents() {
2254 let status = DerivationStatus {
2255 name: "dv_posts.published_comment_count".to_owned(),
2256 definition_hash: Some(count_def().definition_hash()),
2257 stored_hash: None,
2258 backfill_state: Some(BackfillState::Pending),
2259 checkpoint: None,
2260 backfilled_rows: 0,
2261 updated_at: None,
2262 drift: Some(DRIFT_SCAN_LIMIT),
2263 drift_error: None,
2264 };
2265 let json = serde_json::to_value(&status).expect("serialize");
2266 let object = json.as_object().expect("a status is a JSON object");
2267 let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
2268 keys.sort_unstable();
2269 assert_eq!(
2270 keys,
2271 vec![
2272 "backfill_state",
2273 "backfilled_rows",
2274 "checkpoint",
2275 "definition_hash",
2276 "drift",
2277 "drift_error",
2278 "name",
2279 "stored_hash",
2280 "updated_at",
2281 ]
2282 );
2283 // An absent value is reported as `null` rather than dropped, so a
2284 // consumer can tell "not measured" from "zero".
2285 assert!(object["stored_hash"].is_null());
2286 assert!(object["drift_error"].is_null());
2287 assert_eq!(object["drift"], serde_json::json!(DRIFT_SCAN_LIMIT));
2288 }
2289
2290 #[test]
2291 fn two_derivations_sharing_a_name_are_rejected_with_both_module_paths() {
2292 let first = count_def();
2293 let mut second = count_def();
2294 second.model = "DvOther";
2295 second.module_path = "other::module";
2296 let err = check_unique_names(&[&first, &second])
2297 .expect_err("one name cannot carry two backfill states");
2298 let message = err.to_string();
2299 assert!(message.contains("tests::model_derivation"), "{message}");
2300 assert!(message.contains("other::module"), "{message}");
2301 }
2302
2303 #[test]
2304 fn a_binary_with_no_derivation_registers_none() {
2305 // The runtime cost of the feature is gated on this: no descriptor means
2306 // no state table, no reconciliation and no boot task.
2307 assert!(
2308 registered_derivations().is_empty(),
2309 "the library's own unit-test binary declares no derivation"
2310 );
2311 assert!(!has_derivation_descriptors());
2312 }
2313}