mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 04:57:23 +08:00
247 lines
8.0 KiB
Python
247 lines
8.0 KiB
Python
"""整理恢复租约字段的 Alembic 可逆迁移测试。"""
|
|
|
|
import importlib
|
|
|
|
import sqlalchemy as sa
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
|
|
from app.db.models.transferpending import TransferPending
|
|
|
|
MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15"
|
|
LEASE_COLUMNS = {
|
|
"lease_owner",
|
|
"lease_token",
|
|
"lease_expires_at",
|
|
"heartbeat_at",
|
|
"attempt_count",
|
|
}
|
|
TRANSFER_EXECUTION_COLUMNS = {
|
|
"execution_state",
|
|
"execution_version",
|
|
"execution_payload",
|
|
"execution_fingerprint",
|
|
"retry_generation",
|
|
"retry_count",
|
|
"retry_due_at",
|
|
"retry_requested_by",
|
|
"retry_reason",
|
|
"settlement_revision",
|
|
"terminal_history_id",
|
|
"manual_review_revision",
|
|
"reviewed_at",
|
|
"reviewed_by",
|
|
"review_reason",
|
|
"review_decision",
|
|
}
|
|
|
|
|
|
def _bind_migration(monkeypatch, connection):
|
|
"""把 3.0.15 迁移绑定到隔离数据库连接。"""
|
|
migration = importlib.import_module(MIGRATION)
|
|
monkeypatch.setattr(
|
|
migration,
|
|
"op",
|
|
Operations(MigrationContext.configure(connection)),
|
|
)
|
|
return migration
|
|
|
|
|
|
def _create_planning_table(connection) -> None:
|
|
"""创建 3.0.14 时代包含完整规划检查点的登记表。"""
|
|
metadata = sa.MetaData()
|
|
table = sa.Table(
|
|
"transferpending",
|
|
metadata,
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("task_id", sa.String(64), nullable=False),
|
|
sa.Column("storage", sa.String(), nullable=False),
|
|
sa.Column("src_path", sa.String(), nullable=False),
|
|
sa.Column("created_at", sa.String(), nullable=True),
|
|
sa.Column("state", sa.String(32), nullable=False),
|
|
sa.Column("updated_at", sa.String(40), nullable=False),
|
|
sa.Column("last_error", sa.Text(), nullable=True),
|
|
sa.Column("input_version", sa.Integer(), nullable=False),
|
|
sa.Column("planning_input", sa.JSON(), nullable=False),
|
|
sa.Column("input_fingerprint", sa.String(64), nullable=False),
|
|
sa.Column("checkpoint_version", sa.Integer(), nullable=True),
|
|
sa.Column("checkpoint_payload", sa.JSON(), nullable=True),
|
|
sa.Column("planned_at", sa.String(40), nullable=True),
|
|
sa.UniqueConstraint("task_id", name="uq_transferpending_task_id"),
|
|
)
|
|
sa.Index(
|
|
"ux_transferpending_storage_path",
|
|
table.c.storage,
|
|
table.c.src_path,
|
|
unique=True,
|
|
)
|
|
sa.Index(
|
|
"ix_transferpending_state_created",
|
|
table.c.state,
|
|
table.c.created_at,
|
|
table.c.id,
|
|
)
|
|
metadata.create_all(connection)
|
|
connection.execute(
|
|
table.insert(),
|
|
{
|
|
"id": 1,
|
|
"task_id": "stable-task",
|
|
"storage": "local",
|
|
"src_path": "/downloads/Movie.mkv",
|
|
"created_at": "2026-08-27 10:00:00",
|
|
"state": "planned",
|
|
"updated_at": "2026-08-27 10:00:00",
|
|
"last_error": None,
|
|
"input_version": 1,
|
|
"planning_input": {
|
|
"schema_version": 1,
|
|
"source_fileitem": {
|
|
"storage": "local",
|
|
"path": "/downloads/Movie.mkv",
|
|
},
|
|
},
|
|
"input_fingerprint": "0" * 64,
|
|
"checkpoint_version": 1,
|
|
"checkpoint_payload": {"schema_version": 1},
|
|
"planned_at": "2026-08-27 10:00:00",
|
|
},
|
|
)
|
|
|
|
|
|
def test_transfer_lease_upgrade_downgrade_reupgrade(monkeypatch) -> None:
|
|
"""SQLite 应支持租约字段重复升级、降级和再次升级。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
_create_planning_table(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
migration.upgrade()
|
|
|
|
inspector = sa.inspect(connection)
|
|
assert {
|
|
column["name"]
|
|
for column in inspector.get_columns("transferpending")
|
|
} == {
|
|
column.name
|
|
for column in TransferPending.__table__.columns
|
|
if column.name not in TRANSFER_EXECUTION_COLUMNS
|
|
}
|
|
assert "ix_transferpending_recovery_lease" in {
|
|
index["name"]
|
|
for index in inspector.get_indexes("transferpending")
|
|
}
|
|
row = connection.execute(
|
|
sa.text(
|
|
"SELECT lease_owner, lease_token, lease_expires_at, "
|
|
"heartbeat_at, attempt_count FROM transferpending WHERE id = 1"
|
|
)
|
|
).mappings().one()
|
|
assert dict(row) == {
|
|
"lease_owner": None,
|
|
"lease_token": None,
|
|
"lease_expires_at": None,
|
|
"heartbeat_at": None,
|
|
"attempt_count": 0,
|
|
}
|
|
|
|
connection.execute(
|
|
sa.text(
|
|
"UPDATE transferpending SET lease_owner = 'worker', "
|
|
"lease_token = 'token', "
|
|
"lease_expires_at = '2026-08-27 10:05:00.000000', "
|
|
"heartbeat_at = '2026-08-27 10:00:00.000000', "
|
|
"attempt_count = 3 WHERE id = 1"
|
|
)
|
|
)
|
|
migration.downgrade()
|
|
|
|
downgraded = sa.inspect(connection)
|
|
assert LEASE_COLUMNS.isdisjoint({
|
|
column["name"]
|
|
for column in downgraded.get_columns("transferpending")
|
|
})
|
|
assert "ix_transferpending_recovery_lease" not in {
|
|
index["name"]
|
|
for index in downgraded.get_indexes("transferpending")
|
|
}
|
|
assert connection.execute(
|
|
sa.text("SELECT state FROM transferpending WHERE id = 1")
|
|
).scalar_one() == "planned"
|
|
|
|
migration.upgrade()
|
|
reupgraded = connection.execute(
|
|
sa.text(
|
|
"SELECT lease_owner, lease_token, attempt_count "
|
|
"FROM transferpending WHERE id = 1"
|
|
)
|
|
).mappings().one()
|
|
assert dict(reupgraded) == {
|
|
"lease_owner": None,
|
|
"lease_token": None,
|
|
"attempt_count": 0,
|
|
}
|
|
engine.dispose()
|
|
|
|
|
|
def test_replayed_upgrade_repairs_named_lease_index(monkeypatch) -> None:
|
|
"""同名但列和唯一性错误的租约索引必须精确重建。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
_create_planning_table(connection)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
migration.upgrade()
|
|
connection.execute(sa.text(
|
|
"DROP INDEX ix_transferpending_recovery_lease"
|
|
))
|
|
connection.execute(sa.text(
|
|
"CREATE UNIQUE INDEX ix_transferpending_recovery_lease "
|
|
"ON transferpending (task_id)"
|
|
))
|
|
|
|
migration.upgrade()
|
|
|
|
index = next(
|
|
item for item in sa.inspect(connection).get_indexes("transferpending")
|
|
if item["name"] == "ix_transferpending_recovery_lease"
|
|
)
|
|
assert index["column_names"] == [
|
|
"state", "lease_expires_at", "created_at", "id",
|
|
]
|
|
assert index["unique"] == 0
|
|
|
|
|
|
def test_partial_transfer_lease_upgrade_preserves_existing_owner(
|
|
monkeypatch,
|
|
) -> None:
|
|
"""迁移中断后重跑应补齐字段且不得覆盖已经写入的租约拥有者。"""
|
|
engine = sa.create_engine("sqlite://")
|
|
with engine.begin() as connection:
|
|
_create_planning_table(connection)
|
|
connection.execute(
|
|
sa.text("ALTER TABLE transferpending ADD COLUMN lease_owner VARCHAR(128)")
|
|
)
|
|
connection.execute(
|
|
sa.text(
|
|
"UPDATE transferpending SET lease_owner = 'preserved-worker' "
|
|
"WHERE id = 1"
|
|
)
|
|
)
|
|
migration = _bind_migration(monkeypatch, connection)
|
|
|
|
migration.upgrade()
|
|
|
|
row = connection.execute(
|
|
sa.text(
|
|
"SELECT lease_owner, lease_token, attempt_count "
|
|
"FROM transferpending WHERE id = 1"
|
|
)
|
|
).mappings().one()
|
|
assert dict(row) == {
|
|
"lease_owner": "preserved-worker",
|
|
"lease_token": None,
|
|
"attempt_count": 0,
|
|
}
|
|
engine.dispose()
|