Skip to content

UN-3445 [GATED-FEAT] Complete the PostgreSQL queue transport: scheduler, metrics, log streaming and strand recovery - #2254

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

UN-3445 [GATED-FEAT] Complete the PostgreSQL queue transport: scheduler, metrics, log streaming and strand recovery#2254
muhammad-ali-e wants to merge 35 commits into
mainfrom
feat/UN-3445-pg-queue-completion

Conversation

@muhammad-ali-e

Copy link
Copy Markdown
Contributor

What

Completes the PostgreSQL queue transport so the platform can run with every Celery
worker scaled to zero. All of it sits behind the pg_queue_enabled feature flag.

  • PG scheduler — fires the non-pipeline Beat periodics, with ownership converging
    in both directions so rollback is a values change rather than a manual DB edit.
  • PG metrics execution pathworker-pg-metrics plus its internal API, the
    consumer that finally lets celery-beat and worker-metrics go to zero.
  • Log streaming without Celery — a Redis-list transport, removing the last worker
    that forced a Celery dependency for logs.
  • Delayed visibility — the countdown/eta primitive the queue lacked.
  • Strand recovery — the sweep that finalizes executions abandoned by a transport
    cutover, plus the undispatched sweep for executions that died before dispatch.

Why

Celery cannot be decommissioned while any part of the platform still depends on it.
Each item above was a remaining dependency: the scheduler, the metrics consumer, log
transport, delayed dispatch, and recovery of work stranded mid-cutover.

The recovery work in particular came out of rehearsing the cutover on a live
environment rather than from review. A 30-file ETL was deliberately stranded mid-run
by removing its workers; it stranded exactly as designed (every file COMPLETED, the
execution stuck EXECUTING, no duplicate work and no data loss) and then was not
recovered, because the sweep's selection could never reach it. That is fixed here.

How

Dispatch goes through a single seam that resolves the transport per execution and
fails closed to Celery. Transport is pinned at dispatch, so an in-flight execution
finishes on the transport it started on and only new work moves.

Schedule ownership is complementary rather than duplicated: a schedule is owned by
PG or by Beat, never both, and the convergence command moves ownership either way on
every deploy — which is what makes rollback real.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why.

The flag-off path is unchanged: this adds PG paths and removes nothing. With
pg_queue_enabled off, resolve_transport() returns celery and every existing
code path runs as before.

Two things worth a reviewer's attention:

  • Schedule ownership touches rows shared with django-celery-beat. Ownership is
    complementary and the convergence command is idempotent in both directions, but this
    is the highest-risk surface in the change and the one to look at hardest.
  • Recovery finalizes executions. It acts only when every file execution is already
    terminal and the files have been quiet past the window, so it cannot terminalise
    live work. It re-computes the same status the callback would have written.

Database Migrations

Four, all additive:

  • pg_queue/0002_pgqueuemessage_available_at — delayed visibility
  • pg_queue/0003_pgperiodictask — periodic schedules
  • dashboard_metrics/0004_pg_periodic_tasks — metrics periodics
  • workflow_manager/workflow_v2/0026_workflowexecution_undispatched_idx — index, built
    CONCURRENTLY

No column is dropped or altered; a deployment that never enables the flag carries the
schema inert.

Env Config

  • PG_SCHEDULER_ENABLED — hands the periodics to PG; the complement of Beat's own
    enabled flag
  • LOG_TRANSPORTredis or celery; must be set on every log publisher, not
    just the consumer
  • WORKER_PG_STUCK_EXECUTION_RECOVERY_ENABLED (default on) and
    WORKER_PG_STUCK_EXECUTION_RECOVERY_SECONDS (default 600)

Notes on Testing

Rehearsed end-to-end on a live environment rather than only in unit tests:

  • 30-file ETL on Celery as a clean baseline — completed normally in ~14 minutes
  • the same ETL cut to PG mid-run with the Celery fleet removed — stranded exactly as
    predicted, with 30 destination rows for 30 files, no duplicates and no data loss;
    only the execution status was left dangling
  • schedule hand-over and release both exercised, including confirming that releasing
    back to Beat does not replay every missed interval

Unit tests cover the strand-recovery selection (starvation regression, drain property,
both halves of the staleness predicate) and the schedule-ownership convergence.

One gap stated plainly: the tests added in the final commit have not been executed.
The backend suite cannot start in the author's checkout — every test in that file errors
at setup on a pre-existing schema-provisioning issue, including tests the commit does not
touch. Lint, formatting and compilation are clean. CI is what will verify them.

Related Issues or PRs

UN-3445 (epic), UN-3796, UN-3755, UN-3843

Checklist

  • I have added an appropriate PR title and description
  • I have read and understood the Contribution Guidelines
  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented on my code, particularly in hard-to-understand areas
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes — see Notes on Testing
  • I have checked my code and corrected any misspellings

muhammad-ali-e and others added 26 commits August 5, 2026 15:10
…wn/eta)

The PG queue delivered every message immediately, so any dispatch relying on
Celery's countdown/eta had to stay Celery-only. Adds the deferral primitive.

A deferred row is written state='scheduled' with a future available_at and is
absent from the claim's partial index (which covers only 'ready'), so a pending
delay costs the hot claim path nothing. The reaper promotes it to 'ready' once
available_at passes, on the same per-tick cadence as the crash re-arm.

Deliberately NOT `available_at <= now()` in the claim, which is what the ticket
originally proposed: that parks every not-yet-due row inside
pg_queue_message_claim_idx to be walked and discarded on every claim, which is
exactly the scan-past cost the state-machine claim was introduced to remove.
The trade is granularity — delivery is "not before available_at", never early,
at reaper-tick resolution (default 5s) rather than exact ETA. Celery's countdown
is likewise approximate, and the consumers of this (staggered sends, retry
backoff) need a floor, not an instant.

- unstract.core: QueueMessageState.SCHEDULED (shared enum, drift-tested).
- backend: available_at column, widened state constraint, partial
  pg_queue_message_scheduled_idx, and countdown/eta on enqueue_task. Non-positive
  countdown / past eta resolve to the immediate path so a computed-zero stagger
  step doesn't pay a tick.
- workers: promote_due_scheduled() + tick wiring, with a dedicated failure
  counter and re-raise — a silently stalled sweep means delayed messages never
  fire, with nothing at the enqueue site to trace it back from.

Migration 0002 re-adds a persistent `DEFAULT now()` that Django's AddField drops.
Without it the workers' raw enqueue (explicit column list, no available_at) would
fail with a not-null violation in ANY deploy order. Covered by
test_default_available_at_keeps_the_raw_insert_working.

Additive and inert: existing rows and every existing enqueue resolve to
available_at=now() / state='ready', unchanged. Flag-off untouched.

Tests: 22 new (8 producer DB-free, 6 worker integration on live Postgres, 6
reaper wiring, SQL contract + rollback parametrisation). 1464 workers pass,
47 backend pg_queue pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…scheduling half)

Beat is the only thing still firing periodic tasks, so it blocks the "scale every
Celery deployment to zero" gate. The PG scheduler already existed but mirrored and
fired ONLY pipeline schedules. This adds the generic half.

- PgPeriodicTask: a sibling of PgPeriodicSchedule keyed on PeriodicTask.name,
  carrying task_name/queue/args/kwargs/org_id + cron and run-state. A sibling
  rather than widening the existing table: that one is live, mid-ramp and
  dual-written from five sites, and the two carry different ownership POLICIES
  (per-pipeline Flipt percentage vs all-or-nothing operator adopt). org_id is
  present from the start so a later unification is a data migration, not a
  redesign.
- dispatch_due_periodic_tasks(): same shape as the pipeline dispatcher — leader
  gated, per-row txn, enqueue+advance in ONE transaction, baseline-without-firing
  on first observation, invalid-cron quiesce. Differs only in that each row
  carries its own task/args/queue, which is the whole reason for the second table.
- mirror_pg_periodic_tasks: mirrors generically from Beat (the live PeriodicTask
  table is the only authority — DatabaseScheduler keeps schedules as rows, not
  code). --adopt flips pg_owned AND disables the Beat row in one transaction; a
  row PG-owned while Beat still has it enabled fires twice.

Running it against a real Beat table surfaced three things source could not:
celery.backend_cleanup (Celery's own result-backend housekeeping — excluded, it
retires with Celery); a legacy execute_pipeline_task_v2 row (a pipeline trigger
under a second task path, which would have given one pipeline two owners — now
excluded by task path, not by luck); and workflow_log_history_v2 at a 30-second
interval, which has no cron expression and is skipped loudly rather than rounded
up to a minute.

Scale, per review: excluded paths are filtered in SQL, not Python — there is one
PeriodicTask row per scheduled pipeline, so the earlier .all() dragged the whole
pipeline population through memory to discard it. Both commands now use
.iterator(chunk_size=) with --batch-size; reconcile_pg_schedules also stops
materialising every mirrored id as a set. Its five existing tests pass unchanged
(behaviour is identical); new tests pin the chunking so it can't be silently
undone.

Inert: rows land pg_owned=False, so the PG scheduler fires nothing and Beat keeps
firing everything until an operator adopts a row. Flag-off untouched.

Tests: 1475 workers, 72 backend pg_queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nternal API

The scheduling half (previous commit) fires dashboard_metrics.* onto
dashboard_metric_events, but nothing on PG could execute them: the three tasks are
Django (ORM, cache, a Redis lock) and the PG consumer bootstraps its tasks from
workers/ with no Django. Firing them would have stranded the messages — nothing
drains that queue on PG.

Execution follows the pattern this repo already uses for Django-side periodic work
(process_log_history.py / process_notification_buffer.py -> backend internal API):

- backend: dashboard_metrics/internal_views.py + internal_urls.py, registered under
  /internal/v1/dashboard-metrics/. The three task bodies are plain functions that
  happen to carry @shared_task, so they are called VERBATIM — aggregation windows
  and the Redis lock reused, not reimplemented.
- workers: three thin @worker_task proxies registered under the EXACT Beat task
  names, so the mirror's verbatim copy needs no remap and --release stays a true
  inverse. The same name now exists in two registries (backend = the Django
  implementation for Celery; workers = the HTTP proxy for PG) — separate processes,
  separate registries, neither imports the other. Documented loudly in both.
- deployment: a pg-metrics role + worker-pg-metrics compose service, NOT another
  queue on worker-pg-scheduler. The consumer's health heartbeat freezes while a task
  runs, so HEALTH_STALE is an upper bound on one task's wall clock; the scheduler's
  240s bound against a minutes-long aggregation would trip the liveness probe,
  restart the pod and take in-flight pipeline triggers with it. Sized 900/960,
  concurrency 1, MAX_ATTEMPTS=1 (for a periodic the next cron tick supersedes a
  failed run; retrying stacks in-flight messages).

Two correctness notes:
- Org scoping: the Celery path runs with no organization set (hence _base_manager
  throughout tasks.py). The proxies never send X-Organization-ID and each view
  clears StateStore defensively — it is a thread-local and gunicorn reuses threads,
  so a leftover value would silently scope a global aggregation to one tenant.
- Redelivery is safe: the upserts are INSERT ... ON CONFLICT DO UPDATE SET
  (overwrite with recomputed values, not increment) and the cleanups are
  DELETE ... WHERE ts < cutoff, so a double-run costs duplicate work, never wrong
  numbers. That is also why chunking needs no cross-call lock.

Chunking seam ships OFF: _run_aggregation(org_ids=None) + _active_org_ids() extracted
and GET aggregate/orgs/ exposed, with DASHBOARD_METRICS_ORG_CHUNK_SIZE default 0.
Gunicorn caps a request at 600s and a SIGKILLed request leaks the Redis lock until
its 900s self-heal, so this is the escape hatch — flipping it is env-only.
_run_aggregation() with no args is unchanged, so the Celery path is byte-identical.

Inert until an operator adopts a schedule; flag-off untouched.

