Files
MoviePilot/app/db/adapters/plugininstallation.py
T

512 lines
20 KiB
Python

"""插件安装事务 Application Port 的同步 SQLAlchemy 实现。"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timezone
from typing import cast
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.application.plugin.declaration import PluginDeclaredMetadata
from app.application.plugin.identity import (
PluginBindingBasis,
PluginIdentity,
PluginPayloadSourceType,
TrustedPluginSourceType,
)
from app.application.plugin.transaction import (
PluginInstallationConflictError,
PluginInstallationPhase,
PluginInstallationRecord,
PluginInstallationStore,
)
from app.db.models.pluginidentity import PluginIdentity as IdentityModel
from app.db.models.plugininstallation import PluginInstallation
_INSTALLED_PLUGINS_KEY = "UserInstalledPlugins"
AtomicMembershipUpdater = Callable[
[
str,
Callable[
[Session, object],
tuple[PluginInstallationRecord, object],
],
],
PluginInstallationRecord,
]
def _identity_from_model(model: IdentityModel) -> PluginIdentity:
"""把同一 Session 读出的身份模型还原为应用记录。"""
return PluginIdentity(
plugin_id=model.plugin_id,
normalized_plugin_id=model.normalized_plugin_id,
trusted_source_type=TrustedPluginSourceType(model.trusted_source_type),
trusted_source_key=model.trusted_source_key,
binding_basis=PluginBindingBasis(model.binding_basis),
payload_source_type=PluginPayloadSourceType(model.payload_source_type),
payload_source_key=model.payload_source_key,
declared_version=model.declared_version,
package_generation=model.package_generation,
declared_metadata=(
PluginDeclaredMetadata.from_storage(model.declared_metadata)
if model.declared_metadata is not None
else None
),
payload_receipt=model.payload_receipt,
revision=model.revision,
created_at=datetime.fromisoformat(model.created_at),
updated_at=datetime.fromisoformat(model.updated_at),
bound_at=(
datetime.fromisoformat(model.bound_at)
if model.bound_at
else None
),
payload_applied_at=(
datetime.fromisoformat(model.payload_applied_at)
if model.payload_applied_at
else None
),
)
def _identity_model_values(identity: PluginIdentity) -> dict[str, object]:
"""把应用身份映射为不含自增主键的模型列值。"""
return {
"plugin_id": identity.plugin_id,
"normalized_plugin_id": identity.normalized_plugin_id,
"trusted_source_type": identity.trusted_source_type.value,
"trusted_source_key": identity.trusted_source_key,
"binding_basis": identity.binding_basis.value,
"payload_source_type": identity.payload_source_type.value,
"payload_source_key": identity.payload_source_key,
"declared_version": identity.declared_version,
"package_generation": identity.package_generation,
"declared_metadata": (
identity.declared_metadata.to_json()
if identity.declared_metadata is not None
else None
),
"payload_receipt": identity.payload_receipt,
"revision": identity.revision,
"created_at": identity.created_at.isoformat(),
"updated_at": identity.updated_at.isoformat(),
"bound_at": identity.bound_at.isoformat() if identity.bound_at else None,
"payload_applied_at": (
identity.payload_applied_at.isoformat()
if identity.payload_applied_at
else None
),
}
class TransactionalPluginInstallationStore(PluginInstallationStore):
"""以单张事务表协调单插件 membership、来源身份和 phase。"""
def __init__(
self,
session_factory: Callable[[], Session],
update_membership_atomically: AtomicMembershipUpdater,
) -> None:
"""保存事务会话工厂和配置 membership 的窄原子写入口。"""
self._session_factory = session_factory
self.__update_membership_atomically = update_membership_atomically
def __session(self) -> Session:
"""创建不会在提交后过期状态的短生命周期 Session。"""
session = self._session_factory()
session.expire_on_commit = False
return session
@staticmethod
def __now() -> str:
"""生成带时区的持久化更新时间。"""
return datetime.now(timezone.utc).isoformat()
@staticmethod
def __phase(value: PluginInstallationPhase | str) -> PluginInstallationPhase:
"""把调用方 phase 转为受限枚举。"""
try:
return (
value
if isinstance(value, PluginInstallationPhase)
else PluginInstallationPhase(value)
)
except ValueError as error:
raise PluginInstallationConflictError(
f"未知插件安装 phase: {value}"
) from error
@staticmethod
def __to_record(model: PluginInstallation) -> PluginInstallationRecord:
"""把 ORM 行还原为经过应用层校验的事务记录。"""
try:
return PluginInstallationRecord(
transaction_id=model.transaction_id,
plugin_id=model.plugin_id,
phase=PluginInstallationPhase(model.phase),
membership_before=model.membership_before,
membership_target=model.membership_target,
identity_before_revision=model.identity_before_revision,
identity_target_revision=model.identity_target_revision,
package_existed=model.package_existed,
persistent_backup_existed=model.persistent_backup_existed,
created_at=datetime.fromisoformat(model.created_at),
updated_at=datetime.fromisoformat(model.updated_at),
schema_version=model.schema_version,
)
except (TypeError, ValueError) as error:
raise PluginInstallationConflictError(
f"插件安装事务 {model.transaction_id} 的持久化状态无效"
) from error
@staticmethod
def __identity_query(session: Session, plugin_id: str) -> IdentityModel | None:
"""读取并锁定指定插件的身份行。"""
return session.execute(
select(IdentityModel)
.where(IdentityModel.normalized_plugin_id == plugin_id.lower())
.with_for_update()
).scalar_one_or_none()
@staticmethod
def __membership_state(current: object, plugin_id: str) -> bool:
"""只读取目标插件 membership,不把其他插件写入事务快照。"""
if current is None:
return False
if not isinstance(current, list) or any(
not isinstance(item, str) for item in current
):
raise PluginInstallationConflictError(
"UserInstalledPlugins 当前值不是 JSON 字符串数组"
)
normalized_id = plugin_id.lower()
return any(item.lower() == normalized_id for item in current)
@staticmethod
def __write_membership(
current: object,
plugin_id: str,
target: bool,
) -> list[str]:
"""在配置写锁内只增删目标插件,保留其他插件并发变更。"""
if current is None:
values: list[str] = []
elif isinstance(current, list) and all(
isinstance(item, str) for item in current
):
values = list(current)
else:
raise PluginInstallationConflictError(
"UserInstalledPlugins 当前值不是 JSON 字符串数组"
)
normalized_id = plugin_id.lower()
values = [item for item in values if item.lower() != normalized_id]
if target:
values.append(plugin_id)
return values
@classmethod
def __identity_revision(
cls,
session: Session,
plugin_id: str,
) -> int | None:
"""读取锁定身份行的 revision;缺行表示 CAS 的 null。"""
identity = cls.__identity_query(session, plugin_id)
return identity.revision if identity is not None else None
@classmethod
def __write_identity(
cls,
session: Session,
plugin_id: str,
identity: PluginIdentity | None,
) -> None:
"""在调用方事务中写入或删除目标插件身份。"""
current = cls.__identity_query(session, plugin_id)
if identity is None:
if current is not None:
session.delete(current)
return
if identity.plugin_id != plugin_id:
raise PluginInstallationConflictError(
"PluginIdentity target 与事务 plugin_id 不一致"
)
values = _identity_model_values(identity)
if current is None:
session.add(IdentityModel(**values))
else:
for key, value in values.items():
setattr(current, key, value)
@staticmethod
def __assert_target_identity(
record: PluginInstallationRecord,
identity: PluginIdentity,
) -> None:
"""确认目标身份属于当前插件且 revision 只前进一步。"""
if identity.plugin_id != record.plugin_id:
raise PluginInstallationConflictError(
"PluginIdentity target 与事务 plugin_id 不一致"
)
expected_revision = (record.identity_before_revision or 0) + 1
if identity.revision != expected_revision:
raise PluginInstallationConflictError(
f"事务 {record.transaction_id} 的 target identity revision "
f"必须为 {expected_revision}"
)
@staticmethod
def __require_row(session: Session, transaction_id: str) -> PluginInstallation:
"""读取并锁定事务行,缺失时拒绝继续写入。"""
row = session.execute(
select(PluginInstallation)
.where(PluginInstallation.transaction_id == transaction_id)
.with_for_update()
).scalar_one_or_none()
if row is None:
raise PluginInstallationConflictError(
f"插件安装事务不存在: {transaction_id}"
)
return cast(PluginInstallation, row)
@classmethod
def __check_phase(
cls,
row: PluginInstallation,
expected_phase: PluginInstallationPhase | str,
) -> PluginInstallationPhase:
"""执行写操作共用的 phase CAS。"""
expected = cls.__phase(expected_phase)
try:
actual = PluginInstallationPhase(row.phase)
except ValueError as error:
raise PluginInstallationConflictError(
f"事务 {row.transaction_id} 的 phase 无效: {row.phase}"
) from error
if actual is not expected:
raise PluginInstallationConflictError(
f"事务 {row.transaction_id} phase 已变化: "
f"expected={expected.value}, actual={actual.value}"
)
return actual
@classmethod
def __assert_before_state(
cls,
record: PluginInstallationRecord,
session: Session,
current_membership: object,
) -> None:
"""确认目标插件仍处于事务创建时的 before 状态。"""
membership = cls.__membership_state(current_membership, record.plugin_id)
revision = cls.__identity_revision(session, record.plugin_id)
if membership != record.membership_before:
raise PluginInstallationConflictError(
f"事务 {record.transaction_id} 的插件 membership 发生漂移"
)
if revision != record.identity_before_revision:
raise PluginInstallationConflictError(
f"事务 {record.transaction_id} 的插件身份 revision 发生漂移"
)
def create(self, record: PluginInstallationRecord) -> PluginInstallationRecord:
"""原子预留单插件 journal 槽位并立即 flush 唯一键竞争。"""
def reserve(
session: Session,
current_membership: object,
) -> tuple[PluginInstallationRecord, object]:
"""在配置写事务内阻断同一物理插件的未收尾 journal。"""
existing = session.execute(
select(PluginInstallation)
.where(
func.lower(PluginInstallation.plugin_id)
== record.plugin_id.lower()
)
.with_for_update()
).scalars().first()
if existing is not None:
raise PluginInstallationConflictError(
f"插件 {record.plugin_id} 存在未收尾安装事务: "
f"{existing.transaction_id} ({existing.phase})"
)
session.add(
PluginInstallation(
transaction_id=record.transaction_id,
plugin_id=record.plugin_id,
phase=record.phase.value,
membership_before=record.membership_before,
membership_target=record.membership_target,
identity_before_revision=record.identity_before_revision,
identity_target_revision=record.identity_target_revision,
package_existed=record.package_existed,
persistent_backup_existed=record.persistent_backup_existed,
created_at=record.created_at.isoformat(),
updated_at=record.updated_at.isoformat(),
schema_version=record.schema_version,
)
)
session.flush()
return record, current_membership
try:
return cast(
PluginInstallationRecord,
self.__update_membership_atomically(
_INSTALLED_PLUGINS_KEY,
reserve,
),
)
except IntegrityError as error:
raise PluginInstallationConflictError(
f"插件安装事务创建发生并发竞争: {record.transaction_id}"
) from error
def get(self, transaction_id: str) -> PluginInstallationRecord | None:
"""按事务 ID 读取记录。"""
session = self.__session()
try:
row = session.execute(
select(PluginInstallation).where(
PluginInstallation.transaction_id == transaction_id
)
).scalar_one_or_none()
return self.__to_record(row) if row else None
finally:
session.close()
def list(
self,
*,
plugin_id: str | None = None,
) -> list[PluginInstallationRecord]:
"""按创建时间稳定列出事务记录。"""
session = self.__session()
try:
statement = select(PluginInstallation).order_by(
PluginInstallation.created_at,
PluginInstallation.transaction_id,
)
if plugin_id is not None:
statement = statement.where(PluginInstallation.plugin_id == plugin_id)
return [
self.__to_record(row)
for row in session.execute(statement).scalars()
]
finally:
session.close()
def set_target(
self,
transaction_id: str,
*,
membership_target: bool,
identity_target: PluginIdentity | None,
expected_phase: PluginInstallationPhase,
) -> PluginInstallationRecord:
"""按 phase CAS 登记目标 membership 和身份 revision,不写业务状态。"""
if not isinstance(membership_target, bool):
raise PluginInstallationConflictError("membership_target 必须是布尔值")
session = self.__session()
try:
with session.begin():
row = self.__require_row(session, transaction_id)
self.__check_phase(row, expected_phase)
record = self.__to_record(row)
if identity_target is not None:
self.__assert_target_identity(record, identity_target)
row.membership_target = membership_target
row.identity_target_revision = (
identity_target.revision if identity_target is not None else None
)
row.updated_at = self.__now()
session.flush()
return self.__to_record(row)
finally:
session.close()
def commit_target(
self,
transaction_id: str,
*,
identity_target: PluginIdentity | None,
expected_phase: PluginInstallationPhase,
) -> PluginInstallationRecord:
"""原子提交目标 membership、身份 CAS 和 COMMITTED phase。"""
def commit(
session: Session,
current_membership: object,
) -> tuple[PluginInstallationRecord, list[str]]:
"""在配置行锁持有期间完成事务行、身份和 membership 写入。"""
row = self.__require_row(session, transaction_id)
self.__check_phase(row, expected_phase)
record = self.__to_record(row)
if record.membership_target is None:
raise PluginInstallationConflictError(
f"事务 {transaction_id} 尚未设置 membership target"
)
if identity_target is not None:
self.__assert_target_identity(record, identity_target)
if identity_target.revision != record.identity_target_revision:
raise PluginInstallationConflictError(
f"事务 {transaction_id} 的 target identity revision 不匹配"
)
elif record.identity_target_revision is not None:
raise PluginInstallationConflictError(
f"事务 {transaction_id} 缺少 target identity"
)
self.__assert_before_state(record, session, current_membership)
updated_membership = self.__write_membership(
current_membership,
record.plugin_id,
record.membership_target,
)
self.__write_identity(session, record.plugin_id, identity_target)
row.phase = PluginInstallationPhase.COMMITTED.value
row.updated_at = self.__now()
session.flush()
return self.__to_record(row), updated_membership
try:
return cast(
PluginInstallationRecord,
self.__update_membership_atomically(
_INSTALLED_PLUGINS_KEY,
commit,
),
)
except IntegrityError as error:
raise PluginInstallationConflictError(
f"插件 {transaction_id} 的身份提交发生唯一键竞争"
) from error
def delete(
self,
transaction_id: str,
*,
expected_phase: PluginInstallationPhase,
) -> bool:
"""按 phase CAS 删除事务记录;缺失记录按幂等删除处理。"""
session = self.__session()
try:
with session.begin():
row = session.execute(
select(PluginInstallation)
.where(PluginInstallation.transaction_id == transaction_id)
.with_for_update()
).scalar_one_or_none()
if row is None:
return False
self.__check_phase(row, expected_phase)
session.delete(row)
session.flush()
return True
finally:
session.close()