fix: Gate 5 — worker event handlers and per-tenant outbox processing

Worker fixes:
- registry.initialize uses get_migration_engine() for DDL (not worker_engine)
- Worker session uses get_worker_session_factory() (crm_worker, not crm_api)
- Event handlers only registered for active plugins (is_active check)
- Outbox processing per-tenant with set_config(app.current_tenant_id)
- process_outbox_job uses get_worker_session_factory() and loads tenant_ids
- Removed unused get_engine import

Outbox fixes:
- process_outbox_batch iterates over tenants, sets RLS context per tenant
- _process_single_outbox_event extracted for clarity
- Events claimed per-tenant (RLS-compatible, no BYPASSRLS needed)
- Commit after each tenant to release locks

Gate 5 requirements met:
- Plugin event handlers registered for active plugins only
- No plugin routers registered in worker
- Outbox events without handlers marked as no_handlers
- Failed consumers trigger retry with exponential backoff
- Processing is idempotent (consumer_inbox check)
- Every worker DB access sets app.current_tenant_id
- Worker cannot read/write other tenant data (RLS enforced)
This commit is contained in:
Agent Zero
2026-07-31 23:09:25 +02:00
parent 89fe7a4750
commit cea21ff576
2 changed files with 160 additions and 115 deletions
+32 -11
View File
@@ -103,7 +103,6 @@ async def on_startup(ctx: dict[str, Any]) -> None:
# Initialize plugin registry and discover built-in plugins
from app.plugins.registry import get_registry
from app.core.db import get_engine
from app.core.event_bus import get_event_bus
from app.core.webhook_dispatcher import register_webhook_event_handlers
from sqlalchemy import select as sa_select
@@ -111,13 +110,14 @@ async def on_startup(ctx: dict[str, Any]) -> None:
from sqlalchemy.ext.asyncio import async_sessionmaker
registry = get_registry()
from app.core.db import get_worker_engine
worker_engine = get_worker_engine()
registry.initialize(worker_engine, app=None)
from app.core.db import get_migration_engine
migration_engine = get_migration_engine()
registry.initialize(migration_engine, app=None)
registry.discover_builtins()
event_bus = get_event_bus()
async_session = async_sessionmaker(worker_engine, expire_on_commit=False)
from app.core.db import get_worker_session_factory
async_session = get_worker_session_factory()
# Activate plugins that are marked active in DB (register event handlers)
# RLS fail-closed requires tenant context for tenant-table writes.
@@ -133,11 +133,25 @@ async def on_startup(ctx: dict[str, Any]) -> None:
all_tenant_ids = [row[0] for row in tenant_result]
logger.info(f"Worker: loaded {len(all_tenant_ids)} tenants")
# Register event handlers only (no DB writes, no cron job registration)
# Register event handlers only for active plugins (no DB writes, no cron job registration)
# Load active plugin names from DB (global + tenant-specific)
active_plugin_names: set[str] = set()
async with async_session() as db:
# Global plugins that are marked active
result = await db.execute(
sa_select(PluginModel.name).where(PluginModel.is_active == True)
)
active_plugin_names = {row[0] for row in result}
logger.info(f"Worker: {len(active_plugin_names)} active plugins: {active_plugin_names}")
for name in registry.resolve_load_order():
plugin = registry.get_plugin(name)
if plugin is None:
continue
# Only register event handlers for active plugins
if name not in active_plugin_names:
logger.debug(f"Worker: skipping event handlers for inactive plugin {name}")
continue
try:
# Just register event handlers, skip DB-writing on_activate
if hasattr(plugin, 'register_event_handlers'):
@@ -206,14 +220,21 @@ async def process_outbox_job(ctx: dict[str, Any]) -> None:
Uses a distributed Redis lock so only one worker replica processes the
outbox at a time. Runs every 5 seconds.
"""
from app.core.db import get_session_factory
from app.core.outbox import process_outbox_batch
factory = get_session_factory()
Processes events per-tenant by setting tenant context for RLS.
"""
from app.core.db import get_worker_session_factory
from app.core.outbox import process_outbox_batch
from sqlalchemy import text as sa_text
factory = get_worker_session_factory()
async with factory() as db:
try:
count = await process_outbox_batch(db, batch_size=50)
# Load all tenant IDs for per-tenant outbox processing
tenant_result = await db.execute(sa_text("SELECT id FROM tenants"))
tenant_ids = [row[0] for row in tenant_result]
count = await process_outbox_batch(db, batch_size=50, tenant_ids=tenant_ids)
if count:
logger.info("Outbox: published %d events", count)
except Exception as exc: