Repository navigation
Batch queue consumption independently of coroutines - #316
Conversation
Benchmark resultsqueue — workload shapes across both concurrency axes (4 cores, 600 messages, median of 3)
Shared CI runners — treat absolute numbers as rough, compare modes within a run. Commit de792b9. |
8370635 to
25bbdf0
Compare
|
Updated and rebased for the callback-only NATS API in #315; 185 queue unit tests and 53 NATS broker/prefetch integration tests pass locally. For the queue-specific review before this draft becomes ready: verify command-connection replacement after an ambiguous transport failure. NATS requests() intentionally disconnects without replay, while commandsConnection() currently retains the cached instance. Recovery of future operations needs explicit coverage in the broker; callback-abort and response-timeout connection usability are already covered in the NATS package. |
|
Rebased onto the unified NATS request lifecycle in #315. The queue now passes JetStreamMessage objects to JetStream::ackBatch(); it no longer constructs acknowledgement subjects or payloads. Updated the design document. Validation passes locally: 185 queue unit tests / 442 assertions, 53 NATS broker and prefetch integration tests / 261 assertions (including batch 100 with one coroutine), Pint, PHPStan, Rector, and documentation lint. Existing package-release and command-connection recovery review gates remain open. |
aee7186 to
8ae062d
Compare
|
Rebased onto the NATS review fix 24cbdee: server-closing errors recycle the connection before invoking onError, so a throwing callback cannot leave a dead connection marked connected. Queue validation after rebase: 185 unit tests / 442 assertions and 53 NATS broker/prefetch integration tests / 261 assertions pass. Existing release and command-connection recovery gates remain unchanged. |
8ae062d to
e683b77
Compare
|
Rebased onto a746aaf from #315. Request collection now performs keepalive checks without reconnecting/replaying an in-flight batch; ackBatch documentation explicitly requires AckPolicy::Explicit for isolation of selected messages. The queue already uses Explicit. Dependent NATS broker/prefetch integration suite passes: 53 tests / 261 assertions. Existing release and command-connection recovery gates remain unchanged. |
e683b77 to
b507633
Compare
|
Rebased onto NATS fix 1077972, which consumes buffered protocol data before keepalive emission or stale checks. This preserves buffered acknowledgement replies and PONGs. Dependent NATS broker/prefetch integration validation passes: 53 tests / 261 assertions. Existing release and command-connection recovery gates remain unchanged. |
b507633 to
4cae744
Compare
|
NATS 1.4.0 is released and available on Packagist: https://github.com/utopia-php/nats/releases/tag/1.4.0 . Rebased onto main, retargeted the PR from codex/nats-requests to main, and updated the rollout document. Existing ^1.4 constraints now resolve normally; Composer installed the published archive (mirror commit 016f48bd25f7bf7a06e5e12a9c768036945d5459), not a local path package. Validation against that archive: 185 unit tests / 442 assertions and 53 NATS broker/prefetch integration tests / 261 assertions pass. The full runner hit a port conflict when starting duplicate services, so targeted integration ran against the existing services. Board moved from Blocked to In progress. Remains draft for the separate command-connection recovery review; downstream queue/Platform/Appwrite/Cloud releases are not performed. |
|
Closed the acknowledgement connection recovery gap in b6a535e. Reproduced both failure modes against released NATS 1.4.0: an acknowledgement write never delivered and a write delivered with its confirmation lost. Previously the next commit failed because the disconnected connection stayed cached. Now command() clears the disconnected factory-created connection, JetStream handle and consumer handles under the lock in finally; the next operation obtains a fresh connection. No retry of the failed operation and no reset of the receive connection or other in-flight messages. Tests verify successful later commits, working queue-depth reads through refreshed consumer handles, redelivery only when the original acknowledgement did not reach NATS, and no replay when commit is called again for the failed delivery. Live-connection sources retain the existing owner-managed replacement contract. Validation: 185 unit tests / 442 assertions; 55 NATS broker/prefetch integration tests / 276 assertions; Pint, PHPStan, Rector and Vale pass. Updated the design document and removed this blocker. Remote CI is running. |
|
|
Benchmark interpretation from source review: the screenshot shows Redis about 1.55x NATS throughput for the null-handler batch-16 row (10,043 vs 6,465 msg/s), while I/O/mixed handlers largely hide broker overhead. Concrete differences: Redis uses ext-redis and Lua reserve/claim/settle operations with aggregate replies; the NATS client parses/dispatches messages in PHP and ackBatch still uses an independent confirmation per message (pipelined, not serial round trips). For a normal backlog with batch > 1, NATS pull() performs an empty priority poll, a one-message blocking fetch, then a no-wait top-up; each Consumer::fetch creates and removes an inbox subscription. Benchmark defaults also differ in persistence: NATS File storage with one replica, Redis compose without AOF enabled. These are candidate contributors, not measured attribution: the table is not a CPU profile or a durability-matched comparison. A 600-message null-handler sample at these rates lasts only about 60-93 ms; longer runs and component timing are needed before assigning percentages or selecting an optimization. |
|
Removed priority in 94c48be, net -129 lines. Publisher APIs no longer accept the flag; Redis always uses normal FIFO insertion; background batches group by queue only; NATS has one work consumer, one depth query and no priority poll. Existing normal subject/durable names remain unchanged. Added an integration regression for existing normal messages on a legacy two-consumer topology with an unused priority consumer. Audited current Appwrite main f2f494fd and Cloud main 72a03895 for queue priority publishing; found no usage. Cloud cutover status still mentions worker_priority, but handles its absence as 404. This does not establish that every deployed priority backlog is empty. Queue 5 migration notes require stopping priority producers and draining pending/unacknowledged legacy priority deliveries with old workers before upgrade. No live resources or messages were deleted. Validation: 184 unit tests / 440 assertions; final NATS broker suite 50 tests / 258 assertions; 14 background/prefetch integration tests passed (including Redis and NATS). The initial combined run had a test-setup error while constructing the legacy stream config; corrected the fixture and reran the entire NATS suite successfully. Pint, PHPStan, Rector and Vale pass. No throughput gain is claimed without another benchmark run. |
|
Review of 94c48be: three correctness findings to address before merge. [P1] Do not silently drop expired prefetched payloads on release
With a positive Queue::jobTtl, a prefetched message can lose its jobs payload while waiting for an earlier handler. Renewal extends only the claim, not that payload. release() then deletes the claim/processing entry, skips RPUSH because GET returned false, decrements processing and reports success. Work that never entered a handler disappears. Reproduced against Redis with jobTtl=1: publish, receive, wait 2s, release; ready=0, processing=0, claim absent, no exception. This confirms Greptile's release finding. Preserve the payload for the outstanding delivery (or make release reconstruct it safely); do not report successful release after discarding the only delivery state. [P1] Preserve long-running Redis jobs on adapters without renewal
Settlement now requires the 90-second claim key to remain live, but only Adapter/Swoole renews deliveries. KubernetesJob and Workerman still inherit Adapter::withAckExtension(), which merely invokes the handler. Consequently a handler lasting beyond CLAIM_TTL completes its side effects and then cannot commit or reject, even when no other consumer reclaimed it and the job payload is intact. It remains processing until recovery and can run again. This is a regression from the previous commit path, which did not require a live heartbeat. Reproduced through KubernetesJob::consume(), accelerating claim expiry with PEXPIRE during its handler: handled=1, success callbacks=0, error='Queue delivery is no longer owned by this consumer'. Provide renewal for all supported execution paths or separate durable ownership from the expiring liveness signal; simply removing the ownership check would weaken fencing. [P2] Partition grouped Cluster settlements by compatible key placement
The broker has one settlement Buffer for every queue/namespace, and concatenates all waiting keys into one script. Individually valid Redis Cluster queues with different namespace hash tags therefore fail once their completions coalesce. Reproduced using one broker with The consolidated worker loop and JetStream ackBatch boundary are improvements. The remaining modeling issue is that payload retention, ownership and heartbeat lifetime have different meanings, while settlement currently assumes they expire together. Likewise, a batching boundary must represent a valid atomic operation, not just all calls sharing a broker instance. Validation on an isolated export of the exact head:
|
|
Checked the linked review and both Greptile comments against current head 94c48be. All three correctness findings are valid merge blockers: (1) claim.lua applies jobTtl to payload while extend.lua renews only the claim; settle.lua release removes delivery state and reports success even if payload is missing. (2) settle.lua fences on a 90-second expiring claim, while KubernetesJob and Workerman have no renewal override, so long handlers lose settlement rights without a takeover. (3) the broker-wide settlement Buffer combines keys across namespaces; RedisCluster::execute rejects distinct hash tags even when each queue is valid on its own. Greptile Release Can Lose Work duplicates finding 1. Its test-quality finding is also valid: BufferTest and RedisReservationTest pin exact scheduler-dependent groupings; LockingTest mirrors the interface method list through reflection. Replace these with behavioral checks for delivery/outcomes, coalescing and serialization rather than exact partitions or a copied API inventory. Recommended correction: separate payload retention, durable ownership and expiring liveness; retain fencing across actual takeover. Partition settlement batches by compatible placement (namespace is a conservative option). These need focused TTL, non-renewing-adapter and cross-namespace Cluster regressions. Source verification only in this pass; I did not rerun the review reproductions or modify code. The Appwrite/Cloud priority backlog is confirmed empty by the owner and is not a rollout blocker. |
|
Prior-art-based consumer model proposal (design only; no changes to the correctness fixes currently being edited in the PR worktree): Use Message, Consumer, Worker, receipt, acknowledgement, prefetch, heartbeat. No new public Delivery/Claim/Lease/Settlement objects.
Concrete target:
Deletion targets: nullable outcome interpretation, duplicated result accumulation, generic-looking public Buffer, and redundant state/type wrappers. Keep the consolidated worker loop. A renewal capability should replace reflection only with an explicit third-party migration plan. |
|
Addressed all three findings in #316 (comment) and both Greptile comments in 20b2327.
The four primary regressions fail against the previous implementation and pass after the fixes. Final validation: 183 unit tests / 443 assertions; 22 Redis/Cluster/prefetch integration tests / 82 assertions; 16 reservation tests / 54 assertions against Dragonfly. Includes an actual KubernetesJob consume path with accelerated heartbeat expiry, failed-payload TTL, missing-payload release, stale owner after reclaim, and completion/renewal winning races with reclaim. Pint, PHPStan, Rector, Vale and diff checks pass. Remote CI is running. Updated retention and rollback documentation; no production changes. |
|
Design update requested by @loks0n: rename $server->job('stats-usage', coroutines: 1, prefetch: 100);Apply consistently to parameter names, internal queue specifications, examples and named-argument callers in queue 5; retain positional argument order. This updates the proposed model, not a claim that the implementation has already changed. |
|
Implemented the domain model proposal and naming update in 3addb37. $server->job('stats-usage', coroutines: 1, prefetch: 100);
Queue 5 migration is documented: rename named arguments and Platform job keys from Validation: 187 queue unit tests / 459 assertions; 72 Redis, Redis Cluster, NATS and prefetch integration tests / 340 assertions; 16 Dragonfly reservation tests / 54 assertions; 2 Platform worker tests / 8 assertions against the local queue 5 package. New regressions cover unconfirmed messages counting against prefetch, handlers progressing before confirmations, successful confirmations surviving a later transport failure, and rejecting undersized prefetch. Pint, PHPStan, Rector, package validation, documentation lint, and benchmark shell syntax pass. |
|
Re-reviewed head The three earlier findings are addressed: active Redis payloads survive retention expiry, settlement checks durable ownership independently of heartbeat expiry, and settlement batches are scoped by namespace. Atomic recovery also rechecks ownership and heartbeat before changing state. The model is substantially cleaner: Independent validation against this head:
Platform linked tests and validation are green in CI. Queue tests, benchmark, and Greptile are still pending at the time of this review. Board status remains In review. |
|
Checked outstanding Greptile threads against 3addb37: one unresolved finding, Test Mirrors Lua Internals. It is valid and still present.
Recommended fix: use a Consumer fake for adapter lifecycle/concurrency unit tests; test Redis state transitions against real Redis through observable results. Delete the PHP copy of the Lua protocol rather than extending it. This is a test-design issue, not a newly identified runtime defect. Greptile's latest summary reviewed this exact commit and lists no additional findings. No code changed during this check. |
| foreach ($this->onWorkerStart as $callback) { | ||
| $callback('0'); | ||
| } |
There was a problem hiding this comment.
Test Mirrors Lifecycle Internals
This test directly iterates Swoole's protected $onWorkerStart storage and reproduces its internal callback argument to enter Server::start(). A behavior-preserving lifecycle refactor could therefore break the test even when prefetch still works correctly. RedisExpiryTest.php:48-50 has the same problem by identifying a Lua operation from its source prefix. This violates the repository directive to test observable behavior rather than mirror source code or configuration, so the requirement must be satisfied before merging.
Context Used: Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/queue/tests/Queue/Unit/PrefetchTest.php
Line: 61-63
Comment:
**Test Mirrors Lifecycle Internals**
This test directly iterates Swoole's protected `$onWorkerStart` storage and reproduces its internal callback argument to enter `Server::start()`. A behavior-preserving lifecycle refactor could therefore break the test even when prefetch still works correctly. `RedisExpiryTest.php:48-50` has the same problem by identifying a Lua operation from its source prefix. This violates the repository directive to test observable behavior rather than mirror source code or configuration, so the requirement must be satisfied before merging.
**Context Used:** Call out and harshly judge implementation-coupled tests. We don't mirror source code, configuration, or version pins in assertions. We test observable behavior; use linters for syntax and schema checks. ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Merged #316 with the requested admin override after every check on Published from that merge commit and verified on Packagist:
CI tests: queue unit 145 tests / 329 assertions; queue integration 151 tests / 743 assertions; linked Platform 11 tests / 92 assertions. Validation, static checks, benchmarks, and Greptile checks all passed. Both tag-split release workflows succeeded. One advisory Greptile test-harness coupling note remains visible; it did not fail the review check and was not resolved by this release. Next rollout steps remain Appwrite/Cloud configuration-key and package-lock updates, staging validation on both brokers, then the stats-usage canary. No application deployments or deployment defaults changed here. Migration and rollback details are included in the release notes. |

