Skip to content

Feat/un 3445 pg queue integration#2108

Open
muhammad-ali-e wants to merge 93 commits into
mainfrom
feat/UN-3445-pg-queue-integration
Open

Feat/un 3445 pg queue integration#2108
muhammad-ali-e wants to merge 93 commits into
mainfrom
feat/UN-3445-pg-queue-integration

Conversation

@muhammad-ali-e

@muhammad-ali-e muhammad-ali-e commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

What

Introduces PG-Queue — a PostgreSQL-backed task transport that can run the entire workers pipeline as an alternative to Celery/RabbitMQ. It is pluggable and feature-flagged: with the flag off the Celery routing path is byte-for-byte unchanged. Two shared-code notes: (1) update_execution's status write adds a brief SELECT FOR UPDATE marker-safety lock so a stale in-memory PG row can't mis-route to the legacy save() and null the queue marker; (2) the executions-list failed-count reconcile (UN-3657 / #2126) is a general, display-only fix that applies to both transports by design; with it on, tasks are dispatched, orchestrated, and delivered end-to-end through Postgres.

Adds a new backend/pg_queue Django app (7 models: queue message, barrier state, orchestrator lock, batch dedup, periodic schedule, task result, orchestration claim) and a workers/queue_backend/pg_queue transport providing:

  • Indexed claim-state dequeueSELECT … FOR UPDATE SKIP LOCKED over partial indexes keyed on message state, so claiming stays scan-free under high concurrency (no visibility-timeout scan-past).
  • Renewable visibility lease — a live worker heartbeats its lease; a dead worker's message redelivers in minutes rather than after the full VT.
  • Single-leader orchestrator — an advisory-lock leader fans batches out and runs the fan-in barrier in Postgres (replacing the Celery chord).
  • Reaper — GCs orphaned orchestration claims and fast-fails stalled batches for crash-recovery.
  • Result signal backend — Redis pub/sub or DB-poll delivery, replacing the per-file result poll loop.
  • /metrics exporter — application-level queue depth, oldest-message age, stranded barriers, and reaper outcomes.

Routing is strangler-fig: tasks opt in per-task via WORKER_PG_QUEUE_ENABLED_TASKS, and each execution runs wholly on one transport.

Why

On-prem Unstract's core promise is "one datastore." PG-Queue removes RabbitMQ from the required stack for deployments that want that: durable queueing, crash-recovery, and observability all come from the Postgres the app already runs. It also gives a transport with stronger delivery/idempotency guarantees and per-execution isolation than the Celery + Redis chord path — validated at load to match or beat Celery throughput.

How

  • Transport seamqueue_backend.dispatch() selects Celery or PG per task via the is_pg_transport helper (flag pg_queue_enabled). All PG behavior lives on this branch; the Celery branch is untouched.
  • Enqueue / claim — messages land in pg_queue_message with a state column (ready/claimed); claiming flips state under SKIP LOCKED against partial indexes; the visibility lease is renewed by a live worker and reclaimed by the reaper on death.
  • Orchestration — a single leader (row-locked pg_orchestrator_lock) dispatches file batches and drives the barrier (pg_barrier_state) to fan-in; pg_batch_dedup + pg_orchestration_claim provide idempotency and double-orchestration guards.
  • Results — leaf tasks write pg_task_result; the caller is signalled via the configured result backend instead of polling.
  • Recovery — the reaper sweeps orphaned claims and fast-fails stalled batches; terminal transitions are one-way and heartbeat-gated.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

No behavior changes by default. The transport is gated behind the pg_queue_enabled flag (is_pg_transport) and the WORKER_PG_QUEUE_ENABLED_TASKS allowlist; with both unset the Celery path is byte-clean (verified). The pg_queue tables are created by migration but inert while the flag is off. All new dispatch / orchestration / idempotency code is on the PG branch only, so the existing Celery + RabbitMQ pipeline is unaffected.

Database Migrations

Yes — one squashed migration backend/pg_queue/migrations/0001_initial_squashed.py (replaces 0001–0014): creates the 7 tables, the partial claim indexes, and seeds the single-row orchestrator lock. Tables are inert while the flag is off. Deploy order: migrate before starting PG workers. Already-migrated DBs reconcile through replaces with zero SQL; fresh DBs run the squash.

Env Config

  • pg_queue_enabled — master flag for the PG transport (is_pg_transport).
  • WORKER_PG_QUEUE_ENABLED_TASKS — comma-list of leaf tasks routed to Postgres (per-task opt-in).
  • WORKER_PG_QUEUE_CONSUMER_CONCURRENCY — consumer prefork concurrency (parallel file batches).
  • WORKER_PG_QUEUE_CONNECT_RETRIES / WORKER_PG_QUEUE_CONNECT_BACKOFF — direct-connect retry.
  • PG_RESULT_SIGNAL_BACKENDredis (pub/sub) or db (poll) for result delivery.

See workers/sample.env for the full annotated list.

Relevant Docs

In-repo READMEs and runbooks under workers/ document the transport, consumer, and recovery behavior.

Related Issues or PRs

Epic UN-3445. Sub-tasks span UN-3534 … UN-3740 — transport seam, orchestrator, fan-in barrier, indexed claim-state, renewable lease, reaper / crash-recovery, result-signal backend, /metrics exporter, idempotency guards, and the migration squash. Follow-ups tracked separately: UN-3666 / UN-3667 (CI wiring for the workers suite) and UN-3673 (rollback sweep for unclaimed messages).

Dependencies Versions

No new runtime service dependency — PG-Queue reuses the existing Postgres and Redis. litellm is at 1.90.3.

Notes on Testing

  • Workers unit/contract suite: barrier substrate, dispatch, routing, reaper, result backend, schema-drift.
  • Load-tested at 800 concurrent users in a live environment: PG matches/beats Celery throughput with 0 stranded executions; native CPU-HPA autoscaling validated (executor / api-file-processing pools scaled 2 → 8).
  • UI A/B parity (flag off vs on): no regression.
  • Celery path verified byte-clean with the flag off.

🤖 Generated with Claude Code

muhammad-ali-e and others added 30 commits June 11, 2026 15:27
…ffold (#2033)

* UN-3534 [FEAT] PG Queue Phase 8a — queue-transport routing gate + scaffold

Add the Strangler-Fig routing seam that lets PG Queue (PGMQ) coexist with
Celery so task types can be migrated one at a time. Scaffold only: the PG
branch is a Celery-routing stub (no PG consumer exists yet), so this is
zero-behaviour-change by construction.

- queue_backend/routing.py: QueueBackend{CELERY,PG} + select_backend(task_name)
  reading the WORKER_PG_QUEUE_ENABLED_TASKS allow-list (default empty -> all
  Celery). Tolerant CSV parsing; never raises.
- dispatch(): consults select_backend(); PG-selected tasks are logged but
  still dispatched via Celery. The send_task call sits outside the PG branch
  so the wire is byte-identical regardless of the routing decision.
- queue_backend/pg_queue/: scaffold subpackage. PGMQ is a core transport
  substrate, so it lives in the seam beside dispatch/routing/barrier, not
  under the git-ignored plugins/ cloud overlay.
- sample.env: documents the flag (default-safe, OSS-friendly, no Flipt server).
- tests: 12 routing tests incl. the byte-identical-dispatch characterisation
  pinning the inert-scaffold invariant.

Barrier axis untouched (WORKER_BARRIER_BACKEND stays chord). Per-org routing
intentionally deferred to the rollout phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3534 [FEAT] Address Phase 8a review feedback

- test seam: drop stale WORKER_PG_QUEUE_ENABLED_ORGS reference — the org
  axis was removed, that flag never existed [must-fix]
- observability: routing log DEBUG -> INFO so a cutover survives a default
  log config; log-once per task name bounds volume. Log the configured
  allow-list once per process so a typo'd task name is eyeballable at boot
  even when it never matches a real dispatch [important]
- tests: pin the routing branch with caplog assertions (PG -> log fires,
  Celery -> no log, bounded to once) so the inert gate can't be silently
  deleted; assert allow-list logging too [important — closes test gap]
- QueueBackend: document the is-not-== discipline (StrEnum makes a typo'd
  "== cellery" a silent False) [suggestion]
- routing: drop dead _parse_allow_list(env_var) param, read the constant
  directly; one-pass strip; test imports the constant (single source of
  truth) [nits]
- pg_queue docstring: clarify plugins/ subdirs are git-ignored while the
  dir itself is tracked; soften volatile labs branch/section/filename
  references [nits]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3534 [DOCS] Note migration-coherence constraint in routing gate

Document that the per-task allow-list may only split independent/leaf
tasks across substrates. The coupled execution pipeline (async_execute_bin
-> file processing -> callback, with the barrier fan-in) must run a single
execution entirely on one transport — its migration unit is the execution,
not the task. The next phase resolves transport once at kickoff and carries
it in ExecutionContext; select_backend then honours that carried marker
over the per-task env. Until then, only leaf tasks should be enabled here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3534 [DOCS] Fix sample.env example to a leaf task (not pipeline)

async_execute_bin is the pipeline kickoff — exactly the task the coherence
note says must NOT be split per-task. Switch the example to a leaf task
(send_webhook_notification) and warn against listing coupled pipeline tasks
until ExecutionContext carries the transport choice.

Addresses Greptile review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
We don't use the pgmq project (github.com/pgmq/pgmq) — no extension, no
Python package, no copied SQL. The queue is a bespoke SKIP LOCKED schema
(see the extension-free decision on UN-3533). Rename the 5 prose spots
that called our substrate "PGMQ" to "PG Queue" so the code no longer reads
as if it depends on that project. PGMQ stays only as prior-art reference
in the decision record, not as the name of our substrate.

Docs-only; no code or behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… LOCKED dequeue (#2036)

* UN-3537 [FEAT] PG Queue Phase 9a — extension-free queue schema + SKIP LOCKED dequeue

The storage + dequeue primitive the routing seam will route to. Inert:
nothing in dispatch() calls it yet (the PG branch still routes to Celery),
so zero behaviour change even on the integration branch.

Backend (schema only — SHARED_APPS, cross-org infra, shared schema):
- new pg_queue app; 0001 is 100% makemigrations-generated (pg_queue_message
  table + dequeue index). No CREATE EXTENSION, no DB-side function — plain
  Django. managed=True model doubles as a typed read handle.

Workers (the client; first direct-DB worker capability):
- queue_backend/pg_queue: send / read / delete + QueueMessage. read() runs
  one atomic UPDATE ... FOR UPDATE SKIP LOCKED ... RETURNING (visibility-
  timeout pattern): claim+commit, process outside the txn, delete on
  success; a crash lets vt expire and the row redelivers (at-least-once,
  no double-delivery, VACUUM-safe, PgBouncer txn-pooling compatible).
- connection.py reuses the backend DB_* env -> PgBouncer in cloud, direct
  in OSS (UN-3533 decision).
- psycopg2-binary==2.9.9 (matches backend), promoted to a direct dep.

Tests: 4 unit (mocked SQL shape) + 4 integration (real Postgres) proving
roundtrip, vt-hiding, vt-expiry redelivery, and no-double-delivery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3537 [FIX] org_id empty-string default, not null=True (Django S6553)

A string-based field shouldn't have two "no data" values (NULL and "").
Use default="" for "no org" (leaf tasks) instead of null=True; regenerate
the generated 0001 accordingly. The client coerces None -> "" since the
column is now non-null.

Addresses SonarCloud S6553.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3537 [FIX] Address Phase 9a review feedback

Robustness + docstring-accuracy fixes on the PG-queue primitive:

- client: roll back on error and recover a dead connection (drop the
  cached conn on OperationalError/InterfaceError -> next call reconnects);
  add close() + context manager. One connection blip no longer wedges the
  9c consumer.
- client.read(): raise on non-positive vt_seconds/qty (vt<=0 is a silent
  double-delivery window).
- client.delete(): WARN when no row was removed (the at-least-once
  re-delivery signal was previously swallowed).
- QueueMessage: slots=True + doc that the payload is decoded JSONB (the
  dict stays mutable; frozen freezes the binding only).
- connection: parameterise create_pg_connection(env_prefix); wrap connect
  with a self-identifying error log (non-secret host/port/dbname/schema);
  drop the stale pg_queue_read reference; soften the DB_HOST default claim.
- models: fix stale docstrings describing a removed 0002 / DB-level
  defaults / pg_queue_read function.
- tests: narrow integration skip to OperationalError (don't mask
  ImportError/bugs/permission errors as a green skip); rollback before the
  teardown DELETE; integration conn delegates to
  create_pg_connection(env_prefix="TEST_DB_"); add unit coverage for
  create_pg_connection, read() validation, and error-rollback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3537 [FIX] Address Phase 9a review round 2 + SonarCloud S2068

- perf: ORDER BY (vt, msg_id) so the (queue_name, vt, msg_id) index drives
  an indexed top-N instead of sorting the whole visible backlog on each
  read() — a real regression on a deep queue.
- recovery: a failed rollback now proves the connection is dead, so a
  poisoned connection is recycled regardless of which psycopg2 error
  subclass was raised (not only Operational/Interface); also checks
  conn.closed. Closes the "one blip wedges the consumer" gap more fully.
- docs: clarify the contract — at-least-once means a message CAN be
  processed more than once after vt-expiry; SKIP LOCKED only prevents
  concurrent double-claim. "(no double-delivery)" -> "(no concurrent
  double-claim)".
- tests: cover the recovery/ownership branches (owned conn recycled on
  OperationalError and on failed rollback; injected conn never closed;
  close() owned-vs-injected).
- security (SonarCloud S2068): the create_pg_connection test mapped a
  literal "PASSWORD": "p" -> use a runtime uuid token so it isn't flagged
  as a hard-coded credential. Resolves the Security Rating C gate failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2043)

* UN-3538 [FEAT] PG Queue Phase 9b — enqueue PG-routed tasks to Postgres

When a task is opted into WORKER_PG_QUEUE_ENABLED_TASKS, dispatch() now
serialises it as a TaskPayload and enqueues to pg_queue_message instead of
Celery, returning a PgDispatchHandle (.id = msg_id). A process-singleton
PgQueueClient is reused across dispatches. The Celery path is unchanged and
the default-empty flag routes everything to Celery — zero behaviour change.

- queue_backend/pg_queue/task_payload.py: TaskPayload TypedDict + to_payload()
  — the producer<->consumer wire contract. This is the *contents* of the
  pg_queue_message.message JSONB column, distinct from the backend's
  PgQueueMessage *row* model (envelope vs payload — they nest, not duplicate).
- dispatch(): PG branch enqueues + returns; cutover log (INFO, once per task);
  .warning docstring that an opt-in requires the 9c consumer running, else the
  task enqueues but never executes.
- sample.env: same warning on the flag.
- backend pg_queue model: note that `message` holds a TaskPayload.
- tests: TestDispatchRouting (PG->enqueue, Celery->send_task), TestCutoverLog,
  to_payload shape + real-PG integration (dispatch lands a decodable row).

Leaf-only (migration-coherence) — pipeline tasks stay on Celery until
execution-level routing (9e). INERT by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3538 [FIX] Address Phase 9b review feedback

- dispatch: per-thread PG client via threading.local — a libpq connection
  isn't safe for concurrent use across threads; correct under prefork AND
  a -P threads pool (gevent would need a pool — noted, out of scope).
- dispatch: log the routing *decision* BEFORE send() so a first-dispatch
  failure (DB down / unmigrated) doesn't suppress the one announcement;
  wrap the enqueue with a logger.exception breadcrumb (a raw psycopg2.Error
  or a json.dumps TypeError on a non-serialisable arg otherwise propagate
  with no "PG-routed dispatch" context). No Celery fallback retained.
- dispatch: hoist `pg_queue = queue or _DEFAULT_PG_QUEUE` (one source).
- fairness: share a FairnessPayload TypedDict; to_dict() -> FairnessPayload;
  TaskPayload.fairness uses it instead of a loose dict[str, Any].
- pg_queue/__init__ docstring: describe 9b state (no longer "inert, rides
  Celery"); sample.env: drop a duplicated routes-to-Celery sentence.
- tests: no-fallback-on-enqueue-failure (Critical gap) + log-ordering pin;
  lazy-init + reuse of the per-thread client; cutover-log assertions
  updated for the new message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ack) (#2045)

* UN-3539 [FEAT] PG Queue Phase 9c — consumer poll loop (claim → run → ack)

PgQueueConsumer drains pg_queue_message and runs each claimed task
in-process: poll_once() claims a batch (SKIP LOCKED + vt via
PgQueueClient.read), runs it via current_app.tasks[name].apply(throw=True),
and acks by deleting on success. Task failure -> leave the row (vt expiry
redelivers, at-least-once); unknown task -> drop + error (no poison loop).
run() adds an empty-queue backoff loop + SIGTERM/SIGINT graceful stop;
main() is a `python -m` entrypoint (env-configured).

Completes the leaf-first end-to-end path: 8a route -> 9b enqueue -> 9a
store -> 9c consume+run. Validated live on the dev stack: a real
send_webhook_notification routed to PG was claimed by the consumer, POSTed
to Slack (HTTP 200), and acked (row removed).

Deployment note: the consumer PROCESS must bootstrap the worker app (import
the task modules) so current_app.tasks resolves the task — an
entrypoint/rollout concern, not consumer logic. Documented in the module
docstring; the rollout phase wires a consumer container that boots the app.

Tests: 5 unit (run+ack, fail->no-ack, unknown->drop, empty, graceful stop)
+ 1 real-PG integration (enqueue -> poll -> execute -> ack).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3539 [FIX] Dedup PG integration test fixtures into conftest (SonarCloud)

The connect-to-dev-DB + skip-if-unreachable/unmigrated block was copy-pasted
across test_pg_queue_client / test_dispatch_pg / test_pg_queue_consumer
(6.3% duplication on new code, over the 3% gate). Extracted into shared
pg_conn / pg_client fixtures + an integration_pg_conn() helper in
tests/conftest.py; the three files now use them. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3539 [FIX] Address Phase 9c review feedback

- fairness header rebuilt from the payload on the PG run path (was dropped)
  — a PG-routed run now mirrors the Celery dispatch contract.
- poison-message guard: surface read_ct (QueueMessage + dequeue RETURNING),
  drop a task that keeps failing past max_attempts (default 5, env
  WORKER_PG_QUEUE_CONSUMER_MAX_ATTEMPTS) with a loud ERROR carrying the
  payload, instead of redelivering forever.
- malformed payload (missing task_name) -> distinct "missing task_name"
  drop log, not a misleading "unknown task None".
- run() wraps poll_once() so a transient read/DB blip backs off and
  continues instead of tearing down the loop (the client self-recovers).
- ack: WARN when delete() finds no row (task exceeded vt -> possible
  double-run).
- __init__ validates positive tuning params; main() wires backoff_max +
  max_attempts via env (prefix helper); non-main-thread signal-install
  failure -> WARNING.
- tests: poison drop, missing task_name, fairness-header propagation,
  multi-message batch, ack-no-row warn, construction validation, backoff
  growth/reset, poll-error resilience; fixed the read mock for read_ct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3539 [FIX] Address Greptile review on the 9c consumer

- [P1] batch shared-vt window: default batch_size 10 -> 1 so each message
  gets its own visibility window. The batch's vt is set atomically at claim
  but messages run sequentially, so with batch_size>1 the tail could exceed
  vt and be re-claimed mid-run (double-run). Batching stays opt-in; doc the
  vt > batch x worst-case-duration constraint.
- [P2] QueueMessage.read_ct: drop the misleading =0 default (a "never
  claimed" state the dequeue can't produce — read_ct is always >=1). 0 would
  silently bypass the poison guard; now required, all callers supply it.
- [P2] __init__: reject backoff_max < poll_interval (else min(poll*2, max)
  shrinks the backoff below poll_interval instead of growing).
- [P2] dedup the ack-miss warning: client.delete() now logs at DEBUG; the
  consumer keeps the contextual WARNING (it names the task).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…guard (#2047)

* UN-3541 [FEAT] Wire PG-queue consumer into run-worker.sh + bootstrap guard

Make the 9c PG-queue consumer (UN-3539) runnable via the normal worker
flow, safely. Split from #2045 to keep that PR focused on consumer logic.

- Launcher (pg_queue_consumer/__main__.py): set WORKER_TYPE to the source
  worker (default notification) BEFORE `import worker`, so the right tasks
  register. worker.py loads exactly one worker type's tasks; a bare import
  would load the general worker's and drop every notification as unknown.
- run-worker.sh: `pg-queue-consumer` type runs `python -m pg_queue_consumer`
  (not a celery command) from the workers root; queue via env.
- Startup guard: PgQueueConsumer.run() refuses to start on an empty task
  registry — fail loud instead of silently dropping every message.
- Drop the hard-coded 8086 health port (consumer runs no health server).

Integration fixes found during live dev-test (real send_webhook_notification
end-to-end → Slack HTTP 200):
- Opt-in status: consumer is not part of `all`; shown only when running.
- Log path: detach writes to an absolute $worker_dir/$type.log so -L/-C
  find it (also fixes the same latent bug for pluggable workers).
- PID discovery: get_worker_pids matches the `python -m` invocation, so
  --status / -k / -r work for the consumer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3541 [FIX] Address PR #2047 review feedback

- run-worker.sh: verify liveness after a detached launch (kill -0 + tail);
  `set -e` doesn't apply to `&`, so a fork that died on startup was reported
  as "started" — acute for the health-port-less consumer (High).
- consumer.py: log the registered application task names at startup so a
  *wrong* (non-empty but mismatched) registry is diagnosable — the guard only
  catches an *empty* one (Medium observability).
- consumer.py: type _env() with a TypeVar (was `cast: type -> object`,
  erasing types at the typed __init__) and name the offending var on a bad
  cast instead of a context-free ValueError (Medium).
- tests: add the guard's positive / require_tasks=False bypass / built-in
  filter arms (only the failure arm was covered).
- get_worker_pids: warn on pgrep rc>1 (operational/regex error) instead of
  collapsing it to "not running" (Low).
- run-worker.sh: extract the repeated "pg_queue_consumer" literal into a
  readonly constant (SonarCloud S1192).
- Comment accuracy: build_celery_app configures but does not import tasks;
  `notification` is the worker that owns the leaf task, not the task itself;
  generalise the "every notification dropped" example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3541 [FIX] Address Greptile review feedback

- __main__.py: move the WORKER_TYPE mutation + `import worker` bootstrap into
  a guarded _bootstrap_and_run() called only under `__name__ == "__main__"`,
  so an accidental import (test/IDE/type-checker walking __main__) no longer
  overwrites WORKER_TYPE or triggers a full worker-app bootstrap.
- run-worker.sh: list `pg-queue-consumer` in the usage/--help worker types,
  plus a note for its WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE / _QUEUE overrides.
- run-worker.sh: clarify the post-launch liveness check is a best-effort
  fast-fail for *immediate* (sub-second) crash-on-import/bad-config faults,
  not a connectivity check; kept general (an immediate crash can hit any
  worker) and noted the `all` subshells overlap the 1s with inter-launch sleep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…at) (#2051)

* UN-3544 [FEAT] PG-queue consumer liveness endpoint (poll-loop heartbeat)

Give the consumer a /health HTTP endpoint for K8s liveness probing, like
every other worker — but keyed only on a poll-loop heartbeat.

- consumer.py: track _last_poll_monotonic (refreshed at the top of poll_once,
  so a loop wedged on a long task goes stale and is detectable — which
  pgrep-based --status and the launch-liveness check cannot see). Expose
  seconds_since_last_poll() / is_poll_stale(). main() starts a LivenessServer
  when WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT is set (opt-in), stops it on exit.
- LivenessServer: tiny stdlib HTTP server (/health, /healthz, /livez) → 200
  while fresh, 503 once stale. Deliberately lean: a liveness probe must report
  only "is this process making progress?", NOT broker/API reachability or
  resource pressure (those would crash-loop a healthy consumer on a blip).
  So it does NOT reuse the shared HealthChecker (which also bundles an
  api_connectivity check that is both wrong for liveness and currently broken
  — its `from .api_client_singleton` import points at a non-existent module;
  tracked separately).
- run-worker.sh: default port 8090 (outside the 8080-8089 core range, no
  collision), exported opt-in; documented in --help.
- tests: heartbeat fresh/stale + a real bind-and-GET 200->503 endpoint test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3544 [FIX] Address PR #2051 review feedback

Important:
- Liveness bind failure (OSError/port-in-use) now degrades gracefully: log
  and continue probe-less instead of aborting the consumer before it polls.
  Verified live (2nd consumer on a taken :8090 keeps draining).
- run-worker.sh: 8090 collided with the first auto-discovered pluggable worker
  (8090 + count); pluggable discovery now starts at 8091, 8090 reserved.
- LivenessServer.stop() is defensive (can't raise into main()'s finally and
  mask the real run() exception) and warns if the thread outlives the join.
- Empty WORKER_PG_QUEUE_CONSUMER_HEALTH_PORT now hits the clean opt-out
  (_env treats "" as unset) instead of int("") crashing at launch.

Suggestions:
- LivenessServer: guard double start(); reset state in stop(); wrap
  serve_forever so a thread crash is logged; route handler errors to the
  logger (log_message=pass was hiding log_error too); guard wfile.write
  against client disconnects; single clock read per request.
- Type _httpd/_thread as HTTPServer|None / Thread|None via TYPE_CHECKING
  (drops the Any import); restores static checking.
- run-worker.sh: status line shows the effective -p override, not the map default.
- Docstrings: phrase the helper trigger in terms of `port`; note 0.0.0.0 bind;
  document that a fast-failing loop stays healthy by design (liveness must not
  couple to backend reachability).
- tests: heartbeat-stamped-before-read (pins top-of-poll), /healthz + /livez
  aliases + unknown-path 404, double-start rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3544 [FIX] Address SonarCloud issues

- consumer.py: use logger.exception() in the liveness-bind except block
  (preserves the traceback; S6679).
- test: lift the walrus assignment out of the PgQueueConsumer() argument
  list — plain `client = MagicMock()` first (clearer; S6328).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3544 [FIX] Address Greptile review feedback

- LivenessServer handler strips the query string before matching paths
  (self.path includes it), so /health?probe=k8s returns 200 not 404. Added
  a query-string case to the alias test.
- Document the stale-threshold trade-off prominently: the heartbeat is frozen
  during task execution, so WORKER_PG_QUEUE_CONSUMER_HEALTH_STALE_SECONDS is
  also an upper bound on single-task wall-clock (a longer task trips the probe
  → restart → redelivery). 60s suits the sub-second leaf; raise it above
  max(batch_size x worst_case_task_seconds, backoff_max) for longer tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…laim (#2052)

* UN-3546 [FEAT] Priority-ordered PG-queue dequeue + concurrency-safe claim

Start enforcing the load-independent part of fairness — pipeline_priority
(L3) — directly in the single-table dequeue: higher priority is claimed
first, FIFO (msg_id) within a priority. The org-tier (L1) / workload (L2)
axes + burst_max admission stay deferred to the fair-admission orchestrator.

- Schema: add `priority` (smallint, default 5 = FairnessKey.DEFAULT_PRIORITY)
  to pg_queue_message; swap the dequeue index to (queue_name, priority DESC,
  msg_id) so the priority-ordered claim stays an indexed top-N.
- Enqueue: dispatch() writes priority from fairness.pipeline_priority; a bare
  dispatch (fairness=None) writes the neutral default.
- Dequeue: ORDER BY priority DESC, msg_id.

Also fixes a latent concurrency bug in the original dequeue (9a):
`UPDATE ... WHERE msg_id IN (SELECT ... FOR UPDATE SKIP LOCKED LIMIT n)` can
OVER-CLAIM under concurrent writers — EvalPlanQual re-evaluates the LIMIT
subquery when a row it tried to lock was concurrently touched, so one claim
can return more than n rows. Switched to the canonical PGMQ-safe shape: lock
candidates in a CTE, then `UPDATE ... FROM locked WHERE q.msg_id = locked.msg_id`,
which locks exactly n rows once. The trailing SELECT re-orders RETURNING (which
is otherwise unspecified) so batched claims come back in priority order too.

Tests: priority selection (one-at-a-time) + batch ordering against real
Postgres; send writes priority; dispatch wiring (fairness + neutral default);
read param order. Verified live end-to-end via dispatch()->claim-order (9>7>3a>3b>1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3546 [FIX] Address PR #2052 review feedback

- Validate priority at the write boundary: client.send() raises ValueError on
  out-of-range (mirrors its vt_seconds/qty guards) — an out-of-range value
  would silently jump/sink the row in the priority DESC claim order.
- Add a DB CheckConstraint (priority 1..10) as the backstop no ORM/raw writer
  can bypass (migration 0003). check= (not condition=) — repo is on Django 4.2.
- Soften the "indexed top-N" comments (client.py + models.py): the dequeue is
  an index walk with vt<=now() as a per-row filter, NOT a guaranteed top-N —
  vt is not in the index, so in-flight (future-vt) high-priority rows are
  scanned past on each claim; the orchestrator's admission is the high-backlog
  answer. Update the module docstring to the CTE FROM-join shape; fix the
  fairness.DEFAULT_PRIORITY symbol reference; drop the duplicated param-order
  comment.
- Tests: send() range-guard (parametrized) + DB CheckConstraint backstop;
  concurrent-writer over-claim guard (two readers, no batch exceeds qty —
  the regression test for the EvalPlanQual fix); vt × priority (visible low
  beats invisible high); FIFO-within-band for multi-member batch bands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3546 [FIX] Address Greptile review feedback

- Concurrency test: assert the drain worker terminated after join (a hung
  worker now fails the test instead of passing silently while conn_b.close()
  races its in-flight queries).
- Priority-bounds drift guard: backend models.py and workers fairness.py are
  separate codebases that can't import each other, so the DB constraint bounds
  (1/10) duplicate fairness.MIN/MAX_PRIORITY. Replaced the hardcoded "42" reject
  test with test_db_check_constraint_matches_fairness_bounds — raw-inserts at
  MIN/MAX (accepted) and MIN-1/MAX+1 (CheckViolation), pinning the DB constraint
  to the fairness range so a future widening that misses one side fails loudly.
  Documented the canonical source in the model comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3546 [DOCS] Note check=/condition= handling on the constraint migration

Breadcrumb for a future Django upgrade: `check=` is correct on the pinned
Django 4.2 (deprecated 5.1, removed 6.0); fresh installs always replay under
the shipped Django, so leave it. When the pin reaches >= 6.0, squash (or do the
behaviour-preserving check= -> condition= edit) so a from-scratch migrate runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…R_BACKEND) (#2053)

* UN-3548 [FEAT] PgBarrier — Postgres fan-in barrier (3rd WORKER_BARRIER_BACKEND)

Add a Postgres Barrier substrate selected by WORKER_BARRIER_BACKEND=pg (default
stays chord). Moves the fan-in aggregation ("wait for N header tasks, then fire
the callback with their results") onto a pg_barrier_state row — the same DB that
holds the PG queue, so an execution can coordinate without Redis/RabbitMQ. The
9e pipeline on-ramp primitive.

Mirrors RedisDecrBarrier 1:1 — same Barrier protocol, fairness plumbing,
Celery-dispatched header tasks with .link/.link_error, empty->None,
missing-execution_id->raise, mid-loop dispatch cleanup. Defaults-off, zero
behaviour change until the flag flips.

- Schema (backend/pg_queue): pg_barrier_state (execution_id PK, remaining,
  results jsonb, aborted, expires_at) + migration 0004.
- pg_barrier.py: PgBarrier + barrier_pg_decr_and_check / barrier_pg_abort.
  Atomic decrement is ONE statement (UPDATE ... SET remaining = remaining-1,
  results = results || jsonb_build_array(%s) ... RETURNING remaining, results,
  aborted) — row lock serialises concurrent decrements so exactly one sees 0;
  no Lua. Guards: reads aborted in the same statement (never fires partial),
  row-missing / negative-remaining clean up without firing, callback dispatched
  BEFORE row delete. Orphan bound via expires_at + opportunistic sweep in
  enqueue (periodic sweep is the backstop).
- __init__.py: BarrierBackend.PG -> PgBarrier() in get_barrier().

Tests: protocol shape, TTL env validation, enqueue (upsert/links/fairness/
stale-reset/expiry-sweep/mid-loop-cleanup), decr paths (pending/complete-fires/
aborted/negative/missing/unserialisable), abort (claim+delete/dedup), and a real
two-connection decrement-atomicity check (exactly one sees 0). Selector PG case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3548 [FIX] Address PR #2053 review feedback

High:
- Abort is now ONE atomic statement: `WITH claimed AS (DELETE ... RETURNING) ...`
  — claim+teardown in a single transaction (no claimed-but-not-deleted window;
  a crash rolls back so a sibling retries). This makes the `aborted` column
  redundant — dropped it; the decrement's "row missing -> abandoned" branch now
  covers the failed-task case. The callback can only fire when remaining hits 0
  (all tasks succeeded), so a failed task (which deletes the row) can never let a
  partial-results fire.
- Dropped the per-enqueue global orphan sweep (unbounded DELETE on the hot path,
  deadlock-prone, shared the UPSERT txn). Reclaim is a future periodic sweep.
- A NUL byte survives json.dumps but jsonb rejects it -> catch the DataError and
  tear the barrier down (fail fast) instead of hanging to expiry.

Medium:
- Post-dispatch row delete is best-effort (logged, not raised) so a delete error
  can't mask the already-fired callback; documented the no-double-fire invariant
  (last decrement + max_retries=0).
- Added a DB CheckConstraint (expires_at > created_at) — the one writer-proof
  invariant; Meta comment warns off a `remaining >= 0` check (teardown needs
  negative). Softened the "periodic sweep" comments to future/not-yet-shipped.

Low:
- Extracted shared `barrier_ttl_seconds()` + `CallbackDescriptor` into barrier.py;
  both backends import them (redis keeps back-compat aliases). signature_kwargs
  dict instead of inline ** spread. Atomicity comment notes the per-transaction
  premise.

Tests: callback-dispatch-failure-preserves-row; decrement-after-abort-no-fire;
atomicity through barrier_pg_decr_and_check (two threads, exactly one fires);
list-result-as-single-element; NUL-byte teardown; DB-constraint; max_retries=0.
92 barrier tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-3548 [DOCS] Drop stale aborted-column reference in PgBarrier docstring

The wire-model docstring's enqueue step still listed `aborted = false` as an
UPSERT column after the column was removed (abort now dedups via DELETE …
RETURNING / row existence). Remove it so a reader doesn't hunt for — or
re-add — a column that no longer exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tor_lock) (#2056)

* UN-3553 [FEAT] PG Queue 9d slice 1 — leader-election lease (orchestrator_lock)

First slice of 9d (orchestrator/reaper): the singleton-guarantee primitive
the reaper loop and a future fair-admission gate hang off. Ships dark —
nothing acquires leadership yet, so merging changes no runtime behaviour.

Why now: 9d was skipped in the merged spine (9c -> liveness -> priority ->
PgBarrier) and is the safety net 9e needs — without a reaper, every
at-least-once hang / orphaned barrier bottoms out at the 6h TTL with no
recovery. The reaper must run as exactly one instance, so leader election is
the foundation.

Lease, not advisory lock: leadership is a TTL'd row UPDATE (take it if the
leader is free or its lease is stale), not pg_advisory_lock. Session-scoped
advisory locks don't survive the transaction-pooled PgBouncer the queue
connects through (UN-3533) — a plain UPDATE is one transaction, pooling-safe.
All time comparisons are server-side (now()), so candidate clock skew can't
split leadership.

- backend/pg_queue: PgOrchestratorLock single-row model (id PK, leader,
  acquired_at) + CheckConstraint(id=1); generated migration 0005 + a
  reversible RunPython seeding the one free row. Free = empty leader
  (follows the PgQueueMessage.org_id no-nullable-text convention).
- workers/queue_backend/pg_queue/leader_election.py: LeaderLease
  (try_acquire/renew/release), lease_seconds_from_env() (default 10s,
  loud-on-misconfig), default_worker_id(). Instance-owned self-recovering
  connection.
- tests: 20 real-PG tests. Load-bearing properties — concurrent try_acquire
  yields exactly one winner; renew returns False after a stale-lease
  takeover (the signal that stops a stalled leader). Plus lease-expiry
  takeover, release-frees-immediately, non-holder no-ops, env validation,
  single-row constraint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3553 [FEAT] Address review: connection ownership, logging, recovery tests

Toolkit + SonarCloud review on #2056:

- [High] _owns_conn ownership guard: LeaderLease now mirrors PgQueueClient —
  an injected connection is never closed/swapped on a transient error (it
  would otherwise silently re-point an injected TEST_DB_/caller connection at
  a fresh DB_-env one). _get_conn only recreates an OWNED missing/closed conn.
- [Medium] Log the owned-connection discard in _cursor (worker_id + exc type)
  — a silent rebuild on the reaper singleton correlates with missed renews.
- [Medium] Test the recovery machinery: owned-conn recovered on
  OperationalError, owned-conn recovered when rollback fails, injected-conn
  never swapped. Plus two documented-invariant gaps — same-holder re-acquire
  on a fresh lease returns False, and release after a takeover is a no-op.
- [Low] release() branches on rowcount — only logs "released" when it really
  freed the lease; a no-op release logs debug (truthful post-mortems).
- [Low] Scope the lease_seconds<=0 guard to the explicit-arg branch (dead on
  the env path, which already rejects <=0).
- [Low] Document the exception-propagation contract (raise == "leadership
  unknown, stop acting"), relabel the durable Usage example.
- [Low] Migration: note the seed row is load-bearing (future reaper-bootstrap
  should self-heal with INSERT ... ON CONFLICT DO NOTHING).
- SonarCloud S117: rename the migration's get_model local to lock_model.

25 leader-election tests pass; makemigrations --check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3553 [FEAT] Address Greptile: idempotent worker id + clearer renew log

- default_worker_id() is now cached (functools.cache) → idempotent per
  process, so a caller passing it inline in a retry/restart loop can't drift
  the worker id out from under renew()/release(). Lazy (first-call), so it's
  fixed after a fork rather than shared across children. Test asserts
  idempotency.
- renew()'s failure warning now reads "not the current leader (taken over by
  another candidate, or the lease was never held)" — accurate for the
  non-holder-renew case too, and fixes the "took over"->"taken over" grammar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…sweep (#2058)

* UN-3554 [FEAT] PG Queue 9d slice 2 — reaper process + barrier-orphan sweep

Builds on the leader-election lease (UN-3553): stands up the reaper process —
the leader-elected recovery loop — with its first recovery job, the
barrier-orphan sweep. Ships dark (launched explicitly, never in the default
worker set).

- reaper.py:
  - sweep_expired_barriers(conn): DELETE pg_barrier_state WHERE expires_at <
    now() RETURNING + loud per-orphan WARNING. The documented PgBarrier
    backstop — reclaims barriers whose header tasks never all completed; a
    late in-flight decrement then finds no row and abandons (existing
    semantics). Execution terminal-status recovery is 9e's job.
  - PgReaper: leader-elected loop. Each cycle renews (steps down to standby if
    renew() returns False), else tries to acquire; sweeps ONLY while leader.
    run() loops with graceful SIGTERM/SIGINT shutdown + lease release on exit.
    Guard: cycle interval must be shorter than the lease window, or the leader
    thrashes leadership between renews.
  - reaper_interval_from_env() (WORKER_PG_REAPER_INTERVAL_SECONDS, default 5s),
    main(), python -m queue_backend.pg_queue.reaper entrypoint.
- leader_election.py: expose lease_seconds property (reaper validates its
  cycle against it).
- test_pg_reaper.py: 15 tests — env/construction guards (interval < lease),
  leadership gating (sweeps only when leader, steps down on renew-fail,
  releases on stop), real-PG sweep (reclaims only expired, leaves fresh).

Out of scope: run-worker.sh wiring + liveness (followup, like 9c-followup);
pipeline recovery (counter reconstruction, per-stage re-enqueue) deferred to
9e where there's a real PG pipeline to test against.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3554 [FEAT] Address review: sweep rollback, conn recovery, tick contract

Toolkit + SonarCloud review on #2058:

- [CRITICAL] sweep_expired_barriers now rolls back on error before re-raising
  (conn is manual-commit; an un-rolled-back failure left it in an aborted-txn
  state, poisoning every later cycle → silent self-perpetuating stall). This
  also clears SonarCloud's C-reliability gate.
- [HIGH] On a failed sweep PgReaper discards its OWNED connection so the next
  tick reconnects — covers a poisoned/dead handle that `.closed` alone misses.
- [MEDIUM] renew() raising now sets _is_leader=False before propagating
  (honours the lease's "raise == stop acting" contract).
- [MEDIUM] release() failure on shutdown is logged (with the lease-window
  note) instead of silently suppressed.
- [MEDIUM] signal-handler ValueError is re-raised unless we're off the main
  thread (don't mislabel an unrelated ValueError).
- [MEDIUM/type-design] tick() returns a TickOutcome(was_leader, reclaimed)
  NamedTuple instead of an overloaded `-1` int sentinel; added an is_leader
  property; lease param typed against a new LeaderLeaseLike Protocol.
- [LOW] Reworded the sweep race comment, the step-down log (same-cycle
  re-acquire), and the run() self-recovery comment for accuracy.
- Tests: +8 — run() swallows a tick error; owned-conn recreated-when-closed;
  injected-conn never swapped; failed-sweep discards owned conn; sweep SQL
  contract (no DB); sweep rolls back on error; step-down-then-reacquire;
  renew-raising steps down. 23 total; drive paths via is_leader, no
  private-flag poking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3554 [FIX] Use pytest.approx for float-equality asserts (SonarCloud S1244)

The two reaper-interval asserts compared float returns with ==; the values
are exactly representable so it was harmless, but pytest.approx is the correct
idiom and clears the S1244 reliability bugs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3554 [FEAT] Address Greptile: manual-commit Layer-4 fixture, close owned conn

- The real-Postgres fixture (barrier_conn) was autocommit, which made
  sweep_expired_barriers' own commit() a no-op and its rollback unreachable —
  so Layer 4 tested a different mode than the production reaper
  (create_pg_connection is manual-commit). Switched the fixture to manual-commit
  and added explicit commits to the seed/read/cleanup helpers, so the real-DB
  tests now exercise the sweep's commit (and rollback) in production mode.
- run() now closes its OWNED sweep connection on shutdown (an injected one is
  the caller's). Harmless for the main() process but keeps PgReaper clean if
  ever embedded / test-driven.

23 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…pe + pg-queue set (#2059)

* UN-3555 [FEAT] PG Queue 9d slice-2 followup — run-worker.sh reaper type + pg-queue set

Wires the reaper (UN-3554) into run-worker.sh and adds a pg-queue set so the
whole PG-queue group launches in one shot. Mirrors the 9c -> 9c-followup split
(launcher wiring as its own slice). Liveness probe is a separate follow-on.

- workers/pg_queue_reaper/: thin entrypoint package (python -m pg_queue_reaper
  -> queue_backend.pg_queue.reaper.main). No worker-app bootstrap (the reaper
  runs no Celery tasks), unlike pg_queue_consumer; exists so the process has a
  stable name run-worker.sh can launch + pgrep-match.
- run-worker.sh:
  - reaper / pg-queue-reaper type — opt-in (NOT in `all`), launches
    `python -m pg_queue_reaper`, runs from workers root, --status/-k/-r match
    via the `-m` pgrep branch (now covers consumer + reaper). Lease/interval env
    documented in --help.
  - pg / pg-queue SET — run_pg_queue_set launches consumer + reaper together
    (always detached, like `all`). Restart (-r pg-queue) kills both members then
    relaunches. list_core_worker_dirs skips the set alias (no phantom status
    entry). Help documents the Celery `all` set and the PG `pg-queue` set as
    independent, runnable in parallel for a dual-transport (strangler-fig) setup.

Dev-tested live: `run-worker.sh reaper -d` acquires leadership + ticks and shows
RUNNING in --status; `run-worker.sh pg-queue` brings up consumer + reaper, both
RUNNING, reaper leader, no phantom set entry; bash -n clean; --help renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3555 [FEAT] Address review: set start-failure propagation, restart-kill guard

Toolkit review on #2059:

- [High] run_pg_queue_set swallowed member start-failures (backgrounded
  subshells' status was lost, banner+return 0 unconditional). Now each member
  runs in a FOREGROUND subshell so run_worker's own `return 1` on a
  crash-on-start is captured; the set returns non-zero if any member fails.
  (The reviewer's kill -0 on `$!` would false-fail — that PID is the launcher
  subshell, which exits the instant it backgrounds the nohup'd worker; the
  foreground-subshell return value is the correct signal and isolates `cd`.)
  Documented that the set always runs detached (ignores -d).
- [Medium] Dispatch now `|| exit 1` so a member start-failure reaches the
  script exit code — the only programmatic startup signal (reaper has no
  health port yet).
- [Medium] Set-restart aggregates kill_one_worker failures and aborts the
  relaunch if a member survives SIGKILL (avoids a duplicate consumer
  double-polling Postgres). Mirrors kill_workers' discipline.
- [Medium/minor] Startup banner prints `Queues: n/a` when empty (reaper).
- [Low] Reworded pg_queue_reaper/__main__ docstring: the launcher DOES export
  WORKER_TYPE for every worker; the accurate claim is the reaper neither reads
  nor mutates it (vs the consumer overwriting it before `import worker`).
- [Low] Added a smoke test that pg_queue_reaper.__main__ re-exports the real
  reaper main (guards the `python -m pg_queue_reaper` launch path against an
  ImportError regression).

Dev-tested: `run-worker.sh pg-queue` returns exit 0 with both members up;
24 reaper tests pass; bash -n + ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3555 [FEAT] Address Greptile: set partial-start teardown + --logs set alias

- run_pg_queue_set: on a partial start-failure (one member up, the other
  crashed) tear the whole set down before returning 1 — kill both members so a
  restart-on-failure relaunch can't spawn a second instance over the survivor
  (the consumer would double-poll Postgres). All-or-nothing, mirroring the
  restart path's discipline.
- tail_logs: handle the pg/pg-queue set alias — `--logs pg-queue` now tails
  both member logs (pg_queue_consumer + pg_queue_reaper) instead of looking for
  a non-existent workers/pg-queue/pg-queue.log and printing a misleading
  "no log file" error. Mirrors how list_core_worker_dirs skips the set value.

Dev-tested: `--logs pg-queue` tails 2 files (consumer + reaper); bash -n clean;
24 reaper tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ader) (#2061)

* UN-3556 [FEAT] PG Queue 9d — reaper liveness probe (heartbeat + is_leader)

Closes the gap flagged in #2059's review: a reaper that crashes after startup
was invisible (opt-in skip in --status + no health port). Mirrors the
consumer's liveness (UN-3544).

- PgReaper heartbeat: _last_tick_monotonic stamped at the START of every tick
  (a standby tick counts as progress — liveness tracks the loop, not
  leadership) + seconds_since_last_tick() / is_tick_stale().
- ReaperLivenessServer: lean HTTP probe (mirrors the consumer's LivenessServer)
  — /health (also /healthz, /livez) returns 200 while the tick loop is fresh,
  503 when stale. Payload also surfaces is_leader (which pod holds the lease —
  useful for 9e debugging). The 200/503 verdict is PURELY the heartbeat, never
  leadership (a standby is healthy) or DB reachability (a blip must not
  crash-loop a fine process).
- main() wires it from WORKER_PG_REAPER_HEALTH_PORT (unset → no server, no
  stray port); staleness window from WORKER_PG_REAPER_HEALTH_STALE_SECONDS
  (default 30s, comfortably above the 5s tick interval). Bind failure degrades
  gracefully (logs, runs probe-less).
- run-worker.sh: reserve port 8086 for the reaper, export the health-port env
  in the reaper special-case, document the two new env vars in --help.
- Tests (+13, 37 total): heartbeat fresh/stale + tick refresh; liveness server
  200-fresh / 503-stale / is_leader reflected / 404 / double-start; health
  staleness env default+override+invalid; server-disabled-when-no-port.

Dev-tested live: `run-worker.sh reaper -d` → GET :8086/health →
{"status":"healthy","check":"pg_reaper_tick","is_leader":true,...}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3556 [REFACTOR] Extract shared LivenessServer (SonarCloud duplication)

SonarCloud flagged the new ReaperLivenessServer as duplicating the consumer's
LivenessServer (~19 lines of HTTP-probe boilerplate, over the 3% new-code gate).

Extracted one generic LivenessServer into queue_backend/pg_queue/liveness.py —
parameterised by a freshness callable + the payload's check/age labels + an
optional extra-status callable (the reaper's is_leader). Both sides are now thin
subclasses that preserve their exact constructor signatures and wire payloads:
- consumer LivenessServer(consumer, port=, stale_after=) → check="pg_queue_poll",
  seconds_since_last_poll (unchanged on the wire; its tests pass untouched).
- ReaperLivenessServer(reaper, port=, stale_after=) → check="pg_reaper_tick",
  seconds_since_last_tick, is_leader.

The boilerplate now lives once → duplication cleared, consumer behaviour
preserved. reaper 37 tests + consumer liveness/health tests green; ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3556 [FEAT] Address review: validate health port, type/guard liveness, tests

Toolkit review (10 findings; several already resolved by the dedup refactor
0d2b3f7 — the threading-alias, the query-strip comment, and the
extract-shared-server follow-up itself):

- [Medium] Port parse: extracted _reaper_health_port_from_env() — names the var
  on a bad value (no more context-free int('abc') crash) and range-checks
  0-65535 at parse time, so an out-of-range value can't escape the bind catch as
  OverflowError inside start(). main() uses it.
- [Medium] liveness.py: typed _httpd/_thread as HTTPServer|None / Thread|None via
  TYPE_CHECKING (was Any in the shared server) — restores the lifecycle invariant
  + type-checking on .shutdown()/.join()/etc.
- [Low] LivenessServer.__init__ re-validates stale_after > 0 (a direct caller
  could otherwise build an always-503 probe).
- [Low] bound_port docstring: clarified the port=0 / not-started case.
- [Low] _DEFAULT_HEALTH_STALE_SECONDS comment references the interval constant,
  not a hard-coded "5s".
- [Medium/Low test gaps] +9 tests: port-env helper (unset/empty/valid/non-int
  named/out-of-range); main() wiring (parsed port reaches the wiring + health
  stopped in finally; port=None when unset); _maybe_start_health_server OSError
  graceful-degrade → None + logger.exception; stale_after<=0 constructor guard.

reaper 46 + consumer liveness 10 green; ruff clean. The shared-server extraction
(flagged as a follow-up) was already done in 0d2b3f7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3556 [FEAT] Address Greptile: protect core payload + per-process log label

- extra_status_fn could silently clobber core payload fields (status / check /
  age_key / stale_after_seconds) that a monitor reads. Now the handler builds
  extra fields first and overlays the core fields, so core ALWAYS wins — a
  future caller's extra dict can't corrupt the status a monitor parses.
  Test: an extra_status_fn returning {"status":"HACKED",...} leaves status
  "healthy" and check intact, while a non-reserved extra key is preserved.
- The dedup refactor moved the consumer's liveness warnings to the shared
  liveness logger with generic text, so log-based filtering keyed on the old
  "PG-queue consumer: ..." would miss them. Added a log_label param (default
  "pg-queue"); consumer passes "pg-queue consumer", reaper "pg-queue reaper", so
  the messages stay attributable to the source process.

57 tests green (reaper 47 + consumer liveness 10); ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* UN-3560 [FEAT] PG Queue 9 — run-worker.sh `-L celery` log alias

`./run-worker.sh -L pg-queue` already tails the PG-queue set's logs, but
there was no symmetric way to tail only the Celery set: `-L` (no arg) tails
EVERYTHING (Celery + PG-queue consumer/reaper), since list_core_worker_dirs
includes the PG worker dirs.

Add a `-L celery` log alias (mirror of `-L pg-queue`): tails every worker
log EXCEPT the PG-queue members (pg_queue_consumer, pg_queue_reaper). Logs-only
— the Celery set is still run via 'all'.

run-worker.sh only: CELERY_SET constant + a branch in tail_logs() + usage/examples.

Dev-tested with stub PG logs: -L = 11 files, -L celery = 9 (PG excluded),
-L pg-queue = 2 (PG only). bash -n clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3560 [FIX] -L celery review — complement wording + single-source PG members + dedup

Address PR #2066 toolkit review:

- Comment accuracy (P1, x2): reword 'mirrors PG_QUEUE_SET' / 'mirror of the
  pg-queue alias' — the celery set is the COMPLEMENT (all minus the two PG
  members), in a different branch, not a mirror. Drop the directional 'below'.
- Modeling (P2): add a single 'PG_QUEUE_MEMBERS' source of truth (readonly assoc
  array) so 'celery = all - pg-queue' stays correct by construction; the celery
  branch tests membership via it instead of hand-rolling consumer/reaper.
- Simplification (P1): collapse the near-duplicate 'all' and 'celery' tail_logs
  branches into one loop with a membership-guarded skip.

Deferred (P2, inherited): set-aware empty-result message for the shared zero-log
guard — spans all three set aliases, best done as one follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…2062)

* UN-3559 [FEAT] PG Queue 9e PR 1 — execution transport seam (inert)

Establish the per-execution transport seam for the 9e coupled-pipeline
migration: the transport a workflow execution rides ("celery" | "pg_queue")
is resolved once at the creation chokepoint and carried in the task payload.
Inert — transport always resolves to "celery", so behaviour is unchanged.

Design (chosen = payload-carry, not a WorkflowExecution column):
workers/queue_backend/pg_queue/9e-design.md.

- core: WorkflowTransport enum + DEFAULT_WORKFLOW_TRANSPORT (shared vocabulary).
- backend: resolve_transport() hardwired to celery (signature shaped for PR 3's
  Flipt wiring); create-execution internal API returns "transport";
  execute_workflow_async adds it to the async_execute_bin kwargs.
- workers: scheduler threads transport from the create response into the
  dispatch kwargs; async_execute_bin_general / _execute_general_workflow carry
  it onto the live WorkflowContextData.
- tests: backend resolver + enum (5), worker WorkflowContextData carry/default
  (2), dispatch characterisation updated (+ backend-resolved-transport thread).

Out of scope: live PG routing + per-batch idempotency key (PR 2); Flipt canary
wiring (PR 3); rollout (ops).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3559 [FIX] 9e PR 1 review — fail-closed transport coercion + doc/test fixes

Address PR #2062 review (PR Review Toolkit findings):

- Validation/silent-failure: add normalize_transport() (core) — fail-closed
  coercion of any inbound transport to a known value (unknown/None -> celery +
  warn). Applied at the scheduler read boundary and in WorkflowContextData
  __post_init__, so a garbage payload value can't reach the PR 2 fan-out read.
- Comment accuracy: WorkflowContextData.transport comment now present-tense
  (carried in PR 1; fan-out read lands in PR 2).
- Dead-code hygiene: add 'transport' to EXECUTION_EXCLUDED_PARAMS so the legacy
  execute_bin -> create_workflow_execution path can't TypeError.
- Two-resolution-sites: documented the deliberate two-site design + the PR 3
  single-chokepoint requirement at execute_workflow_async.
- transport.py: drop the unused logger; leave a PR-3 fail-closed marker.
- Design doc: correct anchors (transport.py / internal_api_views / workflow_
  helper, not execution.py:126), class name (WorkflowContextData not
  WorkflowExecutionContext), scope claims (stage-1 only in PR 1), and remove the
  hard-coded Flipt version/date/live-state.
- Tests: normalize_transport (passthrough / invalid->celery / None / logging),
  WorkflowContextData invalid-transport coercion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3559 [FIX] 9e PR 1 — resolve transport after execution_id guard (greptile P1)

Move the normalize_transport(...) extraction below the `if not execution_id`
guard in _execute_scheduled_workflow. Previously it ran before the guard, so an
error response with no execution_id logged a misleading `[exec:None]` context
and discarded the computed transport. Now transport is only resolved once
execution_id is known non-empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3559 [FIX] 9e PR 1 — SonarCloud: drop unused resolve_transport params + dict literal

Address SonarCloud code smells on PR #2062:

- python:S1172 (x3): resolve_transport() no longer declares the unused
  workflow_id/pipeline_id/organization_id params — the PR-1 seam is inert and
  needs no inputs. PR 3 reintroduces them (keyed for Flipt) when it wires the
  evaluation; the two call sites (internal_api_views view, execute_workflow_async)
  now call resolve_transport() with no args. Tests updated.
- Replace dict(...) constructor with a {...} literal in
  test_workflow_context_transport._make_context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…(dispatch backend override + barrier decrement core) (#2067)

* UN-3561 [FEAT] PG Queue 9e PR 2a — live-PG-pipeline inert foundation (dispatch backend override + barrier decrement core)

First slice of 9e PR 2 (the live PG execution pipeline), split 2a/2b/2c.
2a is the inert foundation: the two seams the live switch (2c) consumes,
each with zero behaviour change on the default path.

- dispatch(backend=...): per-call transport override. None (default, every
  call site today) keeps the env allow-list decision via select_backend —
  byte-identical. When set it wins over the allow-list, so 2c can route a
  whole execution's header/callback onto PG without opting their task names
  into WORKER_PG_QUEUE_ENABLED_TASKS (allow-list is for leaf tasks; the
  pipeline's migration unit is the execution).
- Extract _barrier_pg_decrement(...) plain core out of the @worker_task
  barrier_pg_decr_and_check (now a thin delegator). 2c calls the core in-body
  on the PG-consumed path (a PG-consumed task fires no Celery .link, so the
  decrement runs in-body — fire-and-forget self-chaining).

Inert by construction: default barrier backend is chord (Celery executions
never import the PgBarrier module), and no call site passes backend=. Net
behaviour change: NONE. Transport threading + live switch land in 2c;
per-batch idempotency in 2b.

Tests: dispatch backend-override (3) + decrement-core extraction (3, incl.
real-PG in-body decrement + verbatim delegation). pg_barrier/dispatch/
barrier/routing suites green; ruff clean; worker-app bootstrap clean under
WORKER_BARRIER_BACKEND=pg (both barrier tasks registered, get_barrier→PgBarrier).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3561 address review (muhammad-ali-e): loud in-transaction guard, resolve_backend helper, comment/test fixes

- pg_barrier: enforce the "own committed transaction" decrement contract
  loudly — _barrier_pg_decrement raises at entry if the shared connection is
  mid-transaction (was prose-only; the 2c in-body caller is the real risk).
  Safe for existing paths: Celery .link enters idle, tests use autocommit
  conns → always idle. Fix the inaccurate "one call = one _cursor() txn"
  parenthetical (the delete paths open a 2nd txn) and drop the rot-prone
  "(fire-and-forget self-chaining)" jargon.
- routing: extract resolve_backend(task_name, override) — the override-wins-
  else-allow-list precedence now lives in one self-documenting place (2c
  reuses it); dispatch() calls it instead of inlining the None-means-auto rule.
- dispatch: reword the backend= docstring — avoid the "payload" term collision
  (local to_payload var) and the stale routing.py cross-ref; point at the live
  carrier WorkflowContextData.transport + 9e-design.md.
- tests: +fairness-reaches-row on the backend= override path; +real-row wrapper
  test that catches core-param drift (the mocked delegation test couldn't);
  +open-transaction guard test. Kept the mocked test as the keyword-forwarding pin.

Deferred (LOW, reviewer-aligned): BarrierDecrementResult TypedDict union — lands
in 2c when the in-body caller actually branches on the status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3561 address greptile (3× P2): docstring refs + routing-log suppression note

All doc/comment-only, no logic change. greptile gave 4/5 "safe to merge"; these
are the staleness its findings flagged, introduced by the resolve_backend
extraction in the prior review round:
- dispatch module docstring + routing "Scaffold posture" now name resolve_backend
  (wrapping select_backend) as the routing seam, not select_backend alone.
- Note at the _pg_routing_logged log-once site that it's keyed on task name only,
  so an override-then-allow-list cutover won't re-announce (benign: override =
  pipeline headers vs allow-list = leaf tasks, no overlap expected; the allow-list
  config is still announced by _log_allow_list_once).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…g_batch_dedup + claim_batch / clear_execution_batches) (#2068)

* UN-3562 [FEAT] PG Queue 9e PR 2b — per-batch idempotency primitive (pg_batch_dedup + claim_batch / clear_execution_batches)

Second slice of 9e PR 2, after 2a (#2067). Inert idempotency primitive: the
durable per-batch dedup marker 2c wires into the at-least-once PG path.

Why: the PG queue is at-least-once, so process_file_batch can be redelivered
after a crash-before-ack → re-run batch + double-decrement the barrier
(non-idempotent, max_retries=0). Recon showed existing per-file protection is
only partial (Redis lock released after write; WFE COMPLETED skips tool re-exec
but not necessarily the destination write; FileHistory is cross-execution only),
so a durable per-batch gate is needed; per-file status stays the partial-crash
backstop.

- backend: PgBatchDedup model + migration 0006 — table pg_batch_dedup with a
  UniqueConstraint(execution_id, batch_index) (the ON CONFLICT target; its
  execution_id-leading index also serves the cleanup DELETE). Django-managed,
  extension-free, same posture as the sibling pg_queue models.
- workers (pg_barrier.py, reusing the barrier's _cursor() → one PG conn per
  worker child): claim_batch(execution_id, batch_index) -> bool (atomic
  INSERT ... ON CONFLICT DO NOTHING RETURNING; True=first/decrement,
  False=redelivery/skip) + clear_execution_batches(execution_id) -> int
  (barrier-teardown cleanup; reaper sweep is the backstop).

No call-site wiring — claim/clear + batch_index threading + transport switch
land in 2c. Inert: new table + two helpers, no callers. Net behaviour: NONE.

Tests: +8 real-PG (first-claim, redelivery-rejected, distinct-batch,
distinct-exec, concurrent-exactly-one-winner, clear-only-target,
clear-empty-zero, reclaim-after-clear). Migration applied to dev DB,
makemigrations --check clean, bootstrap clean under WORKER_BARRIER_BACKEND=pg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3562 address review (muhammad-ali-e P1-P8): fix reaper-backstop docstring claim, batch_index constraint, race test, nits

- P1 (verified bug): the docstrings claimed the reaper's barrier-orphan sweep
  reclaims orphaned pg_batch_dedup markers — it doesn't. sweep_expired_barriers
  DELETEs only pg_barrier_state (no cascade), so orphaned markers leak today.
  Reworded both PgBatchDedup + clear_execution_batches docstrings to state the
  leak honestly + flag the dedup-orphan sweep as intended future work.
- P4: add CheckConstraint(batch_index >= 0) (writer-proof, mirrors
  PgQueueMessage.priority) + a test that the DB rejects a negative index.
  Regenerated migration 0006 to include it.
- P6: claim_batch docstring no longer says it decrements; defers the single
  decrement to the caller (the function only inserts the marker).
- P7: generalized "partial per-file protection" → "not fully idempotent on
  redelivery" so the rationale can't rot.
- P5: documented created_at as observability-only (future age-based sweep).
- P2: REQUIRE_PG_TESTS env → skip becomes fail, so the idempotency primitive
  can't ship untested-green in CI where PG is expected.
- P3: strengthened the race test — pre-build N=8 conns in the main thread,
  align claims with threading.Barrier, loop 5 trials (forces the contended
  ON CONFLICT path instead of a serial fast-path).
- P8: hoisted import os to module top.

35 pg_barrier + 9 dedup tests green; migration applied to dev DB,
makemigrations --check clean; ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…fire-and-forget) (#2069)

* UN-3563 [FEAT] PG Queue 9e PR 2c — live PG fan-out/barrier/callback (fire-and-forget)

Wires the coupled pipeline's fan-out → barrier → callback onto the PG queue for
a transport=="pg_queue" execution. Gated: resolve_transport() still returns
celery (PR3 Flipt flips it), so the whole PG branch is present-but-unreachable —
default path byte-identical. Orchestrator task (async_execute_bin) stays on
Celery (hybrid); routing it onto PG is a 2d follow-up.

- barrier.py: Barrier Protocol + CeleryChordBarrier/RedisDecrBarrier accept (and
  ignore) a `transport` param; CallbackDescriptor gains an optional `backend`.
- orchestration_utils._barrier_for_transport: pg_queue → fresh PgBarrier()
  (bypasses the WORKER_BARRIER_BACKEND singleton), else the singleton.
- pg_barrier.PgBarrier.enqueue(transport): pg_queue → fire-and-forget mode —
  _dispatch_header_pg sends each header via dispatch(backend=PG) with an injected
  _barrier_context {execution_id, batch_index, callback_descriptor}, no .link;
  descriptor marked backend=pg_queue; UPSERT block also clears pg_batch_dedup
  (greptile #2068 reuse-reset). _fire_barrier_callback self-chains the callback
  onto PG when backend==pg_queue. clear_execution_batches at finalise + abort.
  run_batch_with_barrier(): claim → work → in-body _barrier_pg_decrement;
  redelivery skips; exception → barrier_pg_abort.
- file_processing.process_file_batch(_barrier_context=None): core routes None →
  _run_batch_stages (celery chord path), else → run_batch_with_barrier.
- general/api fan-outs thread transport into create_chord_execution.

Tests: +8 PgBarrier fire-and-forget + 2 orchestration routing + 2 process_file_batch
routing. Each test file green alone; ruff clean. End-to-end forced-pg dev-test
pending before PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3563 fix SonarCloud S1172: drop unused task_instance from _run_batch_stages

The extracted _run_batch_stages never uses task_instance — its only purpose
(deriving celery_task_id) happens in _process_file_batch_core before the call.
Removed the param + updated both call sites. _process_file_batch_core keeps
task_instance (it reads .request.id). Routing test mocks with *a, unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3563 address review (muhammad-ali-e, 15): strand-on-failure hardening + typing/dedup/docs/tests

Decision (with reviewer): reaper-as-safety-net for the un-catchable strand
windows + fix what's catchable + document + gate on PR3.

Failure handling:
- [#69 Critical] run_batch_with_barrier wraps BOTH work + decrement in the abort:
  a decrement-side failure (guard / DB / last-batch callback dispatch) tears the
  barrier down in-body instead of stranding to expiry.
- [#79] extracted _abort_barrier_in_body — logs when the teardown itself fails
  (was silently suppressed under a misleading "torn down" message).
- [#74/#81] documented the two un-catchable strand windows (hard-crash-during-work,
  post-commit callback-dispatch-fail) as a HARD reaper dependency for PR3.
- [#86] finalise cleanup split into independent try/excepts with distinct logs.

Typing / clarity:
- [#1] BarrierContext(TypedDict) for _barrier_context (header fan-out, run_batch_with_barrier,
  process_file_batch).
- [#3] renamed CallbackDescriptor "backend" -> "transport" (WorkflowTransport value;
  avoids the QueueBackend "pg" collision).
- [#27] is_pg_transport() predicate in core; used in orchestration_utils + pg_barrier.
- [#20] extracted _dispatch_pg() — single home for cycle-avoiding local import + backend=PG.
- [#35] normalize_transport() at the general worker entry (parity w/ api/scheduler).
- [#94] log when a header has no queue option.
- [#9/#13] fixed born-stale comment + kwargs-not-args docstring.

Tests (+#37/#41): last-batch self-chains callback to PG + cleans up barrier/dedup;
decrement-failure aborts; PG-branch mid-loop dispatch-failure deletes row; header
args/queue/pre-existing-kwargs preservation. 137 barrier/dedup/routing tests green;
bootstrap clean under WORKER_BARRIER_BACKEND=pg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3563 fix run_batch_with_barrier strand-window doc inconsistency (review)

The second "NOT catchable" bullet conflated two different things: it described
the in-body catchable abort ("the abort here removes the row") and a *software*
callback-dispatch failure — but that failure is already caught + torn down by
step 3's wrap (paragraph 1), so it doesn't belong under the un-catchable
heading, and on the PG path _fire_barrier_callback IS the enqueue so "committed
but before the enqueue" couldn't both hold.

Rewrote the bullet to the genuinely un-catchable window: a hard crash BETWEEN
the decrement committing (remaining→0) and the callback enqueue completing —
decrement committed (redelivery blocked by the marker), process gone before the
callback enqueues or any abort runs, row survives to expiry, reaper-only
recovery. Explicitly notes a software dispatch failure is the catchable case.
Keeps this list an accurate spec for the PR-3 reaper-recovery dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3563 address greptile (#2069, 2): clear dedup on mid-loop PG failure + carry fairness on PG callback

Both in the gated PG path (greptile 4/5, safe to merge).

- Issue 1: PgBarrier.enqueue mid-loop dispatch-failure handler now also calls
  clear_execution_batches on the PG path. Earlier headers may have committed a
  claim_batch marker; with the barrier row deleted, their in-flight
  barrier_pg_abort is a no-op (already_aborted) and never reaches the clear
  inside it, so reclaim the markers directly here.
- Issue 2: the PG callback now carries the producer's fairness. Added
  _fairness_from_headers() to reconstruct the FairnessKey from the stored
  x-fairness-key headers and pass it to _dispatch_pg, so the callback rides the
  same org/priority as the Celery path (was always default priority).

Tests: +fairness-carried / +fairness-none-safe on _fire_barrier_callback;
extended the PG mid-loop test to assert an already-claimed marker is reclaimed.
75 barrier/dedup tests green; bootstrap clean under WORKER_BARRIER_BACKEND=pg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3563 fix SonarCloud S3776: reduce PgBarrier.enqueue cognitive complexity (17→under 15)

Extracted the per-header dispatch loop into PgBarrier._dispatch_headers — the
deeply-nested for→try/except→if/else→if (PG-vs-celery branch + mid-loop failure
teardown + PG dedup-clear) was the complexity driver. enqueue now calls the
helper; behaviour identical. radon: enqueue C(11)→B(6); ruff C901 passes.
75 barrier/dedup tests green; ruff + ruff-format clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3563 fix greptile #2069: mid-loop dedup-clear test passed for the wrong reason

The pre-seeded claim_batch marker was wiped by enqueue's UPSERT block (the
reuse-reset DELETE) before the dispatch loop, so the mid-loop
clear_execution_batches deleted 0 rows — the count==0 assertion passed on the
UPSERT, not the guard under test. Now the first dispatch side-effect claims the
marker AFTER the UPSERT (simulating a fast PG consumer), so the mid-loop clear
is what removes it. Verified: with the clear disabled the marker orphans (count=1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…s ERROR (gates PR 3) (#2070)

* UN-3564 [FEAT] PG Queue 9e — reaper-recovery: mark stranded executions ERROR

The reaper only DELETEd expired pg_barrier_state rows — a stranded execution
vanished silently (stuck in EXECUTING). This makes it RECOVER them — the hard
dependency from PR 2c review for enabling the PG transport (un-catchable strand
windows otherwise bottom out at the ~6h barrier expiry).

Per expired barrier the leader (recover_expired_barriers), best-effort + per-exec:
- Marks the execution ERROR via the internal API (the path the normal callback
  uses — business state never goes direct-DB; the API is functional: execution_time,
  error truncation, attempts, events/notifications, multi-tenant boundary). Message
  distinguishes remaining>0 (work incomplete) vs remaining==0 (callback never fired).
  Reads status first and SKIPS if already terminal (a remaining==0 row can be a
  COMPLETED exec whose row-delete failed; update_execution has no terminal guard) or
  if the row has no org.
- Reclaims pg_batch_dedup + pg_barrier_state directly in PG (same boundary as the
  rest of queue_backend). Recover-THEN-delete: a failed mark leaves the row for the
  next sweep to retry (single-leader → no double-claim).

- backend: organization_id column on PgBarrierState + migration 0007 (reaper needs
  it for the org-scoped status API).
- workers: PgBarrier.enqueue stamps organization_id into the UPSERT; PgReaper holds
  a lazily-built InternalAPIClient; sweep_expired_barriers → recover_expired_barriers.

Tests: reaper suite reworked — real-PG recovery w/ fake API client (mark-ERROR
remaining>0/==0, terminal-skip no-overwrite, org-missing skip, API-failure leaves
row, dedup reclaim, tick-via-real-conn). 109 reaper/barrier/dedup tests green.
Dev-tested vs real InternalAPIClient+backend: PENDING stranded → ERROR + cleaned;
COMPLETED → NOT overwritten + cleaned (terminal-guard verified end-to-end).

Deferred: callback re-fire for remaining==0 (needs callback_descriptor on the row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-3564 fix SonarCloud S6741: avoid IndexError-prone update_calls[0] in reaper tests

Replaced `api.update_calls[0]` (which Sonar can't prove non-empty → IndexError
risk) with single-element unpacking `(call,) = api.update_calls` — removes the
index and additionally asserts exactly one status mark was made. Behaviour
identical; 52 reaper tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3564 address review (muhammad-ali-e, 11): fix read-failure→false-ERROR (Critical) + hardening

- [Critical] _execution_status now RAISES when the status read fails
  (get_workflow_execution returns success=False, never raises) — a transient
  blip no longer reads as "non-terminal" and flips a COMPLETED execution to
  ERROR; the caller's except retains the row for retry. + test.
- [Medium] use ExecutionStatus.is_completed (single source of truth) instead of
  a local _TERMINAL_STATUSES frozenset that could drift; dropped the fake
  in (_TERMINAL_STATUSES) parens.
- [Medium] remaining is NOT-NULL int → typed int, three-way branch
  (>0 work-incomplete / ==0 callback-never-fired / <0 already-torn-down) with
  accurate messages.
- [Medium] re-guard the barrier DELETE on `expires_at < now()` + only clear
  dedup when the barrier row was actually reclaimed — a same-id re-enqueue
  (UPSERT resets expires_at) is no longer torn down mid-recovery.
- [Medium] org-missing now LEAVES the row (doesn't erase the only recovery
  handle) + logs ERROR; PgBarrier.enqueue logs ERROR at write time if a barrier
  is enqueued without an org (should never happen).
- [Low] recover_expired_barriers emits an aggregate summary; escalates to
  logger.error when a non-empty sweep recovers nothing (systemic: API down).
- [Low] refreshed the stale "(future) periodic sweep" comment in pg_barrier.

Tests: _FakeApiClient models the real success/failure contract + records
file_execution; +read-failure-retains-row, +file_execution=False, +multi-row
isolation (one fails, others recover), +org-stamping x3 (stamp/default/UPSERT
refresh). 115 reaper/barrier/dedup tests green; ruff + ruff-format clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3564 fix SonarCloud S6741 (new instance): index-free get_calls access

A new occurrence introduced by the prior review-round test
(test_status_read_passes_file_execution_false used api.get_calls[0][2]). Replaced
with single-element unpacking — [(_exec_id, _org, file_execution)] = api.get_calls
— index-free and additionally pins exactly one status read. 55 reaper tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3564 address greptile #2070: re-arm race, status=None edge, aggregate skip-vs-fail

- [main] re-arm race: the ERROR mark now fires only after a re-check that the
  row is STILL expired (_still_expired) immediately before marking — so a same-id
  re-enqueue (expires_at reset to future) between the sweep SELECT and the mark
  can't get its freshly-running execution flagged ERROR. The DELETE stays guarded
  on expires_at < now() as the second line of defence. + test (re-arm via the
  status-read side-effect) asserting no mark + row left intact.
- status=None on a success=True read no longer falls through to mark ERROR —
  treated as indeterminate: skip + leave the row for the next sweep. + test.
- aggregate logging distinguishes genuine failures (exceptions) from benign skips
  (terminal / re-armed / no-status / no-org): the systemic "recovered NONE / API
  down" ERROR escalates only on real failures, not on all-skipped sweeps. + caplog
  test that an all-org-missing sweep doesn't escalate.

58 reaper + 51 barrier tests green; ruff + ruff-format clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3564 fix SonarCloud S125: drop code-like tuple from a test comment

The comment ended with a parenthesised tuple "(exec_id, org, file_execution)" —
a complete Python expression S125 reads as commented-out code. Reworded to prose
(and the stale `sweep_expired_barriers`→recover rename + a couple of inline
`foo()` prose mentions neutralised while here). Comment-only; tests unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
…esolution (#2071)

* UN-3570 [FEAT] PG Queue 9e PR 3 — Flipt canary wiring for transport resolution

Replace the hardwired Celery result in resolve_transport() with a real
decision: an env master-gate (PG_QUEUE_TRANSPORT_ENABLED, default off) that,
when on, consults the Flipt boolean flag pg_queue_execution_enabled. Fails
closed to Celery on any error.

- entity_id = execution_id (per-execution sticky %-rollout; resolved once,
  carried in the task payload so an in-flight execution never re-buckets)
- context carries org/workflow/pipeline ids (str-coerced — UUIDs in the gRPC
  map<string,string> context would be swallowed as False and silently force
  Celery) for segment rollouts
- both creation chokepoints wired: internal_api_views.create_workflow_execution
  (scheduler path) and workflow_helper.execute_workflow_async (API/manual/async)

Gated off by default — no behaviour change until the flag is enabled, which
requires PG consumers deployed first (deploy-ordering safety).

Tests: 15 cases in test_transport.py (gate off/on, flag true/false, fail-closed,
entity/context shape, UUID-coercion regression).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3570 address review: type honesty, fail-closed org/Flipt guards, observability

- Widen resolve_transport id params to `str | UUID` (callers pass UUIDs; the
  body already str-coerces) — resolves the SonarCloud type-mismatch and makes
  the contract honest.
- Fail closed to celery when organization_id is missing (str(None) must never
  reach the Flipt org segment) — the helper path reads it from StateStore which
  can be empty.
- Fail closed + loud warning when the gate is ON but FLIPT_SERVICE_AVAILABLE is
  not true, so a blind Flipt can't masquerade as a healthy 100%-celery canary.
- Log the resolved transport on the gate-ON path (deliberate celery vs pg_queue
  vs blind are now distinguishable); drop the inaccurate "import-time fault"
  clause from the fail-closed comment.
- Doc: organization_id is Organization.organization_id (X-Organization-ID), not
  the DB pk.
- sample.env: document that pg_queue needs all three (gate + FLIPT_SERVICE_AVAILABLE
  + flag).
- +2 tests (missing-org → celery, gate-on + Flipt-unavailable → celery).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3570 address greptile: organization_id None type + FLIPT_SERVICE_AVAILABLE parse parity

- organization_id annotation widened to `str | UUID | None` — the helper path
  passes UserContext.get_organization_identifier() which can be None at runtime;
  the existing `if not organization_id` guard handles it, so the type should
  admit None rather than mislead callers/static analysis.
- FLIPT_SERVICE_AVAILABLE check now parses exactly like FliptClient (`.lower()`,
  no `.strip()`) so the two can't disagree on a whitespaced value like " true"
  (which would otherwise skip the "Flipt blind" warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…G + shared TaskPayload contract (#2072)

* UN-3574 [FEAT] PG Queue 9e 2d — orchestrator (async_execute_bin) on PG + shared TaskPayload

Route the orchestrator task onto the PG queue when transport==pg_queue, so a
pg_queue execution's orchestrate → fan-out → barrier → callback all run on PG
(was hybrid: orchestrator on Celery, only fan-out/callback on PG).

- Promote TaskPayload + FairnessPayload (the PG-message wire contract) to
  unstract.core; workers re-export them so existing imports keep working and the
  backend producer + worker consumer share one definition.
- Backend PG producer (pg_queue/producer.py): enqueue a TaskPayload row to
  pg_queue_message via the ORM, mirroring the workers' PgQueueClient.send. UUIDs
  in args/kwargs are JSON-coerced (the message JSONField has no Django encoder).
- Backend dispatch (execute_workflow_async): when transport==pg_queue, enqueue
  async_execute_bin to PG (general → "celery", api → "celery_api_deployments")
  instead of celery_app.send_task; task_id becomes "pg:<msg_id>".
- Scheduler dispatch: pass backend=QueueBackend.PG when is_pg_transport(transport)
  (dispatch() already had the per-call override from 2a).

Gated off by default — Celery path unchanged. Executor (tool run) + log workers
stay Celery (PR B). Tests: 5 producer unit tests; workers regression suite green
after the core move. Dev-tested end-to-end: a real API deployment ran
async_execute_bin on the orchestrator PG consumer (not Celery), then fan-out →
barrier → callback on PG to COMPLETED, clean teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3574 address review: reduce complexity, consolidate fairness contract, fix dispatch fork

SonarCloud (cognitive complexity 18→<15) + review round on #2072:
- Extract WorkflowHelper._dispatch_orchestrator_task — the PG-vs-Celery fork
  moves out of execute_workflow_async (complexity down) and becomes unit-testable.
- HIGH: a `dispatched` flag — a post-dispatch bookkeeping failure no longer flips
  a running (already-enqueued) execution to ERROR; only pre-dispatch failures do.
- HIGH: task_id is now bare `str(msg_id)` (was `pg:{msg_id}`) — one format across
  entry paths, matching the worker PgDispatchHandle.id.
- Drop the dead Celery-only TimeoutError handler (manual poll never raised it;
  the generic handler covers any stray case).
- Consolidate the fairness contract in unstract.core: WorkloadType (StrEnum) +
  FAIRNESS_MIN/MAX/DEFAULT_PRIORITY + FairnessPayload.workload_type Literal;
  workers re-export, backend producer + workflow_helper reference them (no more
  hand-built "api"/"non_api" literals or triplicated [1,10] bounds).
- Producer: log+re-raise on enqueue failure (parity with worker _enqueue_pg);
  document the TaskPayload.queue field as diagnostic-only.

Tests: +dispatch-fork suite (PG vs Celery, bare id, two org sentinels),
+producer boundary/datetime/failure cases, +scheduler backend=PG override
assertions. 34 backend + 99 workers green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3574 address sonar: use logging.exception() in the dispatch error handlers

Both handlers in execute_workflow_async were `logger.error(..., exc_info=True)`
inside `except Exception` → switch to `logger.exception(...)` (idiomatic, and
exc_info is implicit). The post-dispatch branch drops the redundant `{error}`
from the message since the traceback is now attached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3574 address greptile: drop redundant stack_info=True from logger.exception

logger.exception() already attaches the active exception's traceback; stack_info
additionally dumps the current call stack, producing a second overlapping stack
trace per error in a direct except handler. Drop it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…sh PG roles (#2073)

* UN-3566 [FEAT] PG Queue 9f — multi-queue consumer + named run-worker.sh PG roles

Make PG-queue workers first-class in run-worker.sh, runnable individually or as a
set, like the Celery workers — removing the per-process-env footgun.

- Multi-queue consumer: PgQueueConsumer takes queue_names: list[str] and polls
  them round-robin (per-queue read preserves the dequeue index's top-N; no queue
  starves another). WORKER_PG_QUEUE_CONSUMER_QUEUE is comma-parsed; a single
  value stays a one-element list (back-compat with the existing leaf consumer).
- run-worker.sh named roles (PG_CONSUMER_ROLES), each a registry-bound consumer
  with its source worker-type + queue list baked in (no manual env):
  pg-orchestrator-api / pg-orchestrator-general (async_execute_bin has distinct
  impls per registry → split), pg-fileproc / pg-callback (multi-queue, one
  process drains ETL+API). The role name rides in argv (python -m
  pg_queue_consumer <role>) so pgrep (-s/-k/-r) tells co-running roles apart.
- `./run-worker.sh -d pg` launches the 4 pipeline roles + reaper (mirrors `all`);
  each role also runs/kills/restarts/tails individually. resolve_log_file + the
  -L pg set + status/kill enumerate the roles.

Tests: round-robin aggregation, empty-queue-no-starve, empty-list reject,
comma-parse (incl single-value back-compat); existing consumer tests updated to
the list arg.

Dev-tested: `./run-worker.sh -d pg` → status shows 4 roles + reaper RUNNING; an
API execution drains orchestrator-api → fileproc (draining both file_processing
and api_file_processing) → callback → COMPLETED; `-r pg-fileproc` restarts only
that role, leaving pg-callback untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3566 run-worker.sh: accept multiple worker types + tighten generic consumer pgrep

- Multiple positional worker types: `./run-worker.sh -d all pg` starts the Celery
  set and the PG set in one shot (loops WORKER_TYPES). Warns if >1 type is given
  without -d, since a non-detached set `wait`s and would block the rest.
- Tighten the generic pg_queue_consumer pgrep to end-anchored so --status/-k/-r
  for the generic consumer no longer also match (and aggregate/kill) the named
  role consumers, which run as `python -m pg_queue_consumer <role>` and have
  their own role-anchored pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3566 address sonar: hoist PG role-name literals into constants

Define PG_ROLE_ORCH_API / _ORCH_GENERAL / _FILEPROC / _CALLBACK once and
reference them in PG_CONSUMER_ROLES, PG_QUEUE_MEMBERS and WORKERS, instead of
repeating each role-name string literal 4×.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3566 run-worker.sh: add 'celery' run alias for the Celery set (symmetric with 'pg')

`./run-worker.sh -d celery` now runs the Celery set (== `all`, excludes the PG
workers), so celery-only / pg-only / both read symmetrically:
  -d celery   (Celery only)
  -d pg       (PG only)
  -d celery pg | -d all pg   (both)

'celery' maps to "all" in WORKERS → dispatches to run_all_workers, is skipped by
list_core_worker_dirs (no phantom dir), and falls through to the restart-all
branch under -r. The pre-existing `-L celery` log-tail alias is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3566 address review: -r pg kills roles, per-queue isolation, dedup, stale comments

Critical/High + cleanup from the #2073 review round:
- [Critical] `-r pg` (restart set) now kills the SAME members run_pg_queue_set
  launches (the 4 named roles + reaper), not just the generic consumer — was
  leaving the roles running, so a relaunch double-polled Postgres.
- [High] poll_once isolates each queue: a read/handle failure on one queue is
  logged and skipped so the others still run AND the work already done this cycle
  still counts (no false empty-queue backoff after a partial failure).
- [High] fixed the stale CELERY_SET comment that claimed "no 'celery' run alias".
- [Medium-High] run_pg_queue_set start-failure teardown aggregates kill_one_worker
  returns and surfaces a survivor (mirrors the restart path).
- [Medium] de-dup + copy queue_names (list(dict.fromkeys(...))) — a duplicate
  would double-read a queue; the copy stops a caller mutation bypassing validation.
- [Medium] _parse_queue_list warns when it drops empty entries (config typo).
- [Med/Low] comment fixes: only fileproc/callback are multi-queue; "4 roles +
  reaper" not "consumer + reaper"; "read once per cycle in list order" not
  round-robin. Cleanups: drop redundant export; cwd predicate uses PG_QUEUE_MEMBERS;
  spell out api_file_processing_callback in help.

Tests: +one-queue-failing-isolation, +batch_size qty per queue, +duplicate dedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ices (gated profile) (#2074)

* UN-3576 [FEAT] PG Queue 9g — docker-compose PG consumer + reaper services (gated profile)

Make the already-built PG-queue pipeline runnable under docker compose, gated
OFF by default, so it runs in containers (not just the host run-worker script).
Designed one-service-per-component for a mechanical K8s mapping later.

- run-worker-docker.sh (image ENTRYPOINT): recognise `pg-queue-consumer` and
  `pg-queue-reaper` launch types -> exec the dedicated Python module instead of
  building a Celery worker command. Dispatched before the Celery two-path; no
  Dockerfile change.
- docker-compose.yaml: 5 services behind the off-by-default `pg-queue` profile
  (4 consumers: orchestrator-api / orchestrator-general / fileproc / callback,
  plus a single-instance reaper). Orchestrators + reaper are broker-free;
  fileproc + callback keep rabbitmq (they still hand off to the Celery executor
  / notifications until those are migrated).
- sample.env: document the new env vars + profile; the backend transport gate
  stays off (ramping traffic is a later step).

Non-regression: default `docker compose up` starts zero PG services. Even with
them running, executions route to Celery until the gate + Flipt flag are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3576 address review: guard PG interpreter, warn on missing consumer config, reject typo'd PG commands, comment accuracy

- run-worker-docker.sh: ensure_pg_interpreter() fails loudly if the venv python
  is missing (avoids a silent restart:unless-stopped crash-loop); WARN when
  WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE / _QUEUE are unset (non-compose launches);
  add a `pg-*|*-reaper` catch arm so a typo'd PG command fails loudly instead of
  silently becoming a default Celery worker (log-consumer etc. still pass
  through — verified by routing test).
- docker-compose.yaml: soften "broker-free" wording (fail-closed-to-Celery on a
  missing transport); spell out the executor/notification migration steps
  instead of the ③/④ markers; use the exact `pg_orchestrator_lock` name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3576 address SonarCloud: add explicit default (*) arm to PG-dispatch case

The PG-queue dispatch case relied on an implicit no-match fall-through to the
Celery logic below. SonarCloud flags a case without a default; add an explicit
`*)` no-op arm documenting the intentional fall-through. Behaviour unchanged
(verified by routing test: legit worker types still reach Celery, typo'd PG
commands still rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3576 address greptile: scope PG near-miss catch to pg- prefix only

Narrow the safety-net catch arm from `pg-*|*-reaper` to `pg-*` so it can never
intercept a pluggable worker whose name ends in `-reaper` (e.g. a future
bulk-reaper / log-history-reaper) — those now fall through to the Celery path.
The `pg-` prefix is reserved for PG-queue components; the exact reaper aliases
are already matched above, and pg-prefixed typos (pg-reapr, pg-queue-reapr) are
still rejected loudly. Verified by routing test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…al-write (inert) (#2080)

* UN-3581 [FEAT] PG Queue 9h-a — pg_periodic_schedule mirror table + dual-write (inert)

First inert slice toward replacing Celery Beat with a PG-backed periodic
scheduler (to be folded into the leader-elected reaper/orchestrator loop). This
slice only mirrors each scheduled pipeline's cron definition into a new Postgres
table; nothing reads it yet, so behaviour is unchanged.

- backend/pg_queue/models.py: new PgPeriodicSchedule (pipeline_id PK, org_id,
  workflow_id, pipeline_name, cron_string, enabled; last_run_at/next_run_at left
  NULL — the scheduler tick owns all cron computation in the next slice). Index
  on (enabled, next_run_at) for the future "due schedules" query.
- migration 0008 (generated; makemigrations --check clean).
- backend/scheduler/tasks.py: dual-write the mirror from the four schedule
  choke-points (create/update, pause, resume, delete) that already manage the
  django_celery_beat PeriodicTask. Toggles are placed right after task.save() so
  a downstream pipeline-status failure can't desync the mirror. Every mirror
  write is best-effort (try/except + log) — a mirror failure can NEVER break the
  existing Beat scheduling path.
- tests: 6 DB-free unit tests (field extraction, enable/disable, delete,
  delete-when-PeriodicTask-missing, best-effort swallow).

Inert / non-regressive: nothing reads the table; the PeriodicTask + celery-beat
container are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3581 address review: mirror real pipeline_name from helper, harden toggles, tighten types

- [High] pipeline_name now sourced from the Pipeline object in
  SchedulerHelper._schedule_task_job (pipeline.pipeline_name), not task_args[6]
  — which carries the synthetic "Pipeline job-<id>" label, never the user name.
  The mirror upsert moves to the helper (clean named ids, no positional arg
  parsing); create_or_update_periodic_task is Beat-only again.
- [Med] _mirror_periodic_schedule_set_enabled now bumps updated_at explicitly
  (queryset .update() does not fire auto_now) and logs when it matches 0 rows
  (a pre-existing/unmirrored schedule — backfill lands in ②b).
- [Med] dropped the positional task_args[N] parsing entirely (the helper passes
  named fields), removing the silent-placeholder-on-contract-drift risk.
- [Low] tightened mirror helper param types (str / str | None) — the callers
  already pass stringified UUIDs.
- [Nit] index renamed pg_sched_due_idx -> pg_periodic_schedule_due_idx (sibling
  convention); migration 0008 regenerated (not yet merged).
- tests: cover the standalone upsert (+enabled=False, +failure-swallow), the
  helper wiring proving the real pipeline_name flows, disable/enable/delete
  failure-swallow + still-calls-update_pipeline, and the 0-row log. 11 tests.

cron_string validation: the existing 5-field split unpack in
create_or_update_periodic_task already gates the Beat path before the mirror
runs; full cron semantics are validated by croniter in ②b (the reader).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…trator loop (per-schedule ownership) (#2081)

* UN-3596 [FEAT] PG Queue 9h-b — PG scheduler tick in the reaper/orchestrator loop (per-schedule ownership)

Adds the periodic-trigger half of the orchestrator: the leader-elected reaper
now also fires due, PG-owned schedules onto the PG queue — the Celery Beat
replacement — without Beat/RabbitMQ in the trigger path. Dark by default;
never double-fires.

- pg_scheduler.py (new): dispatch_due_schedules() scans pg_periodic_schedule for
  pg_owned + enabled + due rows, enqueues scheduler.tasks.execute_pipeline_task
  on the PG `scheduler` queue AND advances next_run_at in ONE transaction (a
  crash between can't re-fire). A NULL next_run_at records a baseline and does
  NOT fire (no burst when a schedule is handed over; matches Beat). A bad cron on
  one row is logged and skipped without blocking the others. croniter computes
  next-run; all time comparisons use the DB clock.
- pg_queue/models.py + migration 0009: pg_owned flag (default False = Beat owns
  it; the PG scheduler fires only owned rows) + due index (pg_owned, enabled,
  next_run_at). Default-false keeps the table inert until a schedule is handed
  over, and a schedule fires from exactly one side — never both.
- reaper.py: the leader tick runs the scheduler AFTER recovery (a scheduler error
  can't starve the recovery net).
- workers deps: add croniter (already a backend dep).
- run-worker.sh + docker-compose: pg-scheduler consumer role + service
  (profile-gated) that runs the fired execute_pipeline_task.

Out of scope (next slice ②c): the ramp control that flips pg_owned by percentage
+ disables the matching Beat PeriodicTask atomically (reusing the existing Flipt
mechanism), the one-time backfill, and retiring Beat.

Non-regression: pg_owned defaults False, so the reaper fires nothing until rows
are explicitly owned; recovery-only behaviour is unchanged. Tests: 10 scheduler
(real-PG) + 3 reaper-wiring; full reaper suite kept green via a scheduler stub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3596 address review: per-row DB isolation, self-quiescing bad cron, typed rows, shared INSERT SQL, stronger tests

- [Critical] per-row fire (INSERT+UPDATE+commit) now wrapped in try/except →
  rollback + log + continue, so one bad row can't poison the connection or drop
  the rest of the batch (mirrors recover_expired_barriers). Baseline UPDATE too.
- [High] invalid cron now disables the row (enabled=FALSE) + logs once, instead
  of re-selecting it and emitting a traceback every ~5s tick forever.
- [Med] read step (SELECT now() + due scan) wraps rollback + re-raise so the
  conn isn't handed back in an aborted-txn state.
- [High] softened the "never double-fires" docstring + models.py comment: the
  guarantee is CONDITIONAL on the ②c ramp control disabling Beat; pre-ramp,
  safety rests on pg_owned defaulting to False.
- [Med] _build_trigger_payload -> TaskPayload; workflow_id/pipeline_id typed
  str | uuid.UUID (| None); _DueSchedule NamedTuple binds SELECT columns to
  names at one site (no silent misassign on a reorder).
- [Med] extracted INSERT_MESSAGE_SQL constant in client.py; send() and the
  scheduler share it (no verbatim SQL duplication).
- [Low] comment fixes: reaper tick (ordering not isolation), execute_pipeline_task
  blanks vs Beat populating execution_action, models.py drop "next slice".
- tests: fired == 1 (not >= 1, catches double-fire); next_run asserted at the
  cron's 09:00 match; baseline asserts == 0; +tz-aware next-run; +multi-row
  (fired == 2); +atomicity (advance UPDATE fails post-INSERT → enqueue rolls
  back, next_run unchanged); +reaper scheduler-error-discards-owned-conn. 75 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3596 chore: drop accidentally-committed 9f-design.md (untracked scoping doc)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3596 address greptile P1: roll back if the bad-cron disable UPDATE fails

_quiesce_invalid_cron used contextlib.suppress around the cursor block, so if
the enabled=FALSE UPDATE raised, commit() was skipped and the connection was
left in an aborted-transaction state — poisoning the NEXT row's INSERT (caught
by the outer handler and mislogged as "failed to fire"). Wrap in try/except with
conn.rollback() on failure so the connection is always clean for the next row.
+test: a forced disable-UPDATE failure on a bad-cron row doesn't stop a
following healthy row from firing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3596 chore: remove superseded 9f-design.md (impl merged in #2073)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…fill (Beat→PG hand-over) (#2085)

* UN-3597 [FEAT] PG Queue 9h-c — schedule ownership ramp control + backfill (Beat→PG hand-over)

The ramp control that hands schedules from Celery Beat to the PG scheduler,
per-schedule and reversibly. Inert by default (Flipt at 0% / gate off → every
schedule stays on Beat), so deploying it changes nothing until ops ramps.

- scheduler/ownership.py: resolve_schedule_owner (master-gate PG_QUEUE_TRANSPORT_
  ENABLED → pg_scheduler_enabled Flipt flag, keyed on pipeline_id; fail-closed to
  Beat) + reconcile_ownership_for (ONE transaction: set pg_owned AND
  PeriodicTask.enabled = active AND NOT pg_owned). Doing both atomically is what
  makes "never double-fires" real — a pg_owned schedule always has its Beat
  PeriodicTask disabled. Separate Flipt flag from pg_queue_execution_enabled so
  scheduling and execution ramp independently. No new env.
- scheduler/helper.py: reconcile ownership after the mirror upsert on every
  schedule create/update (best-effort; a no-op while the rollout is off).
- pg_queue management command reconcile_pg_schedules: backfills mirror rows for
  pre-existing Beat schedules + reconciles all ownership against the current
  rollout (run once + after each Flipt ramp; idempotent, --dry-run).
- tests: 10 ownership (fail-closed matrix + the disable-Beat/pause invariants) +
  the helper-wiring assertion (21 backend tests green).

Dev-tested live on the real DB: hand-over (pg_owned + Beat disabled), rollback,
pause; backfill of real unmirrored schedules; and the cross-slice link — a
reconciled-to-PG schedule is fired by the ②b reaper. Full container e2e
(pg-scheduler consumer → real execution via the live Flipt flag) folded into the
ops ramp, alongside the execution canary.

Out of scope: stopping the celery-beat container / removing django_celery_beat
(ops, once ramped to 100%; final decommission).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3597 address review: fix resume double-fire, guard command, atomic-rollback proof, ramp robustness

- [HIGH] resume double-fire: enable_task (resume path, separate from reconcile)
  unconditionally re-enabled Beat — on a pg_owned schedule that meant Beat AND PG
  both firing. Now sets PeriodicTask.enabled = NOT pg_owned (reads the mirror).
- [CRITICAL] reconcile_pg_schedules: per-row json.loads guard (+ isinstance list)
  so one malformed PeriodicTask.args row logs+skips instead of aborting the whole
  command (and starving the reconcile step).
- [HIGH] command failure reporting: reconcile_ownership_for returns None on a
  swallowed DB failure; the command tallies failed, writes an ERROR summary, and
  raises CommandError so automation/ops notice (was: counted failures as success).
- [MED] rollback (PG→Beat) now also clears next_run_at, so a later re-hand-over
  re-enters the NULL baseline instead of bursting on a stale timestamp.
- [MED] reconcile_ownership_for: active is keyword-only (boolean-trap guard).
- [MED] docstring stale name reconcile_schedule_ownership -> reconcile_ownership_for.
- [LOW] flag_key passed by keyword; Flipt-failure log downgraded to
  warning(exc_info=True) (expected, runs every edit); single f-string; dry-run
  now previews would-be pg_owned via resolve_schedule_owner (read-only).
- tests: enable_task resume (both pg_owned states), next_run_at reset on rollback,
  None-on-failure, the full command (backfill skip / malformed-args / non-list /
  dry-run preview / CommandError), and a real-DB atomicity test (force the
  PeriodicTask update to fail → pg_owned rolls back). 29 backend tests green.

Deferred [LOW]: bool-vs-enum return (pg_owned maps directly to the BooleanField;
not a wire contract like WorkflowTransport) — see reply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3597 address SonarCloud: reduce reconcile_pg_schedules complexity + drop redundant except

- Cognitive Complexity 29→<15: extracted handle() into _backfill_mirrors,
  _parse_task_args, and _reconcile_all; handle() is now a thin orchestrator.
- Dropped the redundant `json.JSONDecodeError` from the except tuple — it's a
  ValueError subclass, so `except ValueError` already covers both the parse
  error and the non-array guard.

Behaviour unchanged; 5 command tests + the rest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3597 address SonarCloud: reduce _backfill_mirrors complexity 17→<15

Move the per-arg extraction (the three positional ternaries) out of the backfill
loop into _mirror_fields_from_args, which returns the mirror kwargs (or None on a
bad row). _backfill_mirrors now just loops + splats the fields, dropping the
nested ternaries that drove the cognitive complexity. Behaviour unchanged;
command tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3597 address greptile: race-safe resume, accurate ramp accounting, updated_at, N+1

- [P1] enable_task lost-update race: task.save() could clobber a concurrent
  reconcile_ownership_for that flipped pg_owned=True + PeriodicTask.enabled=False
  → both Beat and PG firing. Now reads pg_owned under select_for_update inside a
  transaction and writes only the enabled column via .update() (no stale full-row
  save). Preserves DoesNotExist on a bad name.
- [P2] reconcile_ownership_for: when there's no mirror row (updated==0) return
  False, not the raw resolved pg_owned — PG can't fire without a row, so the
  effective owner is Beat; returning True would inflate the ramp pg_owned count.
- [P2] reconcile_ownership_for now bumps updated_at in the .update() (queryset
  .update() doesn't fire auto_now), so an ownership flip advances the timestamp.
- [P2] reconcile_pg_schedules._backfill_mirrors N+1: pre-fetch all mirrored
  pipeline ids in one query into a set instead of an EXISTS per PeriodicTask.
- tests updated for all four (incl. select_for_update mocking, no-row→False,
  values_list prefetch). 29 backend tests green; handover + updated_at bump
  re-verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3597 address greptile: add missing import pytest to ownership test

TestReconcileAtomicityRealDB calls pytest.skip() when the DB is unavailable, but
pytest was never imported — so a DB gap would raise NameError instead of skipping
(latent; local runs had a DB so the line was never hit). Add the import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…the PG path (#2086)

* UN-3602 [FIX] PG Queue — restore API-deployment timeout sync-wait on the PG path

On the PG transport, async_execute_bin dispatch returns the bigint
pg_queue_message msg_id as the handle. Writing it into WorkflowExecution.task_id
(a UUIDField) raised ValueError on save, which the broad post-dispatch handler
swallowed and returned EXECUTING — silently skipping the synchronous timeout
poll loop. Every PG-routed API deployment ignored `timeout` and returned
immediately.

- add nullable BigIntegerField queue_message_id to WorkflowExecution (migration
  0020; metadata-only AddField, no table rewrite, safe on large tables)
- store the PG msg_id in queue_message_id; task_id stays NULL on the PG path
- new WorkflowHelper._record_dispatch_handle routes the handle by transport,
  called inside its own try/except so post-dispatch bookkeeping can never skip
  the timeout wait again (the structural root cause)
- Celery path unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3602 [FIX] address review — defensive PG handle parse + regression test

Addresses PR #2086 review (toolkit + greptile):
- _record_dispatch_handle: @classmethod -> @staticmethod (never uses cls)
- parse the PG msg_id defensively (try/except TypeError/ValueError) so a
  malformed/future handle format logs a specific cause instead of being
  absorbed by the caller's generic post-dispatch guard
- update_execution_queue_message_id annotation int -> int | None (matches the
  None guard in the body; mirrors update_execution_task)
- add the missing regression test (test_execute_workflow_async_wait.py): a raise
  during handle recording must NOT skip the timeout poll loop
- add a non-numeric-PG-handle test; scope the routing-test docstring
- comment precision: the ValueError was raised inside update_execution_task on
  save (UUID coercion), not at the call site

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…nify gating to a single flag (#2094)

* UN-3603 [GATED-FEAT] PG Queue — blocking executor RPC on Postgres + unify gating to a single flag

A parallel PG-native executor RPC for prompt-studio's blocking dispatch, gated by
the single pg_queue_enabled flag. The SDK ExecutionDispatcher and the Celery
executor worker are left completely untouched.

- pg_task_result store (migration 0010) + PgResultBackend (store / poll-wait;
  idempotent ON CONFLICT; poll-based, PgBouncer-safe — no LISTEN/NOTIFY)
- reply_key on the shared TaskPayload contract + backend producer
- consumer result-write hook: request-reply messages store result/error + ack
  after one attempt; fire-and-forget path unchanged (guarded by reply_key)
- backend executor_rpc: resolve_executor_transport gate + PgExecutionDispatcher
  (enqueue + poll, mirrors the SDK dispatch contract) + RoutingExecutionDispatcher;
  _get_dispatcher returns the routing dispatcher so all call sites stay unchanged
- worker-pg-executor role (run-worker.sh + docker-compose; broker-free)
- unify PG-queue gating to a SINGLE flag pg_queue_enabled: execution, scheduler,
  and executor all read one key (renamed from pg_queue_execution_enabled /
  pg_scheduler_enabled / pg_executor_enabled). One flip gates the whole feature;
  the PG_QUEUE_TRANSPORT_ENABLED env stays the master kill-switch.

Gated off by default. Dev-tested live: on->PG / off->Celery cycle (COMPLETED both
ways) for API + ETL; executor RPC round-trip through worker-pg-executor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3603 [GATED-FEAT] address SonarCloud — unused param, logging.exception, no-NULL error

- PgExecutionDispatcher.dispatch: drop the unused `headers` param (PG carries
  fairness in the enqueue payload, not Celery headers; the routing dispatcher no
  longer forwards headers to the PG path)
- enqueue failure handler: logger.error(exc_info=True) -> logger.exception
- PgTaskResult.error: TextField(null=True) -> TextField(blank=True, default="")
  (no-NULL-text convention); store_result writes "" on completed; migration 0010
  regenerated; result-backend test updated

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3603 [GATED-FEAT] address review — request-reply robustness, single-flag constant, tests, doc accuracy

Toolkit review (UN-3603):
- Critical: guard the success-path result store in the consumer — a store failure
  now logs + acks (avoids re-running the executor = LLM spend) instead of
  vt-redelivering; pre-execution drop branches (malformed/poison/unknown) and the
  task-raised branch store a definitive failure reply via a new guarded
  _fail_reply so the caller fails fast instead of blocking to its timeout
- Med: shared PgTaskStatus StrEnum in unstract.core (writer + reader agree across
  the process boundary); guard ExecutionResult.from_dict so a malformed completed
  row can't break the never-raises contract; log timeout/failure branches
- High: release the DB connection between dispatch polls (close_old_connections)
  + document dispatch must not run inside a transaction
- Med: single source of truth for the flag key (pg_queue/flags.py PG_QUEUE_FLAG_KEY,
  imported by transport/ownership/executor_rpc; SCHEDULER_FLAG_KEY folded in)
- Med: result-backend logs the swallowed rollback failure
- doc/accuracy: soften the not-yet-wired retention-sweep claims; fix stale
  pg_executor_enabled -> pg_queue_enabled comments; tighten reply_key to NotRequired[str]
- tests: PgExecutionDispatcher.dispatch (8 branches, DB-free) + consumer reply_key
  store/ack + drop-branch + store-failure-still-acks cases

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3603 [GATED-FEAT] address SonarCloud — logging.exception + drop comment-as-code

- consumer.py: the two new request-reply error handlers use logger.exception()
  instead of logger.error(..., exc_info=True)
- test_executor_rpc.py: reword the `# timeout=None` inline comment (Sonar read it
  as commented-out code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3603 [GATED-FEAT] address greptile — executor consumer VT/health tuning + guard env timeout parse

greptile's load-bearing finding: worker-pg-executor inherited the consumer's
30s/60s vt / health-stale defaults, so an LLM task exceeding 30s would be
re-claimed mid-run once the gate ramps — double execution + token double-spend,
two runs racing the same reply_key.
- docker-compose: set WORKER_PG_QUEUE_CONSUMER_VT_SECONDS=3660 and
  HEALTH_STALE_SECONDS=3720 on worker-pg-executor (above the executor's hard
  EXECUTOR_TASK_TIME_LIMIT=3600), both overridable via env
- executor_rpc.dispatch: guard the EXECUTOR_RESULT_TIMEOUT int parse so a
  misconfigured value can't raise out of dispatch() (never-raises) + test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…gres (+ register executors in PG consumer) (#2095)

* UN-3605 [GATED-FEAT] PG Queue — structure_tool executor RPC over Postgres (+ register executors in PG consumer)

Routes the in-workflow structure_tool executor dispatch onto the Postgres
executor RPC when the single pg_queue_enabled flag is on; Celery otherwise
(zero-regression by construction). Next slice after UN-3603 (prompt-studio
blocking path). Gated off by default.

- workers queue_backend/pg_queue/executor_rpc.py (new): the workers twin of
  the backend module on worker primitives — resolve_executor_transport
  (master PG_QUEUE_TRANSPORT_ENABLED env, then the single pg_queue_enabled
  Flipt flag, fail-closed to Celery); PgExecutionDispatcher (enqueue
  execute_extraction with a unique reply_key via PgQueueClient, poll
  PgResultBackend; never raises — timeout/failure -> ExecutionResult.failure);
  RoutingExecutionDispatcher (per-call gate routing; async/callback stay on
  Celery); get_executor_dispatcher factory.
- to_payload gains an optional reply_key (request-reply marker; fire-and-forget
  rows stay byte-identical).
- structure_tool_task swaps its dispatcher factory to get_executor_dispatcher;
  the 3 blocking dispatch call sites are unchanged.
- Fix (latent gap exposed by this slice): the PG executor consumer registered
  no executors ("No executor registered with name 'legacy'"). The
  @ExecutorRegistry.register side-effect import lived only in the Celery
  executor/worker.py, but the PG consumer bootstraps via executor/tasks.py.
  Import executor.executors from executor/tasks.py so any worker registering
  execute_extraction (Celery or PG) has the executors — also fixes the
  prompt-studio PG path from UN-3603.
- sample.env: document the worker-side PG_QUEUE_TRANSPORT_ENABLED gate; fix a
  stale pg_queue_execution_enabled -> pg_queue_enabled doc reference.
- Tests: workers executor_rpc unit suite (gate fail-closed matrix,
  zero-regression routing, never-raises dispatch); subprocess regression that
  importing executor.tasks alone registers the executors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3605 [GATED-FEAT] Address PR review (toolkit + greptile)

Hardening + test-coverage from the review:
- dispatch: row.get("status") (never-raises must not depend on producer keys);
  accept-and-ignore headers param for substitutability with the SDK/Routing
  dispatch shapes; log the EXECUTOR_RESULT_TIMEOUT parse fallback instead of
  swallowing it; surface the parse cause in the malformed-result error.
- timeout branch: document the orphaned-task / retry-double-run risk (greptile);
  de-dup belongs at the file-execution layer (at-least-once + caller-timeout).
- _wait_for_result: note the connection pin is bounded by file_processing
  prefork concurrency (vs the backend twin's close_old_connections per poll).
- executor/tasks.py: reword the registration-import comment (it's executor/
  worker.py, the Celery entrypoint, that the PG consumer bootstrap skips).
- tests: org-less resolve bucketing (entity_id=run_id, no org in context);
  real _enqueue wiring (queue/org_id/payload via PgQueueClient.send); routing
  arg passthrough (gate-off forwards timeout+headers; gate-on drops headers);
  to_payload reply_key set/omitted; structure_tool factory-swap call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…tor RPCs run in parallel (#2096)

* UN-3606 [FIX] PG Queue — prefork the consumer so file batches + executor RPCs run in parallel

The PG file-processing path ran files SERIALLY: worker-pg-fileproc was a single
batch=1 consumer, so multi-file ETL batches drained one at a time (Celery runs
them in parallel via prefork --concurrency). Same single-consumer bottleneck on
worker-pg-executor serialized the executor RPCs (the LLM extraction), so even
parallel file fan-out gained nothing end-to-end.

Fix: a prefork supervisor for the PG-queue consumer launcher — the PG analogue of
Celery --pool=prefork --concurrency=N.

- supervisor.py (new): when WORKER_PG_QUEUE_CONSUMER_CONCURRENCY > 1, fork N
  isolated consumer children (each does its own worker bootstrap, so no
  connections are inherited across the fork), monitor + re-fork dead children, and
  own a single fleet-liveness endpoint (503 when the oldest child stalls past the
  threshold). SKIP LOCKED distributes batches across children AND replicas; a
  single execution is still capped by MAX_PARALLEL_FILE_BATCHES; total live
  parallelism = concurrency x replicas (k8s HPA scales replicas).
- The consumer code is UNCHANGED — each child is the current single-threaded
  PgQueueConsumer.run(); concurrency is purely a launch concern. CONCURRENCY=1
  (default) keeps the byte-identical single-process path, so every other PG
  consumer is non-regressive.
- consumer.py: extract build_consumer_from_env()/consumer_env() so the children
  and main() build identical consumers.
- docker-compose: CONCURRENCY=4 on worker-pg-fileproc AND worker-pg-executor
  (mirrors WORKER_FILE_PROCESSING_CONCURRENCY). Also raise their VT_SECONDS to
  3660 — process_file_batch/execute_extraction block on the executor up to 3600s,
  so the prior vt=30 default would re-claim a long batch mid-run (latent
  double-run); health-stale sits just above vt.
- Tests: env-knob parse/clamp/validation, fleet-staleness calc, reap/restart
  matrix (dead -> re-fork, live -> left, shutdown -> not resurrected).

Process model matches Celery prefork (the cloud-trusted choice): full process
isolation, a crash is contained + re-forked, its in-flight message redelivers via
vt (at-least-once). Dev-tested end-to-end: 4 fileproc + 4 executor children, a
2-file ETL runs fully parallel (both files + both executor RPCs overlap on
separate children; ~25s vs ~42s serial), child crash auto-restarts, health green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3606 [FIX] Address PR review (toolkit + greptile) — supervisor hardening

Critical/High:
- Crash-loop no longer hides a wedged fleet from the probe: re-fork does NOT
  reseed the heartbeat (a never-polling slot ages), and a slot that dies
  immediately N times in a row forces freshness=inf → 503 (k8s restarts the pod).
- os.fork() wrapped: initial-fleet failure fails fast with an actionable message;
  reap-path failure logs + leaves the slot for the next tick (no uncaught crash /
  no SIGTERM storm against healthy children).
- Heartbeat publish loop guarded (try/except + log) so a transient error can't
  silently kill the thread and false-stale a healthy child.
- Children reset SIGTERM/SIGINT to SIG_DFL immediately after fork — a signal in
  the fork→run window no longer fires the parent's _on_term against stale pids.

Medium:
- _Fleet class owns pid/last_fork/heartbeat/crash-count/restart-schedule with a
  validated slot and consistent reap (replaces three loose slot-keyed dicts).
- Re-fork backoff is a scheduled not-before (non-blocking), re-checking stopping
  before each fork — no in-loop sleep serialising recovery, no child spawned into
  shutdown.
- Liveness bind: EADDRINUSE logged at error (vs transient), and
  liveness_probe_bound surfaced in the status payload.
- _join_children: per-child grace budget (one slow child can't starve the rest);
  _wait_for_exit helper extracted.

Low:
- real Callable annotations (drop noqas); HEALTH_PORT typed int|None; comment
  fixes (heartbeat writer reasoning, "JSON body differs" for the fleet probe);
  WORKER_TYPE selection deduped into pg_queue_consumer/_bootstrap.py.

Tests rewritten + expanded: _Fleet bookkeeping/crash-loop/freshness, reap
scheduling, restart-due gating, fork OSError + child hard-exit guard, _wait_for_exit
+ _join_children SIGKILL escalation, health no-port/bind-error. 32 tests.
Re-validated live: 4 children, fleet health (crash_looping/liveness_probe_bound
fields), crash → re-fork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3606 [FIX] Address SonarCloud — reliability + maintainability nits

- test: avoid float equality (math.isinf for the crash-loop freshness; drop the
  0.0 literal) — clears the only New-Code reliability bug (Rating C → A).
- supervisor: clean the `# noqa: ANN201` suppression syntax (explanation moved to
  the docstring); narrow the child-failure catch BaseException → Exception (the
  realistic startup-failure surface; SystemExit/KeyboardInterrupt would exit
  anyway); logger.error(exc_info=True) → logger.exception() on the os.fork() guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* UN-3606 [FIX] Address greptile — snapshot crash dict in is_crash_looping

is_crash_looping() runs in the LivenessServer daemon thread (via freshness())
while the main thread mutates _consecutive_crashes in schedule_restart() — a bare
.values() iteration could raise "dictionary changed size during iteration".
Snapshot with tuple(...) (atomic under the GIL) before iterating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/workflow_manager/internal_views.py (1)

557-596: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reload the transport marker under the row lock before applying related writes.

get_object() can observe queue_message_id=None immediately before dispatch records the PG marker. The model may subsequently detect PG and reject the requested status, but this view still persists the request’s aggregates and terminal-file cascade, leaving a protected COMPLETED execution with mismatched counters or ERROR files.

Proposed fix
 with transaction.atomic():
-    if execution.queue_message_id is not None:
-        execution = WorkflowExecution.objects.select_for_update().get(
-            pk=execution.pk
-        )
+    execution = WorkflowExecution.objects.select_for_update().get(
+        pk=execution.pk
+    )
+    if execution.queue_message_id is not None:
         protected = {
             ExecutionStatus.COMPLETED.value,
             ExecutionStatus.STOPPED.value,
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow_manager/internal_views.py` around lines 557 - 596, Move the
queue_message_id reload and protected-status validation so they always occur
under the select_for_update row lock before update_execution,
_update_file_aggregates, or _cascade_terminal_files. Re-read the transport
marker after locking, then skip all related writes when the execution is already
protected and the requested status differs; retain the existing response
behavior for skipped updates.
🟠 Major comments (29)
unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py-229-235 (1)

229-235: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not return raw infrastructure exceptions to callers.

str(exc) can expose database, connection, or configuration details through ExecutionResult.error. Keep the traceback in logs and return a stable public message.

Proposed fix
-            return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}")
+            return ExecutionResult.failure(
+                error="Unable to enqueue the executor request"
+            )
...
-            return ExecutionResult.failure(error=f"{type(exc).__name__}: {exc}")
+            return ExecutionResult.failure(
+                error="Unable to retrieve the executor result"
+            )

Also applies to: 244-253

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unstract/workflow-execution/src/unstract/workflow_execution/executor_rpc.py`
around lines 229 - 235, Update the exception handlers around the PG executor
dispatch enqueue and related failure path to stop embedding str(exc) in
ExecutionResult.failure. Preserve logger.exception for traceback diagnostics,
but return a stable, non-sensitive public error message while retaining the
existing exception logging context.
backend/pg_queue/producer.py-64-65 (1)

64-65: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the persisted priority synchronized with fairness.pipeline_priority.

A caller can provide fairness priority 10 while omitting priority, producing a row claimed at priority 5. Mismatched explicit values likewise make the wire payload disagree with actual queue ordering. Derive the row priority from fairness, or reject mismatches.

Proposed fix
-    priority: int = DEFAULT_PRIORITY,
+    priority: int | None = None,
     fairness: FairnessPayload | None = None,
@@
+    fairness_priority = (
+        fairness["pipeline_priority"] if fairness is not None else None
+    )
+    if priority is None:
+        priority = (
+            fairness_priority
+            if fairness_priority is not None
+            else DEFAULT_PRIORITY
+        )
+    elif fairness_priority is not None and priority != fairness_priority:
+        raise ValueError(
+            "priority must match fairness.pipeline_priority"
+        )
+
     if not FAIRNESS_MIN_PRIORITY <= priority <= FAIRNESS_MAX_PRIORITY:

Also applies to: 94-108, 127-132

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/pg_queue/producer.py` around lines 64 - 65, Update the producer entry
points and row-construction logic around the priority and fairness parameters so
persisted queue priority always matches fairness.pipeline_priority. When
fairness is provided, derive priority from its pipeline_priority; if an explicit
priority is retained, validate that it matches and reject mismatches, including
the default-priority case.
backend/scheduler/ownership.py-123-126 (1)

123-126: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject reconciliation when the matching Beat task is absent.

The update count is ignored. An orphan mirror can therefore be marked pg_owned=True even though its PeriodicTask was deleted, reviving a removed schedule through PG. Check the affected-row count and quiesce/report the orphan inside this transaction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/scheduler/ownership.py` around lines 123 - 126, Update the
reconciliation logic around PeriodicTask.objects.filter(...).update(...) to
inspect the affected-row count. If no matching Beat task exists, quiesce the
orphan mirror and report the reconciliation failure within the same transaction;
only apply the active and not pg_owned state when the update succeeds.
backend/pg_queue/management/commands/reconcile_pg_schedules.py-114-121 (1)

114-121: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Treat a swallowed mirror upsert as a command failure.

mirror_periodic_schedule_upsert catches write/validation errors, but this command still increments backfilled and exits successfully. The missing row is then absent from reconciliation, giving operators a false successful migration result. Return a success signal or provide a strict command mode and include failures in failed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/pg_queue/management/commands/reconcile_pg_schedules.py` around lines
114 - 121, Update the reconciliation flow around mirror_periodic_schedule_upsert
so swallowed upsert errors are detected and treated as command failures. Only
increment backfilled after a confirmed successful upsert; otherwise record the
pipeline in failed and ensure the command exits unsuccessfully, while preserving
dry-run behavior.
backend/scheduler/tests/test_pg_schedule_ownership.py-142-165 (1)

142-165: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Run this atomicity test as a real DB test.
CrontabSchedule.objects.get_or_create() is wrapped in except Exception, so pytest-django access errors and schema/migration failures both turn into skip(). Add @pytest.mark.django_db(transaction=True) and narrow the skip to unreachable-DB cases only; otherwise this rollback check can disappear instead of failing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/scheduler/tests/test_pg_schedule_ownership.py` around lines 142 -
165, Update
TestReconcileAtomicityRealDB.test_periodictask_update_failure_rolls_back_pg_owned
by adding the django_db(transaction=True) marker and narrowing the
CrontabSchedule.objects.get_or_create() exception handling to only genuine
database-unreachable cases; let schema, migration, and other setup failures
propagate so the rollback test cannot silently skip.
workers/run-worker.sh-54-83 (1)

54-83: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include the IDE callback consumer in the host PG set.

Docker Compose launches ide_callback, but PG_CONSUMER_ROLES omits it. Consequently, ./run-worker.sh -d pg leaves PG-routed Prompt Studio callback messages unconsumed. Add an ide_callback;ide_callback role and register it in the maps/help.

Also applies to: 116-121, 205-213, 1007-1010

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/run-worker.sh` around lines 54 - 83, Add a dedicated IDE callback
role to PG_CONSUMER_ROLES using the ide_callback queue, then register
PG_ROLE_IDE_CALLBACK in PG_QUEUE_MEMBERS and the corresponding role/help
mappings referenced elsewhere in run-worker.sh. Ensure ./run-worker.sh -d pg
includes this consumer so Prompt Studio callback messages are consumed.
workers/run-worker.sh-1238-1239 (1)

1238-1239: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make environment validation transport-aware.

The unconditional validator requires CELERY_BROKER_BASE_URL, so broker-free commands such as pg-executor and pg_queue_reaper cannot start in a PG-only deployment. Require Celery variables only when at least one requested target uses the Celery transport.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/run-worker.sh` around lines 1238 - 1239, Update validate_env and its
invocation in the worker startup flow to be transport-aware: require Celery
broker variables only when at least one requested target uses the Celery
transport, while allowing broker-free targets such as pg-executor and
pg_queue_reaper to start without them. Preserve validation of all required
PG-only variables and existing Celery validation when Celery targets are
requested.
workers/run-worker.sh-1058-1061 (1)

1058-1061: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not restart multiple targets using only the first target's cleanup.

Launch iterates over every positional target, while restart cleanup uses only WORKER_TYPE. For example, -r pg all stops PG workers and then starts both sets without stopping existing Celery workers, creating duplicate consumers. Either reject multiple targets with --restart or stop every requested target before launching any.

Also applies to: 1133-1135, 1167-1186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/run-worker.sh` around lines 1058 - 1061, Update the restart flow
around WORKER_TYPE and the launch loop to ensure every positional target is
cleaned up before any target starts. Either reject multiple WORKER_TYPES when
restart mode is enabled, or iterate over WORKER_TYPES and apply the existing
cleanup to each target before launching; do not rely solely on the first
target’s cleanup.
workers/run-worker.sh-1244-1248 (1)

1244-1248: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail instead of continuing without --detach.

This branch only warns. If all is the first target, run_all_workers waits indefinitely and subsequent targets are never launched.

Proposed fix
 if [[ ${`#WORKER_TYPES`[@]} -gt 1 && "$DETACH" != "true" ]]; then
     print_status $YELLOW "Multiple worker types given without -d/--detach: a foreground set will block the rest. Re-run with -d to start them all."
+    exit 1
 fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/run-worker.sh` around lines 1244 - 1248, Update the multi-type launch
guard in the run-worker dispatch logic to fail immediately when WORKER_TYPES
contains more than one type and DETACH is not true, rather than only calling
print_status. Preserve the existing warning text or equivalent context, then
return a nonzero status so run_all_workers cannot block and later targets are
not skipped.
workers/run-worker.sh-849-868 (1)

849-868: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Propagate -c/--concurrency to PG consumers.

The Celery command receives --concurrency, but that command is then replaced with python -m pg_queue_consumer. No WORKER_PG_QUEUE_CONSUMER_CONCURRENCY value is exported, so -c 4 pg-fileproc still runs with the module default.

Proposed fix
         export WORKER_PG_QUEUE_CONSUMER_QUEUE="$queues"
         export WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE="${WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE:-notification}"
+        if [[ -n "$concurrency" ]]; then
+            export WORKER_PG_QUEUE_CONSUMER_CONCURRENCY="$concurrency"
+        fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/run-worker.sh` around lines 849 - 868, Update the PG consumer setup
in the worker command replacement block to export the requested concurrency
value as WORKER_PG_QUEUE_CONSUMER_CONCURRENCY before launching
pg_queue_consumer. Derive it from the existing -c/--concurrency argument
handling so values such as “-c 4 pg-fileproc” reach the module instead of
falling back to its default; leave the existing queue, worker-type, health-port,
and cmd_args behavior unchanged.
workers/pg_queue_consumer/supervisor.py-528-542 (1)

528-542: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fail closed when the configured liveness endpoint cannot bind.

Continuing without the endpoint breaks the fleet’s health contract; an existing listener can also answer probes for the wrong process.

  • workers/pg_queue_consumer/supervisor.py#L528-L542: log and re-raise the bind error instead of returning None.
  • workers/tests/test_pg_consumer_supervisor.py#L378-L387: expect the bind error to propagate rather than asserting it is swallowed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/pg_queue_consumer/supervisor.py` around lines 528 - 542, Make the
liveness bind failure fail closed: in workers/pg_queue_consumer/supervisor.py
lines 528-542, keep the existing logging in the server.start exception path but
re-raise the OSError instead of returning None. Update
workers/tests/test_pg_consumer_supervisor.py lines 378-387 to assert that the
bind error propagates rather than being swallowed.
workers/pg_queue_consumer/_bootstrap.py-22-24 (1)

22-24: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Treat an empty worker-type variable as unset.

Line 22 preserves WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE="", despite documenting notification as the default. That can select the fallback task registry and make the consumer reject its intended tasks.

Proposed fix
-    os.environ["WORKER_TYPE"] = os.environ.get(
-        "WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE", "notification"
-    )
+    source_worker_type = os.environ.get(
+        "WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE", ""
+    ).strip()
+    os.environ["WORKER_TYPE"] = source_worker_type or "notification"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/pg_queue_consumer/_bootstrap.py` around lines 22 - 24, Update the
WORKER_TYPE initialization in the bootstrap module to treat an empty
WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE value as unset, selecting "notification"
instead. Preserve non-empty configured worker types unchanged.
workers/pg_queue_consumer/supervisor.py-355-365 (1)

355-365: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Avoid os.fork() after the liveness thread starts. health = _maybe_start_supervisor_health(fleet) starts LivenessServer.start(), which spawns a daemon thread; _restart_due_children() then keeps forking from that multi-threaded parent. The child immediately imports modules, logs, and starts its own heartbeat thread, which can deadlock on inherited locks. Use spawn/forkserver, or exec a fresh consumer after fork.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/pg_queue_consumer/supervisor.py` around lines 355 - 365, Change the
child restart flow around _restart_due_children and _try_fork_child so it no
longer calls os.fork() after _maybe_start_supervisor_health starts
LivenessServer’s thread. Use a multiprocessing spawn/forkserver mechanism or
fork only into an immediate exec of a fresh consumer, ensuring the replacement
child does not inherit threads or execute Python work in the multi-threaded
parent.
workers/shared/workflow/execution/service.py-1389-1414 (1)

1389-1414: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not fail open when idempotency cannot be verified.

On an at-least-once PG retry, a transient FileHistory failure currently permits the destination write. This can duplicate non-idempotent database, filesystem, or HITL side effects. Propagate a retryable failure and retain the queue message until the lookup conclusively returns found/not-found.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/shared/workflow/execution/service.py` around lines 1389 - 1414, The
FileHistory lookup in the duplicate-write guard must not proceed with the write
when verification fails. Update the exception path around
get_file_history_by_cache_key to propagate a retryable failure, allowing the
queue message to be retained, while preserving the existing found/not-found
behavior for successful lookups and the early-return guards.
workers/general/tasks.py-309-331 (1)

309-331: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Clear task-local state before this terminal early return.

Line 327 bypasses both StateStore.clear_all() blocks after LOG_EVENTS_ID was set above. The reused worker process can therefore route the next task’s UI logs to the previous execution.

Move cleanup into an outer finally, or explicitly clear StateStore before returning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/general/tasks.py` around lines 309 - 331, Clear the task-local
StateStore before the terminal early return in the orchestration flow, ensuring
the LOG_EVENTS_ID and related state from the finished execution cannot affect
later tasks. Update the terminal branch around the existing
StateStore.clear_all() handling, preferably by placing cleanup in an outer
finally that covers all return paths.
workers/file_processing/tasks.py-340-349 (1)

340-349: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Clear StateStore before returning a terminal-skip result.

The guard runs after organization context is stored, but this early return bypasses _compile_batch_result and its StateStore.clear_all(). A stale/redelivered PG batch can therefore leak task/tenant context into later work in the same process.

Proposed fix
     except _TerminalExecutionSkip as skip:
         logger.warning(
             ...
         )
+        try:
+            StateStore.clear_all()
+        except Exception:
+            logger.warning(
+                "Failed to clean StateStore after terminal execution skip",
+                exc_info=True,
+            )
         return _terminal_skip_result(batch_data)

Also applies to: 530-533

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/file_processing/tasks.py` around lines 340 - 349, Before returning
from the _TerminalExecutionSkip handler in the batch execution flow, explicitly
clear StateStore using its existing clear_all() operation. Apply the same
cleanup to the corresponding terminal-skip return around the alternate
referenced location, ensuring context is cleared before
_terminal_skip_result(batch_data) is returned.
backend/workflow_manager/internal_views.py-718-744 (1)

718-744: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not treat all existing rows as all expected files.

For an execution with total_files=3 but only one completed file row, this computes COMPLETED and then rewrites total_files to 1. That silently erases the two missing files and reports a false successful run.

Compare the observed row count with the persisted expected count. Missing rows should either produce a failure or remain skipped until queue state proves no work is outstanding; they must not be finalized as successful or reduce total_files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow_manager/internal_views.py` around lines 718 - 744, The
file-recovery logic around the observed counts must compare the row count with
the execution’s persisted expected total before finalizing. When rows are
missing, do not compute COMPLETED or overwrite total_files with the smaller
observed count; instead return "skipped" or produce the established failure
outcome until queue state confirms no work remains. Preserve the persisted
total_files when calling _update_file_aggregates, while retaining the existing
errored/stopped/completed status precedence for complete row sets.
backend/workflow_manager/workflow_v2/migrations/0023_workflowexecution_active_by_workflow_idx.py-84-95 (1)

84-95: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Scope the invalid-index check to the schema being migrated.

pg_class.relname is not schema-unique. An invalid we_active_by_workflow_idx in another tenant schema will make this migration fail even when the current schema’s index is valid.

Proposed fix
 SELECT 1 FROM pg_class c
 JOIN pg_index i ON i.indexrelid = c.oid
-WHERE c.relname = '{INDEX_NAME}' AND NOT i.indisvalid
+JOIN pg_namespace n ON n.oid = c.relnamespace
+WHERE c.relname = '{INDEX_NAME}'
+  AND n.nspname = current_schema()
+  AND NOT i.indisvalid
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/workflow_manager/workflow_v2/migrations/0023_workflowexecution_active_by_workflow_idx.py`
around lines 84 - 95, Update the _ASSERT_INDEX_VALID SQL check to scope the
index lookup to the schema being migrated, joining or filtering through
pg_namespace alongside pg_class.relname. Use the migration’s current schema
identifier and preserve the existing invalid-index exception behavior for a
matching index in that schema.
backend/workflow_manager/workflow_v2/models/execution.py-442-459 (1)

442-459: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Synchronize self.status before status-optional PG saves.

When status=None and only error or increment_attempt is supplied, save(update_fields=...) invokes _handle_execution_cache() with the stale in-memory status. The database remains correct, but the execution cache can be reverted.

Proposed fix
 committed = locked["status"]
+self.status = committed
 should_release = False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow_manager/workflow_v2/models/execution.py` around lines 442 -
459, Update the status-update flow around _apply_guarded_status and the
conditional save so self.status is synchronized with the locked committed status
whenever status is None before save(update_fields=...). Preserve explicit status
handling while ensuring _handle_execution_cache() never receives a stale
in-memory status during error-only or attempt-only updates.
backend/workflow_manager/execution/tests/test_execution_serializer.py-24-37 (1)

24-37: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Restore the process-global import state after loading the serializer.

This permanently replaces workflow_manager.workflow_v2.models and DJANGO_SETTINGS_MODULE during test collection. Later modules can import the stub instead of the real model package, causing order-dependent failures.

Proposed isolation
+_missing = object()
+_previous_settings = os.environ.get("DJANGO_SETTINGS_MODULE", _missing)
+_previous_models = sys.modules.get(
+    "workflow_manager.workflow_v2.models", _missing
+)
 os.environ["DJANGO_SETTINGS_MODULE"] = "backend.settings.test"
 
 _models_stub = types.ModuleType("workflow_manager.workflow_v2.models")
 _models_stub.WorkflowExecution = type("WorkflowExecution", (), {})
 sys.modules["workflow_manager.workflow_v2.models"] = _models_stub
 
-from workflow_manager.execution.serializer.execution import (
-    ExecutionSerializer,
-)
+try:
+    from workflow_manager.execution.serializer.execution import (
+        ExecutionSerializer,
+    )
+finally:
+    if _previous_models is _missing:
+        sys.modules.pop("workflow_manager.workflow_v2.models", None)
+    else:
+        sys.modules["workflow_manager.workflow_v2.models"] = _previous_models
+
+    if _previous_settings is _missing:
+        os.environ.pop("DJANGO_SETTINGS_MODULE", None)
+    else:
+        os.environ["DJANGO_SETTINGS_MODULE"] = _previous_settings
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow_manager/execution/tests/test_execution_serializer.py` around
lines 24 - 37, Restore the original process-global state after importing
ExecutionSerializer: save any existing DJANGO_SETTINGS_MODULE and
workflow_manager.workflow_v2.models entry, apply the temporary test settings and
model stub only for that import, then restore or remove both entries in a
guaranteed cleanup path. Keep the serializer import behavior unchanged while
ensuring later test modules see their original environment and module state.
workers/queue_backend/pg_barrier.py-623-635 (1)

623-635: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject barriers that cannot be recovered by the reaper.

A missing organization_id is acknowledged as unrecoverable, but enqueue continues and creates the barrier anyway. Fail before writing or dispatching instead of allowing a stranded execution with no usable recovery path.

Proposed fix
-            organization_id = str(callback_kwargs.get("organization_id") or "")
-            if not organization_id:
-                logger.error(
-                    f"[exec:{execution_id}] PgBarrier enqueued with NO "
-                    f"organization_id in callback_kwargs — a stranded barrier "
-                    f"could not be reaper-recovered. This is a bug in the caller."
-                )
+            raw_organization_id = callback_kwargs.get("organization_id")
+            if not raw_organization_id:
+                raise ValueError(
+                    "PgBarrier requires organization_id in callback_kwargs "
+                    "for org-scoped reaper recovery"
+                )
+            organization_id = str(raw_organization_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_barrier.py` around lines 623 - 635, The PgBarrier
enqueue flow should reject missing organization_id instead of logging and
continuing. In the callback_kwargs validation around execution_id, raise or
otherwise fail before any barrier write or dispatch when organization_id is
empty, while preserving the existing error context so unrecoverable barriers
cannot be created.
workers/queue_backend/pg_barrier.py-1082-1094 (1)

1082-1094: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Preserve the recovery row when the Celery link cannot store a result.

On the Celery-link path, this branch deletes pg_barrier_state and raises without marking the execution terminal. The failed link has no outer in-body handler, and the reaper loses its only recovery handle, potentially leaving the execution EXECUTING indefinitely. Leave the row for reaping; the PG in-body path already performs terminal marking and teardown in its outer handler.

Proposed fix
     except psycopg2.DataError:
-        logger.exception(
-            f"[exec:{execution_id}] Header result rejected by jsonb (e.g. a NUL "
-            f"byte) — tearing down the barrier so the execution fails fast "
-            f"rather than hanging until expiry."
-        )
-        with contextlib.suppress(Exception):
-            _delete_barrier(execution_id)
+        logger.exception(
+            f"[exec:{execution_id}] Header result rejected by jsonb; "
+            f"leaving the barrier row intact for reaper recovery."
+        )
         raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_barrier.py` around lines 1082 - 1094, In the
psycopg2.DataError handling around the Celery-link result-storage path, stop
calling _delete_barrier(execution_id) before re-raising. Preserve the
pg_barrier_state recovery row so the reaper can handle the still-nonterminal
execution, while leaving the existing PG in-body terminal-marking and teardown
behavior unchanged.
workers/queue_backend/barrier.py-101-108 (1)

101-108: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

workers/queue_backend/barrier.py:101-108 — Raise the stuck timeout above the longest legitimate file-processing runtime.
With FILE_PROCESSING_TASK_TIME_LIMIT=10800, the 9000s no-progress window can mark an active run ERROR 30 minutes early if no other batch completes. Derive this default from the task-limit ceiling, or validate the two envs together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/barrier.py` around lines 101 - 108, Update
_DEFAULT_BARRIER_STUCK_TIMEOUT_SECONDS so the default no-progress window is
derived from or exceeds the configured FILE_PROCESSING_TASK_TIME_LIMIT ceiling,
including the 10800-second deployment value. Ensure the related environment
configuration is validated together so an active file-processing task cannot be
marked stuck before its legitimate runtime expires.
workers/tests/test_leader_election.py-158-168 (1)

158-168: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail instead of skipping when REQUIRE_PG_TESTS is enabled.

These custom fixtures bypass the mandatory-integration-lane contract, so unavailable PostgreSQL or missing migrations can silently skip core lease and recovery guarantees.

  • workers/tests/test_leader_election.py#L158-L168: route connection and migration failures through a REQUIRE_PG_TESTS-aware helper.
  • workers/tests/test_pg_reaper.py#L753-L763: apply the same behavior to barrier_conn.
  • workers/tests/test_pg_reaper.py#L1288-L1298: apply the same behavior to claim_conn.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/tests/test_leader_election.py` around lines 158 - 168, Update the
custom PostgreSQL setup blocks in workers/tests/test_leader_election.py:158-168,
workers/tests/test_pg_reaper.py:753-763 for barrier_conn, and
workers/tests/test_pg_reaper.py:1288-1298 for claim_conn to use the shared
REQUIRE_PG_TESTS-aware failure/skip helper for connection and migration checks.
Preserve normal skipping when the requirement is disabled, but fail the tests
when REQUIRE_PG_TESTS is enabled and PostgreSQL is unavailable or
pg_orchestrator_lock is missing.
workers/queue_backend/pg_queue/client.py-383-405 (1)

383-405: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Bind lease renewal to the specific claim.

After expiry and re-claim, an old worker can call set_vt(msg_id, ...) and extend the new worker’s lease. Include read_ct or a dedicated receipt token in the predicate.

Proposed direction
-def set_vt(self, msg_id: int, vt_seconds: int) -> bool:
+def set_vt(self, msg_id: int, read_ct: int, vt_seconds: int) -> bool:
 ...
- "SET vt = ... WHERE msg_id = %s",
- (vt_seconds, msg_id),
+ "SET vt = ... WHERE msg_id = %s AND state = 'claimed' AND read_ct = %s",
+ (vt_seconds, msg_id, read_ct),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_queue/client.py` around lines 383 - 405, Update
set_vt to bind the lease renewal to the specific claim rather than only msg_id:
accept the claim’s read_ct or receipt token and include it in the UPDATE
predicate alongside msg_id. Preserve the existing validation and boolean
row-update result, so stale workers return False without extending a newer
claim’s lease.
workers/queue_backend/pg_queue/connection.py-137-144 (1)

137-144: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound each PostgreSQL connection attempt. psycopg2.connect() here still has no connect_timeout, so an unreachable host can stall every retry on the platform TCP timeout and delay startup/shutdown. Add a per-attempt timeout so backoff remains effective.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_queue/connection.py` around lines 137 - 144, Add a
per-attempt connect timeout to the psycopg2.connect call in the PostgreSQL
connection helper, using the existing configuration or timeout convention if
available. Keep the timeout scoped to each connection attempt so retry backoff
remains effective.
workers/queue_backend/pg_queue/leader_election.py-197-244 (1)

197-244: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Fence recovery work against lease takeover.

A process can pause after acquiring or renewing, exceed the TTL, and resume recovery after a standby has taken over. Since mutations carry no lease generation, both leaders can act concurrently. Return a monotonically increasing fencing token on acquisition and require it in each recovery mutation transaction.

#!/bin/bash
ast-grep outline workers/queue_backend/pg_queue/reaper.py \
  --items all --type class,function --match 'PgReaper|tick|run'
rg -n -C4 '\.(try_acquire|renew|release)\s*\(' workers/queue_backend/pg_queue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_queue/leader_election.py` around lines 197 - 244,
Update LeaderLease.try_acquire to return a monotonically increasing fencing
token whenever leadership is acquired, and persist it atomically with the lease
takeover. Propagate that token through PgReaper recovery flows such as tick/run,
and require every recovery mutation transaction to validate the token against
the current lease before mutating state. Ensure stale leaders fail the
validation after takeover, while renew preserves the current token and existing
leadership-loss behavior.
workers/queue_backend/pg_queue/consumer.py-791-800 (1)

791-800: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log the complete poison-message payload.

args and kwargs may contain document contents, identifiers, credentials, or other customer data. Persist replay material in a restricted dead-letter store rather than application logs.

Minimal log-safe change
         logger.error(
             "PG-queue consumer: task %r (msg_id=%s) exceeded max_attempts=%s "
-            "(read_ct=%s, execution_id=%s) — poison; full payload: %r",
+            "(read_ct=%s, execution_id=%s) — poison; payload omitted from logs",
             task_name,
             message.msg_id,
             self.max_attempts,
             message.read_ct,
             execution_id,
-            payload,
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_queue/consumer.py` around lines 791 - 800, Update
the poison-message error log in the consumer handling this max_attempts
condition to remove the complete payload from the message and its logging
arguments. Retain only the non-sensitive task and execution metadata already
present, and rely on the restricted dead-letter store for replay material.
workers/queue_backend/pg_queue/consumer.py-750-773 (1)

750-773: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Record failure status for on_success-only dispatches.

When such a task raises—or is dropped before execution—_fail_dispatch() has no on_error, so it acks without writing pg_task_result. REST polling then remains "processing" indefinitely.

Proposed fix
         on_error = payload.get("on_error")
         if on_error:
             self._chain_continuation(
                 on_error,
                 prepend=payload.get("task_id") or "",
                 payload=payload,
                 error=error,
             )
+        elif payload.get("on_success"):
+            self._record_task_status(
+                payload,
+                error=error,
+                executor_result={},
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_queue/consumer.py` around lines 750 - 773, Update
_fail_dispatch so failures for payloads with neither reply_key nor on_error are
persisted to pg_task_result, allowing REST polling to reach a terminal failure
state. Reuse the existing task-result failure mechanism and preserve the current
reply and on_error continuation paths unchanged.
🟡 Minor comments (10)
backend/pg_queue/management/commands/reconcile_pg_schedules.py-55-58 (1)

55-58: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include prospective backfills in the dry-run ownership preview.

_backfill_mirrors(True) creates nothing and returns only a count, so _reconcile_all() scans only existing mirrors. The test hides this by making values_list() report no row while all() reports one.

  • backend/pg_queue/management/commands/reconcile_pg_schedules.py#L55-L58: carry unmirrored candidates into the dry-run owner-resolution pass.
  • backend/pg_queue/tests/test_reconcile_pg_schedules_command.py#L92-L105: use consistent ORM snapshots and assert that the unmirrored candidate itself reaches resolve_schedule_owner.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/pg_queue/management/commands/reconcile_pg_schedules.py` around lines
55 - 58, The handle flow must include prospective mirror backfills in dry-run
ownership resolution: update _backfill_mirrors and _reconcile_all integration so
unmirrored candidates are passed to _reconcile_all(True) without creating
records, while preserving normal-mode behavior. In
backend/pg_queue/tests/test_reconcile_pg_schedules_command.py lines 92-105, make
the mocked ORM snapshots consistent between values_list() and all(), and assert
that the unmirrored candidate reaches resolve_schedule_owner.
workers/run-worker.sh-179-185 (1)

179-185: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Mark every named PG role as opt-in.

show_status suppresses stopped workers only when present in OPTIN_WORKERS, but this map contains only the generic consumer and reaper. A normal status check therefore reports every intentionally-disabled named PG role as STOPPED.

Proposed fix
 declare -A OPTIN_WORKERS=(
     ["$PG_QUEUE_CONSUMER_TYPE"]=1
     ["$PG_QUEUE_REAPER_TYPE"]=1
 )
+for pg_member in "${!PG_QUEUE_MEMBERS[@]}"; do
+    OPTIN_WORKERS["$pg_member"]=1
+done

Also applies to: 667-672

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/run-worker.sh` around lines 179 - 185, Expand the OPTIN_WORKERS map
used by show_status to include every named PG role, not just
PG_QUEUE_CONSUMER_TYPE and PG_QUEUE_REAPER_TYPE. Ensure all intentionally
disabled named PostgreSQL workers are recognized as opt-in so their absent state
is suppressed rather than reported as STOPPED.
docker/sample.env-134-134 (1)

134-134: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Describe this as the reaper loop interval, not the sweep interval.

Retention sweeps use the separate WORKER_PG_REAPER_SWEEP_SECONDS setting. This comment may lead operators to tune the wrong cadence.

-WORKER_PG_REAPER_INTERVAL_SECONDS=5   # Reaper sweep interval (seconds)
+WORKER_PG_REAPER_INTERVAL_SECONDS=5   # Reaper loop interval (seconds)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker/sample.env` at line 134, Update the inline comment for
WORKER_PG_REAPER_INTERVAL_SECONDS to describe the reaper loop interval,
distinguishing it from the separate WORKER_PG_REAPER_SWEEP_SECONDS retention
sweep setting.
workers/shared/workflow/execution/orchestration_utils.py-94-108 (1)

94-108: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Log unsuccessful status responses during PG failure reconciliation.

update_workflow_execution_status() can return success=False without raising. This path currently treats that as success, silently leaving the execution and file counters unreconciled.

Proposed fix
-            api_client.update_workflow_execution_status(
+            response = api_client.update_workflow_execution_status(
                 execution_id=execution_id,
                 status=ExecutionStatus.ERROR.value,
                 error_message=error_message,
                 total_files=total_files,
                 successful_files=0,
                 failed_files=total_files,
             )
+            if not getattr(response, "success", True):
+                logger.error(
+                    "Failed to write ERROR status + file counts for execution %s: %s",
+                    execution_id,
+                    getattr(response, "error", "unknown API error"),
+                )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/shared/workflow/execution/orchestration_utils.py` around lines 94 -
108, Update the PG failure reconciliation call to inspect the response from
update_workflow_execution_status; when it returns success=False, log the
unsuccessful status response with the execution ID and relevant error details,
while preserving the existing exception logging for raised failures.
backend/workflow_manager/workflow_v2/workflow_helper.py-718-733 (1)

718-733: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return the execution’s current status after post-dispatch failures.

If result retrieval or acknowledgement fails after the execution becomes terminal, this handler reports EXECUTING anyway. Best-effort reload the row’s status, falling back to EXECUTING only if that reload also fails.

Proposed fix
         except Exception as error:
             if dispatched:
                 logger.exception(
                     ...
                 )
+                try:
+                    current_status = WorkflowExecution.objects.only("status").get(
+                        id=execution_id
+                    ).status
+                except Exception:
+                    current_status = ExecutionStatus.EXECUTING.value
                 return ExecutionResponse(
-                    workflow_id, execution_id, ExecutionStatus.EXECUTING.value
+                    workflow_id, execution_id, current_status
                 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow_manager/workflow_v2/workflow_helper.py` around lines 718 -
733, The dispatched exception handler should return the execution’s current
persisted status instead of always reporting EXECUTING. In the dispatched branch
of the exception handler, best-effort reload the execution row and use its
status in ExecutionResponse; if reloading fails, log or suppress that secondary
failure and retain EXECUTING as the fallback.
workers/tests/test_leader_election.py-291-309 (1)

291-309: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Surface exceptions from every contender in the concurrent claim tests.

A single successful thread plus multiple crashed threads currently still produces sum(results) == 1, allowing these load-bearing race tests to pass falsely.

  • workers/tests/test_leader_election.py#L291-L309: capture thread exceptions and assert one completed result per label.
  • workers/tests/test_pg_batch_dedup.py#L96-L138: capture exceptions, assert len(results) == n, and use bounded joins or futures.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/tests/test_leader_election.py` around lines 291 - 309, Make both
concurrent claim tests surface contender failures: in
workers/tests/test_leader_election.py lines 291-309, capture exceptions from
each run invocation, assert every label produced exactly one completed result,
and retain bounded thread joins; in workers/tests/test_pg_batch_dedup.py lines
96-138, capture thread or future exceptions, assert len(results) equals n, and
use bounded joins or futures so crashed or hung contenders cannot be counted as
successful completion.
workers/tests/test_pg_schema.py-88-103 (1)

88-103: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Detect unqualified queue tables following JOIN.

The guard currently misses JOIN pg_queue_message and similar references, despite those having the same search_path failure mode.

Proposed fix
-        bare = re.compile(rf"\b(?:FROM|INTO|UPDATE)\s+(?:{tables})\b")
+        bare = re.compile(rf"\b(?:FROM|JOIN|INTO|UPDATE)\s+(?:{tables})\b")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/tests/test_pg_schema.py` around lines 88 - 103, Extend the
bare-reference regex in test_no_unqualified_queue_table_reference to also match
queue table names immediately following JOIN, while preserving the existing
FROM, INTO, and UPDATE checks and offender reporting.
workers/queue_backend/pg_queue/README.md-83-83 (1)

83-83: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use BATCH, not BATCH_SIZE, in the lease note.

The configured consumer suffix is BATCH; BATCH_SIZE may mislead operators into setting an ignored variable.

Proposed fix
-Because renewal covers only the in-flight message, `BATCH_SIZE` is forced to 1 whenever the lease is the short window.
+Because renewal covers only the in-flight message, `BATCH` is forced to 1 whenever the lease is the short window.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/queue_backend/pg_queue/README.md` at line 83, Update the lease note
in the README to refer to the configured consumer suffix as BATCH instead of
BATCH_SIZE, so operators use the recognized variable.
workers/tests/test_pg_result_signal.py-275-283 (1)

275-283: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the configured Redis endpoint in the integration fixture.

Hardcoding localhost:6379 bypasses the production REDIS_/Sentinel/SSL configuration. In the required-PG lane, this test’s skip is converted to failure, so a valid non-local Redis deployment can break CI. Build the client through the canonical Redis factory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/tests/test_pg_result_signal.py` around lines 275 - 283, Update the
real_redis fixture to construct its client through the project’s canonical Redis
factory, using the configured REDIS_/Sentinel/SSL settings instead of hardcoded
localhost:6379 values. Preserve the existing connectivity check and skip
behavior, while ensuring the required-PG lane can use non-local Redis
deployments.
workers/tests/test_pg_result_backend.py-111-125 (1)

111-125: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Close the caller-owned writer connection explicitly.

writer receives an injected connection, so writer.close() is a no-op by contract. Keep the connection handle and close it directly.

Proposed fix
-        writer = PgResultBackend(conn=create_pg_connection(env_prefix="TEST_DB_"))
+        writer_conn = create_pg_connection(env_prefix="TEST_DB_")
+        writer = PgResultBackend(conn=writer_conn)
 ...
-            writer.close()
+            writer_conn.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/tests/test_pg_result_backend.py` around lines 111 - 125, Update the
test around writer and result_backend to retain the connection returned by
create_pg_connection(env_prefix="TEST_DB_") and close that connection explicitly
in the finally block. Keep writer.close() if needed for backend cleanup, but do
not rely on it to close the caller-owned connection.
🧹 Nitpick comments (2)
backend/pg_queue/models.py (1)

66-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update stale migration and scheduler rollout documentation.

Line 68 points to removed migration 0014, while the squashed migration explicitly keeps that tuning out of schema DDL. Lines 301-332 also describe the PG scheduler and atomic ownership handoff as future work although both are now included in this stack. Update these comments to reflect the deployed design and avoid misleading operators.

Also applies to: 295-332

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/pg_queue/models.py` around lines 66 - 72, Update the migration and
scheduler rollout comments in backend/pg_queue/models.py: remove the stale
reference to migration 0014 and describe fillfactor/autovacuum tuning as
operational rather than schema-managed, then revise the documentation around the
PG scheduler and atomic ownership handoff to state that both are already
deployed in this stack instead of future work.
backend/workflow_manager/workflow_v2/tests/test_active_execution_index.py (1)

41-55: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Verify the migration’s actual partial-index predicate too.

These assertions only inspect Meta.indexes; migration 0023 has an independent RunSQL literal, so deployment drift would still pass. Add a migration-state or PostgreSQL catalog assertion comparing the deployed predicate with ExecutionStatus.terminal_values().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/workflow_manager/workflow_v2/tests/test_active_execution_index.py`
around lines 41 - 55, Extend the tests in
test_active_index_exists_and_is_a_negated_partial and
test_active_index_excludes_exactly_the_terminal_statuses to inspect migration
0023’s deployed partial-index predicate, using migration state or the PostgreSQL
catalog. Parse the actual predicate and assert its excluded statuses exactly
equal ExecutionStatus.terminal_values(), independently of Meta.indexes.

Comment thread backend/scheduler/helper.py
Comment thread workers/queue_backend/pg_queue/client.py
muhammad-ali-e and others added 2 commits July 17, 2026 18:07
Conflict resolutions:
- backend/prompt_studio/.../views.py: keep-both — main's owner/co-owner/
  metadata (UN-3741/3739/2202) + feat's PG task_status path (UN-3693)
- 6 worker tests: main renamed+rewrote the old phase/sanity tests into
  properly-named ones (test_sanity_phase* -> test_*); took main's versions
- tests/groups.yaml: union — kept feat's new integration-workers group +
  main's stricter "not integration and not slow" fast-lane marker
- tests/rig/cli.py: took main's more robust empty-collection guard
- workers/tests/conftest.py: union — feat's PG-integration + isolation
  fixtures + main's DB_* stripping (ordered before _PRISTINE_ENVIRON capture)
  and _restore_current_celery_app
- Semantic fix: repointed structure-tool test patches from ExecutionDispatcher
  to get_executor_dispatcher (feat's flag-gated RoutingExecutionDispatcher)

Workers unit lane: 1286 passed (-m "not integration" -n 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/prompt_studio/prompt_studio_core_v2/views.py (2)

928-943: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not treat every database exception as “still processing.”

This catches permanent failures such as a missing migration or schema mismatch and converts them into a successful 200 response with an indefinitely pending task. Catch only expected transient database errors and return a retriable error (for example, HTTP 503); surface schema/programming errors instead of hiding them as task state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/prompt_studio/prompt_studio_core_v2/views.py` around lines 928 - 943,
Update the database-read exception handling in the task status view around
PgTaskResult.objects.filter so only expected transient database exceptions are
treated as pending. Return an HTTP 503 retriable response for those transient
failures, while allowing schema, migration, and programming errors to propagate
instead of setting row to None and returning a successful pending status.

910-925: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not re-evaluate the live feature flag to choose the result backend.

The task’s transport is decided at dispatch time, but this code decides it again during polling. A transient Flipt failure or rollout change makes a PG task fall back to AsyncResult; because PG execution does not write the Celery backend, that task will remain "processing" indefinitely. Persist the selected transport with the dispatch handle/task ID, or otherwise ensure polling uses the backend selected at dispatch time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/prompt_studio/prompt_studio_core_v2/views.py` around lines 910 - 925,
The polling logic around the PG_QUEUE_FLAG_KEY check must not call
check_feature_flag_status again to select the result backend. Persist the
transport chosen during dispatch alongside the task handle or ID, then have
polling use that stored transport to query the matching backend; preserve Celery
as the default only when the dispatch-time transport is unavailable.
🧹 Nitpick comments (1)
workers/tests/test_dispatch_with_callback.py (1)

848-854: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename parameters to reflect the new mock target.

The patch target was updated from the ExecutionDispatcher class to the get_executor_dispatcher factory function. This change renders the parameter name mock_dispatcher_cls misleading, since it now mocks a function rather than a class.

  • workers/tests/test_dispatch_with_callback.py#L848-L854: Rename mock_dispatcher_cls to mock_get_executor_dispatcher.
  • workers/tests/test_structure_tool_pipeline.py#L316-L318: Rename mock_dispatcher_cls to mock_get_executor_dispatcher to match the patched target.
  • workers/tests/test_structure_tool_pipeline.py#L740-L742: Rename mock_dispatcher_cls to mock_get_executor_dispatcher to match the patched target.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workers/tests/test_dispatch_with_callback.py` around lines 848 - 854, Rename
the mock parameter for the get_executor_dispatcher patch from
mock_dispatcher_cls to mock_get_executor_dispatcher in
test_single_dispatch_normal and the corresponding tests at
workers/tests/test_structure_tool_pipeline.py lines 316-318 and 740-742; update
all references within those tests consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@backend/prompt_studio/prompt_studio_core_v2/views.py`:
- Around line 928-943: Update the database-read exception handling in the task
status view around PgTaskResult.objects.filter so only expected transient
database exceptions are treated as pending. Return an HTTP 503 retriable
response for those transient failures, while allowing schema, migration, and
programming errors to propagate instead of setting row to None and returning a
successful pending status.
- Around line 910-925: The polling logic around the PG_QUEUE_FLAG_KEY check must
not call check_feature_flag_status again to select the result backend. Persist
the transport chosen during dispatch alongside the task handle or ID, then have
polling use that stored transport to query the matching backend; preserve Celery
as the default only when the dispatch-time transport is unavailable.

---

Nitpick comments:
In `@workers/tests/test_dispatch_with_callback.py`:
- Around line 848-854: Rename the mock parameter for the get_executor_dispatcher
patch from mock_dispatcher_cls to mock_get_executor_dispatcher in
test_single_dispatch_normal and the corresponding tests at
workers/tests/test_structure_tool_pipeline.py lines 316-318 and 740-742; update
all references within those tests consistently.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 80427472-536a-4089-9637-f1f8c5816531

📥 Commits

Reviewing files that changed from the base of the PR and between 583d88f and 2ef2987.

📒 Files selected for processing (9)
  • backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py
  • backend/prompt_studio/prompt_studio_core_v2/views.py
  • backend/workflow_manager/workflow_v2/models/execution.py
  • tests/groups.yaml
  • tests/rig/cli.py
  • workers/file_processing/structure_tool_task.py
  • workers/tests/conftest.py
  • workers/tests/test_dispatch_with_callback.py
  • workers/tests/test_structure_tool_pipeline.py
🚧 Files skipped from review as they are similar to previous changes (5)
  • backend/prompt_studio/prompt_studio_core_v2/prompt_studio_helper.py
  • tests/rig/cli.py
  • workers/file_processing/structure_tool_task.py
  • backend/workflow_manager/workflow_v2/models/execution.py
  • workers/tests/conftest.py

…change

The structure-tool patch target moved from the ExecutionDispatcher class to
the get_executor_dispatcher factory during the main back-merge, leaving the
mock parameter named mock_dispatcher_cls (mocks a function, not a class).
Rename to mock_get_executor_dispatcher to match. Test-only; 1286 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@muhammad-ali-e

Copy link
Copy Markdown
Contributor Author

CodeRabbit follow-ups — outside-diff & nitpick

Addressing the three findings that could not be threaded inline (the two Critical inline comments are answered in their own threads → UN-3761 scheduler-mirror atomicity, UN-3762 PG claim receipt).

views.py L910-925 — re-evaluating the flag at poll time → won't fix (by design).
This is deliberate. The call-time pg_queue_enabled read is load-bearing for runtime rollback — persisting the transport to the dispatch handle would defeat the rollback lever. The flag is org-bucketed and sticky (resolve_pg_transport), so it does not flip per-poll for a given org absent an active, intentional rollback of that org, and check_feature_flag_status fails closed to Celery on any Flipt error. The trade-off (an in-flight PG task re-runs as Celery if its org is rolled back mid-flight) is documented inline at L910-918 and is the behaviour we want. Keeping as-is.

views.py L928-943 — broad except Exception degrading to "processing" → keeping, with rationale.
Partly by design: this is a read-only poll endpoint, and a genuine schema/migration break surfaces loudly at the writer and at migrate time, not here. The broad catch intentionally degrades a transient DB blip to a retriable processing rather than a bare 500 that a status-code-keyed client misreads as a terminal task failure (see the inline comment at L935-937). Noted as a possible future hardening (narrow to OperationalError/InterfaceError, let ProgrammingError propagate); low value, not filing a separate ticket.

Test nitpick — mock_dispatcher_cls rename → done.
Fixed in d7c8ab0b9: renamed mock_dispatcher_clsmock_get_executor_dispatcher in both test_dispatch_with_callback.py and test_structure_tool_pipeline.py to match the get_executor_dispatcher patch target. Test-only; full workers fast lane green (1286 passed).

… leaves

The main->feat back-merge left two parallel leaf nodes in workflow_v2:
main's 0022_absorb_shared_users (co-owner management) and the epic's
0023_workflowexecution_active_by_workflow_idx (index). git saw no text
conflict (different files) but Django rejects the multi-leaf graph, so
create_test_db failed and every integration-backend + e2e test errored at
setup (162 setup errors, 0 real failures).

Empty merge migration — graph reconciliation only, no schema/data change.
Verified locally: fresh-DB migrate applies all migrations incl. 0022, 0023,
and this merge as the single leaf; makemigrations --check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nline

update_execution() fired the Redis rate-limit release inline, but callers
(update_status, the PG reaper's _recover_one_stuck_pg_execution) wrap it in an
OUTER transaction with further writes. On a rollback of that outer txn the slot
was freed while the terminal status never persisted — a freed slot plus a
still-stuck execution, and under concurrency an over-admission of the next
API-deployment caller.

Schedule the release via transaction.on_commit so it fires only on the OUTERMOST
commit and is dropped on rollback (runs immediately in autocommit). Shared code
path, so this covers BOTH transports; happy-path behaviour is unchanged.

Fixes the Greptile P1 on the reaper recovery path; also closes the same
pre-existing window in update_status on the Celery path.

Tests: 4 new (Celery + PG fire-on-commit, rollback-suppression, non-terminal),
33/33 in the file pass against real Postgres. Live concurrency micro-test: 300
ops across both transports (commit/rollback/mixed) — correct release, 0 deadlocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@muhammad-ali-e

muhammad-ali-e commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Greptile findings — resolved

P1 (_recover_one_stuck_pg_execution / update_execution rate-limit ordering) — fixed in 34ce5ca12.
The Redis rate-limit release is now scheduled via transaction.on_commit instead of firing inline, so it runs only on the outermost commit and is dropped on rollback (and runs immediately in autocommit). This closes the exact defect flagged — a rollback of the reaper recovery path (or update_status) can no longer free a slot while the terminal status fails to persist. Because update_execution is shared code, this also closes the same pre-existing window on the Celery path.

Validated:

  • 4 new unit tests (Celery + PG fire-on-commit, rollback-suppression, non-terminal), full file 33/33 against real Postgres.
  • Live concurrency micro-test — 300 ops across both transports (50 commits / 50 rollbacks / 25+25 mixed, each path): every commit releases exactly once, every rollback releases nothing, 0 deadlocks, 0 errors.

P2s:

  • producer.py fairness not _json_safe-sanitised → already fixed in current code (producer.py:108 sanitises fairness).
  • result_backend.py non-atomic ready-token RPUSH+EXPIRE, and injected-connection lifecycle in the BLPOP wait path → tracked as a follow-up (UN-3763). Both are PG-path-only and low-risk (the DB row + safety-net SELECT remain source of truth); not blocking this PR.

@greptile-apps

…ll timeout

_wait_via_redis sets a deadline then does a pre-wait get_result; when Redis is
unavailable up front it degraded to _poll_for_result with the FULL original
timeout instead of the remaining budget, so wait_for_result could overshoot its
stated timeout on the Redis-down path. Mirror the mid-BLPOP RedisError fallback
below it: pass max(0.0, deadline - now). PG-only, latency-only; no Celery impact.

Test: TestRedisDownFallbackBudget pins that the early fallback receives the
remaining budget (<full timeout). Fast lane 1287 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread backend/workflow_manager/internal_views.py
…structured 500

The Http404 handler in WorkflowExecutionInternalViewSet.retrieve does an unscoped
exists() to distinguish "deleted" (404 → reaper GCs the claim) from "scoped out"
(500 → reaper retains). That query runs INSIDE the except block, so a DatabaseError
there is not caught by the sibling `except Exception` (Python doesn't route between
sibling handlers) — it escaped as Django's unstructured 500, losing the shape the
reaper expects. Wrap it in its own try/except and fail closed to a structured 500
(retain the claim) — never a 404 that would GC a live execution on a transient DB
blip. Reaper/PG-recovery path only; no Celery impact.

Test: test_retrieve_existence_check_db_error_returns_structured_500. File 34/34 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread workers/queue_backend/pg_queue/README.md

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PG-Queue review (transport correctness, error handling, tests, integration, contamination)

Reviewed across transport correctness, silent-failure/error handling, backend integration, test coverage, redelivery idempotency, and OSS/cloud separation. The core queue protocol is sound and unusually well-defended — claim path is EvalPlanQual-safe, barrier decrement is exactly-once, leader election is split-brain-safe (TTL lease, PgBouncer-compatible), the flag-off=Celery-unchanged routing contract is airtight, and the squashed migration is lock-safe with no data loss. Nice work.

The findings below are narrow and specific, not structural. Inline comments are attached to the relevant lines; summarized here by tier:

🔴 Critical

  • Unfenced lease SQL (client.py) — set_vt/delete on msg_id alone → reaper-rearm vs stalled-renewal double-run.
  • wait_for_result hangs under ATOMIC_REQUESTS (executor_rpc.py) — frozen MVCC snapshot on the request connection.
  • DB destination double-write (file_processing/tasks.pydestination.py) — snapshot-not-lock guard + no unique key on the destination write; concurrent redelivery corrupts DB destinations.

🟠 Important

  • update_execution now locks the Celery path too, and get_failed_files recount is ungated → the "byte-for-byte unchanged" claim isn't literally true.
  • Scheduler dual-write double-fire window; consumer health-blindspot; executor re-spends LLM on redelivery; ERROR/STOPPED callback re-fires webhooks/run-count/billing.

🟡 Suggestions

  • Reaper 5s stranded-scan (decouple cadence + LIMIT the sweeps); on-prem max_connections sizing note; api-deployment entry dedup for symmetry.

Redelivery is "guarded sequential, fail-open under concurrency" — every guard in the chain is a snapshot read, not a lock. The two money/corruption side effects (DB destination write, billing) and the executor LLM spend are the exposed ones. Hardening priority: a unique key/upsert on the destination write + confirm plugin billing idempotency.

✅ OSS/cloud separation is clean — subscription/billing stays behind get_client_plugin("subscription_usage")→None, notifications behind get_plugin, single Flipt flag, PgBouncer referenced only in comments. No cloud logic leaked into OSS.

Scope note: this PR also bundles the unrelated UN-2202 shared_usersResourceMembership co-owner migration across 6 apps — broadens the blast radius and touches the same for_user() managers/serializers. Consider splitting, or at least calling it out for reviewers.

One meta-ask on code comments 🙏 — many of the new docstrings/inline comments are multi-paragraph and explain the full how of the mechanism (e.g. models.py:PgQueueMessage, consumer.py, reaper.py). Per our convention, keep code comments concise and generic — explain the WHY, not the step-by-step HOW. The HOW drifts as the code changes (stale comments) and bloats every reader's context. Trim these to the intent; the mechanism should be legible from the code itself.

Full aggregated review + per-finding failure scenarios available on request.

Comment thread workers/queue_backend/pg_queue/client.py
Comment thread backend/pg_queue/executor_rpc.py
Comment thread backend/workflow_manager/workflow_v2/models/execution.py
Comment thread backend/workflow_manager/execution/serializer/execution.py
Comment thread backend/scheduler/helper.py
Comment thread workers/queue_backend/pg_queue/connection.py
Comment thread workers/file_processing/tasks.py
Comment thread workers/executor/tasks.py
Comment thread workers/callback/tasks.py
Comment thread workers/api-deployment/tasks.py
@muhammad-ali-e

Copy link
Copy Markdown
Contributor Author

@chandrasekharan-zipstack — thank you, genuinely thorough review. I verified all 14 findings against current code and split them into in-PR vs tracked. Disposition:

Celery / flag-off (handled in this PR):

  • Basic workflow and adapter exception messages #4 update_execution lock — confirmed intentional & load-bearing: it reads queue_message_id under the lock to stop a stale in-memory PG row misrouting to the legacy save() and nulling the marker, so it can't be gated on the marker without reopening that race. Accepting the added short row-lock + on_commit rate-limit deferral on the Celery status-write path as deliberate, and refining the claim wording to: "flag-off routing is byte-unchanged; the status-write path adds a brief marker-safety row lock." Practical contention is low (short lock, infrequent same-row writes).
  • Add support for configuring and using OCR adapters #5 failed-count recount — confirmed not transport-gated (display-only, executions-list serializer; no execution effect). Decision pending: gate to PG to keep Celery reporting unchanged, or keep as an intended global fix.

PG-only, tracked (flag-gated → no merge regression):

Finding Ticket
#3 DB-destination double-write (corruption) UN-3771 (P0)
#10 executor LLM/usage re-spend + #12 api-deployment entry dedup UN-3772 (P0)
#1 unfenced lease + missing reclaim test UN-3762
#11 ERROR/STOPPED notif/run-count + reaper-flip UN-3764
#2 ATOMIC_REQUESTS rpc + #7 consumer read-failure health UN-3773 (P1)
#6 scheduler PG-owned double-fire UN-3761
#8 barrier-scan cadence/LIMIT UN-3765
#9 conn-sizing doc + #13 README UN-3774

Verified already-handled (no action): subscription billing is idempotent (commit_batch_subscription_usage dedups on file_execution_id, COMPLETED-only) — so the "bills on late delivery" case is covered; and sweep_orphan_claims is already off the 5s tick (moved to the 300s sweep). The COMPLETED-redelivery callback path is also already guarded.

None of these are flag-off regressions; the PG hardening (esp. UN-3771/3772) will land before ramping PG traffic. Thanks again.

@kirtimanmishrazipstack kirtimanmishrazipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated PR review — PG queue backend (toolkit pass)

Ran the PR Review Toolkit agents against main...HEAD, deduped against the ~100 existing inline comments, and verified each finding against source. Filtered to the actionable bugs: LOW findings, comment-accuracy, simplifications, and test-coverage gaps (whose use cases already have tests) are omitted.

8 findings — 3 HIGH correctness, 5 MEDIUM silent-failure/operational. Highlights: a Redis-cache status-revert race on the queue_message_id write, and two Celery-Beat bulk-.update() calls that skip the reload signal (pipeline resume silently never fires; PG hand-over double-fires).

Comment thread backend/workflow_manager/workflow_v2/execution.py Outdated
Comment thread backend/scheduler/tasks.py
Comment thread backend/scheduler/ownership.py
Comment thread workers/general/tasks.py
Comment thread workers/queue_backend/pg_queue/consumer.py
Comment thread workers/queue_backend/pg_queue/consumer.py
Comment thread workers/queue_backend/pg_barrier.py
Comment thread workers/shared/models/file_processing.py

@chandrasekharan-zipstack chandrasekharan-zipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Flag-off path is unaffected (Celery routing byte-unchanged), and the PG-transport criticals are dispositioned as flag-gated follow-ups — UN-3771 (destination double-write) and UN-3772 (executor/api-deployment dedup) to land before any PG traffic ramp, with UN-3761/3762/3764/3765/3773 tracking the rest. Please also reword the "byte-for-byte unchanged" line per #4 and trim the multi-paragraph code comments to WHY-not-HOW. Good work on the transport design.

Address kirtiman's review on #2108:

- enable_task / reconcile_ownership_for: the bulk PeriodicTask.objects
  .filter().update() bypassed django-celery-beat's post_save signal, so
  PeriodicTasks.last_update never bumped and DatabaseScheduler never
  reloaded — a resumed/handed-over pipeline silently never fired. This
  affects the Celery path too (a regression the ownership-ramp feature
  introduced when it switched off task.save()). Call
  PeriodicTasks.update_changed() after the bulk update.

- update_execution_queue_message_id: replace .get()+.save() with a queryset
  .update() so the PG marker write no longer re-runs _handle_execution_cache()
  and can't republish a stale in-memory status to the Redis execution cache
  (the same reason _set_result_acknowledge uses .update()). PG-only path.

Tests: unit tests added for all three; a real-Postgres dev-test confirms the
real enable_task() bumps last_change (Beat reloads) while a bare .update()
does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread backend/workflow_manager/workflow_v2/models/execution.py

@kirtimanmishrazipstack kirtimanmishrazipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-pass review (automated, PR Review Toolkit) — scoped to the 6 new commits since the last review round (2ef2987..HEAD). Consensus across Code Reviewer, Silent-Failure Hunter, Type-Design, Comment Analyzer, and Simplifier: the 6 fixes correctly close the pass-1 gaps and introduce no new correctness/silent-failure/type issues. Only 3 net-new LOW nits below (test-coverage + one comment), none duplicating existing threads.

@kirtimanmishrazipstack kirtimanmishrazipstack left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-pass review of the 6 fix commits since the last round (2ef2987..HEAD, head ca12733): all verified correct — no blocking issues. Approving.

@github-actions

Copy link
Copy Markdown
Contributor

Frontend Lint Report (Biome)

All checks passed! No linting or formatting issues found.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-coowners e2e 1 0 0 0 1.5
e2e-login e2e 2 0 0 0 1.5
e2e-smoke e2e 2 0 0 0 1.0
integration-backend integration 161 0 0 27 70.1
integration-connectors integration 1 0 0 7 8.3
integration-workers integration 0 0 0 141 129.9
unit-backend unit 265 0 0 1 23.4
unit-connectors unit 63 0 0 0 10.0
unit-core unit 27 0 0 0 1.3
unit-platform-service unit 15 0 0 0 2.7
unit-rig unit 71 0 0 0 4.6
unit-sdk1 unit 471 0 0 0 26.4
unit-workers unit 1312 0 0 0 117.2
TOTAL 2391 0 0 176 397.7

Critical paths

⚠️ Critical paths not yet covered

  • workflow-create-execute — Create a workflow, configure source+destination, execute, poll, fetch result. (declared coverage: e2e-workflow)
  • api-deployment-run — Deploy a workflow as an API, POST a document, receive structured JSON. (declared coverage: e2e-api-deployment)
  • prompt-studio-fetch-response — Prompt Studio: create project, add prompt, run single-pass, get response. (declared coverage: e2e-prompt-studio)
  • pipeline-etl-execute — Run an ETL pipeline from source connector to destination. (declared coverage: no groups declared)
  • usage-token-tracking — Per-execution token usage is recorded and retrievable. (declared coverage: no groups declared)
  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
  • callback-result-delivery — Async results are posted back via the callback worker. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • connector-register-test — covered by integration-backend
  • usage-aggregate-read — covered by integration-backend

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants