86 lines
3.0 KiB
Python
86 lines
3.0 KiB
Python
"""命台租约(authoritative_runtime_seat):同一人物唯一权威运行位置。
|
|
|
|
- 活跃租约(未过期、非本人)存在时,其他持有者获取被拒(SeatConflict);
|
|
- 租约过期可接管,epoch 递增(旧持有者写入按 epoch 失效——M2 运行时接入);
|
|
- 事件(seat.acquired / released / contested)由 API 层在事务提交后发射,
|
|
避免事务内跨连接写冲突;M1 为 API 级租约,Kernel 前台串行仍为进程内锁,多进程一致在 M2 收口。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.time import as_utc, utc_now
|
|
from app.models.jingsheng import RuntimeSeat
|
|
|
|
|
|
class SeatConflict(Exception):
|
|
"""命台被其他持有者占用(HJ-1101 语义);调用方须留痕 seat.contested。"""
|
|
|
|
def __init__(self, seat: RuntimeSeat) -> None:
|
|
super().__init__(f"命台被 {seat.holder} 占用至 {seat.lease_expires_at}")
|
|
self.seat = seat
|
|
|
|
|
|
async def acquire_lease(
|
|
session: AsyncSession, character_id: str, holder: str, ttl_seconds: int = 60
|
|
) -> RuntimeSeat:
|
|
"""获取或续期命台租约;冲突抛 SeatConflict(不在此发事件,避免事务内跨连接写)。"""
|
|
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
|
|
if seat is None:
|
|
seat = RuntimeSeat(character_id=character_id, epoch=0)
|
|
session.add(seat)
|
|
now = utc_now()
|
|
if (
|
|
seat.holder is not None
|
|
and seat.holder != holder
|
|
and seat.lease_expires_at is not None
|
|
and as_utc(seat.lease_expires_at) > now
|
|
):
|
|
raise SeatConflict(seat)
|
|
# 仅"从其他持有者手中接管"递增 epoch;无人持有时首次获取沿用当前纪元
|
|
takeover = seat.holder is not None and seat.holder != holder
|
|
seat.holder = holder
|
|
seat.lease_expires_at = now + timedelta(seconds=ttl_seconds)
|
|
if takeover:
|
|
seat.epoch += 1
|
|
await session.flush()
|
|
return seat
|
|
|
|
|
|
async def release_lease(session: AsyncSession, character_id: str, holder: str) -> bool:
|
|
"""释放租约;返回是否实际释放(holder 不匹配时无操作)。"""
|
|
seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id))
|
|
if seat is None or seat.holder != holder:
|
|
return False
|
|
seat.holder = None
|
|
seat.lease_expires_at = None
|
|
await session.flush()
|
|
return True
|
|
|
|
|
|
async def emit_seat_event(
|
|
event_type: str,
|
|
character_id: str,
|
|
holder: str | None,
|
|
epoch: int | None,
|
|
*,
|
|
requester: str | None = None,
|
|
) -> None:
|
|
"""事务外发射命台事件(一炁,visibility=internal)。"""
|
|
from app.domains import one_qi
|
|
|
|
payload: dict = {"holder": holder, "epoch": epoch}
|
|
if requester is not None:
|
|
payload["requester"] = requester
|
|
await one_qi.emit(
|
|
event_type,
|
|
character_id=character_id,
|
|
payload=payload,
|
|
visibility="internal",
|
|
source="jingsheng",
|
|
)
|