Skip to main content

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(&registered_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(&registered_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(&registered_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}