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]>
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
"""Which drawings the drawing job looks up this run, in order.
|
|
|
|
Over live packets made from VISUAL that are not filed, have a drawing number, and sit in a department in scope
|
|
(feed setting `departments`; empty = every department). One item per drawing number (trimmed), the best priority
|
|
winning:
|
|
|
|
1. the VISUAL rev changed since the last lookup (`drawing_lookups.visual_rev_seen`), or a pending feed change
|
|
order waits for a drawing (the item is the CO's target drawing and rev; at most every 15 minutes);
|
|
2. drafts still needing a drawing (`drawing_state = 'needed'`, or never looked up), unless a failed fetch set a
|
|
later `next_check_at`;
|
|
3. failed lookups whose backoff ran out (`ok = false and next_check_at <= now`);
|
|
4. rechecks due (`checked_at` older than `drawing_recheck_hours`).
|
|
|
|
A packet with a hand drawing joins only through (1) or (4) once the feed has taken the hand drawing off; while
|
|
`drawing_state = 'hand'` it is skipped.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
BACKOFF_FIRST = timedelta(minutes=15)
|
|
BACKOFF_MAX = timedelta(hours=24)
|
|
CO_RECHECK = timedelta(minutes=15) # a change order's target drawing is looked up at most this often
|
|
|
|
QUEUE_SQL = """
|
|
WITH own AS (
|
|
SELECT p.id AS packet_id, btrim(p.drawing) AS dwg, coalesce(btrim(p.rev), '') AS visual_rev, NULL::bigint AS co_id,
|
|
CASE
|
|
WHEN d.dwg IS NOT NULL AND d.checked_at IS NOT NULL
|
|
AND d.visual_rev_seen IS DISTINCT FROM coalesce(btrim(p.rev), '') THEN 1
|
|
WHEN p.status = 'draft' AND (d.dwg IS NULL OR p.drawing_state = 'needed')
|
|
AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 2
|
|
WHEN d.ok = false AND (d.next_check_at IS NULL OR d.next_check_at <= %(now)s) THEN 3
|
|
WHEN d.checked_at IS NULL OR d.checked_at < %(recheck_before)s THEN 4
|
|
END AS priority,
|
|
d.checked_at
|
|
FROM packets p
|
|
LEFT JOIN drawing_lookups d ON d.dwg = btrim(p.drawing)
|
|
WHERE p.removed_at IS NULL AND p.source_kind = 'visual' AND p.status <> 'filed'
|
|
AND coalesce(btrim(p.drawing), '') <> '' AND p.drawing_state <> 'hand'
|
|
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
|
|
),
|
|
co AS (
|
|
SELECT p.id AS packet_id, btrim(c.waiting->>'dwg') AS dwg, coalesce(btrim(c.waiting->>'rev'), '') AS visual_rev,
|
|
c.id AS co_id, 1 AS priority, d.checked_at
|
|
FROM change_orders c
|
|
JOIN packets p ON p.id = c.packet_id
|
|
LEFT JOIN drawing_lookups d ON d.dwg = btrim(c.waiting->>'dwg')
|
|
WHERE c.review = 'pending' AND c.origin IN ('visual', 'drawing') AND c.waiting IS NOT NULL
|
|
AND jsonb_typeof(c.waiting) = 'object' AND coalesce(btrim(c.waiting->>'dwg'), '') <> ''
|
|
AND p.removed_at IS NULL AND p.status NOT IN ('draft', 'filed')
|
|
AND (d.checked_at IS NULL OR d.checked_at < %(co_before)s)
|
|
AND (d.ok IS NOT false OR d.next_check_at IS NULL OR d.next_check_at <= %(now)s)
|
|
AND (cardinality(%(depts)s::text[]) = 0 OR p.dept_code = ANY(%(depts)s::text[]))
|
|
),
|
|
items AS (
|
|
SELECT * FROM own WHERE priority IS NOT NULL
|
|
UNION ALL
|
|
SELECT * FROM co
|
|
),
|
|
best AS (
|
|
SELECT DISTINCT ON (dwg) packet_id, dwg, visual_rev, priority, co_id, checked_at
|
|
FROM items
|
|
ORDER BY dwg, priority, co_id NULLS LAST, packet_id
|
|
)
|
|
SELECT packet_id, dwg, visual_rev, priority, co_id
|
|
FROM best
|
|
ORDER BY priority, checked_at NULLS FIRST, dwg
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class QueueItem:
|
|
packet_id: int
|
|
dwg: str
|
|
visual_rev: str
|
|
priority: int
|
|
co_id: int | None
|
|
|
|
|
|
def backoff(fail_count: int) -> timedelta:
|
|
"""15 minutes x 2^(fail_count - 1), at most 24 hours. fail_count counts this failure (1 = first)."""
|
|
n = max(0, int(fail_count) - 1)
|
|
if n >= 10:
|
|
return BACKOFF_MAX
|
|
return min(BACKOFF_FIRST * (2**n), BACKOFF_MAX)
|
|
|
|
|
|
def params(feed_settings: Any, now: datetime) -> dict[str, Any]:
|
|
hours = int(getattr(feed_settings, "drawing_recheck_hours", 4))
|
|
depts = [str(d) for d in (getattr(feed_settings, "departments", None) or [])]
|
|
return {
|
|
"now": now,
|
|
"recheck_before": now - timedelta(hours=hours),
|
|
"co_before": now - CO_RECHECK,
|
|
"depts": depts,
|
|
}
|
|
|
|
|
|
def queue(conn: Any, feed_settings: Any, now: datetime, *, limit: int | None = None) -> list[QueueItem]:
|
|
"""The ordered lookup list. `limit` cuts it (the job passes the budget)."""
|
|
sql = QUEUE_SQL
|
|
p = params(feed_settings, now)
|
|
if limit is not None:
|
|
sql += " LIMIT %(limit)s"
|
|
p["limit"] = max(0, int(limit))
|
|
rows = conn.execute(sql, p).fetchall()
|
|
return [
|
|
QueueItem(int(r[0]), str(r[1]), str(r[2] or ""), int(r[3]), None if r[4] is None else int(r[4])) for r in rows
|
|
]
|