mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [cd2bf666] feat(usage): add token usage types, API client, hooks, and UI components (#87) (#88) - Append 5 TypeScript interfaces to src/types/index.ts: TokenUsageSnapshot, AgentUsageRow, UsageSession, UsageTimePoint, ModelUsageSlice - Create src/lib/api/usage.ts: Axios singleton + isMockMode guards for getUsageSnapshot, getUsageTimeSeries, getAgentUsage, getUsageSessions, getModelUsage - Create src/hooks/use-usage.ts: usageKeys factory + useUsageSnapshot, useUsageTimeSeries, useAgentUsage, useUsageSessions, useModelUsage hooks - Create UsageOverviewPanel (dashboard/usage-overview-panel.tsx): 6 metric rows with Skeleton loading state; week-over-week trend arrow for cost - Update CommandCenter: Metrics+Alerts row expanded from 2-col to 3-col grid adding UsageOverviewPanel - Create src/components/metrics/ folder: UsageTimeSeriesChart (recharts stacked AreaChart with var(--chart-1/2/3)), ModelUsageDonut (PieChart), AgentUsageChart and TeamUsageChart (BarChart), SessionsTable (sortable columns + 10-row Prev/Next pagination) - Update Metrics page: Token Usage & Costs section with 5 rows (summary cards, time series+donut, agent+team bar charts, projection+cache efficiency, sessions table) - Add usage mini-bar to AgentCard: token count + cost + progress bar; AgentGrid and Agents page pass agentUsageMap through - Install recharts 3.8.1 - Export all new symbols through their barrel index.ts files Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [10372f0f] Implement full token usage instrumentation: DB migration, SDK endpoints, orchestrator hooks, analytics API, WebSocket events, dashboard integration (#86) (#89) * [10372f0f] feat(token-usage): add Alembic migration 026 for token usage tables Create agent_spawn_sessions, token_usage_snapshots, and daily_usage_rollups tables with correct BIGINT columns, indexes, and unique constraint. Chain: 025_agentrole_prompter → 026_token_usage_tables. * [10372f0f] feat(token-usage): add ORM table classes for token usage instrumentation Add AgentSpawnSessionTable, TokenUsageSnapshotTable, DailyUsageRollupTable to db/tables.py. Import BigInteger and Date from SQLAlchemy. All columns match the migration schema with BIGINT token counts and proper indexes. * [10372f0f] feat(billing): add pricing module with calculate_cost() function Create roboco/billing/__init__.py and roboco/billing/pricing.py with calculate_cost() supporting Claude opus/sonnet/haiku models with input/output/cache pricing. Unknown models return 0.0 without raising. * [10372f0f] feat(sdk): add POST /usage/report and GET /usage/status endpoints to agent SDK Extend _SessionState with token counters. Add TokenReportRequest and TokenUsageStatus models. POST /usage/report additively accumulates token counts; GET /usage/status returns current session totals for sweeper polling. * [10372f0f] feat(orchestrator): add token usage instrumentation hooks - _launch_spawn() calls _record_spawn_session() after successful container spawn - stop_agent() calls _finalize_spawn_session() before container removal - _run_sweep() calls _sweep_token_snapshots() and _sweep_daily_rollup() each tick - New methods: _record_spawn_session, _finalize_spawn_session, _sweep_token_snapshots, _sweep_daily_rollup in TOKEN USAGE section * [10372f0f] feat(api): add token usage analytics API with 7 endpoints Create roboco/services/usage.py (UsageService) and roboco/api/routes/usage.py. Endpoints: GET /api/usage/summary, /time-series, /by-agent, /by-team, /by-model, /projection, /cache-efficiency. Register in app.py. * [10372f0f] feat(dashboard): add usage_summary field to CEO dashboard Add UsageSummary schema (tokens_today, cost_today_usd) to dashboard schemas. Add usage_summary: UsageSummary | None to CEOOverview. Update get_ceo_overview() to populate usage_summary from daily_usage_rollups. * [10372f0f] fix(billing/tests): remove dead except block in _sweep_daily_rollup, add unit tests for pricing.py and services/usage.py - Remove unreachable `except Exception as e` block in orchestrator.py _sweep_daily_rollup() (lines 3376-3381) which referenced undefined `agent_id` and was copy-pasted from _sweep_token_snapshots by mistake - Add tests/unit/billing/test_pricing.py: 31 tests covering opus/sonnet/ haiku tiers with all 4 token types, unknown model → 0.0, empty string → 0.0, and substring-match priority (longer fragment wins) - Add tests/unit/services/test_usage.py: 25 tests covering get_summary trend_pct edge cases (prev=0, both=0, prev>0), get_by_agent/team/model pct_of_total summing to 100%, get_projection formula (avg_daily×30), and get_cache_efficiency hit-rate and cost_saved arithmetic - pricing.py: 100% coverage; services/usage.py: 83% coverage (>80% target) * [10372f0f] fix(usage): include cache tokens in time-series total_tokens to fix AC9 consistency violation get_time_series() previously computed total_tokens as tokens_input + tokens_output only. get_summary() includes all 4 token types (input + output + cache_read + cache_write). AC9 requires both endpoints to agree on their totals for the same period. Fix: add tokens_cache_read and tokens_cache_write to the SELECT query in get_time_series() and include them in the total_tokens calculation. Also adds 4 new unit tests in TestGetTimeSeries covering: - total_tokens includes cache_read and cache_write (the AC9 guard) - zero cache tokens still produces correct total - empty result returns empty list - required fields are present in each point * [10372f0f] fix(usage): remove unused imports and include cache tokens in breakdown totals (AC10) - Remove import math (F401 — never used) - Remove text from sqlalchemy import (F401 — never used) - Remove unused local calculate_cost import inside get_cache_efficiency (F401) - Add tokens_cache_read and tokens_cache_write to SELECT in get_by_agent, get_by_team, and get_by_model; update grand_total and per-item total to include all 4 token types so totals match get_summary() (AC10 fix) - Update test mock rows to include explicit tokens_cache_read=0 and tokens_cache_write=0 so they work with the fixed code - Add new test cases: test_cache_tokens_included_in_total_tokens and test_pct_of_total_sums_to_100_with_cache_tokens for each breakdown class --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [44b9eb1f] feat(usage): align frontend API client, TS types, and chart components to real backend contract (#92) (#94) Update all usage-related frontend code to match the actual FastAPI backend response shapes and endpoint paths: - panel/src/lib/api/usage.ts: rewrite all 7 API functions to use correct endpoint paths (/usage/summary, /usage/by-agent, /usage/by-model, /usage/by-team, /usage/time-series, /usage/projection, /usage/cache-efficiency); send period query param (24h/7d/30d not hours); mock generators produce data matching real backend shapes exactly; getUsageSessions returns [] in prod (no /usage/sessions endpoint exists) - panel/src/types/index.ts: replace TokenUsageSnapshot with UsageSummary (tokens_input/tokens_output/total_cost_usd/trend_pct); update AgentUsageRow to use agent_slug/total_tokens/cost_usd/pct_of_total; add TeamUsageRow, UsageProjection, CacheEfficiencyResponse; update UsageTimePoint to use bucket field; update UsageSession to use agent_slug - panel/src/hooks/use-usage.ts: rewrite all hooks to match new API and types; add useTeamUsage, useUsageProjection, useCacheEfficiency hooks - panel/src/components/metrics/usage-time-series-chart.tsx: use bucket field (not timestamp) for axis labels - panel/src/components/metrics/agent-usage-chart.tsx: use agent_slug and total_tokens (not agent_name/tokens_today) - panel/src/components/metrics/team-usage-chart.tsx: rewrite to accept TeamUsageRow[] from API directly - panel/src/components/metrics/model-usage-donut.tsx: use total_tokens, cost_usd, pct_of_total (not tokens/cost/percentage) - panel/src/components/metrics/sessions-table.tsx: use agent_slug, sort keys updated - panel/src/components/dashboard/usage-overview-panel.tsx: use useUsageSummary with tokens_input/tokens_output/total_cost_usd/trend_pct - panel/src/app/(dashboard)/metrics/page.tsx: wire all new hooks, add TeamUsageChart, ProjectionCard, CacheEfficiencyCard with correct types - panel/src/app/(dashboard)/agents/page.tsx: key agentUsageMap by agent_slug - panel/src/components/agents/agent-card.tsx: use total_tokens and cost_usd Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [2161b832] fix: SDK_PORT constant, stop_agent lock refactor, usage_session_id binding, rollup 7-day window (#93) (#95) - Add SDK_PORT = 9000 module-level constant to orchestrator.py; replace hardcoded 9000 in _sweep_budget_exceeded URL with SDK_PORT - Add UUID to TYPE_CHECKING imports to satisfy ruff F821 - Refactor stop_agent: call _finalize_spawn_session BEFORE acquiring self._lock so the SDK HTTP round-trip does not hold the lock - Add usage_session_id: UUID | None field to AgentInstance dataclass - Change _record_spawn_session to return UUID | None; wire return value back to instance.usage_session_id in _launch_spawn - Update _finalize_spawn_session to use WHERE id=usage_session_id for direct session row lookup when usage_session_id is not None - Add started_at >= (now_utc - 7 days) filter to _sweep_daily_rollup aggregate query to avoid re-aggregating all-time history each sweep Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [2e0759e1] fix: pricing accuracy, import ordering, session-id binding, rollup cleanup, write-hook tests (#97) (#98) - pricing.py: correct claude-opus-4 prices (5/25/0.50/6.25 not 15/75/1.5/3.75) and haiku family prices (1/5/0.10/1.25 not 0.8/4/0.08/0.20); add Ollama zero-cost early-return; add structlog warning for unmatched model names - app.py: move usage_router import before routes.v1 block (ruff isort fix) - orchestrator.py _sweep_daily_rollup: remove unused calculate_cost import; add blank line between stdlib (uuid4) and third-party (sqlalchemy) imports - orchestrator.py _sweep_token_snapshots: prefer direct lookup by instance.usage_session_id; fall back to agent_slug heuristic only when None - tests: add test_sweep_daily_rollup_inserts_new_row and test_stop_agent_finalizes_before_lock to test_orchestrator_write_hooks.py - usage.py, routes/usage.py, stream_bus.py, test files: ruff format/lint fixes Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * Mypy compliance * fix(migrations,tests): linearize forked migration chain + correct ceo_reject coordination-root expectation The master merge brought in 026_completed_dependency_ids alongside the rework's 026_token_usage_tables — both off 025, forking the alembic head and breaking the enum-parity test. Rebase token-usage onto 026_completed_dependency_ids (linear chain, single head). Also: test_ceo_reject_routes_coordination_task_to_main_pm asserted the old NEEDS_REVISION behavior; the lifecycle fix correctly routes a coordination root to PENDING (Main PM's claim source). Update the assertion. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
455 lines
15 KiB
Python
455 lines
15 KiB
Python
"""
|
|
Stream Event Bus
|
|
|
|
Redis Streams-based event system with durable message delivery.
|
|
Replaces the pub/sub-based EventBus with persistence and consumer groups.
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import os
|
|
import socket
|
|
from collections.abc import Callable, Coroutine
|
|
from typing import Any
|
|
|
|
import redis.asyncio as redis
|
|
import structlog
|
|
from redis.exceptions import ResponseError
|
|
from redis.exceptions import TimeoutError as RedisTimeoutError
|
|
|
|
from roboco.config import settings
|
|
from roboco.models.events import Event, EventType
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
# Type for event handlers
|
|
EventHandler = Callable[[Event], Coroutine[Any, Any, None]]
|
|
|
|
|
|
class StreamEventBus:
|
|
"""
|
|
Event bus using Redis Streams for durable message delivery.
|
|
|
|
Features:
|
|
- Message persistence (survives Redis restart with AOF)
|
|
- Consumer groups for at-least-once delivery
|
|
- Message acknowledgment after successful processing
|
|
- Automatic stream trimming (configurable retention)
|
|
"""
|
|
|
|
STREAM_PREFIX = "roboco:stream:"
|
|
DEFAULT_GROUP = "roboco-handlers"
|
|
MAX_STREAM_LENGTH = 10000 # Trim streams to this length
|
|
|
|
def __init__(
|
|
self,
|
|
redis_url: str | None = None,
|
|
consumer_name: str | None = None,
|
|
group_name: str | None = None,
|
|
):
|
|
self.redis_url = redis_url or settings.redis_url
|
|
# Default consumer name is stable across restarts of the same process
|
|
# (host + pid), so pending messages don't get orphaned to a new
|
|
# id(self)-based name every time the orchestrator restarts. Redis
|
|
# consumer groups still auto-reassign via xclaim after idle_time.
|
|
self.consumer_name = consumer_name or (
|
|
f"consumer-{socket.gethostname()}-{os.getpid()}"
|
|
)
|
|
self.group_name = group_name or self.DEFAULT_GROUP
|
|
self._redis: redis.Redis | None = None
|
|
self._handlers: dict[EventType, list[EventHandler]] = {}
|
|
self._running = False
|
|
self._listen_task: asyncio.Task | None = None
|
|
|
|
async def connect(self) -> None:
|
|
"""Connect to Redis."""
|
|
self._redis = redis.from_url(self.redis_url)
|
|
logger.info("StreamEventBus connected to Redis")
|
|
|
|
def is_connected(self) -> bool:
|
|
"""Check if the event bus is connected to Redis."""
|
|
return self._redis is not None
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Disconnect from Redis."""
|
|
self._running = False
|
|
|
|
if self._listen_task:
|
|
self._listen_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await self._listen_task
|
|
|
|
if self._redis:
|
|
await self._redis.close()
|
|
|
|
logger.info("StreamEventBus disconnected")
|
|
|
|
def _get_stream_name(self, event_type: EventType) -> str:
|
|
"""Get stream name for event type (grouped by prefix)."""
|
|
# Group by event category: task.*, agent.*, notification.*, etc.
|
|
category = event_type.value.split(".")[0]
|
|
return f"{self.STREAM_PREFIX}{category}"
|
|
|
|
def _get_all_stream_names(self) -> list[str]:
|
|
"""Get all stream names for registered handlers."""
|
|
categories = set()
|
|
for event_type in self._handlers:
|
|
category = event_type.value.split(".")[0]
|
|
categories.add(category)
|
|
return [f"{self.STREAM_PREFIX}{cat}" for cat in categories]
|
|
|
|
async def _ensure_consumer_group(self, stream: str) -> None:
|
|
"""Ensure consumer group exists for stream."""
|
|
if not self._redis:
|
|
return
|
|
try:
|
|
await self._redis.xgroup_create(
|
|
stream,
|
|
self.group_name,
|
|
id="0",
|
|
mkstream=True,
|
|
)
|
|
logger.debug("Created consumer group", stream=stream, group=self.group_name)
|
|
except ResponseError as e:
|
|
if "BUSYGROUP" not in str(e):
|
|
raise
|
|
# Group already exists, that's fine
|
|
|
|
def subscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
|
"""Subscribe a handler to an event type."""
|
|
if event_type not in self._handlers:
|
|
self._handlers[event_type] = []
|
|
self._handlers[event_type].append(handler)
|
|
logger.debug("Handler subscribed", event_type=event_type.value)
|
|
|
|
def unsubscribe(self, event_type: EventType, handler: EventHandler) -> None:
|
|
"""Unsubscribe a handler from an event type."""
|
|
if event_type in self._handlers:
|
|
self._handlers[event_type] = [
|
|
h for h in self._handlers[event_type] if h != handler
|
|
]
|
|
|
|
async def publish(self, event: Event) -> str:
|
|
"""
|
|
Publish an event to the stream.
|
|
|
|
Returns the message ID assigned by Redis.
|
|
"""
|
|
if not self._redis:
|
|
raise RuntimeError("StreamEventBus not connected")
|
|
|
|
stream = self._get_stream_name(event.type)
|
|
|
|
# Add to stream with automatic ID (*) and trim to max length
|
|
raw_message_id = await self._redis.xadd(
|
|
stream,
|
|
{
|
|
"type": event.type.value,
|
|
"data": event.to_json(),
|
|
},
|
|
maxlen=self.MAX_STREAM_LENGTH,
|
|
approximate=True,
|
|
)
|
|
# Convert bytes to str if needed
|
|
message_id = (
|
|
raw_message_id.decode()
|
|
if isinstance(raw_message_id, bytes)
|
|
else str(raw_message_id)
|
|
)
|
|
|
|
logger.info(
|
|
"Event published to stream",
|
|
event_type=event.type.value,
|
|
event_id=str(event.id),
|
|
stream=stream,
|
|
message_id=message_id,
|
|
source=event.source_agent,
|
|
)
|
|
|
|
return message_id
|
|
|
|
async def publish_task_event(
|
|
self,
|
|
event_type: EventType,
|
|
task_id: str,
|
|
agent_id: str | None = None,
|
|
**extra_data: Any,
|
|
) -> str:
|
|
"""Convenience method to publish task-related events."""
|
|
event = Event(
|
|
type=event_type,
|
|
data={"task_id": task_id, **extra_data},
|
|
source_agent=agent_id,
|
|
)
|
|
return await self.publish(event)
|
|
|
|
async def start_listening(self) -> None:
|
|
"""Start listening for events."""
|
|
if not self._redis:
|
|
raise RuntimeError("StreamEventBus not connected")
|
|
|
|
streams = self._get_all_stream_names()
|
|
if not streams:
|
|
logger.warning("No event handlers registered, nothing to subscribe to")
|
|
return
|
|
|
|
# Ensure consumer groups exist for all streams
|
|
for stream in streams:
|
|
await self._ensure_consumer_group(stream)
|
|
|
|
self._running = True
|
|
self._listen_task = asyncio.create_task(self._listen_loop())
|
|
logger.info("StreamEventBus listening", streams=streams)
|
|
|
|
async def _listen_loop(self) -> None:
|
|
"""Main event listening loop using XREADGROUP."""
|
|
if not self._redis:
|
|
return
|
|
|
|
streams = self._get_all_stream_names()
|
|
# Build stream dict: {stream_name: ">"} (> = only new messages)
|
|
stream_dict = dict.fromkeys(streams, ">")
|
|
|
|
while self._running:
|
|
try:
|
|
await self._listen_tick(stream_dict)
|
|
except asyncio.CancelledError:
|
|
break
|
|
except ResponseError as e:
|
|
if await self._handle_response_error(e, streams):
|
|
continue
|
|
await asyncio.sleep(1)
|
|
except (RedisTimeoutError, TimeoutError):
|
|
# An idle XREADGROUP(block=...) hits the client read-timeout when
|
|
# no new message arrives within the block window. This is the
|
|
# normal idle path, not an error — re-block on the next iteration
|
|
# without logging or back-off.
|
|
continue
|
|
except Exception as e:
|
|
logger.error("Error in stream event loop", error=str(e))
|
|
await asyncio.sleep(1)
|
|
|
|
async def _listen_tick(self, stream_dict: dict[str, str]) -> None:
|
|
"""Block for one XREADGROUP cycle and dispatch any messages."""
|
|
assert self._redis is not None
|
|
results = await self._redis.xreadgroup(
|
|
self.group_name,
|
|
self.consumer_name,
|
|
stream_dict,
|
|
count=10,
|
|
block=5000,
|
|
)
|
|
if not results:
|
|
return
|
|
for stream_name, messages in results:
|
|
for message_id, data in messages:
|
|
await self._handle_message(stream_name, message_id, data)
|
|
|
|
async def _handle_response_error(
|
|
self, exc: ResponseError, streams: list[str]
|
|
) -> bool:
|
|
"""Recover from NOGROUP by rebootstrapping; return True iff recovered."""
|
|
if "NOGROUP" in str(exc):
|
|
logger.warning(
|
|
"Stream consumer group missing; recreating",
|
|
group=self.group_name,
|
|
)
|
|
for stream in streams:
|
|
await self._ensure_consumer_group(stream)
|
|
return True
|
|
logger.error("Error in stream event loop", error=str(exc))
|
|
return False
|
|
|
|
@staticmethod
|
|
def _decode_event_data(data: dict) -> str | None:
|
|
"""Pull the event payload out of a stream record."""
|
|
event_data = data.get(b"data") or data.get("data")
|
|
if isinstance(event_data, bytes):
|
|
event_data = event_data.decode()
|
|
if not event_data or not isinstance(event_data, str):
|
|
return None
|
|
return event_data
|
|
|
|
@staticmethod
|
|
def _check_handler_results(event: Event, handlers: list, results: list) -> bool:
|
|
"""Log handler errors; return True only when every handler succeeded."""
|
|
all_succeeded = True
|
|
for i, result in enumerate(results):
|
|
if isinstance(result, Exception):
|
|
all_succeeded = False
|
|
logger.error(
|
|
"Event handler error",
|
|
event_type=event.type.value,
|
|
handler=handlers[i].__name__,
|
|
error=str(result),
|
|
)
|
|
return all_succeeded
|
|
|
|
async def _dispatch_event(self, event: Event) -> bool:
|
|
"""Run all handlers for an event; return True if all succeeded."""
|
|
handlers = self._handlers.get(event.type, [])
|
|
if not handlers:
|
|
return True
|
|
|
|
logger.debug(
|
|
"Handling event from stream",
|
|
event_type=event.type.value,
|
|
handler_count=len(handlers),
|
|
)
|
|
tasks = [handler(event) for handler in handlers]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
return self._check_handler_results(event, handlers, results)
|
|
|
|
async def _handle_message(
|
|
self,
|
|
stream: str,
|
|
message_id: str,
|
|
data: dict,
|
|
) -> None:
|
|
"""Handle an incoming message and ACK on success."""
|
|
if not self._redis:
|
|
return
|
|
|
|
try:
|
|
event_data = self._decode_event_data(data)
|
|
if event_data is None:
|
|
logger.error("Invalid event data", message_id=message_id)
|
|
await self._redis.xack(stream, self.group_name, message_id)
|
|
return
|
|
|
|
event = Event.from_json(event_data)
|
|
all_succeeded = await self._dispatch_event(event)
|
|
|
|
# ACK the message if all handlers succeeded
|
|
# If any failed, message stays pending and can be reclaimed later
|
|
if all_succeeded:
|
|
await self._redis.xack(stream, self.group_name, message_id)
|
|
logger.debug("Message acknowledged", message_id=message_id)
|
|
else:
|
|
logger.warning(
|
|
"Message not acknowledged due to handler errors",
|
|
message_id=message_id,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to handle stream message",
|
|
error=str(e),
|
|
message_id=message_id,
|
|
)
|
|
|
|
async def _claim_and_handle(
|
|
self, stream: str, msg_id: str, idle_time_ms: int
|
|
) -> int:
|
|
"""Claim a single idle message and process it; return count recovered."""
|
|
if self._redis is None:
|
|
raise RuntimeError("Invariant: self._redis must be set — guarded by caller")
|
|
claimed = await self._redis.xclaim(
|
|
stream,
|
|
self.group_name,
|
|
self.consumer_name,
|
|
min_idle_time=idle_time_ms,
|
|
message_ids=[msg_id],
|
|
)
|
|
if not claimed:
|
|
return 0
|
|
for claim_id, data in claimed:
|
|
await self._handle_message(stream, claim_id, data)
|
|
return 1
|
|
|
|
async def _recover_stream(self, stream: str, idle_time_ms: int) -> int:
|
|
"""Recover idle pending messages from a single stream."""
|
|
if self._redis is None:
|
|
raise RuntimeError("Invariant: self._redis must be set — guarded by caller")
|
|
pending = await self._redis.xpending(stream, self.group_name)
|
|
if not pending or pending["pending"] == 0:
|
|
return 0
|
|
|
|
pending_details = await self._redis.xpending_range(
|
|
stream,
|
|
self.group_name,
|
|
min="-",
|
|
max="+",
|
|
count=100,
|
|
)
|
|
|
|
recovered = 0
|
|
for msg in pending_details:
|
|
if int(msg["time_since_delivered"]) >= idle_time_ms:
|
|
recovered += await self._claim_and_handle(
|
|
stream, str(msg["message_id"]), idle_time_ms
|
|
)
|
|
return recovered
|
|
|
|
async def recover_pending(self, idle_time_ms: int = 60000) -> int:
|
|
"""
|
|
Recover pending messages that weren't acknowledged.
|
|
|
|
Useful for startup to process messages from crashed consumers.
|
|
|
|
Args:
|
|
idle_time_ms: Only recover messages idle for this long (default 1 minute)
|
|
|
|
Returns:
|
|
Number of messages recovered
|
|
"""
|
|
if not self._redis:
|
|
return 0
|
|
|
|
recovered = 0
|
|
for stream in self._get_all_stream_names():
|
|
try:
|
|
recovered += await self._recover_stream(stream, idle_time_ms)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Error recovering pending messages",
|
|
stream=stream,
|
|
error=str(e),
|
|
)
|
|
|
|
if recovered:
|
|
logger.info("Recovered pending messages", count=recovered)
|
|
|
|
return recovered
|
|
|
|
|
|
# =============================================================================
|
|
# SINGLETON ACCESS
|
|
# =============================================================================
|
|
|
|
|
|
class _StreamEventBusHolder:
|
|
"""Holder for singleton StreamEventBus instance."""
|
|
|
|
instance: StreamEventBus | None = None
|
|
|
|
|
|
def get_stream_event_bus() -> StreamEventBus:
|
|
"""Get or create the global stream event bus instance."""
|
|
if _StreamEventBusHolder.instance is None:
|
|
_StreamEventBusHolder.instance = StreamEventBus()
|
|
return _StreamEventBusHolder.instance
|
|
|
|
|
|
async def init_stream_event_bus(
|
|
consumer_name: str | None = None,
|
|
recover_pending: bool = True,
|
|
) -> StreamEventBus:
|
|
"""
|
|
Initialize and start the stream event bus.
|
|
|
|
Args:
|
|
consumer_name: Unique name for this consumer instance
|
|
recover_pending: Whether to recover unacknowledged messages on startup
|
|
"""
|
|
bus = get_stream_event_bus()
|
|
if consumer_name:
|
|
bus.consumer_name = consumer_name
|
|
await bus.connect()
|
|
|
|
if recover_pending:
|
|
await bus.recover_pending()
|
|
|
|
return bus
|