Keep complete schema diagnostics across update rollback

This commit is contained in:
Дмитрий committed 2026-10-10 19:34:52 +05:00
1 parent e34ab45443
commit dc2bffc57e
4 files changed
+64 -5

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Failed database checks keep a complete report after rollback
---
Schema errors show readable default values and save every detected difference for troubleshooting, including after the server is restored.
+37 -1
View File
@@ -39,6 +39,34 @@ pub fn require_release_contract(root: &Path) -> Result<Contract> {
#[derive(Debug, Serialize)]
pub struct Problem { pub database: String, pub table: String, pub column: String, pub detail: String }
/// Keep all differences for support; never change a database to match a contract automatically.
pub fn check_with_report(db: &Db, root: &Path) -> Result<Vec<Problem>> {
let problems = check(db, root)?;
let report = serde_json::json!({
"schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(),
"realm": db.realm().name(), "problems": problems,
});
let path = crate::registry::metadata_dir_for(root)?.join("diagnostics")
.join(format!("schema-validation-{}.json", db.realm().name()));
// A log write failure must neither hide the original mismatch nor accept an invalid schema.
if let Err(error) = fsx::atomic_write_json(&path, &report) {
tracing::warn!(%error, path = %path.display(), "Could not save database schema report");
}
Ok(problems)
}
fn describe_signature(signature: &str) -> String {
let fields: Vec<_> = signature.split('|').collect();
if fields.len() != 4 { return signature.into(); }
let decode = |value: &str| -> String {
if value == "<NULL>" { return "no default".into(); }
if value == "<NONE>" { return "none".into(); }
hex::decode(value).ok().and_then(|bytes| String::from_utf8(bytes).ok())
.map(|text| format!("{text:?}")).unwrap_or_else(|| value.into())
};
format!("type={}, nullable={}, default={}, extra={}", fields[0], fields[1], decode(fields[2]), decode(fields[3]))
}
fn read_columns(db: &Db, kind: &str) -> Result<BTreeMap<String, BTreeMap<String, String>>> {
let schema = db.realm_schema(crate::db::schema_of(kind)?);
let text = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME,CONCAT(COLUMN_TYPE,'|',IS_NULLABLE,'|',IF(COLUMN_DEFAULT IS NULL,'<NULL>',HEX(COLUMN_DEFAULT)),'|',IF(EXTRA='','<NONE>',HEX(EXTRA))) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' ORDER BY TABLE_NAME,ORDINAL_POSITION;"))?;
@@ -98,7 +126,7 @@ pub fn check(db: &Db, root: &Path) -> Result<Vec<Problem>> {
for (column, want) in cols {
let found = actual[table].get(column);
if found.is_none() || (!want.is_empty() && found != Some(want)) {
problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {want}, found {s}")).unwrap_or_else(|| "Missing column".into()) });
problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {}, found {}", describe_signature(want), describe_signature(s))).unwrap_or_else(|| "Missing column".into()) });
}
}
}
@@ -147,6 +175,14 @@ fn missing_coa_starts(rows: &str) -> Vec<Problem> {
mod tests {
use super::*;
#[test]
fn schema_defaults_are_readable_and_not_conflated() {
assert_eq!(describe_signature("int unsigned|NO|30|<NONE>"), "type=int unsigned, nullable=NO, default=\"0\", extra=none");
assert!(describe_signature("int unsigned|NO|<NULL>|<NONE>").contains("default=no default"));
assert!(describe_signature("varchar(8)|YES||<NONE>").contains("default=\"\""));
assert_eq!(describe_signature("legacy"), "legacy");
}
#[test]
fn a_release_cannot_use_a_partial_or_empty_schema_contract() {
let dir = tempfile::tempdir().unwrap();
+4 -4
View File
@@ -900,7 +900,7 @@ impl Env for RepackEnv<'_> {
let mut modes = vec![crate::realms::Mode::Coa];
if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); }
for mode in modes {
if let Some(problem) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() {
if let Some(problem) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() {
return Err(Error::Invalid(format!("The installed {} database does not match its upgrade source: {}.{}: {}", mode.name(), problem.database, problem.table, problem.detail)));
}
}
@@ -968,13 +968,13 @@ impl Env for RepackEnv<'_> {
result.failed = extra.failed;
if result.snapshot.is_none() { result.snapshot = extra.snapshot; }
if result.failed.is_none() {
if let Some(p) = crate::schema_check::check(&other_db, root)?.first() {
if let Some(p) = crate::schema_check::check_with_report(&other_db, root)?.first() {
return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", other.name(), p.table, p.column, p.detail)));
}
}
}
if result.failed.is_none() {
let problems = crate::schema_check::check(db, root)?;
let problems = crate::schema_check::check_with_report(db, root)?;
if let Some(p) = problems.first() {
return Err(Error::Invalid(format!("Database validation failed: {}.{}.{}: {} ({} problems).", p.database, p.table, p.column, p.detail, problems.len())));
}
@@ -995,7 +995,7 @@ impl Env for RepackEnv<'_> {
let mut modes = vec![crate::realms::Mode::Coa];
if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); }
for mode in modes {
if let Some(p) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() {
if let Some(p) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() {
return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.database, p.table, p.detail)));
}
}
+16
View File
@@ -12,6 +12,7 @@ def main():
parser.add_argument('--mysql-bin', required=True, type=Path)
parser.add_argument('--core', required=True, type=Path)
parser.add_argument('--before', required=True)
parser.add_argument('--display-repair', type=Path)
args = parser.parse_args()
suffix = '.exe' if (args.mysql_bin / 'mysqld.exe').exists() else ''
server = args.mysql_bin / ('mysqld' + suffix)
@@ -66,6 +67,21 @@ def main():
effects = sql('SELECT effect_d0,effect_d1,effect_d2,effect_d3 FROM coa_boss_schedule WHERE entry=12264 AND idx=6;', schema).stdout.strip()
assert effects == '2105613\t2105614\t2105615\t2105616', effects
print('PASS: original SQL reproduces ERROR 1136 after migration; fixed SQL supports fresh, repeated and old-layout initialization')
if args.display_repair:
repair = args.display_repair.read_text()
sql('CREATE DATABASE display_fixture;')
sql(repair, 'display_fixture') # An absent table is not created by a default repair.
sql('CREATE TABLE creature_display_preset (entry INT UNSIGNED NOT NULL, display_id INT UNSIGNED NOT NULL, PRIMARY KEY(entry,display_id)); INSERT INTO creature_display_preset VALUES(42,123);', 'display_fixture')
before_rows = sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout
sql(repair, 'display_fixture')
sql(repair, 'display_fixture')
default = sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip()
assert default == '0', default
assert sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout == before_rows
sql('ALTER TABLE creature_display_preset ALTER COLUMN display_id SET DEFAULT 99;', 'display_fixture')
sql(repair, 'display_fixture')
assert sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip() == '99'
print('PASS: display default repair is repeatable, keeps rows and custom defaults, and tolerates absent tables')
finally:
subprocess.run([str(admin), *connection, 'shutdown'], capture_output=True, creationflags=flags)
try: