Files
webmail-client/backend/app/plugins/jobs.py
T
lukasandClaude Opus 4.8 4e077f1a37 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
2026-07-26 21:42:35 +02:00

131 lines
4.4 KiB
Python

"""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