Tests: 1494 workers (19 new, incl. the registry check that catches "unknown task ->
message silently dropped"), 72 backend pg_queue. The 9 errors in
dashboard_metrics/tests/test_tasks.py are pre-existing — verified identical with
these changes stashed (backend DB tests can't run without the tenant schema).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… Beat never reloads

Found via UN-2898, which describes exactly this failure mode for the pipeline path.

--adopt sets pg_owned=True and disables the Beat row in one transaction so the two
can never both fire. But the disable is a bulk
`PeriodicTask.objects.filter(...).update(...)`, which bypasses django-celery-beat's
post_save signal — so PeriodicTasks.last_update never bumps and DatabaseScheduler
keeps running from its stale in-memory copy. The DB would read "disabled" while Beat
carried on firing alongside the PG scheduler: a DOUBLE FIRE, which is precisely what
the atomic transaction exists to prevent. For cleanup_* that means concurrent deletes;
for aggregate_* double work. --release fails the mirror way — Beat never resumes.

Fix mirrors scheduler/ownership.py:132, which already does this on the pipeline path
with the same rationale in a comment.

Test pins both directions and was verified to go RED with the call removed — the
symptom is invisible in the DB (the row really is disabled), so only a mutation check
proves the guard works.

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

`command: ["pg-metrics"]` reached run-worker-docker.sh, whose pg-* dispatch
(:563-578) accepts only pg-queue-consumer / pg-consumer / pg-queue-reaper /
pg-reaper / reaper and rejects everything else with a loud exit 1:

    pg-*)  print_status $RED "Unrecognized PG-queue command: '$1' ..."; exit 1 ;;

So the service would have exited immediately and crash-looped.

I had assumed the `pg-metrics` role added to run-worker.sh would be honoured in the
container. It isn't — that script is the HOST runner. Containers go through
run-worker-docker.sh, which is purely env-driven (`run_pg_consumer` reads
WORKER_PG_QUEUE_CONSUMER_WORKER_TYPE / _QUEUE at :515-516). Every sibling PG compose
service already uses the generic command for exactly this reason.

Switch to ["pg-queue-consumer"]; the env already carries type=scheduler and
queue=dashboard_metric_events, so nothing else changes. The run-worker.sh role stays
— `./run-worker.sh pg-metrics` is a real local-dev path, same as pg-scheduler and
pg-executor — and the comment now records that the two paths differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t a migration

DASHBOARD_METRICS_ORG_CHUNK_SIZE and everything behind it had no counterpart in the
Celery worker, which runs the whole aggregation in one task with no slicing. I built
it on a concern I then disproved myself:

- I framed gunicorn's --timeout 600 as a NEW ceiling the PG path introduces. It is
  not. The Celery task already declares soft_time_limit=600 and gunicorn declares
  --timeout 600 — the same 600s. A run exceeding it would already be failing on
  Celery with SoftTimeLimitExceeded.
- The "~120s p95" threshold attached to it was invented outright.

And a >600s run is not a PG-only loss either: SoftTimeLimitExceeded is not in
autoretry_for, so Celery loses the run too. Both recover on the next tick, because
_acquire_aggregation_lock already self-heals a SIGKILLed run (its docstring says so)
and AGGREGATION_LOCK_TIMEOUT equals the schedule interval.

So the seam guarded an unchanged ceiling, nothing used it (default 0 = off), and part
of it edited a file on the flag-off Celery path — which this branch's own rule says
must stay byte-identical to main.

Removed: the org_ids parameter and _active_org_ids extraction (dashboard_metrics/
tasks.py reverted to main outright), ActiveOrgsAPIView and the aggregate/orgs route,
the chunking branch in the worker proxy, the env var from compose and sample.env, and
the six TestChunking cases.

What remains is a strict 1:1 with Celery: 3 Beat periodics -> PG scheduler; 3 tasks
-> 3 thin proxies calling 3 endpoints that invoke the same functions verbatim, lock
included; workerMetrics -> worker-pg-metrics.

Verified: `git diff origin/main -- backend/dashboard_metrics/tasks.py` is empty, which
is the check that the Celery path is untouched. 1488 workers pass, 74 backend
pg_queue, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Single-step is the last entry path whose fan-out was never transport-gated.
`step_execution` -> `run_workflow` -> `process_input_files` builds a Celery chord
directly in the Django web process; the normal path never reaches that code with the
flag on, because the PG dispatcher enqueues async_execute_bin and the *workers'*
general task does its own PG-gated fan-out (workers/general/tasks.py:875). So the
backend chord is Celery-only residue reachable solely via step.

With the flag on and the Celery file_processing workers scaled to zero (the epic's
acceptance gate), those batches sit unconsumed and the execution hangs in EXECUTING
forever, invisible to the PG reaper. Fail fast instead: a 500 naming the cause beats
a silent forever-EXECUTING row, which is the exact failure class this epic exists to
eliminate.

The guard lives in step_execution, NOT in process_input_files. The latter is also on
the Celery hot path, where the transport is already resolved upstream — re-resolving
there would break the "resolved once per execution" invariant stated in
workflow_v2/transport.py and add a Flipt call to every execution. Step executions are
created by create_and_make_execution_response, which resolves no transport, so this
is their first and only resolution.

Unreachable from the UI: every write of `execution_action` in Agency.jsx sits behind
an isStepExecution guard, and both live call sites pass false (the START/NEXT/STOP/
CONTINUE buttons are gone; only "Run Workflow" remains). Cloud has no frontend
override. This guards a direct API call.

Known trade-off: during the rollout window (flag on, Celery still up) step execution
would work today and now fails. Accepted because the gate says nothing may fall back
to Celery and the UI cannot generate the call. Not confirmed against production data
— no Superset session available — so this rests on code reading.

Flag-off is untouched and byte-identical; two of the five tests pin that specifically,
since it is what staging and production run.

Tests: 5 new, mutation-checked (stubbing the guard to `if False and ...` turns exactly
one red). 139 pass across pg_queue/, workflow_manager/, notification_v2/, pipeline_v2/
at -m "not integration"; the DB-bound tests are deselected here and run in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
worker-log-consumer is the last worker forcing a Celery deployment to stay up, so
it blocks the acceptance gate. This adds a second transport for the hop between
LogPublisher and the consumer, selected by LOG_TRANSPORT.

Redis, not the PG queue — the decision, recorded so it is not relitigated:

The PG option never removed Redis. Both of the consumer's sinks are already Redis
(RPUSH log_history_queue + the Socket.IO emit), so routing through pg_queue_message
inserts Postgres into the MIDDLE of a Redis-to-Redis path. It buys mechanism
consistency, not durability: logs stay lossy (dropped at the 10k cap in
log_utils.py:107, every exception swallowed) and durability still begins at
execution_log via the existing batched drain.

Nor is Redis a second mechanism. Logs already use a Redis list drained by a loop —
log_history_queue + worker-log-history-scheduler-v2, the very next stage of this
same pipeline. This extends that pattern one hop upstream.

Cost sealed it. LogPublisher lives in unstract/core with 14 call sites across 6
deployables, including tool-sidecar, spawned per file execution (live, not legacy:
shared/workflow/execution/service.py:52 -> WorkflowExecutionService -> ToolSandbox
-> runner -> sidecar). PG would need psycopg2 in unstract/core inherited by every
importer, a third producer there (it can import neither Django nor
workers.queue_backend), DB credentials in runner.py:229's sidecar env allowlist,
and one unpooled psycopg2.connect() per live sidecar. At 30+ logs per execution
that is ~3 DB ops per line against the platform's most contended resource.

BLMOVE, not BLPOP. I expected the Celery consumer to run acks_late=False, making
loss-on-crash a non-regression. It does not — task_acks_late defaults to true
(shared/infrastructure/config/worker_config.py:545, backend/celery_config.py:63),
so a crash today REDELIVERS. BLPOP deletes on read and would have quietly made
crashes lossy. The loop parks each envelope on a per-pod processing list and
removes it only after the handler returns; startup re-queues what the previous
incarnation left. Residual gap, stated plainly: a container restart (crash loop,
OOM — the dominant mode) recovers fully; a pod REPLACEMENT strands that pod's
in-flight envelope. Sweeping other pods' lists cannot distinguish a dead owner
from a live one and would duplicate logs on every start, so it is not attempted.

Business logic is untouched. The pipeline is 8 stages; this changes 1-3 only. The
consumer runs the SAME logs_consumer body, writes the same two Redis sinks, and
the same 5s scheduler drains to the same execution_log table.

Flag-off is unchanged: LOG_TRANSPORT defaults to celery and only the exact string
"redis" opts in. The Kombu publish block is byte-identical; task_message
construction moved above the branch (pure, no behaviour change).

The publisher side must flip too, or this drains an empty list — wired in the
cloud chart as one global.logTransport knob (backend, workers, runner; runner
forwards it to each sidecar).

Tests: 13 new; 1501 pass across the full workers suite (no regressions). Three
mutations verified red — capacity guard, task-name in envelope, ack-before-handle
ordering.

NOT verified: no end-to-end run. The gate (flag on, worker-log-consumer at zero,
logs still streaming and landing in execution_log) needs a live stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three dashboard_metrics schedules are static, code-declared rows — Beat gets
them from a data migration (0002_setup_periodic_tasks, update_or_create), so the PG
scheduler gets them the same way rather than by mirroring Beat's table at runtime.
That removes the mirror command from the rollout path for these entirely.

Placed in dashboard_metrics/ rather than pg_queue/ deliberately: the failure mode
that matters is the two declarations drifting apart, and a reviewer editing a
schedule only sees both if they sit side by side.

Why a migration here when the pipeline mirror stays a management command: three
fixed rows known at build time is exactly what a data migration is for — tiny,
idempotent, reaches every environment including on-prem with no operator step.
Pipeline schedules are bulk and per-environment, so they stay a chunked command that
runs outside the migrate transaction.

Rows land INERT: pg_owned=False and next_run_at=NULL. The PG scheduler's due scan is
WHERE pg_owned AND enabled, so nothing is selectable; and NULL next_run_at means
"baseline next tick", not "overdue, fire now", so enabling the flag later cannot
produce a burst of catch-up runs.

task_kwargs is stored DECODED — Beat keeps kwargs as a JSON string
('{"retention_days": 30}') while PgPeriodicTask.task_kwargs is a JSONField. The
drift test compares them after json.loads; a silent mismatch there would change the
cleanup retention.

Safe to ship flag-off, verified rather than assumed:
- pg_queue/0003 (the table) is branch-only, so table and seed rows ship together.
- Single schema — django_tenants is commented out and TENANT_APPS is empty — so 3
  rows total, not 3 per org.
- Only reader is pg_scheduler.py, gated on pg_owned; the invalid-cron UPDATE at :265
  is reachable only for rows that scan already selected.
- workerPgScheduler.enabled is false in base values with no env override, so the PG
  scheduler is not even deployed. Inert twice over.
- Both pg_queue and dashboard_metrics are in settings/base.py, which every variant
  imports, so apps.get_model resolves everywhere the migration runs.
- Beat is untouched: the migration writes only pg_periodic_task.

Caveat: this is static analysis. The migration has not been applied against a live
database here (no DB; DB-bound tests are deselected as integration). Integration is
the real confirmation.

Tests: 12, DB-free — the Beat migration's forward function is run against fakes and
its declarations compared to the PG specs, so the test cannot drift alongside a
hardcoded copy. Mutation-checked: changing a cron (2am->4am) and a retention kwarg
(365->90) each turn it red. 151 pass across dashboard_metrics/, pg_queue/,
scheduler/, workflow_v2/.

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

Pipeline schedules created before the PG mirror existed have no
pg_periodic_schedule row, so the PG scheduler has nothing to fire for them. Turning
the flag on without backfilling first means those pipelines silently stop running.
Until now the backfill was a manual command nobody was reminded to run.

--mirror-only, and why it is load-bearing: reconcile_pg_schedules has no --adopt
flag — handle() runs _reconcile_all unconditionally. With the rollout off that is
inert (resolve_schedule_owner fails closed, so enabled = active AND NOT False leaves
Beat untouched), but with the rollout ON it flips pg_owned AND disables the matching
Beat PeriodicTask. That is a behaviour change no unattended job may make on its own.
Backfilling is additive at every flag state, so that is all automation does; the
ownership hand-over stays an explicit operator action.

entrypoint.sh runs it inside the existing --migrate branch, after migrate returns.
It is an ordinary management command, NOT a migration — sequenced after migrate only
because pg_periodic_schedule must exist first. Only the single backend service passes
--migrate, so there is no concurrent-replica race, and every other compose service is
restart: unless-stopped, so a one-shot service would be a new convention for no gain.

Best-effort by design (`|| echo WARNING`): entrypoint.sh has no `set -e`, but don't
depend on that. A mirror failure must never stop the backend booting — Beat keeps
firing everything in that case, which is the safe state.

Runs on EVERY start rather than once. Schedules created while an older backend was
deployed, and rows previously skipped for malformed PeriodicTask.args, are only
picked up by a re-run; it is idempotent (already-mirrored pipelines are skipped), so
there is nothing to retire until Celery is decommissioned.

Not a gap this closes and not one it needs to: schedules created or edited SINCE the
mirror shipped are already dual-written by SchedulerHelper on every save
(helper.py:70), unconditionally and flag-independently. This is pre-existing rows only.

Tests: 12 in the command suite (1 new), 117 across pg_queue/, dashboard_metrics/,
scheduler/. Mutation-checked: making --mirror-only reconcile anyway turns it red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stays sole scheduler

Beat remains the ONLY scheduler for the whole PG rollout; the PG scheduler is
activated later, as part of retiring Beat. That works because pipelines already reach
PG without it: execute_pipeline_task_v2 -> WorkflowHelper.complete_execution ->
execute_workflow_async -> resolve_transport (workflow_helper.py:631). The PG
scheduler exists to retire the Beat *deployment*, not to get work onto PG. One
scheduler at all times means the duplicate-trigger class never arises.

pg_scheduler_enabled was already referenced by scheduler/helper.py:79 and twice in
reconcile_pg_schedules (docstring + help text) — but no code implemented it.
resolve_schedule_owner gated on pg_queue_enabled alone, welding the two flips
together. This builds the gate that was designed and documented but never written.

Without it, turning pg_queue_enabled on hands schedules to a PG scheduler that is not
running: reconcile_ownership_for disables the Beat PeriodicTask, nothing polls the PG
side, and the pipeline has NO firer at all. It runs in the backend on every schedule
save, so scaling Beat to zero does not avoid it, and no ramp command is needed — a
user saving a schedule is enough.

Two call sites, both required:
- resolve_schedule_owner returns False immediately, short-circuiting BEFORE the Flipt
  call so there is no evaluation per schedule save.
- reconcile_ownership_for returns early, writing NEITHER Beat table. This one is
  load-bearing beyond the obvious: merely resolving to Beat would still issue
  PeriodicTask.update(enabled=active) and bump PeriodicTasks.update_changed() on
  every save — writing back a value Beat already had and forcing a Beat reload, on
  tables we promised not to touch. (Both exist: ..._periodictask holds the schedules;
  ..._periodictasks is a single-row reload signal.)

It also stops pg_owned being set. Otherwise it would drift to True across the rollout
and the day the PG scheduler is switched on it would immediately fire everything Beat
is also firing.

Flag-off is unchanged. Flag-on now leaves Beat's tables untouched, so turning the
flag back off is a pure Flipt flip with nothing to restore.

Tests: 5 new for the gate (incl. reconcile touching neither Beat table); the 7
existing hand-over tests still pin the FINAL-phase behaviour and are scoped behind an
autouse fixture that turns the gate on, rather than weakened. 167 pass across
scheduler/, pg_queue/, dashboard_metrics/, workflow_v2/, pipeline_v2/. Both gates
mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bug in my own UN-3755 change, found while checking whether tool containers use
Celery. They do not — but the SIDECAR does publish logs, and it never received the
transport switch.

tool-sidecar/log_processor.py:165 calls LogPublisher.publish. LogPublisher reads
LOG_TRANSPORT and defaults to Celery when unset. The sidecar's environment comes from
the hand-picked allowlist in runner.py's _get_sidecar_container_config — it does NOT
inherit the runner's env — and LOG_TRANSPORT was not in it.

Effect with the flag on: every other publisher moves to the Redis list while the
sidecar keeps publishing to celery_log_task_queue. Once worker-log-consumer is scaled
to zero (the acceptance gate) those tool logs are dropped outright — no live
streaming, no execution_log rows — silently, for container-based tool workflows.

The cloud values comment asserted "runner forwards it to each tool sidecar". That was
not true; I wrote it without checking. Corrected there to say the forwarding is
explicit, not inherited, and to point at the test that now pins it.

LOG_STREAM_QUEUE_NAME is forwarded too: if a deployment renames the queue, the
sidecar must push to the same list the consumer drains.

Needs NO new credentials — REDIS_* is already in that allowlist. That is the concrete
payoff of choosing a Redis list over the PG queue for this hop; PG would have
required database credentials in every spawned sidecar.

Scope note, since it also corrects something I overstated earlier: the sidecar path
is NOT every file execution. Structure-tool workflows now run in-process via the
executor (file_processing/structure_tool_task.py: "Replaces the Docker-container-based
StructureTool.run()"), so runner + tool container + sidecar remain only for
non-structure tools. I had cited per-execution sidecars as a load-bearing argument for
Redis over PG; it is real but narrower than I presented. The decision stands on the
other grounds (PG would sit in the middle of a Redis-to-Redis path, and unstract/core
has no psycopg2).

Tests: 5 new, DB-free. Mutation-checked — removing the LOG_TRANSPORT line reproduces
the bug and turns 2 red. 16 pass across the runner suite. Also adds runner/tests/ as a
package root; the runner venv needs `uv sync --group test`.

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

Regression I introduced in c6f77e4. The dashboard-metrics side-effect import was
written as a BARE module import:

    import dashboard_metrics_tasks

and my comment on it explicitly justified that form. The justification covered one
entry path and missed the other.

worker.py reaches a tasks.py two different ways:

  1. BY PATH — spec_from_file_location with the worker's own dir appended to
     sys.path. A bare import resolves, because /app/scheduler is on the path.
  2. AS A PACKAGE — general/tasks.py:16 does
     `from scheduler.tasks import execute_pipeline_task_v2`. Now /app/scheduler is
     NOT on sys.path, only /app. The bare import raises ModuleNotFoundError.

So worker-general crash-looped in integration:

    File "/app/general/tasks.py", line 16, in <module>
        from scheduler.tasks import execute_pipeline_task_v2
    File "/app/scheduler/tasks.py", line 14, in <module>
        import dashboard_metrics_tasks
    ModuleNotFoundError: No module named 'dashboard_metrics_tasks'

This is a FLAG-OFF regression — PG is not involved, the flag was never enabled, and
no PG worker was deployed. It breaks the Celery path we promised to leave untouched.

Fix: `from scheduler import dashboard_metrics_tasks`. /app is on PYTHONPATH
(run-worker-docker.sh:571), so it resolves under both mechanisms. Comment rewritten
to record why it must stay absolute.

Why the existing 1501 tests missed it: nothing exercised either import mechanism.
The new suite closes that — it discovers every */tasks.py and imports each one BOTH
ways. Mutation-checked: restoring the bare import reproduces the exact production
ModuleNotFoundError.

The tests run each mechanism in a SUBPROCESS. In-process, importing the same file as
both `dashboard_metrics_tasks` and `scheduler.dashboard_metrics_tasks` creates two
module objects and duplicate Celery registrations, which made test_dashboard_metrics_tasks
fail on patches that hit the wrong copy. A fresh interpreter is properly isolated and
is also a truer reproduction of a booting worker.

Also asserts the three proxies register under their exact wire names — deleting the
import would otherwise "fix" the crash while silently unregistering the tasks the PG
metrics consumer resolves by name.

api-deployment is excluded from the package-import check only: a hyphen is not a valid
Python identifier, so it can never be imported that way.

Tests: 4 new; 1505 pass across the workers suite (was 1501 + 4, no regressions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…OVE block

Regression I introduced in 2fa6272, found running in integration:

    ERROR {module:redis_stream_consumer} :- Log stream read failed; retrying
    redis.exceptions.TimeoutError: Timeout reading from socket

every ~5.01 seconds while idle.

_BLOCK_TIMEOUT_SECONDS was 5, and create_redis_client defaults socket_timeout to 5
(unstract/core/.../redis_client.py:44). redis-py enforces socket_timeout on the
blocking read itself, so the socket expired at the same instant the server-side
block did — and won the race. Hence the exact 5.01s cadence.

This is the SAME trap already documented at
workers/queue_backend/pg_queue/result_backend.py:152, where the identical pairing
is spelled out for the PG result signal. I wrote that warning and then reproduced
the bug three files over.

Two consequences, one loud and one quiet:

  1. A traceback every 5s while idle, each tearing down the connection
     (_disconnect_raise) and reconnecting on the next pass. Log noise plus
     needless connection churn.
  2. The one that matters: BLMOVE is atomic server-side. When the socket timed out
     after Redis had already moved an envelope onto the processing list but before
     the reply reached the client, that envelope was stranded there — recovered
     only by _recover_in_flight on a restart of the same pod. A real, if narrow,
     log-loss window.

Fix: build the client with socket_timeout DERIVED from the block

    _SOCKET_TIMEOUT_SECONDS = _BLOCK_TIMEOUT_SECONDS + 5

so the two cannot drift apart. RedisQueueClient.from_env() is dropped because it
hard-codes the 5s timeout and exposes no override; create_redis_client takes one.
Every method used here (blmove/lmove/lrem) is native redis-py, so nothing is lost.

_BLOCK_TIMEOUT_SECONDS deliberately stays 5s: it is the loop's only chance to
observe a shutdown signal and must stay well under the pod's 60s
terminationGracePeriodSeconds. This removes the exception, not the wakeup.

Tests: 4 new, covering the invariant, its survival when LOG_STREAM_BLOCK_TIMEOUT
is raised to 45 (proving it is structural rather than two defaults coinciding),
that the constant actually reaches create_redis_client, and that BLMOVE still gets
the block value. Two existing tests re-pointed off the removed RedisQueueClient.
Mutation-checked: reverting to `= _BLOCK_TIMEOUT_SECONDS` fails 2 of them.

Flag-gated: this worker only runs when workerLogStreamConsumer is enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…k is a values change

Beat→PG hand-over had no working reverse. Found on integration 2026-08-12, where
Beat and the PG scheduler fired one pipeline 2.4s apart; a single execution resulted
only because the Celery consumers happened to be scaled to zero.

Three defects, one theme: the reverse direction was never reachable.

1. PIPELINES HAD NO REVERSE AT ALL. reconcile_ownership_for returned early whenever
   PG_SCHEDULER_ENABLED was off, writing NEITHER table. That is correct for a clean
   environment, but it assumed pg_owned could never be True while the gate is off. A
   build predating the gate breaks that: there, resolve_schedule_owner keys on the
   pg_queue_enabled Flipt flag ALONE, so flipping it hands schedules to PG. Roll
   forward onto the gate and the row is stranded — nothing can clear it, not even
   `reconcile_pg_schedules`, which routes back through the same early return. Only
   hand-written SQL could, which is not a deploy procedure.

   Reachable in production WITHOUT a mis-built image: deploy this branch, roll back
   to a pre-gate build while the flag is on, roll forward. Rollback is precisely when
   a stranded scheduler is least affordable.

   The gate-off path now ASSERTS its invariant instead of assuming it: one indexed
   existence check, and still zero writes when the row is already correct — so
   "Beat's tables stay untouched for the whole rollout" holds for every environment
   that never had a contradictory row.

2. --release RESURRECTED DELIBERATELY-DISABLED PERIODICS. It wrote enabled=True
   unconditionally, so a job an operator had switched off in Beat before the
   migration came back ON after a rollback. A rollback restores the previous state;
   it does not invent a new one. The pre-migration value was already recorded —
   plan_mirror copies task.enabled — so release now restores row.enabled.

3. RE-MIRRORING AFTER ADOPTION SILENTLY KILLED THE ROW. `enabled` is the one mirrored
   field that stops tracking Beat once PG owns it: after --adopt, Beat's copy is False
   by definition. Copying that back left pg_owned=True with enabled=False, matching
   NEITHER firer (the PG tick selects WHERE pg_owned AND enabled; Beat's row is
   disabled). The periodic stops with no error, and the value needed to release it
   correctly is gone. Cron/args/queue still track Beat, so a schedule edit made while
   PG owns the row is still picked up.

Rather than leave the reverse as a second command an operator must remember, a single
converge_pg_scheduler command routes on PG_SCHEDULER_ENABLED — adopt when on, release
when off — and entrypoint.sh runs it on every start. Reverting becomes a values change
like any other, including on-prem where a deploy cannot reach `manage.py`. Idempotent
in both directions; an unset gate converges to Beat, so the default is the safe
direction. Periodics stay opt-in behind --periodics (PG_SCHEDULER_ADOPT_PERIODICS),
because adopting them needs workerPgMetrics deployed first.

What this deliberately does NOT do: converging to Beat restores ownership, not
capacity. Beat publishes over RabbitMQ, so released schedules only fire again if
workerSchedulerV2 (and workerMetrics) are running — flip those in the SAME change that
turns the gate off, or the outage merely moves. The release path logs that warning.

Flag-gated: with PG_SCHEDULER_ENABLED unset — every environment today — the only
behaviour change is that a contradictory row gets repaired instead of stranded.

Tests: 18 new across three files, covering the released-back-to-Beat transaction, a
paused pipeline surviving repair unchanged, Flipt never consulted while the gate is
off (asking could re-hand a schedule to a scheduler that is not running, turning a
double-fire into no firer), a DB error falling back to writing nothing, enabled
preserved through a full adopt→release cycle, and the converge routing in both
directions. Mutation-checked: restoring the blanket early return fails 2, the blanket
re-enable fails 1, the mirror clobber fails 1, inverting the converge direction fails
5. One existing fixture gained an `enabled` attribute a real PgPeriodicTask always has.

370→378 unit-backend pass. NOT verified against a real database — every test here is
mocked, and the atomicity test that needs Postgres skips; the rig's integration-backend
tier is what exercises the actual transactions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… patch the live module

Fallout from 6397dd5, exposed by backmerging main.

That commit changed scheduler/tasks.py to `from scheduler import dashboard_metrics_tasks`
(the bare form crash-looped worker-general). This test still imported the module by BARE
name, mirroring only worker.py's by-path load. Once both forms were in play the two
coexisted as SEPARATE module objects with separate Celery registrations: the test patched
one while the other stayed live, so a test that believed it had mocked the HTTP client
issued a REAL request and died on DNS.

Order-dependent, which is why it read as flakiness — it needed some earlier test in the
run to have imported scheduler.tasks first. Passing the file alone was green; `tests/`
as a directory was not. Latent since 6397dd5; main's new tests shifted collection order
enough to surface it. Verified against pristine origin/main (1487 passed) to confirm the
merge itself was not the cause.

The sys.path insertion was the second half of the damage: putting `scheduler/` on the
path let scheduler/worker.py SHADOW the top-level worker module, so
shared/tests/test_session_lifecycle.py failed with
`module 'worker' has no attribute 'on_task_postrun'`. Same root cause, different symptom;
both go away by importing the package form and putting the workers ROOT on the path
instead.

The package form is right rather than merely convenient: worker.py's by-path load of
tasks.py still resolves `from scheduler import ...` against /app, so
scheduler.dashboard_metrics_tasks is the single module object under BOTH runtime
mechanisms. The test now patches what the worker actually runs.

Test-only; no production change. unit-workers 1387, unit-core 33, unit-backend 737, all
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r fires a catch-up

Observed on integration 2026-08-14. gallh_load_test was re-enabled in the UI at
11:14:37; the PG scheduler fired it at 11:14:41 — two seconds later, against
next_run_at=2026-08-12 06:08, a timestamp two days old. The operator's own manual run
was already in flight, so the pipeline ran twice within 2s.

The tick selects:

    WHERE pg_owned AND enabled AND (next_run_at IS NULL OR next_run_at <= now())

so a next_run_at left over from an earlier PG-ownership period is already in the past
and matches on the very next pass. NULL is the guard — "record a baseline next tick,
don't fire this cycle" (pg_queue/models.py:366) — but it was only applied on release,
not on the two transitions that re-arm a row:

  * ADOPT — reconcile_ownership_for cleared next_run_at only when handing BACK to Beat,
    so a stale value survived the hand-over.
  * RESUME — _mirror_periodic_schedule_set_enabled wrote `enabled` and left next_run_at
    alone. While a schedule is paused its next_run_at keeps drifting into the past, so
    re-enabling fires immediately. The longer the pause, the more certain.

Stated generally: pausing a PG-owned schedule and later re-enabling it caused an
immediate unscheduled run. Beat never behaved this way — DatabaseScheduler keeps no
persisted next_run_at and recomputes due-ness from the crontab each tick — so this was a
PG-path regression against it, not a cosmetic difference.

The adopt fix is scoped to the TRANSITION (was_pg_owned False → True), not to every
call. reconcile_ownership_for runs on every pipeline save, and clearing unconditionally
would re-baseline mid-cycle: a save at 12:07:59 against a 12:08 next_run_at would push
it to 13:08 and SKIP that fire. The resume fix is unconditional because that helper is
reached only from enable_task/disable_task — an explicit pause/resume — never from the
per-save path. Pause deliberately leaves next_run_at alone: nothing fires while
disabled, and clearing there would discard the value resume baselines against.

An existing test was NARROWED rather than kept: test_pg_owned_does_not_clear_next_run
asserted "adopt never clears next_run_at", which is now the opposite of correct. It was
also passing for the wrong reason — a bare MagicMock is truthy, so was_pg_owned read as
"already owned" by accident. It is now
test_an_ALREADY_pg_owned_schedule_does_not_clear_next_run, sets was_pg_owned explicitly,
and documents that it covers the True→True case. The guarantee it defends — no
re-baselining on an ordinary save — is real and still enforced.

NOT the whole story: next_run_at still overloads one nullable timestamp with three
meanings (NULL = baseline, past = fire now, future = wait), so a stale value is
indistinguishable from a legitimately due one. THREE writers have now forgotten to clear
it (the original mirror, adopt, resume). The durable fix is a staleness guard in the tick
— if next_run_at is more than one cron period old, baseline instead of firing — enforced
once where it fires rather than in every path that touches ownership. Filed separately.

unit-backend 740 → 742, unit-workers 1387, pre-commit clean. Mutation-checked one test
each: reverting the adopt baseline fails test_handing_over_to_pg_baselines, reverting the
resume baseline fails test_resume_clears_next_run_at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PENDING is not a terminal state, so every execution must eventually reach COMPLETED or
ERROR. One case had no owner at all.

deployment_helper.py commits the execution row at :236 and dispatches at :310, with file
staging in between. If the request dies in that window the row is orphaned:

  * the reaper recovers strands by scanning pg_barrier_state, and a barrier only exists
    once a batch is dispatched — no dispatch, no barrier, invisible;
  * execute_workflow_async marks dispatch FAILURES ERROR, but never runs at all;
  * nothing else looks at PENDING, so the row is a permanent lie in the UI and in every
    completion metric.

Observed on integration 2026-08-17: an 800-user load test left 1947 COMPLETED and 967
PENDING — 2914 rows from 2444 requests, because rows outlived their requests. The backend
access log shows 1153 completions and ZERO 4xx/5xx, so 1761 handlers created a row and
never returned; 794 had already dispatched (workers finished them) and 967 had not.
pg_queue_message was completely empty, proving the queue consumed everything it was given.

NOT PG-specific. The window sits upstream of resolve_transport, so the Celery path has it
too and the predicate is transport-aware for that reason. But note the TRIGGER is the PG
reaper, so this only runs where workerPgReaper is deployed — prod-on-Celery is NOT covered
until it migrates. Deliberate: the alternatives were a second caller in the log-history
scheduler or a Beat periodic, and neither belongs in this change.

THE PREDICATE. workflow_helper.py:566/570 stamps queue_message_id (PG) or task_id (Celery)
immediately after a successful dispatch, and the model documents the other stays NULL. So
PENDING + both handles NULL + older than the grace period means dispatch never happened —
no JSONB probing, no joins, correct under either transport.

CLAIMING is one UPDATE ... RETURNING. The inner SELECT bounds the batch and takes row
locks (SKIP LOCKED); the OUTER WHERE re-carries the full predicate so Postgres
re-evaluates at write time under those locks — a row dispatched between selection and
write is skipped rather than marked ERROR *while it runs*, the one failure this must never
cause. RETURNING names exactly the claimed rows, which is what lets the irreversible
cleanup run for those and only those. (A first draft looped one UPDATE per row to get that
guarantee; this keeps it and drops 500 round trips.)

CLEANUP does what the abort prevented: releases the API-deployment rate-limit slot (held
slots consume the org's concurrency budget until the 6h Redis TTL) and deletes the API
storage dir (scoped by workflow_id + execution_id, exists()-guarded, so a no-op for a row
that died before staging). Best-effort and isolated — the status write already succeeded.

TIMING: 15-minute grace, 5-minute sweep cadence, so ~20 min worst case to terminal.
Generous on purpose — elapsed time is the ONLY thing separating "abandoned" from "about to
dispatch", and terminalising a live execution is far worse than leaving a dead one longer.

The error_message is USER-FACING: ExecutionSerializer uses `exclude`, not `fields`, so
every unlisted model field is serialized to customers. It names no internals and answers
the three things a user needs — did anything run, is my data affected, what do I do:

    This execution did not start. The request was interrupted before any processing
    began, so no files were processed. You can safely run it again. (ref: EXEC_NOT_STARTED)

170/256 chars so the ref survives truncation. The precise cause goes to logger.error with
the ids. The no-internals test earned its keep immediately: it caught this commit's own
first ref code, EXEC_NOT_DISPATCHED, which leaked "dispatch".

INDEX: migration 0026 adds a partial index built CONCURRENTLY, copying 0023's structure on
this same table (atomic=False, SeparateDatabaseAndState, IF NOT EXISTS, INVALID-index
guard, out-of-band build instructions). workflow_execution is multi-million-row in
production, where a plain AddIndex would hold a SHARE lock for the whole build and block
every in-flight execution. The index is near-EMPTY in steady state — executions leave
PENDING within seconds — so it costs almost nothing and only grows when something is
wrong. we_active_by_workflow_idx is usable for this predicate (PENDING implies NOT IN
terminal) but is keyed on workflow_id, which the sweep does not filter on, so without this
the sweep falls back to a full scan of that index.

METRICS: pg_reaper_undispatched_swept_total + _failures_total on the reaper's EXISTING
liveness server (:8086/metrics) — no new process or scrape target. Follows the recovery
sweeps (barrier_recovered, claim_recovered) rather than the retention ones, which have no
success counter. Nothing scrapes these in cloud yet (OPERATIONS.md); they are for incident
curl.

Tests: 28 new. unit-backend 745 -> 755, unit-workers 1387 -> 1392. Mutation-checked:
drifting the index status literal fails 1; dropping CONCURRENTLY fails 1; re-raising in
the reaper instead of swallowing fails 28 (confirming the swallow is load-bearing — a
fault here must not abort a tick that also dispatches schedules). One mutation ESCAPED
first time: removing the outer re-check from the claim broke nothing, because the substring
assertions were satisfied by the inner SELECT. Fixed by counting occurrences instead —
test_the_predicate_appears_TWICE__inner_select_and_outer_recheck now fails on it.

NOT verified locally: the 10 django_db tests and the raw SQL itself only run in the rig's
integration-backend tier (no Postgres here). The 3 message-contract and 10 drift tests are
deliberately kept unit-tier by scoping the marker to the DB-bound classes.

Recovery only. The mechanism that killed those handlers is still unconfirmed — two
hypotheses were tested and both failed (gunicorn crashes: the messages are graceful and
pair with HPA churn, no OOMKilled; short termination grace: it is actually 900s with a 60s
preStop). Closing the window itself, and any capacity change, are deliberately out of
scope; a sweep is needed regardless, since a node eviction can always kill a request
mid-flight.

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

Re-enabling a paused pipeline fired one spurious catch-up run within ~5s.
Observed three times on integration (2026-08-12, -14, -20) against
gallh_load_test; the 08-12 occurrence collided with the operator's manual run
over the same 100 files and blew the shared Azure gpt-4o quota, failing 83 and
88 files across the two executions.

ec0362f fixed this for enable_task/disable_task, but the UI resume does not
take that path: it re-saves the pipeline, reaching _schedule_task_job ->
mirror_periodic_schedule_upsert and reconcile_ownership_for. The latter
baselines only on a Beat->PG hand-over (pg_owned and not was_pg_owned), so an
already-pg_owned row kept the next_run_at it held when paused -- by then in the
past -- and the tick's `next_run_at <= now()` fired it on the next pass.

The upsert could not express the fix: _retargeted_next_run_at returned None both
for "leave the column alone" and would have for "write NULL", and the call site
read None as the former. Split by a _LEAVE_NEXT_RUN_AT sentinel so NULL can be
written deliberately, and clear on the enabled False->True transition only --
the same transition-scoping reconcile_ownership_for uses, so a save at 12:07:59
against a 12:08 next_run_at still fires.

Resume is checked before the cron comparison: a plain pause/resume does not
change the cron, so the equal-cron early-out would otherwise swallow the common
case. Mutation-checked -- dropping the branch, collapsing the sentinel to None,
and reordering the two checks each turn tests red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uivalent

cron_from_periodic_task's docstring promised "a step cron where one exists
exactly", but the code range-checked (every < 60 / < 24 / < 32). `*/N` restarts
at each field boundary, so it means "every N" only when N divides that field's
range. Where it does not, the last step of one period runs into the first of the
next and the task fires early -- silently, and only at the boundary:

  */7  minutes -> :00 :07 ... :56 then :00   -- a 4-minute gap, not 7
  */45 minutes -> :00 :45 then :00           -- roughly twice as often
  0 */5 hours  -> 0 5 10 15 20 then 0        -- a 4-hour gap, not 5

Days are worse: `*/N` on day-of-month restarts every month and months are 28-31
days, so `0 0 */7 * *` fires on the 1st, 8th, 15th, 22nd, 29th and then the 1st
again -- 2 to 4 days later depending on the month. There is no correct cron for
"every N days" beyond N=1, so only every==1 maps, to a plain daily.

Now requires 60 % every == 0 / 24 % every == 0 / every == 1. Anything else falls
through to plan_mirror's existing skip-and-explain path, the same one that
already refuses second-resolution intervals: the periodic stays on Beat visibly
rather than being adopted at a frequency nobody chose.

Found while verifying the integration cutover, where
dashboard_metrics_aggregate_from_sources turned out to be an IntervalSchedule
(interval_id=8) rather than a crontab. It was unaffected -- 15 divides 60 -- but
Beat schedules are per-environment DB rows that exist in no source file, so
staging or production can carry an interval that does not.

The (3, DAYS, "0 0 */3 * *") case in test_interval_maps_to_an_exact_step_cron
encoded the bug as an expectation; replaced deliberately, since that cron does
not fire every 3 days. Mutation-checked: restoring the range check fails 13
tests, dropping only the days restriction fails 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… missed interval

Releasing schedules back to Celery Beat fired a burst of catch-up runs. Observed on
integration 2026-08-24: converge_pg_scheduler released 23 schedules and Beat
dispatched 4 pipelines plus all 3 dashboard_metrics.* periodics within 30 ms of
logging "Released to Beat".

Release restored PeriodicTask.enabled but left last_run_at untouched. DatabaseScheduler
stores no next_run_at -- it derives due-ness from last_run_at against the crontab -- so
rows that had been PG-owned since 2026-08-21 were overdue by every interval they had
missed, and Beat replayed them the instant `enabled` flipped back.

This is the exact mirror of the adopt-side bug fixed in 2088d69, which baselines
next_run_at so a hand-over cannot fire a catch-up. That half was done; this half was
not. Fixed at both release sites, scoped to the TRANSITION like its counterpart:

  scheduler/ownership.py            pipelines, on `was_pg_owned and not pg_owned`
  mirror_pg_periodic_tasks.py       periodics, on `not to_pg`

Stamping unconditionally would push Beat's clock forward on an ordinary pipeline save
(reconcile runs on every save) and silently skip a due fire, so both are gated on the
release edge. Adopt deliberately does NOT touch last_run_at: Beat is being switched
off, its clock is irrelevant, and overwriting it would destroy the value the eventual
release restores from.

`timezone` was not imported in mirror_pg_periodic_tasks.py -- the fix would have raised
NameError on the very path it repairs, so that import is load-bearing.

CORRECTS A CLAIM I HAD PROPAGATED. ec0362f asserted "Beat parity, not a new rule:
DatabaseScheduler holds no persisted next_run_at and recomputes due-ness from the
crontab each tick, so re-enabling never produced a catch-up run there." That is wrong:
it recomputes from last_run_at, which is precisely what makes it catch up. I used that
claim to argue the catch-up was a PG-only regression against Beat; it is in fact a
hazard both schedulers share. Corrected in all three places it had spread to
(ownership.py, scheduler/tasks.py, and two test docstrings), so the next reader does
not inherit it.

Why it matters beyond tidiness: release is the ROLLBACK path. Staging and production
would use it under pressure, and a rollback that immediately replays every overdue
schedule -- burning an LLM pass per pipeline -- is a poor thing to discover mid-incident.

782 backend + 1392 worker tests pass; pre-commit clean. Mutation-checked, each red for
a distinct reason: dropping the pipeline stamp fails 4, stamping unconditionally
instead of on the transition fails 2, dropping the periodics stamp fails 1.

Four expectations changed deliberately, not loosened: they asserted the whole kwargs
dict (`== {"enabled": True}`), which now carries a second key. Each is per-key now plus
new cases, pinning strictly more than the exact-dict form did -- including that a paused
pipeline is released disabled but still baselined, so resuming it later does not replay
the backlog it accrued while PG owned it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… too

An execution stranded by a PG cutover sat EXECUTING forever with nothing able to
close it. recover_stuck_pg_executions filtered `queue_message_id__isnull=False`,
and a Celery row carries task_id with queue_message_id NULL -- so it was invisible.
The other recovery paths could not help either: the chord callback is gone with its
worker, and the reaper's barrier sweeps scan pg_barrier_state, which a Celery
execution never had.

The mechanism is a grace-period asymmetry, not a queue defect:

  worker-file-processing-v2           grace 7200s
  worker-file-processing-callback-v2  grace  300s

On a cutover both get SIGTERM. File processing has two hours and finishes its
batches; the callback worker dies after five minutes. The batches then dispatch
process_batch_callback to a worker that is already gone. Every file reaches
COMPLETED and the execution never leaves EXECUTING.

Only the SELECT was PG-scoped -- _recover_one_stuck_pg_execution was always
transport-agnostic, reading file statuses and recomputing the terminal status
without touching a queue. So this widens the filter and changes nothing else:

  Q(queue_message_id__isnull=False) | Q(task_id__isnull=False)

Rows with BOTH handles NULL are deliberately still excluded. Those were never
dispatched and belong to undispatched_sweep.py, whose claim requires
`task_id IS NULL AND queue_message_id IS NULL`. The two predicates are now disjoint
on task_id, which matters because both run on the same reaper cadence against the
same table -- an overlap would have one marking a row ERROR while the other
finalized it. The `total == 0 -> skipped` guard would also have caught it, but
relying on a downstream guard for correctness is how overlaps get reintroduced.

The method and route keep the `_pg_` name despite no longer being PG-only: the URL
is an internal-API contract between backend and workers, and renaming it would
break during a rolling deploy where an older worker still calls the old path.
Misleading name, deliberate trade, documented in the docstring.

test_celery_execution_never_scanned asserted exactly the behaviour being changed --
INVERTED deliberately, not loosened, with the reason recorded in the test. Added
test_a_NEVER_DISPATCHED_execution_is_left_to_the_undispatched_sweep as the
disjointness guard.

VERIFICATION IS INCOMPLETE AND CI IS THE GATE. 782 unit tests pass, syntax and
pre-commit clean -- but the tests that exercise this change are DB-backed and did
not run locally: the rig needs testcontainers (unavailable here), and running
against the local unstract-db container fails on multi-tenant schema setup. Both
new/changed tests are CI-verified only; I have not seen them execute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rrier timeout

The safety net that finalizes a stranded execution waited ~2.5 hours before acting,
because WORKER_PG_STUCK_EXECUTION_RECOVERY_SECONDS defaulted to the barrier
stuck-timeout. The two answer different questions:

  barrier stuck-timeout   how long a batch may make NO progress   -> hours, since a
                          single file can legitimately take that long
  recovery window         debounce against a callback that is      -> seconds
                          about to fire

Reusing one for the other was convenience, not design, and it bought nothing: the
threshold is NOT what makes recovery safe. internal_views.py skips unless EVERY file
is terminal (`total == 0 or terminal < total -> skipped`), so a legitimately running
execution is never a candidate however short the window is. The long default only
meant a genuinely dead execution stayed dead for 2.5 h.

The shape that motivates this is a grace-period asymmetry, not a queue defect:

  worker-file-processing-v2           grace 7200s
  worker-file-processing-callback-v2  grace  300s

On a cutover both get SIGTERM. File processing has two hours and finishes its
batches; the callback worker dies after five minutes; the batches then dispatch
process_batch_callback to a worker that is gone. Every file is COMPLETED and the
execution never leaves EXECUTING. eabb788 lets the finalizer SEE those; this makes
it act in minutes instead of hours.

A DEFAULT rather than an operator setting, deliberately. Production and on-prem have
no Flipt and no operator: a value that exists only as an env override is one those
environments never receive, and they are exactly the ones that cannot diagnose a hung
execution themselves. The env still overrides for tuning.

Not lowered further because `total` counts the file rows that EXIST, not
execution.total_files -- while discovery is still creating rows there is a brief
"all existing rows terminal" moment. Ten minutes clears it comfortably. Comparing
against total_files would close that window properly and allow less; noted in the
constant's docstring as a follow-up.

4 tests, mutation-checked: restoring the 9000s default fails test_default_is_ten_
minutes and test_default_is_NOT_the_barrier_stuck_timeout. 782 backend + 1396 worker
tests pass, pre-commit clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The safety net that finalizes executions stranded by a transport cutover was
selecting rows it could never act on, and never reaching the ones it could.
Found by rehearsing the cutover on integration: a 30-file ETL was stranded
exactly as designed (every file COMPLETED, execution stuck EXECUTING, no
duplicate work, no data loss) and then sat there while every sweep logged a
healthy-looking "scanned 100, recovered 0".

Three changes, all in the selection path; the finalization logic is untouched.

1. Select only rows that can actually finalize.

   Candidates were any dispatched PENDING/EXECUTING row past the cutoff, and
   the all-files-terminal decision happened per-row afterwards. A rejected row
   is skipped WITHOUT touching modified_at, so under oldest-first ordering the
   same rows were re-selected on every sweep, forever, and anything behind them
   was invisible — not scanned, not skipped. Integration held 1964 candidates,
   1122 of them permanently unrecoverable (476 with no file rows, 646 with a
   non-terminal file); the freshly stranded execution ranked 1964th and would
   never have been reached.

   Pushing the guard into the query makes the drain real: every selected row
   finalizes and leaves the candidate set. That is what keeps the existing
   oldest-first ordering safe — FIFO is only fair if the queue moves — so the
   ordering is deliberately left alone.

2. Measure staleness on the files, not the execution row.

   workflow_execution.modified_at is effectively the START time: file
   completions write the file row, not the execution row. "modified_at <
   cutoff" therefore meant "started more than stuck_seconds ago", so any run
   longer than the window was formally stuck while still running — a 14-minute
   ETL was eligible from minute ten against a 600s window.

   The all-files-terminal filter kept that from being catastrophic, but left a
   live race: between the last file going terminal and the callback finalizing,
   a healthy execution passes every check. A sweep landing there finalizes it
   first, the terminal-one-way guard then refuses the callback's own write, and
   the notification is silently lost. Change (1) makes that race reachable for
   the first time by removing the starvation, so this ships with it rather than
   after it.

3. Stop fabricating execution_time on retroactive finalization.

   update_execution() stamps execution_time = now - created_at on any terminal
   transition. Right for a live finalization; wrong here, where it records how
   late the reaper was rather than how long the run took — multi-day runtimes on
   executions that ran for minutes, feeding whatever reads that column.
   Recomputed from the last file to finish, and left alone when no file carries
   a usable timestamp.

Tests pin the starvation regression (a backlog larger than `limit` composed of
permanently-skippable rows must not hide a recoverable one), the drain property,
both halves of the file-staleness predicate, and the execution_time behaviour;
each states its mutation check. Two existing tests moved from asserting
"skipped == 1" to "scanned == 0" — those rows are now excluded at selection
rather than rejected downstream. The invariant they protect is unchanged and
still asserted: neither is ever finalized or failed.

NOT VERIFIED LOCALLY. The backend suite cannot start in this checkout — every
test in the file errors at setup with "no schema has been selected to create
in", including ones this commit does not touch, and settings.cloud will not
import without the enterprise pluggable_apps. Confirmed instead: both files
compile, ruff reports exactly the findings HEAD already had, ruff-format clean,
scoped pre-commit passed. The tests need CI or a provisioned stack.

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

@muhammad-ali-e muhammad-ali-e left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PR Review — Standardized (INITIAL)

Verdict: BLOCK

Summary — Critical: 1 · High: 13 (this repo) · Medium: ~20 · Low: ~6 · Lenses run: 16/16

Reviewed under the team standardized 16-lens rubric, executed via the PR Review Toolkit specialist agents (5 scoped passes over this repo) plus an orchestrator pass on lenses 5/6/11/12/14 and the cross-repo seam with Zipstack/unstract-cloud#1744.

Coverage caveat: ~6.1k added lines across 60 files. Past what one pass covers exhaustively; depth is deliberately uneven — the sweep, scheduler ownership and reaper surfaces got the most attention.


The blocking finding

C1 — the undispatched sweep can delete the staged input of a running execution. Inline on undispatched_sweep.py. It is Critical rather than High on the rubric's irreversible state change / data loss anchor: the ERROR status is recoverable, the deleted input is not.

The evidence spans a file this diff does not touch, so it could not be attached inline:

workflow_helper.py:667 sets dispatched = True, then :679-692 records the dispatch handle best-effort with the exception swallowed — under the comment "continuing — the orchestrator is already running". Two further paths return without writing a handle after the message is already on the transport: :545-551 (empty handle) and :556-564 (unparseable PG handle). All three leave task_id and queue_message_id NULL on an execution that is running, which is exactly the sweep's "never dispatched" predicate. workflow_helper.py:665-666 states the invariant the sweep violates.


Unanchored findings

These have no line in this diff to attach to.

[High] [Lens 13, 11] — Both PRs are drafts, so no test tier is running. The PR body says of the never-executed tests: "CI is what will verify them." It is not. ci-test.yaml:64 gates on github.event.pull_request.draft == false, and gh pr checks currently reports test, e2e and report as skipping. The same is true on the cloud side, including the shadow guard that would check the on-prem render claim. Nothing verifies the new tests until these are marked ready for review.

[High] [Lens 13] — TestReconcileAtomicityRealDB can never execute (backend/scheduler/tests/test_pg_schedule_ownership.py:364-387, lines this PR does not modify). No @pytest.mark.django_db and not a TestCase, so pytest-django's blocker raises RuntimeError on the first ORM call — which is then caught by except Exception as exc: pytest.skip(...) at :387. It reports SKIPPED in every lane. ownership.py:1-12 calls the property it guards "load-bearing" (that pg_owned and PeriodicTask.enabled commit or roll back together); it has zero executing coverage. Pre-existing, but this PR makes that property considerably more load-bearing.

[High] [Lens 13, 11] — runner/tests/ is registered in no rig group. tests/groups.yaml defines no unit-runner group and tox.ini has no runner alias, so runner/tests/test_sidecar_log_transport.py — the sole guard that LOG_TRANSPORT and LOG_STREAM_QUEUE_NAME reach the sidecar's hand-picked env allowlist — is never collected. Combined with the disclosure that the new tests were not run locally, this file has been verified by nothing.

[Medium] [Lens 13, 5] — available_at missing from the worker schema-drift contract. workers/tests/test_pg_schema_drift.py:38 (pg_queue_message set, unmodified here). This PR adds worker raw SQL depending on that column (reaper.py:397-402) and adds pg_periodic_task to the manifest in the same commit, but not available_at. Rename or drop the column and the guard stays green while delayed-visibility delivery breaks at runtime.


Other Mediums worth naming

  • The sweep does 500 x 3 remote round-trips inside a 30s client timeout that is then retried 3x on timeout (undispatched_sweep.py:59, :190-238) — a per-row WorkflowExecution query, a Redis call and an object-store call for every claimed row, including ETL rows that never had API storage.
  • The under-lock re-check is strictly weaker than the selection predicate it re-confirms — last_file_at is never re-evaluated under the row lock (internal_views.py:744-745 vs :785).
  • cron_from_periodic_task silently drops CrontabSchedule.timezone (mirror_pg_periodic_tasks.py:107-109); a non-UTC periodic is adopted at the wrong hour.
  • Beat-side PeriodicTask.objects.filter(...).update(...) discards the match count on both write sites (mirror_pg_periodic_tasks.py:403, ownership.py:264), so a release can orphan a schedule and still report success — on the rollback path.
  • converge_pg_scheduler's docstring claim that "the pipeline path no-ops when ownership already matches" is false: 2N row writes plus N PeriodicTasks.update_changed() Beat reloads per backend start.
  • LOG_STREAM_QUEUE_NAME is set only on the consumer in the sibling chart while publishers rely on a matching code default (pubsub_helper.py:30) — the one place the "derive it so the bad pairing is unrepresentable" discipline was not applied.

Lens checklist

# Lens Result
1 Spec & intent See findings
2 Architectural fit See findings
3 Correctness & edge cases See C1, H1, H2
4 Security Clean — new internal endpoints match the repo baseline; InternalAPIAuthMiddleware gates every /internal/ path on the Bearer key before the view runs
5 Data integrity & migrations See C1. The migrations themselves are careful — 0026 is exemplary (atomic = False, CONCURRENTLY, explicit INVALID-index guard)
6 Concurrency Clean on the primitives — claim atomicity (FOR UPDATE SKIP LOCKED), the _CLAIM_SQL re-carried predicate, and lock ordering all verified sound. Residual issues are in findings
7 API & contract compatibility See findings (untyped log envelope, queue-name derivation gap)
8 Reliability & resilience See H1, H2, H4, H9
9 Performance & cost See findings
10 Observability See H1
11 Operational safety See findings
12 LLM/agent N/A — no model, prompt, tool or agent path touched
13 Testing See findings
14 Dependencies & build N/A — no pyproject.toml, lockfile or Dockerfile touched
15 Code quality See Lows
16 Doc & comment accuracy See findings

Open questions

  1. Are the three no-handle paths in _record_dispatch_handle genuinely reachable in production? If they provably are not, C1 drops to High.
  2. Merge/release ordering of this PR's backend image relative to the cloud chart — the sibling PR's post-upgrade hook runs converge_pg_scheduler on every cloud environment including production, and that command ships here.
  3. Was widening stuck-recovery to the Celery transport and cutting the window to 600s intended as one change, or did they land together incidentally?

Comment on lines +190 to +238
def _release_abandoned_resources(execution_id: str, workflow_id: str) -> None:
"""Do what the abort prevented the request's own error path from doing.

``deployment_helper`` releases the rate-limit slot and deletes the API storage dir
when staging *raises*. An abort raises nothing — the thread is simply gone — so
neither runs and the execution leaks both.

**Best-effort, and deliberately so.** The status write already succeeded and is the
part that matters; a failure to tidy up must never propagate and stall the rest of
the batch. Each side is isolated so one failing does not skip the other.
"""
# Slot first: it is the one with a live cost. Held slots consume the org's API
# deployment concurrency budget until the Redis ZSET TTL (6h) expires them, so a
# 502 storm can throttle a tenant for hours. Self-healing, but slowly.
try:
from api_v2.rate_limiter import APIDeploymentRateLimiter

from workflow_manager.workflow_v2.models import WorkflowExecution

organization = (
WorkflowExecution.objects.select_related("workflow__organization")
.get(id=execution_id)
.workflow.organization
)
APIDeploymentRateLimiter.release_slot(organization, execution_id)
except Exception:
logger.warning(
"Undispatched sweep: could not release the rate-limit slot for %s "
"(it expires with the limiter TTL regardless)",
execution_id,
exc_info=True,
)

# Then the staged input. Scoped by workflow_id + execution_id, and guarded by an
# exists() check inside, so it is a clean no-op for an execution that died BEFORE
# staging — nothing else's files can be reached from here.
try:
from workflow_manager.endpoint_v2.destination import DestinationConnector

DestinationConnector.delete_api_storage_dir(
workflow_id=workflow_id, execution_id=execution_id
)
except Exception:
logger.warning(
"Undispatched sweep: could not delete the API storage dir for %s "
"(orphaned input files remain)",
execution_id,
exc_info=True,
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Critical] [Lens 3, 5, 8] — The sweep deletes the staged input of a dispatched, running execution

Failure mode. The sweep infers "never dispatched" from the absence of two nullable columns (task_id IS NULL AND queue_message_id IS NULL, :112-130). Three paths in workflow_helper.py reach exactly that state after the message is already on the transport:

  • :679-692 — handle recording is best-effort, exception swallowed, immediately after dispatched = True at :667
  • :545-551 — empty handle, early return
  • :556-564 — unparseable PG handle, early return

After the 15-minute grace (DEFAULT_MIN_AGE_SECONDS = 900) such a row is claimed, and _release_abandoned_resources deletes the API storage directory at :229-231 while the worker is still going to read it. The user is simultaneously told "You can safely run it again" (:70-74) — inviting a duplicate run against deleted input.

The predicate is also not transport-filtered, so this reaches Celery executions, not just PG ones.

Why Critical and not High. The rubric's Critical anchor covers data loss and irreversible state change. Marking the row ERROR is recoverable; deleting the staged input is not. workflow_helper.py:665-666 states the invariant this violates in so many words: "Past this point the orchestrator is on its transport: a failure in the bookkeeping below must NOT flip the (now-running) row to ERROR."

Suggested fix. Make dispatch a positive fact rather than an inferred absence: stamp a dispatched_at in the same call that records the handle — including on the three non-recording branches, which know the dispatch succeeded even when the handle is unusable — and key both the sweep predicate and the 0026 partial index on it. Until the predicate is sound, drop delete_api_storage_dir from the sweep; the status write is the part that matters and it is reversible.

Confidence: High that the state is reachable and that the predicate matches it. Medium on frequency — the defensive parses make the handle paths uncommon; what makes it Critical is that the consequence is irreversible.

exc_info=True,
)
return
swept = (getattr(response, "data", None) or {}).get("swept", 0)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 10, 13, 3] — This metric can never increment; the alert for the incident this feature exists to detect is dead on arrival

sweep_undispatched_executions() returns self.post(...) -> BaseAPIClient.post -> _make_request -> _parse_response, which returns the parsed JSON body as a plain dict ({"swept": N}). A dict has no .data, so getattr(response, "data", None) is None -> {} -> swept is always 0.

pg_reaper_undispatched_swept_total never increments and the "terminalised N undispatched execution(s)" line never fires. The sweep works; every operator-facing signal for it reports nothing. The metric's own help text says a sustained non-zero rate means requests are dying between create_workflow_execution and dispatch — that is precisely the 967-orphan condition this feature was built for, and it is now invisible.

The sibling path already documents this exact trap and works around it, which is what makes this a regression rather than an unknown — execution_client.py:352-358:

# The endpoint returns a FLAT body ... convert_dict_response() reads response["data"],
# which is absent -> it would silently zero every counter the reaper logs from.
return APIResponse.success_response(data=response if isinstance(response, dict) else None)

Found independently by four of the five review agents.

Suggested fix. Either wrap in internal_client.py:1448-1461 the way execution_client does, or read the flat dict here: swept = (response or {}).get("swept", 0) behind an isinstance guard. Then fix the test fixture — see the comment on test_reaper_undispatched_sweep.py.

Confidence: High.

api = MagicMock()
api.sweep_undispatched_executions.return_value = SimpleNamespace(
data={"swept": 3}
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 13] — This fixture encodes a contract the real client does not honour, and is why the dead metric shipped

SimpleNamespace(data={"swept": 3}) is a shape nothing in the call chain produces. The real InternalAPIClient.sweep_undispatched_executions() returns a bare dict. So inc.assert_called_once_with(3) passes against a fiction while production always increments by 0. CI cannot catch the bug because the test asserts the bug away.

Separately, and worse: nothing in this file pins that the sweep is ever invoked. Every test calls r._sweep_undispatched_executions() directly on a PgReaper.__new__ stand-in; no test drives tick() or _maybe_sweep(). Delete the call site at reaper.py:1355 and the entire feature goes dead with a green suite — PENDING rows accumulate forever with no error anywhere. The file's own docstring claims the opposite: "These pin the wiring, which is the part the backend tests cannot see." grep -rn "_maybe_sweep" workers/tests/ returns nothing.

Suggested fix. Return {"swept": 3} from the mock so the test exercises the real contract, and add one test that drives _maybe_sweep() with _sweep_undispatched_executions patched — asserting it is called once and that an immediate second call is cadence-gated out.

Confidence: High.

Comment on lines +713 to +715
# Dispatched on EITHER transport. Both-NULL means never dispatched —
# undispatched_sweep.py's row, not ours.
Q(queue_message_id__isnull=False) | Q(task_id__isnull=False),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 3, 8, 11] — Stuck-recovery was widened to the Celery transport and had its window cut 9000s -> 600s in the same change, default-on

Verified against the base ref. The predicate changed from:

queue_message_id__isnull=False,  # PG-only; Celery uses task_id

to Q(queue_message_id__isnull=False) | Q(task_id__isnull=False) — every Celery execution is now in scope — while the recovery window default moved from self._stuck_timeout_seconds (the barrier timeout, ~9000s) to a hard-coded _DEFAULT_STUCK_RECOVERY_SECONDS = 600 (reaper.py:203), with recovery enabled by default.

Failure mode. Any execution whose files all went terminal >=10 minutes ago and whose chord callback has not yet written a terminal status gets finalized by the reaper. A backlogged callback queue produces exactly that state for healthy work — and a backlogged callback queue is the condition under which strands occur in the first place. The user sees the execution flip while the callback is still pending; the API-deployment rate-limit slot is released early; and the real callback's write is then refused by the protected-status guard, so the failure notification never fires.

The mitigation the code offers does not cover this. The comment at :730-742 argues that requiring last_file_at < cutoff closes the race. It closes only the immediately-after-completion window: an execution whose files finished 20 minutes ago with a still-queued callback satisfies both predicates.

Suggested fix. Separate the two changes. Keep the Celery widening if it is wanted, but leave the window at the barrier stuck-timeout for task_id-only rows, or gate the Celery half behind its own switch defaulting off so this GATED-FEAT PR does not alter live Celery behaviour. At minimum emit a distinct metric for Celery-transport recoveries so premature finalizations are countable.

Confidence: High that the predicate admits backlogged-callback executions; Medium on frequency, which depends on the real callback-latency distribution.

Comment on lines +184 to +192
#: whereas this only needs to outlast a callback that is about to fire — seconds.
#:
#: **The threshold is not what makes this safe.** The endpoint skips unless every file
#: is terminal (``internal_views.py``: ``total == 0 or terminal < total → skipped``), so
#: a legitimately running execution is never a candidate no matter how short this is.
#: Inheriting the multi-hour barrier timeout therefore bought nothing and cost a lot: a
#: stranded execution — the common shape being a Celery run whose callback worker was
#: removed by a deploy (its grace is 300s while file-processing gets 7200s) — sat dead
#: for ~2.5 h before anything noticed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 16] — This comment tells the next maintainer the threshold is not load-bearing; this PR is what made it load-bearing

The comment argues the recovery window "only needs to outlast a callback that is about to fire — seconds" and that "a legitimately running execution is never a candidate no matter how short this is."

That was arguably true when the only guard was all-files-terminal. This PR adds a second, threshold-dependent guard (last_file_at__lt=cutoff) precisely because all-files-terminal is not sufficient — and says so 500 lines away in internal_views.py:733-741: "the all-files-terminal filter above stops that being catastrophic, but it leaves a live race... Requiring the LAST FILE to also be older than the cutoff closes it."

Someone who trusts this comment and drops WORKER_PG_STUCK_EXECUTION_RECOVERY_SECONDS to 30s reopens the callback race. Per lens 16 that rates High: a comment that would actively mislead a maintainer into a bug.

Suggested fix. Invert the claim — the threshold must exceed the worst-case callback latency after the last file goes terminal, because the selection predicate now includes last_file_at < cutoff. Keep the "not the barrier timeout" rationale; delete "no matter how short this is" and "— seconds".

Confidence: High.

Comment on lines +85 to +90
if periodics:
# An empty name list means "every mirrored row" (the flag is
# `nargs="*"`, and the command distinguishes absent from empty).
# mirror_pg_periodic_tasks always backfills before flipping
# ownership, so this one call covers both halves.
call_command("mirror_pg_periodic_tasks", adopt=[], dry_run=dry_run)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 8, 11, 16] — adopt=[] means adopt everything, on every backend start, with no check that a consumer exists

