feat(core): dry-run mode for reclassify, and a per-kind tally
CI / Build, lint & test (push) Successful in 3m7s
CI / Build, lint & test (push) Successful in 3m7s
Production is frozen and sits on schema 19, so the content_kind correction it
needs cannot be applied yet. `--dry-run` runs the real UPDATEs and rolls the
transaction back, so an operator can see exactly what a run would touch before
touching a database they cannot easily restore — the counts are measured, not
predicted.
`updated_by_kind` reports the SHAPE of the change ("consumable 17, staff 3"),
which is the thing worth sanity-checking: a different shape means the source
profile moved and the mapping needs regenerating.
Measured against a copy of prod-core.db: 20 rows would change (17 consumable,
3 staff), 1966 already correct, 0 unmatched, and the copy was verified unchanged
afterwards.
This commit is contained in:
+10
-6
@@ -57,20 +57,24 @@ async fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `openfut-core reclassify <request.json>` — correct the content_kind of
|
||||
// already-imported owned rows. A profile import is once-only, so a taxonomy
|
||||
// fix cannot arrive by re-importing; the adapter supplies card_id -> kind
|
||||
// because only it can map its own taxonomy. Idempotent.
|
||||
// `openfut-core reclassify <request.json> [--dry-run]` — correct the
|
||||
// content_kind of already-imported owned rows. A profile import is
|
||||
// once-only, so a taxonomy fix cannot arrive by re-importing; the adapter
|
||||
// supplies card_id -> kind because only it can map its own taxonomy.
|
||||
// Idempotent. `--dry-run` runs the same statements and rolls back, so an
|
||||
// operator can see what a production run would touch before it touches it.
|
||||
if std::env::args().nth(1).as_deref() == Some("reclassify") {
|
||||
let path = std::env::args()
|
||||
.nth(2)
|
||||
.context("usage: openfut-core reclassify <request.json>")?;
|
||||
.context("usage: openfut-core reclassify <request.json> [--dry-run]")?;
|
||||
let dry_run = std::env::args().any(|a| a == "--dry-run");
|
||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("read reclassify request {path}"))?;
|
||||
let req: openfut_core::services::import::ReclassifyRequest =
|
||||
let mut req: openfut_core::services::import::ReclassifyRequest =
|
||||
serde_json::from_str(&raw).context("parse reclassify request JSON")?;
|
||||
req.dry_run |= dry_run;
|
||||
let outcome = openfut_core::services::import::reclassify_owned_content(&pool, &req).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||
return Ok(());
|
||||
|
||||
+29
-2
@@ -19,6 +19,8 @@
|
||||
//! non-imported profile is never clobbered.
|
||||
//! - The whole thing commits together or not at all.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::db::Pool;
|
||||
use crate::models::card::ContentKind;
|
||||
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
||||
@@ -385,16 +387,26 @@ pub struct ContentKindAssignment {
|
||||
pub struct ReclassifyRequest {
|
||||
pub game_id: String,
|
||||
pub assignments: Vec<ContentKindAssignment>,
|
||||
/// Compute the outcome and roll back instead of committing. Lets an operator
|
||||
/// see exactly what a production run would touch before it touches it.
|
||||
#[serde(default)]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct ReclassifyOutcome {
|
||||
/// Rows whose `content_kind` actually changed.
|
||||
/// Rows whose `content_kind` actually changed. On a dry run, the rows that
|
||||
/// WOULD change; nothing is committed.
|
||||
pub updated: usize,
|
||||
/// Rows already carrying the requested kind (a rerun updates nothing).
|
||||
pub unchanged: usize,
|
||||
/// Assignments naming a definition this game owns no copy of.
|
||||
pub unmatched_definitions: Vec<String>,
|
||||
/// True when the transaction was rolled back rather than committed.
|
||||
pub dry_run: bool,
|
||||
/// Per-kind tally of the rows that changed, so an operator can sanity-check
|
||||
/// the shape of the change ("3 staff, 17 consumable") before committing.
|
||||
pub updated_by_kind: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
/// Correct the `content_kind` of ALREADY-IMPORTED owned rows, in one transaction.
|
||||
@@ -419,6 +431,7 @@ pub async fn reclassify_owned_content(
|
||||
let mut updated = 0usize;
|
||||
let mut unchanged = 0usize;
|
||||
let mut unmatched = Vec::new();
|
||||
let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for a in &req.assignments {
|
||||
// Scope by game through the owning club, so the same definition id in
|
||||
// another game is never touched.
|
||||
@@ -452,11 +465,25 @@ pub async fn reclassify_owned_content(
|
||||
.rows_affected() as usize;
|
||||
updated += changed;
|
||||
unchanged += present as usize - changed;
|
||||
if changed > 0 {
|
||||
*by_kind
|
||||
.entry(a.content_kind.as_str().to_string())
|
||||
.or_default() += changed;
|
||||
}
|
||||
}
|
||||
// A dry run does the real UPDATEs and then throws them away, so the counts
|
||||
// it reports are measured rather than predicted — the same statements, the
|
||||
// same WHERE clauses, just no commit.
|
||||
if req.dry_run {
|
||||
tx.rollback().await?;
|
||||
} else {
|
||||
tx.commit().await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(ReclassifyOutcome {
|
||||
updated,
|
||||
unchanged,
|
||||
unmatched_definitions: unmatched,
|
||||
dry_run: req.dry_run,
|
||||
updated_by_kind: by_kind,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user