Files
hunjing/backend/app/domains/jingsheng/seat.py
T

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",
)