M3 plugin platform: SDK + loader, RLS-isolated tables, migrations, jobs
Backend foundation for the plugin platform (SPEC §11), Phases 1-3.
Phase 1 — SDK + loader:
- app/plugins/types.py: frozen Plugin/UiManifest/Slot/Page/JobSpec contract.
- app/plugins/sdk.py: sdk(name) facade — the only app.* import allowed for plugins
(scoped session, plugin.{name}.* publish, blob, outbox, settings, SSRF http_get, KV, enqueue).
- app/plugins/loader.py: entry-point discovery (webmail.plugins), hook forwarding
(API + worker), router mounting at /api/plugins/{name}, ui-manifest aggregation.
- plugin_kv core table (migration 0013) + GET /api/plugins/ui-manifest.
Phase 2 — non-removable core plugins + DB-enforced privacy:
- builtin flag (calendar/contacts will be builtin=True; uninstall refuses them).
- RLS per-user isolation on plg_* tables: least-privilege webmail_plugin role
(migration 0014), plugin_session(user_id) sets LOCAL ROLE + app.current_user_id,
rls.enable_rls() (ENABLE+FORCE RLS + USING/WITH CHECK policy). Mail untouched.
- Per-plugin Alembic runner (shared env, dedicated version table) + install/uninstall
--drop-data CLI; skeleton fixture proves install -> run -> uninstall --drop-data
leaves zero residue (§12 M3 acceptance).
Phase 3 — plugin jobs (§11.2):
- JobSpec registered as arq function plg.{plugin}.{func} + cron (5-field parser);
singleton=True guarded by a per-(plugin,user) Redis lock; wired onto the shared
interactive worker.
Gates: ruff, mypy --strict (94 files + tests), full suite 326 passed. RLS isolation,
migration round-trip, and singleton lock covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""Plugin background jobs on the shared arq worker (SPEC §11.2).
|
||||
|
||||
Each plugin ``JobSpec`` is registered as an arq function under a namespaced name
|
||||
``plg.{plugin}.{func}`` (enqueue on demand from hooks/routes via ``sdk.enqueue``),
|
||||
and, when it has a ``schedule``, also as an arq cron job. ``singleton=True`` wraps
|
||||
the job in a Redis NX lock keyed by (plugin, func, user) so at most one instance
|
||||
runs per user (or globally for scheduleds with no user). Jobs share the worker
|
||||
pool + retry semantics with core jobs and log under the ``plg`` logger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from arq import cron
|
||||
from arq.cron import CronJob
|
||||
from arq.worker import Function, func
|
||||
|
||||
from app.plugins import loader
|
||||
from app.plugins.types import JobSpec, Plugin
|
||||
from app.redis_client import get_redis
|
||||
|
||||
logger = logging.getLogger("plg")
|
||||
|
||||
# Safety expiry on the singleton lock so a crashed job can't wedge a user forever.
|
||||
# A job that runs longer than this could overlap; acceptable for M3.
|
||||
_LOCK_TTL = 3600
|
||||
|
||||
# Standard 5-field cron order and each field's inclusive value bounds.
|
||||
_FIELD_ORDER = ("minute", "hour", "day", "month", "weekday")
|
||||
_FIELD_BOUNDS = {
|
||||
"minute": (0, 59),
|
||||
"hour": (0, 23),
|
||||
"day": (1, 31),
|
||||
"month": (1, 12),
|
||||
"weekday": (0, 6),
|
||||
}
|
||||
|
||||
|
||||
def job_name(plugin_name: str, spec: JobSpec) -> str:
|
||||
"""The arq function name a plugin job is registered under."""
|
||||
return f"plg.{plugin_name}.{spec.func.__name__}"
|
||||
|
||||
|
||||
def _lock_key(plugin_name: str, func_name: str, user: object) -> str:
|
||||
return f"plglock:{plugin_name}:{func_name}:{user}"
|
||||
|
||||
|
||||
def _make_runner(plugin: Plugin, spec: JobSpec) -> tuple[Any, str]:
|
||||
"""Wrap a job func with the singleton lock (when requested) + namespaced name."""
|
||||
func_name = spec.func.__name__
|
||||
name = job_name(plugin.name, spec)
|
||||
|
||||
async def runner(ctx: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
||||
if not spec.singleton:
|
||||
return await spec.func(ctx, *args, **kwargs)
|
||||
user = kwargs.get("user_id", "global")
|
||||
key = _lock_key(plugin.name, func_name, user)
|
||||
acquired = await get_redis().set(key, b"1", nx=True, ex=_LOCK_TTL)
|
||||
if not acquired:
|
||||
logger.info("%s skipped: singleton already running (user=%s)", name, user)
|
||||
return None
|
||||
try:
|
||||
return await spec.func(ctx, *args, **kwargs)
|
||||
finally:
|
||||
await get_redis().delete(key)
|
||||
|
||||
return runner, name
|
||||
|
||||
|
||||
def plugin_arq_functions() -> list[Function]:
|
||||
"""Every plugin job as an arq function (enqueue-on-demand), namespaced."""
|
||||
funcs: list[Function] = []
|
||||
for plugin in loader.get_plugins():
|
||||
for spec in plugin.jobs:
|
||||
runner, name = _make_runner(plugin, spec)
|
||||
funcs.append(func(runner, name=name))
|
||||
return funcs
|
||||
|
||||
|
||||
def _parse_field(token: str, lo: int, hi: int) -> set[int] | None:
|
||||
"""Parse one cron field to a value set (``*`` -> None = every)."""
|
||||
if token == "*":
|
||||
return None
|
||||
values: set[int] = set()
|
||||
for part in token.split(","):
|
||||
if part.startswith("*/"):
|
||||
values.update(range(lo, hi + 1, int(part[2:])))
|
||||
elif "-" in part:
|
||||
start, end = part.split("-", 1)
|
||||
values.update(range(int(start), int(end) + 1))
|
||||
else:
|
||||
values.add(int(part))
|
||||
return values
|
||||
|
||||
|
||||
def parse_cron(expr: str) -> dict[str, set[int] | None]:
|
||||
"""Parse a standard 5-field cron string into arq ``cron`` field kwargs."""
|
||||
parts = expr.split()
|
||||
if len(parts) != 5:
|
||||
raise ValueError(f"cron expression must have 5 fields (min hour dom mon dow): {expr!r}")
|
||||
return {
|
||||
name: _parse_field(token, *_FIELD_BOUNDS[name])
|
||||
for name, token in zip(_FIELD_ORDER, parts, strict=True)
|
||||
}
|
||||
|
||||
|
||||
def plugin_cron_jobs() -> list[CronJob]:
|
||||
"""Cron-scheduled plugin jobs as arq ``CronJob``s (run on the shared worker)."""
|
||||
jobs: list[CronJob] = []
|
||||
for plugin in loader.get_plugins():
|
||||
for spec in plugin.jobs:
|
||||
if spec.schedule is None:
|
||||
continue
|
||||
runner, name = _make_runner(plugin, spec)
|
||||
f = parse_cron(spec.schedule)
|
||||
jobs.append(
|
||||
cron(
|
||||
runner,
|
||||
name=f"{name}.cron",
|
||||
second=0,
|
||||
minute=f["minute"],
|
||||
hour=f["hour"],
|
||||
day=f["day"],
|
||||
month=f["month"],
|
||||
weekday=f["weekday"],
|
||||
)
|
||||
)
|
||||
return jobs
|
||||
Reference in New Issue
Block a user