use std::process::{Command, Stdio};
pub struct MoveSlotArgs {
pub from: String,
pub to: String,
pub table: String,
pub key_column: String,
pub id_column: String,
pub tenants: Vec<String>,
pub confirm: bool,
pub profile: Option<String>,
}
pub fn run_move_slot(args: &MoveSlotArgs) {
eprintln!("\u{1F342} autumn shard move-slot\n");
if args.tenants.is_empty() {
fail("at least one --tenant is required");
}
if args.from == args.to {
fail("--from and --to must be different shards");
}
validate_identifier("--table", &args.table);
validate_identifier("--key-column", &args.key_column);
validate_identifier("--id-column", &args.id_column);
let (from_url, to_url) = resolve_move_urls(args);
check_psql();
let filter = build_key_filter(&args.key_column, &args.tenants);
eprintln!(
"Moving {} tenant(s) on table {:?}: shard {:?} \u{2192} shard {:?}",
args.tenants.len(),
args.table,
args.from,
args.to
);
eprintln!(
"\u{26A0}\u{FE0F} These tenant(s) MUST be quiesced (read-only) on source shard {:?} for the\n \
whole move — from this copy until the post-cutover delete. A delete on the\n \
source mid-move is otherwise resurrected on {:?} after cutover. See the\n \
`move-slot` module runbook.",
args.from, args.to
);
eprintln!("\u{2192} Checking source\u{2026}");
let src_count = psql_scalar(&from_url, &count_sql(&args.table, &filter));
let mut verified_src_entries: Vec<String> = Vec::new();
if src_count == "0" {
eprintln!("\u{26A0}\u{FE0F} No rows found on source shard for the given tenant(s).");
} else {
let (src_entries, src_fp) =
source_snapshot(&from_url, &args.table, &args.id_column, &filter);
verified_src_entries = src_entries;
let dst_fp = row_fingerprints(&to_url, &args.table, &filter);
if dest_contains_source(&src_fp, &dst_fp) {
eprintln!(
"\u{2713} Destination already contains all source rows (previous dry-run); skipping copy."
);
} else {
eprintln!("\u{2192} Copying rows\u{2026}");
copy_rows(&from_url, &to_url, &args.table, &filter);
let dst_fp2 = row_fingerprints(&to_url, &args.table, &filter);
eprintln!(" source rows: {}", src_fp.len());
eprintln!(" dest rows: {}", dst_fp2.len());
if !dest_contains_source(&src_fp, &dst_fp2) {
fail(
"verification FAILED: destination does not contain all source rows. No rows deleted.",
);
}
}
eprintln!("\u{2713} Verified: destination contains all source rows.");
}
if src_count != "0" {
eprintln!(
"\u{2192} Advancing destination sequence for {:?}.{:?}\u{2026}",
args.table, args.id_column
);
run_psql(
&to_url,
&[
"--single-transaction",
"-c",
&reset_sequence_sql(&args.table, &args.id_column),
],
);
}
if !args.confirm {
eprintln!(
"\u{2713} Copy verified but source rows were KEPT (no --confirm).\n \
Next, route these tenant(s) to {:?}, then re-run with --confirm to\n \
delete the stale source rows.\n \
\u{26A0}\u{FE0F} Routing: prefer the directory router \
(database.directory_shard_router = true) and pin each moved tenant\n \
to {:?}. Do NOT simply remap a hash slot in autumn.toml unless you\n \
copied EVERY tenant key in that slot — a slot is shared by all keys\n \
that hash to it, so flipping it reroutes co-tenants you did not copy\n \
and their rows would be lost.",
args.to, args.to
);
return;
}
delete_source_and_report(&from_url, args, &filter, &verified_src_entries);
}
fn delete_source_and_report(from_url: &str, args: &MoveSlotArgs, filter: &str, entries: &[String]) {
eprintln!("\u{2192} Deleting rows from source (--confirm)\u{2026}");
let remaining =
delete_source_rows_by_id(from_url, &args.table, &args.id_column, filter, entries);
if remaining != "0" {
fail(&format!(
"{remaining} moved-tenant row(s) still remain on source shard {:?} after delete.\n \
These changed during the cutover window (or were written after the snapshot) and\n \
were preserved to avoid losing uncopied data. Route the affected tenant(s) to {:?},\n \
then re-run this move with --confirm to copy and remove the stragglers. Do NOT\n \
decommission the source shard until this reports 0 rows remaining.",
args.from, args.to
));
}
eprintln!(
"\u{2713} Done. Source rows removed; shard {:?} now owns these tenant(s).\n \
Confirm routing sends them to {:?}: pin each tenant in the directory\n \
router, or (hash routing only) ensure every key in the moved slot was\n \
copied — a shared slot must move as a whole or co-tenants lose data.",
args.to, args.to
);
}
fn delete_source_rows_by_id(
from_url: &str,
table: &str,
id_column: &str,
filter: &str,
entries: &[String],
) -> String {
use std::io::Write as _;
if entries.is_empty() {
eprintln!(" No verified source rows for these tenant(s); nothing deleted.");
return psql_scalar(from_url, &count_sql(table, filter));
}
let delete = delete_by_staged_ids_sql(table, id_column, filter);
let mut child = Command::new("psql")
.args([
from_url,
"-v",
"ON_ERROR_STOP=1",
"--single-transaction",
"-c",
stage_ids_create_sql(),
"-c",
stage_ids_copy_in_sql(),
"-c",
&delete,
])
.stdin(Stdio::piped())
.spawn()
.unwrap_or_else(|e| fail(&format!("failed to start delete psql: {e}")));
{
let mut stdin = child.stdin.take().expect("piped stdin");
for entry in entries {
writeln!(stdin, "{entry}")
.unwrap_or_else(|e| fail(&format!("failed to stream entries to psql: {e}")));
}
}
let status = child
.wait()
.unwrap_or_else(|e| fail(&format!("delete psql did not finish: {e}")));
if !status.success() {
fail("source delete failed (see psql output above).");
}
psql_scalar(from_url, &count_sql(table, filter))
}
fn resolve_move_urls(args: &MoveSlotArgs) -> (String, String) {
let effective = crate::migrate::effective_profile(args.profile.as_deref());
let table = crate::migrate::read_autumn_toml_table_with_profile(Some(&effective));
let shards = crate::migrate::resolve_shard_database_urls_from_sources(
|k| std::env::var(k),
table.as_ref(),
);
let from_url = resolve_shard_url(&args.from, &shards).unwrap_or_else(|e| fail(&e));
let to_url = resolve_shard_url(&args.to, &shards).unwrap_or_else(|e| fail(&e));
if from_url == to_url {
fail(
"--from and --to resolve to the same database URL (check profile / \
AUTUMN_DATABASE__SHARDS__* overrides); refusing to move a table onto itself",
);
}
(from_url, to_url)
}
fn resolve_shard_url(name: &str, shards: &[(String, String)]) -> Result<String, String> {
if let Some((_, url)) = shards.iter().find(|(n, _)| n == name) {
return Ok(url.clone());
}
let known: Vec<&str> = shards.iter().map(|(n, _)| n.as_str()).collect();
Err(if known.is_empty() {
format!("unknown shard {name:?}: no [[database.shards]] entries found")
} else {
format!("unknown shard {name:?}. Known shards: {}", known.join(", "))
})
}
fn is_valid_simple_identifier(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.enumerate()
.all(|(i, c)| c == '_' || c.is_ascii_alphanumeric() && !(i == 0 && c.is_ascii_digit()))
}
fn validate_identifier(flag: &str, value: &str) {
let parts: Vec<&str> = value.split('.').collect();
if parts.is_empty() || parts.len() > 2 {
fail(&format!(
"{flag} {value:?} must be a plain or schema-qualified SQL identifier \
(e.g. `bookmarks` or `public.bookmarks`)"
));
}
for part in &parts {
if !is_valid_simple_identifier(part) {
fail(&format!(
"{flag} {value:?} must be a plain or schema-qualified SQL identifier \
([A-Za-z_][A-Za-z0-9_]* parts)"
));
}
}
}
fn quote_identifier(value: &str) -> String {
value
.split('.')
.map(|part| format!("\"{part}\""))
.collect::<Vec<_>>()
.join(".")
}
fn build_key_filter(key_column: &str, tenants: &[String]) -> String {
let list = tenants
.iter()
.map(|t| format!("'{}'", t.replace('\'', "''")))
.collect::<Vec<_>>()
.join(", ");
let quoted_col = quote_identifier(key_column);
format!("{quoted_col}::text = ANY(ARRAY[{list}]::text[])")
}
fn count_sql(table: &str, filter: &str) -> String {
let qt = quote_identifier(table);
format!("SELECT count(*) FROM {qt} WHERE {filter}")
}
fn copy_out_sql(table: &str, filter: &str) -> String {
let qt = quote_identifier(table);
format!("\\copy (SELECT * FROM {qt} WHERE {filter}) TO STDOUT")
}
const STAGING_TABLE: &str = "__autumn_move_staging";
fn staging_create_sql(table: &str) -> String {
let qt = quote_identifier(table);
format!("CREATE TEMP TABLE {STAGING_TABLE} (LIKE {qt})")
}
fn staging_copy_in_sql() -> String {
format!("\\copy {STAGING_TABLE} FROM STDIN")
}
fn staging_insert_sql(table: &str) -> String {
let qt = quote_identifier(table);
format!("INSERT INTO {qt} SELECT * FROM {STAGING_TABLE} ON CONFLICT DO NOTHING")
}
fn reset_sequence_sql(table: &str, id_column: &str) -> String {
let qt = quote_identifier(table);
let qcol = quote_identifier(id_column);
let tbl_lit = qt.replace('\'', "''");
let col_lit = id_column.replace('\'', "''");
format!(
"DO $$ DECLARE seq text := pg_get_serial_sequence('{tbl_lit}', '{col_lit}'); mx bigint; \
BEGIN IF seq IS NOT NULL THEN SELECT max({qcol}) INTO mx FROM {qt}; \
PERFORM setval(seq, COALESCE(mx, 1), mx IS NOT NULL); END IF; END $$;"
)
}
fn copy_rows(from_url: &str, to_url: &str, table: &str, filter: &str) {
let mut src = Command::new("psql")
.args([
from_url,
"-v",
"ON_ERROR_STOP=1",
"-c",
©_out_sql(table, filter),
])
.stdout(Stdio::piped())
.spawn()
.unwrap_or_else(|e| fail(&format!("failed to start source psql: {e}")));
let src_out = src.stdout.take().expect("piped stdout");
let dst_status = Command::new("psql")
.args([
to_url,
"-v",
"ON_ERROR_STOP=1",
"--single-transaction",
"-c",
&staging_create_sql(table),
"-c",
&staging_copy_in_sql(),
"-c",
&staging_insert_sql(table),
])
.stdin(src_out)
.status()
.unwrap_or_else(|e| fail(&format!("failed to start destination psql: {e}")));
let src_status = src
.wait()
.unwrap_or_else(|e| fail(&format!("source psql did not finish: {e}")));
if !src_status.success() || !dst_status.success() {
fail("copy failed (see psql output above). No rows deleted.");
}
}
fn psql_scalar(url: &str, sql: &str) -> String {
let out = Command::new("psql")
.args([url, "-v", "ON_ERROR_STOP=1", "-At", "-c", sql])
.output()
.unwrap_or_else(|e| fail(&format!("failed to run psql: {e}")));
if !out.status.success() {
eprintln!("{}", String::from_utf8_lossy(&out.stderr));
fail("psql query failed (see output above)");
}
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
fn psql_lines(url: &str, sql: &str) -> Vec<String> {
let out = psql_scalar(url, sql);
if out.is_empty() {
return Vec::new();
}
out.lines().map(str::to_owned).collect()
}
fn row_fingerprints(url: &str, table: &str, filter: &str) -> Vec<String> {
let qt = quote_identifier(table);
psql_lines(
url,
&format!("SELECT md5(to_jsonb(t)::text) FROM {qt} t WHERE {filter} ORDER BY 1"),
)
}
fn source_snapshot_sql(table: &str, id_column: &str, filter: &str) -> String {
let qt = quote_identifier(table);
let qcol = quote_identifier(id_column);
format!("SELECT md5(to_jsonb(t)::text) || '|' || {qcol}::text FROM {qt} t WHERE {filter}")
}
fn source_snapshot(
url: &str,
table: &str,
id_column: &str,
filter: &str,
) -> (Vec<String>, Vec<String>) {
let entries = psql_lines(url, &source_snapshot_sql(table, id_column, filter));
let fps = entries
.iter()
.filter_map(|line| line.split_once('|').map(|(fp, _)| fp.to_owned()))
.collect();
(entries, fps)
}
const fn stage_ids_create_sql() -> &'static str {
"CREATE TEMP TABLE __autumn_del_ids (entry text)"
}
const fn stage_ids_copy_in_sql() -> &'static str {
"\\copy __autumn_del_ids FROM STDIN"
}
fn delete_by_staged_ids_sql(table: &str, id_column: &str, filter: &str) -> String {
let qt = quote_identifier(table);
let qcol = quote_identifier(id_column);
format!(
"DELETE FROM {qt} t WHERE {filter} AND EXISTS (\
SELECT 1 FROM __autumn_del_ids d \
WHERE d.entry = md5(to_jsonb(t)::text) || '|' || t.{qcol}::text)"
)
}
fn dest_contains_source(src: &[String], dst: &[String]) -> bool {
let mut available: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for f in dst {
*available.entry(f.as_str()).or_default() += 1;
}
for f in src {
match available.get_mut(f.as_str()) {
Some(n) if *n > 0 => *n -= 1,
_ => return false,
}
}
true
}
fn run_psql(url: &str, args: &[&str]) {
let mut full = vec![url];
full.extend_from_slice(args);
let status = Command::new("psql")
.args(&full)
.status()
.unwrap_or_else(|e| fail(&format!("failed to run psql: {e}")));
if !status.success() {
fail("psql command failed (see output above)");
}
}
fn check_psql() {
if Command::new("psql").arg("--version").output().is_err() {
fail(
"`psql` not found on PATH. Install the PostgreSQL client tools to use `autumn shard move-slot`.",
);
}
}
fn fail(msg: &str) -> ! {
eprintln!("\u{2717} {msg}");
std::process::exit(1);
}
#[cfg(test)]
mod tests {
use super::*;
fn shards() -> Vec<(String, String)> {
vec![
("shard0".to_owned(), "postgres://h/s0".to_owned()),
("shard1".to_owned(), "postgres://h/s1".to_owned()),
]
}
#[test]
fn resolve_shard_url_finds_by_name() {
assert_eq!(
resolve_shard_url("shard1", &shards()).unwrap(),
"postgres://h/s1"
);
}
#[test]
fn resolve_shard_url_unknown_lists_known() {
let err = resolve_shard_url("nope", &shards()).unwrap_err();
assert!(err.contains("shard0") && err.contains("shard1"), "{err}");
}
#[test]
fn resolve_shard_url_unknown_without_shards_explains() {
let err = resolve_shard_url("shard0", &[]).unwrap_err();
assert!(err.contains("no [[database.shards]]"), "{err}");
}
#[test]
fn build_key_filter_quotes_column_and_escapes_values() {
let f = build_key_filter("tenant_id", &["acme".to_owned(), "o'brien".to_owned()]);
assert_eq!(
f,
"\"tenant_id\"::text = ANY(ARRAY['acme', 'o''brien']::text[])"
);
}
#[test]
fn sql_builders_quote_table_and_interpolate_filter() {
let f = build_key_filter("tenant_id", &["acme".to_owned()]);
assert_eq!(
count_sql("bookmarks", &f),
"SELECT count(*) FROM \"bookmarks\" WHERE \"tenant_id\"::text = ANY(ARRAY['acme']::text[])"
);
assert_eq!(
source_snapshot_sql("bookmarks", "id", &f),
"SELECT md5(to_jsonb(t)::text) || '|' || \"id\"::text FROM \"bookmarks\" t \
WHERE \"tenant_id\"::text = ANY(ARRAY['acme']::text[])"
);
assert_eq!(
delete_by_staged_ids_sql("bookmarks", "id", &f),
"DELETE FROM \"bookmarks\" t WHERE \"tenant_id\"::text = ANY(ARRAY['acme']::text[]) \
AND EXISTS (SELECT 1 FROM __autumn_del_ids d \
WHERE d.entry = md5(to_jsonb(t)::text) || '|' || t.\"id\"::text)"
);
assert!(
copy_out_sql("bookmarks", &f).starts_with("\\copy (SELECT * FROM \"bookmarks\" WHERE")
);
assert_eq!(
staging_create_sql("bookmarks"),
"CREATE TEMP TABLE __autumn_move_staging (LIKE \"bookmarks\")"
);
assert_eq!(
staging_copy_in_sql(),
"\\copy __autumn_move_staging FROM STDIN"
);
assert_eq!(
staging_insert_sql("bookmarks"),
"INSERT INTO \"bookmarks\" SELECT * FROM __autumn_move_staging ON CONFLICT DO NOTHING"
);
}
#[test]
fn sql_builders_support_schema_qualified_table() {
let f = build_key_filter("tenant_id", &["acme".to_owned()]);
assert_eq!(
count_sql("public.bookmarks", &f),
"SELECT count(*) FROM \"public\".\"bookmarks\" WHERE \"tenant_id\"::text = ANY(ARRAY['acme']::text[])"
);
assert_eq!(
staging_create_sql("public.bookmarks"),
"CREATE TEMP TABLE __autumn_move_staging (LIKE \"public\".\"bookmarks\")"
);
assert_eq!(
staging_insert_sql("public.bookmarks"),
"INSERT INTO \"public\".\"bookmarks\" SELECT * FROM __autumn_move_staging ON CONFLICT DO NOTHING"
);
}
#[test]
fn reset_sequence_sql_quotes_and_guards() {
let sql = reset_sequence_sql("bookmarks", "id");
assert!(
sql.contains("pg_get_serial_sequence('\"bookmarks\"', 'id')"),
"{sql}"
);
assert!(
sql.contains("SELECT max(\"id\") INTO mx FROM \"bookmarks\""),
"{sql}"
);
assert!(sql.contains("IF seq IS NOT NULL"), "{sql}");
assert!(
sql.contains("setval(seq, COALESCE(mx, 1), mx IS NOT NULL)"),
"{sql}"
);
let sql = reset_sequence_sql("public.bookmarks", "id");
assert!(
sql.contains("pg_get_serial_sequence('\"public\".\"bookmarks\"', 'id')"),
"{sql}"
);
}
#[test]
fn validate_identifier_rejects_injection() {
for ok in ["bookmarks", "tenant_id", "_t", "t1", "public.bookmarks"] {
assert!(is_valid_schema_or_simple(ok), "{ok} should be valid");
}
for bad in ["", "1table", "drop table", "a;b", "a-b", "a.b.c"] {
assert!(!is_valid_schema_or_simple(bad), "{bad} should be invalid");
}
}
fn is_valid_schema_or_simple(value: &str) -> bool {
let parts: Vec<&str> = value.split('.').collect();
if parts.is_empty() || parts.len() > 2 {
return false;
}
parts.iter().all(|p| is_valid_simple_identifier(p))
}
#[test]
fn dest_contains_source_handles_subset_superset_and_multiplicity() {
let v = |xs: &[&str]| xs.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
assert!(dest_contains_source(&v(&[]), &v(&[])));
assert!(dest_contains_source(&v(&[]), &v(&["a"])));
assert!(dest_contains_source(&v(&["a", "b"]), &v(&["a", "b"])));
assert!(dest_contains_source(&v(&["a", "b"]), &v(&["a", "b", "c"])));
assert!(!dest_contains_source(&v(&["a", "b"]), &v(&["a"])));
assert!(!dest_contains_source(&v(&["a"]), &v(&[])));
assert!(!dest_contains_source(&v(&["a", "a"]), &v(&["a"])));
assert!(dest_contains_source(&v(&["a", "a"]), &v(&["a", "a", "a"])));
}
}