In _set_ownership, if names: is falsy for [], so the name filter is skipped and all rows are adopted. _mirror runs first and mirrors every PeriodicTask not in the three-entry _EXCLUDED_TASK_PATHS, with no verification that task_name is registered in the PG worker's task registry or that any PG consumer polls that queue.

Because entrypoint.sh runs this on every backend start, any periodic added to Beat afterwards — by a plugin, a cloud-repo migration, an operator via the admin — is silently mirrored and adopted on the next pod restart: pg_owned=True and the Beat row disabled in one transaction. If nothing implements that task, the periodic stops running entirely and exits 0.

The docstring at :25 claims "Adoption is not unilateral: it happens only because someone set the env var." That holds for rows present when the var was set; it is false for every row created afterwards. This is not hypothetical — mirror_pg_periodic_tasks.py:49-53 records that a real Beat table was found carrying a legacy execute_pipeline_task_v2 row "only excluded here by luck of being disabled."

Suggested fix. Have the unattended path adopt a declared allowlist (e.g. the names seeded by dashboard_metrics/migrations/0004) rather than []-means-everything, and have plan_mirror refuse to adopt a task_name absent from the worker registry, reporting it as a skip.

Confidence: High on the mechanism.

Comment on lines +87 to +88
if not pg_scheduler_enabled():
return False

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 8, 11, 2] — A Flipt outage at backend start silently releases every PG-owned schedule

resolve_schedule_owner returns False when FLIPT_SERVICE_AVAILABLE != "true" or when the Flipt call raises. reconcile_ownership_for then writes pg_owned=False, next_run_at=NULL and re-enables the Beat row — for every schedule in the sweep.

Fail-closed-to-Beat was the right call when this ran as an explicit operator action. It is a different risk now that entrypoint.sh:63 runs the full sweep (no mirror_only) unattended on every container start. If Beat has already been scaled down — which is the stated goal of this epic — nothing fires any scheduled pipeline. It does not self-heal when Flipt recovers; only the next backend restart re-adopts. The command still exits 0, so there is no alert, only per-pipeline logger.warning.

reconcile_pg_schedules.py:11-13 still describes the old posture: "kept a command here so the ramp stays an explicit, auditable ops action."

Suggested fix. Distinguish "flag says no" from "could not evaluate", and treat the latter as leave ownership unchanged rather than release. Alternatively abort _reconcile_all with a non-zero exit when FLIPT_SERVICE_AVAILABLE != "true", rather than sweeping thousands of rows to Beat.

Confidence: High on the mechanism; Medium on blast radius, which is only an outage once Beat is genuinely retired.

Comment thread backend/entrypoint.sh Outdated
Comment on lines +31 to +45
# Backfill PG-scheduler mirror rows for pipeline schedules created before the
# mirror existed (UN-3796). NOT a migration — an ordinary management command,
# sequenced after migrate only because pg_periodic_schedule must exist first.
#
# --mirror-only: purely additive, writes only pg_periodic_schedule and never a
# Beat PeriodicTask, so it is safe at any flag state. Ownership hand-over stays
# an explicit operator action.
#
# Runs on EVERY start, not once: schedules created while an older backend was
# deployed, and rows previously skipped for malformed args, are only picked up by
# a re-run. It is idempotent (already-mirrored pipelines are skipped), so there
# is nothing to retire until Celery is decommissioned.
#
# Best-effort by design: a mirror failure must never stop the backend from
# starting. Beat keeps firing everything in that case, which is the safe state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 16, 11] — This comment block describes a command that is not run, and asserts the opposite of what the container now does

