Skip to content

POC: KEDA ScaledJob for queue jobs (payload stays in Redis)#41

Open
abnegate wants to merge 10 commits into
mainfrom
feat/queue-keda-poc
Open

POC: KEDA ScaledJob for queue jobs (payload stays in Redis)#41
abnegate wants to merge 10 commits into
mainfrom
feat/queue-keda-poc

Conversation

@abnegate

@abnegate abnegate commented Jul 1, 2026

Copy link
Copy Markdown
Member

Alternative POC to the env-var KubernetesJob broker (#35), built to compare the two ways of running queue jobs as Kubernetes Jobs.

Approach

The K8s-native pattern: the payload stays in the Redis queue, and KEDA scales worker Jobs off the queue depth.

  • Producers enqueue with the ordinary Utopia\Queue\Broker\Redis broker — no custom broker, no Kubernetes involvement at enqueue time.
  • A KEDA ScaledJob watches the queue's Redis list length and spawns one-shot worker Jobs (up to maxReplicaCount).
  • Each worker (tests/Queue/servers/Keda/worker.php) drains the queue with the same Redis broker (receive → handle → commit) and exits.

Notably this needs zero library code — just the existing Redis broker + a drain worker + the ScaledJob manifest.

Proven end-to-end

tests/keda-e2e.sh stands up kind + KEDA (helm) + Redis + the ScaledJob, loads the worker image, and runs KedaTest, which enqueues messages and asserts KEDA spawns Jobs that drain the queue. Verified on kind (OK (1 test, 4 assertions); observed 8 queue-worker-* Jobs draining the queue). bin/monorepo check queue passes (pint + phpstan + rector).

env-var KubernetesJob (#35) vs KEDA ScaledJob (this PR)

env-var KubernetesJob KEDA ScaledJob
Payload inlined in Job env var — etcd (~1.5 MB)/ARG_MAX limits, visible in pod spec, duplicated per Pod stays in Redis; never on a K8s object
Custom broker yes none — reuses the Redis broker
Producer needs cluster access yes (creates Jobs) no (just Redis)
Scaling one Job per message KEDA scales N Jobs off depth; workers batch-drain
Extra dependency appwrite-labs/php-k8s in the app KEDA operator in the cluster
Secrets/PII in payload exposed via pod spec fine (stays in Redis)

Recommendation: KEDA is the more correct/standard path (keeps the queue a queue, no payload on etcd, no custom broker); its cost is requiring the KEDA operator in the cluster. See tests/Queue/servers/Keda/README.md.

🤖 Generated with Claude Code

Alternative to the env-var KubernetesJob broker: instead of inlining the payload
in a per-message Job, producers enqueue to the ordinary Redis broker and a KEDA
ScaledJob scales one-shot worker Jobs off the queue depth. Each worker drains
the queue with the same Redis broker and exits — no custom broker, no payload on
any Kubernetes object.

Adds a drain worker, Dockerfile, redis + ScaledJob manifests, a kubectl-driven
e2e (KedaTest) proving KEDA spawns Jobs that drain the queue, and keda-e2e.sh
(kind + KEDA + redis). Verified end-to-end on kind. See servers/Keda/README.md
for the comparison.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 1, 2026 04:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a proof-of-concept for running utopia-php/queue jobs as Kubernetes Jobs via KEDA ScaledJob, keeping job payloads in the existing Redis queue (no Kubernetes involvement at enqueue time, no payload stored on K8s objects).

Changes:

  • Introduces a KEDA worker image + one-shot drain worker script and a ScaledJob manifest for scaling off Redis list depth.
  • Adds an end-to-end PHPUnit test (KedaTest) plus a kind + KEDA + Redis harness script to validate the flow.
  • Documents the approach and trade-offs vs the env-var KubernetesJob approach.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/queue/tests/Queue/servers/Keda/worker.php One-shot worker that drains Redis queue messages using the existing Redis broker.
packages/queue/tests/Queue/servers/Keda/README.md Documentation for running the KEDA ScaledJob POC and trade-offs.
packages/queue/tests/Queue/servers/Keda/k8s.yaml Kind-friendly namespace + Redis + KEDA ScaledJob manifest for scaling jobs from Redis depth.
packages/queue/tests/Queue/servers/Keda/Dockerfile Builds the worker container image (PHP + redis extension + project code).
packages/queue/tests/Queue/E2E/Adapter/KedaTest.php E2E test that enqueues directly into Redis and asserts KEDA drains the queue via Jobs.
packages/queue/tests/keda-e2e.sh Harness that provisions kind + KEDA, loads the worker image, applies manifests, and runs the test.
packages/queue/phpunit.xml Registers KedaTest in the e2e testsuite (skips unless KEDA_E2E=true).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/queue/tests/Queue/servers/Keda/worker.php Outdated
Comment thread packages/queue/tests/Queue/servers/Keda/k8s.yaml Outdated
Comment thread packages/queue/tests/keda-e2e.sh Outdated
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This POC introduces a K8s-native KEDA ScaledJob approach for queue consumption: producers use the existing Redis broker unchanged, KEDA watches queue depth and spawns one-shot worker Jobs, and each worker drains the queue with the new KubernetesJob adapter then exits. No payload ever touches a Kubernetes object.

  • KubernetesJob adapter is a new run-to-completion adapter that replaces the infinite poll loop with a drain-then-return loop; runsToCompletion() overrides to true so Server re-throws infrastructure errors rather than swallowing them.
  • Server.php adds a single guard that re-throws on infrastructure failures when the adapter runs to completion, correctly scoped to the outer catch since message-level errors are absorbed by Adapter::process() first.
  • keda-lib.sh provisions kind + KEDA + Redis with all tools downloaded at pinned versions and SHA256-verified before execution.

Confidence Score: 5/5

Safe to merge as a POC; the two findings are minor asymmetries in the adapter's signal-handling lifecycle that don't affect message correctness or drain-loop behavior.

The core drain loop, runsToCompletion() re-throw path, and KEDA harness are all sound. The two issues — a redundant consumer->close() in stop() that duplicates the close already done by Server's workerStop callback, and pcntl signal handlers not being cleared after drain — are lifecycle asymmetries that can produce noisy close errors or stale handlers during process teardown, but neither causes message loss or incorrect job outcomes in the normal flow.

packages/queue/src/Queue/Adapter/KubernetesJob.php — the stop() double-close and missing pcntl signal cleanup

Important Files Changed

Filename Overview
packages/queue/src/Queue/Adapter/KubernetesJob.php New run-to-completion adapter; stop() closes the consumer early conflicting with the Server's workerStop callback, and pcntl signal handlers are not cleared after drain exits
packages/queue/src/Queue/Server.php Adds runsToCompletion() re-throw in the outer catch; correctly scoped to infrastructure errors only
packages/queue/src/Queue/Broker/Redis.php Wraps claim operations in try/catch with rollback; recovery uses the same failed connection and the inner swallow can silently lose a message
packages/queue/tests/Queue/E2E/Adapter/KubernetesJobAdapterTest.php Unit tests covering drain/return, empty-queue, and failure-then-continue cases
packages/queue/tests/Queue/E2E/Adapter/KedaTest.php E2E test asserting KEDA-spawned workers drain both the main and failed queues; properly guarded by KEDA_E2E env var
packages/queue/tests/keda-lib.sh kind, kubectl, and helm all pinned to specific versions with SHA256 verification before execution
packages/queue/tests/Queue/servers/Keda/k8s.yaml KEDA ScaledJob manifest; backoffLimit:0 rationale, crash-recovery note, and FQDN requirement all documented inline

Reviews (9): Last reviewed commit: "fix(queue): run the whole KubernetesJob ..." | Re-trigger Greptile

Comment thread packages/queue/tests/Queue/E2E/Adapter/KedaTest.php
Comment thread packages/queue/tests/Queue/servers/Keda/k8s.yaml
Comment thread packages/queue/tests/Queue/E2E/Adapter/KedaTest.php
Factor the kind + KEDA + Redis + ScaledJob provisioning into tests/keda-lib.sh
(keda_up/keda_down) and have both keda-e2e.sh and the package's e2e.sh use it,
so `bin/monorepo test queue` (and thus CI) stands up KEDA and runs KedaTest
against a real cluster instead of skipping it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/queue/tests/keda-lib.sh
- keda-lib.sh: pin + SHA-256-verify Helm (same as kind/kubectl) instead of
  curl|bash of an unpinned installer; only tear down a kind cluster this run
  created, never a pre-existing one.
- KedaTest: also assert the .failed.* list is empty — draining the main queue
  alone doesn't prove success since receive() pops before handling; note that
  enqueue() mirrors Redis::enqueue()'s envelope.
- k8s.yaml: correct the scaling comment (KEDA scales N Jobs, workers batch-drain)
  and document the backoffLimit/processing-orphan crash-recovery caveat.
- README: crash-recovery section (processing-list reaper via Publisher::retry).

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

abnegate commented Jul 1, 2026

Copy link
Copy Markdown
Member Author

Addressed in 4781c8f:

greptile-apps

  • P1 — assertion passes even if all messages are rejected (KedaTest): receive() pops from the main list before handling, so a drained main queue alone isn't proof. Now also asserts LLEN utopia-queue.failed.keda == 0 (OK (1 test, 5 assertions) on kind).
  • P1/security — Helm via unpinned curl | bash (keda-lib.sh): Helm is now downloaded at a pinned version (v3.16.4) and SHA-256-verified before use, matching the kind/kubectl pattern.
  • P2 — backoffLimit: 0 can orphan messages (k8s.yaml): documented — a pod killed after receive() claims a message but before commit/reject strands it in the processing list, invisible to KEDA; backoffLimit can't recover it (a new pod only drains the main queue). Production needs a periodic Publisher::retry() reaper. Noted in k8s.yaml and servers/Keda/README.md.
  • P2 — enqueue hard-codes the wire format (KedaTest): added a comment noting it mirrors Redis::enqueue()'s envelope (the in-cluster Redis isn't exposed to the host, so we push via kubectl).

Copilot

  • k8s.yaml comment — corrected to reflect that KEDA scales N Jobs off queue depth and workers batch-drain (not one Job per message).
  • keda-e2e.sh cleanup deleting a pre-existing clusterkeda-lib.sh now tracks whether it created the cluster and only deletes it in that case.
  • worker.php "string interpolation is a parse error" — false positive: "{$message->getPid()}" is valid PHP (curly-brace syntax supports method calls). php -l is clean and the worker runs in the e2e (it drains the queue). Left as-is.

The KEDA approach still needs one bit of library code: a consumer that drains
the queue and exits (so a Job completes) instead of blocking like the Swoole/
Workerman adapters. Extract that out of the test worker into
Utopia\Queue\Adapter\KubernetesJob, cover it with a bare-host unit test
(KubernetesJobAdapterTest), and have the KEDA worker run it via Server. Producers
are unchanged — they still enqueue with any Publisher (e.g. the Redis broker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/queue/tests/e2e.sh
abnegate and others added 2 commits July 1, 2026 22:42
keda_up ran unconditionally under set -e, so a provisioning failure (no
Docker, kind download failure) aborted the whole e2e suite before phpunit,
skipping the Swoole/Workerman/pool tests. Gate it: required in CI (hard fail
so KEDA regressions surface), best-effort locally where KedaTest self-skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d termination and boot failures

Register pcntl SIGTERM/SIGINT handlers in the KubernetesJob consume loop so
graceful pod termination drains the in-flight message instead of being SIGKILLed
(PHP is PID 1 in the Job container, so an unhandled SIGTERM is ignored). Run the
workerStart callbacks under try/finally so workerStop (Timer cleanup) always runs.
Add runsToCompletion() to Adapter, overridden true in KubernetesJob, and rethrow
boot/consume-loop failures from Server::start() for run-to-completion adapters so
the Job fails (honouring backoffLimit) rather than exiting 0. Guard the Redis
broker claim writes after the pop and requeue the payload on failure so a mid-claim
error no longer strands the message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@abnegate abnegate force-pushed the feat/queue-keda-poc branch from d399b5c to df8e154 Compare July 14, 2026 06:40
abnegate and others added 2 commits July 14, 2026 19:25
php:8.4-cli-alpine ships without pcntl, so the new SIGTERM handling in
Adapter\KubernetesJob threw at startup and every KEDA Job exited
non-zero (the run-to-completion rethrow working as intended) before
draining, failing KedaTest. Suggest ext-pcntl at the package level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pcntl conflicts with Swoole's signalfd, so when Swoole is loaded the
drain runs inside a coroutine scheduler with a sibling coroutine on
Coroutine\System::waitSignal. pcntl remains only as the non-Swoole
fallback. Throwables are captured inside the scheduler and rethrown
after run() since exceptions cannot cross coroutine boundaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/queue/src/Queue/Broker/Redis.php Outdated
abnegate and others added 2 commits July 14, 2026 20:18
Timers and signal watchers registered by workerStart hooks (e.g. a
telemetry Timer::tick) must be created and cleared inside the same
coroutine scheduler, or Coroutine\run never returns and the Job never
completes. Coroutine::cancel on a waitSignal coroutine also leaves an
orphaned signalfd watcher that swallows SIGTERM with no callback, so
signals now use Process::signal registered and removed around the
drain. Proven against the real cloud worker: empty-queue exit 0 in 2s,
SIGTERM mid-drain graceful exit 0, message consumed off the queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A mid-claim Redis failure now propagates as before; the run-to-completion
adapter surfaces it as a failed Job rather than silently requeueing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants