Skip to content

feat: replace Redis queue with Postgres-native atomic job ownership - #582

Open
acardace wants to merge 16 commits into
llm-d:mainfrom
acardace:feat/postgres-job-queue
Open

feat: replace Redis queue with Postgres-native atomic job ownership#582
acardace wants to merge 16 commits into
llm-d:mainfrom
acardace:feat/postgres-job-queue

Conversation

@acardace

Copy link
Copy Markdown
Contributor

Summary

Replaces the Redis-based priority queue and in-flight tracking with a Postgres-native queue using SELECT ... FOR UPDATE SKIP LOCKED. Job dequeue and ownership become a single atomic transaction — no gap, no distributed bookkeeping.

Design: #572

Changes

Postgres-native queue

  • Add processor_id, priority, and epoch columns to batch_items
  • PQDequeue: atomic CTE that claims jobs with FOR UPDATE SKIP LOCKED and sets processor_id
  • PQEnqueue: re-enqueue with guards against resurrecting terminal or already-queued jobs
  • PQDelete: atomic cancel-from-queue with cancelled_at timestamp
  • Partial indexes for queue (priority ASC WHERE processor_id IS NULL) and recovery (processor_id WHERE NOT NULL)

Processor: Deployment → StatefulSet

  • Stable pod identity (processor-0, processor-1, ...) via os.Hostname()
  • Headless service for StatefulSet DNS

Job recovery

  • recoverOwnedJobs: on startup, processor queries DB for non-terminal jobs by processor_id and recovers them — no GC dependency for same-pod restarts
  • Removed recoverStaleJobs (filesystem scan) — superseded by DB-based recovery
  • Recovery is phase-aware: finalizing → complete uploads, cancelling → cancelled, in_progress → re-enqueue or fail based on SLO

GC: event-driven orphan recovery

  • K8s pod watcher (informer) detects processor pod changes
  • Reconciler re-enqueues orphans with valid SLO, fails expired ones, transitions cancelling orphans to cancelled
  • HasProcessorID filter — reconciler only fetches owned jobs, not the entire queue

Epoch-based fencing

  • Fencing token incremented on every ownership change (dequeue, re-enqueue, GC reclaim)
  • Processor writes include WHERE epoch = N — a zombie whose job was reclaimed cannot overwrite the new owner's state
  • GC terminal transitions bump epoch via BumpEpoch flag

Cancel handler

  • PQDelete sets cancelled + cancelled_at atomically; handler re-reads and returns current state
  • In-progress cancel uses CAS (expectedStatus) to prevent overwriting terminal status

Complexity reduction

  • Removed: Redis BatchDBClient, FileDBClient, InFlightClient, heartbeat goroutine
  • Kept: Redis for real-time progress counts, cancel events, exchange
  • Net change: -567 lines

Testing

  • Unit tests for queue, pod watcher, reconciler, recovery, epoch fencing, CAS cancel
  • E2e tests updated for StatefulSet self-recovery semantics
  • All unit and e2e tests passing

@acardace

Copy link
Copy Markdown
Contributor Author

@hexfusion @evacchi @wseaton

@acardace
acardace force-pushed the feat/postgres-job-queue branch 2 times, most recently from f5ffbc3 to 8fb15ea Compare July 14, 2026 15:30
Comment thread BUGS
@@ -0,0 +1,3 @@
TODO:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this to be committed, or just a planning doc?

@j-mok-dev

j-mok-dev commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

I agree that this PR is trying to solve a real correctness problem, but I have a concern about the architectural direction it takes.

This design collapses queue ownership directly into the Postgres batch store (processor_id, priority, epoch on batch_items). Once we do that, the queue is no longer an independently pluggable component. Queue semantics become part of the SQL row model.

I do not think this is the direction we want. One of the goals of this project is to keep storage roles independently replaceable. We have already been moving in the direction of making Redis optional by providing alternative backend implementations, not by collapsing multiple roles into a single storage model. If we merge this design, we effectively give up queue-level pluggability and make SQL-backed ownership semantics the only realistic model.

The better direction, in my view, is to separate delivery from ownership:

  • keep the queue as a pluggable delivery mechanism
  • introduce a separate claim / lease / fencing abstraction for ownership correctness
  • implement that coordination layer in Postgres first if needed
  • require processors to claim work after dequeue and only execute on successful claim
  • carry the fencing token through later writes, recovery, and finalization

That would preserve the correctness properties this PR is aiming for without destroying the queue abstraction.

@wseaton

wseaton commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