These lines document a reconcile_pg_schedules --mirror-only invocation that does not exist in this file, and state:

--mirror-only: purely additive, writes only pg_periodic_schedule and never a Beat PeriodicTask, so it is safe at any flag state. Ownership hand-over stays an explicit operator action.

What actually runs at :63 is converge_pg_scheduler, which with PG_SCHEDULER_ENABLED=true flips pg_owned and disables Beat PeriodicTask rows for every mirrored pipeline. An SRE auditing "can a rolling restart move schedules off Beat?" gets the wrong answer — and the answer is yes, for the single riskiest behaviour this PR introduces.

The staleness is easy to miss because two contradictory "Best-effort by design" paragraphs survive side by side (:44-45 and :55-56), describing two different commands.

Flagged independently by three review agents. The same contradiction exists at reconcile_pg_schedules.py:102-107.

Suggested fix. Delete :31-45; the block at :46-56 already describes what runs. Update reconcile_pg_schedules.py:102-107 to say the entrypoint runs the non-mirror_only mode when the gate is on.

Confidence: High.

Comment on lines +235 to +249
class _DuePeriodicTask(NamedTuple):
"""One row from the generic-periodic due scan (UN-3796).

Sibling of :class:`_DueSchedule`. Same reason for existing: the field names are
bound to the SELECT's column order at exactly one site.
"""

name: str
task_name: str
queue: str
task_args: list
task_kwargs: dict
org_id: str
cron_string: str
next_run_at: datetime | None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 16, 15] — The docstring promises reorder-safety that positional unpacking does not provide

The type is justified as binding "the field names to the SELECT's column order at exactly one site", and its sibling _DueSchedule states outright that this means "a future reorder of the SELECT can't silently misassign fields."

_DuePeriodicTask(*row) at :303 is pure positional unpacking — no name binding, no runtime validation. A reorder is exactly what it cannot catch. Six of the eight fields are same-typed TEXT (name, task_name, queue, org_id, cron_string), so swapping task_name and queue constructs cleanly and then fires the queue name as a task, onto a queue named after the task — a periodic that silently stops running, with a MAX_ATTEMPTS=1 message dropped on an unwatched queue.

A maintainer who reads this docstring will reorder the SELECT believing the type protects them. That is the bug the comment claims to prevent.

Suggested fix. Make the claim true instead of weakening it — derive the column list from the type:

f"SELECT {', '.join(_DuePeriodicTask._fields)} FROM ..."

One line, and it makes reordering structurally impossible for both this and _DueSchedule. Or use psycopg2.extras.NamedTupleCursor.

Confidence: High.

Comment on lines +199 to +241
def _release_stale(self, dry_run: bool, batch_size: int) -> tuple[int, int]:
"""Hand every stale pg_owned row back to Beat. Returns (released, failed).

Scoped to ``pg_owned=True`` rows so a clean installation does no work at all,
and gated on the env switch being OFF: with the ramp ON, a pg_owned row is
legitimate and releasing it would silently undo the rollout.

Unlike :meth:`_reconcile_all` this IS safe to run unattended, because its only
possible effect is moving a schedule to Beat — the same direction the system
already fails to. That is what lets the deploy run it; see entrypoint.sh.
"""
if pg_scheduler_enabled():
self.stdout.write(
"--release-stale: PG_SCHEDULER_ENABLED is on; pg_owned rows are "
"legitimate here, nothing released."
)
return 0, 0

released = failed = 0
for row in (
PgPeriodicSchedule.objects.filter(pg_owned=True)
.order_by("pk")
.iterator(chunk_size=batch_size)
):
if dry_run:
released += 1
self.stdout.write(
f"[dry-run] would release pipeline {row.pipeline_id} "
f"({row.pipeline_name or 'unnamed'}) back to Beat"
)
continue
# Routes through the same transaction the gate-off repair path uses, so
# Beat's PeriodicTask is re-enabled and next_run_at cleared in step.
result = reconcile_ownership_for(
str(row.pipeline_id), row.organization_id, active=row.enabled
)
if result is None: # transaction failed (already logged)
failed += 1
continue
released += 1
return released, failed

def _reconcile_all(self, dry_run: bool, batch_size: int) -> tuple[int, int, int]:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[High] [Lens 13] — _release_stale is the rollback half and runs on every backend start, with no test

entrypoint.sh runs converge_pg_scheduler on every backend start with migrate; gate-off routes here via --mirror-only --release-stale. Three separately-fatal branches are unexercised:

  1. the if pg_scheduler_enabled(): return 0, 0 early-out — invert or drop it and every deploy releases the whole live PG-owned fleet back to Beat mid-rollout, giving two firers per schedule
  2. the filter(pg_owned=True) scoping — widen it and every clean install does N writes plus a PeriodicTasks.update_changed() per row on each boot, contradicting the "Beat's tables stay untouched" guarantee the design rests on
  3. active=row.enabled — pass True and every operator-paused pipeline is resurrected on deploy

test_converge_pg_scheduler.py patches call_command and asserts only kwargs["release_stale"] is True, so it never reaches this body. grep -n "release_stale" backend/pg_queue/tests/ matches nothing else.

Suggested fix. Add DB-free tests mirroring the existing TestChunking mock style: gate-on returns (0, 0) and calls nothing; gate-off releases exactly the pg_owned=True rows; active comes from row.enabled; --dry-run writes nothing.

Confidence: High.

muhammad-ali-e and others added 3 commits August 25, 2026 10:59
…n's input

The sweep infers "never dispatched" from the ABSENCE of both handles
(task_id IS NULL AND queue_message_id IS NULL). That inference is unsound.
Three paths in workflow_helper reach exactly that state AFTER the message is
already on its transport:

  * _record_dispatch_handle raises and the caller swallows it, under the comment
    "continuing - the orchestrator is already running" (workflow_helper.py:686)
  * the handle comes back empty and it returns without writing (:544)
  * a PG handle will not parse as a bigint and it returns without writing (:562)

workflow_helper.py:665-666 states the invariant in so many words: past dispatch,
a bookkeeping failure must not flip the now-running row. The sweep then does
exactly that 15 minutes later - and, worse, deleted the execution's staged input
while the worker was still going to read it, telling the user "You can safely
run it again".

