282 lines
8.1 KiB
Python
282 lines
8.1 KiB
Python
"""Run queue/redis helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
from datetime import UTC, datetime
|
|
|
|
from yuxi.storage.redis import close_async_redis_client, create_arq_redis_pool, get_async_redis_client
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
RUN_CANCEL_KEY_TTL_SECONDS = int(os.getenv("RUN_CANCEL_KEY_TTL_SECONDS", "1800"))
|
|
RUN_EVENTS_STREAM_TTL_SECONDS = int(os.getenv("RUN_EVENTS_STREAM_TTL_SECONDS", "7200"))
|
|
RUN_EVENTS_STREAM_MAXLEN = int(os.getenv("RUN_EVENTS_STREAM_MAXLEN", "0"))
|
|
RUN_CANCEL_CHANNEL = os.getenv("RUN_CANCEL_CHANNEL", "run:cancel:ch")
|
|
|
|
_arq_pool = None
|
|
|
|
|
|
def _cancel_key(run_id: str) -> str:
|
|
return f"run:cancel:{run_id}"
|
|
|
|
|
|
def _event_stream_key(run_id: str) -> str:
|
|
return f"run:events:{run_id}"
|
|
|
|
|
|
def _is_valid_stream_seq(value: str) -> bool:
|
|
major, sep, minor = value.partition("-")
|
|
if sep != "-":
|
|
return False
|
|
return major.isdigit() and minor.isdigit()
|
|
|
|
|
|
def normalize_after_seq(after_seq: str | None) -> str:
|
|
"""Normalize after_seq cursor to redis stream id format."""
|
|
if after_seq is None:
|
|
return "0-0"
|
|
|
|
text = str(after_seq).strip()
|
|
if not text:
|
|
return "0-0"
|
|
|
|
if _is_valid_stream_seq(text):
|
|
return text
|
|
return "0-0"
|
|
|
|
|
|
def build_run_event_envelope(
|
|
*,
|
|
run_id: str,
|
|
event_type: str,
|
|
payload: dict | None = None,
|
|
thread_id: str | None = None,
|
|
created_at: str | None = None,
|
|
) -> dict:
|
|
return {
|
|
"schema_version": 1,
|
|
"run_id": run_id,
|
|
"thread_id": thread_id,
|
|
"event": event_type,
|
|
"payload": payload or {},
|
|
"created_at": created_at or datetime.now(tz=UTC).isoformat(),
|
|
}
|
|
|
|
|
|
def _payload_thread_id(payload: dict | None) -> str | None:
|
|
chunk = payload.get("chunk") if isinstance(payload, dict) else None
|
|
if not isinstance(chunk, dict):
|
|
return None
|
|
thread_id = chunk.get("thread_id")
|
|
return thread_id.strip() if isinstance(thread_id, str) and thread_id.strip() else None
|
|
|
|
|
|
async def get_redis_client():
|
|
return await get_async_redis_client()
|
|
|
|
|
|
async def get_arq_pool():
|
|
global _arq_pool
|
|
if _arq_pool is not None:
|
|
return _arq_pool
|
|
|
|
_arq_pool = await create_arq_redis_pool()
|
|
return _arq_pool
|
|
|
|
|
|
@asynccontextmanager
|
|
async def redis_pubsub(channel: str):
|
|
redis = await get_redis_client()
|
|
pubsub = redis.pubsub()
|
|
await pubsub.subscribe(channel)
|
|
try:
|
|
yield pubsub
|
|
finally:
|
|
try:
|
|
await pubsub.unsubscribe(channel)
|
|
finally:
|
|
await pubsub.close()
|
|
|
|
|
|
async def publish_cancel_signal(run_id: str) -> None:
|
|
redis = await get_redis_client()
|
|
key = _cancel_key(run_id)
|
|
try:
|
|
await redis.set(key, "1", ex=RUN_CANCEL_KEY_TTL_SECONDS)
|
|
await redis.publish(RUN_CANCEL_CHANNEL, run_id)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to publish cancel signal for run {run_id}: {e}")
|
|
|
|
|
|
async def has_cancel_signal(run_id: str) -> bool:
|
|
redis = await get_redis_client()
|
|
key = _cancel_key(run_id)
|
|
try:
|
|
return bool(await redis.get(key))
|
|
except Exception as e:
|
|
logger.warning(f"Failed to read cancel signal for run {run_id}: {e}")
|
|
return False
|
|
|
|
|
|
async def wait_for_cancel_signal(run_id: str, poll_timeout_seconds: float = 1.0) -> bool:
|
|
if await has_cancel_signal(run_id):
|
|
return True
|
|
|
|
try:
|
|
async with redis_pubsub(RUN_CANCEL_CHANNEL) as pubsub:
|
|
while True:
|
|
msg = await pubsub.get_message(
|
|
ignore_subscribe_messages=True,
|
|
timeout=poll_timeout_seconds,
|
|
)
|
|
if msg and str(msg.get("data")) == run_id:
|
|
return True
|
|
if await has_cancel_signal(run_id):
|
|
return True
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning(f"Failed to wait cancel signal for run {run_id}: {e}")
|
|
return False
|
|
|
|
|
|
async def clear_cancel_signal(run_id: str) -> None:
|
|
redis = await get_redis_client()
|
|
key = _cancel_key(run_id)
|
|
try:
|
|
await redis.delete(key)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to clear cancel signal for run {run_id}: {e}")
|
|
|
|
|
|
async def append_run_stream_event(run_id: str, event_type: str, payload: dict, *, thread_id: str | None = None) -> str:
|
|
redis = await get_redis_client()
|
|
key = _event_stream_key(run_id)
|
|
now = datetime.now(tz=UTC)
|
|
now_ms = int(now.timestamp() * 1000)
|
|
event_thread_id = thread_id or _payload_thread_id(payload)
|
|
envelope = build_run_event_envelope(
|
|
run_id=run_id,
|
|
event_type=event_type,
|
|
payload=payload or {},
|
|
thread_id=event_thread_id,
|
|
created_at=now.isoformat(),
|
|
)
|
|
fields = {
|
|
"event_type": event_type,
|
|
"payload": json.dumps(envelope, ensure_ascii=False),
|
|
"ts": str(now_ms),
|
|
}
|
|
|
|
kwargs = {}
|
|
if RUN_EVENTS_STREAM_MAXLEN > 0:
|
|
kwargs["maxlen"] = RUN_EVENTS_STREAM_MAXLEN
|
|
kwargs["approximate"] = True
|
|
|
|
event_id = await redis.xadd(key, fields, **kwargs)
|
|
await redis.expire(key, RUN_EVENTS_STREAM_TTL_SECONDS)
|
|
return str(event_id)
|
|
|
|
|
|
async def list_run_stream_events(
|
|
run_id: str,
|
|
*,
|
|
after_seq: str = "0-0",
|
|
limit: int = 200,
|
|
) -> list[dict]:
|
|
redis = await get_redis_client()
|
|
key = _event_stream_key(run_id)
|
|
start = "-" if after_seq in {"0-0", ""} else f"({after_seq}"
|
|
rows = await redis.xrange(key, min=start, max="+", count=limit)
|
|
events = []
|
|
|
|
for event_id, fields in rows:
|
|
payload_raw = fields.get("payload") or "{}"
|
|
try:
|
|
payload = json.loads(payload_raw)
|
|
except Exception:
|
|
payload = {}
|
|
|
|
event_type = fields.get("event_type") or "message"
|
|
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
|
payload = {
|
|
"schema_version": 1,
|
|
"run_id": run_id,
|
|
"thread_id": None,
|
|
"event": event_type,
|
|
"payload": payload if isinstance(payload, dict) else {},
|
|
"created_at": None,
|
|
}
|
|
|
|
ts_value = fields.get("ts")
|
|
events.append(
|
|
{
|
|
"seq": str(event_id),
|
|
"event_type": event_type,
|
|
"payload": payload,
|
|
"ts": int(ts_value) if ts_value else None,
|
|
}
|
|
)
|
|
return events
|
|
|
|
|
|
async def list_recent_run_stream_events(run_id: str, *, limit: int = 100) -> list[dict]:
|
|
"""从 Redis Stream 反向读取最近的 run events,返回顺序为新到旧。"""
|
|
redis = await get_redis_client()
|
|
key = _event_stream_key(run_id)
|
|
rows = await redis.xrevrange(key, max="+", min="-", count=limit)
|
|
events = []
|
|
|
|
for event_id, fields in rows:
|
|
payload_raw = fields.get("payload") or "{}"
|
|
try:
|
|
payload = json.loads(payload_raw)
|
|
except Exception:
|
|
payload = {}
|
|
|
|
event_type = fields.get("event_type") or "message"
|
|
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
|
payload = {
|
|
"schema_version": 1,
|
|
"run_id": run_id,
|
|
"thread_id": None,
|
|
"event": event_type,
|
|
"payload": payload if isinstance(payload, dict) else {},
|
|
"created_at": None,
|
|
}
|
|
|
|
ts_value = fields.get("ts")
|
|
events.append(
|
|
{
|
|
"seq": str(event_id),
|
|
"event_type": event_type,
|
|
"payload": payload,
|
|
"ts": int(ts_value) if ts_value else None,
|
|
}
|
|
)
|
|
return events
|
|
|
|
|
|
async def get_last_run_stream_seq(run_id: str) -> str:
|
|
redis = await get_redis_client()
|
|
key = _event_stream_key(run_id)
|
|
rows = await redis.xrevrange(key, max="+", min="-", count=1)
|
|
if not rows:
|
|
return "0-0"
|
|
event_id, _ = rows[0]
|
|
return str(event_id)
|
|
|
|
|
|
async def close_queue_clients() -> None:
|
|
global _arq_pool
|
|
if _arq_pool is not None:
|
|
try:
|
|
await _arq_pool.close()
|
|
except Exception:
|
|
pass
|
|
_arq_pool = None
|
|
await close_async_redis_client()
|