I do not think this is the direction we want. One of the goals of this project is to keep storage roles independently replaceable. We have already been moving in the direction of making Redis optional by providing alternative backend implementations, not by collapsing multiple roles into a single storage model. If we merge this design, we effectively give up queue-level pluggability and make SQL-backed ownership semantics the only realistic model.

As a follow-up, maybe it's better to focus instead on decoupling job level ownership (low TPS) and transport (higher TPS) into two distinct interfaces, if we break up BatchPriorityQueueClient we can keep two seperate queue implementations but fix the original correctness issues that motivated this PR.

Ownership can stay in an RDBMS (we already require one) and then transport can stay pluggable. I think I agree with all the other points made

@j-mok-dev

Copy link
Copy Markdown
Collaborator

I do not think this is the direction we want. One of the goals of this project is to keep storage roles independently replaceable. We have already been moving in the direction of making Redis optional by providing alternative backend implementations, not by collapsing multiple roles into a single storage model. If we merge this design, we effectively give up queue-level pluggability and make SQL-backed ownership semantics the only realistic model.

As a follow-up, maybe it's better to focus instead on decoupling job level ownership (low TPS) and transport (higher TPS) into two distinct interfaces, if we break up BatchPriorityQueueClient we can keep two seperate queue implementations but fix the original correctness issues that motivated this PR.

Ownership can stay in an RDBMS (we already require one) and then transport can stay pluggable. I think I agree with all the other points made

Yes, I agree. This makes the direction much clearer.
My concern was not “ownership in Postgres,” but “ownership and transport collapsing into the same abstraction.” If we separate those concerns, we can preserve the correctness guarantees without giving up transport-level pluggability.
I think this is the right direction to move in. 👍

@lioraron

Copy link
Copy Markdown
Collaborator

I agree with @j-mok-dev and @wseaton - added a comment in the design issue #572 (comment)

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

This PR is marked as stale after 21d of inactivity. After an additional 14d of inactivity (7d to become rotten, then 7d more), it will be closed. To prevent this PR from being closed, add a comment or remove the lifecycle/stale label.

@acardace

acardace commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Comment to remove stale label.

The ExtraColumns scan destinations and return maps used
map[string]string, forcing all extra columns to be strings.
Change to map[string]any so that numeric types (e.g. BIGINT)
are scanned as their native Go types without string conversion.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
Add processor_id (TEXT) and priority (BIGINT) columns to the
batch_items schema. processor_id records which processor pod owns
an in-progress job. priority stores SLO.UnixMicro() for queue
ordering (lower = earlier deadline = higher priority).

Add partial indexes for efficient dequeue (unclaimed validating
jobs ordered by priority) and processor ownership lookup (crash
recovery by processor_id).

Add corresponding fields to BatchItem and wire them through the
Postgres batch DB client as extra columns.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
…table

Implement the priority queue interface using the batch_items table as
a native Postgres queue. Jobs with status 'validating' and
processor_id IS NULL are the queue entries.

PQDequeue uses a CTE with SELECT FOR UPDATE SKIP LOCKED + UPDATE in
a single atomic statement to claim jobs — no transaction needed.
PQEnqueue sends a NOTIFY to wake listening processors.
PQDelete atomically transitions unclaimed jobs to cancelled using
FOR UPDATE SKIP LOCKED to prevent races with concurrent dequeue.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
When db_client.type is postgresql, create a PostgresBatchQueueClient
that replaces the Redis priority queue. The processor's hostname
(os.Hostname) is used as the processor ID for job claiming. For
non-processor components (apiserver, GC), the processor ID is empty
since they only enqueue/delete, never dequeue.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
Add recoverOwnedJobs which queries the DB for non-terminal jobs owned
by this processor (via processor_id column) and recovers them at
startup. This handles both container-level crashes and pod-level
restarts within a StatefulSet.

Replace the filesystem-based recoverStaleJobs with recoverOwnedJobs
in the processor Run() flow. The DB is now the source of truth for
crash recovery, not the local workdir.

Add ProcessorID filter to BatchQuery so DBGet can filter by
processor_id.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
Add a pod watcher that uses client-go informers to watch processor
pods via label selectors. On pod add/delete events, it computes
the set of live processor pod names and calls a handler function.

This enables the reconciler to detect processor crashes immediately
via Kubernetes events instead of periodic polling with staleness
thresholds.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
BatchToDBItem now derives Priority (SLO as UnixMicro) from the
batch's CompletionWindow and CreatedAt fields. This ensures the
priority column is set when the apiserver stores new batch jobs,
enabling the Postgres queue to order jobs by SLO deadline.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
Replace the processor Deployment with a StatefulSet to provide stable
pod identities (processor-0, processor-1, ...) required for DB-based
crash recovery. Each processor uses its hostname (stable across
restarts) as the processor_id when claiming jobs.