Marking the row ERROR is survivable: the running worker's own terminal write
supersedes it, and error->completed is explicitly permitted by the status guard.
Deleting the input is not survivable. This drops the delete and keeps the
reversible half (the rate-limit slot release, which has a live cost - a held
slot consumes the org's concurrency budget until the limiter TTL expires it).

The cost is a leaked input directory for executions that genuinely never
started. That is the right trade while the predicate is unsound.

The real fix is to make dispatch a POSITIVE fact - stamp dispatched_at in the
same call that records the handle, including on the three branches above, and
key both this predicate and the 0026 partial index on it. Tracked separately
because it needs a migration and an index rebuild.

Tests pin the ABSENCE of the delete, so it cannot be reinstated without
revisiting the predicate, and that the slot release survives the removal.

Found by the standardized 16-lens review on PR #2254 (C1, the sole blocking
finding). Verified against the source rather than accepted on report: all three
no-handle paths confirmed present, and no existing test pinned the deletion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`swept = (getattr(response, "data", None) or {}).get("swept", 0)` reads `.data`
off the return of `sweep_undispatched_executions()`, which is typed
`-> dict[str, Any]` and returns the parsed body verbatim: {"swept": N}, with no
{"data": ...} envelope. `getattr` on a plain dict yields None, so the counter was
always 0.

Consequence: `pg_reaper_undispatched_swept_total` never incremented and the
"terminalised N undispatched execution(s)" line never fired. The sweep itself
worked; every operator-facing signal for it reported nothing. The metric's help
text says a sustained non-zero rate means requests are dying between
create_workflow_execution and dispatch - the 967-orphan condition this feature
was built to detect - and that was invisible.

The sibling recover_stuck_pg_executions() meets the same flat-body shape and
documents the trap explicitly (execution_client.py:352-358), wrapping the body
so `.data` works. That is why that path's counters are correct and this one's
were not. Fixed by reading the dict directly rather than introducing a second
wrapping convention.

The test fixture was the reason this shipped: it returned
SimpleNamespace(data={"swept": 3}), a shape nothing in the call chain produces,
so it asserted inc(3) against a fiction while production incremented by 0. CI
could not have caught it - the test asserted the bug away. Fixtures now return
the real contract, and the tolerance test additionally feeds an object with
`.data` to prove that shape is ignored rather than silently trusted.

Found by the standardized 16-lens review on PR #2254; verified against the
client's declared return type and the sibling's comment before accepting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… that misdirect

Four findings from the standardized review on PR #2254, each verified against
source before accepting.

1. _DueSchedule / _DuePeriodicTask did not provide the safety they promise.

   Both docstrings say the type binds field names to columns "so a future reorder
   of the SELECT can't silently misassign fields". Both were built by positional
   unpacking - `_DuePeriodicTask(*row)` - against a hardcoded column list, which
   is exactly what cannot catch a reorder. Six of the eight periodic fields are
   same-typed TEXT, so swapping task_name and queue constructs cleanly and then
   fires the queue name as a task onto a queue named after the task: a periodic
   that silently stops running, with a MAX_ATTEMPTS=1 message dropped on an
   unwatched queue.

   Now derived: `SELECT {", ".join(_DuePeriodicTask._fields)}`. Verified the
   field names match the previous column lists exactly and in order, so the
   emitted SQL is unchanged - the claim is simply true now.

2. reaper.py's recovery-window comment told the next maintainer the threshold is
   not load-bearing. This branch is what made it load-bearing.

   It argued the value "only needs to outlast a callback that is about to fire -
   seconds" and that a running execution "is never a candidate no matter how
   short this is". That held when all-files-terminal was the only guard. The
   selection now also requires last_file_at < cutoff, precisely because
   all-files-terminal is NOT sufficient. Anyone trusting the old wording and
   dropping the window to 30s reopens the callback race.

3. entrypoint.sh documented a command it does not run, and asserted the opposite
   of what the container does.

   A block described `--mirror-only` and stated "Ownership hand-over stays an
   explicit operator action". What runs is converge_pg_scheduler, which with
   PG_SCHEDULER_ENABLED=true flips pg_owned and DISABLES Beat PeriodicTask rows.
   An SRE auditing "can a rolling restart move schedules off Beat?" got the wrong
   answer for the single riskiest behaviour here. Two contradictory "Best-effort
   by design" paragraphs sat side by side; the stale one is gone.

4. reconcile_pg_schedules.py carried the same stale claim ("that is what
   automation runs; ownership stays an operator action") and is corrected to say
   the entrypoint now runs the non-mirror-only mode unattended.

Tests: workers/tests/test_pg_scheduler.py 24 passed,
test_reaper_undispatched_sweep.py 5 passed. Shell syntax checked with bash -n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@muhammad-ali-e
muhammad-ali-e marked this pull request as ready for review August 25, 2026 06:27
… orphaning on release

Four findings from the standardized review on PR #2254. All are test wiring,
contract or logging - no control flow or dispatch behaviour changes.

1. runner/tests was in no rig group, so it was collected by nothing.

   tests/groups.yaml defines no runner group and tox.ini has no alias, so
   runner/tests/test_sidecar_log_transport.py never ran in any lane. It is the
   sole guard that LOG_TRANSPORT and LOG_STREAM_QUEUE_NAME reach the tool
   sidecar's hand-picked env allowlist - a list that DROPS anything not named in
   it, so a missing entry means the sidecar silently logs to the wrong transport.
   It shipped alongside the Redis log transport and was verified by nothing.

   Added `unit-runner`, shaped like unit-core: install_editable, because the
   suite imports `unstract.runner` which resolves only from this package's own
   src layout.

2. available_at was missing from the worker schema-drift contract.

   Migration 0002 adds the column and worker raw SQL depends on it, but
   WORKER_SCHEMA_CONTRACT's pg_queue_message set never listed it - so renaming or
   dropping it would keep the guard green while delayed-visibility delivery broke
   at runtime. That is precisely the drift the file exists to catch. 9 passed.

3. Both Beat-side `.update()` sites discarded their match count.

   A bulk update returning 0 is indistinguishable from success. On the RELEASE
   direction 0 rows means PG has let go of a schedule and Beat has no row to take
   it over - a schedule with no firer at all, on the rollback path, reported as
   success. Both sites now log when nothing matched. Log-only: no branch, no
   raise, no change to what is written.

4. converge_pg_scheduler's docstring claimed a no-op that does not happen.

   "the pipeline path no-ops when ownership already matches" is false: the write
   and PeriodicTasks.update_changed() are unconditional, so every backend start
   costs 2N row writes and N Beat reloads regardless. Corrected to say idempotent
   in outcome but not free, which is what actually justifies running it unattended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR completes the PostgreSQL queue rollout path behind feature flags, including periodic scheduling, dashboard-metrics execution, Redis-backed log streaming, delayed visibility, and execution recovery.

  • Adds complementary PostgreSQL/Celery Beat schedule ownership and deployment-time convergence.
  • Adds PostgreSQL metrics workers and internal backend endpoints.
  • Adds positive dispatch tracking and recovery sweeps for stranded or undispatched executions.
  • Adds Redis-list log transport and the corresponding worker/runtime configuration.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
backend/workflow_manager/workflow_v2/workflow_helper.py Stamps successful dispatch before best-effort transport-handle bookkeeping while preserving the post-dispatch boundary.
backend/workflow_manager/workflow_v2/undispatched_sweep.py Uses the positive dispatch marker together with legacy handle checks to avoid claiming live executions during rolling deployment.
backend/workflow_manager/workflow_v2/migrations/0027_workflowexecution_dispatched_at.py Adds the nullable dispatch marker needed to distinguish queued work from executions abandoned before dispatch.
backend/workflow_manager/workflow_v2/migrations/0028_undispatched_idx_dispatched_at.py Replaces the recovery partial index concurrently while preserving index coverage during deployment and rollback.
backend/pg_queue/management/commands/converge_pg_scheduler.py Converges schedule ownership in either direction according to the PostgreSQL scheduler rollout configuration.
backend/pg_queue/management/commands/mirror_pg_periodic_tasks.py Mirrors eligible Beat schedules and transfers ownership atomically between Beat and PostgreSQL.
workers/queue_backend/pg_queue/pg_scheduler.py Implements PostgreSQL-owned periodic dispatch with transactional schedule advancement.
workers/queue_backend/pg_queue/reaper.py Extends leader-driven recovery for queue claims, stranded executions, and undispatched execution cleanup.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Trigger[Workflow or periodic trigger] --> Route{Selected transport}
  Route -->|Celery| Celery[Celery queue and Beat]
  Route -->|PostgreSQL| PG[PostgreSQL queue and scheduler]
  PG --> Worker[PG worker]
  Celery --> Worker
  Worker --> Execute[Execute workflow or metric task]
  Execute --> Finalize[Persist terminal execution state]
  Recovery[Recovery sweeps] --> Finalize
  Logs[Execution logs] --> Redis[Redis list]
  Redis --> LogConsumer[Log consumer]
Loading

Reviews (5): Last reviewed commit: "UN-3796 [FIX] Match 0027's field to the ..." | Re-trigger Greptile

Comment on lines +120 to +121
AND task_id IS NULL
AND queue_message_id IS NULL

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.

P1 Live executions lose concurrency slots

When dispatch succeeds but handle recording returns an empty or non-numeric handle or raises after enqueue, the execution retains both NULL handles; after 15 minutes this sweep marks the active execution ERROR and releases its API concurrency slot, causing an incorrect failure state and allowing the organization to exceed its configured concurrency limit while the worker is still running.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/workflow_manager/workflow_v2/undispatched_sweep.py
Line: 120-121

Comment:
**Live executions lose concurrency slots**

When dispatch succeeds but handle recording returns an empty or non-numeric handle or raises after enqueue, the execution retains both NULL handles; after 15 minutes this sweep marks the active execution `ERROR` and releases its API concurrency slot, causing an incorrect failure state and allowing the organization to exceed its configured concurrency limit while the worker is still running.

**Knowledge Base Used:**
- [Workflow execution system](https://app.greptile.com/zipstack/-/custom-context/knowledge-base/zipstack/unstract/-/docs/workflow-execution.md)
- [Queue-backed worker orchestration](https://app.greptile.com/zipstack/-/custom-context/knowledge-base/zipstack/unstract/-/docs/worker-queue-orchestration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid finding — not disputing it. Partially addressed; the root cause is still open and tracked. Breaking it down, since "fixed" would overstate it:

Fixed — the irreversible half. _release_abandoned_resources no longer calls delete_api_storage_dir (bb772503b). That was the part that could not self-correct: the sweep deleted a live execution's staged input while the worker was still going to read it, and told the user "You can safely run it again". Tests now pin the absence of the call so it cannot be reinstated without revisiting the predicate.

Mitigated — the window. DEFAULT_MIN_AGE_SECONDS raised 900 → 3600 (1b7a26f65), so this comment's "after 15 minutes" is now an hour. That was chosen to sit clear of any realistic dequeue latency, because the failure needs an unrecorded handle and a queue wait longer than the grace, at the same time.

Not fixed — the predicate itself. It is still an inference from the absence of two nullable columns, and the three workflow_helper paths you describe still reach that state after the message is on its transport. The unsoundness is now documented at the predicate rather than left implicit, but documenting is not fixing.

One correction worth recording, because it cuts against us. The concurrency-slot consequence you name was not actually reachable when this review ran: release_slot(cls, organization_id: str, ...) was being passed an Organization instance, so its Redis key formatted to ...:org:Organization object (12) while acquire_slot had built the key from str(organization.organization_id). The ZREM removed a non-member, returned 0, and raised nothing — a mis-claimed live execution kept its slot by accident. That is fixed in 6c658c060, which makes your finding's specific consequence real for the first time.

We still think that is the right trade: the previous behaviour leaked a slot for the full 6h limiter TTL on every genuinely-abandoned execution, which throttles the whole org — a 502 storm could hold a tenant down for hours. One org briefly exceeding its limit in a rare mis-claim is the smaller harm. But it is a genuine cost of the fix and worth having in the thread.

The actual close is to make dispatch a positive fact rather than an inferred absence: stamp a dispatched_at in the same call that records the handle — including on the three branches that currently return without writing one — and key both this predicate and the 0026 partial index on it. That needs a migration and an index rebuild, so it is tracked separately rather than folded into this PR.

For context on the score: this review ran against 9e502f7da, which predates every mitigation above (7f3cc38ab, 6c658c060, 1b7a26f65, 02ecc60a2). Leaving the thread open deliberately — it should stay open until dispatched_at lands.

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.

Agreed. The finding remains open: the irreversible cleanup is removed and the grace period reduces the mis-claim window, but nullable-handle absence is still not a sound proof that dispatch never occurred. The correction on concurrency-slot behavior is also noted—the slot-exceedance consequence became reachable only after release_slot was fixed. Keeping this thread open until dispatched_at makes dispatch a positive fact is appropriate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Root fix landed in 1c4f1c785 (+ a713fb6e4). This is no longer mitigated — the inference is gone.

What changed. dispatched_at is stamped by the dispatcher the instant dispatch returns, at workflow_helper.py:667, before any handle bookkeeping runs. That single write sits upstream of all three paths you identified — the swallowed exception, the empty handle, the unparseable PG handle — so the sweep now tests a recorded fact instead of inferring one from absence. A live execution always carries the stamp and can no longer be claimed.

Rollout is single-phase, deliberately. The predicate keeps both handle checks alongside the new one:

WHERE status = 'PENDING' AND created_at < now() - grace
  AND dispatched_at IS NULL
  AND task_id IS NULL AND queue_message_id IS NULL

During a rolling upgrade an old pod dispatches without stamping; that row matches dispatched_at IS NULL but is excluded by task_id IS NOT NULL. Existing rows need no backfill for the same reason. Dropping the handle checks would have required two releases, or swept live work mid-deploy.

A gap this opened, and closed. Requiring dispatched_at IS NULL means a row that was dispatched but whose handle write failed stops matching this sweep — and it did not match recover_stuck_pg_executions either, which tested the handles alone. It would have matched neither, trading "wrongly terminalised" for "never terminalised". Recovery is now a three-way test (dispatched_at OR either handle), so the two sweeps are disjoint and jointly exhaustive: undispatched takes rows where all three are absent, recovery takes rows where any one is present. Cleaner than before this change.

Verified against a live Postgres, both directions — not asserted:

  • makemigrations --check --dry-run → no changes detected (this is what caught a713fb6e4: the migration and model had divergent db_comment text, which Django read as a pending alteration)
  • 0026 → 0027 → 0028 apply cleanly; dispatched_at is nullable timestamptz
  • old index dropped, we_undispatched_dispatch_idx created, 0 INVALID indexes
  • EXPLAIN on the sweep's exact predicate → Index Scan using we_undispatched_dispatch_idx, so the partial index is matched rather than bypassed
  • migrate back to 0026 unapplies both, restores the old index and drops the column
  • 139 tests pass across workflow_v2 and execution, including three new regressions: a dispatched row with no handles is never swept (the exact shape your three paths produce), an unstamped row with a handle is not swept (the rolling-deploy guarantee), and a genuinely undispatched row still is swept (the fix must not disable the sweep)

Residual, stated plainly. If the stamp itself fails, that one execution falls back to the old ambiguity — three paths narrowed to one, not zero. The log line at that site says so rather than staying silent. There is also one extra small UPDATE per dispatch, and 0028 builds its index CONCURRENTLY on a multi-million-row table (the migration docstring carries the out-of-band build command for production).

Follow-up this unlocks: the grace period was raised 900s → 3600s in 1b7a26f65 purely to narrow this window. With the inference gone it is no longer load-bearing and can return to 15 minutes, terminalising genuine orphans 4× sooner. Left as-is for now — separate, reversible tuning.

Leaving the thread open for you to close.

muhammad-ali-e and others added 5 commits August 25, 2026 12:57
Findings from a standardized 16-lens FOLLOWUP review of the four preceding
commits, run through five specialist agents. Several are defects the previous
round introduced or left behind; each was verified against source before being
accepted.

CI-SURFACED, and the reason they were invisible until now
---------------------------------------------------------
Marking this PR ready for review started the test tier for the first time, and
it immediately failed 12 tests. Eight of them were broken from the day they were
written: test_undispatched_sweep.py's _execution() built a WorkflowExecution with
no workflow, and save() calls _handle_execution_cache() which dereferences
self.workflow.id. They could never have passed. This is the prior review's own
High - "both PRs are drafts, so no test tier is running" - cashing out exactly as
predicted. Fixed by giving the helper a real workflow.

Two more were mine from the previous round: modified_at is NOT NULL, so a test
that forced it to NULL to reach an early return died on an IntegrityError. It now
patches the aggregate instead, which is the only way the helper can legitimately
see a missing timestamp.

A SILENT NO-OP IN THE HALF THE LAST COMMIT CLAIMED TO PRESERVE
--------------------------------------------------------------
release_slot(cls, organization_id: str, ...) was being passed an Organization
INSTANCE, so its Redis key formatted to "...:org:Organization object (12)" while
acquire_slot had built the key from str(organization.organization_id). The ZREM
removed a non-member, returned 0, raised nothing. The previous commit justified
dropping the storage delete by saying it "keeps the reversible half (the
rate-limit slot release, which has a live cost)" - that half did nothing. Now
passes the identifier string, matching the one call site that had it right.

A COMMENT CORRECTION THAT INTRODUCED A FRESH FALSE CLAIM
--------------------------------------------------------
The previous commit rewrote entrypoint.sh to end "a restart that changes nothing
writes nothing", and the NEXT commit corrected converge_pg_scheduler's docstring
to say the exact opposite: the pipeline path rewrites both tables and bumps the
Beat reload marker for every schedule regardless. The commit whose stated purpose
was "correct three comments that misdirect" introduced a new misdirection at the
riskiest site. Corrected, along with two other claims in the same file: ownership
is additionally Flipt-gated and fails closed (so the env var alone moves nothing
when Flipt is blind), and convergence commits per schedule, so a failure leaves a
partial hand-over rather than the untouched state the warning promised.

"SELF-CORRECTS" WAS FALSE ON THE PG TRANSPORT
----------------------------------------------
The C1 fix reasoned that the surviving ERROR write is tolerable because "the
running worker's own terminal write supersedes it". It does not. Both PG worker
entry points treat a terminal execution as a reason to STOP: general/tasks.py
returns skipped_terminal_execution before it would write EXECUTING, and
file_processing/tasks.py raises _TerminalExecutionSkip. A wrongly-claimed row is
therefore silently DROPPED - message acked, no retry, only a worker WARNING. The
realistic trigger is not the three no-handle paths but a saturated queue, which
is the condition this sweep exists for. The comment now says so plainly, and also
records that error_message is not cleared by a later terminal write, so a
wrongly-claimed run can complete still showing the customer EXEC_NOT_STARTED.

This is recorded, not fixed: making the sweep safe under queue saturation is a
behaviour change (a positive dispatched_at, or a predicate that also checks for a
live pg_queue_message) and is escalated rather than attempted here.

OTHER COMMENT AND MESSAGE DEFECTS
----------------------------------
- The zero-match warning added last round fired the same alarming text in both
  directions. On adopt a missing Beat row just means there was nothing to disable
  - PG is the firer - and that path runs on every pipeline save. Warning on the
  benign direction is how the real one, on rollback, gets filtered out. Both sites
  now branch the whole message, not just the verb.
- reaper.py still stated "only needs to outlast a callback - seconds" directly
  above the paragraph retracting it.
- reconcile_pg_schedules' argparse help - the copy an operator actually sees -
  still said --mirror-only is "the mode automation runs".
- Three docstrings still justified the worker/backend boundary with "the sweep
  needs the rate limiter and the API storage connector"; the storage half was
  removed last round.
- The sweep's module docstring asserted the predicate as fact while the body
  declares it unsound. The header is what a reader hits first, and it is what
  would justify reinstating the delete or shortening the grace period.

TYPE-DESIGN HARDENING
----------------------
Three independent agents confirmed the _fields-derived SELECT emits a column list
identical to the previous hardcoded one. Two gaps remained: the docstrings still
described the dependency in the direction that no longer applies (the field names
ARE the query now, so a reorder is safe and a RENAME is the hazard), and nothing
pinned the tuples in a lane without Postgres - every test that executes those
queries skips without a live database. Added DB-free assertions pinning both
_fields tuples, and quoted the derived identifiers so the derivation is total.

Verified: workers suites 39 passed; rig unit-runner 5 passed; entrypoint.sh
passes bash -n. The backend suite still cannot start in this checkout, so the
Django-side fixes above are unverified locally and rely on CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s what it terminalises

The sweep infers "never dispatched" from both handles being NULL. Three paths in
workflow_helper reach that state AFTER the message is on its transport, so the
predicate can claim a row whose message is still queued.

On Celery that was survivable: the orchestrator ran on the row regardless and its
own terminal write superseded the sweep's ERROR. On PG it is not. Both worker
entry points STOP on a terminal execution - general/tasks.py returns
skipped_terminal_execution, file_processing/tasks.py raises
_TerminalExecutionSkip - so the message is acked and the work is DROPPED, with a
worker WARNING as the only trace. The guard is explicitly PG-only; its own
comment says "Celery has no redelivery ... the check is a no-op there". So this
is a risk the transport migration introduces, in the phase we are entering.

It needs two things at once: an unrecorded handle AND a queue wait longer than
the grace. Raising 900 -> 3600 puts the grace clear of any realistic dequeue
latency, which collapses the overlap without new machinery or a migration.

Not the fix. The fix is to make dispatch a POSITIVE fact - stamp dispatched_at in
the same call that records the handle, including on the three branches that
currently return without writing one, and key the predicate and the 0026 partial
index on it. That needs a migration and is tracked separately.

Blast radius of the raise: a genuinely undispatched execution now shows PENDING
for up to an hour before it errors, rather than 15 minutes. For ETL that is the
whole cost - the sweep never writes file rows and file history is only recorded
after a file is actually processed, so nothing is marked done and the next
scheduled run re-discovers the file. For API deployments there is no next run,
so the caller re-submits, which is what the error message already tells them.
Still well under the barrier stuck-timeout, so the two sweeps stay disjoint.
Overridable per environment via UNDISPATCHED_EXECUTION_GRACE_SECONDS.

Tests pin the value and its upper bound, with the reason, so lowering it back
toward the queue's p99 wait has to be a deliberate act with a latency figure in
hand rather than a tidy-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… was un-ageing the row

The last integration failure on this branch, and the cause is a manager
convention rather than anything in the code under test.

BaseModelManager.update() does `kwargs.setdefault("modified_at", timezone.now())`
(backend/utils/models/base_model.py:39-41). So

    WorkflowExecution.objects.filter(pk=ex.pk).update(created_at=created)

silently re-stamped modified_at to NOW, undoing the ageing _age() had just
applied. The execution then failed `modified_at__lt=cutoff` at selection, the
endpoint reported scanned=0, and the test failed on STATUS — with nothing in the
failure output hinting that a timestamp had moved. I could not reproduce it
locally (this checkout cannot start the backend suite) and it took a CI round
trip plus reading the manager to find.

Fixed by passing modified_at explicitly, which the manager's own docstring
documents as the supported override.

Checked the sibling suite for the same trap: test_undispatched_sweep.py has two
updates without modified_at, but that sweep selects on created_at, so re-stamping
modified_at cannot affect it. Left alone rather than changed defensively.

This was the single remaining integration-backend failure. The previous CI run
went from 12 failures to 2 after the earlier remediation commits; this closes the
one that was mine. (The other is an e2e API-deployment timeout — see the PR
thread; not attributed either way without evidence.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uessing

Closes the root cause behind three independently-reported findings: the
standardized review's C1 (Critical), its follow-up N5, and Greptile's P1 on
PR #2254. All three are the same defect seen from different angles.

The defect
----------
The undispatched sweep decides "never dispatched" from `task_id IS NULL AND
queue_message_id IS NULL`. That is an inference, and it is unsound. Three paths
in workflow_helper reach exactly that state AFTER the message is on its
transport:

  * _record_dispatch_handle raising and the caller swallowing it, under the
    comment "continuing - the orchestrator is already running" (:687)
  * the handle coming back empty and the recorder returning without writing (:551)
  * a PG handle that will not parse as a bigint, same early return (:564)

So the sweep could claim a RUNNING execution and mark it ERROR. On Celery that
self-corrected: the orchestrator ran regardless and its terminal write superseded
the ERROR. On PG it does NOT - general/tasks.py returns
skipped_terminal_execution and file_processing/tasks.py raises
_TerminalExecutionSkip, so the message is acked and the work is silently DROPPED.

Prior rounds removed the irreversible half (the staged-input delete) and widened
the grace 900s -> 3600s. Both were mitigations. This removes the inference.

The fix
-------
`dispatched_at` is stamped by the dispatcher the instant dispatch returns, BEFORE
any handle bookkeeping can fail - one write upstream of all three paths. The
sweep then asks a question with a true answer.

Single-phase rollout, deliberately. The predicate KEEPS both handle checks
alongside the new one:

    WHERE status = 'PENDING' AND created_at < now() - grace
      AND dispatched_at IS NULL
      AND task_id IS NULL AND queue_message_id IS NULL

During a rolling upgrade an old pod dispatches without stamping; that row matches
`dispatched_at IS NULL` but is excluded by `task_id IS NOT NULL`. Drop the handle
checks and this would need two releases, or it would sweep live work mid-deploy.
Existing rows need no backfill for the same reason.

A gap this opened, and closed
------------------------------
Making the sweep require `dispatched_at IS NULL` means a row that WAS dispatched
but whose handle write failed stops matching it. It also did not match
recover_stuck_pg_executions, which tested the handles alone - so it would have
matched NEITHER sweep and could sit non-terminal forever, trading "wrongly
terminalised" for "never terminalised". The recovery predicate is now three-way
(dispatched_at OR either handle), which keeps the two sweeps disjoint AND jointly
exhaustive: undispatched takes rows where all three are absent, recovery takes
rows where any one is present. That is a cleaner split than before this change.

Migrations
----------
0027 adds the column - nullable, additive, transactional, no backfill.
0028 re-keys the partial index, mirroring 0026's pattern (atomic = False,
CONCURRENTLY, IF NOT EXISTS, the INVALID-index guard, and the out-of-band build
instructions). It creates the replacement BEFORE dropping the old one so the
predicate stays served throughout; an interruption between the two leaves both,
which costs a little write overhead and nothing else.

Cost and residual risk, stated plainly
---------------------------------------
One extra small UPDATE per dispatch. The residual hole is the stamp itself
failing, which leaves that one execution in exactly the pre-existing ambiguity -
three paths narrowed to one, not zero. The log line at that site says so.

With the inference gone, the 3600s grace is no longer load-bearing and could be
returned to 15 minutes, terminalising genuine orphans 4x sooner. Left as-is here:
that is a separate, reversible tuning decision and this change is already the
larger one.

Tests: a dispatched row with NO handles is never swept (the exact shape the three
paths produce); an unstamped row WITH a handle is not swept (the rolling-deploy
guarantee); a genuinely undispatched row still IS swept (the fix must not disable
the sweep); and the model/migration index-parity test now pins all three signals.

NOT VERIFIED LOCALLY - the backend suite cannot start in this checkout. Compile,
ruff and scoped pre-commit are clean, and lint is unchanged against HEAD. CI is
what will verify the migrations and the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The migration and the model declared different db_comment text for
dispatched_at, so Django saw a pending field alteration and
`makemigrations --check` proposed a 0029. Caught by running the check against a
live Postgres rather than by reading.

Verified end to end against the running stack (DB_SCHEMA=public on a scratch
database, since a fresh test DB has no `unstract` schema for search_path):

  * makemigrations --check --dry-run -> "No changes detected in app 'workflow_v2'"
  * migrate 0026 -> 0027 -> 0028 applies cleanly
  * dispatched_at: nullable, timestamptz
  * we_undispatched_idx dropped, we_undispatched_dispatch_idx created, and zero
    INVALID indexes (the CONCURRENTLY interruption case 0026's guard exists for)
  * EXPLAIN on the sweep's exact predicate -> "Index Scan using
    we_undispatched_dispatch_idx", so the partial index is matched, not bypassed
  * migrate back to 0026 unapplies both, restores we_undispatched_idx and drops
    the column - rollback is real, not assumed

Tests: 139 passed across workflow_v2 and execution, including the three new
positive-fact regressions and the recovery suite. One unrelated failure
(test_workflow_author) reproduces identically with these changes stashed, so it
is pre-existing and not attributed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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-api-deployment e2e 3 0 0 0 21.0
e2e-coowners e2e 1 0 0 0 1.6
e2e-etl e2e 1 0 0 0 8.4
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 4.5
e2e-smoke e2e 2 0 0 0 1.5
e2e-workflow e2e 1 0 0 0 16.4
integration-backend integration 310 0 0 26 42.2
integration-connectors integration 1 0 0 7 7.6
integration-workers integration 157 0 0 1 49.3
unit-backend unit 1140 0 0 1 42.5
unit-connectors unit 63 0 0 0 10.2
unit-core unit 33 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.8
unit-rig unit 117 0 0 0 5.5
unit-runner unit 5 0 0 0 3.1
unit-sdk1 unit 563 0 0 0 30.2
unit-workers unit 1397 0 0 1 128.5
TOTAL 3812 0 0 36 377.8

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (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
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • mcp-server-auth — covered by integration-backend
  • mcp-platform-auth — covered by integration-backend
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

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.

1 participant