mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: close transactional boundary debt batch
This commit is contained in:
+25
-12
@@ -19,7 +19,12 @@ from app.application.chain.events import (
|
||||
snapshot_transfer_result,
|
||||
transfer_result_event_key,
|
||||
)
|
||||
from app.application.history import TransferHistoryRecord, TransferHistoryWriter
|
||||
from app.application.history import (
|
||||
DownloadFileWrite,
|
||||
DownloadHistoryWrite,
|
||||
TransferHistoryRecord,
|
||||
TransferHistoryWriter,
|
||||
)
|
||||
from app.application.outbox import (
|
||||
DOWNLOAD_ADDED_TOPIC,
|
||||
DurableEventCommand,
|
||||
@@ -32,7 +37,10 @@ from app.application.transfer.execution import (
|
||||
TransferExecutionState,
|
||||
TransferSettlementResult,
|
||||
)
|
||||
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
|
||||
from app.db.adapters.outbox import (
|
||||
SqlAlchemyOutboxDispatchStore,
|
||||
SqlAlchemyOutboxStager,
|
||||
)
|
||||
from app.db.models.transfersettlementreceipt import TransferSettlementReceipt
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.oper.transferexecutionstep import TransferExecutionStepOper
|
||||
@@ -114,8 +122,8 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
def download_added(
|
||||
self,
|
||||
*,
|
||||
history_payload: dict[str, Any],
|
||||
file_payloads: list[dict[str, Any]],
|
||||
history: DownloadHistoryWrite,
|
||||
files: tuple[DownloadFileWrite, ...],
|
||||
event_payload: dict[str, Any],
|
||||
after_commit: Callable[[], None],
|
||||
publish: Callable[[dict[str, Any]], None],
|
||||
@@ -124,17 +132,20 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
session = self._session_factory()
|
||||
try:
|
||||
repository = DownloadHistoryOper(session)
|
||||
outbox = SqlAlchemyOutboxRepository(session)
|
||||
outbox = SqlAlchemyOutboxStager(session)
|
||||
command = DurableEventCommand(
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
outbox=outbox,
|
||||
stager=outbox,
|
||||
store=SqlAlchemyOutboxDispatchStore(self._session_factory),
|
||||
)
|
||||
def stage_business() -> int:
|
||||
"""在同一事务暂存下载历史和可选文件清单。"""
|
||||
history = repository.stage_add(history_payload)
|
||||
if file_payloads:
|
||||
repository.stage_add_files(file_payloads)
|
||||
return int(history.id)
|
||||
record = repository.stage_add(history.to_payload())
|
||||
if files:
|
||||
repository.stage_add_files([
|
||||
file_item.to_payload() for file_item in files
|
||||
])
|
||||
return int(record.id)
|
||||
|
||||
def build_intent(history_id: int) -> OutboxIntent:
|
||||
"""历史 ID 确定后构造本次下载事实的稳定事件键。"""
|
||||
@@ -182,7 +193,8 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
|
||||
command = DurableEventCommand(
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
outbox=SqlAlchemyOutboxRepository(session),
|
||||
stager=SqlAlchemyOutboxStager(session),
|
||||
store=SqlAlchemyOutboxDispatchStore(self._session_factory),
|
||||
)
|
||||
|
||||
def stage_business() -> _StagedTransferResult:
|
||||
@@ -271,7 +283,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
)
|
||||
|
||||
try:
|
||||
result = command.execute(
|
||||
execution = command.execute(
|
||||
intent=build_intent if topic is not None else None,
|
||||
stage_business=stage_business,
|
||||
publish=(
|
||||
@@ -301,6 +313,7 @@ class TransactionalChainDurableEventWriter(ChainDurableEventWriter):
|
||||
if replay is None:
|
||||
raise
|
||||
return replay
|
||||
result = execution.value
|
||||
return result.settlement or result.history
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""用户配置快照的显式短会话与事务适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Optional, Union
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.oper.userconfig import UserConfigOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.types import UserConfigKey
|
||||
|
||||
|
||||
class TransactionalUserConfigurationRepository:
|
||||
"""在短事务提交后发布用户配置缓存,并在发布失败时重载事实源。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
snapshot: Optional[UserConfigOper] = None,
|
||||
) -> None:
|
||||
"""保存会话工厂及进程级用户配置快照。"""
|
||||
self._session_factory = session_factory
|
||||
self._snapshot = snapshot or UserConfigOper()
|
||||
|
||||
def load_snapshot(self) -> None:
|
||||
"""使用独立只读会话从数据库发布完整配置快照。"""
|
||||
with self._session_factory() as session:
|
||||
self._snapshot.load_snapshot(session)
|
||||
|
||||
def get(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
) -> JsonData:
|
||||
"""从进程级快照读取一项深拷贝配置。"""
|
||||
return self._snapshot.get(username=username, key=key)
|
||||
|
||||
def set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""提交一项配置后发布快照,发布异常时从数据库恢复快照。"""
|
||||
with self._snapshot.write_scope():
|
||||
with self._session_factory() as session:
|
||||
unit_of_work = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
deleted = self._snapshot.stage_set(
|
||||
session,
|
||||
username,
|
||||
key,
|
||||
value,
|
||||
)
|
||||
unit_of_work.commit()
|
||||
except Exception:
|
||||
unit_of_work.rollback()
|
||||
raise
|
||||
try:
|
||||
self._snapshot.publish(
|
||||
username,
|
||||
key,
|
||||
value,
|
||||
deleted=deleted,
|
||||
)
|
||||
except Exception:
|
||||
self.load_snapshot()
|
||||
raise
|
||||
|
||||
def publish_rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""发布已提交用户改名,并在同一写锁内以数据库事实源收口。"""
|
||||
with self._snapshot.write_scope():
|
||||
try:
|
||||
self._snapshot.publish_rename(previous_name, current_name)
|
||||
except Exception:
|
||||
self.load_snapshot()
|
||||
raise
|
||||
self.load_snapshot()
|
||||
|
||||
def publish_delete(self, username: str) -> None:
|
||||
"""发布已提交用户删除,并在同一写锁内以数据库事实源收口。"""
|
||||
with self._snapshot.write_scope():
|
||||
try:
|
||||
self._snapshot.publish_delete(username)
|
||||
except Exception:
|
||||
self.load_snapshot()
|
||||
raise
|
||||
self.load_snapshot()
|
||||
@@ -0,0 +1 @@
|
||||
"""历史持久化适配器包。"""
|
||||
@@ -0,0 +1,287 @@
|
||||
"""下载历史的类型化查询、写入与事务适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from copy import deepcopy
|
||||
from typing import Optional, TypeVar, Union
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.history import (
|
||||
DownloadFileSnapshot,
|
||||
DownloadFileWrite,
|
||||
DownloadHistorySnapshot,
|
||||
DownloadHistoryWrite,
|
||||
)
|
||||
from app.db.oper.downloadhistory import DownloadHistoryOper
|
||||
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
ResultT = TypeVar("ResultT")
|
||||
|
||||
|
||||
def _project_history(record: object) -> DownloadHistorySnapshot:
|
||||
"""在 Session 内把下载历史 ORM 记录投影为不可变快照。"""
|
||||
history_id = getattr(record, "id", None)
|
||||
path = getattr(record, "path", None)
|
||||
media_type = getattr(record, "type", None)
|
||||
title = getattr(record, "title", None)
|
||||
if (
|
||||
not isinstance(history_id, int)
|
||||
or not isinstance(path, str)
|
||||
or not isinstance(media_type, str)
|
||||
or not isinstance(title, str)
|
||||
):
|
||||
raise ValueError("下载历史记录缺少稳定身份、路径、类型或标题")
|
||||
media_source = normalize_media_source(getattr(record, "media_source", None))
|
||||
media_id_value = getattr(record, "media_id", None)
|
||||
media_id = str(media_id_value).strip() if media_id_value is not None else None
|
||||
if not media_source or not media_id or media_id == "0":
|
||||
media_source = None
|
||||
media_id = None
|
||||
return DownloadHistorySnapshot(
|
||||
id=history_id,
|
||||
path=path,
|
||||
type=media_type,
|
||||
title=title,
|
||||
year=getattr(record, "year", None),
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=getattr(record, "music_type", None),
|
||||
seasons=getattr(record, "seasons", None),
|
||||
episodes=getattr(record, "episodes", None),
|
||||
image=getattr(record, "image", None),
|
||||
poster=getattr(record, "poster", None),
|
||||
downloader=getattr(record, "downloader", None),
|
||||
download_hash=getattr(record, "download_hash", None),
|
||||
torrent_name=getattr(record, "torrent_name", None),
|
||||
torrent_description=getattr(record, "torrent_description", None),
|
||||
torrent_site=getattr(record, "torrent_site", None),
|
||||
userid=(
|
||||
str(userid_value)
|
||||
if (userid_value := getattr(record, "userid", None)) is not None
|
||||
else None
|
||||
),
|
||||
username=getattr(record, "username", None),
|
||||
channel=getattr(record, "channel", None),
|
||||
date=getattr(record, "date", None),
|
||||
note=deepcopy(getattr(record, "note", None)),
|
||||
media_category=getattr(record, "media_category", None),
|
||||
episode_group=getattr(record, "episode_group", None),
|
||||
custom_words=getattr(record, "custom_words", None),
|
||||
)
|
||||
|
||||
|
||||
def _project_file(record: object) -> DownloadFileSnapshot:
|
||||
"""在 Session 内把下载文件 ORM 记录投影为不可变快照。"""
|
||||
file_id = getattr(record, "id", None)
|
||||
state = getattr(record, "state", None)
|
||||
if not isinstance(file_id, int) or not isinstance(state, int):
|
||||
raise ValueError("下载文件记录缺少稳定身份或状态")
|
||||
return DownloadFileSnapshot(
|
||||
id=file_id,
|
||||
downloader=getattr(record, "downloader", None),
|
||||
download_hash=getattr(record, "download_hash", None),
|
||||
fullpath=getattr(record, "fullpath", None),
|
||||
savepath=getattr(record, "savepath", None),
|
||||
filepath=getattr(record, "filepath", None),
|
||||
torrentname=getattr(record, "torrentname", None),
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
class TransactionalDownloadHistoryRepository:
|
||||
"""为 Chain 和 Agent 下载历史读写创建短生命周期 Session。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
sync_session: Callable[[], Session],
|
||||
async_session: Callable[[], AbstractAsyncContextManager[AsyncSession]],
|
||||
) -> None:
|
||||
"""保存由启动组合根提供的同步与异步 Session 工厂。"""
|
||||
self._sync_session = sync_session
|
||||
self._async_session = async_session
|
||||
|
||||
def _read(self, operation: Callable[[DownloadHistoryOper], ResultT]) -> ResultT:
|
||||
"""在独立同步 Session 中执行一次只读操作。"""
|
||||
session = self._sync_session()
|
||||
try:
|
||||
return operation(DownloadHistoryOper(session))
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载任务 Hash 返回最新历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: (
|
||||
_project_history(record)
|
||||
if (record := repository.get_by_hash(download_hash)) is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
def get_by_hashes(
|
||||
self,
|
||||
download_hashes: list[str],
|
||||
) -> dict[str, DownloadHistorySnapshot]:
|
||||
"""批量返回以下载任务 Hash 为键的最新历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: {
|
||||
download_hash: _project_history(record)
|
||||
for download_hash, record in repository.get_by_hashes(download_hashes).items()
|
||||
}
|
||||
)
|
||||
|
||||
def get_by_path(self, path: str) -> Optional[DownloadHistorySnapshot]:
|
||||
"""按下载保存路径返回历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: (
|
||||
_project_history(record)
|
||||
if (record := repository.get_by_path(path)) is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
def get_by_media_identity(
|
||||
self,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
music_type: Optional[str] = None,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""按规范媒体身份返回历史快照。"""
|
||||
return self._read(
|
||||
lambda repository: [
|
||||
_project_history(record)
|
||||
for record in repository.get_by_media_identity(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=music_type,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def get_file_by_fullpath(
|
||||
self,
|
||||
fullpath: str,
|
||||
) -> Optional[DownloadFileSnapshot]:
|
||||
"""按完整路径返回一条有效下载文件快照。"""
|
||||
return self._read(
|
||||
lambda repository: (
|
||||
_project_file(record)
|
||||
if (record := repository.get_file_by_fullpath(fullpath)) is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
def get_files_by_hash(
|
||||
self,
|
||||
download_hash: str,
|
||||
state: Optional[int] = None,
|
||||
) -> list[DownloadFileSnapshot]:
|
||||
"""按下载任务 Hash 返回文件快照。"""
|
||||
return self._read(
|
||||
lambda repository: [
|
||||
_project_file(record)
|
||||
for record in repository.get_files_by_hash(
|
||||
download_hash,
|
||||
state=state,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
def get_files_by_savepath(self, savepath: str) -> list[DownloadFileSnapshot]:
|
||||
"""按保存目录返回下载文件快照。"""
|
||||
return self._read(
|
||||
lambda repository: [
|
||||
_project_file(record)
|
||||
for record in repository.get_files_by_savepath(savepath)
|
||||
]
|
||||
)
|
||||
|
||||
async def async_list_by_page(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""在独立异步 Session 内分页读取并投影历史。"""
|
||||
async with self._async_session() as session:
|
||||
records = await DownloadHistoryOper(session).async_list_by_page(
|
||||
page,
|
||||
count,
|
||||
)
|
||||
return [_project_history(record) for record in records]
|
||||
|
||||
def add(
|
||||
self,
|
||||
history: DownloadHistoryWrite,
|
||||
files: tuple[DownloadFileWrite, ...] = (),
|
||||
) -> int:
|
||||
"""在一个同步事务中新增历史与关联文件。"""
|
||||
session = self._sync_session()
|
||||
unit_of_work = SqlAlchemyUnitOfWork(session)
|
||||
try:
|
||||
repository = DownloadHistoryOper(session)
|
||||
record = repository.stage_add(history.to_payload())
|
||||
if files:
|
||||
repository.stage_add_files([file_item.to_payload() for file_item in files])
|
||||
history_id = int(record.id)
|
||||
unit_of_work.commit()
|
||||
return history_id
|
||||
except Exception:
|
||||
unit_of_work.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
async def async_delete(self, history_id: int) -> None:
|
||||
"""在一个异步事务中删除指定下载历史。"""
|
||||
async with self._async_session() as session:
|
||||
unit_of_work = SqlAlchemyAsyncUnitOfWork(session)
|
||||
try:
|
||||
await DownloadHistoryOper(session).async_delete_history(history_id)
|
||||
await unit_of_work.commit()
|
||||
except Exception:
|
||||
await unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class SessionDownloadHistoryRepository:
|
||||
"""把 API 请求持有的 Session 适配为下载历史查询和暂存端口。"""
|
||||
|
||||
def __init__(self, session: Union[Session, AsyncSession]) -> None:
|
||||
"""保存由请求依赖独占的数据库 Session。"""
|
||||
self._session = session
|
||||
|
||||
async def async_list_by_page(
|
||||
self,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
) -> list[DownloadHistorySnapshot]:
|
||||
"""在请求异步 Session 内分页读取并投影历史。"""
|
||||
if not isinstance(self._session, AsyncSession):
|
||||
raise RuntimeError("下载历史异步查询需要 AsyncSession")
|
||||
records = await DownloadHistoryOper(self._session).async_list_by_page(
|
||||
page,
|
||||
count,
|
||||
)
|
||||
return [_project_history(record) for record in records]
|
||||
|
||||
def stage_delete_history(self, history_id: int) -> None:
|
||||
"""在请求同步 Session 内暂存下载历史删除。"""
|
||||
if not isinstance(self._session, Session):
|
||||
raise RuntimeError("下载历史同步删除需要 Session")
|
||||
DownloadHistoryOper(self._session).stage_delete_history(history_id)
|
||||
|
||||
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
|
||||
"""在请求同步 Session 内暂存下载文件失效状态。"""
|
||||
if not isinstance(self._session, Session):
|
||||
raise RuntimeError("下载文件同步变更需要 Session")
|
||||
DownloadHistoryOper(self._session).stage_delete_file_by_fullpath(fullpath)
|
||||
+249
-164
@@ -1,10 +1,15 @@
|
||||
"""Application outbox 端口的 SQLAlchemy 持久化适配器。"""
|
||||
"""Application outbox 暂存与派发端口的 SQLAlchemy 适配器。"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import Select
|
||||
from sqlalchemy.sql.dml import Update
|
||||
|
||||
from app.application.outbox import ClaimedOutboxMessage, OutboxIntent
|
||||
from app.db.base import execute_dml
|
||||
@@ -16,198 +21,278 @@ def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
class SqlAlchemyOutboxRepository:
|
||||
"""使用调用方 Session 原子暂存并条件认领 outbox。"""
|
||||
def _message(model: OutboxMessage, attempt: int) -> ClaimedOutboxMessage:
|
||||
"""把已认领 ORM 行复制为脱离会话的稳定消息。"""
|
||||
return ClaimedOutboxMessage(
|
||||
message_id=model.id,
|
||||
event_key=model.event_key,
|
||||
topic=model.topic,
|
||||
payload=dict(model.payload),
|
||||
payload_version=model.payload_version,
|
||||
attempt=attempt,
|
||||
)
|
||||
|
||||
|
||||
def _claim_query(
|
||||
now_text: str,
|
||||
event_key: Optional[str] = None,
|
||||
) -> Select[tuple[OutboxMessage]]:
|
||||
"""构造到期且 lease 可取得的候选查询。"""
|
||||
statement = select(OutboxMessage).where(
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
if event_key is not None:
|
||||
statement = statement.where(OutboxMessage.event_key == event_key)
|
||||
return statement.order_by(OutboxMessage.id).limit(1)
|
||||
|
||||
|
||||
def _claim_update(
|
||||
candidate: OutboxMessage,
|
||||
now_text: str,
|
||||
lease_until: datetime,
|
||||
) -> Update:
|
||||
"""构造带旧 attempt fencing 的条件认领更新。"""
|
||||
return (
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=candidate.attempt + 1,
|
||||
lease_until=_iso(lease_until),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemyOutboxStager:
|
||||
"""只在调用方同步业务事务中暂存 durable intent。"""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""保存由调用方拥有的 SQLAlchemy Session。"""
|
||||
"""保存业务事务拥有的同步 Session。"""
|
||||
self._session = session
|
||||
|
||||
def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""加入当前事务并 flush,使唯一键冲突在业务 commit 前暴露。"""
|
||||
self._session.add(
|
||||
OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=intent.payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
)
|
||||
"""加入当前事务并 flush,使唯一键冲突在 commit 前暴露。"""
|
||||
self._session.add(_outbox_model(intent, now))
|
||||
self._session.flush()
|
||||
|
||||
|
||||
class SqlAlchemyAsyncOutboxStager:
|
||||
"""只在调用方异步业务事务中暂存 durable intent。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""保存业务事务拥有的异步 Session。"""
|
||||
self._session = session
|
||||
|
||||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""暂存并 flush,业务行与 intent 由同一次 commit 决定。"""
|
||||
self._session.add(_outbox_model(intent, now))
|
||||
await self._session.flush()
|
||||
|
||||
|
||||
class SqlAlchemyOutboxDispatchStore:
|
||||
"""用独立同步短事务认领并结算 outbox 消息。"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存每次操作创建独立 Session 的工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def claim(
|
||||
self,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> ClaimedOutboxMessage | None:
|
||||
"""条件更新候选行;并发丢失竞争时返回 None。"""
|
||||
now_text = _iso(now)
|
||||
candidate = self._session.execute(
|
||||
select(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.order_by(OutboxMessage.id)
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if candidate is None:
|
||||
return None
|
||||
next_attempt = candidate.attempt + 1
|
||||
claimed = execute_dml(
|
||||
self._session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=next_attempt,
|
||||
lease_until=_iso(lease_until),
|
||||
),
|
||||
)
|
||||
self._session.commit()
|
||||
if not claimed:
|
||||
return None
|
||||
return ClaimedOutboxMessage(
|
||||
message_id=candidate.id,
|
||||
event_key=candidate.event_key,
|
||||
topic=candidate.topic,
|
||||
payload=dict(candidate.payload),
|
||||
payload_version=candidate.payload_version,
|
||||
attempt=next_attempt,
|
||||
)
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""原子认领最早一条到期消息。"""
|
||||
return self._claim(now, lease_until)
|
||||
|
||||
def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> bool:
|
||||
"""按事件键原子认领同步投递,避免与 dispatcher 并发重复发送。"""
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领到期消息。"""
|
||||
return self._claim(now, lease_until, event_key)
|
||||
|
||||
def _claim(
|
||||
self,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
event_key: Optional[str] = None,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""在独立事务中以 compare-and-swap 取得 lease。"""
|
||||
now_text = _iso(now)
|
||||
candidate = self._session.execute(
|
||||
select(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.event_key == event_key,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
with self._session_factory() as session:
|
||||
candidate = session.execute(_claim_query(now_text, event_key)).scalars().first()
|
||||
if candidate is None:
|
||||
return None
|
||||
next_attempt = candidate.attempt + 1
|
||||
claimed = execute_dml(
|
||||
session,
|
||||
_claim_update(candidate, now_text, lease_until),
|
||||
)
|
||||
session.commit()
|
||||
return _message(candidate, next_attempt) if claimed else None
|
||||
|
||||
def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 processing owner 标记完成。"""
|
||||
with self._session_factory() as session:
|
||||
changed = execute_dml(
|
||||
session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="completed",
|
||||
completed_at=_iso(completed_at),
|
||||
lease_until=None,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
).scalars().first()
|
||||
if candidate is None:
|
||||
return False
|
||||
claimed = execute_dml(
|
||||
self._session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == candidate.id,
|
||||
OutboxMessage.attempt == candidate.attempt,
|
||||
OutboxMessage.event_key == event_key,
|
||||
OutboxMessage.status.in_(("pending", "processing")),
|
||||
OutboxMessage.next_retry_at <= now_text,
|
||||
or_(
|
||||
OutboxMessage.lease_until.is_(None),
|
||||
OutboxMessage.lease_until <= now_text,
|
||||
),
|
||||
)
|
||||
.values(
|
||||
status="processing",
|
||||
attempt=OutboxMessage.attempt + 1,
|
||||
lease_until=_iso(lease_until),
|
||||
),
|
||||
)
|
||||
self._session.commit()
|
||||
return bool(claimed)
|
||||
|
||||
def complete(self, message_id: int, completed_at: datetime) -> None:
|
||||
"""持久化完成终态并释放 lease。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.id == message_id)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
self._session.commit()
|
||||
|
||||
def complete_by_event_key(self, event_key: str, completed_at: datetime) -> None:
|
||||
"""即时 post-commit 全部成功时按幂等键收口对应 intent。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.event_key == event_key)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
self._session.commit()
|
||||
session.commit()
|
||||
return bool(changed)
|
||||
|
||||
def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 owner 释放 lease 或写入 dead 终态。"""
|
||||
with self._session_factory() as session:
|
||||
changed = execute_dml(
|
||||
session,
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="dead" if dead else "pending",
|
||||
next_retry_at=_iso(next_retry_at),
|
||||
lease_until=None,
|
||||
last_error=last_error,
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
return bool(changed)
|
||||
|
||||
|
||||
class SqlAlchemyAsyncOutboxDispatchStore:
|
||||
"""用独立异步短事务认领并结算 outbox 消息。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], AbstractAsyncContextManager[AsyncSession]],
|
||||
) -> None:
|
||||
"""持久化下一次退避或不可自动重试的 dead 终态。"""
|
||||
self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.id == message_id)
|
||||
.values(
|
||||
status="dead" if dead else "pending",
|
||||
next_retry_at=_iso(next_retry_at),
|
||||
lease_until=None,
|
||||
last_error=last_error,
|
||||
)
|
||||
)
|
||||
self._session.commit()
|
||||
"""保存每次操作创建独立异步 Session 的工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
class SqlAlchemyAsyncOutboxStager:
|
||||
"""只负责把 outbox 意图加入调用方异步事务。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
"""保存由异步订阅命令拥有的 Session。"""
|
||||
self._session = session
|
||||
|
||||
async def stage(self, intent: OutboxIntent, now: datetime) -> None:
|
||||
"""暂存并 flush,确保业务行与意图由同一次 commit 决定。"""
|
||||
self._session.add(
|
||||
OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=intent.payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
)
|
||||
await self._session.flush()
|
||||
|
||||
async def complete_by_event_key(
|
||||
async def claim_by_event_key(
|
||||
self,
|
||||
event_key: str,
|
||||
now: datetime,
|
||||
lease_until: datetime,
|
||||
) -> Optional[ClaimedOutboxMessage]:
|
||||
"""按稳定事件键原子认领到期消息。"""
|
||||
now_text = _iso(now)
|
||||
async with self._session_factory() as session:
|
||||
candidate = (await session.execute(_claim_query(now_text, event_key))).scalars().first()
|
||||
if candidate is None:
|
||||
return None
|
||||
next_attempt = candidate.attempt + 1
|
||||
result = await session.execute(_claim_update(candidate, now_text, lease_until))
|
||||
await session.commit()
|
||||
return _message(candidate, next_attempt) if result.rowcount else None
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
completed_at: datetime,
|
||||
) -> None:
|
||||
"""异步 post-commit 全部成功时按幂等键收口 intent。"""
|
||||
await self._session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(OutboxMessage.event_key == event_key)
|
||||
.values(status="completed", completed_at=_iso(completed_at), lease_until=None)
|
||||
)
|
||||
await self._session.commit()
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 processing owner 标记完成。"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="completed",
|
||||
completed_at=_iso(completed_at),
|
||||
lease_until=None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
async def retry(
|
||||
self,
|
||||
message_id: int,
|
||||
attempt: int,
|
||||
*,
|
||||
next_retry_at: datetime,
|
||||
last_error: str,
|
||||
dead: bool,
|
||||
) -> bool:
|
||||
"""仅允许当前 attempt 的 owner 释放 lease 或写入 dead 终态。"""
|
||||
async with self._session_factory() as session:
|
||||
result = await session.execute(
|
||||
update(OutboxMessage)
|
||||
.where(
|
||||
OutboxMessage.id == message_id,
|
||||
OutboxMessage.status == "processing",
|
||||
OutboxMessage.attempt == attempt,
|
||||
)
|
||||
.values(
|
||||
status="dead" if dead else "pending",
|
||||
next_retry_at=_iso(next_retry_at),
|
||||
lease_until=None,
|
||||
last_error=last_error,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return bool(result.rowcount)
|
||||
|
||||
|
||||
def _outbox_model(intent: OutboxIntent, now: datetime) -> OutboxMessage:
|
||||
"""构造由业务事务持有的新 outbox ORM 行。"""
|
||||
payload = dict(intent.payload)
|
||||
payload["idempotency_key"] = intent.event_key
|
||||
return OutboxMessage(
|
||||
event_key=intent.event_key,
|
||||
topic=intent.topic,
|
||||
payload_version=intent.payload_version,
|
||||
payload=payload,
|
||||
status="pending",
|
||||
attempt=0,
|
||||
next_retry_at=_iso(now),
|
||||
created_at=_iso(now),
|
||||
)
|
||||
|
||||
+184
-32
@@ -2,12 +2,19 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.outbox import (
|
||||
OUTBOX_LEASE_SECONDS,
|
||||
AsyncOutboxDispatchStore,
|
||||
ClaimedOutboxMessage,
|
||||
OutboxDispatchStore,
|
||||
OutboxLeaseLostError,
|
||||
)
|
||||
from app.application.subscription.write import (
|
||||
AfterCommitEffect,
|
||||
AsyncAfterCommitEffect,
|
||||
@@ -18,8 +25,10 @@ from app.application.subscription.write import (
|
||||
subscription_added_report_key,
|
||||
)
|
||||
from app.db.adapters.outbox import (
|
||||
SqlAlchemyAsyncOutboxDispatchStore,
|
||||
SqlAlchemyAsyncOutboxStager,
|
||||
SqlAlchemyOutboxRepository,
|
||||
SqlAlchemyOutboxDispatchStore,
|
||||
SqlAlchemyOutboxStager,
|
||||
)
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
|
||||
@@ -51,7 +60,8 @@ class TransactionalSubscribeWriter:
|
||||
"""在独占同步会话内执行一次完整订阅新增事务。"""
|
||||
session = self._sync_session()
|
||||
try:
|
||||
outbox = SqlAlchemyOutboxRepository(session)
|
||||
outbox = SqlAlchemyOutboxStager(session)
|
||||
dispatch_store = SqlAlchemyOutboxDispatchStore(self._sync_session)
|
||||
command = CreateSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
@@ -61,21 +71,13 @@ class TransactionalSubscribeWriter:
|
||||
def delivered(subscribe_id: int) -> None:
|
||||
"""执行提交后编排,分别收口已确认的 durable intent。"""
|
||||
if after_commit:
|
||||
report_delivered = after_commit(subscribe_id)
|
||||
outbox.complete_by_event_key(
|
||||
subscription_added_event_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
_deliver_added_effects(
|
||||
dispatch_store,
|
||||
subscribe_id,
|
||||
payload,
|
||||
notification,
|
||||
lambda: after_commit(subscribe_id),
|
||||
)
|
||||
if notification:
|
||||
outbox.complete_by_event_key(
|
||||
subscription_added_notification_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
if report_delivered is not False:
|
||||
outbox.complete_by_event_key(
|
||||
subscription_added_report_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return command.execute(
|
||||
identity,
|
||||
@@ -98,6 +100,7 @@ class TransactionalSubscribeWriter:
|
||||
"""在独占异步会话作用域内执行一次完整订阅新增事务。"""
|
||||
async with self._async_session() as session:
|
||||
outbox = SqlAlchemyAsyncOutboxStager(session)
|
||||
dispatch_store = SqlAlchemyAsyncOutboxDispatchStore(self._async_session)
|
||||
command = AsyncCreateSubscriptionCommand(
|
||||
repository=SubscribeOper(session),
|
||||
unit_of_work=SqlAlchemyAsyncUnitOfWork(session),
|
||||
@@ -107,21 +110,13 @@ class TransactionalSubscribeWriter:
|
||||
async def delivered(subscribe_id: int) -> None:
|
||||
"""异步执行提交后编排,分别收口已确认的 durable intent。"""
|
||||
if after_commit:
|
||||
report_delivered = await after_commit(subscribe_id)
|
||||
await outbox.complete_by_event_key(
|
||||
subscription_added_event_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
await _deliver_added_effects_async(
|
||||
dispatch_store,
|
||||
subscribe_id,
|
||||
payload,
|
||||
notification,
|
||||
lambda: after_commit(subscribe_id),
|
||||
)
|
||||
if notification:
|
||||
await outbox.complete_by_event_key(
|
||||
subscription_added_notification_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
if report_delivered is not False:
|
||||
await outbox.complete_by_event_key(
|
||||
subscription_added_report_key(subscribe_id, payload),
|
||||
datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
return await command.execute(
|
||||
identity,
|
||||
@@ -130,3 +125,160 @@ class TransactionalSubscribeWriter:
|
||||
delivered,
|
||||
notification,
|
||||
)
|
||||
|
||||
|
||||
def _added_effect_keys(
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
notification: Optional[dict[str, object]],
|
||||
) -> tuple[str, ...]:
|
||||
"""返回组合回调实际包含的独立 durable effect 键。"""
|
||||
keys = [subscription_added_event_key(subscribe_id, payload)]
|
||||
if notification:
|
||||
keys.append(subscription_added_notification_key(subscribe_id, payload))
|
||||
keys.append(subscription_added_report_key(subscribe_id, payload))
|
||||
return tuple(keys)
|
||||
|
||||
|
||||
def _claim_added_effects(
|
||||
store: OutboxDispatchStore,
|
||||
keys: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> Optional[tuple[ClaimedOutboxMessage, ...]]:
|
||||
"""全量认领组合回调;竞争丢失时释放本次已取得的 lease。"""
|
||||
claimed: list[ClaimedOutboxMessage] = []
|
||||
for key in keys:
|
||||
message = store.claim_by_event_key(
|
||||
key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if message is None:
|
||||
for owned in claimed:
|
||||
store.retry(
|
||||
owned.message_id,
|
||||
owned.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="组合副作用由其他 owner 接管",
|
||||
dead=False,
|
||||
)
|
||||
return None
|
||||
claimed.append(message)
|
||||
return tuple(claimed)
|
||||
|
||||
|
||||
def _deliver_added_effects(
|
||||
store: OutboxDispatchStore,
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
notification: Optional[dict[str, object]],
|
||||
effect: Callable[[], Optional[bool]],
|
||||
) -> None:
|
||||
"""认领组合回调并按事件、通知、统计的确认结果分别结算。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
claimed = _claim_added_effects(
|
||||
store,
|
||||
_added_effect_keys(subscribe_id, payload, notification),
|
||||
now,
|
||||
)
|
||||
if claimed is None:
|
||||
return
|
||||
try:
|
||||
report_delivered = effect()
|
||||
except Exception as error:
|
||||
for message in claimed:
|
||||
store.retry(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
for message in claimed[:-1]:
|
||||
if not store.complete(message.message_id, message.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增完成凭证已失效")
|
||||
report = claimed[-1]
|
||||
if report_delivered is False:
|
||||
store.retry(
|
||||
report.message_id,
|
||||
report.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="订阅新增统计未确认",
|
||||
dead=False,
|
||||
)
|
||||
else:
|
||||
if not store.complete(report.message_id, report.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增统计完成凭证已失效")
|
||||
|
||||
|
||||
async def _claim_added_effects_async(
|
||||
store: AsyncOutboxDispatchStore,
|
||||
keys: tuple[str, ...],
|
||||
now: datetime,
|
||||
) -> Optional[tuple[ClaimedOutboxMessage, ...]]:
|
||||
"""异步全量认领组合回调,竞争丢失时释放已取得 lease。"""
|
||||
claimed: list[ClaimedOutboxMessage] = []
|
||||
for key in keys:
|
||||
message = await store.claim_by_event_key(
|
||||
key,
|
||||
now,
|
||||
now + timedelta(seconds=OUTBOX_LEASE_SECONDS),
|
||||
)
|
||||
if message is None:
|
||||
for owned in claimed:
|
||||
await store.retry(
|
||||
owned.message_id,
|
||||
owned.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="组合副作用由其他 owner 接管",
|
||||
dead=False,
|
||||
)
|
||||
return None
|
||||
claimed.append(message)
|
||||
return tuple(claimed)
|
||||
|
||||
|
||||
async def _deliver_added_effects_async(
|
||||
store: AsyncOutboxDispatchStore,
|
||||
subscribe_id: int,
|
||||
payload: dict[str, Any],
|
||||
notification: Optional[dict[str, object]],
|
||||
effect: Callable[[], Any],
|
||||
) -> None:
|
||||
"""异步认领组合回调并按各 intent 的确认结果分别结算。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
claimed = await _claim_added_effects_async(
|
||||
store,
|
||||
_added_effect_keys(subscribe_id, payload, notification),
|
||||
now,
|
||||
)
|
||||
if claimed is None:
|
||||
return
|
||||
try:
|
||||
report_delivered = await effect()
|
||||
except Exception as error:
|
||||
for message in claimed:
|
||||
await store.retry(
|
||||
message.message_id,
|
||||
message.attempt,
|
||||
next_retry_at=now,
|
||||
last_error=str(error)[:4000],
|
||||
dead=False,
|
||||
)
|
||||
raise
|
||||
for message in claimed[:-1]:
|
||||
if not await store.complete(message.message_id, message.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增完成凭证已失效")
|
||||
report = claimed[-1]
|
||||
if report_delivered is False:
|
||||
await store.retry(
|
||||
report.message_id,
|
||||
report.attempt,
|
||||
next_retry_at=now,
|
||||
last_error="订阅新增统计未确认",
|
||||
dead=False,
|
||||
)
|
||||
else:
|
||||
if not await store.complete(report.message_id, report.attempt, now):
|
||||
raise OutboxLeaseLostError("订阅新增统计完成凭证已失效")
|
||||
|
||||
+100
-22
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Any
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -13,9 +15,12 @@ from app.application.security.user import (
|
||||
AuxiliaryUserCreate,
|
||||
ChainUserRepository,
|
||||
FrozenJson,
|
||||
LastActiveSuperuserError,
|
||||
UserAuthSnapshot,
|
||||
UserNameConflictError,
|
||||
UserRepository,
|
||||
UserSnapshot,
|
||||
UserUpdateResult,
|
||||
)
|
||||
from app.db.models.user import User
|
||||
from app.db.oper.user import UserOper
|
||||
@@ -54,12 +59,12 @@ class SqlAlchemyUserRepository(UserRepository):
|
||||
self._session = session
|
||||
self._oper = UserOper(db=session)
|
||||
|
||||
def get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
def get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""在同步请求会话中按用户名读取冻结快照。"""
|
||||
model = self._oper.get_by_name(name)
|
||||
return _to_snapshot(model) if model else None
|
||||
|
||||
def get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""在同步请求会话中按 ID 读取冻结快照。"""
|
||||
model = self._oper.get_by_id(user_id)
|
||||
return _to_snapshot(model) if model else None
|
||||
@@ -68,33 +73,64 @@ class SqlAlchemyUserRepository(UserRepository):
|
||||
"""在异步请求会话中读取全部冻结用户快照。"""
|
||||
return [_to_snapshot(model) for model in await self._oper.async_list()]
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""在异步请求会话中按用户名读取冻结快照。"""
|
||||
model = await self._oper.async_get_by_name(name)
|
||||
return _to_snapshot(model) if model else None
|
||||
|
||||
async def async_get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
async def async_get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""在异步请求会话中按 ID 读取冻结快照。"""
|
||||
model = await self._oper.async_get_by_id(user_id)
|
||||
return _to_snapshot(model) if model else None
|
||||
|
||||
async def async_create(self, payload: dict[str, Any]) -> UserSnapshot | None:
|
||||
async def async_create(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> Optional[UserSnapshot]:
|
||||
"""在请求事务中暂存用户创建并返回冻结快照。"""
|
||||
model = await self._oper.async_create(payload)
|
||||
return _to_snapshot(model) if model else None
|
||||
session = self._require_async_session()
|
||||
model = User(**payload)
|
||||
session.add(model)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError as error:
|
||||
raise UserNameConflictError(payload.get("name")) from error
|
||||
return _to_snapshot(model)
|
||||
|
||||
async def async_update(
|
||||
self,
|
||||
user_id: int,
|
||||
payload: dict[str, Any],
|
||||
) -> UserSnapshot | None:
|
||||
"""在请求事务中暂存用户更新并返回更新后的冻结快照。"""
|
||||
model = await self._oper.async_update(user_id, payload)
|
||||
return _to_snapshot(model) if model else None
|
||||
) -> Optional[UserUpdateResult]:
|
||||
"""原子更新用户;数据库外键负责按用户名级联偏好。"""
|
||||
session = self._require_async_session()
|
||||
model = await self._locked_user(session, user_id, payload)
|
||||
if model is None:
|
||||
return None
|
||||
old_name = model.name
|
||||
new_name = str(payload.get("name", old_name))
|
||||
values = {key: value for key, value in payload.items() if key != "id"}
|
||||
for key, value in values.items():
|
||||
setattr(model, key, value)
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError as error:
|
||||
raise UserNameConflictError(new_name) from error
|
||||
return UserUpdateResult(
|
||||
user=_to_snapshot(model),
|
||||
previous_name=old_name,
|
||||
)
|
||||
|
||||
async def async_delete(self, user_id: int) -> None:
|
||||
"""在请求事务中暂存用户删除。"""
|
||||
await self._oper.async_delete(user_id)
|
||||
async def async_delete(self, user_id: int) -> Optional[str]:
|
||||
"""原子删除用户;数据库外键负责级联偏好和 PassKey。"""
|
||||
session = self._require_async_session()
|
||||
model = await self._locked_user(session, user_id, None)
|
||||
if model is None:
|
||||
return None
|
||||
username: str = model.name
|
||||
await session.delete(model)
|
||||
await session.flush()
|
||||
return username
|
||||
|
||||
async def async_update_otp_by_name(
|
||||
self,
|
||||
@@ -105,6 +141,45 @@ class SqlAlchemyUserRepository(UserRepository):
|
||||
"""在请求事务中暂存用户 OTP 状态更新。"""
|
||||
await self._oper.async_update_otp_by_name(name, otp, secret)
|
||||
|
||||
def _require_async_session(self) -> AsyncSession:
|
||||
"""返回写用例要求的异步 Session,拒绝错误组合。"""
|
||||
if not isinstance(self._session, AsyncSession):
|
||||
raise RuntimeError("用户异步写入必须绑定 AsyncSession")
|
||||
return self._session
|
||||
|
||||
@staticmethod
|
||||
async def _locked_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
payload: Optional[dict[str, Any]],
|
||||
) -> Optional[User]:
|
||||
"""先锁管理员集合再锁目标用户,保护并发下最后一个启用管理员。"""
|
||||
result = await session.execute(
|
||||
select(User)
|
||||
.where(User.is_active.is_(True), User.is_superuser.is_(True))
|
||||
.order_by(User.id)
|
||||
.with_for_update()
|
||||
)
|
||||
administrators = list(result.scalars().all())
|
||||
model = next(
|
||||
(administrator for administrator in administrators if administrator.id == user_id),
|
||||
None,
|
||||
)
|
||||
if model is None:
|
||||
locked = await session.execute(select(User).where(User.id == user_id).with_for_update())
|
||||
model = cast(Optional[User], locked.scalars().first())
|
||||
if model is None:
|
||||
return None
|
||||
remains_active = (
|
||||
payload is not None
|
||||
and bool(payload.get("is_active", model.is_active))
|
||||
and bool(payload.get("is_superuser", model.is_superuser))
|
||||
)
|
||||
removes_active_superuser = bool(model.is_active and model.is_superuser and not remains_active)
|
||||
if removes_active_superuser and len(administrators) <= 1:
|
||||
raise LastActiveSuperuserError(model.name)
|
||||
return model
|
||||
|
||||
|
||||
class TransactionalUserRepository(ChainUserRepository):
|
||||
"""为 Chain、Agent 和进程级认证提供短生命周期用户会话。"""
|
||||
@@ -119,23 +194,23 @@ class TransactionalUserRepository(ChainUserRepository):
|
||||
self._sync_session = sync_session
|
||||
self._async_session = async_session
|
||||
|
||||
def get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
def get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""按用户名读取公开用户快照。"""
|
||||
with self._sync_session() as session:
|
||||
return SqlAlchemyUserRepository(session).get_by_name(name)
|
||||
|
||||
def get_by_id(self, user_id: int) -> UserSnapshot | None:
|
||||
def get_by_id(self, user_id: int) -> Optional[UserSnapshot]:
|
||||
"""按 ID 读取公开用户快照。"""
|
||||
with self._sync_session() as session:
|
||||
return SqlAlchemyUserRepository(session).get_by_id(user_id)
|
||||
|
||||
def get_auth_by_name(self, name: str) -> UserAuthSnapshot | None:
|
||||
def get_auth_by_name(self, name: str) -> Optional[UserAuthSnapshot]:
|
||||
"""按用户名读取认证凭据快照。"""
|
||||
with self._sync_session() as session:
|
||||
model = UserOper(db=session).get_by_name(name)
|
||||
return _to_auth_snapshot(model) if model else None
|
||||
|
||||
async def async_get_by_name(self, name: str) -> UserSnapshot | None:
|
||||
async def async_get_by_name(self, name: str) -> Optional[UserSnapshot]:
|
||||
"""异步按用户名读取公开用户快照。"""
|
||||
async with self._async_session() as session:
|
||||
return await SqlAlchemyUserRepository(session).async_get_by_name(name)
|
||||
@@ -164,7 +239,7 @@ class TransactionalUserRepository(ChainUserRepository):
|
||||
def get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""同步读取用户通知设置的只读快照。"""
|
||||
user = self.get_by_name(name)
|
||||
return user.settings if user else None
|
||||
@@ -172,12 +247,15 @@ class TransactionalUserRepository(ChainUserRepository):
|
||||
async def async_get_notification_settings(
|
||||
self,
|
||||
name: str,
|
||||
) -> Mapping[str, FrozenJson] | None:
|
||||
) -> Optional[Mapping[str, FrozenJson]]:
|
||||
"""异步读取用户通知设置的只读快照。"""
|
||||
user = await self.async_get_by_name(name)
|
||||
return user.settings if user else None
|
||||
|
||||
def find_name_by_bindings(self, bindings: Mapping[str, object]) -> str | None:
|
||||
def find_name_by_bindings(
|
||||
self,
|
||||
bindings: Mapping[str, object],
|
||||
) -> Optional[str]:
|
||||
"""仅在全部绑定唯一匹配同一启用用户时返回用户名。"""
|
||||
if not bindings:
|
||||
return None
|
||||
|
||||
+105
-67
@@ -4,25 +4,27 @@
|
||||
同步引擎与未池化的全局异步引擎都在此按需创建(首次访问时,不在 import 期);
|
||||
按事件循环池化的异步引擎由 session 模块创建。三者的构建参数在这里收口。
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Dict, Optional, cast
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
from sqlalchemy import NullPool, QueuePool, create_engine, event, text
|
||||
from sqlalchemy.engine import Engine as SyncEngine
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine, create_async_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine as SaAsyncEngine
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import Pool
|
||||
|
||||
from app.foundation.environment import is_free_threaded_runtime
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.db.diagnostics import _register_database_error_logging
|
||||
from app.db.worker import DATABASE_WORKER_MAX_WORKERS
|
||||
from app.foundation.environment import is_free_threaded_runtime
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.observability import record_metric
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
|
||||
|
||||
def _database_backend_label() -> str:
|
||||
"""把数据库类型收敛为有限的观测标签。"""
|
||||
return "postgresql" if get_runtime_setting('DB_TYPE').lower() == "postgresql" else "sqlite"
|
||||
return "postgresql" if get_runtime_setting("DB_TYPE").lower() == "postgresql" else "sqlite"
|
||||
|
||||
|
||||
def _sync_postgresql_driver() -> Optional[str]:
|
||||
@@ -49,6 +51,23 @@ def _register_database_pool_metrics(engine: SyncEngine) -> None:
|
||||
event.listen(engine.pool, "checkin", record_checkin)
|
||||
|
||||
|
||||
def _register_sqlite_foreign_keys(engine: SyncEngine) -> None:
|
||||
"""为每条 SQLite 连接启用模型声明的级联和引用完整性约束。"""
|
||||
|
||||
def enable_foreign_keys(
|
||||
dbapi_connection: Any,
|
||||
_connection_record: Any,
|
||||
) -> None:
|
||||
"""在连接进入池前启用 SQLite 外键检查。"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
event.listen(engine, "connect", enable_foreign_keys)
|
||||
|
||||
|
||||
def _async_pool_kwargs(pooled: bool) -> dict:
|
||||
"""
|
||||
异步引擎的连接池参数。
|
||||
@@ -61,9 +80,9 @@ def _async_pool_kwargs(pooled: bool) -> dict:
|
||||
if not pooled:
|
||||
return {"poolclass": NullPool}
|
||||
return {
|
||||
"pool_size": get_runtime_setting('DB_ASYNC_POOL_SIZE'),
|
||||
"max_overflow": get_runtime_setting('DB_ASYNC_MAX_OVERFLOW'),
|
||||
"pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'),
|
||||
"pool_size": get_runtime_setting("DB_ASYNC_POOL_SIZE"),
|
||||
"max_overflow": get_runtime_setting("DB_ASYNC_MAX_OVERFLOW"),
|
||||
"pool_timeout": get_runtime_setting("DB_POOL_TIMEOUT"),
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +94,7 @@ def _get_database_engine(is_async: bool = False, pooled: bool = False):
|
||||
:return: 返回对应的数据库引擎
|
||||
"""
|
||||
# 根据数据库类型选择连接方式
|
||||
if get_runtime_setting('DB_TYPE').lower() == "postgresql":
|
||||
if get_runtime_setting("DB_TYPE").lower() == "postgresql":
|
||||
return _get_postgresql_engine(is_async, pooled=pooled)
|
||||
else:
|
||||
return _get_sqlite_engine(is_async, pooled=pooled)
|
||||
@@ -87,39 +106,42 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False):
|
||||
"""
|
||||
# 连接参数
|
||||
_connect_args = {
|
||||
"timeout": get_runtime_setting('DB_TIMEOUT'),
|
||||
"timeout": get_runtime_setting("DB_TIMEOUT"),
|
||||
}
|
||||
# 允许部署侧注入驱动级参数(如 PgBouncer 事务模式下的 statement_cache_size)
|
||||
_connect_args.update(get_runtime_setting('DB_CONNECT_ARGS') or {})
|
||||
_connect_args.update(get_runtime_setting("DB_CONNECT_ARGS") or {})
|
||||
# 启用 WAL 模式时的额外配置
|
||||
if get_runtime_setting('DB_WAL_ENABLE'):
|
||||
if get_runtime_setting("DB_WAL_ENABLE"):
|
||||
_connect_args["check_same_thread"] = False
|
||||
|
||||
# 创建同步引擎
|
||||
if not is_async:
|
||||
# 根据池类型设置 poolclass 和相关参数
|
||||
_pool_class = NullPool if get_runtime_setting('DB_POOL_TYPE') == "NullPool" else QueuePool
|
||||
_pool_class = NullPool if get_runtime_setting("DB_POOL_TYPE") == "NullPool" else QueuePool
|
||||
|
||||
# 数据库参数
|
||||
_db_kwargs = {
|
||||
"url": get_runtime_setting('DB_SQLITE_URL')(),
|
||||
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
|
||||
"echo": get_runtime_setting('DB_ECHO'),
|
||||
"url": get_runtime_setting("DB_SQLITE_URL")(),
|
||||
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
|
||||
"echo": get_runtime_setting("DB_ECHO"),
|
||||
"poolclass": _pool_class,
|
||||
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
|
||||
"connect_args": _connect_args
|
||||
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
|
||||
"connect_args": _connect_args,
|
||||
}
|
||||
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if _pool_class == QueuePool:
|
||||
_db_kwargs.update({
|
||||
"pool_size": get_runtime_setting('DB_SQLITE_POOL_SIZE'),
|
||||
"pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'),
|
||||
"max_overflow": get_runtime_setting('DB_SQLITE_MAX_OVERFLOW')
|
||||
})
|
||||
_db_kwargs.update(
|
||||
{
|
||||
"pool_size": get_runtime_setting("DB_SQLITE_POOL_SIZE"),
|
||||
"pool_timeout": get_runtime_setting("DB_POOL_TIMEOUT"),
|
||||
"max_overflow": get_runtime_setting("DB_SQLITE_MAX_OVERFLOW"),
|
||||
}
|
||||
)
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(**_db_kwargs)
|
||||
_register_sqlite_foreign_keys(engine)
|
||||
_register_database_error_logging(engine)
|
||||
_register_database_pool_metrics(engine)
|
||||
|
||||
@@ -129,7 +151,7 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False):
|
||||
# 设置一次,而同步引擎的首次创建由 lifespan 数据库准备组件中的 init_db() 完成,
|
||||
# 不存在一群线程
|
||||
# 等在锁上的场面;即便退化到运行期首次访问,阻塞的也只是本地 SQLite 的一次 PRAGMA。
|
||||
_journal_mode = "WAL" if get_runtime_setting('DB_WAL_ENABLE') else "DELETE"
|
||||
_journal_mode = "WAL" if get_runtime_setting("DB_WAL_ENABLE") else "DELETE"
|
||||
with engine.connect() as connection:
|
||||
current_mode = connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")).scalar()
|
||||
print(f"SQLite database journal mode set to: {current_mode}")
|
||||
@@ -138,15 +160,16 @@ def _get_sqlite_engine(is_async: bool = False, pooled: bool = False):
|
||||
else:
|
||||
# 数据库参数,只能使用 NullPool
|
||||
_db_kwargs = {
|
||||
"url": get_runtime_setting('DB_SQLITE_URL')("aiosqlite"),
|
||||
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
|
||||
"echo": get_runtime_setting('DB_ECHO'),
|
||||
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
|
||||
"url": get_runtime_setting("DB_SQLITE_URL")("aiosqlite"),
|
||||
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
|
||||
"echo": get_runtime_setting("DB_ECHO"),
|
||||
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
|
||||
"connect_args": _connect_args,
|
||||
**_async_pool_kwargs(pooled),
|
||||
}
|
||||
# 创建异步数据库引擎
|
||||
async_engine = create_async_engine(**_db_kwargs)
|
||||
_register_sqlite_foreign_keys(async_engine.sync_engine)
|
||||
_register_database_error_logging(async_engine.sync_engine)
|
||||
_register_database_pool_metrics(async_engine.sync_engine)
|
||||
|
||||
@@ -162,51 +185,55 @@ def _get_postgresql_engine(is_async: bool = False, pooled: bool = False):
|
||||
"""
|
||||
获取PostgreSQL数据库引擎
|
||||
"""
|
||||
db_url = get_runtime_setting('DB_POSTGRESQL_URL')(_sync_postgresql_driver())
|
||||
db_url = get_runtime_setting("DB_POSTGRESQL_URL")(_sync_postgresql_driver())
|
||||
|
||||
# PostgreSQL连接参数。允许部署侧注入驱动级参数,
|
||||
# 例如经 PgBouncer 事务模式接入时 asyncpg 需要 statement_cache_size=0
|
||||
_connect_args = dict(get_runtime_setting('DB_CONNECT_ARGS') or {})
|
||||
_connect_args = dict(get_runtime_setting("DB_CONNECT_ARGS") or {})
|
||||
|
||||
# 创建同步引擎
|
||||
if not is_async:
|
||||
# 根据池类型设置 poolclass 和相关参数
|
||||
_pool_class = NullPool if get_runtime_setting('DB_POOL_TYPE') == "NullPool" else QueuePool
|
||||
_pool_class = NullPool if get_runtime_setting("DB_POOL_TYPE") == "NullPool" else QueuePool
|
||||
|
||||
# 数据库参数
|
||||
_db_kwargs = {
|
||||
"url": db_url,
|
||||
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
|
||||
"echo": get_runtime_setting('DB_ECHO'),
|
||||
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
|
||||
"echo": get_runtime_setting("DB_ECHO"),
|
||||
"poolclass": _pool_class,
|
||||
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
|
||||
"connect_args": _connect_args
|
||||
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
|
||||
"connect_args": _connect_args,
|
||||
}
|
||||
|
||||
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
|
||||
if _pool_class == QueuePool:
|
||||
_db_kwargs.update({
|
||||
"pool_size": get_runtime_setting('DB_POSTGRESQL_POOL_SIZE'),
|
||||
"pool_timeout": get_runtime_setting('DB_POOL_TIMEOUT'),
|
||||
"max_overflow": get_runtime_setting('DB_POSTGRESQL_MAX_OVERFLOW')
|
||||
})
|
||||
_db_kwargs.update(
|
||||
{
|
||||
"pool_size": get_runtime_setting("DB_POSTGRESQL_POOL_SIZE"),
|
||||
"pool_timeout": get_runtime_setting("DB_POOL_TIMEOUT"),
|
||||
"max_overflow": get_runtime_setting("DB_POSTGRESQL_MAX_OVERFLOW"),
|
||||
}
|
||||
)
|
||||
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(**_db_kwargs)
|
||||
_register_database_error_logging(engine)
|
||||
_register_database_pool_metrics(engine)
|
||||
print(f"PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}")
|
||||
print(
|
||||
f"PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}"
|
||||
)
|
||||
|
||||
return engine
|
||||
else:
|
||||
async_db_url = get_runtime_setting('DB_POSTGRESQL_URL')("asyncpg")
|
||||
async_db_url = get_runtime_setting("DB_POSTGRESQL_URL")("asyncpg")
|
||||
|
||||
# 数据库参数,只能使用 NullPool
|
||||
_db_kwargs = {
|
||||
"url": async_db_url,
|
||||
"pool_pre_ping": get_runtime_setting('DB_POOL_PRE_PING'),
|
||||
"echo": get_runtime_setting('DB_ECHO'),
|
||||
"pool_recycle": get_runtime_setting('DB_POOL_RECYCLE'),
|
||||
"pool_pre_ping": get_runtime_setting("DB_POOL_PRE_PING"),
|
||||
"echo": get_runtime_setting("DB_ECHO"),
|
||||
"pool_recycle": get_runtime_setting("DB_POOL_RECYCLE"),
|
||||
"connect_args": _connect_args,
|
||||
**_async_pool_kwargs(pooled),
|
||||
}
|
||||
@@ -214,7 +241,9 @@ def _get_postgresql_engine(is_async: bool = False, pooled: bool = False):
|
||||
async_engine = create_async_engine(**_db_kwargs)
|
||||
_register_database_error_logging(async_engine.sync_engine)
|
||||
_register_database_pool_metrics(async_engine.sync_engine)
|
||||
print(f"Async PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}")
|
||||
print(
|
||||
f"Async PostgreSQL database connected to {get_runtime_setting('DB_POSTGRESQL_TARGET')}/{get_runtime_setting('DB_POSTGRESQL_DATABASE')}"
|
||||
)
|
||||
|
||||
return async_engine
|
||||
|
||||
@@ -291,7 +320,7 @@ def _async_pool_enabled() -> bool:
|
||||
"""
|
||||
是否启用异步连接池。设为 NullPool 可回退到池化前的行为。
|
||||
"""
|
||||
return str(get_runtime_setting('DB_ASYNC_POOL_TYPE') or "").strip().lower() != "nullpool"
|
||||
return str(get_runtime_setting("DB_ASYNC_POOL_TYPE") or "").strip().lower() != "nullpool"
|
||||
|
||||
|
||||
def connection_budget() -> Dict[str, int]:
|
||||
@@ -306,16 +335,23 @@ def connection_budget() -> Dict[str, int]:
|
||||
就顶穿了 max_connections。
|
||||
:return: 单进程各项上限、worker 数与合计
|
||||
"""
|
||||
if get_runtime_setting('DB_TYPE').lower() == "postgresql":
|
||||
sync_max = get_runtime_setting('DB_POSTGRESQL_POOL_SIZE') + get_runtime_setting('DB_POSTGRESQL_MAX_OVERFLOW')
|
||||
if get_runtime_setting("DB_TYPE").lower() == "postgresql":
|
||||
sync_max = get_runtime_setting("DB_POSTGRESQL_POOL_SIZE") + get_runtime_setting("DB_POSTGRESQL_MAX_OVERFLOW")
|
||||
else:
|
||||
sync_max = get_runtime_setting('DB_SQLITE_POOL_SIZE') + get_runtime_setting('DB_SQLITE_MAX_OVERFLOW')
|
||||
if get_runtime_setting('DB_POOL_TYPE') == "NullPool":
|
||||
sync_max = get_runtime_setting("DB_SQLITE_POOL_SIZE") + get_runtime_setting("DB_SQLITE_MAX_OVERFLOW")
|
||||
if get_runtime_setting("DB_POOL_TYPE") == "NullPool":
|
||||
# 未池化连接由通用线程池和专属数据库 worker 共同创建,二者都要计入上限估计。
|
||||
sync_max = get_runtime_setting('CONF').threadpool + DATABASE_WORKER_MAX_WORKERS
|
||||
async_max = (get_runtime_setting('DB_ASYNC_POOL_SIZE') + get_runtime_setting('DB_ASYNC_MAX_OVERFLOW')
|
||||
if _async_pool_enabled() else 0)
|
||||
fallback = get_runtime_setting('DB_ASYNC_FALLBACK_LIMIT') if _async_pool_enabled() else get_runtime_setting('CONF').scheduler
|
||||
sync_max = get_runtime_setting("CONF").threadpool + DATABASE_WORKER_MAX_WORKERS
|
||||
async_max = (
|
||||
get_runtime_setting("DB_ASYNC_POOL_SIZE") + get_runtime_setting("DB_ASYNC_MAX_OVERFLOW")
|
||||
if _async_pool_enabled()
|
||||
else 0
|
||||
)
|
||||
fallback = (
|
||||
get_runtime_setting("DB_ASYNC_FALLBACK_LIMIT")
|
||||
if _async_pool_enabled()
|
||||
else get_runtime_setting("CONF").scheduler
|
||||
)
|
||||
per_worker = sync_max + async_max + fallback
|
||||
# worker 数非法时按 1 计:退化成 0 会让合计归零、反而误判「额度充足」
|
||||
workers = get_runtime_setting("API_WORKERS", 1) or 1
|
||||
@@ -339,27 +375,29 @@ def check_connection_budget() -> bool:
|
||||
:return: 是否在额度之内
|
||||
"""
|
||||
budget = connection_budget()
|
||||
if get_runtime_setting('DB_TYPE').lower() != "postgresql":
|
||||
logger.info(f"数据库连接理论峰值: {budget['total']} "
|
||||
f"(单进程 {budget['per_worker']} = 同步 {budget['sync']} + 异步池 "
|
||||
f"{budget['async_pooled']} + 回退 {budget['async_fallback']}"
|
||||
f",worker {budget['workers']})")
|
||||
if get_runtime_setting("DB_TYPE").lower() != "postgresql":
|
||||
logger.info(
|
||||
f"数据库连接理论峰值: {budget['total']} "
|
||||
f"(单进程 {budget['per_worker']} = 同步 {budget['sync']} + 异步池 "
|
||||
f"{budget['async_pooled']} + 回退 {budget['async_fallback']}"
|
||||
f",worker {budget['workers']})"
|
||||
)
|
||||
return True
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
max_conn = int(conn.execute(text("SHOW max_connections")).scalar() or 0)
|
||||
reserved = int(
|
||||
conn.execute(text("SHOW superuser_reserved_connections")).scalar() or 0
|
||||
)
|
||||
reserved = int(conn.execute(text("SHOW superuser_reserved_connections")).scalar() or 0)
|
||||
except Exception as err:
|
||||
logger.warn(f"无法读取 PostgreSQL 连接上限,跳过额度校验: {err}")
|
||||
return True
|
||||
available = max_conn - reserved
|
||||
total = budget["total"]
|
||||
detail = (f"理论峰值 {total} = 单进程 {budget['per_worker']} (同步 {budget['sync']} "
|
||||
f"+ 异步池 {budget['async_pooled']} + 回退 {budget['async_fallback']}) "
|
||||
f"x worker {budget['workers']},数据库可用 {available} "
|
||||
f"(max_connections {max_conn} - 保留 {reserved})")
|
||||
detail = (
|
||||
f"理论峰值 {total} = 单进程 {budget['per_worker']} (同步 {budget['sync']} "
|
||||
f"+ 异步池 {budget['async_pooled']} + 回退 {budget['async_fallback']}) "
|
||||
f"x worker {budget['workers']},数据库可用 {available} "
|
||||
f"(max_connections {max_conn} - 保留 {reserved})"
|
||||
)
|
||||
if total > available:
|
||||
logger.error(
|
||||
f"数据库连接额度不足:{detail}。"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy import JSON, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
+45
-42
@@ -1,8 +1,9 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, Text, select, ForeignKey, update
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
@@ -13,7 +14,8 @@ def _get_by_user_id_statement(model: type["PassKey"], user_id: int):
|
||||
|
||||
|
||||
def _get_by_credential_id_statement(
|
||||
model: type["PassKey"], credential_id: str,
|
||||
model: type["PassKey"],
|
||||
credential_id: str,
|
||||
):
|
||||
"""构造按凭证 ID 筛选启用 PassKey 的查询语句。"""
|
||||
return select(model).where(
|
||||
@@ -26,10 +28,20 @@ class PassKey(Base):
|
||||
"""
|
||||
用户PassKey凭证表
|
||||
"""
|
||||
|
||||
# ID
|
||||
id = get_id_column()
|
||||
# 用户ID
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=False, index=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey(
|
||||
"user.id",
|
||||
name="fk_passkey_user_id_user",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# 凭证ID (credential_id)
|
||||
credential_id: Mapped[str] = mapped_column(String, nullable=False, unique=True, index=True)
|
||||
# 凭证公钥
|
||||
@@ -51,40 +63,32 @@ class PassKey(Base):
|
||||
|
||||
@classmethod
|
||||
def get_by_user_id(
|
||||
cls,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
cls,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
):
|
||||
"""在调用方 Session 中获取用户的所有启用 PassKey。"""
|
||||
return list(db.execute(
|
||||
_get_by_user_id_statement(cls, user_id)
|
||||
).scalars().all())
|
||||
return list(db.execute(_get_by_user_id_statement(cls, user_id)).scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def async_get_by_user_id(cls, db: AsyncSession, user_id: int):
|
||||
"""在调用方 AsyncSession 中获取用户的所有启用 PassKey。"""
|
||||
result = await db.execute(
|
||||
_get_by_user_id_statement(cls, user_id)
|
||||
)
|
||||
result = await db.execute(_get_by_user_id_statement(cls, user_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
def get_by_credential_id(
|
||||
cls,
|
||||
db: Session,
|
||||
credential_id: str,
|
||||
cls,
|
||||
db: Session,
|
||||
credential_id: str,
|
||||
):
|
||||
"""在调用方 Session 中按凭证 ID 获取启用 PassKey。"""
|
||||
return db.execute(
|
||||
_get_by_credential_id_statement(cls, credential_id)
|
||||
).scalars().first()
|
||||
return db.execute(_get_by_credential_id_statement(cls, credential_id)).scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str):
|
||||
"""在调用方 AsyncSession 中根据凭证 ID 获取启用 PassKey。"""
|
||||
result = await db.execute(
|
||||
_get_by_credential_id_statement(cls, credential_id)
|
||||
)
|
||||
result = await db.execute(_get_by_credential_id_statement(cls, credential_id))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
@@ -95,17 +99,13 @@ class PassKey(Base):
|
||||
@classmethod
|
||||
async def async_get_by_id(cls, db: AsyncSession, passkey_id: int):
|
||||
"""在调用方 AsyncSession 中根据 ID 获取 PassKey。"""
|
||||
result = await db.execute(
|
||||
select(cls).filter(cls.id == passkey_id)
|
||||
)
|
||||
result = await db.execute(select(cls).filter(cls.id == passkey_id))
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
def delete_by_id(cls, db: Session, passkey_id: int, user_id: int):
|
||||
"""删除指定用户的PassKey"""
|
||||
passkey = db.execute(
|
||||
select(cls).where(cls.id == passkey_id, cls.user_id == user_id)
|
||||
).scalars().first()
|
||||
passkey = db.execute(select(cls).where(cls.id == passkey_id, cls.user_id == user_id)).scalars().first()
|
||||
if passkey:
|
||||
db.delete(passkey)
|
||||
return True
|
||||
@@ -114,12 +114,7 @@ class PassKey(Base):
|
||||
@classmethod
|
||||
async def async_delete_by_id(cls, db: AsyncSession, passkey_id: int, user_id: int):
|
||||
"""异步删除指定用户的PassKey"""
|
||||
result = await db.execute(
|
||||
select(cls).filter(
|
||||
cls.id == passkey_id,
|
||||
cls.user_id == user_id
|
||||
)
|
||||
)
|
||||
result = await db.execute(select(cls).filter(cls.id == passkey_id, cls.user_id == user_id))
|
||||
passkey = result.scalars().first()
|
||||
if passkey:
|
||||
await db.delete(passkey)
|
||||
@@ -128,16 +123,24 @@ class PassKey(Base):
|
||||
|
||||
def update_last_used(self, db: Session, sign_count: int):
|
||||
"""更新最后使用时间和签名计数"""
|
||||
db.execute(update(type(self)).where(type(self).id == self.id).values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
))
|
||||
db.execute(
|
||||
update(type(self))
|
||||
.where(type(self).id == self.id)
|
||||
.values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
async def async_update_last_used(self, db: AsyncSession, sign_count: int):
|
||||
"""异步更新最后使用时间和签名计数"""
|
||||
await db.execute(update(type(self)).where(type(self).id == self.id).values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
))
|
||||
await db.execute(
|
||||
update(type(self))
|
||||
.where(type(self).id == self.id)
|
||||
.values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
+10
-2
@@ -1,5 +1,6 @@
|
||||
from typing import Any, Optional
|
||||
from sqlalchemy import Boolean, JSON, String, select
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Index, String, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
@@ -10,10 +11,11 @@ class User(Base):
|
||||
"""
|
||||
用户表
|
||||
"""
|
||||
|
||||
# ID
|
||||
id = get_id_column()
|
||||
# 用户名,唯一值
|
||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 邮箱
|
||||
email: Mapped[Optional[str]] = mapped_column(String)
|
||||
# 加密后密码
|
||||
@@ -33,6 +35,8 @@ class User(Base):
|
||||
# 用户个性化设置 json
|
||||
settings: Mapped[Optional[Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
__table_args__ = (Index("ux_user_name", "name", unique=True),)
|
||||
|
||||
@classmethod
|
||||
def get_by_name(
|
||||
cls,
|
||||
@@ -68,18 +72,21 @@ class User(Base):
|
||||
return result.scalars().first()
|
||||
|
||||
def delete_by_name(self, db: Session, name: str):
|
||||
"""在调用方同步会话中按用户名暂存删除。"""
|
||||
user = self.get_by_name(db, name)
|
||||
if user:
|
||||
db.delete(user)
|
||||
return True
|
||||
|
||||
async def async_delete_by_name(self, db: AsyncSession, name: str):
|
||||
"""在调用方异步会话中按用户名暂存删除。"""
|
||||
user = await self.async_get_by_name(db, name)
|
||||
if user:
|
||||
await db.delete(user)
|
||||
return True
|
||||
|
||||
def delete_by_id(self, db: Session, user_id: int):
|
||||
"""在调用方同步会话中按用户 ID 暂存删除。"""
|
||||
user = self.get_by_id(db, user_id)
|
||||
if user:
|
||||
db.delete(user)
|
||||
@@ -94,6 +101,7 @@ class User(Base):
|
||||
return True
|
||||
|
||||
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
|
||||
"""在调用方同步会话中更新指定用户的 OTP 状态。"""
|
||||
user = self.get_by_name(db, name)
|
||||
if user:
|
||||
user.is_otp = otp
|
||||
|
||||
@@ -1,33 +1,46 @@
|
||||
from typing import Any, Optional
|
||||
from sqlalchemy import String, UniqueConstraint, JSON, select
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String, UniqueConstraint, select
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from app.db.base import get_id_column, Base
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
|
||||
class UserConfig(Base):
|
||||
"""
|
||||
用户配置表
|
||||
"""
|
||||
|
||||
id = get_id_column()
|
||||
# 用户名
|
||||
username: Mapped[Optional[str]] = mapped_column(String)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String,
|
||||
ForeignKey(
|
||||
"user.name",
|
||||
name="fk_userconfig_username_user",
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
# 配置键
|
||||
key: Mapped[Optional[str]] = mapped_column(String)
|
||||
key: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 值
|
||||
value: Mapped[Optional[Any]] = mapped_column(JSON)
|
||||
|
||||
__table_args__ = (
|
||||
# 用户名和配置键联合唯一
|
||||
UniqueConstraint('username', 'key'),
|
||||
UniqueConstraint(
|
||||
"username",
|
||||
"key",
|
||||
name="uq_userconfig_username_key",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_by_key(cls, db: Session, username: str, key: str):
|
||||
"""在调用方 Session 中查询用户配置。"""
|
||||
return db.execute(
|
||||
select(cls).where(cls.username == username, cls.key == key)
|
||||
).scalars().first()
|
||||
return db.execute(select(cls).where(cls.username == username, cls.key == key)).scalars().first()
|
||||
|
||||
def delete_by_key(self, db: Session, username: str, key: str):
|
||||
"""在调用方持有的事务中暂存指定用户配置删除。"""
|
||||
|
||||
+34
-7
@@ -1,11 +1,12 @@
|
||||
"""PassKey 数据访问适配器。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.base import DbOper, execute_dml
|
||||
from app.db.models.passkey import (
|
||||
PassKey,
|
||||
_get_by_credential_id_statement,
|
||||
@@ -54,11 +55,37 @@ class PassKeyOper(DbOper):
|
||||
session.add(passkey)
|
||||
session.flush()
|
||||
|
||||
def update_last_used(self, passkey: PassKey, sign_count: int) -> bool:
|
||||
"""更新凭证最后使用时间和签名计数。"""
|
||||
return bool(self._execute_sync_write(
|
||||
lambda session: passkey.update_last_used(session, sign_count)
|
||||
))
|
||||
def compare_and_update_sign_count(
|
||||
self,
|
||||
passkey_id: int,
|
||||
expected_sign_count: int,
|
||||
sign_count: int,
|
||||
) -> bool:
|
||||
"""仅在凭证仍启用且签名计数未变化时记录本次认证。"""
|
||||
if sign_count < expected_sign_count or (
|
||||
expected_sign_count > 0 and sign_count == expected_sign_count
|
||||
):
|
||||
return False
|
||||
|
||||
count_matches = PassKey.sign_count == expected_sign_count
|
||||
if expected_sign_count == 0:
|
||||
count_matches = or_(PassKey.sign_count == 0, PassKey.sign_count.is_(None))
|
||||
|
||||
statement = (
|
||||
update(PassKey)
|
||||
.where(
|
||||
PassKey.id == passkey_id,
|
||||
PassKey.is_active.is_(True),
|
||||
count_matches,
|
||||
)
|
||||
.values(
|
||||
last_used_at=datetime.now(),
|
||||
sign_count=sign_count,
|
||||
)
|
||||
)
|
||||
return self._execute_sync_write(
|
||||
lambda session: execute_dml(session, statement)
|
||||
) == 1
|
||||
|
||||
def delete_by_id(self, passkey_id: int, user_id: int) -> bool:
|
||||
"""删除指定用户的凭证。"""
|
||||
|
||||
+109
-62
@@ -1,23 +1,26 @@
|
||||
import copy
|
||||
import threading
|
||||
from typing import Any, Union, Dict, Optional
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional, Union
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
from app.db.models.userconfig import UserConfig
|
||||
from app.schemas.types import UserConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.types import UserConfigKey
|
||||
|
||||
|
||||
class UserConfigOper(DbOper, metaclass=Singleton):
|
||||
"""
|
||||
用户配置管理
|
||||
"""
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
"""初始化空快照,数据库加载由启动组合根显式执行。"""
|
||||
super().__init__()
|
||||
self.__USERCONF = {}
|
||||
self.__USERCONF: dict[str, dict[str, JsonData]] = {}
|
||||
self._snapshot_lock = threading.RLock()
|
||||
self._write_lock = threading.RLock()
|
||||
self._loaded = False
|
||||
@@ -25,7 +28,7 @@ class UserConfigOper(DbOper, metaclass=Singleton):
|
||||
def load_snapshot(self, db: Optional[Session] = None) -> None:
|
||||
"""从显式会话或 Oper 事务边界加载用户配置并发布内存快照。"""
|
||||
with self._write_lock:
|
||||
snapshot: dict[str, dict[str, Any]] = {}
|
||||
snapshot: dict[str, dict[str, JsonData]] = {}
|
||||
items = UserConfig.list(db) if db is not None else self._execute_sync_query(
|
||||
UserConfig.list
|
||||
)
|
||||
@@ -43,37 +46,109 @@ class UserConfigOper(DbOper, metaclass=Singleton):
|
||||
if not self._loaded:
|
||||
raise RuntimeError("用户配置快照尚未加载")
|
||||
|
||||
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
|
||||
"""
|
||||
设置用户配置
|
||||
"""
|
||||
if isinstance(key, UserConfigKey):
|
||||
key = key.value
|
||||
@contextmanager
|
||||
def write_scope(self) -> Iterator[None]:
|
||||
"""串行化数据库提交与对应快照发布,避免并发写入乱序。"""
|
||||
self._require_loaded()
|
||||
with self._write_lock:
|
||||
yield
|
||||
|
||||
def write(db):
|
||||
"""在当前事务中按用户配置的假值规则写入记录。"""
|
||||
conf = UserConfig.get_by_key(db=db, username=username, key=key)
|
||||
if conf:
|
||||
if value:
|
||||
conf.value = copy.deepcopy(value)
|
||||
else:
|
||||
db.delete(conf)
|
||||
else:
|
||||
db.add(
|
||||
UserConfig(
|
||||
username=username,
|
||||
key=key,
|
||||
value=copy.deepcopy(value),
|
||||
)
|
||||
)
|
||||
def stage_set(
|
||||
self,
|
||||
db: Session,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> bool:
|
||||
"""在调用方 Session 中暂存写入,返回提交后是否应移除缓存项。"""
|
||||
if isinstance(key, UserConfigKey):
|
||||
key = key.value
|
||||
conf = UserConfig.get_by_key(db=db, username=username, key=key)
|
||||
if conf:
|
||||
if value:
|
||||
conf.value = copy.deepcopy(value)
|
||||
return False
|
||||
db.delete(conf)
|
||||
return True
|
||||
db.add(
|
||||
UserConfig(
|
||||
username=username,
|
||||
key=key,
|
||||
value=copy.deepcopy(value),
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
self._execute_sync_write(write)
|
||||
# 既有运行时语义会保留刚写入的假值,即使其数据库记录被删除。
|
||||
self.__set_config_cache(username=username, key=key, value=value)
|
||||
def publish(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
*,
|
||||
deleted: bool,
|
||||
) -> None:
|
||||
"""仅在数据库提交成功后原子发布对应配置快照。"""
|
||||
if isinstance(key, UserConfigKey):
|
||||
key = key.value
|
||||
if not username or not key:
|
||||
return
|
||||
with self._snapshot_lock:
|
||||
if deleted:
|
||||
user_cache = self.__USERCONF.get(username)
|
||||
if user_cache is None:
|
||||
return
|
||||
user_cache.pop(key, None)
|
||||
if not user_cache:
|
||||
self.__USERCONF.pop(username, None)
|
||||
return
|
||||
self.__USERCONF.setdefault(username, {})[key] = copy.deepcopy(value)
|
||||
|
||||
def get(self, username: str, key: Optional[Union[str, UserConfigKey]] = None) -> Any:
|
||||
def publish_rename(self, previous_name: str, current_name: str) -> None:
|
||||
"""原子迁移已提交改名对应的配置快照,并清理目标孤儿配置。"""
|
||||
if not previous_name or not current_name or previous_name == current_name:
|
||||
return
|
||||
with self._snapshot_lock:
|
||||
self._require_loaded()
|
||||
values = self.__USERCONF.pop(previous_name, None)
|
||||
self.__USERCONF.pop(current_name, None)
|
||||
if values:
|
||||
self.__USERCONF[current_name] = copy.deepcopy(values)
|
||||
|
||||
def publish_delete(self, username: str) -> None:
|
||||
"""原子移除已提交用户删除对应的配置快照。"""
|
||||
if not username:
|
||||
return
|
||||
with self._snapshot_lock:
|
||||
self._require_loaded()
|
||||
self.__USERCONF.pop(username, None)
|
||||
|
||||
def set(
|
||||
self,
|
||||
username: str,
|
||||
key: Union[str, UserConfigKey],
|
||||
value: JsonData,
|
||||
) -> None:
|
||||
"""
|
||||
通过兼容事务入口设置用户配置。
|
||||
|
||||
新宿主调用应使用 ``TransactionalUserConfigurationRepository``;此方法保留给
|
||||
旧插件 ABI,并与规范适配器共享同一暂存、提交后发布及失败恢复语义。
|
||||
"""
|
||||
with self.write_scope():
|
||||
deleted = self._execute_sync_write(
|
||||
lambda db: self.stage_set(db, username, key, value)
|
||||
)
|
||||
try:
|
||||
self.publish(username, key, value, deleted=deleted)
|
||||
except Exception:
|
||||
self.load_snapshot()
|
||||
raise
|
||||
|
||||
def get(
|
||||
self,
|
||||
username: Optional[str],
|
||||
key: Optional[Union[str, UserConfigKey]] = None,
|
||||
) -> JsonData:
|
||||
"""
|
||||
获取用户配置
|
||||
"""
|
||||
@@ -84,34 +159,6 @@ class UserConfigOper(DbOper, metaclass=Singleton):
|
||||
if isinstance(key, UserConfigKey):
|
||||
key = key.value
|
||||
if not key:
|
||||
return copy.deepcopy(self.__get_config_caches(username=username))
|
||||
return copy.deepcopy(self.__get_config_cache(username=username, key=key))
|
||||
|
||||
def __set_config_cache(self, username: str, key: str, value: Any):
|
||||
"""
|
||||
设置配置缓存
|
||||
"""
|
||||
if not username or not key:
|
||||
return
|
||||
with self._snapshot_lock:
|
||||
user_cache = self.__USERCONF.setdefault(username, {})
|
||||
user_cache[key] = copy.deepcopy(value)
|
||||
|
||||
def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取配置缓存
|
||||
"""
|
||||
if not username or not self.__USERCONF:
|
||||
return None
|
||||
return self.__USERCONF.get(username)
|
||||
|
||||
def __get_config_cache(self, username: str, key: str) -> Any:
|
||||
"""
|
||||
获取配置缓存
|
||||
"""
|
||||
if not username or not key or not self.__USERCONF:
|
||||
return None
|
||||
user_cache = self.__get_config_caches(username)
|
||||
if not user_cache:
|
||||
return None
|
||||
return user_cache.get(key)
|
||||
return copy.deepcopy(self.__USERCONF.get(username))
|
||||
user_cache = self.__USERCONF.get(username)
|
||||
return copy.deepcopy(user_cache.get(key) if user_cache else None)
|
||||
|
||||
Reference in New Issue
Block a user