mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor: add fenced transfer recovery leases
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""整理恢复租约字段的 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",
|
||||
}
|
||||
|
||||
|
||||
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}
|
||||
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_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()
|
||||
Reference in New Issue
Block a user