Workers can prefetch 100 messages while running one coroutine, reducing broker round trips without acknowledging unfinished work. The public API is
job('stats-usage', coroutines: 1, prefetch: 100). Prefetch bounds waiting, running, and unconfirmed messages together; it defaults to coroutines and cannot be lower. Each message retains its own acknowledgement and error handling.Redis keeps the existing opaque ready-list payloads. Bounded Lua operations reserve and finalize batches, combine waiting completions with independent results, and renew heartbeats. Reservations survive process death after a blocking move. Outstanding payloads and ownership persist until completion or atomic recovery; heartbeat expiry alone does not revoke ownership. Positive
jobTtlapplies after rejection/dead-lettering. Commit, reject, and release are explicit operations, and completion grouping is scoped to the namespace for Redis Cluster.NATS combines independent acknowledgement requests on its command connection, retaining a server confirmation per message. The internal transport buffer reports results incrementally without duplicate aggregate results. Factory-created brokers replace disconnected command connections on the next operation without replaying ambiguous acknowledgements. Shutdown returns work whose handler never started and bounds drain time.
Removes queue priority from publisher interfaces, Redis insertion, background batching, and NATS consumption. Existing normal subjects and Redis list payloads remain unchanged. Queues that used priority must drain that work with old consumers before upgrading; new workers do not consume the priority subject. Empty legacy consumers can remain.
Release as queue 5. NATS support is published as 1.4.0; this PR requires
^1.4and targetsmain. Rename named arguments and Platform job keys frommaxCoroutines/batchtocoroutines/prefetch; positional order is unchanged. Platform now requires queue 5. Custom Redis connections must implementexecute(). Redis Cluster namespaces require a shared hash tag, so existing Cluster queues need an explicit drain/migration. Before rollback, drain and recover reservations with a new consumer still available; old consumers cannot recover those reservations. Existing third-party single-message renewal remains supported.Validation:
Appwrite/Cloud call-site and package-lock updates, staging checks on both brokers, representative stats-usage failure/accounting checks, and a deployment canary follow the package releases. Existing deployment environment names can stay; map their values to the new prefetch contract. No deployment defaults change here. Local transport benchmarks are not a production prefetch recommendation.
Design, compatibility, and rollout gates:
packages/queue/docs/batching.mdandprior-art.md.Tracked in https://github.com/appwrite-labs/cloud/issues/5996.