You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

209 lines
8.3 KiB

"""One-shot startup migrations for the Python configurator.
Migrations are gated by the `schema_meta` SQLite table (separate from
`board_config` to avoid contention with the Java app)."""
# v1.4.6.3 H1: Java CanSpeed Integer + Python enum integer set 정합
# mapping값을 string→int로 (migration 후 DB에 string 잔존하던 type confusion 제거)
_CAN_BAUDRATE_OLD_TO_NEW = {
"2500": 250,
"5000": 500,
"10000": 1000,
# "1000" left unmapped — ambiguous between old and new units;
# conservative policy in guide §6.3 keeps it as-is.
}
def migrate_can_baudrate_units(db) -> bool:
"""Migrate device_config.can_baudrate from old units to new (5/22) units.
Returns True if the migration ran, False if previously applied."""
if db.get_schema_meta("can_baudrate_units_migrated") == "true":
return False
def _mut(cur):
cfg = dict(cur) if isinstance(cur, dict) else {}
new = _CAN_BAUDRATE_OLD_TO_NEW.get(cfg.get("can_baudrate"))
if new is not None:
cfg["can_baudrate"] = new
# Java schema stores can_baudrate as Integer. Any remaining clean
# digit-string value (e.g. the unmapped "1000") would otherwise drift
# from that contract → coerce to int. Non-digit/None left untouched.
v = cfg.get("can_baudrate")
if isinstance(v, str) and v.strip().isdigit():
cfg["can_baudrate"] = int(v.strip())
return cfg
db.update_config("device_config", _mut, default={})
db.set_schema_meta("can_baudrate_units_migrated", "true")
return True
# v1.4.6.7 C-1: Python 전용 키가 device_config에 잘못 들어가 있던 .56 라이브 Phase 1 노출.
# LOG_CONFIG_KEYS 확장과 함께 DB에 이미 잔존한 키를 log_config로 이동.
_LOG_COMPRESS_KEYS = ("log_compress_size_mb", "log_compress_age_days")
def migrate_log_compress_split(db) -> bool:
"""Move `log_compress_size_mb` / `log_compress_age_days` out of `device_config`
into `log_config`. Existing values in `log_config` win (no overwrite)."""
if db.get_schema_meta("log_compress_split_migrated") == "true":
return False
# Snapshot the keys to move (these are Python-only keys dpworldapp doesn't
# write, so a brief read before the atomic updates is not a race surface;
# the race that mattered was clobbering OTHER device_config keys on write,
# which the atomic pop below now avoids).
dev = db.get_config("device_config") or {}
if not isinstance(dev, dict):
db.set_schema_meta("log_compress_split_migrated", "true")
return True
moved = {k: dev[k] for k in _LOG_COMPRESS_KEYS if k in dev}
if moved:
# v1.4.6.8 C4: write order — log_config FIRST (additive, no data loss).
# crash between log_config and device_config: log_config safe, device_config
# keys still present → next startup can re-migrate (idempotent: `if k not in cur`).
# v1.9.1 M-1: both writes are now atomic update_config RMW so concurrent
# writers to OTHER device_config keys are preserved.
# FIRST: additive write to log_config — existing keys win.
db.update_config(
"log_config",
lambda cur: {**(cur if isinstance(cur, dict) else {}),
**{k: v for k, v in moved.items()
if k not in (cur if isinstance(cur, dict) else {})}},
default={},
)
# THEN: atomically pop the log_compress_* keys from CURRENT device_config
# (reads fresh inside the transaction, so concurrent changes to other keys
# are preserved — only the two log_compress_* keys are removed).
db.update_config(
"device_config",
lambda cur: {k: v for k, v in (cur if isinstance(cur, dict) else {}).items()
if k not in _LOG_COMPRESS_KEYS},
default={},
)
db.set_schema_meta("log_compress_split_migrated", "true")
return True
# v1.4.6.7 C-2: frontend convertNestedToFlat 회귀로 ports가 string으로 저장된 .56 라이브 Phase 4 노출.
# dha baseline ports 모두 Integer 확정 (verify-before-asserting). 운영 DB normalize.
_PORT_KEYS = (
"protocol_server_port", "update_server_port", "rtcm_server_port",
"opc_ua_server_port", "modbus_server_port", "lte_server_port",
)
def migrate_port_types(db) -> bool:
"""Convert any string-typed port in `device_config` to Integer.
dha baseline confirms Java schema Integer (2026-06-04 verify)."""
if db.get_schema_meta("port_types_migrated") == "true":
return False
def _mut(cur):
cfg = dict(cur) if isinstance(cur, dict) else {}
for k in _PORT_KEYS:
v = cfg.get(k)
if isinstance(v, str) and v.strip().lstrip("-").isdigit():
cfg[k] = int(v)
return cfg
db.update_config("device_config", _mut, default={})
db.set_schema_meta("port_types_migrated", "true")
return True
# v2 contract alignment migration
def migrate_contract_canonical(db) -> bool:
"""Rewrite existing DB values to v2 device config-reader contract canonical forms.
Idempotent (gated by schema_meta flag). Rewrites:
- device_config: rs485_parity "no" "none"
- protocol_config: two_byte_order/four_byte_order "littleSwap""little swap",
"bigSwap""big swap"
- protocol_config OPC_UA/MODBUS/CAN arrays: per-entry idt "float64""float"
Returns True if ran, False if already applied.
"""
if db.get_schema_meta("contract_canonical_migrated") == "true":
return False
# device_config: rs485_parity "no" → "none"
def _mut_device(cur):
cfg = dict(cur) if isinstance(cur, dict) else {}
if cfg.get("rs485_parity") == "no":
cfg["rs485_parity"] = "none"
return cfg
db.update_config("device_config", _mut_device, default={})
# protocol_config: byte_order camelCase → space + idt float64 → float
_BYTE_ORDER_MAP = {"littleSwap": "little swap", "bigSwap": "big swap"}
def _mut_protocol(cur):
cfg = dict(cur) if isinstance(cur, dict) else {}
for bo_key in ("two_byte_order", "four_byte_order"):
v = cfg.get(bo_key)
if v in _BYTE_ORDER_MAP:
cfg[bo_key] = _BYTE_ORDER_MAP[v]
# rewrite idt float64 → float in all register arrays
for arr_key in ("OPC_UA", "MODBUS", "CAN"):
arr = cfg.get(arr_key)
if not isinstance(arr, list):
continue
new_arr = []
for entry in arr:
if isinstance(entry, dict) and entry.get("idt") == "float64":
entry = dict(entry)
entry["idt"] = "float"
new_arr.append(entry)
cfg[arr_key] = new_arr
return cfg
db.update_config("protocol_config", _mut_protocol, default={})
db.set_schema_meta("contract_canonical_migrated", "true")
return True
# AP: seed — begin
def migrate_seed_ap_config(db) -> bool:
"""ap_config 키 부재 시 기본값 1회 seed (spec §5.1). 존재하면 no-op."""
from network.ap_model import DEFAULT_AP_CONFIG
if db.get_config("ap_config") is not None:
return False
db.save_config("ap_config", dict(DEFAULT_AP_CONFIG))
return True
# AP: seed — end
_MIGRATIONS = (
("can_baudrate_units", migrate_can_baudrate_units),
("log_compress_split", migrate_log_compress_split),
("port_types", migrate_port_types),
("seed_ap_config", migrate_seed_ap_config), # AP: seed
("contract_canonical", migrate_contract_canonical), # v2
)
def apply_all_migrations(db) -> list:
"""Run all pending migrations in order. Returns list of migration names
actually applied (for startup logging).
v1.4.6.9 H11: per-step 로깅 partial-failure 어디서 멈췄는지 식별 가능.
이전에는 마지막 print 직전에 raise 어떤 migration이 적용됐는지 unknown.
Exception은 재전파 (caller server.py가 fail-soft wrap)."""
applied = []
for name, fn in _MIGRATIONS:
try:
if fn(db):
applied.append(name)
print(f"[migration] {name}: applied", flush=True)
except Exception:
print(f"[migration] {name}: failed (re-raise to caller)", flush=True)
raise
return applied