feat(queue): Synchronous/Asynchronous publishers + Swoole Background decorator#44
feat(queue): Synchronous/Asynchronous publishers + Swoole Background decorator#44loks0n wants to merge 12 commits into
Conversation
Adds Broker/Async, a Publisher decorator that can hand a publish off to a background Swoole coroutine so the caller isn't blocked on the broker round-trip: - publish() runs synchronously and returns the broker's result - enqueue() schedules the publish on a coroutine and returns immediately; with no coroutine runtime active it falls back to publishing synchronously Stray throws in the fire-and-forget coroutine are logged (there's no caller to surface them to), mirroring Adapter/Swoole. retry()/getQueueSize() delegate straight through. Covered by a unit test using an in-memory fake publisher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Greptile SummaryThis PR splits the old
Confidence Score: 3/5Background.php has three open defects from previous review rounds still unaddressed: a shutdown race that silently drops messages, a WaitGroup deadlock when coroutine creation fails, and fire-and-forget dispatch discarding false returns from publish. All three previously flagged defects remain in the current code: $this->started is set to false only after waitGroup->wait() (race window), waitGroup->add() is called before Coroutine::create() whose failure is unchecked (potential indefinite hang in shutdown()), and publish() returning false inside the reader closure is silently ignored. Multiple co-located defects in the core new class drive the score down. packages/queue/src/Queue/Broker/Background.php — shutdown(), start(), and the reader closure all need attention for the three previously flagged issues. Important Files Changed
Reviews (7): Last reviewed commit: "refactor(queue): rename BackpressureExce..." | Re-trigger Greptile |
Replace the per-enqueue Coroutine::create() with a producer/consumer built on a bounded Swoole channel. enqueue() pushes the publish onto the channel and a single reader coroutine loops over it, delegating each dispatch to the wrapped publisher. The channel capacity is the back-pressure bound: once full, enqueue() blocks the producing coroutine until the reader drains a slot, so a slow broker throttles producers instead of spawning unbounded coroutines. start() spawns the reader; shutdown() pushes a sentinel and waits for the reader to drain what's queued. enqueue() still falls back to a synchronous publish when no reader loop is running, and publish() remains a direct synchronous delegate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Replace the single Publisher interface with two contracts under the Publisher namespace: - Publisher\Synchronous — publish() (blocks, returns the broker result), plus retry()/getQueueSize(). The Redis and Pool brokers implement this; their former enqueue() is renamed to publish(). - Publisher\Asynchronous — enqueue(), which hands the publish off to run in the background. Broker\Async wraps a Synchronous publisher and implements both: publish() delegates synchronously, enqueue() dispatches via the bounded channel and its reader calls the wrapped publisher's publish(). BREAKING: Redis::enqueue()/Pool::enqueue() are now publish(); the Utopia\Queue\Publisher interface is now Utopia\Queue\Publisher\Synchronous. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Broker\Async -> Broker\Background (and AsyncTest -> BackgroundTest). The name describes what it does — background dispatch — rather than restating the Asynchronous interface it implements. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Add a $workers option (default 1) that spawns N reader coroutines draining the channel concurrently, so an I/O-bound broker can have several publishes in flight at once instead of one at a time. shutdown() pushes one sentinel per reader and the WaitGroup already joins them all. Concurrency is only safe when the wrapped publisher tolerates concurrent use across coroutines — a single-connection broker must not be shared, so pair workers > 1 with a connection Pool. Documented on the class; more than one worker also gives up FIFO dispatch order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Names the knob after what it spawns — reader coroutines — matching the Swoole vocabulary used elsewhere in the package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Background now takes an optional Telemetry adapter (no-op by default) and reports: - messaging.publisher.buffer.depth — observable gauge of the channel length, i.e. publishes buffered awaiting background dispatch (the back-pressure signal) - messaging.publisher.dispatched — counter of successful background publishes - messaging.publisher.errors — counter of failed background publishes Counters carry messaging.destination.name/namespace attributes, matching the Server's queue-depth conventions. The synchronous publish() path stays a transparent passthrough and is left to the wrapped publisher's own telemetry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Describe each publisher contract — publish() blocks and returns delivery success; enqueue() accepts for background dispatch and returns acceptance, not delivery — and point at the brokers that implement them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
The wrapped synchronous publisher sees every publish, so dispatch and failure counts belong there — metering them again in Background just double-counts. Keep only the buffer-depth gauge, which is unique to the background buffer. Failed dispatches are still logged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Add a $timeout (seconds) to the Background constructor, passed to the channel push in enqueue(). When the buffer is full, enqueue() waits up to $timeout for a slot and returns false if none frees — bounding how long back pressure blocks a producer. -1 (default) preserves the previous block-indefinitely behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Asynchronous::enqueue() now returns void instead of bool. A bool that reported "accepted vs not" was easy to ignore; a void signature plus a thrown BackpressureException makes the one actionable outcome — the buffer is full and the message was rejected — impossible to miss. Background::enqueue() throws BackpressureException when the channel push times out (only possible with a positive timeout; -1 still blocks indefinitely). The synchronous fallback and publish() are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
Name the exception after the concrete condition — the buffer is full and can't accept the message — rather than the mechanism. Also avoids leaning on the "backpressure" term. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ
What
Splits publishing into two explicit contracts and adds a Swoole-backed decorator that provides both, with back pressure and telemetry.
Interfaces (
Utopia\Queue\Publisher\*)enqueue()returnsvoid— it reports acceptance, not delivery. When the buffer is full and can't accept the message, it throwsBufferFullExceptionso the caller can shed load or slow down (aboolwas too easy to ignore).RedisandPoolimplementSynchronous(their formerenqueue()is nowpublish()).Broker\Background— wraps aSynchronous, implements bothenqueue()pushes onto aSwoole\Coroutine\Channelsized bycapacity; once full it blocks the producing coroutine until a reader drains a slot — a slow broker throttles producers instead of piling up unbounded work.timeout(seconds, default -1) caps that wait: when the buffer is full,enqueue()waits up totimeoutthen throwsBufferFullException;-1blocks indefinitely.pop()and delegate each dispatch to the wrapped publisher'spublish().coroutines(default 1) sets how many readers dispatch concurrently.coroutines > 1is only safe when the wrapped publisher tolerates concurrent use across coroutines — a single-connection broker (bareRedis) must not be shared, so pair it with a connectionPool. More than one reader also gives up FIFO dispatch order.shutdown()pushes one sentinel per reader andWaitGroup::wait()s, so everything queued is published before the readers exit.enqueue()publishes synchronously.error_log'd (no producer to surface to). Note: a down broker makespublish()throw, so background dispatch drops+logs — the channel is a transient shock absorber, not a durable outage buffer.Telemetry (no-op by default)
Backgroundtakes an optionalTelemetryadapter and reports the one thing unique to the background buffer:messaging.publisher.buffer.depthDispatch/failure counts are intentionally not metered here — the wrapped synchronous publisher sees every publish and reports those itself, so counting them again would double-count.
Redis::enqueue()/Pool::enqueue()→publish().Utopia\Queue\Publisherinterface →Utopia\Queue\Publisher\Synchronous.enqueue()) now returnsvoidand throwsBufferFullExceptioninstead of returningbool.Appwrite's
$publisher->enqueue(...)call sites and anyPublishertype hints must migrate.Tests
BackgroundTest(unit suite, in-memory fakes — no Redis/Docker): syncpublish()delegation, the reader draining the channel in order, a back-pressure case (capacity 1, 20 messages, in order), enqueue throwsBufferFullExceptionwhen the buffer stays full past the timeout, concurrent coroutines (4 readers, 20 messages, each delivered once), the sync fallback, management-call delegation, and the buffer-depth gauge (via theTestadapter). The rest of the suite was migrated topublish().Verified locally:
BackgroundTest8/8 ✅ · pint ✅ · phpstan No errors ✅ · rector ✅🤖 Generated with Claude Code
https://claude.ai/code/session_01LQiyHv2cv5SUHsHYBAE8SQ