Add a headless Service as required by the StatefulSet API. Use
podManagementPolicy: Parallel so replicas scale up/down concurrently
instead of sequentially.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
Replace all usages of the TagSLO tag (slo_unix_micro) with the
Priority field on BatchItem. The converter now derives Priority
from CompletionWindow and CreatedAt. The reconciler and recovery
code read Priority directly instead of parsing from tags.

Remove the TagSLO constant and the SLO tag from job creation.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
The processor_id column in batch_items and the Kubernetes pod watcher
replace the Redis in-flight hash and periodic heartbeat for job
ownership tracking.

Remove:
- InFlightClient interface and InFlightEntry struct
- Redis InFlight implementation (redis_inflight.go)
- Mock InFlight client (mock_inflight_client.go)
- InFlight field from Clientset and Processor
- heartbeat goroutine and deleteInFlight function
- HeartbeatInterval config field and its YAML/helm references
- All InFlight-related tests

Signed-off-by: Antonio Cardace <acardace@redhat.com>
Postgres is now the only supported DB backend. Remove the Redis
CRUD implementation for batch and file items, update default DB
type from redis to postgresql, and remove tests for Redis/Valkey
DB configurations.

The Redis exchange client (events, status, priority queue) is
kept for backward compatibility.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
startProcessorObsPortForward reads scanDone when kubectl exits with an
error, then the defer cleanup reads it again, blocking forever on the
empty buffered channel.

Fix by setting scanDone = nil after draining it so the defer skips the
second read. Also wait for the processor pod to be Ready (kubectl wait)
before attempting the port-forward, avoiding the pod-not-running race
during pod transitions.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
The RollingRestartOrphan test sets inter-token-latency to 30s to keep
requests in-flight during SIGTERM. After the restart, the processor
self-recovers and re-processes the job — but the sim was still slow,
causing the test to take unnecessarily long.

Restore sim latency to 100ms after the rollout completes so
re-processed requests complete quickly.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
PQDelete now atomically transitions queued jobs to cancelled (with
cancelled_at) in a single SQL statement. When PQDelete succeeds
(nDeleted > 0), the handler re-reads the updated batch and returns
it directly — no separate DBUpdate needed.

For in-progress jobs (nDeleted == 0), the handler uses CAS on DBUpdate
to set cancelling status, preventing a race where the processor could
complete the job between the handler's read and write.

Signed-off-by: Antonio Cardace <acardace@redhat.com>
…ueueing

The reconciler re-enqueued all orphans with a valid SLO, including
cancelling jobs. This lost the user's cancel intent.

Add status-aware triage: cancelling orphans transition to cancelled.
Refactor expireOrphan into transitionOrphan to avoid duplication.

Update e2e OrphanRecovery tests for the new architecture:
- HardCrashInProgress: expects re-enqueue then completion
- CancellingOrphan: expects cancelled

Signed-off-by: Antonio Cardace <acardace@redhat.com>
Add an epoch column to batch_items that acts as a fencing token,
incremented on every ownership change. This prevents a zombie processor
(stuck in Terminating, unfrozen after a network partition) from
overwriting state written by the new owner.

Schema:
- Add epoch BIGINT NOT NULL DEFAULT 0 to batch_items

Epoch is incremented on:
- PQDequeue: new processor claims a job
- PQEnqueue: GC re-enqueues an orphan back to the queue
- PQDelete: cancel from queue
- transitionOrphan: GC transitions an orphan to a terminal status
  (BumpEpoch flag on BatchItem triggers epoch = epoch + 1 in SET)

Epoch is checked (not incremented) on:
- All processor writes via StatusUpdater -> DBUpdate: WHERE epoch = N
  ensures a zombie whose job was reclaimed cannot overwrite the new
  owner's state. Sequential writes by the same processor reuse the
  same epoch (like a Raft term).

Implementation:
- BatchItem.Epoch carries the fencing token through the write path
- BatchItem.BumpEpoch signals DBUpdate to atomically increment epoch
- pgCore.update() accepts extraConditions (WHERE) and rawSetClauses
- MockDBClient.DBUpdate checks both expectedStatus (CAS) and Epoch
- TestEpochFencing covers matching, stale, and GC-bump scenarios

Signed-off-by: Antonio Cardace <acardace@redhat.com>
@acardace
acardace force-pushed the feat/postgres-job-queue branch from 8fb15ea to 7758208 Compare August 10, 2026 14:19
@acardace
acardace requested a review from zdtsw as a code owner August 10, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants