Phase 3: VISUAL feed and M-Files drawings in the worker
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m12s
ci / image (push) Successful in 46s
ci / e2e (push) Successful in 1m24s

Migration 0004 (VISUAL and drawing caches, go-live ignored list, resource
map, job tables, order-derived columns with provenance; work order key
includes the operation sequence). The real worker on APScheduler with
settings re-read each fire, pause, run now, advisory locks, job_runs and a
heartbeat. The visual-feed job (five VisualKit reads, COPY and MERGE cache,
suspect-batch guard, the apply in one transaction: drafts, change orders,
retire-moved, work order gone, closed and cancelled observed, projects and
units from order links, auto-release) and the fingerprint. The drawing-lookup
job (queue, budget, download on version change, sha256 into assets, decision
table, staging for rev approval, drawing-origin change orders). Clerical From
VISUAL panel, flags, rev approval, hand uploads, change-order review; Admin
Feed settings and resource map. Synthetic fixture generator and a
fixture-driven worker in CI with a Playwright scenario. Docs updated.

Verified: 3249 server tests and 13 Playwright scenarios on the docker-1 CI
stack, 76 frontend tests, doctor at 0004; one feed and one drawing run
against VISUAL DEV and the vault from the developer machine (counts in the
changelog).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 14:45:38 -06:00
co-authored by Claude Fable 5.1
parent 16950055e3
commit 591fe90076
103 changed files with 16063 additions and 151 deletions
+63
View File
@@ -0,0 +1,63 @@
"""The background worker: the job scheduler plus a heartbeat (beat file and worker_heartbeat row).
`run(beat_file, build, stop=None, interval=BEAT_SECONDS, settings=None)` blocks until SIGTERM, SIGINT or `stop`.
With settings it builds and starts a Scheduler; without (the CLI tests) it only beats the file.
"""
from __future__ import annotations
import logging
import signal
import threading
from pathlib import Path
from typing import TYPE_CHECKING
from .heartbeat import BEAT_SECONDS, STALE_SECONDS, beat_age, touch
if TYPE_CHECKING:
from ..settings import Settings
log = logging.getLogger("packetstation.worker")
__all__ = ["BEAT_SECONDS", "STALE_SECONDS", "beat_age", "run", "touch"]
def run(
beat_file: Path,
build: str,
stop: threading.Event | None = None,
interval: float = BEAT_SECONDS,
settings: Settings | None = None,
) -> int:
stop = stop or threading.Event()
def _stop(signum: int, _frame: object) -> None:
log.info("worker stopping on signal %s", signum)
stop.set()
if threading.current_thread() is threading.main_thread():
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
log.info("worker started", extra={"ps": {"build": build, "beat_file": str(beat_file)}})
sched = None
if settings is not None:
from .scheduler import Scheduler
sched = Scheduler(settings, build=build)
sched.start()
try:
if sched is not None:
sched.serve(beat_file, stop, interval)
else:
while not stop.is_set():
try:
touch(beat_file)
except OSError as e:
log.error("can't touch beat file %s: %s", beat_file, e.strerror)
log.info("heartbeat", extra={"ps": {"build": build}})
stop.wait(interval)
finally:
if sched is not None:
sched.shutdown(wait=True)
log.info("worker stopped")
return 0
+36
View File
@@ -0,0 +1,36 @@
"""The worker's heartbeat: a touched beat file for the container healthcheck and a worker_heartbeat row."""
from __future__ import annotations
import os
import time
from collections.abc import Iterable
from pathlib import Path
from typing import Any
BEAT_SECONDS = 30
STALE_SECONDS = 90
def touch(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.touch(exist_ok=True)
now = time.time()
os.utime(path, (now, now))
def beat_age(beat_file: Path) -> float | None:
try:
return time.time() - beat_file.stat().st_mtime
except OSError:
return None
def write_row(conn: Any, *, build: str, host: str, pid: int, running: Iterable[str]) -> None:
"""Upsert the single worker_heartbeat row."""
conn.execute(
"INSERT INTO worker_heartbeat (id, at, build, host, pid, running) VALUES (1, now(), %s, %s, %s, %s)"
" ON CONFLICT (id) DO UPDATE SET at = EXCLUDED.at, build = EXCLUDED.build, host = EXCLUDED.host,"
" pid = EXCLUDED.pid, running = EXCLUDED.running",
(build, host, pid, sorted(running)),
)
@@ -0,0 +1,146 @@
"""The job registry and the shapes every job uses.
Jobs import `from packetstation.worker.jobs import JobContext, JobSpec, register` and expose `SPEC`. The feed and
drawing jobs are imported lazily by `load_jobs`, so the worker runs before they exist.
"""
from __future__ import annotations
import importlib
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from ...feed.config import FeedSettings
from ...settings import Settings
from ..redaction import MAX_ERRORS, clip, redact
log = logging.getLogger(__name__)
Counts = dict[str, Any]
Cadence = Callable[[FeedSettings, datetime], int | None]
TRIGGERS = ("schedule", "request", "chain", "cli")
@dataclass
class Stack:
"""Per-process state shared by every run: the startup guard result and long-lived objects (the M-Files vault)."""
guard: Any = None
cache: dict[str, Any] = field(default_factory=dict)
lock: threading.Lock = field(default_factory=threading.Lock)
def cached(self, key: str, factory: Callable[[], Any]) -> Any:
"""The object under `key`, made once by `factory`. A factory that raises leaves nothing cached."""
with self.lock:
if key not in self.cache:
self.cache[key] = factory()
return self.cache[key]
def drop(self, key: str) -> None:
with self.lock:
self.cache.pop(key, None)
@dataclass
class JobContext:
"""What a run gets. `conn` is the run's own autocommit connection as the worker role."""
conn: Any
settings: Settings
feed_settings: FeedSettings
run_id: int
trigger: str
started_at: datetime
log: logging.Logger
stack: Stack
degraded: bool = False
errors: list[str] = field(default_factory=list)
visual_target: str | None = None
def error(self, text: str, *, degrade: bool = True) -> None:
"""Record one error (redacted, clipped). It makes the run degraded unless degrade is False."""
if len(self.errors) < MAX_ERRORS:
self.errors.extend(clip([text]))
if degrade:
self.degraded = True
def degrade(self, reason: str | None = None) -> None:
self.degraded = True
if reason:
self.error(reason)
@property
def tz(self) -> ZoneInfo:
return ZoneInfo(self.settings.site_tz)
def now_site(self) -> datetime:
return datetime.now(self.tz)
def today_site(self) -> date:
return self.now_site().date()
@dataclass(frozen=True)
class JobSpec:
name: str
run: Callable[[JobContext], Counts]
cadence: Cadence # seconds to the next run, or None for "no schedule"
pausable: bool # the feed pause skips scheduled fires (requests still run)
chain: tuple[str, ...] = () # moved to now after a run that did not fail
JOBS: dict[str, JobSpec] = {}
_loaded = False
_load_lock = threading.Lock()
LAZY = ("visual_feed", "drawing_lookup")
def register(spec: JobSpec) -> JobSpec:
JOBS[spec.name] = spec
return spec
def load_jobs() -> dict[str, JobSpec]:
"""Register every job once and return the registry. A job module that cannot import is logged and left out."""
global _loaded
with _load_lock:
if not _loaded:
from . import housekeeping
register(housekeeping.SPEC)
for mod in LAZY:
try:
m = importlib.import_module(f"{__name__}.{mod}")
register(m.SPEC)
except ImportError as e:
log.warning("job module %s not loaded: %s", mod, redact(str(e)))
_loaded = True
return JOBS
def seconds_until(now_site: datetime, at: time) -> int:
"""Seconds from now_site to the next `at` (site wall clock), at least 1."""
target = datetime.combine(now_site.date(), at, tzinfo=now_site.tzinfo)
if target <= now_site:
target = datetime.combine(now_site.date() + timedelta(days=1), at, tzinfo=now_site.tzinfo)
# Aware datetimes sharing a tzinfo subtract as wall clock; through UTC a DST change counts right
return max(1, int((target.astimezone(UTC) - now_site.astimezone(UTC)).total_seconds()))
__all__ = [
"JOBS",
"TRIGGERS",
"Cadence",
"Counts",
"JobContext",
"JobSpec",
"Stack",
"load_jobs",
"register",
"seconds_until",
]
@@ -0,0 +1,536 @@
"""The drawing-lookup job: look drawings up in M-Files (read-only), copy released PDFs into assets, and attach,
flag, stage or open a change order on the packets that use them.
Per run: the queue (drawings/queue.py) cut to `drawing_budget`; per drawing one `Lookup.status`, a fetch when a
released file exists (drawings/fetch.py downloads only when its M-Files identity changed), the `drawing_lookups`
row, then `decide` for every live packet that uses the drawing (its own drawing, or the target of its pending feed
change order), applied through `feed.writes.system_write`. A busy packet is left for the next run.
Never writes to M-Files.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from ... import audit, policy
from ...domain import pipeline
from ...drawings import DRAWING_FLAGS, decide, queue
from ...drawings import lookup as lookups
from ...drawings.fetch import FetchError, FetchResult, fetch
from ...drawings.lookup import Found, Lookup
from ...feed import changes, writes
from . import Counts, JobContext, JobSpec
log = logging.getLogger(__name__)
A = policy.Action
D = decide
FEED_ORIGINS = ("visual", "drawing")
class _Stale(Exception):
"""The packet changed between the read and the lock; the next run decides again."""
def new_counts() -> Counts:
return {
"checked": 0,
"fetched": 0,
"unchanged": 0,
"attached": 0,
"flagged": {},
"lookup_failed": 0,
"cos_opened": 0,
"cos_reaimed": 0,
"staged": 0,
"budget_left": 0,
"fetch_failed": 0,
"busy": 0,
"refused": 0,
"no_file": 0,
}
# ---------------------------------------------------------------- the run
def run(ctx: JobContext) -> Counts:
counts = new_counts()
fs = ctx.feed_settings
budget = int(getattr(fs, "drawing_budget", 60))
try:
lk = lookups.make(ctx.settings, ctx.stack.cache)
except Exception as e: # any failure to open M-Files skips the run
ctx.error(f"M-Files could not be opened: {type(e).__name__}: {e}")
counts["reason"] = "mfiles unavailable"
counts["budget_left"] = budget
return counts
now = datetime.now(UTC)
items = queue.queue(ctx.conn, fs, now, limit=budget)
counts["budget_left"] = max(0, budget - len(items))
counts["source"] = lk.describe()
for item in items:
try:
_drawing(ctx, lk, item, counts, now)
except Exception as e: # one bad drawing must not stop the others
log.exception("drawing %s failed", item.dwg)
ctx.error(f"drawing {item.dwg}: {type(e).__name__}: {e}")
return counts
def _drawing(ctx: JobContext, lk: Lookup, item: queue.QueueItem, counts: Counts, now: datetime) -> None:
conn = ctx.conn
try:
found = lk.status(item.dwg)
except Exception as e: # a lookup error is a failed lookup, never a crash
found = lookups.failed(item.dwg, f"M-Files lookup failed: {type(e).__name__}: {e}")
counts["checked"] += 1
fetched: FetchResult | None = None
fetch_failed = False
if found.ok and found.rev is not None:
try:
fetched = fetch(conn, ctx.settings, lk, found, dwg=item.dwg)
except FetchError as e:
fetch_failed = True
counts["fetch_failed"] += 1
ctx.error(str(e))
else:
if fetched.downloaded:
counts["fetched"] += 1
audit.append(
conn,
action="drawing.fetched",
role_snapshot="system",
subject_type="asset",
subject_id=str(fetched.asset_id),
detail={
"dwg": item.dwg,
"rev": found.rev,
"sha256": fetched.sha256.hex(),
"reused": fetched.reused,
},
)
if not found.ok:
counts["lookup_failed"] += 1
ctx.error(f"drawing {item.dwg}: {D.failed_note(found)}")
vrev_seen = item.visual_rev if item.co_id is None else own_rev(conn, item.dwg)
save_lookup(conn, item, found, fetched, now, fetch_failed=fetch_failed, visual_rev_seen=vrev_seen)
scope = [str(d) for d in (getattr(ctx.feed_settings, "departments", None) or [])]
for pid, relations in sharing(conn, item.dwg, scope):
_packet(ctx, item.dwg, found, fetched, pid, relations, counts)
# ---------------------------------------------------------------- drawing_lookups
def save_lookup(
conn: Any,
item: queue.QueueItem,
found: Found,
fetched: FetchResult | None,
now: datetime,
*,
fetch_failed: bool,
visual_rev_seen: str | None,
) -> None:
"""Upsert the drawing_lookups row for this lookup.
A failed lookup keeps everything known before (rev, file, asset) and backs off. A good one stores what M-Files
has; the file identity and the asset are stored together, and cleared when there is no released file or the
copy failed (so the next run downloads again).
"""
with conn.transaction():
prev = conn.execute("SELECT fail_count FROM drawing_lookups WHERE dwg = %s FOR UPDATE", (item.dwg,)).fetchone()
if not found.ok:
fails = (int(prev[0]) if prev else 0) + 1
conn.execute(
"INSERT INTO drawing_lookups (dwg, checked_at, ok, note, fail_count, failing_since, next_check_at,"
" visual_rev_seen) VALUES (%(dwg)s, %(now)s, false, %(note)s, %(fails)s, %(now)s, %(next)s, %(vrev)s)"
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = false, note = EXCLUDED.note,"
" fail_count = EXCLUDED.fail_count,"
" failing_since = coalesce(drawing_lookups.failing_since, EXCLUDED.failing_since),"
" next_check_at = EXCLUDED.next_check_at, visual_rev_seen = EXCLUDED.visual_rev_seen",
{
"dwg": item.dwg,
"now": now,
"note": D.failed_note(found),
"fails": fails,
"next": now + queue.backoff(fails),
"vrev": visual_rev_seen,
},
)
return
has_file = fetched is not None
conn.execute(
"INSERT INTO drawing_lookups (dwg, checked_at, ok, rev, name, obj_id, obj_version, file_id, file_version,"
" file_size, base, draft_rev, draft_where, in_drafting, note, fail_count, failing_since, asset_id, sha256,"
" visual_rev_seen, next_check_at) VALUES (%(dwg)s, %(now)s, true, %(rev)s, %(name)s, %(obj_id)s,"
" %(obj_version)s, %(file_id)s, %(file_version)s, %(file_size)s, %(base)s, %(draft_rev)s,"
" %(draft_where)s, %(in_drafting)s, %(note)s, 0, NULL, %(asset_id)s, %(sha)s, %(vrev)s, %(next)s)"
" ON CONFLICT (dwg) DO UPDATE SET checked_at = EXCLUDED.checked_at, ok = true, rev = EXCLUDED.rev,"
" name = EXCLUDED.name, obj_id = EXCLUDED.obj_id, obj_version = EXCLUDED.obj_version,"
" file_id = EXCLUDED.file_id, file_version = EXCLUDED.file_version, file_size = EXCLUDED.file_size,"
" base = EXCLUDED.base, draft_rev = EXCLUDED.draft_rev, draft_where = EXCLUDED.draft_where,"
" in_drafting = EXCLUDED.in_drafting, note = EXCLUDED.note, fail_count = 0, failing_since = NULL,"
" asset_id = EXCLUDED.asset_id, sha256 = EXCLUDED.sha256,"
" visual_rev_seen = coalesce(EXCLUDED.visual_rev_seen, drawing_lookups.visual_rev_seen),"
" next_check_at = EXCLUDED.next_check_at",
{
"dwg": item.dwg,
"now": now,
"rev": found.rev,
"name": found.name,
"obj_id": found.obj_id if has_file else None,
"obj_version": found.obj_version if has_file else None,
"file_id": found.file_id if has_file else None,
"file_version": found.file_version if has_file else None,
"file_size": found.file_size,
"base": found.base,
"draft_rev": found.draft_rev,
"draft_where": found.draft_where or None,
"in_drafting": found.in_drafting,
"note": found.note or None,
"asset_id": fetched.asset_id if fetched else None,
"sha": fetched.sha256 if fetched else None,
"vrev": visual_rev_seen,
"next": now + queue.backoff(1) if fetch_failed else None,
},
)
# ---------------------------------------------------------------- packets
def own_rev(conn: Any, dwg: str) -> str | None:
"""The rev a live VISUAL packet with this drawing carries (for visual_rev_seen when the item came from a CO)."""
row = conn.execute(
"SELECT coalesce(btrim(rev), '') FROM packets WHERE removed_at IS NULL AND source_kind = 'visual'"
" AND status <> 'filed' AND btrim(drawing) = %s ORDER BY id LIMIT 1",
(dwg,),
).fetchone()
return None if row is None else str(row[0])
SHARING_SQL = """
SELECT p.id, 'own' AS rel FROM packets p
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed' AND btrim(p.drawing) = %(dwg)s
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
UNION
SELECT p.id, 'co' AS rel FROM change_orders c JOIN packets p ON p.id = c.packet_id
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND jsonb_typeof(c.waiting) = 'object'
AND btrim(c.waiting->>'dwg') = %(dwg)s AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
ORDER BY 1
"""
def sharing(conn: Any, dwg: str, depts: list[str]) -> list[tuple[int, set[str]]]:
"""Live packets that use the drawing: as their own drawing ('own') or as a pending feed CO's target ('co')."""
out: dict[int, set[str]] = {}
for pid, rel in conn.execute(SHARING_SQL, {"dwg": dwg, "depts": depts}).fetchall():
out.setdefault(int(pid), set()).add(str(rel))
return sorted(out.items())
@dataclass
class PacketFacts:
row: dict[str, Any]
visual: dict[str, Any]
attached_rev: str | None
attached_shas: set[bytes]
co: dict[str, Any] | None
rejected_sha: bytes | None
def _sha(value: Any) -> bytes | None:
if not isinstance(value, str) or not value:
return None
try:
return bytes.fromhex(value)
except ValueError:
return None
def facts(conn: Any, pid: int) -> PacketFacts | None:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"SELECT id, status::text AS status, drawing, rev, drawing_state::text AS drawing_state, flags, visual,"
" version FROM packets WHERE id = %s AND removed_at IS NULL",
(pid,),
).fetchone()
if row is None:
return None
pages = cur.execute(
"SELECT pg.rev, a.sha256 FROM packet_pages pg LEFT JOIN assets a ON a.id = pg.asset_id"
" WHERE pg.packet_id = %s AND pg.type = 'drawing' AND pg.void_at IS NULL ORDER BY pg.n",
(pid,),
).fetchall()
co = cur.execute(
"SELECT id, co_no, origin::text AS origin, waiting, staged FROM change_orders"
" WHERE packet_id = %s AND review = 'pending' AND origin IN ('visual', 'drawing') ORDER BY id DESC LIMIT 1",
(pid,),
).fetchone()
rej = cur.execute(
"SELECT staged FROM change_orders WHERE packet_id = %s AND origin = 'drawing' AND review = 'rejected'"
" ORDER BY id DESC LIMIT 1",
(pid,),
).fetchone()
visual = row["visual"] if isinstance(row["visual"], dict) else {}
attached_rev = None
if pages:
attached_rev = str(pages[0]["rev"] or "").strip()
shas = {bytes(p["sha256"]) for p in pages if p["sha256"] is not None}
rejected = None
if rej is not None and isinstance(rej["staged"], dict):
rejected = _sha(rej["staged"].get("sha256"))
return PacketFacts(dict(row), dict(visual), attached_rev, shas, dict(co) if co else None, rejected)
def _dwg_rev(value: Any) -> tuple[str, str] | None:
if isinstance(value, dict) and value.get("dwg") and value.get("rev") is not None:
return (str(value["dwg"]).strip(), str(value["rev"]).strip())
return None
def target_for(dwg: str, pf: PacketFacts, *, own: bool) -> D.Target:
row, visual, co = pf.row, pf.visual, pf.co
waiting = co.get("waiting") if co else None
staged = co.get("staged") if co else None
co_waiting = _dwg_rev(waiting)
co_staged = _dwg_rev(staged)
packet_rev = str(row["rev"] or "").strip()
rev_ok = None
ro = visual.get("rev_ok")
if isinstance(ro, dict) and ro.get("rev") is not None and str(ro.get("vis_rev") or "").strip() == packet_rev:
rev_ok = str(ro["rev"]).strip()
visual_rev = packet_rev if own or co_waiting is None else co_waiting[1]
return D.Target(
dwg=dwg,
visual_rev=visual_rev,
attached_rev=pf.attached_rev,
attached_sha=next(iter(sorted(pf.attached_shas)), None),
status=str(row["status"]),
drawing_state=str(row["drawing_state"]),
has_hand_drawing=row["drawing_state"] == "hand" or bool(visual.get("manual_dwg")),
rev_ok=rev_ok,
co_waiting=co_waiting,
co_staged=co_staged,
co_pending=co is not None,
co_staged_sha=_sha(staged.get("sha256")) if isinstance(staged, dict) else None,
rejected_sha=pf.rejected_sha,
)
def appr_of(t: D.Target, f: Found, now: datetime) -> dict[str, Any]:
return {
"ok": f.ok,
"rev": f.rev,
"vrev": t.visual_rev,
"name": f.name,
"note": f.note or "",
"base": f.base,
"draft_rev": f.draft_rev,
"draft_where": f.draft_where,
"in_drafting": f.in_drafting,
"at": now.isoformat(),
}
def _same_appr(a: Any, b: dict[str, Any]) -> bool:
if not isinstance(a, dict):
return False
return {k: v for k, v in a.items() if k != "at"} == {k: v for k, v in b.items() if k != "at"}
def new_flags(flags: list[str], d: D.Decision) -> list[str]:
"""Every flag but the drawing ones, then the decision's flag."""
out = [f for f in flags if f not in DRAWING_FLAGS]
if d.flag and d.flag not in out:
out.append(d.flag)
return out
def _packet(
ctx: JobContext,
dwg: str,
found: Found,
fetched: FetchResult | None,
pid: int,
relations: set[str],
counts: Counts,
) -> None:
conn = ctx.conn
pf = facts(conn, pid)
if pf is None:
return
own = "own" in relations
t = target_for(dwg, pf, own=own)
same_file = fetched is not None and fetched.sha256 in pf.attached_shas
d = D.decide(t, found, same_file=same_file, fetched_sha=fetched.sha256 if fetched else None)
if not own and d.kind != D.STAGE_FOR_CO:
return # only the CO's target drawing: nothing to do until it can be staged
if d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO) and fetched is None:
counts["no_file"] += 1 # the copy failed this run; try again next run
return
now = datetime.now(UTC)
row, visual = pf.row, pf.visual
flags = list(row["flags"] or [])
flags_after = new_flags(flags, d) if own else flags
patch: dict[str, Any] = {}
if own:
appr = appr_of(t, found, now)
if not _same_appr(visual.get("appr"), appr):
patch["appr"] = appr
if found.ok:
confirmed = D.rev_confirmed(t, found)
if bool(visual.get("rev_confirmed")) != confirmed:
patch["rev_confirmed"] = confirmed
state_after = d.drawing_state if own else str(row["drawing_state"])
material = (
d.kind in (D.ATTACH, D.OPEN_CO, D.STAGE_FOR_CO)
or flags_after != flags
or state_after != row["drawing_state"]
or bool(patch)
)
if not material:
counts["unchanged"] += 1
return
action = A.VIS_UPDATE_DRAFT if row["status"] == "draft" else A.VIS_OPEN_CO
label, note = "Drawing status from M-Files", d.note or None
if d.kind == D.ATTACH and fetched is not None:
label = "Drawing attached from M-Files"
note = "; ".join(x for x in (fetched.name or found.name or "", d.note) if x) or None
detail = {"dwg": dwg, "rev": found.rev, "kind": d.kind, "asset_id": str(fetched.asset_id) if fetched else None}
try:
with writes.system_write(conn, pid, action, label=label, note=note, detail=detail) as sw:
if int(sw.packet["version"]) != int(row["version"]):
raise _Stale
if d.kind == D.ATTACH and fetched is not None:
_attach(sw, pid, dwg, found, fetched, d, flags_after, patch, now)
counts["attached"] += 1
else:
_status(sw, pid, state_after, flags_after, patch)
if d.flag:
fl = counts["flagged"]
fl[d.kind] = fl.get(d.kind, 0) + 1
if d.kind == D.OPEN_CO and fetched is not None:
_open_co(sw, t, dwg, found, fetched, d, counts)
elif d.kind == D.STAGE_FOR_CO and fetched is not None and pf.co is not None:
_stage(sw, pf.co, dwg, found, fetched)
counts["staged"] += 1
elif d.kind == D.UNCHANGED:
counts["unchanged"] += 1
except (_Stale, writes.Busy, writes.Missing):
counts["busy"] += 1
except writes.Refused as e:
counts["refused"] += 1
ctx.error(f"packet {pid}: {d.kind} refused: {e}", degrade=False)
def _staged(dwg: str, found: Found, fetched: FetchResult) -> dict[str, Any]:
return {
"asset_id": str(fetched.asset_id),
"name": fetched.name or found.name,
"rev": str(found.rev),
"dwg": dwg,
"sha256": fetched.sha256.hex(),
"pages": fetched.pages,
}
def _attach(
sw: Any,
pid: int,
dwg: str,
found: Found,
fetched: FetchResult,
d: D.Decision,
flags: list[str],
patch: dict[str, Any],
now: datetime,
) -> None:
conn = sw.conn
pages = max(1, int(fetched.pages))
conn.execute("DELETE FROM packet_pages WHERE packet_id = %s AND type = 'drawing'", (pid,))
n0 = pipeline.next_counter(conn, pid, "page", pages)
rev = str(found.rev)
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO packet_pages (packet_id, n, type, asset_id, pn, dwg, rev) VALUES (%s, %s, 'drawing', %s, %s,"
" %s, %s)",
[(pid, n0 + i, fetched.asset_id, i + 1, dwg, rev) for i in range(pages)],
)
vault = {
"asset_id": str(fetched.asset_id),
"name": fetched.name or found.name,
"rev": found.rev,
"note": d.note,
"at": now.isoformat(),
}
merged = {**patch, "rev_confirmed": True, "vault": vault}
conn.execute(
"UPDATE packets SET drawing_state = 'attached', flags = %s, visual = (visual - 'manual_dwg') || %s"
" WHERE id = %s",
(flags, Jsonb(merged), pid),
)
sw.changed("pages", "drawing_state", "flags", "visual")
def _status(sw: Any, pid: int, state: str, flags: list[str], patch: dict[str, Any]) -> None:
sw.conn.execute(
"UPDATE packets SET drawing_state = %s::drawing_state, flags = %s, visual = visual || %s WHERE id = %s",
(state, flags, Jsonb(patch), pid),
)
sw.changed("drawing_state", "flags", "visual")
def _open_co(sw: Any, t: D.Target, dwg: str, found: Found, fetched: FetchResult, d: D.Decision, counts: Counts) -> None:
vis = {
"from_dwg": dwg,
"to_dwg": dwg,
"dwg": dwg,
"from_rev": t.attached_rev,
"to_rev": str(found.rev),
"dwg_change": True,
"changes": [d.note],
}
res = changes.open_or_reaim(
sw,
origin="drawing",
reason=d.note,
vis=vis,
waiting=None,
staged=_staged(dwg, found, fetched),
target_fp=None,
minor=False,
)
# open_or_reaim writes the packet_log label itself
counts["cos_opened" if res.fresh else "cos_reaimed"] += 1
def _stage(sw: Any, co: dict[str, Any], dwg: str, found: Found, fetched: FetchResult) -> None:
staged = _staged(dwg, found, fetched)
sw.conn.execute(
"UPDATE change_orders SET staged = %s, waiting = NULL, row_version = row_version + 1"
" WHERE id = %s AND review = 'pending'",
(Jsonb(staged), co["id"]),
)
sw.changed("change_orders")
sw.log(f"Drawing for change order {co['co_no']} ready for Clerical", staged["name"])
def _cadence(fs: Any, now: datetime) -> int | None:
return int(fs.drawing_interval_minutes) * 60
SPEC = JobSpec(name="drawing-lookup", run=run, cadence=_cadence, pausable=True)
__all__ = ["SPEC", "new_flags", "run", "save_lookup", "sharing", "target_for"]
@@ -0,0 +1,60 @@
"""Housekeeping, nightly at 03:30 site time: prune the VISUAL change rows and gone cache rows, old job runs and
old claimed run requests. It never touches packets, the audit log or anything append-only."""
from __future__ import annotations
from datetime import datetime, time
from ...feed.config import FeedSettings
from . import Counts, JobContext, JobSpec, seconds_until
AT = time(3, 30)
CACHE_DAYS = 90
RUNS_DAYS = 180
REQUESTS_DAYS = 30
STEPS: tuple[tuple[str, str, int], ...] = (
("row_changes", "DELETE FROM visual_row_changes WHERE at < now() - make_interval(days => %s)", CACHE_DAYS),
(
"material_changes",
"DELETE FROM visual_material_changes WHERE at < now() - make_interval(days => %s)",
CACHE_DAYS,
),
(
"gone_rows",
"DELETE FROM visual_rows WHERE gone_batch IN"
" (SELECT id FROM visual_batches WHERE started_at < now() - make_interval(days => %s))",
CACHE_DAYS,
),
(
"gone_materials",
"DELETE FROM visual_materials WHERE gone_batch IN"
" (SELECT id FROM visual_batches WHERE started_at < now() - make_interval(days => %s))",
CACHE_DAYS,
),
(
"job_runs",
"DELETE FROM job_runs WHERE started_at < now() - make_interval(days => %s) AND status <> 'running'",
RUNS_DAYS,
),
(
"job_requests",
"DELETE FROM job_requests WHERE claimed_at IS NOT NULL AND claimed_at < now() - make_interval(days => %s)",
REQUESTS_DAYS,
),
)
def run(ctx: JobContext) -> Counts:
counts: Counts = {}
for name, sql, days in STEPS:
with ctx.conn.transaction():
counts[name] = ctx.conn.execute(sql, (days,)).rowcount
return counts
def cadence(_: FeedSettings, now_site: datetime) -> int:
return seconds_until(now_site, AT)
SPEC = JobSpec(name="housekeeping", run=run, cadence=cadence, pausable=False)
@@ -0,0 +1,141 @@
"""The visual-feed job: read VISUAL (or the fixture files), record the read in the cache, build the candidates
from the cache and apply them.
Per run: the feed settings; the tracked keys (every work order of a live VISUAL packet that is not filed, plus the
go-live rows Clerical pulled in); the window from today in site time; one read through the source (labor at most
every 30 minutes); `cache.write_batch`; `cache.rows_for_apply`; `candidates.build`; `apply.apply`. The run is
degraded when a read section failed or the batch is suspect.
VisualKit: one `Visual.from_env()` per run, closed after, and the stack guard checked against its settings before
the first read. Read-only: VisualKit enforces it.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from ... import guard
from ...feed import apply as feed_apply
from ...feed import cache, candidates, config
from ...feed.config import FeedSettings
from ...feed.source import FeedSource, FixtureSource, VisualKitSource
from ...feed.types import FeedData, WoKey
from . import Counts, JobContext, JobSpec
LABOR_EVERY = timedelta(minutes=30)
def tracked_keys(conn: Any) -> list[WoKey]:
"""Work orders of live VISUAL packets that are not filed, plus pulled-in go-live rows."""
rows = conn.execute(
"SELECT DISTINCT w.wo_base, w.lot, w.split, w.sub FROM packet_work_orders w JOIN packets p"
" ON p.id = w.packet_id WHERE p.removed_at IS NULL AND p.status <> 'filed' AND p.source_kind = 'visual'"
" UNION SELECT wo_base, lot, split, sub FROM visual_ignored_wos WHERE pulled_in_at IS NOT NULL"
).fetchall()
return sorted({WoKey(r[0], r[1], r[2], r[3]) for r in rows}, key=lambda k: k.as_tuple())
def resource_map(conn: Any) -> dict[str, str]:
return {r[0]: r[1] for r in conn.execute("SELECT resource, target FROM visual_resource_map")}
def _labor_due(conn: Any, fs: FeedSettings) -> bool:
if not fs.labor_tickets:
return False
last = cache.last_labor_read(conn)
return last is None or datetime.now(UTC) - last >= LABOR_EVERY
def _read(source: FeedSource, ctx: JobContext, tracked: list[WoKey], window: tuple[Any, Any], labor: bool) -> FeedData:
return source.read(
site=ctx.settings.site_id,
since=window[0],
until=window[1],
tracked=tracked,
with_materials=True,
with_orders=True,
with_labor=labor,
labor_since=None,
)
def run(ctx: JobContext) -> Counts:
conn = ctx.conn
fs = ctx.feed_settings
today = ctx.today_site()
window = config.window(fs, today)
tracked = tracked_keys(conn)
labor_due = _labor_due(conn, fs)
if ctx.settings.feed_source == "fixture":
fixture = FixtureSource(ctx.settings.fixture_dir)
describe = fixture.describe()
data = _read(fixture, ctx, tracked, window, labor_due)
else:
from visualkit import Visual
with Visual.from_env() as v:
# The stack guard against this run's own VisualKit settings, before the first read.
result = guard.check_stack(ctx.settings, v.settings)
ctx.visual_target = ctx.visual_target or result.target
vk = VisualKitSource(v)
describe = vk.describe()
data = _read(vk, ctx, tracked, window, labor_due)
for e in data.errors:
ctx.error(e)
batch = cache.write_batch(conn, ctx.run_id, data, describe, window)
if batch.suspect:
ctx.degrade(f"suspect read: {batch.reason}; nothing marked gone")
in_play = {r.key for r in data.rows} | set(tracked)
rows, mats, mat_legs = cache.rows_for_apply(conn, in_play)
if data.material_legs:
mat_legs |= data.material_legs
built = candidates.build(
rows,
mats,
mat_legs,
resource_map=resource_map(conn),
window=window,
golive=fs.golive_date,
departments=fs.departments,
tracked_keys=set(tracked),
today=today,
)
bases = {r.wo for r in rows}
projects = {candidates.project_of(b) for b in bases} | {c.project for c in built.candidates.values()}
orders = cache.orders(conn, bases=bases | projects, projects=projects)
counts = feed_apply.apply(
conn,
ctx=ctx,
batch=batch,
feed_settings=fs,
built=built,
orders=orders,
labor=cache.labor_hours(conn),
today=today,
seen={r.key: r.st for r in data.rows},
missing=() if batch.suspect else data.tracked_missing,
)
counts["wos_window"] = data.window_count
counts["wos_tracked"] = len(tracked)
counts["rows"] = len(data.rows)
counts["materials"] = len(data.materials) if data.materials is not None else 0
counts["orders"] = int(batch.counts.get("orders", 0))
counts["labor_legs"] = int(batch.counts.get("labor_legs", 0))
counts["batch_id"] = batch.id
if batch.suspect:
counts["suspect_reason"] = batch.reason
cache.finish_batch(conn, batch.id, counts)
return counts
def _cadence(fs: FeedSettings, now_site: datetime) -> int | None:
return config.interval_seconds(fs, now_site)
SPEC = JobSpec(name="visual-feed", run=run, cadence=_cadence, pausable=True, chain=("drawing-lookup",))
__all__ = ["SPEC", "resource_map", "run", "tracked_keys"]
+62
View File
@@ -0,0 +1,62 @@
"""Redaction for job errors and log lines: no password, key or token ever reaches job_runs or a log."""
from __future__ import annotations
import os
import re
MAX_ERRORS = 20
MAX_ERROR_CHARS = 500
# Environment variables whose values are secrets when set (MFilesKit reads MFILES_PASSWORD; VisualKit may read
# VISUAL_PASSWORD). They are read only to strip them from text, never logged.
SECRET_ENV = ("VISUAL_PASSWORD", "MFILES_PASSWORD", "PGPASSWORD", "LDAP_PASSWORD")
SECRET_FILE_ENV = ("VISUAL_PASSWORD_FILE", "MFILES_PASSWORD_FILE")
_PATTERNS = (
re.compile(r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key)\s*[=:]\s*(\"[^\"]*\"|'[^']*'|[^\s;,&]+)"),
re.compile(r"(?i)\bauthorization\s*:\s*[^\r\n]*"),
re.compile(r"(?i)\bx-authentication\s*:\s*[^\r\n]*"),
re.compile(r"(?i)(\w+://[^:/\s@]+:)([^@\s]+)(@)"),
)
def _secrets() -> list[str]:
out = []
for name in SECRET_ENV:
v = os.environ.get(name)
if v and len(v) >= 4:
out.append(v)
for name in SECRET_FILE_ENV:
path = os.environ.get(name)
if not path:
continue
try:
with open(path, encoding="utf-8") as f:
v = f.read().rstrip("\r\n")
except OSError:
continue
if v and len(v) >= 4:
out.append(v)
return out
def redact(text: str) -> str:
"""Strip anything that looks like a credential, plus the VISUAL and M-Files passwords if set."""
s = str(text)
for secret in _secrets():
s = s.replace(secret, "[redacted]")
s = _PATTERNS[0].sub(lambda m: f"{m.group(1)}=[redacted]", s)
s = _PATTERNS[1].sub("Authorization: [redacted]", s)
s = _PATTERNS[2].sub("X-Authentication: [redacted]", s)
s = _PATTERNS[3].sub(lambda m: f"{m.group(1)}[redacted]{m.group(3)}", s)
return s
def clip(errors: list[str]) -> list[str]:
"""At most MAX_ERRORS redacted strings of at most MAX_ERROR_CHARS each."""
out = []
for e in errors[:MAX_ERRORS]:
r = redact(e)
out.append(r if len(r) <= MAX_ERROR_CHARS else r[: MAX_ERROR_CHARS - 3] + "...")
return out
+173
View File
@@ -0,0 +1,173 @@
"""One run of one job: advisory lock, job_runs row, request claiming, the job itself, the result.
`run_job` works with a started Scheduler (the worker) or an unstarted one (the CLI, the tests): it only needs
`settings`, `jobs`, `stack`, `host`, `build`, `connect()`, `set_feed()`, `running_add()`, `running_discard()`
and `after_run()`.
"""
from __future__ import annotations
import json
import logging
import traceback
from datetime import datetime
from typing import TYPE_CHECKING, Any
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from .. import guard
from ..feed import config
from .jobs import JobContext, JobSpec
from .redaction import clip, redact
if TYPE_CHECKING:
from .scheduler import Scheduler
log = logging.getLogger("packetstation.worker.runs")
LOCK_SPACE = 7310
FEED_JOB = "visual-feed"
__all__ = ["LOCK_SPACE", "redact", "run_job"]
def _jsonable(value: Any) -> Any:
return json.loads(json.dumps(value if value is not None else {}, default=str))
def _row(conn: Any, run_id: int) -> dict[str, Any]:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute("SELECT * FROM job_runs WHERE id = %s", (run_id,)).fetchone()
return dict(row) if row else {"id": run_id}
def _insert(conn: Any, sch: Scheduler, name: str, trigger: str, *, status: str = "running", counts: Any = None) -> int:
rid = conn.execute(
"INSERT INTO job_runs (job, trigger, status, counts, build_version, host, finished_at)"
" VALUES (%s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END) RETURNING id",
(name, trigger, status, Jsonb(_jsonable(counts)), sch.build, sch.host, status != "running"),
).fetchone()[0]
return int(rid)
def _finish(conn: Any, run_id: int, *, status: str, counts: Any, errors: list[str], visual_target: str | None) -> None:
conn.execute(
"UPDATE job_runs SET status = %s, counts = %s, errors = %s, visual_target = coalesce(%s, visual_target),"
" finished_at = now() WHERE id = %s",
(status, Jsonb(_jsonable(counts)), Jsonb(clip(errors)), visual_target, run_id),
)
def _target(result: Any) -> str:
""" "<server> / <database>" from a guard result, without the (DEV) or (PRODUCTION) suffix."""
text = str(getattr(result, "target", "") or "")
return text.rsplit(" (", 1)[0] if text.endswith(")") else text
def run_job(sch: Scheduler, name: str, trigger: str) -> dict[str, Any]:
"""Run `name` once and return its job_runs row. Never raises for a job failure; raises when the database
cannot be reached at all (the caller logs it)."""
spec: JobSpec = sch.jobs[name]
s = sch.settings
conn = sch.connect()
locked = False
run_id: int | None = None
status, counts, errors = "failed", {}, [] # type: tuple[str, Any, list[str]]
visual_target: str | None = None
pending_after = False
sch.running_add(name)
try:
feed = config.load(conn)
sch.set_feed(feed)
locked = bool(conn.execute("SELECT pg_try_advisory_lock(%s, hashtext(%s))", (LOCK_SPACE, name)).fetchone()[0])
if not locked:
rid = _insert(conn, sch, name, trigger, status="skipped", counts={"reason": "already running"})
log.info("job %s skipped: already running", name)
return _row(conn, rid)
with conn.transaction():
run_id = _insert(conn, sch, name, trigger)
claimed = conn.execute(
"UPDATE job_requests SET claimed_run_id = %s, claimed_at = now()"
" WHERE job = %s AND claimed_at IS NULL RETURNING requested_by",
(run_id, name),
).fetchall()
if claimed:
trigger = "request"
who = sorted({r[0] for r in claimed if r[0] is not None}, key=str)
conn.execute("UPDATE job_runs SET trigger = 'request', requested_by = %s WHERE id = %s", (who, run_id))
if trigger == "schedule" and spec.pausable and feed.paused:
status, counts = "skipped", {"reason": "paused"}
else:
ctx = JobContext(
conn=conn,
settings=s,
feed_settings=feed,
run_id=run_id,
trigger=trigger,
started_at=datetime.now(sch.tz),
log=logging.getLogger(f"packetstation.worker.jobs.{name}"),
stack=sch.stack,
)
log.info("job %s started", name, extra={"ps": {"run_id": run_id, "trigger": trigger}})
try:
if name == FEED_JOB and s.feed_source == "visualkit":
# The stack guard again with fresh VisualKit settings: a test stack never reads production
ctx.visual_target = _target(guard.check_stack(s))
counts = spec.run(ctx) or {}
status = "degraded" if ctx.degraded else "ok"
except Exception as e:
status = "failed"
ctx.errors.append(f"{type(e).__name__}: {e}")
log.error("job %s failed: %s", name, redact(traceback.format_exc()))
errors, visual_target = ctx.errors, ctx.visual_target
try:
_finish(conn, run_id, status=status, counts=counts, errors=errors, visual_target=visual_target)
except Exception:
# The run's connection is broken: write the result on a fresh one
with sch.connect() as c2:
_finish(c2, run_id, status=status, counts=counts, errors=errors, visual_target=visual_target)
try:
pending_after = bool(
conn.execute(
"SELECT EXISTS (SELECT 1 FROM job_requests WHERE job = %s AND claimed_at IS NULL)", (name,)
).fetchone()[0]
)
row = _row(conn, run_id)
except Exception:
with sch.connect() as c2:
row = _row(c2, run_id)
log.info(
"job %s finished: %s", name, status, extra={"ps": {"run_id": run_id, "status": status, "counts": counts}}
)
return row
except Exception as e:
status = "failed"
if run_id is not None:
try:
with sch.connect() as c2:
_finish(
c2,
run_id,
status="failed",
counts=counts,
errors=[f"{type(e).__name__}: {e}"],
visual_target=visual_target,
)
except Exception as e2:
log.error("job %s: can't record the failure: %s", name, type(e2).__name__)
raise
finally:
if locked:
try:
conn.execute("SELECT pg_advisory_unlock(%s, hashtext(%s))", (LOCK_SPACE, name))
except Exception as e:
log.warning("job %s: unlock failed (%s); closing the connection releases it", name, type(e).__name__)
try:
conn.close()
finally:
sch.running_discard(name)
if run_id is not None:
sch.after_run(name, status, pending_after)
+395
View File
@@ -0,0 +1,395 @@
"""The worker's scheduler: APScheduler 3 in site time, settings read on every fire, "run now" over NOTIFY.
Each job has a SettingsTrigger whose next fire time comes from the job's cadence and the current feed settings,
so a changed interval applies on the next fire. A save NOTIFYs `ps_settings` and the listener calls
`reschedule()` so it applies at once. A "run now" NOTIFYs `ps_job_request` and the listener moves the job to now.
After a run the job's next fire is measured from the end of the run.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import socket
import threading
from collections.abc import Callable, Iterable
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.base import BaseTrigger
from .. import db
from ..feed import config
from ..feed.config import FeedSettings
from ..settings import Settings
from . import heartbeat, runs
from .jobs import JobSpec, Stack, load_jobs
from .redaction import redact
log = logging.getLogger("packetstation.worker")
CHANNELS = ("ps_settings", "ps_job_request")
IDLE_RECHECK_SECONDS = 3600 # a job with no schedule wakes this often and does nothing
BACKOFF_MAX = 30.0
THREADS = 4
MISFIRE_GRACE = 300
class SettingsTrigger(BaseTrigger):
"""Fires `seconds(now)` after now. `seconds` reads the current settings, so a change applies on the next fire.
None means "no schedule": the trigger wakes again after an hour and the fire does nothing."""
__slots__ = ("seconds",)
def __init__(self, seconds: Callable[[datetime], int | None]):
self.seconds = seconds
def get_next_fire_time(self, previous_fire_time: datetime | None, now: datetime) -> datetime:
secs = self.seconds(now)
return now + timedelta(seconds=max(1, IDLE_RECHECK_SECONDS if secs is None else int(secs)))
def __str__(self) -> str:
return "settings"
def next_due(
spec: JobSpec, feed: FeedSettings, now_site: datetime, last_end_site: datetime | None = None
) -> datetime | None:
"""When the job is next due: its cadence measured from the end of its last run (or from now when it never ran),
never before now. None when the cadence says "no schedule"."""
base = last_end_site if last_end_site is not None and last_end_site <= now_site else now_site
secs = spec.cadence(feed, base)
if secs is None:
return None
due = base + timedelta(seconds=max(1, int(secs)))
return due if due > now_site else now_site
class Scheduler:
def __init__(
self,
settings: Settings,
*,
build: str | None = None,
jobs: dict[str, JobSpec] | None = None,
connect: Callable[[], Any] | None = None,
):
self.settings = settings
self.build = build or settings.ps_build
self.jobs = dict(jobs) if jobs is not None else dict(load_jobs())
self.tz = ZoneInfo(settings.site_tz)
self._connect = connect or (lambda: db.connect_as("worker", settings, autocommit=True))
self.stack = Stack()
self.host = socket.gethostname()
self.pid = os.getpid()
self.feed = FeedSettings()
self._lock = threading.Lock()
self._running: set[str] = set()
self._next_trigger: dict[str, str] = {}
self._again: dict[str, str] = {}
self._last_end: dict[str, datetime] = {}
self._db_ready = False
self._stop = threading.Event()
self._listener: threading.Thread | None = None
self.listening = False
self.aps = BackgroundScheduler(
executors={"default": ThreadPoolExecutor(THREADS)},
job_defaults={"coalesce": True, "max_instances": 1, "misfire_grace_time": MISFIRE_GRACE},
timezone=self.tz,
)
self.started = False
# ------------------------------------------------ small helpers used by runs.run_job
def connect(self) -> Any:
return self._connect()
def now(self) -> datetime:
return datetime.now(self.tz)
def set_feed(self, feed: FeedSettings) -> None:
self.feed = feed
def running(self) -> list[str]:
with self._lock:
return sorted(self._running)
def running_add(self, name: str) -> None:
with self._lock:
self._running.add(name)
def running_discard(self, name: str) -> None:
with self._lock:
self._running.discard(name)
# ------------------------------------------------ lifecycle
def start(self) -> None:
"""Set up the database side when it is reachable, add the jobs and start the scheduler and listener."""
self._setup_db()
now = self.now()
for name, spec in self.jobs.items():
self.aps.add_job(
self._fire,
trigger=SettingsTrigger(self._seconds_for(spec)),
args=(name,),
id=name,
name=name,
next_run_time=self._next_time(spec, now),
replace_existing=True,
)
self.aps.start()
self.started = True
self._write_schedule(self.jobs)
self._stop.clear()
self._listener = threading.Thread(target=self._listen, name="ps-worker-listen", daemon=True)
self._listener.start()
log.info("scheduler started", extra={"ps": {"jobs": sorted(self.jobs), "host": self.host}})
def shutdown(self, wait: bool = True) -> None:
self._stop.set()
if self.started:
self.aps.shutdown(wait=wait)
self.started = False
if self._listener is not None:
self._listener.join(5.0)
self._listener = None
def _setup_db(self) -> bool:
"""Seed the feed settings, mark this host's leftover runs abandoned, load the settings and the last run
ends. Safe to call again; returns False when the database is not reachable."""
if self._db_ready:
return True
try:
with self.connect() as conn:
with conn.transaction():
if config.seed(conn):
log.info("feed settings seeded with the defaults")
self._abandon(conn)
self.feed = config.load(conn)
for job, end in conn.execute(
"SELECT job, max(finished_at) FROM job_runs WHERE finished_at IS NOT NULL"
" AND status <> 'skipped' GROUP BY job"
).fetchall():
if end is not None:
self._last_end[job] = end.astimezone(self.tz)
except Exception as e:
log.warning("worker database setup deferred: %s", redact(f"{type(e).__name__}: {e}"))
return False
self._db_ready = True
return True
def _abandon(self, conn: Any) -> None:
"""Runs left `running` by a dead worker become `abandoned`: this host's always, another host's when no
session holds that job's advisory lock (a container recreated with a new host name)."""
rows = conn.execute("SELECT id, job, host FROM job_runs WHERE status = 'running'").fetchall()
for rid, job, host in rows:
if host != self.host:
got = conn.execute("SELECT pg_try_advisory_lock(%s, hashtext(%s))", (runs.LOCK_SPACE, job)).fetchone()[
0
]
if not got:
continue
conn.execute("SELECT pg_advisory_unlock(%s, hashtext(%s))", (runs.LOCK_SPACE, job))
conn.execute(
"UPDATE job_runs SET status = 'abandoned', finished_at = now(),"
" errors = errors || '[\"The worker stopped during this run.\"]'::jsonb"
" WHERE id = %s AND status = 'running'",
(rid,),
)
log.warning("job run %s (%s) marked abandoned", rid, job)
# ------------------------------------------------ timing
def _seconds_for(self, spec: JobSpec) -> Callable[[datetime], int | None]:
def seconds(now: datetime) -> int | None:
return spec.cadence(self.feed, now.astimezone(self.tz))
return seconds
def _next_time(self, spec: JobSpec, now: datetime) -> datetime:
due = next_due(spec, self.feed, now, self._last_end.get(spec.name))
return due if due is not None else now + timedelta(seconds=IDLE_RECHECK_SECONDS)
def reschedule(self, names: Iterable[str] | None = None) -> None:
"""Recompute the next run of every job (or of `names`) from the current settings."""
if not self.started:
return
now = self.now()
todo = list(names) if names is not None else list(self.jobs)
for name in todo:
spec = self.jobs.get(name)
if spec is None:
continue
with contextlib.suppress(Exception): # JobLookupError during shutdown
self.aps.modify_job(name, next_run_time=self._next_time(spec, now))
self._write_schedule(todo)
def run_soon(self, name: str, trigger: str = "request") -> None:
"""Move a job to now. A job that is running now runs again right after."""
if name not in self.jobs:
log.warning("ignoring a run request for unknown job %r", name[:60])
return
with self._lock:
if name in self._running:
self._again[name] = trigger
return
self._next_trigger[name] = trigger
if self.started:
with contextlib.suppress(Exception):
self.aps.modify_job(name, next_run_time=self.now())
def after_run(self, name: str, status: str, pending: bool) -> None:
"""Called by run_job when a run ends: chain, run again for new requests, else time the next run from now."""
self._last_end[name] = self.now()
spec = self.jobs[name]
with self._lock:
again = self._again.pop(name, None)
if status in ("ok", "degraded"):
for c in spec.chain:
self.run_soon(c, "chain")
if pending or again:
self.run_soon(name, again or "request")
self._write_schedule([name])
else:
self.reschedule([name])
def _fire(self, name: str) -> None:
with self._lock:
trigger = self._next_trigger.pop(name, "schedule")
spec = self.jobs[name]
if not self._db_ready and not self._setup_db():
log.warning("job %s not run: database not reachable", name)
return
if trigger == "schedule" and spec.cadence(self.feed, self.now()) is None:
self._write_schedule([name])
return
try:
runs.run_job(self, name, trigger)
except Exception as e:
log.error("job %s could not run: %s", name, redact(f"{type(e).__name__}: {e}"))
def _write_schedule(self, names: Iterable[str]) -> None:
"""job_schedule rows for the Admin and Clerical screens. Best effort."""
if not self.started:
return
rows = []
now = self.now()
for name in names:
spec = self.jobs.get(name)
if spec is None:
continue
job = self.aps.get_job(name)
nxt = job.next_run_time if job is not None else None
secs = spec.cadence(self.feed, now)
if secs is None:
nxt = None
rows.append((name, nxt, secs, bool(spec.pausable and self.feed.paused)))
if not rows:
return
try:
with self.connect() as conn:
for r in rows:
conn.execute(
"INSERT INTO job_schedule (job, next_run_at, interval_seconds, paused, updated_at)"
" VALUES (%s, %s, %s, %s, now()) ON CONFLICT (job) DO UPDATE SET"
" next_run_at = EXCLUDED.next_run_at, interval_seconds = EXCLUDED.interval_seconds,"
" paused = EXCLUDED.paused, updated_at = now()",
r,
)
except Exception as e:
log.warning("job_schedule not written: %s", type(e).__name__)
# ------------------------------------------------ settings
def reload_settings(self, conn: Any | None = None) -> None:
try:
if conn is not None:
self.feed = config.load(conn)
else:
with self.connect() as c:
self.feed = config.load(c)
except Exception as e:
log.warning("feed settings not reloaded: %s", type(e).__name__)
return
self.reschedule()
# ------------------------------------------------ listener
def _on_connect(self, conn: Any) -> None:
"""After every (re)connect: catch up on what was missed while not listening."""
self._setup_db()
self.reload_settings(conn)
for (job,) in conn.execute("SELECT DISTINCT job FROM job_requests WHERE claimed_at IS NULL").fetchall():
self.run_soon(job, "request")
def _notified(self, channel: str, payload: str) -> None:
if channel == "ps_settings":
self.reload_settings()
return
try:
data = json.loads(payload)
except ValueError:
log.warning("ignoring a non-JSON ps_job_request payload")
return
job = data.get("job") if isinstance(data, dict) else None
if isinstance(job, str):
self.run_soon(job, "request")
def _listen(self) -> None:
delay = 1.0
while not self._stop.is_set():
conn = None
try:
conn = self.connect()
for ch in CHANNELS:
conn.execute(f"LISTEN {ch}")
self.listening = True
delay = 1.0
log.info("worker listening on %s", ", ".join(CHANNELS))
self._on_connect(conn)
while not self._stop.is_set():
for n in conn.notifies(timeout=1.0):
self._notified(n.channel, n.payload)
except Exception as e:
if not self._stop.is_set():
log.warning("worker listener: %s; retrying in %.0f s", type(e).__name__, delay)
finally:
self.listening = False
if conn is not None:
with contextlib.suppress(Exception):
conn.close()
if self._stop.wait(delay):
break
delay = min(delay * 2, BACKOFF_MAX)
# ------------------------------------------------ heartbeat loop (main thread)
def serve(self, beat_file: Path, stop: threading.Event, interval: float) -> None:
"""Beat until `stop`: the beat file and the worker_heartbeat row."""
conn = None
while not stop.is_set():
try:
heartbeat.touch(beat_file)
except OSError as e:
log.error("can't touch beat file %s: %s", beat_file, e.strerror)
try:
if conn is None or conn.closed:
conn = self.connect()
heartbeat.write_row(conn, build=self.build, host=self.host, pid=self.pid, running=self.running())
except Exception as e:
log.warning("heartbeat row not written: %s", type(e).__name__)
if conn is not None:
with contextlib.suppress(Exception):
conn.close()
conn = None
stop.wait(interval)
if conn is not None:
with contextlib.suppress(Exception):
conn.close()