use crate::__bypass::RawPoolAccessExt as _;
use crate::context::DjogiContext;
use crate::error::{DbError, DjogiError};
use crate::live_migrate::state::{self, PlanStatus};
use crate::transaction::atomic;
use crate::types::HeerId;
pub(crate) const SIDE_EFFECT_SUPPRESSION_TXN_LOCAL: &str = "djogi.live_migrate.suppress_events";
static SUPPRESS_EVENTS_SET_LOCAL_SQL: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| {
format!("SELECT set_config('{SIDE_EFFECT_SUPPRESSION_TXN_LOCAL}', $1, true)")
});
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackfillChunk {
pub ordinal: u64,
pub rows_affected: u64,
pub rows_done_total: u64,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BackfillError {
#[error("plan {0} not found")]
PlanNotFound(HeerId),
#[error("plan {0} not in running state (status: {1})")]
NotRunning(HeerId, &'static str),
#[error("predicate must be idempotent — pattern marked transform non-idempotent")]
NonIdempotentPredicate,
#[error("predicate template missing required `LIMIT $1` placeholder")]
MalformedPredicateTemplate,
#[error("invalid table name {table:?}: {reason}")]
InvalidTable { table: String, reason: &'static str },
#[error("chunk_size must be greater than zero")]
ZeroChunkSize,
#[error(transparent)]
Database(#[from] DjogiError),
#[error("backfill chunk produced 0 rows but predicate did not exhaust")]
PredicateStuck,
}
fn validate_table_ident(table: &str) -> Result<(), BackfillError> {
let bytes = table.as_bytes();
if bytes.is_empty() {
return Err(BackfillError::InvalidTable {
table: table.to_owned(),
reason: "table name must not be empty",
});
}
if bytes.len() > 63 {
return Err(BackfillError::InvalidTable {
table: table.to_owned(),
reason: "table name exceeds Postgres's 63-byte identifier limit",
});
}
let first = bytes[0];
if !(first.is_ascii_alphabetic() || first == b'_') {
return Err(BackfillError::InvalidTable {
table: table.to_owned(),
reason: "table name must start with an ASCII letter or underscore",
});
}
for &byte in &bytes[1..] {
if !(byte.is_ascii_alphanumeric() || byte == b'_') {
return Err(BackfillError::InvalidTable {
table: table.to_owned(),
reason: "table name must contain only ASCII alphanumerics and underscores",
});
}
}
Ok(())
}
fn validate_predicate_template(template: &str) -> Result<(), BackfillError> {
if !template.contains("LIMIT $1") {
return Err(BackfillError::MalformedPredicateTemplate);
}
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' {
i += 1;
continue;
}
let next = match bytes.get(i + 1) {
Some(&b) => b,
None => break, };
if !next.is_ascii_digit() {
i += 1;
continue;
}
let digits_start = i + 1;
let mut digits_end = digits_start;
while digits_end < bytes.len() && bytes[digits_end].is_ascii_digit() {
digits_end += 1;
}
let digit_run = digits_end - digits_start;
if !(digit_run == 1 && bytes[digits_start] == b'1') {
return Err(BackfillError::MalformedPredicateTemplate);
}
i = digits_end;
}
Ok(())
}
pub(crate) fn compute_rows_done_total(rows_already: u64, rows_just_added: u64) -> u64 {
const PERSISTED_MAX: u64 = i64::MAX as u64;
let raw = rows_already.saturating_add(rows_just_added);
if raw > PERSISTED_MAX {
PERSISTED_MAX
} else {
raw
}
}
pub async fn execute_backfill(
ctx: &mut DjogiContext,
plan_id: HeerId,
table: &str,
predicate_template: &str,
chunk_size: u32,
emit_events: bool,
) -> Result<Vec<BackfillChunk>, BackfillError> {
drive_chunks(
ctx,
plan_id,
table,
predicate_template,
chunk_size,
emit_events,
ResumeFrom::Initial,
)
.await
}
pub async fn resume_backfill(
ctx: &mut DjogiContext,
plan_id: HeerId,
table: &str,
predicate_template: &str,
chunk_size: u32,
emit_events: bool,
) -> Result<Vec<BackfillChunk>, BackfillError> {
drive_chunks(
ctx,
plan_id,
table,
predicate_template,
chunk_size,
emit_events,
ResumeFrom::Persisted,
)
.await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResumeFrom {
Initial,
Persisted,
}
#[allow(clippy::too_many_arguments)]
async fn drive_chunks(
ctx: &mut DjogiContext,
plan_id: HeerId,
table: &str,
predicate_template: &str,
chunk_size: u32,
emit_events: bool,
resume_from: ResumeFrom,
) -> Result<Vec<BackfillChunk>, BackfillError> {
if chunk_size == 0 {
return Err(BackfillError::ZeroChunkSize);
}
validate_table_ident(table)?;
validate_predicate_template(predicate_template)?;
let pool = ctx
.raw_pool()
.ok_or_else(|| {
DjogiError::Db(DbError::other(
"execute_backfill / resume_backfill require a pool-backed DjogiContext; \
each chunk opens its own transaction and cannot nest inside an outer one",
))
})?
.clone();
let row = lookup_plan_row(ctx, plan_id).await?;
if row.status != PlanStatus::Running {
return Err(BackfillError::NotRunning(plan_id, row.status.as_db_str()));
}
let starting_rows_done = match resume_from {
ResumeFrom::Initial => 0_u64,
ResumeFrom::Persisted => row.backfill_rows_done.max(0) as u64,
};
let target_database = row.target_database.clone();
let app_label = row.app_label.clone();
let chunk_sql = format!("UPDATE {table} {predicate_template}");
let chunk_size_i64 = i64::from(chunk_size);
let mut chunks: Vec<BackfillChunk> = Vec::new();
let mut rows_done_total = starting_rows_done;
let mut ordinal: u64 = 0;
let chunk_size_u64 = u64::from(chunk_size);
loop {
ordinal = ordinal.saturating_add(1);
let outcome = run_one_chunk(
&pool,
&chunk_sql,
chunk_size_i64,
chunk_size_u64,
emit_events,
plan_id,
&target_database,
&app_label,
rows_done_total,
)
.await?;
rows_done_total = compute_rows_done_total(rows_done_total, outcome.rows_affected);
chunks.push(BackfillChunk {
ordinal,
rows_affected: outcome.rows_affected,
rows_done_total,
});
if outcome.rows_affected == 0 && ordinal == 1 {
break;
}
if outcome.rows_affected == 0 {
return Err(BackfillError::PredicateStuck);
}
if outcome.rows_affected < chunk_size_u64 {
break;
}
}
Ok(chunks)
}
#[allow(clippy::too_many_arguments)]
async fn run_one_chunk(
pool: &crate::pg::pool::DjogiPool,
chunk_sql: &str,
chunk_size_i64: i64,
chunk_size_u64: u64,
emit_events: bool,
plan_id: HeerId,
target_database: &str,
app_label: &str,
prior_rows_done: u64,
) -> Result<ChunkOutcome, BackfillError> {
let chunk_sql_owned = chunk_sql.to_owned();
let target_database_owned = target_database.to_owned();
let app_label_owned = app_label.to_owned();
let outcome = atomic(pool, move |tx_ctx| {
Box::pin(async move {
if !emit_events {
tx_ctx
.execute(SUPPRESS_EVENTS_SET_LOCAL_SQL.as_str(), &[&"1"])
.await?;
}
let rows_affected = tx_ctx.execute(&chunk_sql_owned, &[&chunk_size_i64]).await?;
let new_rows_done_u64 = compute_rows_done_total(prior_rows_done, rows_affected);
let new_rows_done_i64 = i64::try_from(new_rows_done_u64).unwrap_or(i64::MAX);
state::update_progress(
tx_ctx,
plan_id,
&target_database_owned,
&app_label_owned,
new_rows_done_i64,
)
.await?;
if rows_affected < chunk_size_u64 {
state::update_status(
tx_ctx,
plan_id,
&target_database_owned,
&app_label_owned,
PlanStatus::Validating,
)
.await?;
}
Ok::<ChunkOutcome, DjogiError>(ChunkOutcome { rows_affected })
})
})
.await
.map_err(BackfillError::from)?;
Ok(outcome)
}
struct ChunkOutcome {
rows_affected: u64,
}
async fn lookup_plan_row(
ctx: &mut DjogiContext,
plan_id: HeerId,
) -> Result<state::LivePlanRow, BackfillError> {
let sql = "SELECT plan_id, slug, plan_file_checksum, classification, status, \
current_step, current_step_index, backfill_rows_done, \
backfill_rows_total, started_at, last_progress_at, completed_at, \
last_error, originating_migration, target_database, app_label, daemon_session_token \
FROM djogi_live_plans WHERE plan_id = $1";
let plan_id_i64 = plan_id.as_i64();
let row_opt = ctx
.query_opt(sql, &[&plan_id_i64])
.await
.map_err(BackfillError::from)?;
let row = row_opt.ok_or(BackfillError::PlanNotFound(plan_id))?;
parse_live_plan_row(&row).map_err(BackfillError::from)
}
fn parse_live_plan_row(row: &tokio_postgres::Row) -> Result<state::LivePlanRow, DjogiError> {
use crate::live_migrate::plan::PlanClassification;
use time::OffsetDateTime;
let plan_id_i64: i64 = row
.try_get(0)
.map_err(|e| DjogiError::Db(DbError::other(format!("plan_id read failed: {e}"))))?;
let plan_id = HeerId::from_i64(plan_id_i64)
.map_err(|e| DjogiError::Db(DbError::other(format!("invalid plan_id in row: {e}"))))?;
let slug: String = row
.try_get(1)
.map_err(|e| DjogiError::Db(DbError::other(format!("slug read failed: {e}"))))?;
let plan_file_checksum: String = row
.try_get(2)
.map_err(|e| DjogiError::Db(DbError::other(format!("checksum read failed: {e}"))))?;
let classification_s: String = row
.try_get(3)
.map_err(|e| DjogiError::Db(DbError::other(format!("classification read failed: {e}"))))?;
let classification = PlanClassification::from_db_str(&classification_s).ok_or_else(|| {
DjogiError::Db(DbError::other(format!(
"unknown classification in djogi_live_plans: {classification_s:?}"
)))
})?;
let status_s: String = row
.try_get(4)
.map_err(|e| DjogiError::Db(DbError::other(format!("status read failed: {e}"))))?;
let status = PlanStatus::from_db_str(&status_s).ok_or_else(|| {
DjogiError::Db(DbError::other(format!(
"unknown status in djogi_live_plans: {status_s:?}"
)))
})?;
let current_step: Option<String> = row
.try_get(5)
.map_err(|e| DjogiError::Db(DbError::other(format!("current_step read failed: {e}"))))?;
let current_step_index: i32 = row.try_get(6).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"current_step_index read failed: {e}"
)))
})?;
let backfill_rows_done: i64 = row.try_get(7).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"backfill_rows_done read failed: {e}"
)))
})?;
let backfill_rows_total: Option<i64> = row.try_get(8).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"backfill_rows_total read failed: {e}"
)))
})?;
let started_at: Option<OffsetDateTime> = row
.try_get(9)
.map_err(|e| DjogiError::Db(DbError::other(format!("started_at read failed: {e}"))))?;
let last_progress_at: Option<OffsetDateTime> = row.try_get(10).map_err(|e| {
DjogiError::Db(DbError::other(format!("last_progress_at read failed: {e}")))
})?;
let completed_at: Option<OffsetDateTime> = row
.try_get(11)
.map_err(|e| DjogiError::Db(DbError::other(format!("completed_at read failed: {e}"))))?;
let last_error: Option<String> = row
.try_get(12)
.map_err(|e| DjogiError::Db(DbError::other(format!("last_error read failed: {e}"))))?;
let originating_migration: String = row.try_get(13).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"originating_migration read failed: {e}"
)))
})?;
let target_database: String = row
.try_get(14)
.map_err(|e| DjogiError::Db(DbError::other(format!("target_database read failed: {e}"))))?;
let app_label: String = row
.try_get(15)
.map_err(|e| DjogiError::Db(DbError::other(format!("app_label read failed: {e}"))))?;
let daemon_session_token: Option<String> = row.try_get(16).map_err(|e| {
DjogiError::Db(DbError::other(format!(
"daemon_session_token read failed: {e}"
)))
})?;
Ok(state::LivePlanRow {
plan_id,
slug,
plan_file_checksum,
classification,
status,
current_step,
current_step_index,
backfill_rows_done,
backfill_rows_total,
started_at,
last_progress_at,
completed_at,
last_error,
originating_migration,
target_database,
app_label,
daemon_session_token,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backfill_error_plan_not_found_displays_plan_id() {
let id = HeerId::from_i64(42).expect("HeerId::from_i64(42) is valid");
let msg = format!("{}", BackfillError::PlanNotFound(id));
assert!(msg.contains("42"), "expected plan id in message: {msg}");
assert!(msg.contains("not found"), "expected `not found`: {msg}");
}
#[test]
fn backfill_error_not_running_displays_status_label() {
let id = HeerId::from_i64(7).expect("HeerId::from_i64(7) is valid");
let msg = format!("{}", BackfillError::NotRunning(id, "paused"));
assert!(msg.contains("7"), "expected plan id: {msg}");
assert!(msg.contains("paused"), "expected status label: {msg}");
}
#[test]
fn backfill_error_non_idempotent_predicate_message_actionable() {
let msg = format!("{}", BackfillError::NonIdempotentPredicate);
assert!(msg.contains("idempotent"), "expected `idempotent`: {msg}");
}
#[test]
fn backfill_error_malformed_template_mentions_limit() {
let msg = format!("{}", BackfillError::MalformedPredicateTemplate);
assert!(msg.contains("LIMIT"), "expected `LIMIT` hint: {msg}");
assert!(msg.contains("$1"), "expected `$1` hint: {msg}");
}
#[test]
fn backfill_error_invalid_table_includes_table_and_reason() {
let err = BackfillError::InvalidTable {
table: "1bad".to_owned(),
reason: "table name must start with an ASCII letter or underscore",
};
let msg = format!("{err}");
assert!(msg.contains("1bad"), "expected table name: {msg}");
assert!(
msg.contains("ASCII letter or underscore"),
"expected reason: {msg}",
);
}
#[test]
fn backfill_error_zero_chunk_size_actionable() {
let msg = format!("{}", BackfillError::ZeroChunkSize);
assert!(
msg.contains("greater than zero") || msg.contains("> 0"),
"expected actionable hint: {msg}",
);
}
#[test]
fn backfill_error_predicate_stuck_actionable() {
let msg = format!("{}", BackfillError::PredicateStuck);
assert!(
msg.contains("0 rows") && msg.contains("exhaust"),
"expected stuck-predicate hint: {msg}",
);
}
#[test]
fn compute_rows_done_total_zero_added_is_passthrough() {
assert_eq!(compute_rows_done_total(0, 0), 0);
assert_eq!(compute_rows_done_total(7, 0), 7);
}
#[test]
fn compute_rows_done_total_typical_chunk() {
assert_eq!(compute_rows_done_total(0, 10_000), 10_000);
assert_eq!(compute_rows_done_total(10_000, 10_000), 20_000);
assert_eq!(compute_rows_done_total(20_000, 4_321), 24_321);
}
#[test]
fn compute_rows_done_total_saturates_on_overflow() {
let cap = i64::MAX as u64;
assert_eq!(
compute_rows_done_total(cap - 5, 100),
cap,
"overflow must saturate at i64::MAX as u64, not u64::MAX",
);
assert_eq!(compute_rows_done_total(cap, 1), cap);
assert_eq!(compute_rows_done_total(cap, 0), cap);
assert_eq!(compute_rows_done_total(u64::MAX - 5, 100), cap);
assert_eq!(compute_rows_done_total(u64::MAX, 0), cap);
}
#[test]
fn validate_table_ident_accepts_plain_identifiers() {
assert!(validate_table_ident("vehicles").is_ok());
assert!(validate_table_ident("vehicle_audit_log").is_ok());
assert!(validate_table_ident("_internal").is_ok());
assert!(validate_table_ident("t1").is_ok());
let at_limit: String = "a".repeat(63);
assert!(validate_table_ident(&at_limit).is_ok());
}
#[test]
fn validate_table_ident_rejects_empty() {
let err = validate_table_ident("").expect_err("empty must fail");
match err {
BackfillError::InvalidTable { table, .. } => assert_eq!(table, ""),
other => panic!("wrong variant: {other:?}"),
}
}
#[test]
fn validate_table_ident_rejects_leading_digit() {
let err = validate_table_ident("9vehicles").expect_err("leading digit must fail");
let msg = format!("{err}");
assert!(
msg.contains("ASCII letter or underscore"),
"expected leading-byte reason: {msg}",
);
}
#[test]
fn validate_table_ident_rejects_punctuation_payload() {
assert!(validate_table_ident("vehicles; DROP TABLE users;").is_err());
assert!(validate_table_ident("vehicles\"; --").is_err());
assert!(validate_table_ident("public.vehicles").is_err()); }
#[test]
fn validate_table_ident_rejects_oversized() {
let oversized: String = "a".repeat(64);
let err = validate_table_ident(&oversized).expect_err("oversized must fail");
let msg = format!("{err}");
assert!(msg.contains("63"), "expected 63-byte limit reason: {msg}");
}
#[test]
fn validate_table_ident_rejects_non_ascii() {
assert!(validate_table_ident("café").is_err());
assert!(validate_table_ident("naïve_table").is_err());
}
#[test]
fn validate_predicate_template_accepts_canonical_shape() {
let template =
"SET new_col = derive_from_old(old_col) WHERE new_col IS NULL LIMIT $1 RETURNING id";
assert!(validate_predicate_template(template).is_ok());
}
#[test]
fn validate_predicate_template_rejects_missing_limit() {
let template = "SET new_col = derive(old_col) WHERE new_col IS NULL RETURNING id";
let err = validate_predicate_template(template).expect_err("missing LIMIT $1 must fail");
assert!(matches!(err, BackfillError::MalformedPredicateTemplate));
}
#[test]
fn validate_predicate_template_rejects_wrong_placeholder() {
let template = "SET new_col = derive(old_col) WHERE new_col IS NULL LIMIT $2 RETURNING id";
let err = validate_predicate_template(template).expect_err("wrong placeholder must fail");
assert!(matches!(err, BackfillError::MalformedPredicateTemplate));
}
#[test]
fn validate_predicate_template_rejects_extra_placeholder_alongside_limit() {
let template = "SET new_col = $2 WHERE new_col IS NULL LIMIT $1 RETURNING id";
let err = validate_predicate_template(template).expect_err("stray $2 must fail");
assert!(matches!(err, BackfillError::MalformedPredicateTemplate));
}
#[test]
fn validate_predicate_template_rejects_three_or_higher() {
for stray in ["$3", "$4", "$5", "$6", "$7", "$8", "$9"] {
let template = format!(
"SET new_col = derive({stray}, old_col) WHERE new_col IS NULL LIMIT $1 RETURNING id",
);
let err = validate_predicate_template(&template)
.err()
.unwrap_or_else(|| panic!("expected reject for stray {stray}; got ok"));
assert!(matches!(err, BackfillError::MalformedPredicateTemplate));
}
}
#[test]
fn validate_predicate_template_rejects_multi_digit_placeholder() {
let template = "SET new_col = $10 WHERE new_col IS NULL LIMIT $1 RETURNING id";
let err = validate_predicate_template(template).expect_err("$10 must fail");
assert!(matches!(err, BackfillError::MalformedPredicateTemplate));
let template = "SET new_col = $11 WHERE new_col IS NULL LIMIT $1 RETURNING id";
let err = validate_predicate_template(template).expect_err("$11 must fail");
assert!(matches!(err, BackfillError::MalformedPredicateTemplate));
}
#[test]
fn validate_predicate_template_allows_dollar_followed_by_non_digit() {
let template = "SET new_col = $tag$value$tag$ WHERE new_col IS NULL LIMIT $1 RETURNING id";
assert!(validate_predicate_template(template).is_ok());
let template = "SET new_col = '$' WHERE new_col IS NULL LIMIT $1 RETURNING id";
assert!(validate_predicate_template(template).is_ok());
}
#[test]
fn validate_predicate_template_handles_trailing_dollar() {
let template = "SET col = 'price$' WHERE col IS NULL LIMIT $1";
assert!(validate_predicate_template(template).is_ok());
}
#[test]
fn side_effect_suppression_guc_name_uses_djogi_namespace() {
assert!(
SIDE_EFFECT_SUPPRESSION_TXN_LOCAL.starts_with("djogi."),
"side-effect suppression flag must live under djogi.*: {SIDE_EFFECT_SUPPRESSION_TXN_LOCAL:?}",
);
assert!(
SIDE_EFFECT_SUPPRESSION_TXN_LOCAL.contains("suppress_events"),
"name must reflect intent: {SIDE_EFFECT_SUPPRESSION_TXN_LOCAL:?}",
);
}
#[test]
fn suppress_events_set_local_sql_embeds_canonical_guc_name() {
let sql = SUPPRESS_EVENTS_SET_LOCAL_SQL.as_str();
assert!(
sql.contains(SIDE_EFFECT_SUPPRESSION_TXN_LOCAL),
"set_config SQL must embed the canonical GUC name verbatim: \
SQL = {sql:?}, GUC = {SIDE_EFFECT_SUPPRESSION_TXN_LOCAL:?}",
);
assert!(
sql.contains("set_config") && sql.contains("$1") && sql.contains("true"),
"set_config SQL must use the function form with `true` (SET LOCAL): {sql:?}",
);
}
}