Files

433 lines
15 KiB
Python

"""Admission helper shared by step-2 init and background job start.
The durable database lease is the source of truth. Gateway run inspection is
kept as a reconciliation fallback for legacy/untracked runs. When the limit is
enabled, missing room storage fails closed so a deployment error cannot bypass
the cap.
"""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, Request
from sqlalchemy import select
from deerflow.config.system_settings import get_roundtable_room_limit_settings
from deerflow.persistence.roundtable_jobs.sql import compute_active_dedupe_key
from deerflow.runtime.user_context import get_effective_user_id
def _room_key_from_payload(meta: Any, owner_user_id: str | None = None) -> str | None:
"""Resolve the live-execution key from thread/run metadata."""
if not isinstance(meta, dict):
return None
stamped = str(meta.get("roundtable_room_key") or "").strip()
if stamped:
return stamped
draft_id = str(meta.get("draft_id") or "").strip() or None
if not draft_id:
return None
task_id = str(meta.get("task_id") or meta.get("taskId") or "").strip() or None
return compute_active_dedupe_key(draft_id, owner_user_id, task_id)
def _room_scope_key_from_payload(
meta: Any,
owner_user_id: str | None = None,
) -> str | None:
"""Legacy persisted scope used only to de-duplicate fallback run evidence."""
if not isinstance(meta, dict):
return None
draft_id = str(meta.get("draft_id") or "").strip() or None
if not draft_id:
return None
task_id = str(meta.get("task_id") or meta.get("taskId") or "").strip() or None
owner = owner_user_id or str(meta.get("user_id") or "").strip() or None
return compute_active_dedupe_key(draft_id, owner, task_id)
def _thread_id_of(record: Any) -> str:
return str(getattr(record, "thread_id", "") or "").strip()
async def _all_active_run_records(request: Request) -> list[Any]:
"""Every in-flight Gateway run, regardless of which account started it."""
records: list[Any] = []
seen_ids: set[str] = set()
def _append(items: list[Any] | None) -> None:
for item in items or []:
task = getattr(item, "task", None)
if task is not None and callable(getattr(task, "done", None)) and task.done():
# A terminal asyncio task with a lagging pending/running status
# is cleanup residue, not a live room. This also prevents one
# exceptional worker-finalization path from pinning capacity.
continue
run_id = str(getattr(item, "run_id", "") or "")
thread_id = _thread_id_of(item)
marker = run_id or thread_id
if not marker or marker in seen_ids:
continue
seen_ids.add(marker)
records.append(item)
gate = getattr(request.app.state, "concurrency_gate", None)
if gate is not None and getattr(gate, "enabled", False):
try:
leases = await gate.list_active()
except Exception:
leases = None
if leases:
_append(list(leases))
run_mgr = getattr(request.app.state, "run_manager", None)
if run_mgr is not None:
try:
_append(await run_mgr.list_active())
except Exception:
pass
return records
async def _room_keys_from_thread_rows(
thread_ids: list[str],
) -> tuple[dict[str, str | None], set[str]]:
"""Load room keys for *all* owners. Returns (keys, unresolved thread ids)."""
remaining = {tid for tid in thread_ids if tid}
keys: dict[str, str | None] = {}
if not remaining:
return keys, remaining
try:
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.thread_meta.model import ThreadMetaRow
session_factory = get_session_factory()
if session_factory is None:
return keys, remaining
async with session_factory() as session:
rows = (
await session.execute(
select(
ThreadMetaRow.thread_id,
ThreadMetaRow.user_id,
ThreadMetaRow.metadata_json,
).where(ThreadMetaRow.thread_id.in_(list(remaining)))
)
).all()
for thread_id, user_id, metadata_json in rows:
remaining.discard(thread_id)
key = _room_key_from_payload(metadata_json, user_id)
if key:
keys[key] = _room_scope_key_from_payload(metadata_json, user_id)
except Exception:
return keys, remaining
return keys, remaining
async def extra_live_roundtable_keys(request: Request) -> dict[str, str | None]:
"""Room keys of step-2 sessions whose LangGraph runs are still in flight.
Scans the whole Gateway process: every user's runs are included. Several
seat runs in one session share ``roundtable_room_key``. Intent and
recommend threads have no such key and are ignored.
"""
records = await _all_active_run_records(request)
keys: dict[str, str | None] = {}
missing_tids: list[str] = []
seen_threads: set[str] = set()
for rec in records:
tid = _thread_id_of(rec)
if not tid or tid in seen_threads:
continue
seen_threads.add(tid)
meta = getattr(rec, "metadata", None)
owner = str(getattr(rec, "user_id", "") or "").strip() or None
if owner is None and isinstance(meta, dict):
owner = str(meta.get("user_id") or "").strip() or None
key = _room_key_from_payload(meta if isinstance(meta, dict) else None, owner)
if key:
keys[key] = _room_scope_key_from_payload(meta, owner)
continue
missing_tids.append(tid)
if not missing_tids:
return keys
from_rows, remaining = await _room_keys_from_thread_rows(missing_tids)
keys.update(from_rows)
thread_store = getattr(request.app.state, "thread_store", None)
if thread_store is None or not remaining:
return keys
for tid in remaining:
try:
row = await thread_store.get(tid, user_id=None)
except Exception:
continue
if not isinstance(row, dict):
continue
owner = str(row.get("user_id") or "").strip() or None
key = _room_key_from_payload(row.get("metadata"), owner)
if key:
keys[key] = _room_scope_key_from_payload(row.get("metadata"), owner)
return keys
async def backend_occupancy_keys(request: Request) -> dict[str, str | None]:
"""Live Gateway runs used to reconcile legacy leases, all accounts."""
return await extra_live_roundtable_keys(request)
def current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None and getattr(user, "id", None):
return str(user.id)
return get_effective_user_id()
def _store(request: Request):
return getattr(request.app.state, "roundtable_room_store", None)
async def admit_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
settings = get_roundtable_room_limit_settings()
store = _store(request)
extra_keys = await backend_occupancy_keys(request)
clean_room_session_id = (room_session_id or "").strip() or None
if settings.enabled and clean_room_session_id is None:
return {
"ok": False,
"enabled": True,
"max_rooms": settings.max_rooms,
"active_count": len(extra_keys),
"renewed": False,
"code": "ROOM_SESSION_REQUIRED",
"message": "会商会话标识缺失,请刷新页面后重试。",
}
if store is None:
return {
"ok": not settings.enabled,
"enabled": settings.enabled,
"max_rooms": settings.max_rooms,
"active_count": len(extra_keys),
"renewed": False,
"code": "" if not settings.enabled else "ROOM_STORE_UNAVAILABLE",
"message": "" if not settings.enabled else "会商并发服务暂不可用,请稍后再试。",
}
user_id = current_user_id(request)
clean_draft = (draft_id or "").strip() or None
clean_task = (task_id or "").strip() or None
return await store.admit(
draft_id=clean_draft,
user_id=user_id,
task_id=clean_task,
room_session_id=clean_room_session_id,
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
extra_keys=extra_keys,
)
async def renew_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
"""Renew an existing browser lease; never admit or recreate a room."""
settings = get_roundtable_room_limit_settings()
store = _store(request)
extra_keys = await backend_occupancy_keys(request)
if store is None:
return {
"ok": not settings.enabled,
"enabled": settings.enabled,
"max_rooms": settings.max_rooms,
"active_count": len(extra_keys),
"renewed": False,
"code": "" if not settings.enabled else "ROOM_STORE_UNAVAILABLE",
"message": "" if not settings.enabled else "会商并发服务暂不可用,请稍后再试。",
}
return await store.renew(
draft_id=(draft_id or "").strip() or None,
user_id=current_user_id(request),
task_id=(task_id or "").strip() or None,
room_session_id=(room_session_id or "").strip() or None,
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
extra_keys=extra_keys,
)
async def release_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> bool:
"""Best-effort release used by both the API route and disconnect cleanup."""
store = _store(request)
if store is None:
return False
return await store.release(
draft_id=(draft_id or "").strip() or None,
user_id=current_user_id(request),
task_id=(task_id or "").strip() or None,
room_session_id=(room_session_id or "").strip() or None,
)
async def enforce_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
"""Admit the room or raise 409 before any step-2 model work starts."""
decision = await admit_roundtable_room(
request,
draft_id=draft_id,
task_id=task_id,
room_session_id=room_session_id,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return decision
async def enforce_existing_roundtable_room(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
) -> dict[str, Any]:
"""Require and renew an existing lease without performing admission."""
decision = await renew_roundtable_room(
request,
draft_id=draft_id,
task_id=task_id,
room_session_id=room_session_id,
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return decision
async def _thread_room_scope(
request: Request,
thread_ids: list[str],
) -> tuple[str, str | None, str | None, str | None] | None:
"""Resolve ``(room_key, draft_id, owner_user_id, task_id)`` from metadata."""
wanted = [tid for tid in dict.fromkeys(thread_ids) if tid]
if not wanted:
return None
thread_store = getattr(request.app.state, "thread_store", None)
if thread_store is not None:
for tid in wanted:
try:
row = await thread_store.get(tid, user_id=None)
except Exception:
continue
if not isinstance(row, dict):
continue
meta = row.get("metadata")
owner = str(row.get("user_id") or "").strip() or None
key = _room_key_from_payload(meta, owner)
if key:
data = meta if isinstance(meta, dict) else {}
draft_id = str(data.get("draft_id") or "").strip() or None
task_id = str(data.get("task_id") or data.get("taskId") or "").strip() or None
return key, draft_id, owner, task_id
try:
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.thread_meta.model import ThreadMetaRow
session_factory = get_session_factory()
if session_factory is None:
return None
async with session_factory() as session:
rows = (
await session.execute(
select(
ThreadMetaRow.user_id,
ThreadMetaRow.metadata_json,
).where(ThreadMetaRow.thread_id.in_(wanted))
)
).all()
for owner, meta in rows:
key = _room_key_from_payload(meta, owner)
if key:
data = meta if isinstance(meta, dict) else {}
draft_id = str(data.get("draft_id") or "").strip() or None
task_id = str(data.get("task_id") or data.get("taskId") or "").strip() or None
return key, draft_id, owner, task_id
except Exception:
return None
return None
async def enforce_roundtable_run(
request: Request,
*,
draft_id: str | None,
task_id: str | None,
room_session_id: str | None = None,
thread_ids: list[str],
) -> dict[str, Any] | None:
"""Enforce the room cap at the final model-producing entry point.
Current clients send the draft scope on every run. A model run may only
renew the room that was admitted when Step 2 began; it must never create a
new room while the user resumes, answers a clarification, or intervenes.
Thread metadata is a compatibility fallback for initialized sessions.
"""
clean_draft = (draft_id or "").strip() or None
clean_task = (task_id or "").strip() or None
if clean_draft:
return await enforce_existing_roundtable_room(
request,
draft_id=clean_draft,
task_id=clean_task,
room_session_id=(room_session_id or "").strip() or None,
)
scope = await _thread_room_scope(request, thread_ids)
if scope is None:
return None
room_key, _stored_draft, _owner_user_id, _stored_task = scope
settings = get_roundtable_room_limit_settings()
store = _store(request)
if store is None:
if settings.enabled:
raise HTTPException(
status_code=503,
detail="Roundtable room store not available",
)
return None
decision = await store.renew_room_key(
room_key=room_key,
holder_user_id=current_user_id(request),
enabled=settings.enabled,
max_rooms=settings.max_rooms,
max_hold_minutes=getattr(settings, "max_hold_minutes", 30),
extra_keys=await backend_occupancy_keys(request),
)
if not decision.get("ok", False):
raise HTTPException(status_code=409, detail=decision)
return decision