use std::collections::HashMap;
use std::fmt::Write as _;
use diesel::sql_types::{BigInt, Nullable, Text};
use diesel_async::RunQueryDsl as _;
use scoped_futures::ScopedFutureExt as _;
use serde::{Deserialize, Serialize};
use crate::counter_cache::{SqlView, is_lock_contention};
use crate::db::{RuntimeConnection, scoped_immediate_transaction};
use crate::{AutumnError, AutumnResult};
#[cfg(not(feature = "sqlite"))]
pub const DERIVATION_MIGRATIONS: diesel_migrations::EmbeddedMigrations =
diesel_migrations::embed_migrations!("derivation_migrations");
#[cfg(feature = "sqlite")]
pub const DERIVATION_MIGRATIONS: diesel_migrations::EmbeddedMigrations =
diesel_migrations::embed_migrations!("derivation_migrations_sqlite");
const STATE_TABLE: &str = "_autumn_derivations";
const NOW: &str = "CURRENT_TIMESTAMP";
#[cfg(not(feature = "sqlite"))]
fn ph(n: usize) -> String {
format!("${n}")
}
#[cfg(feature = "sqlite")]
fn ph(_n: usize) -> String {
"?".to_owned()
}
#[cfg(all(test, not(feature = "sqlite")))]
const IS_DISTINCT_FROM: &str = "IS DISTINCT FROM";
#[cfg(all(test, feature = "sqlite"))]
const IS_DISTINCT_FROM: &str = "IS NOT";
#[cfg(not(feature = "sqlite"))]
const FOR_UPDATE: &str = " FOR UPDATE";
#[cfg(feature = "sqlite")]
const FOR_UPDATE: &str = "";
pub const DRIFT_SCAN_LIMIT: i64 = 10_000;
#[derive(Debug)]
pub struct DerivationDef {
pub name: &'static str,
pub model: &'static str,
pub child_table: &'static str,
pub child_pk: &'static str,
pub child_soft_delete: bool,
pub fk_column: &'static str,
pub parent_table: &'static str,
pub parent_pk: &'static str,
pub column: &'static str,
pub transform: &'static str,
pub filter: &'static str,
pub filter_sql: &'static str,
pub contrib_sql: &'static str,
pub tenant_column: Option<&'static str>,
pub module_path: &'static str,
pub file: &'static str,
pub line: u32,
}
impl DerivationDef {
#[must_use]
pub fn definition_hash(&self) -> String {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
push_component(&mut hasher, "child_table", self.child_table.as_bytes());
push_component(&mut hasher, "child_pk", self.child_pk.as_bytes());
push_component(
&mut hasher,
"child_soft_delete",
if self.child_soft_delete { b"1" } else { b"0" },
);
push_component(&mut hasher, "fk_column", self.fk_column.as_bytes());
push_component(&mut hasher, "parent_table", self.parent_table.as_bytes());
push_component(&mut hasher, "parent_pk", self.parent_pk.as_bytes());
push_component(&mut hasher, "column", self.column.as_bytes());
push_component(&mut hasher, "transform", self.transform.as_bytes());
push_component(&mut hasher, "filter_sql", self.filter_sql.as_bytes());
push_component(&mut hasher, "contrib_sql", self.contrib_sql.as_bytes());
push_component(
&mut hasher,
"tenant_column",
self.tenant_column.unwrap_or("").as_bytes(),
);
hex_lower(hasher.finalize())
}
pub(crate) const fn sql_view(&self) -> SqlView {
SqlView {
child_table: self.child_table,
child_pk: self.child_pk,
child_soft_delete: self.child_soft_delete,
fk_column: self.fk_column,
parent_table: self.parent_table,
parent_pk: self.parent_pk,
counter_column: self.column,
contrib_sql: self.contrib_sql,
filter_sql: self.filter_sql,
tenant_column: self.tenant_column,
}
}
}
fn push_component(hasher: &mut sha2::Sha256, label: &str, value: &[u8]) {
use sha2::Digest as _;
hasher.update(label.as_bytes());
hasher.update(b":");
hasher.update((value.len() as u64).to_be_bytes());
hasher.update(b":");
hasher.update(value);
hasher.update(b";");
}
fn hex_lower(bytes: impl AsRef<[u8]>) -> String {
bytes.as_ref().iter().fold(
String::with_capacity(bytes.as_ref().len() * 2),
|mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
},
)
}
#[doc(hidden)]
pub struct DerivationDescriptor {
pub def: &'static DerivationDef,
}
inventory::collect!(DerivationDescriptor);
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
pub struct CounterCacheClaim {
pub model: &'static str,
pub child_table: &'static str,
pub parent_table: &'static str,
pub column: &'static str,
pub direct_sql: bool,
pub module_path: &'static str,
}
inventory::collect!(CounterCacheClaim);
fn registered_column_claims() -> Vec<CounterCacheClaim> {
let mut claims: Vec<CounterCacheClaim> = inventory::iter::<CounterCacheClaim>
.into_iter()
.copied()
.collect();
claims.extend(
inventory::iter::<crate::commentable::CommentableDescriptor>
.into_iter()
.filter_map(|descriptor| {
let column = descriptor.spec.counter_column?;
Some(CounterCacheClaim {
model: (descriptor.model)(),
child_table: descriptor.spec.comments_table,
parent_table: descriptor.spec.parent_table,
column,
direct_sql: true,
module_path: "#[commentable]",
})
}),
);
claims.sort_unstable_by_key(|claim| {
(
claim.parent_table,
claim.column,
claim.module_path,
claim.model,
)
});
claims
}
#[must_use]
pub fn registered_derivations() -> Vec<&'static DerivationDef> {
let mut defs: Vec<&'static DerivationDef> = inventory::iter::<DerivationDescriptor>
.into_iter()
.map(|descriptor| descriptor.def)
.collect();
defs.sort_unstable_by_key(|def| def.name);
defs
}
pub(crate) fn has_derivation_descriptors() -> bool {
inventory::iter::<DerivationDescriptor>
.into_iter()
.next()
.is_some()
}
fn select_checked<'a>(defs: &[&'a DerivationDef], name: &str) -> AutumnResult<&'a DerivationDef> {
check_registry(defs)?;
defs.iter()
.copied()
.find(|def| def.name == name)
.ok_or_else(|| {
AutumnError::from(std::io::Error::other(format!(
"`{name}` is not a derivation registered in this binary"
)))
})
}
fn check_registry(defs: &[&DerivationDef]) -> AutumnResult<()> {
check_unique_names(defs)?;
check_primary_key_columns(defs)?;
let claims = registered_column_claims();
check_unique_columns(defs, &claims)?;
check_source_columns(defs, &claims)
}
fn check_primary_key_columns(defs: &[&DerivationDef]) -> AutumnResult<()> {
let primary_key = ident_key("id");
for def in defs {
if ident_key(def.column) == primary_key {
return Err(AutumnError::from(std::io::Error::other(format!(
"derivation `{}` on {}::{} maintains `{}.{}`, which is the parent's primary \
key under this backend's identifier rules. A maintained value would rewrite \
the parent's identity, so name a dedicated aggregate column",
def.name, def.module_path, def.model, def.parent_table, def.column,
))));
}
}
Ok(())
}
#[cfg(not(feature = "sqlite"))]
fn ident_key(ident: &str) -> String {
postgres_ident_key(ident)
}
#[cfg(not(feature = "sqlite"))]
fn postgres_ident_key(ident: &str) -> String {
const MAX_IDENTIFIER_BYTES: usize = 63;
if ident.len() <= MAX_IDENTIFIER_BYTES {
return ident.to_owned();
}
let mut end = MAX_IDENTIFIER_BYTES;
while !ident.is_char_boundary(end) {
end -= 1;
}
ident[..end].to_owned()
}
#[cfg(feature = "sqlite")]
fn ident_key(ident: &str) -> String {
ident.to_ascii_lowercase()
}
fn source_columns(def: &DerivationDef) -> Vec<&'static str> {
let mut columns = vec![def.fk_column];
columns.extend(def.tenant_column);
for sql in [def.contrib_sql, def.filter_sql] {
let mut rest = sql;
while let Some(start) = rest.find("{c}.\"") {
let after = &rest[start + 5..];
let Some(end) = after.find('"') else { break };
columns.push(&after[..end]);
rest = &after[end + 1..];
}
}
columns
}
fn check_source_columns(defs: &[&DerivationDef], claims: &[CounterCacheClaim]) -> AutumnResult<()> {
for def in defs {
let sources: Vec<String> = source_columns(def).into_iter().map(ident_key).collect();
let source_table = ident_key(def.child_table);
if ident_key(def.parent_table) == source_table && sources.contains(&ident_key(def.column)) {
return Err(AutumnError::from(std::io::Error::other(format!(
"derivation `{}` on {}::{} onto its own table reads the column it maintains, \
`{}.{}` (as its sum, in its filter, as its grouping key or as its tenant \
column). The parent-side update runs no repository hook, so a row's new \
value would change what it contributes to its own parent without that \
parent being maintained. Read another column, or maintain another one",
def.name, def.module_path, def.model, def.parent_table, def.column,
))));
}
if let Some(other) = defs.iter().find(|other| {
other.name != def.name
&& ident_key(other.parent_table) == source_table
&& sources.contains(&ident_key(other.column))
}) {
return Err(AutumnError::from(std::io::Error::other(format!(
"derivation `{}` on {}::{} reads `{}.{}`, which derivation `{}` on {}::{} \
maintains. The parent-side update runs no repository hook, so `{}`'s \
contribution would change without the delta that carries it up. Read a \
column nothing maintains, or maintain the aggregate one level at a time",
def.name,
def.module_path,
def.model,
def.child_table,
other.column,
other.name,
other.module_path,
other.model,
def.name,
))));
}
if let Some(claim) = claims.iter().find(|claim| {
claim.direct_sql
&& ident_key(claim.parent_table) == source_table
&& sources.contains(&ident_key(claim.column))
}) {
return Err(AutumnError::from(std::io::Error::other(format!(
"derivation `{}` on {}::{} reads `{}.{}`, which {}::{} (child table `{}`) \
maintains by direct SQL. That update runs no repository hook, so `{}`'s \
contribution would change without the delta that carries it up. Read a \
column nothing maintains, or maintain the aggregate one level at a time",
def.name,
def.module_path,
def.model,
def.child_table,
claim.column,
claim.module_path,
claim.model,
claim.child_table,
def.name,
))));
}
}
Ok(())
}
fn check_unique_names(defs: &[&DerivationDef]) -> AutumnResult<()> {
for pair in defs.windows(2) {
if pair[0].name == pair[1].name {
return Err(AutumnError::from(std::io::Error::other(format!(
"two derivations are both named `{}`: {}::{} and {}::{}. Give one \
a `name = \"...\"` so each has its own backfill state",
pair[0].name,
pair[0].module_path,
pair[0].model,
pair[1].module_path,
pair[1].model,
))));
}
}
Ok(())
}
fn check_unique_columns(defs: &[&DerivationDef], claims: &[CounterCacheClaim]) -> AutumnResult<()> {
let mut seen: HashMap<(String, String), &DerivationDef> = HashMap::new();
for def in defs {
let key = (ident_key(def.parent_table), ident_key(def.column));
if let Some(claim) = claims
.iter()
.find(|claim| (ident_key(claim.parent_table), ident_key(claim.column)) == key)
{
return Err(AutumnError::from(std::io::Error::other(format!(
"derivation `{}` on {}::{} maintains `{}.{}`, which the counter cache \
declared by {}::{} (child table `{}`) already maintains. The column would \
count twice, and the derivation's backfill would overwrite the counter \
cache's rows, so remove one or point it at another column",
def.name,
def.module_path,
def.model,
def.parent_table,
def.column,
claim.module_path,
claim.model,
claim.child_table,
))));
}
if let Some(first) = seen.insert(key, def) {
return Err(AutumnError::from(std::io::Error::other(format!(
"two derivations both maintain `{}.{}`: `{}` on {}::{} and `{}` on \
{}::{}. The column would count twice, so remove one or point it \
at another column",
def.parent_table,
def.column,
first.name,
first.module_path,
first.model,
def.name,
def.module_path,
def.model,
))));
}
}
Ok(())
}
pub fn check_registered_derivations() -> AutumnResult<()> {
check_registry(®istered_derivations())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BackfillState {
Pending,
Running,
Complete,
Unregistered,
}
impl BackfillState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Complete => "complete",
Self::Unregistered => "unregistered",
}
}
fn parse(value: &str) -> Option<Self> {
match value {
"pending" => Some(Self::Pending),
"running" => Some(Self::Running),
"complete" => Some(Self::Complete),
_ => None,
}
}
const fn is_sweepable(self) -> bool {
matches!(self, Self::Pending | Self::Running)
}
}
impl std::fmt::Display for BackfillState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DerivationStatus {
pub name: String,
pub definition_hash: Option<String>,
pub stored_hash: Option<String>,
pub backfill_state: Option<BackfillState>,
pub checkpoint: Option<i64>,
pub backfilled_rows: i64,
pub updated_at: Option<String>,
pub drift: Option<i64>,
pub drift_error: Option<String>,
}
#[derive(diesel::QueryableByName)]
struct StateRow {
#[diesel(sql_type = Text)]
name: String,
#[diesel(sql_type = Text)]
definition_hash: String,
#[diesel(sql_type = Text)]
backfill_state: String,
#[diesel(sql_type = Nullable<BigInt>)]
checkpoint: Option<i64>,
#[diesel(sql_type = BigInt)]
backfilled_rows: i64,
#[diesel(sql_type = Nullable<Text>)]
updated_at: Option<String>,
}
#[derive(diesel::QueryableByName)]
struct CountRow {
#[diesel(sql_type = BigInt)]
count: i64,
}
async fn load_state(conn: &mut RuntimeConnection) -> AutumnResult<HashMap<String, StateRow>> {
let sql = format!(
"SELECT name, definition_hash, backfill_state, checkpoint, backfilled_rows, \
CAST(updated_at AS TEXT) AS updated_at FROM {STATE_TABLE} ORDER BY name"
);
let rows: Vec<StateRow> = diesel::sql_query(sql)
.load::<StateRow>(conn)
.await
.map_err(AutumnError::from)?;
Ok(rows
.into_iter()
.map(|row| (row.name.clone(), row))
.collect())
}
async fn enqueue(conn: &mut RuntimeConnection, def: &DerivationDef) -> AutumnResult<()> {
let sql = format!(
"INSERT INTO {STATE_TABLE} \
(name, definition_hash, backfill_state, checkpoint, backfilled_rows, updated_at) \
VALUES ({}, {}, 'pending', NULL, 0, {NOW}) \
ON CONFLICT (name) DO UPDATE SET \
definition_hash = excluded.definition_hash, \
backfill_state = 'pending', checkpoint = NULL, backfilled_rows = 0, \
updated_at = {NOW}",
ph(1),
ph(2)
);
diesel::sql_query(sql)
.bind::<Text, _>(def.name)
.bind::<Text, _>(def.definition_hash())
.execute(conn)
.await
.map_err(AutumnError::from)?;
Ok(())
}
pub async fn ensure_derivations(conn: &mut RuntimeConnection) -> AutumnResult<Vec<&'static str>> {
let defs = registered_derivations();
check_registry(&defs)?;
scoped_immediate_transaction::<Vec<&'static str>, AutumnError, _>(conn, move |conn| {
async move {
lock_state_table(conn).await?;
reconcile(conn, &defs).await
}
.scope_boxed()
})
.await
}
#[cfg(not(feature = "sqlite"))]
async fn lock_state_table(conn: &mut RuntimeConnection) -> AutumnResult<()> {
diesel::sql_query(format!("LOCK TABLE {STATE_TABLE} IN EXCLUSIVE MODE"))
.execute(conn)
.await
.map_err(AutumnError::from)?;
Ok(())
}
#[cfg(feature = "sqlite")]
#[allow(clippy::unused_async, reason = "one call shape for both backends")]
async fn lock_state_table(_conn: &mut RuntimeConnection) -> AutumnResult<()> {
Ok(())
}
async fn reconcile(
conn: &mut RuntimeConnection,
defs: &[&'static DerivationDef],
) -> AutumnResult<Vec<&'static str>> {
let state = load_state(conn).await?;
let hashes: Vec<(&DerivationDef, String)> = defs
.iter()
.map(|def| (*def, def.definition_hash()))
.collect();
let registered: HashMap<&str, &str> = hashes
.iter()
.map(|(def, hash)| (def.name, hash.as_str()))
.collect();
let unclaimed =
|row: &StateRow| registered.get(row.name.as_str()) != Some(&row.definition_hash.as_str());
let mut pending = Vec::new();
for (def, hash) in &hashes {
if state
.get(def.name)
.is_some_and(|row| row.definition_hash == *hash)
{
continue;
}
let mut candidates = state
.values()
.filter(|row| row.name != def.name && row.definition_hash == *hash)
.filter(|row| unclaimed(row));
let parked = match (candidates.next(), candidates.next()) {
(Some(row), None) => {
rename_state(conn, &row.name, &parking_name(def.name), hash).await?
}
_ => false,
};
pending.push((def, hash.as_str(), parked));
}
let mut enqueued = Vec::new();
for (def, hash, parked) in pending {
if parked {
delete_stale_state(conn, def.name, hash).await?;
if rename_state(conn, &parking_name(def.name), def.name, hash).await? {
continue;
}
}
enqueue(conn, def).await?;
enqueued.push(def.name);
}
Ok(enqueued)
}
fn parking_name(name: &str) -> String {
format!("{PARKING_PREFIX}{name}")
}
const PARKING_PREFIX: &str = "parked::";
async fn delete_stale_state(
conn: &mut RuntimeConnection,
name: &str,
hash: &str,
) -> AutumnResult<()> {
let sql = format!(
"DELETE FROM {STATE_TABLE} WHERE name = {} AND definition_hash <> {}",
ph(1),
ph(2)
);
diesel::sql_query(sql)
.bind::<Text, _>(name)
.bind::<Text, _>(hash)
.execute(conn)
.await
.map_err(AutumnError::from)?;
Ok(())
}
async fn rename_state(
conn: &mut RuntimeConnection,
from: &str,
to: &str,
hash: &str,
) -> AutumnResult<bool> {
let sql = format!(
"UPDATE {STATE_TABLE} SET name = {}, updated_at = {NOW} \
WHERE name = {} AND definition_hash = {}",
ph(1),
ph(2),
ph(3)
);
let moved = diesel::sql_query(sql)
.bind::<Text, _>(to)
.bind::<Text, _>(from)
.bind::<Text, _>(hash)
.execute(conn)
.await
.map_err(AutumnError::from)?;
Ok(moved == 1)
}
#[derive(Debug, Clone, Copy)]
pub struct BackfillOptions {
pub batch_size: i64,
pub max_batches: Option<usize>,
}
impl Default for BackfillOptions {
fn default() -> Self {
Self {
batch_size: 1000,
max_batches: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct BackfillReport {
pub completed: Vec<String>,
pub in_progress: Vec<String>,
pub rows_repaired: usize,
pub batches_run: usize,
}
async fn advance_checkpoint(
conn: &mut RuntimeConnection,
name: &str,
hash: &str,
checkpoint: i64,
rows: i64,
) -> AutumnResult<()> {
let sql = format!(
"UPDATE {STATE_TABLE} SET checkpoint = {checkpoint}, \
backfilled_rows = backfilled_rows + {rows}, backfill_state = 'running', \
updated_at = {NOW} WHERE name = {} AND definition_hash = {}",
ph(1),
ph(2)
);
diesel::sql_query(sql)
.bind::<Text, _>(name.to_owned())
.bind::<Text, _>(hash.to_owned())
.execute(conn)
.await
.map_err(AutumnError::from)?;
Ok(())
}
async fn mark_complete(conn: &mut RuntimeConnection, name: &str, hash: &str) -> AutumnResult<()> {
let sql = format!(
"UPDATE {STATE_TABLE} SET backfill_state = 'complete', updated_at = {NOW} \
WHERE name = {} AND definition_hash = {}",
ph(1),
ph(2)
);
diesel::sql_query(sql)
.bind::<Text, _>(name.to_owned())
.bind::<Text, _>(hash.to_owned())
.execute(conn)
.await
.map_err(AutumnError::from)?;
Ok(())
}
#[derive(diesel::QueryableByName)]
struct LockedRow {
#[diesel(sql_type = Text)]
definition_hash: String,
#[diesel(sql_type = Text)]
backfill_state: String,
#[diesel(sql_type = Nullable<BigInt>)]
checkpoint: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Batch {
Stopped,
Completed,
Advanced {
repaired: usize,
},
}
async fn run_one_batch(
conn: &mut RuntimeConnection,
def: &'static DerivationDef,
batch_size: i64,
) -> AutumnResult<Batch> {
let view = def.sql_view();
let name = def.name;
let hash = def.definition_hash();
scoped_immediate_transaction::<Batch, AutumnError, _>(conn, move |conn| {
async move {
let lock_sql = format!(
"SELECT definition_hash, backfill_state, checkpoint FROM {STATE_TABLE} \
WHERE name = {}{FOR_UPDATE}",
ph(1)
);
let locked = diesel::sql_query(lock_sql)
.bind::<Text, _>(name)
.load::<LockedRow>(&mut *conn)
.await
.map_err(AutumnError::from)?
.into_iter()
.next();
let Some(row) = locked else {
return Ok(Batch::Stopped);
};
if row.definition_hash != hash {
return Ok(Batch::Stopped);
}
if !BackfillState::parse(&row.backfill_state).is_some_and(BackfillState::is_sweepable) {
return Ok(Batch::Stopped);
}
let ids =
crate::counter_cache::parent_id_page(&mut *conn, &view, row.checkpoint, batch_size)
.await?;
let Some(&last) = ids.last() else {
mark_complete(&mut *conn, name, &hash).await?;
return Ok(Batch::Completed);
};
let visited = i64::try_from(ids.len()).unwrap_or(i64::MAX);
let repaired =
crate::counter_cache::recompute_batch_statements(&mut *conn, &view, &ids).await?;
advance_checkpoint(&mut *conn, name, &hash, last, visited).await?;
Ok(Batch::Advanced { repaired })
}
.scope_boxed()
})
.await
}
pub async fn run_backfill(
conn: &mut RuntimeConnection,
options: &BackfillOptions,
) -> AutumnResult<BackfillReport> {
if options.batch_size <= 0 {
return Err(AutumnError::from(std::io::Error::other(format!(
"a backfill batch must hold at least one parent row; `batch_size` is {}",
options.batch_size
))));
}
let defs = registered_derivations();
check_registry(&defs)?;
let mut report = BackfillReport::default();
let mut batches = 0usize;
let state = load_state(conn).await?;
let candidates: Vec<&'static DerivationDef> = defs
.into_iter()
.filter(|def| {
state.get(def.name).is_some_and(|row| {
row.definition_hash == def.definition_hash()
&& BackfillState::parse(&row.backfill_state)
.is_some_and(BackfillState::is_sweepable)
})
})
.collect();
let mut budget_spent = false;
for def in candidates {
if budget_spent {
report.in_progress.push(def.name.to_owned());
continue;
}
loop {
if options.max_batches.is_some_and(|max| batches >= max) {
budget_spent = true;
report.in_progress.push(def.name.to_owned());
break;
}
let batch_size = if def.child_table == def.parent_table {
1
} else {
options.batch_size
};
match run_one_batch_retrying(conn, def, batch_size).await? {
Batch::Stopped => break,
Batch::Completed => {
report.completed.push(def.name.to_owned());
break;
}
Batch::Advanced { repaired } => {
report.rows_repaired += repaired;
report.batches_run += 1;
batches += 1;
}
}
}
}
Ok(report)
}
const LOCK_CONTENTION_RETRIES: u32 = 5;
async fn run_one_batch_retrying(
conn: &mut RuntimeConnection,
def: &'static DerivationDef,
batch_size: i64,
) -> AutumnResult<Batch> {
let mut attempt = 0;
loop {
match run_one_batch(conn, def, batch_size).await {
Err(error) if attempt < LOCK_CONTENTION_RETRIES && is_lock_contention(&error) => {
attempt += 1;
tracing::warn!(
derivation = def.name,
attempt,
error = %error,
"backfill batch lost a lock race; retrying"
);
tokio::time::sleep(std::time::Duration::from_millis(25 * u64::from(attempt))).await;
}
outcome => return outcome,
}
}
}
fn is_missing_state_table(error: &AutumnError) -> bool {
let message = error.to_string();
message.contains(STATE_TABLE)
&& (message.contains("does not exist") || message.contains("no such table"))
}
pub async fn resweep(conn: &mut RuntimeConnection, name: &str) -> AutumnResult<()> {
let def = select_checked(®istered_derivations(), name)?;
enqueue(conn, def).await
}
pub async fn recompute(conn: &mut RuntimeConnection, name: &str) -> AutumnResult<usize> {
let def = select_checked(®istered_derivations(), name)?;
crate::counter_cache::recompute_view(conn, &def.sql_view(), None).await
}
pub async fn drift(conn: &mut RuntimeConnection, def: &DerivationDef) -> AutumnResult<i64> {
let sql = crate::counter_cache::drift_sql(&def.sql_view(), DRIFT_SCAN_LIMIT);
Ok(diesel::sql_query(sql)
.get_result::<CountRow>(conn)
.await
.map_err(AutumnError::from)?
.count)
}
pub async fn derivation_status(
conn: &mut RuntimeConnection,
) -> AutumnResult<Vec<DerivationStatus>> {
let defs = registered_derivations();
check_registry(&defs)?;
let state = match load_state(conn).await {
Ok(state) => state,
Err(error) if defs.is_empty() && is_missing_state_table(&error) => return Ok(Vec::new()),
Err(error) => return Err(error),
};
let mut out = Vec::with_capacity(defs.len() + state.len());
for def in &defs {
let row = state.get(def.name);
let (drifted, drift_error) = match drift(conn, def).await {
Ok(count) => (Some(count), None),
Err(error) => (None, Some(error.to_string())),
};
out.push(DerivationStatus {
name: def.name.to_owned(),
definition_hash: Some(def.definition_hash()),
stored_hash: row.map(|row| row.definition_hash.clone()),
backfill_state: row.and_then(|row| BackfillState::parse(&row.backfill_state)),
checkpoint: row.and_then(|row| row.checkpoint),
backfilled_rows: row.map_or(0, |row| row.backfilled_rows),
updated_at: row.and_then(|row| row.updated_at.clone()),
drift: drifted,
drift_error,
});
}
for (name, row) in &state {
if defs.iter().any(|def| def.name == name.as_str()) {
continue;
}
out.push(DerivationStatus {
name: name.clone(),
definition_hash: None,
stored_hash: Some(row.definition_hash.clone()),
backfill_state: Some(BackfillState::Unregistered),
checkpoint: row.checkpoint,
backfilled_rows: row.backfilled_rows,
updated_at: row.updated_at.clone(),
drift: None,
drift_error: None,
});
}
out.sort_by(|left, right| left.name.cmp(&right.name));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn count_def() -> DerivationDef {
DerivationDef {
name: "dv_posts.published_comment_count",
model: "DvComment",
child_table: "dv_comments",
child_pk: "id",
child_soft_delete: false,
fk_column: "post_id",
parent_table: "dv_posts",
parent_pk: "id",
column: "published_comment_count",
transform: "count",
filter: "published",
filter_sql: " AND ({c}.\"published\" = TRUE)",
contrib_sql: "1",
tenant_column: None,
module_path: "tests::model_derivation",
file: "model_derivation.rs",
line: 42,
}
}
fn sum_def() -> DerivationDef {
DerivationDef {
name: "dv_posts.visible_score",
column: "visible_score",
transform: "sum(score)",
filter: "published && score > 0",
filter_sql: " AND ({c}.\"published\" = TRUE) AND ({c}.\"score\" > 0)",
contrib_sql: "{c}.\"score\"",
..count_def()
}
}
#[test]
fn the_definition_hash_is_stable_for_identical_definitions() {
assert_eq!(count_def().definition_hash(), count_def().definition_hash());
assert_eq!(
count_def().definition_hash().len(),
64,
"sha256 renders as 64 hex characters"
);
assert!(
count_def()
.definition_hash()
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"the hash is lowercase hex"
);
assert_ne!(count_def().definition_hash(), sum_def().definition_hash());
}
#[test]
fn the_definition_hash_tracks_every_part_of_the_lowered_shape() {
let base = count_def().definition_hash();
let mut unfiltered = count_def();
unfiltered.filter_sql = "";
assert_ne!(unfiltered.definition_hash(), base, "filter_sql");
let mut weighted = count_def();
weighted.contrib_sql = "{c}.\"score\"";
assert_ne!(weighted.definition_hash(), base, "contrib_sql");
let mut renamed_column = count_def();
renamed_column.column = "other_count";
assert_ne!(renamed_column.definition_hash(), base, "column");
let mut summed = count_def();
summed.transform = "sum(score)";
assert_ne!(summed.definition_hash(), base, "transform");
let mut tenanted = count_def();
tenanted.tenant_column = Some("tenant_id");
assert_ne!(tenanted.definition_hash(), base, "tenant_column");
let mut soft = count_def();
soft.child_soft_delete = true;
assert_ne!(soft.definition_hash(), base, "child_soft_delete");
}
#[test]
fn the_definition_hash_ignores_where_the_derivation_was_written() {
let base = count_def().definition_hash();
let mut moved = count_def();
moved.name = "renamed";
moved.model = "OtherComment";
moved.module_path = "elsewhere";
moved.file = "elsewhere.rs";
moved.line = 9001;
moved.filter = "published /* reformatted */";
assert_eq!(moved.definition_hash(), base);
}
#[test]
fn a_filtered_count_recompute_counts_only_matching_rows() {
let sql = crate::counter_cache::recompute_update_sql(&count_def().sql_view(), "1,2");
assert_eq!(
sql,
"UPDATE \"dv_posts\" SET \"published_comment_count\" = \
(SELECT COUNT(*) FROM \"dv_comments\" AS __autumn_cc_child \
WHERE __autumn_cc_child.\"post_id\" = \"dv_posts\".\"id\" \
AND (__autumn_cc_child.\"published\" = TRUE)) \
WHERE \"dv_posts\".\"id\" IN (1,2) \
AND \"dv_posts\".\"published_comment_count\" "
.to_owned()
+ IS_DISTINCT_FROM
+ " (SELECT COUNT(*) FROM \"dv_comments\" AS __autumn_cc_child \
WHERE __autumn_cc_child.\"post_id\" = \"dv_posts\".\"id\" \
AND (__autumn_cc_child.\"published\" = TRUE))"
);
}
#[test]
fn a_sum_recompute_sums_the_contribution() {
let sql = crate::counter_cache::recompute_update_sql(&sum_def().sql_view(), "5");
assert!(
sql.starts_with(
"UPDATE \"dv_posts\" SET \"visible_score\" = \
(SELECT COALESCE(SUM(__autumn_cc_child.\"score\"), 0) \
FROM \"dv_comments\" AS __autumn_cc_child"
),
"{sql}"
);
assert!(
sql.contains(
"AND (__autumn_cc_child.\"published\" = TRUE) \
AND (__autumn_cc_child.\"score\" > 0))"
),
"both conjuncts of the filter must survive: {sql}"
);
assert!(sql.contains("\"dv_posts\".\"id\" IN (5)"), "{sql}");
}
#[test]
fn drift_is_one_aggregate_over_the_parent_table() {
let sql = crate::counter_cache::drift_sql(&sum_def().sql_view(), DRIFT_SCAN_LIMIT);
assert!(
sql.starts_with("SELECT COUNT(*) AS count FROM (SELECT 1 AS drifted FROM "),
"{sql}"
);
assert!(
sql.contains(&format!("\"visible_score\" {IS_DISTINCT_FROM}")),
"{sql}"
);
assert!(
sql.contains(&format!(
"(SELECT * FROM \"dv_posts\" ORDER BY \"dv_posts\".\"id\" DESC LIMIT {DRIFT_SCAN_LIMIT}) AS \"dv_posts\""
)),
"the parents examined are capped, so the actuator cannot hang on a huge table: {sql}"
);
assert!(sql.ends_with(") AS __autumn_cc_drift"), "{sql}");
}
#[test]
fn a_backfill_pages_a_thousand_parents_at_a_time_by_default() {
let options = BackfillOptions::default();
assert_eq!(options.batch_size, 1000);
assert!(options.max_batches.is_none(), "the default runs to the end");
}
#[test]
fn a_backfill_state_round_trips_through_its_stored_spelling() {
for state in [
BackfillState::Pending,
BackfillState::Running,
BackfillState::Complete,
] {
assert_eq!(BackfillState::parse(state.as_str()), Some(state));
assert_eq!(
serde_json::to_string(&state).expect("serialize"),
format!("\"{state}\"")
);
assert_ne!(state.is_sweepable(), state == BackfillState::Complete);
}
assert_eq!(BackfillState::parse("done"), None);
assert_eq!(BackfillState::Unregistered.as_str(), "unregistered");
assert_eq!(BackfillState::parse("unregistered"), None);
assert!(!BackfillState::Unregistered.is_sweepable());
assert_eq!(
serde_json::to_string(&BackfillState::Unregistered).expect("serialize"),
"\"unregistered\""
);
}
#[test]
fn two_derivations_on_one_parent_column_are_rejected() {
let first = count_def();
let mut second = count_def();
second.name = "dv_posts.published_comment_count_again";
second.model = "DvOtherComment";
second.module_path = "other::module";
second.filter_sql = "";
let err = check_unique_columns(&[&first, &second], &[])
.expect_err("one column cannot carry two derivations");
let message = err.to_string();
assert!(
message.contains("dv_posts.published_comment_count"),
"{message}"
);
assert!(message.contains("other::module"), "{message}");
assert!(message.contains("count twice"), "{message}");
let mut sibling = count_def();
sibling.name = "dv_posts.visible_score";
sibling.column = "visible_score";
check_unique_columns(&[&first, &sibling], &[]).expect("two columns, two derivations");
check_registry(&[&first, &second]).expect_err("the registry check covers columns");
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn postgres_ident_key_truncates_to_63_bytes_on_a_char_boundary() {
let long_a = format!("{}{}", "a".repeat(63), "x");
let long_b = format!("{}{}", "a".repeat(63), "y");
assert_eq!(postgres_ident_key(&long_a), "a".repeat(63));
assert_eq!(postgres_ident_key(&long_a), postgres_ident_key(&long_b));
assert_eq!(postgres_ident_key("score"), "score");
assert_eq!(postgres_ident_key(&"a".repeat(63)), "a".repeat(63));
let multi = format!("{}{}", "é".repeat(31), "éé"); let key = postgres_ident_key(&multi);
assert!(key.len() <= 63, "key is {key:?}");
assert!(key.chars().all(|c| c == 'é'), "key is {key:?}");
assert_eq!(key, "é".repeat(31));
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn overlong_derivation_columns_that_share_63_bytes_are_rejected() {
let mut first = count_def();
first.column = Box::leak(format!("{}{}", "a".repeat(63), "x").into_boxed_str());
let mut second = count_def();
second.name = "dv_posts.another_long_derivation";
second.model = "DvOtherComment";
second.module_path = "other::module";
second.column = Box::leak(format!("{}{}", "a".repeat(63), "y").into_boxed_str());
second.filter_sql = "";
let err = check_unique_columns(&[&first, &second], &[])
.expect_err("two names truncating to one column cannot both maintain it");
let message = err.to_string();
assert!(message.contains("count twice"), "{message}");
let mut third = count_def();
third.column = Box::leak(format!("{}{}", "a".repeat(62), "x").into_boxed_str());
check_unique_columns(&[&first, &third], &[])
.expect("distinct 63-byte names are distinct columns");
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_has_no_63_byte_identifier_limit_so_long_names_coexist() {
let mut first = count_def();
first.column = Box::leak(format!("{}{}", "a".repeat(63), "x").into_boxed_str());
let mut second = count_def();
second.column = Box::leak(format!("{}{}", "a".repeat(63), "y").into_boxed_str());
check_unique_columns(&[&first, &second], &[])
.expect("on SQLite the full spellings are distinct columns");
}
#[test]
fn lock_contention_is_recognised_and_other_errors_are_not() {
let contention = [
"deadlock detected",
"ERROR: could not serialize access due to concurrent update",
"database is locked",
];
for message in contention {
let error = AutumnError::from(std::io::Error::other(message));
assert!(is_lock_contention(&error), "{message}");
}
let other = AutumnError::from(std::io::Error::other(
"column \"published_comment_count\" does not exist",
));
assert!(!is_lock_contention(&other));
}
fn case_variant_pair() -> (DerivationDef, DerivationDef) {
let lower = DerivationDef {
name: "dv_posts.score",
column: "score",
..count_def()
};
let upper = DerivationDef {
name: "dv_posts.Score",
column: "Score",
..count_def()
};
(lower, upper)
}
#[test]
fn a_derivation_cannot_maintain_the_parent_primary_key_under_any_spelling() {
let exact = DerivationDef {
name: "dv_posts.id",
column: "id",
..count_def()
};
let message = check_primary_key_columns(&[&exact])
.expect_err("the primary key is never a derivation column")
.to_string();
assert!(message.contains("primary"), "{message}");
let upper = DerivationDef {
name: "dv_posts.ID",
column: "ID",
..count_def()
};
let folded = check_primary_key_columns(&[&upper]);
if cfg!(feature = "sqlite") {
folded.expect_err("`\"ID\"` is `id` on SQLite");
} else {
folded.expect("`\"ID\"` is its own quoted column on Postgres");
}
}
#[test]
fn a_repair_selects_its_definition_only_from_a_checked_registry() {
let first = DerivationDef {
name: "dv_posts.count_a",
..count_def()
};
let second = DerivationDef {
name: "dv_posts.count_b",
..count_def()
};
let message = select_checked(&[&first, &second], "dv_posts.count_a")
.expect_err("two derivations on one column are refused before the sweep")
.to_string();
assert!(message.contains("count twice"), "{message}");
let only = count_def();
let selected = select_checked(&[&only], only.name).expect("a clean registry selects");
assert_eq!(selected.name, only.name);
let missing = select_checked(&[&only], "dv_posts.nowhere")
.expect_err("an unknown name is refused")
.to_string();
assert!(missing.contains("not a derivation registered"), "{missing}");
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_folds_identifier_case_in_the_registry_checks() {
let (lower, upper) = case_variant_pair();
let message = check_unique_columns(&[&lower, &upper], &[])
.expect_err("two spellings of one column collide")
.to_string();
assert!(message.contains("dv_posts.Score"), "{message}");
let claim = CounterCacheClaim {
model: "DvLike",
child_table: "DV_LIKES",
parent_table: "DV_POSTS",
column: "SCORE",
direct_sql: true,
module_path: "likes::module",
};
check_unique_columns(&[&lower], &[claim]).expect_err("a claim collides across case too");
let reads_upper = DerivationDef {
name: "dv_posts.sum_of_score",
column: "sum_of_score",
child_table: "DV_COMMENTS",
transform: "sum(Score)",
contrib_sql: "{c}.\"Score\"",
..count_def()
};
let maintains_lower = DerivationDef {
name: "dv_comments.score",
parent_table: "dv_comments",
column: "score",
..count_def()
};
check_source_columns(&[&maintains_lower, &reads_upper], &[])
.expect_err("a source matched across case is still maintained");
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn postgres_keeps_identifier_case_in_the_registry_checks() {
let (lower, upper) = case_variant_pair();
check_unique_columns(&[&lower, &upper], &[]).expect("two columns coexist");
}
#[test]
fn a_self_referential_derivation_cannot_read_its_own_column_at_boot_either() {
let self_read = DerivationDef {
name: "dv_comments.score",
parent_table: "dv_comments",
column: "score",
transform: "sum(score)",
filter: "",
filter_sql: "",
contrib_sql: "{c}.\"score\"",
..count_def()
};
let message = check_source_columns(&[&self_read], &[])
.expect_err("a derivation onto its own table cannot read its column")
.to_string();
assert!(
message.contains("reads the column it maintains"),
"{message}"
);
let own_fk = DerivationDef {
column: "post_id",
transform: "count",
contrib_sql: "1",
..self_read
};
check_source_columns(&[&own_fk], &[]).expect_err("the grouping key is a source");
let reply_count = DerivationDef {
column: "reply_count",
transform: "count",
contrib_sql: "1",
filter: "published",
filter_sql: " AND ({c}.\"published\" = TRUE)",
..self_read
};
check_source_columns(&[&reply_count], &[]).expect("a self-referential count is fine");
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_folds_identifier_case_in_the_self_reference_check() {
let folded = DerivationDef {
name: "Nodes.Score",
child_table: "nodes",
parent_table: "Nodes",
column: "Score",
transform: "sum(score)",
filter: "",
filter_sql: "",
contrib_sql: "{c}.\"score\"",
..count_def()
};
check_source_columns(&[&folded], &[]).expect_err("case variants are one column on SQLite");
}
#[test]
fn the_control_copy_of_the_state_migration_matches_the_standalone_set() {
assert_eq!(
include_str!("../migrations/20260907101530_create_derivations/up.sql"),
include_str!("../derivation_migrations/20260907101530_create_derivations/up.sql"),
);
assert_eq!(
include_str!("../migrations/20260907101530_create_derivations/down.sql"),
include_str!("../derivation_migrations/20260907101530_create_derivations/down.sql"),
);
}
#[test]
fn source_columns_are_read_off_the_lowered_sql() {
assert_eq!(source_columns(&count_def()), ["post_id", "published"]);
assert_eq!(
source_columns(&sum_def()),
["post_id", "score", "published", "score"]
);
let cast = DerivationDef {
filter_sql: " AND (CAST({c}.\"status\" AS TEXT) = 'featured' {bin})",
tenant_column: Some("org_id"),
..count_def()
};
assert_eq!(source_columns(&cast), ["post_id", "org_id", "status"]);
}
#[test]
fn a_derivation_cannot_group_or_scope_by_a_column_another_derivation_maintains() {
let reparenting = DerivationDef {
name: "dv_comments.post_id",
parent_table: "dv_comments",
column: "post_id",
fk_column: "thread_id",
filter: "",
filter_sql: "",
..count_def()
};
let message = check_source_columns(&[&reparenting, &count_def()], &[])
.expect_err("the grouping key is a source")
.to_string();
assert!(message.contains("dv_comments.post_id"), "{message}");
let retenanting = DerivationDef {
name: "dv_comments.org_id",
parent_table: "dv_comments",
column: "org_id",
filter: "",
filter_sql: "",
..count_def()
};
let scoped = DerivationDef {
tenant_column: Some("org_id"),
..count_def()
};
check_source_columns(&[&retenanting, &scoped], &[])
.expect_err("the tenant column is a source");
check_source_columns(&[&retenanting, &count_def()], &[])
.expect("an unscoped derivation does not read the tenant column");
}
#[test]
fn a_derivation_cannot_read_a_column_another_derivation_maintains_on_its_table() {
let leaf = DerivationDef {
name: "dv_comments.child_score",
parent_table: "dv_comments",
column: "child_score",
transform: "sum(score)",
filter: "",
filter_sql: "",
contrib_sql: "{c}.\"score\"",
..count_def()
};
let grand = DerivationDef {
name: "dv_posts.grand_score",
column: "grand_score",
transform: "sum(child_score)",
filter: "",
filter_sql: "",
contrib_sql: "{c}.\"child_score\"",
..count_def()
};
let message = check_source_columns(&[&leaf, &grand], &[])
.expect_err("a maintained column is not a source")
.to_string();
assert!(message.contains("dv_posts.grand_score"), "{message}");
assert!(message.contains("dv_comments.child_score"), "{message}");
let hot = DerivationDef {
name: "dv_posts.hot_comment_count",
column: "hot_comment_count",
filter: "child_score > 0",
filter_sql: " AND ({c}.\"child_score\" > 0)",
..count_def()
};
check_source_columns(&[&leaf, &hot], &[]).expect_err("a filter read is a read");
check_source_columns(&[&leaf, &sum_def()], &[]).expect("unmaintained sources are fine");
}
#[test]
fn a_derivation_cannot_read_a_column_a_counter_cache_maintains_but_may_read_a_hooked_one() {
let def = sum_def();
let tally = CounterCacheClaim {
model: "DvVote",
child_table: "dv_votes",
parent_table: "dv_comments",
column: "score",
direct_sql: true,
module_path: "votes::module",
};
let message = check_source_columns(&[&def], &[tally])
.expect_err("a counter cache's column is not a source")
.to_string();
assert!(message.contains("votes::module"), "{message}");
assert!(message.contains("dv_posts.visible_score"), "{message}");
let hooked = CounterCacheClaim {
direct_sql: false,
..tally
};
check_source_columns(&[&def], &[hooked]).expect("hooked columns may be read");
let elsewhere = CounterCacheClaim {
parent_table: "dv_posts",
..tally
};
check_source_columns(&[&def], &[elsewhere]).expect("another table's column is unrelated");
}
#[test]
fn a_derivation_on_a_plain_counter_cache_column_is_rejected() {
let def = count_def();
let claim = CounterCacheClaim {
model: "DvLike",
child_table: "dv_likes",
parent_table: def.parent_table,
column: def.column,
direct_sql: true,
module_path: "likes::module",
};
let err = check_unique_columns(&[&def], &[claim])
.expect_err("a counter cache and a derivation cannot share a column");
let message = err.to_string();
assert!(
message.contains("dv_posts.published_comment_count"),
"{message}"
);
assert!(message.contains("likes::module"), "{message}");
assert!(message.contains("dv_likes"), "{message}");
assert!(message.contains("counter cache"), "{message}");
let other = CounterCacheClaim {
column: "like_count",
..claim
};
check_unique_columns(&[&def], &[other]).expect("different columns coexist");
let commentable = CounterCacheClaim {
model: "app::models::DvPost",
child_table: "comments",
module_path: "#[commentable]",
..claim
};
let message = check_unique_columns(&[&def], &[commentable])
.expect_err("a commentable counter and a derivation cannot share a column")
.to_string();
assert!(
message.contains("#[commentable]::app::models::DvPost"),
"{message}"
);
}
#[test]
fn a_missing_state_table_is_recognised_and_other_errors_are_not() {
for message in [
"relation \"_autumn_derivations\" does not exist",
"no such table: _autumn_derivations",
] {
let error = AutumnError::from(std::io::Error::other(message));
assert!(is_missing_state_table(&error), "{message}");
}
for message in ["relation \"posts\" does not exist", "deadlock detected"] {
let error = AutumnError::from(std::io::Error::other(message));
assert!(!is_missing_state_table(&error), "{message}");
}
}
#[test]
fn a_status_row_serializes_every_field_the_actuator_documents() {
let status = DerivationStatus {
name: "dv_posts.published_comment_count".to_owned(),
definition_hash: Some(count_def().definition_hash()),
stored_hash: None,
backfill_state: Some(BackfillState::Pending),
checkpoint: None,
backfilled_rows: 0,
updated_at: None,
drift: Some(DRIFT_SCAN_LIMIT),
drift_error: None,
};
let json = serde_json::to_value(&status).expect("serialize");
let object = json.as_object().expect("a status is a JSON object");
let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"backfill_state",
"backfilled_rows",
"checkpoint",
"definition_hash",
"drift",
"drift_error",
"name",
"stored_hash",
"updated_at",
]
);
assert!(object["stored_hash"].is_null());
assert!(object["drift_error"].is_null());
assert_eq!(object["drift"], serde_json::json!(DRIFT_SCAN_LIMIT));
}
#[test]
fn two_derivations_sharing_a_name_are_rejected_with_both_module_paths() {
let first = count_def();
let mut second = count_def();
second.model = "DvOther";
second.module_path = "other::module";
let err = check_unique_names(&[&first, &second])
.expect_err("one name cannot carry two backfill states");
let message = err.to_string();
assert!(message.contains("tests::model_derivation"), "{message}");
assert!(message.contains("other::module"), "{message}");
}
#[test]
fn a_binary_with_no_derivation_registers_none() {
assert!(
registered_derivations().is_empty(),
"the library's own unit-test binary declares no derivation"
);
assert!(!has_derivation_descriptors());
}
}