Files

604 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deployment-wide roundtable room leases and background-job occupancy.
A room is one live roundtable execution, not one saved draft, not one account,
and not one seat. Different pages running the same draft have distinct hidden
session ids; every seat and step of one execution shares one id. The active set
is the union of every user's:
- a durable foreground/session lease renewed by the browser or a run entry
- live ``extra_keys`` supplied by the Gateway as a reconciliation fallback
only when no durable lease/job already represents that persisted scope
- a background job that is executing or waiting for the user's Step-2
clarification (``queued`` / ``running`` / ``awaiting_input`` /
``cancel_requested``)
Historical jobs older than the hard room lifetime and merely viewing chat
history are not rooms. There is no per-user pool.
Admission and lease creation happen while holding one database-backed gate
row. Updating that row serializes contenders on SQLite, MySQL and PostgreSQL,
so four simultaneous requests cannot all observe the same stale count.
"""
from __future__ import annotations
import asyncio
import hashlib
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.roundtable_jobs.sql import compute_active_dedupe_key
from deerflow.persistence.roundtable_rooms.model import RoundtableRoomLeaseRow
DEFAULT_LEASE_SECONDS = 5 * 60
MIN_LEASE_SECONDS = 60
MAX_LEASE_SECONDS = 60 * 60
JOB_LIVE_SECONDS = 15 * 60
OCCUPYING_JOB_STATUSES = ("queued", "running", "awaiting_input", "cancel_requested")
# A background job keeps running while Step 3 generates its artifacts, but the
# concurrency cap is intentionally scoped to Step 2. ``consensus`` is written
# before Step 3 begins, which releases the slot without waiting for reporting.
NON_OCCUPYING_JOB_PHASES = frozenset({
"consensus",
"summary_writing",
"report_drawing",
"report_done",
})
GATE_ROW_KEY = "__roundtable_room_gate__"
def compute_room_session_key(room_session_id: str | None) -> str | None:
"""Stable key for one live roundtable execution (not one saved draft)."""
clean = (room_session_id or "").strip()
if not clean:
return None
return hashlib.sha256(f"roundtable-session:{clean}".encode()).hexdigest()
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
def _job_is_live(
lease_until: datetime | None,
updated_at: datetime | None,
now: datetime,
live_after: datetime,
) -> bool:
until = _aware(lease_until)
if until is not None and until > now:
return True
updated = _aware(updated_at)
return updated is not None and updated >= live_after
def normalize_lease_seconds(value: int | None) -> int:
"""Clamp a foreground room lease to the supported 1–60 minute window."""
try:
seconds = int(value) if value is not None else DEFAULT_LEASE_SECONDS
except (TypeError, ValueError):
seconds = DEFAULT_LEASE_SECONDS
return max(MIN_LEASE_SECONDS, min(MAX_LEASE_SECONDS, seconds))
def _hard_deadline_reached(
created_at: datetime | None,
now: datetime,
max_hold_minutes: int,
) -> bool:
created = _aware(created_at)
return created is not None and created + timedelta(minutes=max_hold_minutes) <= now
class RoundtableRoomRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
self._lock = asyncio.Lock()
async def admit(
self,
*,
draft_id: str | None,
user_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
enabled: bool,
max_rooms: int,
max_hold_minutes: int = 30,
lease_seconds: int = DEFAULT_LEASE_SECONDS,
extra_keys: set[str] | Mapping[str, str | None] | None = None,
) -> dict[str, Any]:
"""Atomically reserve/renew a room, or reject when the pool is full."""
key = compute_room_session_key(room_session_id) or compute_active_dedupe_key(
draft_id, user_id, task_id
)
return await self.admit_room_key(
room_key=key,
draft_id=draft_id,
user_id=user_id,
task_id=task_id,
holder_user_id=user_id,
enabled=enabled,
max_rooms=max_rooms,
max_hold_minutes=max_hold_minutes,
lease_seconds=lease_seconds,
extra_keys=extra_keys,
)
async def admit_room_key(
self,
*,
room_key: str | None,
draft_id: str | None,
user_id: str | None,
task_id: str | None,
holder_user_id: str | None,
enabled: bool,
max_rooms: int,
max_hold_minutes: int = 30,
lease_seconds: int = DEFAULT_LEASE_SECONDS,
extra_keys: set[str] | Mapping[str, str | None] | None = None,
) -> dict[str, Any]:
"""Atomically admit a pre-resolved room key.
``room_key`` is used by ``/run/stream`` when it resolves the room from
thread metadata. ``user_id`` remains the room owner while
``holder_user_id`` is the account currently renewing a shared room.
"""
async with self._lock:
await self._ensure_gate_row()
now = datetime.now(UTC)
async with self._sf() as session:
# This no-op-ish write is intentional: it is the portable DB
# mutex protecting the subsequent count + lease upsert.
await session.execute(
update(RoundtableRoomLeaseRow)
.where(RoundtableRoomLeaseRow.room_key == GATE_ROW_KEY)
.values(updated_at=now)
)
keys, durable_scopes, hard_expired_keys = await self._active_snapshot(
session,
now,
max_hold_minutes=max_hold_minutes,
)
self._merge_extra_keys(keys, durable_scopes, extra_keys)
if room_key is None:
await session.commit()
blocked = bool(enabled)
return self._decision(
ok=not blocked,
enabled=enabled,
max_rooms=max_rooms,
active_count=len(keys),
renewed=False,
code="DRAFT_REQUIRED" if blocked else "",
message="" if not blocked else "进入第二步需要先保存会商草稿",
)
if room_key in hard_expired_keys:
await session.commit()
return self._decision(
ok=False,
enabled=enabled,
max_rooms=max_rooms,
active_count=len(keys),
renewed=False,
code="ROOM_MAX_HOLD_EXCEEDED",
message="本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
)
owns = room_key in keys
if enabled and not owns and len(keys) >= max_rooms:
await session.commit()
return self._decision(
ok=False,
enabled=enabled,
max_rooms=max_rooms,
active_count=len(keys),
renewed=False,
code="ROUNDTABLE_ROOM_FULL",
message="当前会商房间暂时已满,请稍候片刻后再试。",
)
await self._upsert_lease(
session,
room_key=room_key,
draft_id=draft_id,
user_id=user_id,
task_id=task_id,
holder_user_id=holder_user_id,
now=now,
lease_seconds=lease_seconds,
)
await session.commit()
return self._decision(
ok=True,
enabled=enabled,
max_rooms=max_rooms,
active_count=len(keys) if owns else len(keys) + 1,
renewed=owns,
code="",
message="",
)
async def release(
self,
*,
draft_id: str | None,
user_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> bool:
"""Drop a leftover foreground lease row if one still exists."""
key = compute_room_session_key(room_session_id) or compute_active_dedupe_key(
draft_id, user_id, task_id
)
if not key:
return False
async with self._lock:
await self._ensure_gate_row()
async with self._sf() as session:
await session.execute(
update(RoundtableRoomLeaseRow)
.where(RoundtableRoomLeaseRow.room_key == GATE_ROW_KEY)
.values(updated_at=datetime.now(UTC))
)
row = await session.get(RoundtableRoomLeaseRow, key)
if row is None or (user_id and row.holder_user_id not in (None, user_id)):
await session.commit()
return False
await session.delete(row)
await session.commit()
return True
async def started_at(
self,
*,
room_session_id: str | None,
holder_user_id: str | None,
) -> datetime | None:
"""Return the original admission time for a foreground-to-job handoff."""
key = compute_room_session_key(room_session_id)
if key is None:
return None
async with self._sf() as session:
row = await session.get(RoundtableRoomLeaseRow, key)
if row is None or (
holder_user_id
and row.holder_user_id not in (None, holder_user_id)
):
return None
return _aware(row.created_at)
async def renew(
self,
*,
draft_id: str | None,
user_id: str | None,
task_id: str | None,
room_session_id: str | None,
enabled: bool,
max_rooms: int,
max_hold_minutes: int = 30,
lease_seconds: int = DEFAULT_LEASE_SECONDS,
extra_keys: set[str] | Mapping[str, str | None] | None = None,
) -> dict[str, Any]:
"""Renew one live foreground lease without ever creating it.
Heartbeats are deliberately weaker than admission. A delayed browser
callback must not resurrect a room that was released after a pause,
network disconnect or completed Step 2.
"""
key = compute_room_session_key(room_session_id)
if key is None:
return self._decision(
ok=not enabled,
enabled=enabled,
max_rooms=max_rooms,
active_count=0,
renewed=False,
code="ROOM_SESSION_REQUIRED" if enabled else "",
message="会商会话标识缺失,请刷新页面后重试。" if enabled else "",
)
return await self.renew_room_key(
room_key=key,
holder_user_id=user_id,
enabled=enabled,
max_rooms=max_rooms,
max_hold_minutes=max_hold_minutes,
lease_seconds=lease_seconds,
extra_keys=extra_keys,
)
async def renew_room_key(
self,
*,
room_key: str,
holder_user_id: str | None,
enabled: bool,
max_rooms: int,
max_hold_minutes: int = 30,
lease_seconds: int = DEFAULT_LEASE_SECONDS,
extra_keys: set[str] | Mapping[str, str | None] | None = None,
) -> dict[str, Any]:
"""Renew a resolved persisted key without creating a missing lease."""
async with self._lock:
await self._ensure_gate_row()
now = datetime.now(UTC)
async with self._sf() as session:
await session.execute(
update(RoundtableRoomLeaseRow)
.where(RoundtableRoomLeaseRow.room_key == GATE_ROW_KEY)
.values(updated_at=now)
)
keys, durable_scopes, hard_expired_keys = await self._active_snapshot(
session,
now,
max_hold_minutes=max_hold_minutes,
)
self._merge_extra_keys(keys, durable_scopes, extra_keys)
row = await session.get(RoundtableRoomLeaseRow, room_key)
if row is not None and room_key in hard_expired_keys:
await session.commit()
return self._decision(
ok=False,
enabled=enabled,
max_rooms=max_rooms,
active_count=len(keys),
renewed=False,
code="ROOM_MAX_HOLD_EXCEEDED",
message="本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。",
)
owns = row is not None and room_key in keys and (
not holder_user_id or row.holder_user_id in (None, holder_user_id)
)
if not owns:
await session.commit()
return self._decision(
ok=not enabled,
enabled=enabled,
max_rooms=max_rooms,
active_count=len(keys),
renewed=False,
code="ROOM_LEASE_EXPIRED" if enabled else "",
message="会商连接已断开,请重新恢复研讨。" if enabled else "",
)
row.lease_until = now + timedelta(seconds=normalize_lease_seconds(lease_seconds))
row.updated_at = now
await session.commit()
return self._decision(
ok=True,
enabled=enabled,
max_rooms=max_rooms,
active_count=len(keys),
renewed=True,
code="",
message="",
)
async def summary(
self,
extra_keys: set[str] | Mapping[str, str | None] | None = None,
*,
max_hold_minutes: int = 30,
) -> int:
"""Return the number of distinct occupied rooms across every user."""
async with self._lock:
await self._ensure_gate_row()
now = datetime.now(UTC)
async with self._sf() as session:
keys, durable_scopes, _hard_expired_keys = await self._active_snapshot(
session,
now,
max_hold_minutes=max_hold_minutes,
)
self._merge_extra_keys(keys, durable_scopes, extra_keys)
await session.commit()
return len(keys)
@staticmethod
def _merge_extra_keys(
keys: set[str],
durable_scopes: set[str],
extra_keys: set[str] | Mapping[str, str | None] | None,
) -> None:
"""Add run-manager fallbacks without double-counting durable occupancy.
The map value is the legacy draft/user-or-task scope. If a live page
lease or background job already owns that scope, its stamped run key is
merely evidence for the same room and must not become another room.
"""
if not extra_keys:
return
items = extra_keys.items() if isinstance(extra_keys, Mapping) else ((key, None) for key in extra_keys)
for key, scope_key in items:
if not key or (scope_key and scope_key in durable_scopes):
continue
keys.add(key)
async def _active_snapshot(
self,
session: AsyncSession,
now: datetime,
*,
max_hold_minutes: int,
) -> tuple[set[str], set[str], set[str]]:
lease_rows = (
await session.execute(
select(RoundtableRoomLeaseRow).where(
RoundtableRoomLeaseRow.room_key != GATE_ROW_KEY
)
)
).scalars().all()
expired: list[str] = []
keys: set[str] = set()
durable_scopes: set[str] = set()
hard_expired_keys: set[str] = set()
for row in lease_rows:
until = _aware(row.lease_until)
if until is None or until <= now:
expired.append(row.room_key)
continue
scope_key = compute_active_dedupe_key(row.draft_id, row.user_id, row.task_id)
if _hard_deadline_reached(row.created_at, now, max_hold_minutes):
# Keep the still-live row briefly as a non-occupying tombstone.
# Its scope suppresses a lagging RunManager fallback until the
# browser observes the rejected heartbeat and cancels the run.
hard_expired_keys.add(row.room_key)
if scope_key:
durable_scopes.add(scope_key)
continue
keys.add(row.room_key)
if scope_key:
durable_scopes.add(scope_key)
if expired:
await session.execute(
delete(RoundtableRoomLeaseRow).where(
RoundtableRoomLeaseRow.room_key.in_(expired)
)
)
live_after = now - timedelta(seconds=JOB_LIVE_SECONDS)
job_rows = (
await session.execute(
select(
RoundtableJobRow.id,
RoundtableJobRow.draft_id,
RoundtableJobRow.user_id,
RoundtableJobRow.task_id,
RoundtableJobRow.status,
RoundtableJobRow.phase,
RoundtableJobRow.lease_until,
RoundtableJobRow.updated_at,
RoundtableJobRow.created_at,
).where(RoundtableJobRow.status.in_(OCCUPYING_JOB_STATUSES))
)
).all()
for job_id, draft_id, user_id, task_id, status, phase, lease_until, updated_at, created_at in job_rows:
if phase in NON_OCCUPYING_JOB_PHASES:
continue
if _hard_deadline_reached(created_at, now, max_hold_minutes):
# The worker cancellation watcher will finalize this state.
# Marking the intent here also covers queued jobs and workers
# that stopped renewing but have not yet been reaped.
await session.execute(
update(RoundtableJobRow)
.where(
RoundtableJobRow.id == job_id,
RoundtableJobRow.status.in_(["queued", "running", "awaiting_input"]),
)
.values(
status="cancel_requested",
version=RoundtableJobRow.version + 1,
updated_at=now,
)
)
continue
if status != "awaiting_input" and not _job_is_live(
lease_until,
updated_at,
now,
live_after,
):
continue
job_key = compute_active_dedupe_key(draft_id, user_id, task_id)
if job_key and job_key in durable_scopes:
# Foreground → background handoff can overlap for a few
# milliseconds. Both represent one room for the same scope.
continue
if not job_key:
job_key = f"job:{job_id}"
keys.add(job_key)
scope_key = compute_active_dedupe_key(draft_id, user_id, task_id)
if scope_key:
durable_scopes.add(scope_key)
return keys, durable_scopes, hard_expired_keys
async def _ensure_gate_row(self) -> None:
"""Create the portable database mutex row once, tolerating races."""
now = datetime.now(UTC)
async with self._sf() as session:
if await session.get(RoundtableRoomLeaseRow, GATE_ROW_KEY) is not None:
return
session.add(
RoundtableRoomLeaseRow(
room_key=GATE_ROW_KEY,
draft_id=None,
user_id=None,
task_id=None,
holder_user_id=None,
lease_until=None,
created_at=now,
updated_at=now,
)
)
try:
await session.commit()
except IntegrityError:
await session.rollback()
async def _upsert_lease(
self,
session: AsyncSession,
*,
room_key: str,
draft_id: str | None,
user_id: str | None,
task_id: str | None,
holder_user_id: str | None,
now: datetime,
lease_seconds: int = DEFAULT_LEASE_SECONDS,
) -> None:
row = await session.get(RoundtableRoomLeaseRow, room_key)
lease_until = now + timedelta(seconds=normalize_lease_seconds(lease_seconds))
if row is None:
session.add(
RoundtableRoomLeaseRow(
room_key=room_key,
draft_id=draft_id,
user_id=user_id,
task_id=task_id,
holder_user_id=holder_user_id,
lease_until=lease_until,
created_at=now,
updated_at=now,
)
)
return
row.draft_id = draft_id
row.user_id = user_id
row.task_id = task_id
row.holder_user_id = holder_user_id
row.lease_until = lease_until
row.updated_at = now
@staticmethod
def _decision(
*,
ok: bool,
enabled: bool,
max_rooms: int,
active_count: int,
renewed: bool,
code: str,
message: str,
) -> dict[str, Any]:
return {
"ok": ok,
"enabled": enabled,
"max_rooms": max_rooms,
"active_count": active_count,
"renewed": renewed,
"code": code,
"message": message,
}