Phase 3: VISUAL feed and M-Files drawings in the worker
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:
@@ -0,0 +1,113 @@
|
||||
"""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
|
||||
]
|
||||
Reference in New Issue
Block a user