diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index a47047f30..a22114dd1 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -52,7 +52,7 @@ jobs: - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - name: Install procps for Horizon process tests + - name: Install procps for Horizon's SystemProcessCounter (ps aux) run: apt-get update -qq && apt-get install -y -qq --no-install-recommends procps > /dev/null - name: Execute Redis integration tests @@ -111,7 +111,7 @@ jobs: - name: Install dependencies run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o - - name: Install procps for Horizon process tests + - name: Install procps for Horizon's SystemProcessCounter (ps aux) run: apt-get update -qq && apt-get install -y -qq --no-install-recommends procps > /dev/null - name: Execute Redis integration tests diff --git a/AGENTS.md b/AGENTS.md index b7e41b351..cda10e43e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -682,6 +682,7 @@ Run `./vendor/bin/phpstan` and `./vendor/bin/php-cs-fixer fix` without flags — 6. **Wrong docblock types should be fixed**, not suppressed. Check the actual runtime behavior (extension docs, reflection, tests) to determine the correct type. 7. **Type decisions must be evidence-based.** See Development Conventions — check Laravel/Hyperf signatures and docblocks, then trace real control flow. Don't guess. 8. **Narrowing / suppression order.** When the code is correct but PHPStan can't follow it, in order: (1) fix the type signature or docblock; (2) `@var` to narrow to the correct runtime type; (3) a line- or identifier-scoped `@phpstan-ignore` (e.g. magic `__call`/`__get` forwarding). Never use `assert()` to narrow types, and never add a neon-wide rule on your own (see #9). + When a container string key is also a PHP class name, keep the canonical service key and use `@var` for its actual runtime type; do not change service resolution solely for PHPStan. 9. **Don't add patterns to `phpstan.neon.dist` on your own.** The neon file's global ignores cover fundamental framework patterns (Eloquent magic, generics, `new static`). Fix new phpstan errors at the source, not by masking them with new neon rules. Under rare circumstances a global suppression genuinely is the best choice — if you think one may be needed, STOP, explain why the error can't be fixed at the source or narrowed locally, and ask for approval before adding it. ## Porting Packages diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 2c3ad0b15..708420ed8 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `queue` -- **Ledger entries required for the active work:** `Harden framework contracts and request-scoped state`; `Consolidate reflection metadata and correct callable inference`; `Correct event dispatch, queued-consumer isolation, and queue interoperability`; `Normalize framework enum identifiers at string boundaries`; `Make Bus dispatch, batches, and unique payloads lifecycle-safe`; `Complete Foundation runtime lifecycles and safe publication`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`. -- **Pending revalidation carried into the active work:** None. +- **Active package or work unit:** `reverb` +- **Ledger entries required for the active work:** `Preserve configuration identity across worker reloads`; `Normalize framework enum identifiers at string boundaries`; `Complete Console command, scheduling, and generator lifecycles`; `Unify HTTP response emission and harden native server boundaries`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; `Complete Horizon cluster, process, publication, and current Laravel parity`. +- **Pending revalidation carried into the active work:** `config-02`, `support-02`, `reverb-03`, `reverb-04`, `redis-10`, `redis-11`, `reverb-05`, `http-server-06`, and `reverb-06`. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1052,7 +1052,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events` and `queue` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events`, `bus`, and `queue` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth`, `broadcasting`, `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon`, `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb`, `routing`, `sanctum`, `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth`, `broadcasting`, `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications`, `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb`, `routing`, `sanctum`, `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`, `translation`; later full consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | | `auth-01` | `support`, `auth` | later full `auth` audit | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | | `sanctum-01` | `sanctum` | `encryption`; later full `sanctum` audit | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | @@ -1094,12 +1094,12 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `redis-10` | `redis` | `reverb` (revalidation complete); later full `reverb` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-10` | | `redis-11` | `redis` | `reverb` (revalidation complete); later full `reverb` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-11` | | `redis-12` | `redis`, `cache` | `redis` and `cache` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-12` | -| `redis-13` | `redis` | `horizon`, `cache`, `queue`, and `session` (revalidation complete), `broadcasting`; later full `horizon` and `broadcasting` audits | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-13` | +| `redis-13` | `redis` | `horizon`, `cache`, `queue`, and `session` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-13` | | `redis-21` | `redis` | `queue` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `redis-21` | | `redis-22` | `redis` | `queue` and `support` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `redis-22` | | `reverb-05` | `reverb` | `redis` (revalidation complete); later full `reverb` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `reverb-05` | | `redis-15` | `redis` | `telescope` and `sentry` (revalidation complete); later full consumer audits | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-15` | -| `horizon-01` | `horizon` | `redis` (revalidation complete); later full `horizon` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `horizon-01` | +| `horizon-01` | `horizon` | `redis` and `horizon` (revalidation complete) | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `horizon-01` | | `telescope-01` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-01` | | `telescope-02` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-02` | | `sentry-01` | `sentry` | `redis` (revalidation complete); later full `sentry` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `sentry-01` | @@ -1108,10 +1108,15 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `session-23` | `cache` | `session` (revalidation complete) | `Complete Session lifecycles, persistence, and current Laravel parity`; finding `session-23` | | `contracts-09` | `contracts` | `foundation` (revalidation complete), `broadcasting`; later full `broadcasting` audit | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `contracts-09` | | `notifications-07` | `contracts` | later full `notifications` audit | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `notifications-07` | -| `queue-22` | `queue` | later full `horizon` and `telescope` audits | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-22` | +| `queue-22` | `queue` | `horizon` (revalidation complete); later full `telescope` audit | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-22` | | `queue-29` | `queue` | `foundation` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-29` | | `queue-36` | `queue`, `support` | `support` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-36` | | `queue-37` | `queue`, `support` | `support` (revalidation complete) | `Complete Queue pooling, payload durability, and current Laravel parity`; finding `queue-37` | +| `queue-40` | `queue` | `queue` and `horizon` (revalidation complete) | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `queue-40` | +| `redis-23` | `redis` | `redis` and `horizon` (revalidation complete) | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `redis-23` | +| `telescope-03` | `telescope` | `telescope` (targeted correction complete); later full `telescope` audit | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `telescope-03` | +| `fortify-01` | `fortify` | `fortify` (targeted correction complete); later full `fortify` audit | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `fortify-01` | +| `reverb-06` | `reverb` | `reverb` (targeted correction complete); later full `reverb` audit | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `reverb-06` | ## Package checklist @@ -1185,7 +1190,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `cache` - [x] `session` - [x] `queue` -- [ ] `horizon` +- [x] `horizon` - [ ] `reverb` - [ ] `http` - [ ] `api-client` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index a96abb4fb..93260c6f6 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1191,9 +1191,9 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved owner gates and intentional differences:** The owner approved rejecting pooled RESET and sharded subscription, deleting automatic framework replay and the incompatible named-proxy extension surface, changing Reverb outages to truthful publish failures, adding Laravel-shaped boot-only event controls and macros, retaining connection-local topology, and the source-proven noise-level command-name normalization and macro lookup. Connector-driver `extend()` / `setDriver()`, pooled RESET, and pooled/dedicated `ssubscribe()` are recorded at their README, source, and test locations with supported alternatives. - **Important rejected concerns:** Do not add a retry registry, command ambiguity state machine, connector hierarchy, public transport abstraction, sharded Pub/Sub router, pool retrofit mechanism, publisher queue, generic finalizer, configurable channel policy, raw-client wrapper, recursive object serializer, or compatibility layer. Idle subscriber receive remains correctly unbounded. Cluster subscriber transport follows only `cluster.context`, matching phpredis node sockets; unsupported endpoint ambiguity is rejected rather than guessed. The identical two Cache limiter callback sites remain accepted under `redis-12` for the active Cache work; they require the same direct precedence correction, not a shared abstraction. -- **Implementation:** Command failures now determine only connection disposition and never repeat work. The subscriber uses exact RESP2 stream parsing, full I/O, semantic routing, exact channel/pattern accounting, retained receive causes, bounded foreground operations, and complete standalone/Sentinel/Cluster construction. Reverb publishes directly and cleans failed metric requests. Redis limiter cleanup preserves the primary callback failure. Current supported phpredis validation, credentials, options, macros, event controls, Horizon topology, config-owned application prefix default, both prefix precedence levels, facade metadata, and static cleanup are complete. The later Queue work made SafeScan reject transformed connections before any scan/deletion and reconciled every case-insensitive command signature plus the held-transaction discard boundary. Pooled RESET and sharded subscription fail before native state changes. Telescope formatting/filtering and Sentry database metadata are truthful. Dead replay, Engine EOF framing, timer-per-message delivery, named-proxy extensions, stale compatibility, duplicate config parsing, unbounded Reverb queueing, obsolete exceptions, and misleading documentation are removed. -- **Cross-package implications and revalidation:** `redis-09` was revalidated by Cache. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` is complete at both Redis and Cache limiter sites. `redis-13` is complete for Horizon, Cache, Queue, and Session; Broadcasting remains routed to its full audit. `redis-15` revalidated Telescope and Sentry boot integration. `redis-21` and `redis-22` were completed through Queue's raw inspection/removal and command-metadata work, including Support facade revalidation. `horizon-01`, `telescope-01`, `telescope-02`, and `sentry-01` remain recorded for those packages' later full audits. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. -- **Regression tests:** Focused and live-service coverage proves no replay and exact failure disposition; immediate callback release and terminal defer ownership; real MULTI, PIPELINE, WATCH, DISCARD, RESET rejection, and mixed-case routing; byte-exact fragmented RESP, protocol failures, timeouts, close races, TLS, Unix, IPv6, Sentinel, and Cluster subscription paths; complete phpredis options and credentials; macros without checkout and one-event granularity; boot-only event overrides; Horizon config defaults, topology, prefix publication, and hash tags; direct Reverb publishing and metric cleanup; both limiter failure matrices; raw SafeScan/FlushByPattern operation and transformed-mode rejection before deletion; all case-insensitive command pairs and held-transaction discard semantics; facade/manifest invariants; Telescope no-user-code formatting; and Sentry success/failure database metadata. +- **Implementation:** Command failures now determine only connection disposition and never repeat work. The subscriber uses exact RESP2 stream parsing, full I/O, semantic routing, exact channel/pattern accounting, retained receive causes, bounded foreground operations, and complete standalone/Sentinel/Cluster construction. Reverb publishes directly and cleans failed metric requests. Redis limiter cleanup preserves the primary callback failure. Current supported phpredis validation, credentials, options, macros, event controls, Horizon topology, config-owned application prefix default, both prefix precedence levels, facade metadata, and static cleanup are complete. The later Queue work made SafeScan reject transformed connections before any scan/deletion and reconciled every case-insensitive command signature plus the held-transaction discard boundary. The Horizon work added callback-sensitive MultiExec return contracts without changing runtime behavior. Pooled RESET and sharded subscription fail before native state changes. Telescope formatting/filtering and Sentry database metadata are truthful. Dead replay, Engine EOF framing, timer-per-message delivery, named-proxy extensions, stale compatibility, duplicate config parsing, unbounded Reverb queueing, obsolete exceptions, and misleading documentation are removed. +- **Cross-package implications and revalidation:** `redis-09` was revalidated by Cache. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` is complete at both Redis and Cache limiter sites. `redis-13` is complete for Horizon, Cache, Queue, and Session; Broadcasting remains routed to its full audit. `redis-15` revalidated Telescope and Sentry boot integration. `redis-21` and `redis-22` were completed through Queue's raw inspection/removal and command-metadata work, including Support facade revalidation. `redis-23` is complete through Horizon's Cluster-aware batching and exact result shapes. `horizon-01` is complete after Horizon's full audit; `telescope-01`, `telescope-02`, and `sentry-01` remain recorded for those packages' later full audits. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. +- **Regression tests:** Focused and live-service coverage proves no replay and exact failure disposition; immediate callback release and terminal defer ownership; real MULTI, PIPELINE, WATCH, DISCARD, RESET rejection, and mixed-case routing; callback and no-callback MultiExec result shapes; byte-exact fragmented RESP, protocol failures, timeouts, close races, TLS, Unix, IPv6, Sentinel, and Cluster subscription paths; complete phpredis options and credentials; macros without checkout and one-event granularity; boot-only event overrides; Horizon config defaults, topology, prefix publication, and hash tags; direct Reverb publishing and metric cleanup; both limiter failure matrices; raw SafeScan/FlushByPattern operation and transformed-mode rejection before deletion; all case-insensitive command pairs and held-transaction discard semantics; facade/manifest invariants; Telescope no-user-code formatting; and Sentry success/failure database metadata. - **Performance and complexity:** Ordinary proxy commands add two lowercase operations on a short method name, one static macro-table lookup, and fixed guard comparisons; these are owner-approved measurement-noise costs with no config read, container lookup, lock, context operation, yield, retry, log, reconnect, or extra network command. Failure classification, topology, option normalization, observability, boot overrides, SafeScan's one construction-time raw-mode guard, and command metadata stay on cold or exceptional paths. Exact subscriber parsing removes one timer coroutine per message, and direct Reverb publishing removes unbounded retained outage memory. No new registry, retry queue, state machine, or unbounded worker state is retained. - **Laravel-facing result:** Current supported phpredis options, validation, macros, manager event controls, facade metadata, tests, and task-first documentation are restored while Hypervel retains phpredis-only pooling, exact coroutine ownership, connection-local topology, transforms, SafeScan, raw held access, immediate callback release, and other advanced helpers. The deliberate differences are limited to behaviors Laravel's request-lifetime connector model cannot safely provide through a pooled Swoole connection. - **Validation and review:** Every changed test owner, Redis integration path, and affected Reverb, Horizon, Telescope, and Sentry group passed. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split-manifest validation, `git diff --check`, stale-path and dependency scans, fresh caller/callee, protocol, lifecycle, API, documentation, hot-path, retained-memory, and overengineering self-review, and independent code review are complete with no remaining finding. @@ -1309,6 +1309,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `queue-37` | Parity and upstream defects | Minor | High | QueueFake double-counts delayed work, drops pass-through delay, and lacks current reserved/push-hook behavior | Keep one disjoint operation history and route immediate/delayed work through one instance-owned hook boundary | | `queue-38` | Current Laravel parity defect | Minor | High | Beanstalk `size()` reports only ready jobs despite the standardized total metric | Sum ready, delayed, and reserved counts from one stats read | | `queue-39` | Defect | Major | High | A secondary Batchable rollback failure replaces the original timeout, preventing failed-job transaction cleanup and corrupting failure diagnostics | Keep the batch rollback catch non-capturing so the original timeout remains authoritative | +| `queue-40` | Defect and parity defect | Major | High | Redis bulk enters an unsupported Cluster pipeline and both Redis and Database bulk ignore `#[Delay]` | Select transaction batching for Cluster and use the existing cached Queue attribute reader for delay | | `redis-21` | Cross-package defect | Major | High | Transformed SafeScan results break Queue inspection and can make pattern deletion silently do nothing | Reject transformed connections before scanning or deleting and document the raw held-connection requirement | | `redis-22` | Cross-package metadata and API defect | Major | High | Case-insensitive command aliases conflict and `discard()` conflates a Redis command with pool lifecycle | Reconcile command signatures, keep proxy Redis `discard()`, and expose `discardTransaction()` for held MULTI ownership | | `contracts-09` | Contract defect | Minor | High | The core Broadcaster contract requires optional concrete channel enumeration | Remove it from the contract while retaining concrete/proxy/facade support and a scoped command-side dynamic call | @@ -1316,10 +1317,53 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved owner gates and intentional differences:** The owner approved every current Laravel API, the owner-first protected enqueue callback, one fresh after-commit pooled lease, SQS overflow and batching behavior, concrete-only inspection, Redis raw-scan and held-transaction APIs, boot-only worker controls, contract corrections, explicit failed-provider selection, and every source-proven noise-level cost. Hypervel retains concurrent workers, pooling, Queue routes, debounce, payload context, SafeScan, transformed Redis operations, and Laravel payload interoperability. `--timeout=0` deliberately leaves both job execution and stop-path draining unbounded. - **Important rejected concerns:** Do not add a poison registry, retry/tombstone state machine, generic proxy forwarding, second inspection contract, pagination/lazy-result API, transaction dispatcher service, queue schema validator, generic cleanup abstraction, SQS publication registry, pool retrofit, worker singleton current-job state, arbitrary stop timeout, Redis transformed-scan compatibility, global PHPStan rule, or extra contract methods for optional concrete capabilities. Do not retain a queue lease across user callbacks or transactions. -- **Implementation:** Pooled dispatch now gives immediate work its current owner and deferred work one fresh lease, with logical name/dispatcher state reset on every borrow/release. SQS batches and overflow bodies preserve ordering, exact identifiers, publication ambiguity, and terminal cleanup. Payloads decode once; malformed jobs retain exact evidence, terminate without release, and remain observable without hiding unrelated failures. A secondary Batchable rollback failure no longer replaces the authoritative timeout or prevents failed-job transaction cleanup. Redis reservation keeps invalid bytes atomic. Workers use monotonic lifecycle state, truthful timeouts, complete stop draining, exact configuration, and current events/options/output. Middleware, providers, fakes, inspection, contracts, metadata, facade docs, and public guides now match the supported surface. Superseded duplicate fake state, stale defaults/comments/ignores, ambiguous Redis declarations, and false contract requirements are removed. -- **Cross-package revalidation:** Completed Contracts owns `contracts-09` and the already-canonical `notifications-07`; Foundation is revalidated for Queue configuration and channel enumeration; Database owns `database-14`; Redis owns `redis-21`/`redis-22`; Support is revalidated for QueueFake plus Queue/Redis facade metadata. Queue revalidated carried `queue-01`, `queue-11`, `queue-12`, `queue-14`, `reflection-04`, `events-03`, `support-02`, `bus-03`, `bus-10`, `bus-17`, `bus-18`, and `redis-13`. `queue-22` remains required for full Horizon and Telescope audits, `contracts-09` for Broadcasting, and `notifications-07` for Notifications. -- **Regression tests:** Deterministic coverage proves max-one-pool immediate/commit/rollback/concurrent ownership, one SQS lease per deferred batch, every overflow failure/ambiguity/cleanup transition, credentials and exact FIFO zero, one checked payload decode, poison event/report/delete behavior, timeout preservation across a throwing Batchable rollback, exact Redis raw reservation, failed-record durability, timeout/quiet/pause/drain lifecycle, middleware exactness, named Queue-connection merging versus replace-whole failed/batching blocks, provider-owned optional file defaults and atomic mode-preserving publication, real Redis inspection including literal standalone hash tags, held transactions, truthful command aliases/facades, disjoint QueueFake metrics and hooks, Beanstalk totals, and all affected Horizon/Telescope/Bus/contract consumers. +- **Implementation:** Pooled dispatch now gives immediate work its current owner and deferred work one fresh lease, with logical name/dispatcher state reset on every borrow/release. SQS batches and overflow bodies preserve ordering, exact identifiers, publication ambiguity, and terminal cleanup. Payloads decode once; malformed jobs retain exact evidence, terminate without release, and remain observable without hiding unrelated failures. A secondary Batchable rollback failure no longer replaces the authoritative timeout or prevents failed-job transaction cleanup. Redis reservation keeps invalid bytes atomic. Workers use monotonic lifecycle state, truthful timeouts, complete stop draining, exact configuration, and current events/options/output. The later Horizon work made Redis bulk topology-aware and restored property/attribute delay semantics in Redis and Database bulk. Middleware, providers, fakes, inspection, contracts, metadata, facade docs, and public guides now match the supported surface. Superseded duplicate fake state, stale defaults/comments/ignores, ambiguous Redis declarations, and false contract requirements are removed. +- **Cross-package revalidation:** Completed Contracts owns `contracts-09` and the already-canonical `notifications-07`; Foundation is revalidated for Queue configuration and channel enumeration; Database owns `database-14`; Redis owns `redis-21`/`redis-22`; Support is revalidated for QueueFake plus Queue/Redis facade metadata. Queue revalidated carried `queue-01`, `queue-11`, `queue-12`, `queue-14`, `reflection-04`, `events-03`, `support-02`, `bus-03`, `bus-10`, `bus-17`, `bus-18`, and `redis-13`. `queue-22` is complete for Horizon and remains required for Telescope; `queue-40` is complete at Queue and Horizon's dispatch boundary; `contracts-09` remains for Broadcasting and `notifications-07` for Notifications. +- **Regression tests:** Deterministic coverage proves max-one-pool immediate/commit/rollback/concurrent ownership, one SQS lease per deferred batch, every overflow failure/ambiguity/cleanup transition, credentials and exact FIFO zero, one checked payload decode, poison event/report/delete behavior, timeout preservation across a throwing Batchable rollback, exact Redis raw reservation, failed-record durability, timeout/quiet/pause/drain lifecycle, middleware exactness, named Queue-connection merging versus replace-whole failed/batching blocks, provider-owned optional file defaults and atomic mode-preserving publication, real Redis inspection including literal standalone hash tags, held transactions, truthful command aliases/facades, Cluster-aware Redis bulk and Redis/Database attribute delays, disjoint QueueFake metrics and hooks, Beanstalk totals, and all affected Horizon/Telescope/Bus/contract consumers. - **Performance and complexity:** Ordinary dispatch remains one checkout and does not allocate a per-dispatch dispatcher; a real deferred transaction adds one necessary checkout at commit and retains no lease. SQS batching reduces network calls. Valid jobs decode once. Redis invalid handling remains inside the existing Lua round trip. Pause reads use one cache `many()` call. Inspection is explicitly eager and operational: while live, its materialized collection shares the Swoole worker heap with unrelated coroutines. Its Redis removal member is the exact raw value. QueueFake and metadata changes add no production cost. No lock, retry loop, registry, context slot, compatibility shim, or unbounded retained state was added. - **Laravel-facing result:** Current supported Queue, SQS, worker, middleware, failed-provider, fake, inspection, facade, configuration, and documentation APIs are restored while Hypervel's coroutine/pool adaptations remain explicit. Verified upstream overflow, malformed-payload, fake, exact-zero, and contract defects are corrected rather than copied. - **Validation and review:** Focused source, unit, integration, manifest, facade-documenter, and consumer coverage passed during implementation. The final authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Finding-ID, dependency-index, package-checklist, stale-state, `git diff --check`, and fresh caller/callee, lifecycle, API, documentation, hot-path, retained-state, and overengineering reviews are complete. Independent code review is signed off with every final correction incorporated. - **Assessment:** Every accepted Queue and cross-package finding is implemented at its lowest owner. Borrowed resources do not escape their leases, poison payloads preserve evidence and terminate, cleanup retains the primary failure, and ordinary hot paths contain only bounded correctness work. The result has no workaround, speculative abstraction, hot-path synchronization, or unresolved accepted design. + +### Complete Horizon cluster, process, publication, and current Laravel parity + +- **Architecture and inspected risk surfaces:** Horizon is a Laravel-derived Redis queue monitor adapted to Hypervel's pooled phpredis transport, coroutine Queue workers, Watcher process management, immutable dates, and no-`exit()` process model. The audit covered every Horizon source and test file; Queue publication, Redis topology and MultiExec contracts, process and signal ownership, metrics, locks, HTTP controllers, installers, configuration, split metadata, public docs, Boost resources, frontend metadata, Telescope/Fortify publication siblings, and current Laravel Horizon/Framework/docs/Boost sources and history. The detailed design is recorded in [`2026-07-28-2043-horizon-cluster-lifecycle-and-current-parity.md`](2026-07-28-2043-horizon-cluster-lifecycle-and-current-parity.md). + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `horizon-02` | Defect | Critical | High | `horizon:work` omits an inherited option and narrows two reachable null returns | Restore the option and the Queue parent's nullable result | +| `horizon-03` | Defect | Major | High | Direct pipelines and an untagged ready key fail on Redis Cluster | Batch through one topology-aware trait and use Queue's physical key | +| `queue-40` | Cross-package defect and parity defect | Major | High | Redis bulk enters an unsupported Cluster pipeline and Redis/Database bulk ignore `#[Delay]` | Select transaction batching for Cluster and use Queue's cached attribute reader | +| `horizon-04` | Defect | Major | High | Rankings, clearing, snapshot locks, result typing, and stopwatch ownership are incorrect | Correct exact ranges/types, use one raw clear lease, and release timers at the consuming listener | +| `horizon-05` | Defect | Major | High | Batch search emits invalid MySQL/MariaDB escape syntax | Use one portable explicit escape character and preserve exact cursor semantics | +| `horizon-06` | Defect | Major | High | Monitor termination loses exit status and later queued work can reopen a terminated owner | Keep protected nullable terminal status, clear pending signals, stop later commands/work, and return the status | +| `horizon-07` | Defect and upstream defect | Major | High | Lock acquisition is non-atomic and callback release can delete a successor | Use atomic `SET EX NX` and Cache's token-owned RedisLock callback boundary | +| `horizon-08` | Current parity | Improvement | High | Development command registration and request CSP nonce APIs are absent | Port the APIs and keep nonce state coroutine-local | +| `horizon-09` | Defect | Major | High | Last-pushed job and listener-event context leak into later work | Consume and forget each handoff exactly once, including failure paths | +| `horizon-10` | Parity defect | Minor | High | Delayed payloads omit delay metadata and pass an unresolved queue to hooks | Forward the existing delay and resolved queue through payload creation | +| `horizon-11` | Type and exact-value defects | Minor | High | Names, tags, balancing, zero timing, and process codes use incorrect local boundaries | Apply exact null/empty/strict comparisons and truthful native types at each owner | +| `horizon-12` | Configuration defect | Minor | High | Defaults drift and two supported keys are undeclared | Declare the keys once and retain fallbacks only inside replace-whole nested arrays | +| `horizon-13` | Publication defect | Major | High | Horizon's provider rewrite can publish partial PHP | Read content/mode and publish through checked atomic `Filesystem::replace()` | +| `telescope-03` | Cross-package publication defect | Major | High | Telescope has the same one-file installer rewrite | Apply the same checked atomic publication at Telescope's owner | +| `fortify-01` | Cross-package publication defect | Major | High | Fortify has the same defect across six independently published PHP files | Publish each file atomically and stop before later files/provider registration on failure | +| `horizon-14` | Documentation and dependency sync | Minor | High | Public guidance, package-owned Boost resources, and the frontend lock lag current behavior | Synchronize the supported surface without temporary SMS wording or an unrelated bundle rebuild | +| `horizon-15` | Metadata defect | Major | High | The split package omits hard runtime requirements and retains stale Carbon | Declare exact direct requirements and remove the unused dependency | +| `horizon-16` | Test and maintenance defect | Minor | High | Integration cleanup duplicates authoritative owners and reset literals can drift | Delete duplicate cleanup and derive each reset from one default constant | +| `horizon-17` | Convention and type correction | Minor | High | Container resolution hides actual Redis/Queue contracts | Preserve canonical keys and use local truthful narrowing plus typed `make()` calls | +| `horizon-18` | Test lifecycle defect | Minor | High | The isolated `horizon` command test signals after a fixed delay that contention can outrun, killing the child before its handler exists | Poll the persisted master record as the readiness barrier and always release the command through `finally` | +| `horizon-19` | Test defect | Minor | High | Installer read-failure tests assume denied mode bits always make files unreadable, which fails under privileged CI users | Probe native read capability after denying access and skip only when the failure cannot be reproduced | +| `filesystem-13` | Cross-package test defect | Minor | High | Filesystem write-failure guards call `trim(shell_exec('whoami'))`, which throws when the command is absent or shell execution is disabled, and retain dead returns after throwing skips | Probe write capability directly, remove the dead returns, and create failure expectations only after the precondition holds | +| `horizon-20` | Documentation defect and upstream defect | Major | High | The current Hypervel and Laravel CSP examples reuse a static nonce and omit the matching response header, leaving copied policies ineffective | Generate one fresh request nonce and apply it to Horizon tags plus both script and style response-header directives | +| `horizon-21` | Test dependency defect | Minor | High | The supervisor niceness regression calls `trim()` on nullable shell output and throws when `ps` is absent despite Horizon's required native process extension | Read the current priority through `pcntl_getpriority()` while retaining `procps` for `SystemProcessCounter` | +| `redis-23` | Cross-package type-contract defect | Minor | High | MultiExec conflates callback results with no-callback native clients | Use callback-sensitive return documentation without runtime changes | +| `reverb-06` | Cross-package test lifecycle defect | Minor | High | A child can exit after the parent's final pipe read, losing its buffered assertion result | Drain once after reap and distinguish exited-without-message from observation timeout | + +- **Approved owner gates and intentional differences:** The owner approved all supported current APIs, Cluster transaction batching, protected no-`exit()` terminal state, current configuration/docs/resources, exact dependency edges, and the source-proven noise-level costs. Hypervel retains pooled phpredis, coroutine workers, Watcher integration, immutable dates, direct force-style lock release, and its existing process model. Deprecated `horizon:publish` and `Horizon::night()` plus Laravel-Sentinel-only integration remain intentionally omitted at every future-sync surface. +- **Important rejected concerns:** No retry/backoff, lock renewal, signal masking, process registry, lifecycle state machine, generic Cluster or installer abstraction, HMGET corruption fallback, monitoring-lock loser delay, boolean-true balance support, browser bundle rebuild, duplicate database parser matrix, runtime phpredis/Predis guards, or exhaustive test at every batching call site was added. The Redis split requires phpredis and Horizon has no Predis transport, so upstream runtime driver guards would be dead code. A one-off `SupervisorTest::testProcessesCanBePausedAndContinued` gate failure observed `recent_jobs` as zero, but no reachable removal path exists: `StoreJob` writes synchronously, completion does not remove the reference, `TrimRecentJobs` requires the absent `MasterSupervisorLooped` event, and the isolated parent/child share one non-aliasing worker database. Repeated parallel Horizon sweeps did not reproduce it; the upstream assertion remains unchanged without a retry, longer sleep, or speculative diagnostic. +- **Implementation:** Horizon's command floor, Cluster batching, metrics, locking, batch search, terminal status, CSP/dev APIs, transient context, delayed payloads, exact local boundaries, configuration, installers, split metadata, docs, Boost resources, and frontend metadata now match the supported current surface. Queue bulk is topology-aware and delay-aware; Redis MultiExec types distinguish native clients from executed results; Telescope and Fortify installers publish atomically. The public CSP example now shares one fresh request nonce with both generated asset types and response-header directives. Duplicate cleanup, stale fields/helpers/ignores/defaults/comments/dependencies, obsolete Cluster warnings, and raw installer writes are removed. The later validation pass replaced timing guesses in the Horizon command harness with a bounded persisted-master readiness barrier, made Reverb's process pipe observation deterministic without inflating timeouts, made permission regressions capability-aware across privileged and unprivileged environments, and replaced the niceness test's shell query with native process inspection. +- **Cross-package revalidation:** `horizon-01` is complete against the final named connection, hash-tagged prefix, and Cluster behavior. `queue-22` is complete for Horizon's malformed-payload telemetry boundary and remains routed to Telescope. `support-02` is complete across Horizon's final identifiers. Queue owns `queue-40`; Redis owns `redis-23`; Filesystem owns `filesystem-13`; Telescope and Fortify own their installer publication; Reverb owns `reverb-06` for its later full audit. Queue and Redis completed-package assumptions remain green under focused and full validation. +- **Regression tests:** Coverage proves the executable worker signature and nullable result; standalone/Cluster batching and physical keys; Queue bulk delay/property behavior; ranking, one-lease clear, snapshot lock, stopwatch cleanup, and exact result types; portable MySQL search; terminal commands, signals, status, process niceness, and process reprovisioning; atomic/token-owned locks; dev/CSP APIs and coroutine isolation; consume-once context; delayed metadata; exact `"0"` boundaries; nested-config replacement; all installer success/failure/mode paths under environments that can reproduce them; split metadata and reset ownership; callback-sensitive MultiExec types; deterministic Horizon command readiness; and deterministic Reverb post-reap pipe delivery. The strict Cluster-aware pipeline return type makes native `EXEC === false` fail loudly rather than silently dropping `recent_jobs` metadata. +- **Performance and complexity:** Application request hot paths gain no network call, lock, retry, log, yield, or retained state. Asset rendering adds one coroutine-context read per asset method. Standalone batching is unchanged; Cluster replaces a fatal pipeline with its valid transaction. Lock acquisition drops from two Redis round trips to one. Metrics clear uses one lease instead of one checkout per key. Queue bulk uses the existing cached attribute reader. Terminal checks occur only in once-per-second monitor loops. Installer, metadata, docs, frontend, and test-harness work is cold. No registry, generic abstraction, compatibility shim, state machine, or hot-path synchronization was added. +- **Laravel-facing result:** Current supported Horizon commands, options, CSP/dev APIs, configuration, controllers, metrics, process behavior, locks, docs, and Boost resources are restored or preserved. Hypervel's public Queue and Horizon APIs remain Laravel-shaped; differences are limited to approved Swoole/pool/coroutine adaptations and verified upstream fixes. No useful Hypervel-specific capability was removed. +- **Validation and review:** Every changed/new focused package test and live Horizon/Redis/MySQL path passed during implementation. The authoritative `composer fix` gate passed both PHPStan configurations and the complete parallel components, Testbench package, and dogfood suites. Split metadata and npm locks validate; `git diff --check`, stale-reference, direct-pipeline, installer-write, test-type, dependency, and documentation/resource scans are clean. Fresh caller/callee, signal/process, Redis ownership, publication, API, hot-path, retained-state, and overengineering review is complete. Independent review corrected final typing, confirmed the Reverb post-reap race empirically and rejected its accompanying timeout increase, and found the Horizon command readiness race. Later pull-request review surfaced the CSP defect, CI investigation surfaced the permission-test defects, and follow-up review verified the capability probes, finding allocation, and native niceness boundary. +- **Assessment:** Every accepted Horizon and cross-package finding is fixed at its lowest owner. The result removes fatal Cluster paths, stale process work, non-atomic locks/publication, leaked transient state, false cleanup ownership, metadata drift, and timing-dependent test harnesses while preserving all useful Laravel and Hypervel behavior. It contains no workaround, speculative mechanism, meaningful hot-path regression, or unresolved accepted defect. diff --git a/docs/plans/2026-07-28-2043-horizon-cluster-lifecycle-and-current-parity.md b/docs/plans/2026-07-28-2043-horizon-cluster-lifecycle-and-current-parity.md new file mode 100644 index 000000000..820f57e7d --- /dev/null +++ b/docs/plans/2026-07-28-2043-horizon-cluster-lifecycle-and-current-parity.md @@ -0,0 +1,1228 @@ +# Complete Horizon Cluster, Process, and Current-Parity Lifecycles + +## Status + +The package audit, source research, owner gates, implementation, validation, +review follow-ups, and final audit records are complete. This document records +the resulting design. + +## Scope + +Complete Horizon as one coherent work unit, including the lowest owning Queue, +Redis, Foundation, Filesystem, Reverb, Telescope, Fortify, Boost, +configuration, metadata, and test boundaries required by verified findings. + +The final code must: + +- make `horizon:work` executable with the current Queue worker contract; +- make Horizon metadata and Queue bulk publication work on phpredis Cluster; +- preserve exact key, delay, identifier, and exit-status semantics; +- make metrics, locks, transient context, and process termination truthful; +- use checked atomic publication for every touched installer rewrite; +- add current supported Horizon APIs, configuration, docs, and Boost resources; +- retain Hypervel's pooled Redis, coroutine workers, Watcher integration, + immutable dates, and no-`exit()` process model; +- remove superseded fields, ignores, duplicate cleanup, stale warnings, + undeclared dependencies, and dead helpers; and +- add no retry, registry, state machine, compatibility layer, or hot-path + synchronization. + +## Post-compaction recovery and anti-overengineering rules + +After compaction, read `AGENTS.md` and this plan in full before resuming. Do not +reread the framework-wide audit plan; this section carries its applicable +anti-overengineering rules. + +Read only these companion-ledger entries when their completed context is +needed: `Normalize framework enum identifiers at string boundaries`, +`Make Watcher drivers and managed processes lifecycle-safe`, +`Complete Redis pooling, subscriber transport, topology, parity, and lifecycle +safety`, and `Complete Queue pooling, payload durability, and current Laravel +parity`. + +- Require a supported, realistic path and meaningful harm before treating a + concern as a defect. A merely conceivable state does not justify machinery. +- Trace the exact owner, callers, callees, commit point, cleanup, siblings, + tests, and upstream behavior before changing code. Upstream difference is not + proof of a bug, and upstream parity is not proof of correctness. +- Fix verified failures completely at the lowest inconsistent owner. Do not + compensate in callers, retain a partial fix to reduce churn, or defer a + same-family defect exposed by the change. +- Prefer an existing Laravel or Hypervel API, PHP feature, database guarantee, + Redis primitive, Filesystem boundary, or coroutine-context lifecycle. Do not + duplicate framework behavior locally. +- Add no abstraction, registry, retry, backoff, configurable timeout, state + machine, lock, context slot, cache, token system, or extension point unless a + verified requirement below cannot be completed without it or it deletes + greater complexity. +- Do not add enforcement for deliberate escape hatches or unsupported misuse. + Do not make invariants survive raw Redis access, corrupt key types, disabled + listeners, or direct process mutation unless the public contract promises it. +- For coroutine or worker-state work, name the shared state, realistic + interleaving, and harm before adding isolation or cleanup. Use + `CoroutineContext` only for real invocation-scoped handoff. +- A clearly needed capability may be added before its first use only when the + requirement and design are already understood. Do not add generic future + flexibility. +- Backward compatibility does not preserve flawed Hypervel-only internals, but + current Laravel public APIs, config structure, named arguments, and extension + conventions remain intact unless an approved Hypervel constraint requires a + difference. +- Any newly discovered Laravel-facing divergence or source-proven hot-path + regression returns to owner review before implementation. +- Account for allocations, container/config lookups, hashing, serialization, + locks, network calls, yields, retries, logging, retained memory, and cache + invalidation. Do not implement performance changes whose practical effect is + only measurement noise. +- Bound waits only when progress depends on an external owner that can + disappear. Do not add arbitrary timeouts to internally owned completion. +- Do not make source awkward or slower for PHPStan. Correct truthful types + first, then use local narrowing or a precise line ignore. Do not add a global + ignore or runtime branch for an analysis limitation. +- Test supported public behavior, meaningful failure paths, ownership, and + deterministic interleavings. Do not add production seams or exhaustive + internal call-site tests merely to make tests possible. +- Remove every superseded property, helper, import, ignore, fallback, comment, + test assertion, config key, and documentation statement in the same change. + Avoiding overengineering never permits stale code or an incomplete fix. +- If implementation exposes an unexpected defect or contradiction, stop that + edit, complete read-only investigation, obtain focused second-opinion + consensus, replace the affected plan text with the final design, then resume. + +## Architecture and references + +### Runtime ownership + +| Surface | Final owner and lifetime | +|---|---| +| Horizon Redis metadata | Named pooled `RedisProxy` connection `horizon` | +| Multi-command metadata write | Pipeline on standalone; transaction on Cluster | +| Horizon queue key | Base Queue's hash-tag-aware `getQueueRedisKey()` | +| Worker/supervisor processes | Existing master, supervisor, pool, and process owners | +| Terminal process status | Protected nullable field on the two monitor-loop owners | +| Pending signals/commands | Consumed by their owning monitor loop | +| Metrics stopwatch entry | `UpdateJobMetrics` after it consumes the timer | +| Request CSP nonce | `CoroutineContext`, set from request middleware | +| Last pushed job and listener event | Consume-once coroutine context | +| Horizon locks | Existing Redis command or Cache `RedisLock`, depending on API | +| Installer rewrite | Existing `Filesystem::replace()` atomic publication | +| Test statics | `AfterEachTestSubscriber::flushHorizonState()` | +| Redis integration resources | `InteractsWithRedis` | + +Horizon's configured prefix is hash-tagged when its Redis connection is a +Cluster. Consequently, a Cluster transaction containing Horizon metadata keys +targets one slot and remains a valid atomic batch. + +### Upstream research + +Current implementation references: + +- Laravel Horizon `2ebe3cb25ab6461b53a4e3ef42e167edeafe7932`; +- Laravel Framework `9f27fa054af628015e7ada84b0571e7b86cea03e`; +- Laravel documentation `946622229fa1d90052b7d51614a4a14a7156b9b0`; +- Laravel Boost `9f7d7b754af7df5260c6d5feb667cac86e66b945`. + +Historical commits are discovery evidence only; port the current checked-out +source and tests: + +| Change | Origin/follow-up | +|---|---| +| Horizon Redis Cluster support | Horizon `bfea968`, PR #1760 | +| Development command registration | Horizon `a1f16ca` and `47e07a2`, PRs #1786/#1789 | +| CSP nonce API | Horizon `b9d256c`, PR #1792 | +| Horizon Boost skill | Horizon `637e065`, `70e4a30`, `80c4de7`, `b2b32e3` | +| Worker option and metrics docs | Laravel docs `08c23986`, PR #11297 | +| CSP nonce docs | Laravel docs `2887d9c5`, PR #11298 | +| Axios 1.18 synchronization | Horizon `60e9d13`, PR #1796 | + +The Axios advisories affecting the old development lock target Node adapters. +The committed browser bundle contains the XHR adapter and not +`follow-redirects` or `proxy-from-env`; synchronizing package metadata and the +lock is correct, but no browser-runtime remediation or `dist` rebuild is +claimed. + +## Finding summary + +| ID | Category | Severity | Failure or gap | Final owner | +|---|---|---:|---|---| +| `horizon-02` | Defect | Critical | `horizon:work` omits an inherited option and narrows two reachable null returns | Horizon Work signature/return | +| `horizon-03` | Defect | Major | Direct pipelines and an untagged ready key fail on Redis Cluster | Horizon batching trait and queue | +| `queue-40` | Defect/parity | Major | Redis bulk enters unsupported Cluster pipeline; bulk ignores `#[Delay]` | Queue Redis/Database bulk | +| `horizon-04` | Defect | Major | Rankings, clearing, snapshot locks, and stopwatch ownership are incorrect | Metrics and Lock owners | +| `horizon-05` | Defect | Major | Batch search is invalid SQL on MySQL/MariaDB and silently returns no rows | Batches controller | +| `horizon-06` | Defect | Major | Termination status is lost and a terminated owner can resume work | Monitor-loop owners | +| `horizon-07` | Defect/upstream defect | Major | Lock acquisition is non-atomic and callback release can delete a successor | Horizon Lock | +| `horizon-08` | Current parity | Improvement | Dev command and CSP nonce APIs are absent | Horizon/provider | +| `horizon-09` | Defect | Major | Pushed-job and listener-event context leaks into later work | RedisQueue/Tags | +| `horizon-10` | Parity defect | Minor | Delayed Horizon payload omits delay metadata and gives payload hooks an unresolved queue | RedisQueue | +| `horizon-11` | Defect/type correction | Minor | Exact values and strict typed comparisons are mishandled | Local identifier/type owners | +| `horizon-12` | Config defect | Minor | Defaults drift and two supported keys are undeclared | Horizon config/callers | +| `horizon-13` | Publication defect | Major | Provider namespace rewrites can publish partial PHP | Horizon installer | +| `telescope-03` | Publication defect | Major | Telescope has the same one-file installer rewrite | Telescope installer | +| `fortify-01` | Publication defect | Major | Fortify has the same defect across six independent files | Fortify installer | +| `horizon-14` | Docs/dependency sync | Minor | Public guidance/resources and frontend lock lag current behavior | README/docs/Boost/npm | +| `horizon-15` | Metadata defect | Major | Split install omits hard runtime requirements and retains stale Carbon | Horizon manifest | +| `horizon-16` | Test/maintenance defect | Minor | Integration cleanup is duplicated; reset literals can drift | Test owners/default constants | +| `horizon-17` | Convention correction | Minor | Connector registration uses untyped container array access | Provider | +| `horizon-18` | Test lifecycle defect | Minor | The isolated Horizon command can be signaled before its handler exists | Persisted-master readiness barrier | +| `horizon-19` | Test defect | Minor | Installer read-failure tests assume mode bits always make files unreadable | Installer regressions | +| `filesystem-13` | Cross-package test defect | Minor | Filesystem write-failure guards can throw when `whoami` is absent or shell execution is disabled | Filesystem regressions | +| `horizon-20` | Documentation defect and upstream defect | Major | The CSP example reuses a static nonce and omits the matching response header | Public Horizon guide | +| `horizon-21` | Test dependency defect | Minor | The niceness regression trims nullable shell output even though required `ext-pcntl` exposes the value natively | Supervisor command regression | +| `redis-23` | Type-contract defect | Minor | MultiExec conflates callback results with no-callback clients | Redis MultiExec | +| `reverb-06` | Cross-package test lifecycle defect | Minor | A child can exit after the parent's final pipe read and lose its buffered result | Reverb process harness | + +## Approved Laravel-facing result + +The owner approved all Improvement and public/configuration gates in this +plan. Current Laravel call shapes remain compatible: + +- add `Horizon::cspNonce(string): static`, + `Horizon::registerDevCommands(): void`, and the missing worker option; +- align `Horizon\Console\WorkCommand::handle(): ?int` with its Queue parent; +- keep `Terminable::terminate(int $status = 0): void`; +- preserve upstream public supervisor properties and methods; +- remove only Hypervel's accidental public `shouldExitLoop` implementation + field, replacing it with protected terminal state; +- retain explicit force-style `Horizon\Lock::release()`; +- add only `horizon.proxy_path` and `horizon.metrics.snapshot_lock`; +- omit deprecated `horizon:publish`, deprecated `Horizon::night()`, and + Laravel-Sentinel-only integration, with required difference markers; and +- retain Hypervel's pooled, phpredis-only, coroutine-safe internals. + +## 1. Restore the Horizon worker signature (`horizon-02`) + +`Horizon\Console\WorkCommand::$signature` replaces rather than extends the +Queue command definition. The inherited `gatherWorkerOptions()` always reads +`stop-when-empty-for`; without the option Symfony throws before the worker +starts. + +Add exactly: + +```php +{--stop-when-empty-for=0 : Stop when the queue has been empty for the given number of seconds} +``` + +The wording deliberately matches Hypervel's base Queue command rather than +upstream Horizon's drifted description. Do not add Horizon-owned timer logic; +the Queue worker already implements the option. + +Also widen the override to its parent's truthful return: + +```php +public function handle(): ?int +{ + if (config('horizon.fast_termination')) { + ignore_user_abort(true); + } + + return parent::handle(); +} +``` + +The parent's `runNextJob()` and maintenance-sleep paths both return `null`; +the current `: int` override would turn either into a `TypeError` as soon as +the missing option no longer fails first. + +Tests in a new `tests/Horizon/Console/WorkCommandTest.php`: + +- use Testbench and instance-bind a `Worker` double that captures the supplied + `WorkerOptions` without connecting to a queue; +- execute the real command with `--once` and prove the options reach the + worker while its `null` result is accepted; +- prove the previous missing-option exception no longer occurs; and +- compare Queue and Horizon option names, allowing only Horizon's + `--supervisor` addition. + +The signature test is cheap drift protection; the executed command is the +load-bearing regression. + +## 2. Make Redis batching and Queue bulk Cluster-safe (`horizon-03`, `queue-40`) + +### Horizon batching + +Port `src/horizon/src/Repositories/UsesClusterAwarePipeline.php`, retaining the +upstream name and location: + +```php +protected function pipeline(callable $callback): array +{ + $connection = $this->connection(); + + // Horizon hash-tags its Cluster prefix, so the transaction remains a + // single-slot atomic batch. + // Horizon never issues WATCH, so EXEC cannot abort this transaction. + /** @var array $result */ + $result = $connection->isCluster() + ? $connection->transaction($callback) + : $connection->pipeline($callback); + + return $result; +} +``` + +Use the trait in: + +- `RedisHorizonCommandQueue`; +- `RedisJobRepository`; +- `RedisMasterSupervisorRepository`; +- `RedisProcessRepository`; +- `RedisSupervisorRepository`; and +- `RedisTagRepository`. + +Replace their 17 direct `connection()->pipeline(...)` calls with +`$this->pipeline(...)`. Do not extract another topology or batching service. + +`Horizon\RedisQueue::readyNow()` must read the actual storage key: + +```php +return $this->getConnection()->lLen($this->getQueueRedisKey($queue)); +``` + +### Queue bulk + +In `Queue\RedisQueue::bulk()`, branch once on the existing proxy topology: + +```php +$connection = $this->getConnection(); + +$callback = function () use ($jobs, $data, $queue): void { + foreach ($jobs as $job) { + $delay = is_object($job) + ? $this->getAttributeValue($job, Delay::class, 'delay') + : null; + + if ($delay !== null) { + $this->later($delay, $job, $data, $queue); + } else { + $this->push($job, $data, $queue); + } + } +}; + +if ($connection->isCluster()) { + $connection->transaction($callback); +} else { + $connection->pipeline( + fn () => $connection->transaction($callback) + ); +} +``` + +Use the same existing `getAttributeValue()` delay lookup in +`DatabaseQueue::bulk()`. Preserve a public `$job->delay` property and current +`#[Delay]` behavior; do not add reflection or metadata machinery beyond the +already-cached Queue attribute reader. + +Tests: + +- one trait test proves standalone pipeline and Cluster transaction selection; +- one representative repository path per topology proves callback results; +- existing Redis prefix/queue integration proves Cluster-tagged keys; +- exact `readyNow()` key regression; +- `QueueRedisQueueTest` covers both bulk branches and property/attribute delay; +- `QueueDatabaseQueueUnitTest` covers attribute delay. + +Do not duplicate the same branch assertion at all 17 Horizon call sites. + +## 3. Correct metrics ownership and clearing (`horizon-04`) + +### Rankings and snapshot lock + +Both maximum methods must request the latest one-element range: + +```php +$this->connection()->zRange($key, -1, -1); +``` + +Declare: + +```php +'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], + 'snapshot_lock' => 300, +], +``` + +`SnapshotCommand` passes the configured duration minus 30: + +```php +$seconds = config()->integer('horizon.metrics.snapshot_lock', 300) - 30; + +if ($lock->get('metrics:snapshot', $seconds)) { + // existing snapshot behavior +} +``` + +The Lock owner rejects a non-positive result, so invalid configuration cannot +silently produce an ineffective or permanent lock. + +### One-lease clear + +Replace proxy-per-key scanning with one raw lease: + +```php +$this->connection()->withConnection( + function (RedisConnection $connection): void { + $connection->del( + 'last_snapshot_at', + 'measured_jobs', + 'measured_queues', + 'metrics:snapshot', + ); + + foreach (['queue:*', 'job:*', 'snapshot:*'] as $pattern) { + $connection->flushByPattern($pattern); + } + }, + transform: false, +); +``` + +`flushByPattern()` already handles prefixing, scans all Cluster masters, and +batches deletion. Remove the manual cursor loop and stale imports. +Retain `RedisMetricsRepository::forget()`: the clear rewrite removes its last +internal caller, but it remains part of Laravel's public `MetricsRepository` +contract. + +### Stopwatch release and snapshot typing + +The listener that consumes a timer owns its release: + +```php +$time = $this->watch->check($id = $event->payload->id()) ?: 0; + +try { + $this->metrics->incrementQueue($event->job->getQueue(), $time); + $this->metrics->incrementJob($event->payload->displayName(), $time); +} finally { + $this->watch->forget($id); +} +``` + +`ForgetJobTimer` remains the failure-path net when this listener never runs. +It is not authoritative after a metrics failure because Queue max-exception +cache I/O can fail before `JobExceptionOccurred` is dispatched. + +Use truthful field types: + +```php +/** + * @return array{throughput: false|string, runtime: false|string} + */ +protected function baseSnapshotData(string $key): array +{ + // Horizon never issues WATCH, so EXEC cannot abort this transaction. + /** @var array{0: array{throughput: false|string, runtime: false|string}} $responses */ + $responses = $this->connection()->transaction(/* existing callback */); + + return $responses[0]; +} +``` + +Do not port upstream's fabricated null fallback. A wrong-type command member +must still fail loudly against the native array contract. + +Tests in `MetricsTest` and focused listener/command tests: + +- at least three snapshots distinguish `-1, -1` from the old empty range; +- Snapshot passes exactly 270 under the default config and when a + replace-whole application `metrics` array omits the new key; +- clear uses one lease and removes all exact/pattern families; +- Cluster-shaped clear covers all masters through the raw primitive; +- first and second increment failures both forget the timer and preserve the + increment exception; and +- missing hashes retain `false|string` fields without a fabricated record. + +## 4. Make batch search SQL portable (`horizon-05`) + +Replace the backslash escape with a portable explicit character: + +```php +$pattern = '%' . str_replace( + ['!', '%', '_'], + ['!!', '!%', '!_'], + $request->query('query') +) . '%'; + +$query->whereRaw("lower(name) like lower(?) escape '!'", [$pattern]) + ->orWhereRaw("lower(id) like lower(?) escape '!'", [$pattern]); +``` + +Preserve current case-insensitive matching, the existing exact +`before_id !== null && !== ''` boundary, and the controller's response shape. + +Coverage: + +- retain the existing SQLite controller search/wildcard/zero-cursor tests; +- add a Redis-free Testbench test under + `tests/Integration/Horizon/Database/MySql/`; +- register Horizon, configure only Queue batching, and create only + `job_batches`; +- prove plain and literal `%`/`_` search against MySQL. + +Do not inherit Horizon's Redis integration base, add Redis to database CI, or +duplicate the parser regression under MariaDB. + +## 5. Preserve process exit status and terminal ownership (`horizon-06`) + +Replace both public `shouldExitLoop` fields with: + +```php +protected ?int $exitStatus = null; +``` + +No public reader is added. This is Hypervel-internal state replacing upstream's +direct `exit()`, not a Laravel extension seam. + +At the successful terminal end of both `terminate()` methods: + +```php +$this->pendingSignals = []; +$this->exitStatus = $status; +``` + +Keep these six terminal placements: + +1. the two terminal assignments above; +2. in both `processPendingCommands()` loops, return before processing the next + command when `exitStatus` is non-null; +3. in both `loop()` methods, return immediately before the working/monitoring + block when `exitStatus` is non-null. + +The loop guard skips auto-scaling/monitoring, persistence, and loop events. +The command guard suppresses Scale, Balance, Continue, Restart, and +AddSupervisor commands already drained behind Terminate. Clearing signals +prevents queued Continue/Restart from reopening work. + +Both monitors return the recorded integer: + +```php +public function monitor(): int +{ + // existing setup + + while (true) { + sleep(1); + $this->loop(); + + if ($this->exitStatus !== null) { + return $this->exitStatus; + } + } +} +``` + +Move the master check after `loop()` to match Supervisor. Propagate the return +from `HorizonCommand` and `SupervisorCommand`; the SIGINT handler calls +`terminate()` without returning its void expression. Remove the stale ignore. + +`HorizonCommand::handle(): int` returns `self::SUCCESS` after warning that a +master is already running, preserving upstream's successful idempotent +early-exit behavior, and otherwise returns `$master->monitor()`. + +`SupervisorCommand` must likewise return the monitor status instead of +discarding it: + +```php +public function handle(SupervisorFactory $factory): int +{ + // existing duplicate-name branch still returns 13 + + return $this->start($supervisor); +} + +protected function start(Supervisor $supervisor): int +{ + // existing setup + + return $supervisor->monitor(); +} +``` + +`SupervisorWithFakeMonitor::monitor(): int` returns `0`; type its monitoring +flag and type `FakeSupervisorFactory::$supervisor`. + +Tests: + +- a `#[RunInSeparateProcess]` command-driven monitor test pushes Terminate with + a non-zero status followed by mutating commands, then proves returned status, + repository removal, no later process work/event/persist; +- the isolated `horizon` command test polls the master repository for at most + ten seconds before sending `SIGINT`, always sends the signal so failure cannot + leave the command blocked, and asserts that registration was observed; +- direct termination assertions use repository/process behavior, not protected + state; +- master and supervisor command paths propagate clean and non-zero statuses; +- `SupervisorProcess` reprovisions after memory exit `12` and restart exit `1`; +- exit `0`, `2`, and `13` remains terminal; and +- existing process-monitor tests remain green. + +Process isolation is required because a completed monitor installs +process-global signal handlers, unblocks signals, and retains the monitor +instance in closures. Recreating/restoring that entire process state inside a +shared PHPUnit worker would be more complex and less reliable. + +The four trait-managed async signals only enqueue pending work, so the terminal +loop guard prevents them from reopening the discarded owner. SIGINT is +different: `HorizonCommand` invokes `terminate()` inline. If it arrives after +the master loop's terminal guard but before `persist()`, that iteration can +republish the removed master record. The stale record expires through the +existing TTL and does not revive work; masking SIGINT or adding another guard +for this narrow residual window would be disproportionate. + +## 6. Make Horizon locks atomic and owner-safe (`horizon-07`) + +Reject invalid TTLs through one private owner used by both public acquisition +paths: + +```php +private function assertPositiveLifetime(string $key, int $seconds): void +{ + if ($seconds <= 0) { + throw new InvalidArgumentException( + "Horizon lock [{$key}] requires a positive lifetime; {$seconds} given." + ); + } +} +``` + +Then acquire positive locks atomically: + +```php +$this->assertPositiveLifetime($key, $seconds); + +return $this->connection()->set($key, '1', 'EX', $seconds, 'NX') === true; +``` + +Delegate callback ownership: + +```php +public function with(string $key, Closure $callback, int $seconds = 60): void +{ + $this->assertPositiveLifetime($key, $seconds); + + (new RedisLock($this->connection(), $key, $seconds))->get($callback); +} +``` + +This preserves Horizon's void callback API and uses Cache's owner token plus +owner-checked Lua release. Direct `release()` remains an explicit force delete; +do not add an owner registry or coroutine context. + +Tests: + +- `get()` emits one atomic command, and both acquisition APIs reject + zero/negative TTL; +- a successor B acquired after A's TTL cannot be deleted by A; +- callback failure remains primary if owned release also fails; and +- direct `release()` remains force-style. + +## 7. Port dev commands and request-scoped CSP (`horizon-08`) + +Port the current upstream method using Hypervel Foundation: + +```php +/** + * Register the Horizon development commands. + * + * Boot-only. The registrations persist for the worker lifetime and affect + * every subsequent development command invocation. + */ +public static function registerDevCommands(): void +{ + DevCommands::artisan('horizon', 'horizon'); + DevCommands::except('queue'); +} +``` + +Call it from `HorizonServiceProvider::register()` after configuration, services, +and connector registration, matching current upstream placement. + +Add `Horizon::cspNonce(string): static`, but store the rendered attribute in +`CoroutineContext`: + +```php +protected const CSP_NONCE_CONTEXT_KEY = '__horizon.csp_nonce'; + +public static function cspNonce(string $nonce): static +{ + CoroutineContext::set( + self::CSP_NONCE_CONTEXT_KEY, + ' nonce="' . $nonce . '"', + ); + + return new static; +} +``` + +The docblock must state request/middleware use. A boot-time non-coroutine value +is deliberately not inherited by request coroutines. Read the context once in +`css()` and once in `js()`, applying it to all three style tags and the script. + +Tests: + +- dev registration includes `horizon` and excludes generic `queue`; +- no nonce renders no attribute; +- CSS and JS include the exact nonce; +- replacement within one coroutine uses the latest value; and +- concurrent coroutines render only their own nonce. + +## 8. Consume transient context exactly once (`horizon-09`) + +In `RedisQueue::pushRaw()`, consume and forget before preparing: + +```php +$job = CoroutineContext::get(static::LAST_PUSHED_CONTEXT_KEY); +CoroutineContext::forget(static::LAST_PUSHED_CONTEXT_KEY); + +$payload = (new JobPayload($payload))->prepare($job); +``` + +Delete `getLastPushed()` after its last caller disappears. Keep the one-line +setter; it has a real cross-method handoff. + +In `Tags::tagsForListener()`: + +```php +$event = static::extractEvent($job); +static::setEvent($event); + +try { + return collect([static::extractListener($job), $event]) + ->map(fn ($job) => static::for($job)) + ->collapse() + ->unique() + ->toArray(); +} finally { + static::flushEventState(); +} +``` + +`flushEventState()` uses `CoroutineContext::forget()`, not a retained null +entry. + +Tests: + +- `push()` followed by direct `pushRaw()` in one coroutine does not inherit the + earlier job; +- payload preparation failure still consumes the handoff; and +- a userland `tags()` failure followed by another extraction sees no stale + event. + +No stack, static reset, registry, or shared helper is needed. + +## 9. Forward delayed payload metadata and the resolved queue (`horizon-10`) + +Match the immediate path by passing both the resolved queue and existing delay: + +```php +$payload = (new JobPayload( + $this->createPayload($job, $this->getQueue($queue), $data, $delay) +))->prepare($job)->value; +``` + +Assert the payload's delay field and prove immediate and delayed payload hooks +receive the same resolved queue. This changes only the existing JSON input; it +adds no serialization layer or Redis call. + +## 10. Correct exact and typed local boundaries (`horizon-11`) + +Apply only these source-proven corrections: + +```php +// HorizonServiceProvider +if (($name = $config->get('horizon.name')) === null || $name === '') { + $config->set('horizon.name', $config->string('app.name')); +} + +// MasterSupervisor +return $name === null || $name === '' + ? static::commandQueue() + : 'master:' . $name; + +// FailedJobsController +$tag = $request->query('tag'); +$hasTag = $tag !== null && $tag !== ''; + +// SupervisorOptions +return in_array($this->balance, ['simple', 'auto'], true); + +// SupervisorCommand +$balance = $this->option('balance') ?? 'off'; + +// AutoScaler +if ($timeToClearAll === 0.0 && $supervisor->options->autoScaling()) { + // existing branch +} + +// SupervisorProcess +if (in_array($exitCode, $this->dontRestartOn, true)) { + return; +} +``` + +`FailedJobsController` reuses the hoisted tag for both pagination and count. +Preserve empty tag as unfiltered and literal `"0"` as a valid user tag. + +Do not: + +- special-case unsupported boolean `true` balancing; +- change dashboard title/name `"0"`; +- change the intentional `starting_at = 0` first-page sentinel; or +- add a shared exact-value helper. + +Focused tests cover name/tag/queue `"0"` and empty boundaries, null balance, +the float zero branch, and nullable process exit codes. + +## 11. Make configuration single-owner and reads truthful (`horizon-12`) + +Add top-level: + +```php +'proxy_path' => '', +``` + +Add `metrics.snapshot_lock` as shown in section 3. Correct +`StoreTagsForFailedJob`'s fallback from `2880` to `10080`. + +Use typed getters without duplicated fallbacks only for shipped non-null +top-level keys: + +- `horizon.path`; +- `horizon.use`; +- `horizon.prefix`; +- `horizon.middleware`; +- `horizon.fast_termination`; +- `horizon.memory_limit`; and +- `horizon.proxy_path`. + +Retain fallbacks or nullable reads for: + +- nested `trim`, `metrics`, `waits`, `defaults`, and environment values because + application config can replace those arrays as a whole; +- nullable `domain`, `name`, `watch`, batching, and environment overrides; +- Queue connection defaults; and +- `horizon.env`, which remains an optional undeclared override rather than new + speculative config surface. + +Do not add config objects, recursive merging, validation services, or duplicate +call-site defaults. Extend the currently one-test `HorizonConfigTest` with +declared-key and replace-whole nested-config regressions; the focused +`SnapshotCommand` test owns the `snapshot_lock` call-site fallback. + +## 12. Publish installers atomically and test permissions by capability (`horizon-13`, `telescope-03`, `fortify-01`, `horizon-19`, `filesystem-13`) + +At each command, resolve the existing `Filesystem` service through +`$this->hypervel->make(Filesystem::class)`, check the current file and mode, +then replace: + +```php +if (! is_file($path)) { + // emit the command's existing owner-specific not-published error + return false; +} + +$contents = @file_get_contents($path); +$permissions = @fileperms($path); + +if ($contents === false || $permissions === false) { + // emit the command's existing owner-specific unable-to-read error + return false; +} + +try { + $filesystem->replace( + $path, + $updatedContents, + $permissions & 0777, + ); +} catch (RuntimeException) { + // emit the command's existing owner-specific update error + return false; +} +``` + +Drop the redundant `is_readable()` metadata check. The native content and mode +operations remain the checked boundaries while the distinct missing-file and +read-failure diagnostics stay intact. + +Apply to: + +- Horizon's published provider; +- Telescope's published provider; and +- each of Fortify's six independent published PHP files. + +Continue processing Fortify files only while each prior rewrite succeeds. +Provider registration remains after all rewrites and therefore cannot run +after failure. + +Do not add a shared installer abstraction, lock, backup, retry, or multi-file +transaction. Independent files can be published atomically one at a time. + +Extend Horizon's and Telescope's existing install-command tests. Add +`tests/Fortify/Console/InstallCommandTest.php`; Fortify has no current +install-command test. Each command covers success, a missing file, preserved +mode, read/mode failure, replacement failure, and no provider registration +after failure. Fortify additionally proves a later file is untouched after an +earlier failure. + +Permission regressions must test the capability they depend on. After denying +access, the three installer tests skip only when a native content read still +succeeds, while the two Filesystem adapter tests skip only when the file remains +writable. Keep restoration in `finally`, and create failure expectations only +after the capability probe succeeds. + +## 13. Complete docs, Boost resources, and frontend dependency sync (`horizon-14`, `horizon-20`) + +### Package README and intentional omissions + +Add: + +```md +Ported from: https://github.com/laravel/horizon +``` + +Under `Differences From Laravel`, record only actionable omissions: + +- deprecated `horizon:publish` is not ported; use `horizon:install`; +- Laravel Sentinel integration has no Hypervel consumer and is omitted. + +Add concise `REMOVED:` markers at their natural source and matching upstream +test insertion points. Do not list internal Cluster, pooling, date, process, or +coroutine adaptations. Do not port deprecated `Horizon::night()`. + +### Public Horizon guide + +Update `src/boost/docs/horizon.md` in its existing Laravel-style prose: + +- remove the Redis Cluster incompatibility warning after section 2 is complete; +- document middleware/request-scoped `Horizon::cspNonce()` with one fresh + `Str::random(40)` value shared by the Horizon tags and a response + `Content-Security-Policy` header covering both `script-src` and `style-src`; +- explain `metrics.trim_snapshots` as a count whose time span depends on + snapshot frequency; and +- document `memory`, `maxJobs`, `maxTime`, `sleep`, `rest`, and `nice`. + +Do not expose internal lifecycle choreography. + +### Package-owned Boost skill + +Port the current files to +`src/horizon/resources/boost/skills/configure-horizon/`: + +- `resources/boost/skills/configure-horizon/SKILL.blade.php`; +- `references/metrics.md`; +- `references/notifications.md`; +- `references/tags.md`; and +- `references/supervisors.md`. + +Keep the upstream front-matter name `configuring-horizon`, change the metadata +author and `GuidelineAssist` namespace to Hypervel, remove the unsupported Sail +clause, and adapt Laravel names, commands, config, and Cluster guidance. Keep +the final SMS API and guidance intact so the later first-party Vonage package +port does not require reversing temporary documentation. Delete only the +obsolete commented Nexmo lines from `LongWaitDetected` and +`SendNotification`; do not add a partial `vonage` implementation or a +regression that makes the temporary missing transport part of Horizon's +contract. Update the existing `docs/todo.md` entry to own the dedicated +`hypervel/vonage-notification-channel` port, current-name Horizon wiring, and +functional SMS coverage. Add no extra pages and do not duplicate the public +guide. + +### Axios + +Update `src/horizon/package.json` and lockfile from `^1.8.2`/1.8.2 to current +upstream `^1.18.0`/1.18.0. Do not independently upgrade other direct tools or +rebuild `dist`. + +Validate the lock, documentation anchors, Boost template/reference links, and +`git diff --check`. + +## 14. Correct split metadata (`horizon-15`) + +Add direct requirements to `src/horizon/composer.json`: + +```json +"ext-mbstring": "*", +"ext-redis": "^6.1", +"hypervel/routing": "^0.4", +"symfony/http-foundation": "^8.1", +"symfony/http-kernel": "^8.1" +``` + +Remove direct `nesbot/carbon`; Horizon imports Hypervel's Support date surface. +Keep `ext-redis` on Horizon rather than forcing it on every `hypervel/redis` +consumer. Horizon is Redis-only; the Redis package still has extension-free +subscriber/static surfaces. + +Extend `PackageMetadataTest` with exact presence/non-empty assertions and an +explicit Carbon absence assertion. The root aggregate already carries the new +requirements and needs no dependency command. + +## 15. Remove duplicate test cleanup and external process dependencies (`horizon-16`, `horizon-21`) + +From `tests/Integration/Horizon/IntegrationTestCase` remove: + +- its `beforeApplicationDestroyed` static resets; +- `setUpInCoroutine()` and its deferred pool flushes; and +- the outer default-pool flush in `tearDown()`. + +Retain exact Queue configuration restoration before `parent::tearDown()`. +`AfterEachTestSubscriber` already owns Horizon statics, and +`InteractsWithRedis` already owns every Redis pool. This also removes a LIFO +ordering hazard where a deferred pool flush can precede a proxy's deferred +connection release. + +In `SystemProcessCounter`, `WorkerCommandString`, and +`SupervisorCommandString`, define one protected `DEFAULT_COMMAND` and use it +for initialization and `flushState()`: + +```php +protected const DEFAULT_COMMAND = '...'; + +public static string $command = self::DEFAULT_COMMAND; + +public static function flushState(): void +{ + static::$command = self::DEFAULT_COMMAND; +} +``` + +Extend `StaticStateTest` so all three mutable commands are changed then reset. +Add `: void` to every new or touched test method. + +Assert the supervisor's process niceness with `pcntl_getpriority()` instead of +shelling out to `ps`. Horizon already requires `ext-pcntl`. + +Run the complete Horizon integration group against live Redis to prove the +authoritative cleanup owners are sufficient under real pooling. + +## 16. Use the typed container convention (`horizon-17`) + +Keep the canonical Redis service key and narrow its runtime contract locally. +PHPStan otherwise mistakes the lowercase key for phpredis's global `Redis` +class: + +```php +/** @var RedisFactory $redis */ +$redis = $this->app->make('redis'); + +return new RedisConnector($redis); +``` + +Apply the same local factory narrow to the remaining unannotated +`make('redis')` calls in `RedisServiceProvider`, +`RedisConnectionLifecycleListener`, and `ReverbServiceProvider`. Preserve each +canonical service key and existing runtime guard. Do not add a binding, inject +a new dependency, change unrelated service resolution, or add a static-analysis +ignore. + +At the two Queue connector registrations touched by this work, replace plain +container array access with `make('db')` and `make('redis')`, narrowing the +resolved locals to `ConnectionResolverInterface` and `RedisFactory` +respectively before constructing their connectors. Do not expand this into the +heterogeneous framework-wide array-access conversion tracked separately in +`docs/todo.md`. + +## 17. Correct Redis MultiExec return contracts (`redis-23`) + +In `Redis\Traits\MultiExec`, make callback-sensitive docs truthful: + +```php +/** + * @return ($callback is null ? Redis : array|false) + */ +public function pipeline(?callable $callback = null) + +/** + * @return ($callback is null ? Redis|RedisCluster : array|false) + */ +public function transaction(?callable $callback = null) + +/** + * @return ($callback is null + * ? ($command is 'pipeline' ? Redis : Redis|RedisCluster) + * : array|false) + */ +private function executeMultiExec(string $command, ?callable $callback = null) +``` + +Use a form PHPStan accepts without changing runtime code. Let analysis reveal +consumers needing element-shape narrowing. Update only the affected local +result annotations in `RedisSupervisorRepository` and +`RedisMetricsRepository`; retain `RedisMasterSupervisorRepository`'s unrelated +higher-order Collection proxy ignore. Do not add runtime guards, casts, or a +neon ignore. + +`tests/Redis/MultiExecTest` covers callback/no-callback returns for pipeline and +transaction, including callback `false`. + +## 18. Complete audit records + +After implementation and review: + +- add the Horizon work-unit block to the companion ledger with every finding, + rejected concern, test/gate result, API result, and performance result; +- record the later permission-test and CSP defects as `horizon-19`, + `filesystem-13`, and `horizon-20`, plus the native niceness correction as + `horizon-21`, without duplicating the completed Filesystem entry or recording + rejected bot suggestions; +- close the carried `horizon-01` revalidation against the final Cluster + connection and prefix behavior; +- close `queue-22` against Horizon's malformed-payload telemetry boundary and + `support-02` against Horizon's final identifier handling; +- add cross-package dependency rows for `queue-40`, `redis-23`, + `telescope-03`, `fortify-01`, and `reverb-06`; +- amend the completed Queue and Redis ledger entries with their source changes + and revalidation; +- record Horizon's intentional omission of upstream phpredis/Predis runtime + guards because the split package requires `ext-redis` and has no Predis + transport; +- route the core plan to the next package only after Horizon is complete; and +- check Horizon off only after the owner-approved bookkeeping commit. + +## Rejected concerns + +Do not implement: + +- an HMGET null/corruption fallback; +- monitoring-lock loser backoff; +- retries, renewal, signal masking, process registries, or lifecycle state + machines; +- generic Cluster batching/topology or installer abstractions; +- deprecated `horizon:publish`, deprecated `Horizon::night()`, or Sentinel + integration; +- direct `exit()`; +- support for boolean `true` balance; +- dashboard title `"0"` churn; +- `horizon.env` declaration; +- event guards without a harmful construction/dispatch path; +- a `dist` rebuild or browser-security claim for Node-only Axios adapters; +- Redis service expansion in database CI; +- duplicate MySQL/MariaDB parser tests; +- exhaustive tests for every pipeline call site; +- changes to class-string listeners, nullable Bus batch config, + `findFailed()`'s supported transformed shape, by-reference workload + accumulation, or `loadRoutesFrom()`. + +Keep the harmless terminal async-signal window documented only in this plan; it +does not warrant source comments or machinery. + +## Implementation order + +1. Restore `horizon:work` and split metadata so the package's executable floor + is valid. +2. Correct Redis MultiExec typing, then Cluster batching, Queue bulk, and delay + semantics. +3. Correct metrics, Lock, batch SQL, exact values, and config ownership. +4. Implement terminal process status and its isolated regressions. +5. Add CSP/dev APIs, context cleanup, delayed metadata, and provider convention. +6. Make all three installers atomic. +7. Remove duplicate test cleanup and reset literal drift. +8. Update README, public docs, Boost resources, npm metadata/lock, and the + records in section 18. + +Work one file at a time. Run each changed/new test file immediately after its +source slice. + +## Verification plan + +### Focused tests + +Run affected files as each slice lands, then the complete groups: + +```bash +./vendor/bin/phpunit --no-progress tests/Filesystem +./vendor/bin/phpunit --no-progress tests/Horizon +./vendor/bin/phpunit --no-progress tests/Queue +./vendor/bin/phpunit --no-progress tests/Redis +./vendor/bin/phpunit --no-progress tests/Reverb +./vendor/bin/phpunit --no-progress tests/Telescope +./vendor/bin/phpunit --no-progress tests/Fortify +``` + +Run live Horizon/Redis coverage with the configured local Redis service: + +```bash +./vendor/bin/phpunit --no-progress tests/Integration/Horizon +``` + +Run the MySQL regression with the repository's configured MySQL environment: + +```bash +DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 \ +DB_DATABASE=testing DB_USERNAME=root DB_PASSWORD=password \ +./vendor/bin/phpunit --no-progress tests/Integration/Horizon/Database/MySql +``` + +Do not add environment assumptions beyond existing integration conventions. + +### Static, metadata, and documentation checks + +- run package JSON/lock validation; +- run the exact Horizon split-metadata test; +- manually inspect Boost skill/reference links and public-doc anchors; no + repository command validates package-owned Boost resources; +- search for direct Horizon `pipeline()` owners left outside the trait; +- search for `shouldExitLoop`, stale command literals, raw installer + `file_put_contents`, old Cluster warnings, stale ignored return types, + deleted context helpers, and test-owned `trim(shell_exec(...))` calls; +- confirm every new/touched test method is `: void`; +- run `git diff --check`. + +### Authoritative gate + +Run only after focused tests are green: + +```bash +composer fix +``` + +This runs formatting, both PHPStan configurations, the complete parallel +components suite, and both Testbench suites. Do not separately rerun fixer or +PHPStan immediately before it. + +## Fresh self-review + +After the gate passes, review the entire diff without trusting this plan: + +- trace all six pipeline owners and both Queue bulk branches; +- trace one lease through metrics clear and every Lock success/failure path; +- trace terminal status from signal/command/parent loss through monitor and + command exit, including commands already drained behind Terminate; +- trace request nonce and both consume-once context values across exceptions and + concurrent coroutines; +- trace every installer read, permission read, replacement, failure report, and + provider-registration boundary; +- verify the public CSP example uses one fresh request nonce in both generated + tags and both response-header directives; +- verify all config fallbacks against top-level versus replace-whole nested + ownership; +- compare public source/docs/Boost resources to current upstream while retaining + approved Hypervel adaptations; +- inspect allocations, config/container reads, network calls, retained state, + and removed cleanup; +- verify no workaround, dead code, duplicated docs, stale comment, or + speculative mechanism remains. + +Unexpected non-trivial findings return to focused second-opinion consensus and +replace this plan's affected text before implementation continues. + +## Performance and complexity result + +No application request hot path gains network I/O, locking, retries, logging, +or retained state. Dashboard rendering adds one coroutine-context read per +asset method. Standalone Redis batching remains the same; Cluster changes a +fatal pipeline call into its valid transaction. Lock acquisition drops from two +Redis round trips to one. Metrics clear drops from one checkout per key to one +lease for the sweep. Queue bulk's delay lookup uses the existing cached +attribute reader. Termination checks run only in once-per-second process-control +loops. Installer, metadata, docs, and npm work is cold. + +The final implementation adds no registry, generic abstraction, retry queue, +state machine, compatibility shim, process-wide request state, or hot-path +synchronization. diff --git a/docs/todo.md b/docs/todo.md index 3732142ed..ffeef716a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -39,4 +39,4 @@ ## Horizon -- Wire SMS support for Hypervel Horizon long-wait notifications. The Horizon docs show `Horizon::routeSmsNotificationsTo(...)` and `Hypervel\Horizon\Horizon` stores the configured number, but `Hypervel\Horizon\Notifications\LongWaitDetected::via()` and `Hypervel\Horizon\Listeners\SendNotification` currently have the SMS / Nexmo route commented out because no SMS client is supported yet. Correct fix: add a supported SMS notification channel, route long-wait notifications to it when `Horizon::$smsNumber` is set, add the matching notification message method, document the channel prerequisite, and add coverage for mail, Slack, and SMS routing. +- Port Laravel's first-party `laravel/vonage-notification-channel` as `hypervel/vonage-notification-channel`, then wire Horizon long-wait SMS notifications through the current `vonage` channel and `VonageMessage`. Keep `Horizon::routeSmsNotificationsTo(...)`, add the package prerequisite and functional mail/Slack/SMS coverage, and do not port deprecated Nexmo aliases or fallbacks. diff --git a/src/boost/docs/horizon.md b/src/boost/docs/horizon.md index e2a36639f..29d1bb0e3 100644 --- a/src/boost/docs/horizon.md +++ b/src/boost/docs/horizon.md @@ -3,10 +3,12 @@ - [Introduction](#introduction) - [Installation](#installation) - [Configuration](#configuration) + - [Content Security Policy (CSP) Nonce](#content-security-policy-csp-nonce) - [Dashboard Authorization](#dashboard-authorization) - [Max Job Attempts](#max-job-attempts) - [Job Timeout](#job-timeout) - [Job Backoff](#job-backoff) + - [Other Worker Options](#other-worker-options) - [Silenced Jobs](#silenced-jobs) - [Balancing Strategies](#balancing-strategies) - [Concurrency](#concurrency) @@ -36,7 +38,7 @@ When using Horizon, all of your queue worker configuration is stored in a single ## Installation > [!WARNING] -> Hypervel Horizon requires that you use [Redis](https://redis.io) to power your queue. Therefore, you should ensure that your queue connection is set to `redis` in your application's `config/queue.php` configuration file. Horizon is not compatible with Redis Cluster at this time. +> Hypervel Horizon requires that you use [Redis](https://redis.io) to power your queue. Therefore, you should ensure that your queue connection is set to `redis` in your application's `config/queue.php` configuration file. You may install Horizon into your project using the Composer package manager: @@ -58,6 +60,39 @@ After publishing Horizon's assets, its primary configuration file will be locate > [!WARNING] > Horizon creates an internal Redis connection named `horizon` using the Redis connection specified by the `use` option in your `horizon.php` configuration file. The `horizon` connection name is reserved and should not be assigned to another Redis connection in your `database.php` configuration file or used as the value of the `use` option. + +#### Content Security Policy (CSP) Nonce + +If you wish to use a [nonce attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/nonce) on the script and style tags used in Horizon views as part of your [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), you may use the `Horizon::cspNonce` method to specify the nonce to use. Because the nonce is scoped to the current request, invoke this method within middleware so that a new nonce is assigned for each request: + +```php +use Closure; +use Hypervel\Horizon\Horizon; +use Hypervel\Http\Request; +use Hypervel\Support\Str; +use Symfony\Component\HttpFoundation\Response; + +public function handle(Request $request, Closure $next): Response +{ + $nonce = Str::random(40); + + Horizon::cspNonce($nonce); + + return $next($request)->withHeaders([ + 'Content-Security-Policy' => "script-src 'nonce-{$nonce}'; style-src 'nonce-{$nonce}'", + ]); +} +``` + +You may add this middleware to the `middleware` option in your application's `config/horizon.php` configuration file: + +```php +'middleware' => [ + 'web', + App\Http\Middleware\AddHorizonCspNonce::class, +], +``` + You may also customize the name displayed in the Horizon dashboard and notifications using the `name` configuration option or `HORIZON_NAME` environment variable. If no name is configured, Horizon will use your application's name. @@ -232,6 +267,38 @@ You may also configure "exponential" backoffs by using an array for the `backoff ], ``` + +### Other Worker Options + +In addition to `tries`, `timeout`, and `backoff`, each supervisor accepts several other options that control how its worker processes behave and when they are automatically restarted. Periodically restarting workers is a good practice for long-running processes, as it helps guard against memory leaks: + +```php +'environments' => [ + 'production' => [ + 'supervisor-1' => [ + // ... + 'memory' => 128, + 'maxJobs' => 1000, + 'maxTime' => 3600, + 'sleep' => 3, + 'rest' => 0, + 'nice' => 0, + ], + ], +], +``` + +
+ +- `memory` defines the maximum amount of memory, in megabytes, that a single worker process may consume before it is restarted. By default, this value is `128`. +- `maxJobs` defines the number of jobs a worker should process before restarting. A value of `0` indicates that workers should not be restarted based on the number of jobs processed. By default, this value is `0`. +- `maxTime` defines the number of seconds a worker should run before restarting. A value of `0` indicates that workers should not be restarted based on time. By default, this value is `0`. +- `sleep` defines the number of seconds a worker should wait when no job is available before polling the queue for new jobs again. By default, this value is `3`. +- `rest` defines the number of seconds to pause between processing each job. By default, this value is `0`. +- `nice` defines the "niceness" (scheduling priority) of the worker processes. A higher value gives the process a lower priority. By default, this value is `0`. + +
+ ### Silenced Jobs @@ -728,6 +795,17 @@ use Hypervel\Support\Facades\Schedule; Schedule::command('horizon:snapshot')->everyFiveMinutes(); ``` +You may configure how many snapshots Horizon retains for its metrics graphs using the `metrics.trim_snapshots` option in your application's `config/horizon.php` configuration file. Because this option limits the number of snapshots rather than their age, the retention period depends on how frequently the `horizon:snapshot` command runs: + +```php +'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], +], +``` + If you would like to delete all metric data, you can invoke the `horizon:clear-metrics` Artisan command: ```shell diff --git a/src/fortify/src/Console/InstallCommand.php b/src/fortify/src/Console/InstallCommand.php index 224f6be01..3d3c8146e 100644 --- a/src/fortify/src/Console/InstallCommand.php +++ b/src/fortify/src/Console/InstallCommand.php @@ -5,8 +5,10 @@ namespace Hypervel\Fortify\Console; use Hypervel\Console\Command; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\ServiceProvider; use Hypervel\Support\Str; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'fortify:install')] @@ -87,6 +89,8 @@ protected function registerFortifyServiceProvider(): bool */ protected function updatePublishedNamespaces(string $namespace): bool { + $filesystem = $this->hypervel->make(Filesystem::class); + $paths = [ $this->hypervel->path('Actions/Fortify/CreateNewUser.php'), $this->hypervel->path('Actions/Fortify/PasswordValidationRules.php'), @@ -97,15 +101,16 @@ protected function updatePublishedNamespaces(string $namespace): bool ]; foreach ($paths as $path) { - if (! is_file($path) || ! is_readable($path)) { - $this->components->error("Unable to read published Fortify file [{$path}]."); + if (! is_file($path)) { + $this->components->error("Fortify file [{$path}] was not published."); return false; } - $contents = file_get_contents($path); + $contents = @file_get_contents($path); + $permissions = @fileperms($path); - if ($contents === false) { + if ($contents === false || $permissions === false) { $this->components->error("Unable to read published Fortify file [{$path}]."); return false; @@ -117,7 +122,9 @@ protected function updatePublishedNamespaces(string $namespace): bool $contents, ); - if (file_put_contents($path, $contents) === false) { + try { + $filesystem->replace($path, $contents, $permissions & 0777); + } catch (RuntimeException) { $this->components->error("Unable to update published Fortify file [{$path}]."); return false; diff --git a/src/horizon/README.md b/src/horizon/README.md index c3b1919a2..c691dfc22 100644 --- a/src/horizon/README.md +++ b/src/horizon/README.md @@ -2,3 +2,10 @@ Horizon for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/horizon) + +Ported from: https://github.com/laravel/horizon + +## Differences From Laravel + +- The deprecated `horizon:publish` command is not included. Use `horizon:install` instead. +- Laravel Sentinel integration is not included because Hypervel does not provide a corresponding integration. diff --git a/src/horizon/composer.json b/src/horizon/composer.json index 885a6db6f..743123675 100644 --- a/src/horizon/composer.json +++ b/src/horizon/composer.json @@ -25,9 +25,10 @@ "require": { "php": "^8.4", "ext-json": "*", + "ext-mbstring": "*", "ext-pcntl": "*", "ext-posix": "*", - "nesbot/carbon": "^3.13.1", + "ext-redis": "^6.1", "hypervel/broadcasting": "^0.4", "hypervel/bus": "^0.4", "hypervel/cache": "^0.4", @@ -44,9 +45,12 @@ "hypervel/notifications": "^0.4", "hypervel/queue": "^0.4", "hypervel/redis": "^0.4", + "hypervel/routing": "^0.4", "hypervel/support": "^0.4", "hypervel/watcher": "^0.4", "symfony/console": "^8.1", + "symfony/http-foundation": "^8.1", + "symfony/http-kernel": "^8.1", "symfony/process": "^8.1" }, "autoload": { diff --git a/src/horizon/config/horizon.php b/src/horizon/config/horizon.php index 8a95a2345..afe7d4a4b 100644 --- a/src/horizon/config/horizon.php +++ b/src/horizon/config/horizon.php @@ -37,13 +37,16 @@ |-------------------------------------------------------------------------- | | This is the URI path where Horizon will be accessible from. Feel free - | to change this path to anything you like. Note that the URI will not - | affect the paths of its internal API that aren't exposed to users. + | to change this path to anything you like. The proxy path prefixes it + | when Horizon is served from a subdirectory behind a reverse proxy, so + | the dashboard can still reach its own internal API. | */ 'path' => env('HORIZON_PATH', 'horizon'), + 'proxy_path' => '', + /* |-------------------------------------------------------------------------- | Horizon Redis Connection @@ -145,8 +148,9 @@ |-------------------------------------------------------------------------- | | Here you can configure how many snapshots should be kept to display in - | the metrics graph. This will get used in combination with Horizon's - | `horizon:snapshot` schedule to define how long to retain metrics. + | the metrics graph. This works with the `horizon:snapshot` schedule to + | define retention. The snapshot lock prevents overlapping + | `horizon:snapshot` runs and should match their interval in seconds. | */ @@ -155,6 +159,7 @@ 'job' => 24, 'queue' => 24, ], + 'snapshot_lock' => 300, ], /* diff --git a/src/horizon/package-lock.json b/src/horizon/package-lock.json index d39045465..d0ef2fc80 100644 --- a/src/horizon/package-lock.json +++ b/src/horizon/package-lock.json @@ -7,7 +7,7 @@ "name": "hypervel-horizon", "devDependencies": { "@vitejs/plugin-vue": "^5.1.3", - "axios": "^1.8.2", + "axios": "^1.18.0", "bootstrap": "~5.1.3", "chart.js": "^2.9.4", "highlight.js": "^10.7.3", @@ -461,218 +461,361 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", - "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", - "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", - "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", - "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", - "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", - "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", - "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", - "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", - "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", - "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", - "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", - "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", - "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", - "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", - "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", - "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@vitejs/plugin-vue": { "version": "5.1.3", @@ -804,6 +947,19 @@ "dev": true, "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -827,18 +983,20 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/axios": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", - "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/binary-extensions": { @@ -980,6 +1138,7 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -1003,11 +1162,30 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -1147,9 +1325,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -1157,6 +1335,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -1167,17 +1346,17 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -1301,9 +1480,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -1322,11 +1501,26 @@ "node": "*" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/immutable": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", - "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", - "dev": true + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "dev": true, + "license": "MIT" }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -1412,6 +1606,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -1421,6 +1616,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -1449,10 +1645,17 @@ "node": "*" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -1460,6 +1663,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -1483,17 +1687,18 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -1502,9 +1707,9 @@ } }, "node_modules/postcss": { - "version": "8.4.45", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", - "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -1522,19 +1727,23 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/readdirp": { "version": "3.6.0", @@ -1549,12 +1758,13 @@ } }, "node_modules/rollup": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", - "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -1564,22 +1774,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.22.4", - "@rollup/rollup-android-arm64": "4.22.4", - "@rollup/rollup-darwin-arm64": "4.22.4", - "@rollup/rollup-darwin-x64": "4.22.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", - "@rollup/rollup-linux-arm-musleabihf": "4.22.4", - "@rollup/rollup-linux-arm64-gnu": "4.22.4", - "@rollup/rollup-linux-arm64-musl": "4.22.4", - "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", - "@rollup/rollup-linux-riscv64-gnu": "4.22.4", - "@rollup/rollup-linux-s390x-gnu": "4.22.4", - "@rollup/rollup-linux-x64-gnu": "4.22.4", - "@rollup/rollup-linux-x64-musl": "4.22.4", - "@rollup/rollup-win32-arm64-msvc": "4.22.4", - "@rollup/rollup-win32-ia32-msvc": "4.22.4", - "@rollup/rollup-win32-x64-msvc": "4.22.4", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -1601,10 +1820,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } diff --git a/src/horizon/package.json b/src/horizon/package.json index 9e3d384a3..97fdde2e0 100644 --- a/src/horizon/package.json +++ b/src/horizon/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.1.3", - "axios": "^1.8.2", + "axios": "^1.18.0", "bootstrap": "~5.1.3", "chart.js": "^2.9.4", "highlight.js": "^10.7.3", diff --git a/src/horizon/resources/boost/skills/configure-horizon/SKILL.blade.php b/src/horizon/resources/boost/skills/configure-horizon/SKILL.blade.php new file mode 100644 index 000000000..51b988c18 --- /dev/null +++ b/src/horizon/resources/boost/skills/configure-horizon/SKILL.blade.php @@ -0,0 +1,85 @@ +--- +name: configuring-horizon +description: "Use this skill whenever the user mentions Horizon by name in a Hypervel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Hypervel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching." +license: MIT +metadata: + author: hypervel +--- +@php +/** @var \Hypervel\Boost\Install\GuidelineAssist $assist */ +@endphp +# Horizon Configuration + +## Documentation + +Use `search-docs` for detailed Horizon patterns and documentation covering configuration, supervisors, balancing, dashboard authorization, tags, notifications, metrics, and deployment. + +For deeper guidance on specific topics, read the relevant reference file before implementing: + +- `references/supervisors.md` covers supervisor blocks, balancing strategies, multi-queue setups, and auto-scaling +- `references/notifications.md` covers LongWaitDetected alerts, notification routing, and the `waits` config +- `references/tags.md` covers job tagging, dashboard filtering, and silencing noisy jobs +- `references/metrics.md` covers the blank metrics dashboard, snapshot scheduling, and retention config + +## Basic Usage + +### Installation + +```bash +{{ $assist->artisanCommand('horizon:install') }} +``` + +### Supervisor Configuration + +Define supervisors in `config/horizon.php`. The `environments` array merges into `defaults` and does not replace the whole supervisor block: + +@boostsnippet("Supervisor Config", "php") +'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'minProcesses' => 1, + 'maxProcesses' => 10, + 'tries' => 3, + ], +], + +'environments' => [ + 'production' => [ + 'supervisor-1' => ['maxProcesses' => 20, 'balanceCooldown' => 3], + ], + 'local' => [ + 'supervisor-1' => ['maxProcesses' => 2], + ], +], +@endboostsnippet + +### Dashboard Authorization + +Restrict access in `App\Providers\HorizonServiceProvider`: + +@boostsnippet("Dashboard Gate", "php") +protected function gate(): void +{ + Gate::define('viewHorizon', function (User $user) { + return $user->is_admin; + }); +} +@endboostsnippet + +## Verification + +1. Run `{{ $assist->artisanCommand('horizon') }}` and visit `/horizon` +2. Confirm dashboard access is restricted as expected +3. Check that metrics populate after scheduling `horizon:snapshot` + +## Common Pitfalls + +- Horizon only works with the Redis queue driver. Other drivers such as database and SQS are not supported. +- Horizon supports standalone Redis and Redis Cluster. Keep its reserved `horizon` connection name dedicated to Horizon. +- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration. +- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it. +- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out. +- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `{{ $assist->artisanCommand('horizon') }}` alone does not populate metrics. +- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone. diff --git a/src/horizon/resources/boost/skills/configure-horizon/references/metrics.md b/src/horizon/resources/boost/skills/configure-horizon/references/metrics.md new file mode 100644 index 000000000..0aba2fa5f --- /dev/null +++ b/src/horizon/resources/boost/skills/configure-horizon/references/metrics.md @@ -0,0 +1,21 @@ +# Metrics & Snapshots + +## Where to Find It + +Search with `search-docs`: +- `"horizon metrics snapshot"` for the snapshot command and scheduling +- `"horizon trim snapshots"` for retention configuration + +## What to Watch For + +### Metrics dashboard stays blank until `horizon:snapshot` is scheduled + +Running the `horizon` Artisan command does not populate metrics automatically. The metrics graph is built from snapshots, so `horizon:snapshot` must be scheduled to run every five minutes via Hypervel's scheduler. + +### Register the snapshot in the scheduler rather than running it manually + +A single manual run populates the dashboard momentarily but will not keep it updated. Search `"horizon metrics snapshot"` for the scheduler registration syntax. + +### `metrics.trim_snapshots` is a snapshot count, not a time duration + +The `trim_snapshots.job` and `trim_snapshots.queue` values in `config/horizon.php` are counts of snapshots to keep, not minutes or hours. With the default of 24 snapshots at 5-minute intervals, that provides 2 hours of history. Increase the value to retain more history at the cost of Redis memory usage. diff --git a/src/horizon/resources/boost/skills/configure-horizon/references/notifications.md b/src/horizon/resources/boost/skills/configure-horizon/references/notifications.md new file mode 100644 index 000000000..28aa646d9 --- /dev/null +++ b/src/horizon/resources/boost/skills/configure-horizon/references/notifications.md @@ -0,0 +1,21 @@ +# Notifications & Alerts + +## Where to Find It + +Search with `search-docs`: +- `"horizon notifications"` for Horizon's built-in notification routing helpers +- `"horizon long wait detected"` for LongWaitDetected event details + +## What to Watch For + +### `waits` in `config/horizon.php` controls the LongWaitDetected threshold + +The `waits` array (e.g., `'redis:default' => 60`) defines how many seconds a job can wait in a queue before Horizon fires a `LongWaitDetected` event. This value is set in the config file, not in Horizon's notification routing. If alerts are firing too often or too late, adjust `waits` rather than the routing configuration. + +### Use Horizon's built-in notification routing in `HorizonServiceProvider` + +Configure notifications in the `boot()` method of `App\Providers\HorizonServiceProvider` using `Horizon::routeMailNotificationsTo()`, `Horizon::routeSlackNotificationsTo()`, or `Horizon::routeSmsNotificationsTo()`. Horizon already wires `LongWaitDetected` to its notification sender, so the documented setup is notification routing rather than manual listener registration. + +### Failed job alerts are separate from Horizon's documented notification routing + +Hypervel's Horizon documentation covers built-in long-wait notifications. Do not assume the docs provide a `JobFailed` listener example in `HorizonServiceProvider`. If a user needs failed job alerts, treat that as custom queue event handling and consult the queue documentation instead of Horizon's notification-routing API. diff --git a/src/horizon/resources/boost/skills/configure-horizon/references/supervisors.md b/src/horizon/resources/boost/skills/configure-horizon/references/supervisors.md new file mode 100644 index 000000000..b71285cfd --- /dev/null +++ b/src/horizon/resources/boost/skills/configure-horizon/references/supervisors.md @@ -0,0 +1,27 @@ +# Supervisor & Balancing Configuration + +## Where to Find It + +Search with `search-docs` before writing any supervisor config, as option names and defaults change between Horizon versions: +- `"horizon supervisor configuration"` for the full options list +- `"horizon balancing strategies"` for auto, simple, and false modes +- `"horizon autoscaling workers"` for autoScalingStrategy details +- `"horizon environment configuration"` for the defaults and environments merge + +## What to Watch For + +### The `environments` array merges into `defaults` rather than replacing it + +The `defaults` array defines the complete base supervisor config. The `environments` array patches it per environment, overriding only the keys listed. There is no need to repeat every key in each environment block. A common pattern is to define `connection`, `queue`, `balance`, `autoScalingStrategy`, `tries`, and `timeout` in `defaults`, then override only `maxProcesses`, `balanceMaxShift`, and `balanceCooldown` in `production`. + +### Use separate named supervisors to enforce queue priority + +Horizon does not enforce queue order when using `balance: auto` on a single supervisor. The `queue` array order is ignored for load balancing. To process `notifications` before `default`, use two separately named supervisors: one for the high-priority queue with a higher `maxProcesses`, and one for the low-priority queue with a lower cap. The docs include an explicit note about this. + +### Use `balance: false` to keep a fixed number of workers on a dedicated queue + +Auto-balancing suits variable load, but if a queue should always have exactly N workers such as a video-processing queue limited to 2, set `balance: false` and `maxProcesses: 2`. Auto-balancing would scale it up during bursts, which may be undesirable. + +### Set `balanceCooldown` to prevent rapid worker scaling under bursty load + +When using `balance: auto`, the supervisor can scale up and down rapidly under bursty load. Set `balanceCooldown` to the number of seconds between scaling decisions, typically 3 to 5, to smooth this out. `balanceMaxShift` limits how many processes are added or removed per cycle. diff --git a/src/horizon/resources/boost/skills/configure-horizon/references/tags.md b/src/horizon/resources/boost/skills/configure-horizon/references/tags.md new file mode 100644 index 000000000..b53bd7086 --- /dev/null +++ b/src/horizon/resources/boost/skills/configure-horizon/references/tags.md @@ -0,0 +1,21 @@ +# Tags & Silencing + +## Where to Find It + +Search with `search-docs`: +- `"horizon tags"` for the tagging API and auto-tagging behavior +- `"horizon silenced jobs"` for the `silenced` and `silenced_tags` config options + +## What to Watch For + +### Eloquent model jobs are tagged automatically without any extra code + +If a job's constructor accepts Eloquent model instances, Horizon automatically tags the job with `ModelClass:id` such as `App\Models\User:42`. These tags are filterable in the dashboard without any changes to the job class. Only add a `tags()` method when custom tags beyond auto-tagging are needed. + +### `silenced` hides jobs from the dashboard completed list but does not stop them from running + +Adding a job class to the `silenced` array in `config/horizon.php` removes it from the completed jobs view. The job still runs normally. This is a dashboard noise-reduction tool, not a way to disable jobs. + +### `silenced_tags` hides all jobs carrying a matching tag from the completed list + +Any job carrying a matching tag string is hidden from the completed jobs view. This is useful for silencing a category of jobs such as all jobs tagged `notifications`, rather than silencing specific classes. diff --git a/src/horizon/src/AutoScaler.php b/src/horizon/src/AutoScaler.php index 9892aee1a..77d478925 100644 --- a/src/horizon/src/AutoScaler.php +++ b/src/horizon/src/AutoScaler.php @@ -104,7 +104,7 @@ protected function numberOfWorkersPerQueue(Supervisor $supervisor, Collection $q return [$queue => $numberOfProcesses *= $supervisor->options->maxProcesses]; } - if ($timeToClearAll == 0 + if ($timeToClearAll === 0.0 && $supervisor->options->autoScaling() ) { return [ diff --git a/src/horizon/src/Console/HorizonCommand.php b/src/horizon/src/Console/HorizonCommand.php index 6d69eaa68..4e029a573 100644 --- a/src/horizon/src/Console/HorizonCommand.php +++ b/src/horizon/src/Console/HorizonCommand.php @@ -26,11 +26,12 @@ class HorizonCommand extends Command /** * Execute the console command. */ - public function handle(MasterSupervisorRepository $masters): void + public function handle(MasterSupervisorRepository $masters): int { if ($masters->find(MasterSupervisor::name())) { $this->components->warn('A master supervisor is already running on this machine.'); - return; + + return self::SUCCESS; } $environment = $this->option('environment') ?? config('horizon.env') ?? config('app.env'); @@ -50,9 +51,9 @@ public function handle(MasterSupervisorRepository $masters): void $this->components->info('Shutting down.'); - return $master->terminate(); // @phpstan-ignore method.void + $master->terminate(); }); - $master->monitor(); + return $master->monitor(); } } diff --git a/src/horizon/src/Console/InstallCommand.php b/src/horizon/src/Console/InstallCommand.php index c6961bac9..1abb614d7 100644 --- a/src/horizon/src/Console/InstallCommand.php +++ b/src/horizon/src/Console/InstallCommand.php @@ -5,8 +5,10 @@ namespace Hypervel\Horizon\Console; use Hypervel\Console\Command; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\ServiceProvider; use Hypervel\Support\Str; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'horizon:install')] @@ -69,25 +71,32 @@ protected function registerHorizonServiceProvider(): bool $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); $providerPath = $this->hypervel->path('Providers/HorizonServiceProvider.php'); - if (! is_file($providerPath) || ! is_readable($providerPath)) { + if (! is_file($providerPath)) { $this->components->error('HorizonServiceProvider file was not published.'); return false; } - $contents = file_get_contents($providerPath); + $contents = @file_get_contents($providerPath); + $permissions = @fileperms($providerPath); - if ($contents === false) { + if ($contents === false || $permissions === false) { $this->components->error('Unable to read the HorizonServiceProvider file.'); return false; } - if (file_put_contents($providerPath, str_replace( - 'namespace App\Providers;', - "namespace {$namespace}\\Providers;", - $contents, - )) === false) { + try { + $this->hypervel->make(Filesystem::class)->replace( + $providerPath, + str_replace( + 'namespace App\Providers;', + "namespace {$namespace}\\Providers;", + $contents, + ), + $permissions & 0777, + ); + } catch (RuntimeException) { $this->components->error('Unable to update the HorizonServiceProvider namespace.'); return false; diff --git a/src/horizon/src/Console/SnapshotCommand.php b/src/horizon/src/Console/SnapshotCommand.php index b17b1e376..76f97050e 100644 --- a/src/horizon/src/Console/SnapshotCommand.php +++ b/src/horizon/src/Console/SnapshotCommand.php @@ -27,7 +27,9 @@ class SnapshotCommand extends Command */ public function handle(Lock $lock, MetricsRepository $metrics): void { - if ($lock->get('metrics:snapshot', config('horizon.metrics.snapshot_lock', 300))) { + $seconds = config()->integer('horizon.metrics.snapshot_lock', 300) - 30; + + if ($lock->get('metrics:snapshot', $seconds)) { $metrics->snapshot(); $this->components->info('Metrics snapshot stored successfully.'); diff --git a/src/horizon/src/Console/SupervisorCommand.php b/src/horizon/src/Console/SupervisorCommand.php index e1fa18c5c..d6bad0c12 100644 --- a/src/horizon/src/Console/SupervisorCommand.php +++ b/src/horizon/src/Console/SupervisorCommand.php @@ -55,7 +55,7 @@ class SupervisorCommand extends Command /** * Execute the console command. */ - public function handle(SupervisorFactory $factory): ?int + public function handle(SupervisorFactory $factory): int { $supervisor = $factory->make( $this->supervisorOptions() @@ -69,15 +69,13 @@ public function handle(SupervisorFactory $factory): ?int return 13; } - $this->start($supervisor); - - return 0; + return $this->start($supervisor); } /** * Start the given supervisor. */ - protected function start(Supervisor $supervisor): void + protected function start(Supervisor $supervisor): int { if ($supervisor->options->nice) { proc_nice($supervisor->options->nice); @@ -97,7 +95,7 @@ protected function start(Supervisor $supervisor): void $balancedWorkerCount - $supervisor->totalSystemProcessCount() )); - $supervisor->monitor(); + return $supervisor->monitor(); } /** @@ -105,7 +103,7 @@ protected function start(Supervisor $supervisor): void */ protected function supervisorOptions(): SupervisorOptions { - $balance = $this->option('balance'); + $balance = $this->option('balance') ?? 'off'; $autoScalingStrategy = $balance === 'auto' ? $this->option('auto-scaling-strategy') : null; diff --git a/src/horizon/src/Console/TerminateCommand.php b/src/horizon/src/Console/TerminateCommand.php index 386a98655..2023bf3e4 100644 --- a/src/horizon/src/Console/TerminateCommand.php +++ b/src/horizon/src/Console/TerminateCommand.php @@ -35,9 +35,8 @@ class TerminateCommand extends Command */ public function handle(CacheFactory $cache, MasterSupervisorRepository $masters): void { - if (config('horizon.fast_termination')) { - /* @phpstan-ignore-next-line */ - $cache->forever( + if (config()->boolean('horizon.fast_termination')) { + $cache->store()->forever( 'horizon:terminate:wait', $this->option('wait') ); @@ -62,7 +61,6 @@ public function handle(CacheFactory $cache, MasterSupervisorRepository $masters) } })->whenNotEmpty(fn () => $this->output->writeln('')); - $this->hypervel->make(CacheFactory::class) - ->store()->forever(Worker::RESTART_SIGNAL_CACHE_KEY, $this->currentTime()); + $cache->store()->forever(Worker::RESTART_SIGNAL_CACHE_KEY, $this->currentTime()); } } diff --git a/src/horizon/src/Console/WorkCommand.php b/src/horizon/src/Console/WorkCommand.php index e88bd06e6..27925e42a 100644 --- a/src/horizon/src/Console/WorkCommand.php +++ b/src/horizon/src/Console/WorkCommand.php @@ -19,6 +19,7 @@ class WorkCommand extends BaseWorkCommand {--once : Only process the next job on the queue} {--concurrency=1 : The number of jobs to process at once} {--stop-when-empty : Stop when the queue is empty} + {--stop-when-empty-for=0 : Stop when the queue has been empty for the given number of seconds} {--delay=0 : The number of seconds to delay failed jobs (Deprecated)} {--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception} {--max-jobs=0 : The number of jobs to process before stopping} @@ -41,9 +42,9 @@ class WorkCommand extends BaseWorkCommand /** * Execute the console command. */ - public function handle(): int + public function handle(): ?int { - if (config('horizon.fast_termination')) { + if (config()->boolean('horizon.fast_termination')) { ignore_user_abort(true); } diff --git a/src/horizon/src/Horizon.php b/src/horizon/src/Horizon.php index 6d3ce3625..acd2bce00 100644 --- a/src/horizon/src/Horizon.php +++ b/src/horizon/src/Horizon.php @@ -6,6 +6,8 @@ use Closure; use Exception; +use Hypervel\Context\CoroutineContext; +use Hypervel\Foundation\DevCommands; use Hypervel\Http\Request; use Hypervel\Redis\RedisConnection; use Hypervel\Support\HtmlString; @@ -47,6 +49,11 @@ class Horizon 'Metrics', 'Locks', 'Processes', ]; + /** + * The context key for Horizon's CSP nonce attribute. + */ + protected const CSP_NONCE_CONTEXT_KEY = '__horizon.csp_nonce'; + /** * Determine if the given request can access the Horizon dashboard. */ @@ -121,10 +128,12 @@ public static function css(): HtmlString throw new RuntimeException('Unable to load the Horizon dashboard CSS.'); } + $nonceAttribute = CoroutineContext::get(self::CSP_NONCE_CONTEXT_KEY, ''); + return new HtmlString(<<{$light} - - + + + {$app} HTML); } @@ -139,22 +148,26 @@ public static function js(): HtmlString $horizon = Js::from(static::scriptVariables()); + $nonceAttribute = CoroutineContext::get(self::CSP_NONCE_CONTEXT_KEY, ''); + return new HtmlString(<< + HTML); } + // REMOVED: Deprecated Horizon::night() theme mutator. + /** * Get the default JavaScript variables for Horizon. */ public static function scriptVariables(): array { return [ - 'path' => config('horizon.path'), - 'proxy_path' => config('horizon.proxy_path', ''), + 'path' => config()->string('horizon.path'), + 'proxy_path' => config()->string('horizon.proxy_path'), ]; } @@ -198,6 +211,34 @@ public static function routeSmsNotificationsTo(string $number): static return new static; } + /** + * Set the CSP nonce to use for style and script tags. + * + * Call this from request middleware so the nonce is isolated to the + * current request coroutine. + */ + public static function cspNonce(string $nonce): static + { + CoroutineContext::set( + self::CSP_NONCE_CONTEXT_KEY, + ' nonce="' . $nonce . '"', + ); + + return new static; + } + + /** + * Register the Horizon development commands. + * + * Boot-only. The registrations persist for the worker lifetime and affect + * every subsequent development command invocation. + */ + public static function registerDevCommands(): void + { + DevCommands::artisan('horizon', 'horizon'); + DevCommands::except('queue'); + } + /** * Flush all static state. */ diff --git a/src/horizon/src/HorizonServiceProvider.php b/src/horizon/src/HorizonServiceProvider.php index f8e23ac40..f1dd574d5 100644 --- a/src/horizon/src/HorizonServiceProvider.php +++ b/src/horizon/src/HorizonServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Horizon; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Horizon\Connectors\RedisConnector; use Hypervel\Queue\QueueManager; use Hypervel\Support\Facades\Route; @@ -20,6 +21,7 @@ class HorizonServiceProvider extends ServiceProvider */ public function boot(): void { + // REMOVED: Laravel Sentinel middleware has no Hypervel integration. $this->normalizeConfig(); $this->registerEvents(); $this->registerRoutes(); @@ -35,7 +37,7 @@ protected function normalizeConfig(): void { $config = $this->app->make('config'); - if (! $config->get('horizon.name')) { + if (($name = $config->get('horizon.name')) === null || $name === '') { $config->set('horizon.name', $config->string('app.name')); } } @@ -59,11 +61,13 @@ protected function registerEvents(): void */ protected function registerRoutes(): void { + $config = $this->app->make('config'); + Route::group([ - 'domain' => config('horizon.domain', null), - 'prefix' => config('horizon.path'), + 'domain' => $config->get('horizon.domain'), + 'prefix' => $config->string('horizon.path'), 'namespace' => 'Hypervel\Horizon\Http\Controllers', - 'middleware' => config('horizon.middleware', ['web']), + 'middleware' => $config->array('horizon.middleware'), ], function () { $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); }); @@ -111,6 +115,7 @@ protected function registerCommands(): void Console\ListenCommand::class, Console\PauseCommand::class, Console\PauseSupervisorCommand::class, + // REMOVED: Deprecated horizon:publish; use horizon:install. Console\PurgeCommand::class, Console\SupervisorCommand::class, Console\SupervisorStatusCommand::class, @@ -141,6 +146,8 @@ public function register(): void $this->configure(); $this->registerServices(); $this->registerQueueConnectors(); + + Horizon::registerDevCommands(); } /** @@ -153,7 +160,7 @@ protected function configure(): void 'horizon' ); - Horizon::use(config('horizon.use', 'default')); + Horizon::use(config()->string('horizon.use')); } /** @@ -173,9 +180,10 @@ protected function registerQueueConnectors(): void { $this->callAfterResolving(QueueManager::class, function (QueueManager $manager) { $manager->addConnector('redis', function () { - return new RedisConnector( - $this->app['redis'] - ); + /** @var RedisFactory $redis */ + $redis = $this->app->make('redis'); + + return new RedisConnector($redis); }); }); } diff --git a/src/horizon/src/Http/Controllers/BatchesController.php b/src/horizon/src/Http/Controllers/BatchesController.php index 172f114dd..8eb328b95 100644 --- a/src/horizon/src/Http/Controllers/BatchesController.php +++ b/src/horizon/src/Http/Controllers/BatchesController.php @@ -48,15 +48,19 @@ public function index(Request $request): array */ private function searchBatches(Request $request): array { - $pattern = '%' . addcslashes($request->query('query'), '\%_') . '%'; + $pattern = '%' . str_replace( + ['!', '%', '_'], + ['!!', '!%', '!_'], + $request->query('query') + ) . '%'; $beforeId = $request->query('before_id'); return DB::connection(Config::get('queue.batching.database')) ->table(Config::string('queue.batching.table')) ->where(function ($q) use ($pattern) { - $q->whereRaw("lower(name) like lower(?) escape '\\'", [$pattern]) - ->orWhereRaw("lower(id) like lower(?) escape '\\'", [$pattern]); + $q->whereRaw("lower(name) like lower(?) escape '!'", [$pattern]) + ->orWhereRaw("lower(id) like lower(?) escape '!'", [$pattern]); }) ->orderByDesc('id') ->limit(50) diff --git a/src/horizon/src/Http/Controllers/FailedJobsController.php b/src/horizon/src/Http/Controllers/FailedJobsController.php index 2c2125b5a..5ab3d47f0 100644 --- a/src/horizon/src/Http/Controllers/FailedJobsController.php +++ b/src/horizon/src/Http/Controllers/FailedJobsController.php @@ -26,12 +26,15 @@ public function __construct( */ public function index(Request $request): array { - $jobs = ! $request->query('tag') + $tag = $request->query('tag'); + $hasTag = $tag !== null && $tag !== ''; + + $jobs = ! $hasTag ? $this->paginate($request) - : $this->paginateByTag($request, $request->query('tag')); + : $this->paginateByTag($request, $tag); - $total = $request->query('tag') - ? $this->tags->count('failed:' . $request->query('tag')) + $total = $hasTag + ? $this->tags->count('failed:' . $tag) : $this->jobs->countFailed(); return [ @@ -43,7 +46,7 @@ public function index(Request $request): array /** * Paginate the failed jobs for the request. */ - protected function paginate(Request $request) + protected function paginate(Request $request): Collection { $startingAt = $request->query('starting_at') ?: -1; @@ -73,7 +76,7 @@ protected function paginateByTag(Request $request, string $tag): Collection /** * Get a failed job instance. */ - public function show(string $id): mixed + public function show(string $id): array { return (array) $this->jobs->getJobs([$id])->map(function ($job) { return $this->decode($job); diff --git a/src/horizon/src/Listeners/MonitorMasterSupervisorMemory.php b/src/horizon/src/Listeners/MonitorMasterSupervisorMemory.php index d7cbc8e87..9b3144454 100644 --- a/src/horizon/src/Listeners/MonitorMasterSupervisorMemory.php +++ b/src/horizon/src/Listeners/MonitorMasterSupervisorMemory.php @@ -21,7 +21,7 @@ public function handle(MasterSupervisorLooped $event): void $master = $event->master; - $memoryLimit = config('horizon.memory_limit', 64); + $memoryLimit = config()->integer('horizon.memory_limit'); if ($master->memoryUsage() > $memoryLimit) { event(new MasterSupervisorOutOfMemory($master)); diff --git a/src/horizon/src/Listeners/SendNotification.php b/src/horizon/src/Listeners/SendNotification.php index ccb7bf2f7..35df52e33 100644 --- a/src/horizon/src/Listeners/SendNotification.php +++ b/src/horizon/src/Listeners/SendNotification.php @@ -23,8 +23,6 @@ public function handle(LongWaitDetected $event): void } Notification::route('slack', Horizon::$slackWebhookUrl) - // no sms client supported yet - // ->route('nexmo', Horizon::$smsNumber) ->route('mail', Horizon::$email) ->notify($notification); } diff --git a/src/horizon/src/Listeners/StoreTagsForFailedJob.php b/src/horizon/src/Listeners/StoreTagsForFailedJob.php index 2702cb054..d5208899c 100644 --- a/src/horizon/src/Listeners/StoreTagsForFailedJob.php +++ b/src/horizon/src/Listeners/StoreTagsForFailedJob.php @@ -29,7 +29,7 @@ public function handle(JobFailed $event): void })->all(); $this->tags->addTemporary( - config('horizon.trim.failed', 2880), + config('horizon.trim.failed', 10080), $event->payload->id(), $tags ); diff --git a/src/horizon/src/Listeners/UpdateJobMetrics.php b/src/horizon/src/Listeners/UpdateJobMetrics.php index b378719eb..33994cb14 100644 --- a/src/horizon/src/Listeners/UpdateJobMetrics.php +++ b/src/horizon/src/Listeners/UpdateJobMetrics.php @@ -33,16 +33,18 @@ public function handle(JobDeleted $event): void $time = $this->watch->check($id = $event->payload->id()) ?: 0; - $this->metrics->incrementQueue( - $event->job->getQueue(), - $time - ); - - $this->metrics->incrementJob( - $event->payload->displayName(), - $time - ); - - $this->watch->forget($id); + try { + $this->metrics->incrementQueue( + $event->job->getQueue(), + $time + ); + + $this->metrics->incrementJob( + $event->payload->displayName(), + $time + ); + } finally { + $this->watch->forget($id); + } } } diff --git a/src/horizon/src/Lock.php b/src/horizon/src/Lock.php index 261e95022..e5c831e16 100644 --- a/src/horizon/src/Lock.php +++ b/src/horizon/src/Lock.php @@ -5,8 +5,10 @@ namespace Hypervel\Horizon; use Closure; +use Hypervel\Cache\RedisLock; use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Redis\RedisProxy; +use InvalidArgumentException; class Lock { @@ -25,13 +27,9 @@ public function __construct( */ public function with(string $key, Closure $callback, int $seconds = 60): void { - if ($this->get($key, $seconds)) { - try { - call_user_func($callback); - } finally { - $this->release($key); - } - } + $this->assertPositiveLifetime($key, $seconds); + + (new RedisLock($this->connection(), $key, $seconds))->get($callback); } /** @@ -47,13 +45,9 @@ public function exists(string $key): bool */ public function get(string $key, int $seconds = 60): bool { - $result = $this->connection()->setNx($key, '1') === 1; + $this->assertPositiveLifetime($key, $seconds); - if ($result) { - $this->connection()->expire($key, $seconds); - } - - return $result; + return $this->connection()->set($key, '1', 'EX', $seconds, 'NX') === true; } /** @@ -64,6 +58,18 @@ public function release(string $key): void $this->connection()->del($key); } + /** + * Ensure the lock lifetime is positive. + */ + private function assertPositiveLifetime(string $key, int $seconds): void + { + if ($seconds <= 0) { + throw new InvalidArgumentException( + "Horizon lock [{$key}] requires a positive lifetime; {$seconds} given." + ); + } + } + /** * Get the Redis connection instance. */ diff --git a/src/horizon/src/MasterSupervisor.php b/src/horizon/src/MasterSupervisor.php index 5cba8324d..5f67803ea 100644 --- a/src/horizon/src/MasterSupervisor.php +++ b/src/horizon/src/MasterSupervisor.php @@ -56,7 +56,10 @@ class MasterSupervisor implements Pausable, Restartable, Terminable */ protected static ?string $token = null; - public bool $shouldExitLoop = false; + /** + * The terminal exit status for this master supervisor. + */ + protected ?int $exitStatus = null; /** * Create a new master supervisor instance. @@ -176,18 +179,18 @@ public function terminate(int $status = 0): void sleep(1); } - if (config('horizon.fast_termination')) { - /* @phpstan-ignore-next-line */ - app(CacheFactory::class)->forget('horizon:terminate:wait'); + if (config()->boolean('horizon.fast_termination')) { + app(CacheFactory::class)->store()->forget('horizon:terminate:wait'); } - $this->shouldExitLoop = true; + $this->pendingSignals = []; + $this->exitStatus = $status; } /** * Monitor the worker processes. */ - public function monitor(): void + public function monitor(): int { $this->ensureNoOtherMasterSupervisors(); @@ -198,11 +201,11 @@ public function monitor(): void while (true) { sleep(1); - if ($this->shouldExitLoop) { - break; - } - $this->loop(); + + if ($this->exitStatus !== null) { + return $this->exitStatus; + } } } @@ -228,6 +231,10 @@ public function loop(): void $this->processPendingCommands(); + if ($this->exitStatus !== null) { + return; + } + if ($this->working) { $this->monitorSupervisors(); } @@ -246,6 +253,10 @@ public function loop(): void protected function processPendingCommands(): void { foreach (app(HorizonCommandQueue::class)->pending($this->commandQueue()) as $command) { + if ($this->exitStatus !== null) { + return; + } + app($command->command)->process($this, $command->options); } } @@ -299,7 +310,9 @@ public static function commandQueue(): string */ public static function commandQueueFor(?string $name = null): string { - return $name ? 'master:' . $name : static::commandQueue(); + return $name === null || $name === '' + ? static::commandQueue() + : 'master:' . $name; } /** diff --git a/src/horizon/src/Notifications/LongWaitDetected.php b/src/horizon/src/Notifications/LongWaitDetected.php index 02dec6a37..8e3d5a996 100644 --- a/src/horizon/src/Notifications/LongWaitDetected.php +++ b/src/horizon/src/Notifications/LongWaitDetected.php @@ -39,8 +39,6 @@ public function via(mixed $notifiable): array { return array_filter([ Horizon::$slackWebhookUrl ? 'slack' : null, - // no sms client supported yet - // Horizon::$smsNumber ? 'nexmo' : null, Horizon::$email ? 'mail' : null, ]); } @@ -79,9 +77,8 @@ public function toSlack(mixed $notifiable): ChannelIdSlackMessage|SlackMessage $this->seconds ); - if (class_exists('\Hypervel\Notifications\Messages\SlackMessage') - && class_exists('\Hypervel\Notifications\Slack\BlockKit\Blocks\SectionBlock') - && ! (is_string(Horizon::$slackWebhookUrl) && Str::startsWith(Horizon::$slackWebhookUrl, ['http://', 'https://'])) + if (! (is_string(Horizon::$slackWebhookUrl) + && Str::startsWith(Horizon::$slackWebhookUrl, ['http://', 'https://'])) ) { return (new ChannelIdSlackMessage) ->username($fromName) diff --git a/src/horizon/src/ProcessPool.php b/src/horizon/src/ProcessPool.php index 192bdc7cb..152d27838 100644 --- a/src/horizon/src/ProcessPool.php +++ b/src/horizon/src/ProcessPool.php @@ -145,7 +145,7 @@ protected function start(): static */ protected function createProcess(): WorkerProcess { - $class = config('horizon.fast_termination') + $class = config()->boolean('horizon.fast_termination') ? BackgroundProcess::class : Process::class; diff --git a/src/horizon/src/RedisHorizonCommandQueue.php b/src/horizon/src/RedisHorizonCommandQueue.php index 1f9542d74..37b3bab72 100644 --- a/src/horizon/src/RedisHorizonCommandQueue.php +++ b/src/horizon/src/RedisHorizonCommandQueue.php @@ -6,10 +6,13 @@ use Hypervel\Contracts\Redis\Factory as Redis; use Hypervel\Horizon\Contracts\HorizonCommandQueue; +use Hypervel\Horizon\Repositories\UsesClusterAwarePipeline; use Hypervel\Redis\RedisProxy; class RedisHorizonCommandQueue implements HorizonCommandQueue { + use UsesClusterAwarePipeline; + /** * Create a new command queue instance. */ @@ -41,7 +44,7 @@ public function pending(string $name): array return []; } - $results = $this->connection()->pipeline(function ($pipe) use ($name, $length) { + $results = $this->pipeline(function ($pipe) use ($name, $length) { $pipe->lRange('commands:' . $name, 0, $length - 1); $pipe->lTrim('commands:' . $name, $length, -1); diff --git a/src/horizon/src/RedisQueue.php b/src/horizon/src/RedisQueue.php index a6be881d6..0a7509738 100644 --- a/src/horizon/src/RedisQueue.php +++ b/src/horizon/src/RedisQueue.php @@ -30,7 +30,7 @@ class RedisQueue extends BaseQueue */ public function readyNow(?string $queue = null): int { - return $this->getConnection()->lLen($this->getQueue($queue)); + return $this->getConnection()->lLen($this->getQueueRedisKey($queue)); } /** @@ -59,7 +59,10 @@ static function (BaseQueue $owner, $payload, $queue) use ($job) { #[Override] public function pushRaw(string $payload, ?string $queue = null, array $options = []): mixed { - $payload = (new JobPayload($payload))->prepare($this->getLastPushed()); + $job = CoroutineContext::get(static::LAST_PUSHED_CONTEXT_KEY); + CoroutineContext::forget(static::LAST_PUSHED_CONTEXT_KEY); + + $payload = (new JobPayload($payload))->prepare($job); $this->event($this->getQueue($queue), new JobPending($payload->value)); @@ -89,7 +92,9 @@ protected function createPayloadArray(array|object|string $job, ?string $queue, #[Override] public function later(DateInterval|DateTimeInterface|int $delay, object|string $job, mixed $data = '', ?string $queue = null): mixed { - $payload = (new JobPayload($this->createPayload($job, $queue, $data)))->prepare($job)->value; + $payload = (new JobPayload( + $this->createPayload($job, $this->getQueue($queue), $data, $delay) + ))->prepare($job)->value; return $this->enqueueUsing( $job, @@ -194,12 +199,4 @@ protected function setLastPushed(object|string $job): void { CoroutineContext::set(static::LAST_PUSHED_CONTEXT_KEY, $job); } - - /** - * Get the job that last pushed to queue via the "push" method. - */ - protected function getLastPushed(): object|string|null - { - return CoroutineContext::get(static::LAST_PUSHED_CONTEXT_KEY); - } } diff --git a/src/horizon/src/Repositories/RedisJobRepository.php b/src/horizon/src/Repositories/RedisJobRepository.php index 947c4606a..ca0a71c2e 100644 --- a/src/horizon/src/Repositories/RedisJobRepository.php +++ b/src/horizon/src/Repositories/RedisJobRepository.php @@ -17,6 +17,8 @@ class RedisJobRepository implements JobRepository { + use UsesClusterAwarePipeline; + /** * The keys stored on the job hashes. */ @@ -230,7 +232,7 @@ protected function minutesForType(string $type): int */ public function getJobs(array $ids, mixed $indexFrom = 0): Collection { - $jobs = $this->connection()->pipeline(function ($pipe) use ($ids) { + $jobs = $this->pipeline(function ($pipe) use ($ids) { foreach ($ids as $id) { $pipe->hmget($id, $this->keys); } @@ -264,7 +266,7 @@ protected function indexJobs(Collection $jobs, int $indexFrom): Collection */ public function pushed(string $connection, string $queue, JobPayload $payload): void { - $this->connection()->pipeline(function ($pipe) use ($connection, $queue, $payload) { + $this->pipeline(function ($pipe) use ($connection, $queue, $payload) { $this->storeJobReference($pipe, 'recent_jobs', $payload); $this->storeJobReference($pipe, 'pending_jobs', $payload); @@ -327,7 +329,7 @@ public function released(string $connection, string $queue, JobPayload $payload, */ public function remember(string $connection, string $queue, JobPayload $payload): void { - $this->connection()->pipeline(function ($pipe) use ($connection, $queue, $payload) { + $this->pipeline(function ($pipe) use ($connection, $queue, $payload) { $this->storeJobReference($pipe, 'monitored_jobs', $payload); $pipe->hmset( @@ -355,7 +357,7 @@ public function remember(string $connection, string $queue, JobPayload $payload) */ public function migrated(string $connection, string $queue, Collection $payloads): void { - $this->connection()->pipeline(function ($pipe) use ($payloads) { + $this->pipeline(function ($pipe) use ($payloads) { foreach ($payloads as $payload) { $pipe->hmset( $payload->id(), @@ -379,7 +381,7 @@ public function completed(JobPayload $payload, bool $failed = false, bool $silen $this->updateRetryInformationOnParent($payload, $failed); } - $this->connection()->pipeline(function ($pipe) use ($payload, $silenced) { + $this->pipeline(function ($pipe) use ($payload, $silenced) { $this->storeJobReference($pipe, $silenced ? 'silenced_jobs' : 'completed_jobs', $payload); $this->removeJobReference($pipe, 'pending_jobs', $payload); @@ -432,7 +434,7 @@ protected function updateRetryStatus(JobPayload $payload, array $retries, bool $ */ public function deleteMonitored(array $ids): void { - $this->connection()->pipeline(function ($pipe) use ($ids) { + $this->pipeline(function ($pipe) use ($ids) { foreach ($ids as $id) { $pipe->expireat($id, CarbonImmutable::now()->addDays(7)->getTimestamp()); } @@ -444,7 +446,7 @@ public function deleteMonitored(array $ids): void */ public function trimRecentJobs(): void { - $this->connection()->pipeline(function ($pipe) { + $this->pipeline(function ($pipe) { $pipe->zRemRangeByScore( 'recent_jobs', (string) (CarbonImmutable::now()->subMinutes($this->recentJobExpires)->getTimestamp() * -1), @@ -527,7 +529,7 @@ public function findFailed(string $id): ?stdClass */ public function failed(Throwable $exception, string $connection, string $queue, JobPayload $payload): void { - $this->connection()->pipeline(function ($pipe) use ($exception, $connection, $queue, $payload) { + $this->pipeline(function ($pipe) use ($exception, $connection, $queue, $payload) { $this->storeJobReference($pipe, 'failed_jobs', $payload); $this->storeJobReference($pipe, 'recent_failed_jobs', $payload); $this->removeJobReference($pipe, 'pending_jobs', $payload); @@ -615,7 +617,7 @@ public function purge(string $queue): int 2, 'recent_jobs', 'pending_jobs', - config('horizon.prefix'), + config()->string('horizon.prefix'), $queue, $cursor, ); diff --git a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php index 122adde9a..23afacdbc 100644 --- a/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisMasterSupervisorRepository.php @@ -15,6 +15,8 @@ class RedisMasterSupervisorRepository implements MasterSupervisorRepository { + use UsesClusterAwarePipeline; + /** * Create a new repository instance. */ @@ -56,7 +58,7 @@ public function find(string $name): ?stdClass */ public function get(array $names): array { - $records = $this->connection()->pipeline(function ($pipe) use ($names) { + $records = $this->pipeline(function ($pipe) use ($names) { foreach ($names as $name) { $pipe->hmget('master:' . $name, ['name', 'pid', 'status', 'supervisors', 'environment']); } @@ -77,7 +79,7 @@ public function update(MasterSupervisor $master): void /** @phpstan-ignore-next-line */ $supervisors = $master->supervisors->map->name->all(); - $this->connection()->pipeline(function ($pipe) use ($master, $supervisors) { + $this->pipeline(function ($pipe) use ($master, $supervisors) { $pipe->hmset( 'master:' . $master->name, [ diff --git a/src/horizon/src/Repositories/RedisMetricsRepository.php b/src/horizon/src/Repositories/RedisMetricsRepository.php index 36c8b02a6..f0c9cfc83 100644 --- a/src/horizon/src/Repositories/RedisMetricsRepository.php +++ b/src/horizon/src/Repositories/RedisMetricsRepository.php @@ -9,10 +9,9 @@ use Hypervel\Horizon\Lock; use Hypervel\Horizon\LuaScripts; use Hypervel\Horizon\WaitTimeCalculator; -use Hypervel\Redis\PhpRedis; +use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Support\CarbonImmutable; -use Hypervel\Support\Str; class RedisMetricsRepository implements MetricsRepository { @@ -120,7 +119,7 @@ protected function runtimeFor(string $key): float public function queueWithMaximumRuntime(): ?string { return collect($this->measuredQueues())->sortBy(function ($queue) { - if ($snapshots = $this->connection()->zRange('snapshot:queue:' . $queue, -1, 1)) { + if ($snapshots = $this->connection()->zRange('snapshot:queue:' . $queue, -1, -1)) { return json_decode($snapshots[0])->runtime; } })->last(); @@ -132,7 +131,7 @@ public function queueWithMaximumRuntime(): ?string public function queueWithMaximumThroughput(): ?string { return collect($this->measuredQueues())->sortBy(function ($queue) { - if ($snapshots = $this->connection()->zRange('snapshot:queue:' . $queue, -1, 1)) { + if ($snapshots = $this->connection()->zRange('snapshot:queue:' . $queue, -1, -1)) { return json_decode($snapshots[0])->throughput; } })->last(); @@ -261,11 +260,12 @@ protected function storeSnapshotForQueue(string $queue): void /** * Get the base snapshot data for a given key. * - * @return array{throughput: string, runtime: string} + * @return array{throughput: false|string, runtime: false|string} */ protected function baseSnapshotData(string $key): array { - /** @var array{0: array{throughput: string, runtime: string}} $responses */ + // Horizon never issues WATCH, so EXEC cannot abort this transaction. + /** @var array{0: array{throughput: false|string, runtime: false|string}} $responses */ $responses = $this->connection()->transaction(function ($trans) use ($key) { $trans->hmget($key, ['throughput', 'runtime']); @@ -320,25 +320,21 @@ public function forget(string $key): void */ public function clear(): void { - $this->forget('last_snapshot_at'); - $this->forget('measured_jobs'); - $this->forget('measured_queues'); - $this->forget('metrics:snapshot'); - - foreach (['queue:*', 'job:*', 'snapshot:*'] as $pattern) { - $cursor = PhpRedis::initialScanCursor(); - - do { - [$cursor, $keys] = $this->connection()->scan( - $cursor, - ['match' => config('horizon.prefix') . $pattern] + $this->connection()->withConnection( + function (RedisConnection $connection): void { + $connection->del( + 'last_snapshot_at', + 'measured_jobs', + 'measured_queues', + 'metrics:snapshot', ); - foreach ($keys ?? [] as $key) { - $this->forget(Str::after($key, config('horizon.prefix'))); + foreach (['queue:*', 'job:*', 'snapshot:*'] as $pattern) { + $connection->flushByPattern($pattern); } - } while ($cursor > 0); - } + }, + transform: false, + ); } /** diff --git a/src/horizon/src/Repositories/RedisProcessRepository.php b/src/horizon/src/Repositories/RedisProcessRepository.php index eb610c8a8..3fbd8f414 100644 --- a/src/horizon/src/Repositories/RedisProcessRepository.php +++ b/src/horizon/src/Repositories/RedisProcessRepository.php @@ -11,6 +11,8 @@ class RedisProcessRepository implements ProcessRepository { + use UsesClusterAwarePipeline; + /** * Create a new repository instance. * @@ -46,7 +48,7 @@ public function orphaned(string $master, array $processIds): void $this->connection()->hDel($key, ...$shouldRemove); } - $this->connection()->pipeline(function ($pipe) use ($key, $time, $processIds) { + $this->pipeline(function ($pipe) use ($key, $time, $processIds) { foreach ($processIds as $processId) { $pipe->hSetNx($key, $processId, $time); } diff --git a/src/horizon/src/Repositories/RedisSupervisorRepository.php b/src/horizon/src/Repositories/RedisSupervisorRepository.php index 41421f3f9..5582ef3ce 100644 --- a/src/horizon/src/Repositories/RedisSupervisorRepository.php +++ b/src/horizon/src/Repositories/RedisSupervisorRepository.php @@ -14,6 +14,8 @@ class RedisSupervisorRepository implements SupervisorRepository { + use UsesClusterAwarePipeline; + /** * Create a new repository instance. */ @@ -56,7 +58,7 @@ public function find(string $name): ?stdClass public function get(array $names): array { /** @var array $records */ - $records = $this->connection()->pipeline(function ($pipe) use ($names) { + $records = $this->pipeline(function ($pipe) use ($names) { foreach ($names as $name) { $pipe->hmget('supervisor:' . $name, ['name', 'master', 'pid', 'status', 'processes', 'options']); } @@ -91,7 +93,7 @@ public function update(Supervisor $supervisor): void return [$supervisor->options->connection . ':' . $pool->queue() => count($pool->processes())]; })->toJson(); - $this->connection()->pipeline(function ($pipe) use ($supervisor, $processes) { + $this->pipeline(function ($pipe) use ($supervisor, $processes) { $pipe->hmset( 'supervisor:' . $supervisor->name, [ diff --git a/src/horizon/src/Repositories/RedisTagRepository.php b/src/horizon/src/Repositories/RedisTagRepository.php index c9b293673..4c47e802c 100644 --- a/src/horizon/src/Repositories/RedisTagRepository.php +++ b/src/horizon/src/Repositories/RedisTagRepository.php @@ -10,6 +10,8 @@ class RedisTagRepository implements TagRepository { + use UsesClusterAwarePipeline; + /** * Create a new repository instance. * @@ -57,7 +59,7 @@ public function stopMonitoring(string $tag): void */ public function add(string $id, array $tags): void { - $this->connection()->pipeline(function ($pipe) use ($id, $tags) { + $this->pipeline(function ($pipe) use ($id, $tags) { foreach ($tags as $tag) { $pipe->zAdd($tag, str_replace(',', '.', (string) microtime(true)), $id); } @@ -69,7 +71,7 @@ public function add(string $id, array $tags): void */ public function addTemporary(int $minutes, string $id, array $tags): void { - $this->connection()->pipeline(function ($pipe) use ($minutes, $id, $tags) { + $this->pipeline(function ($pipe) use ($minutes, $id, $tags) { foreach ($tags as $tag) { $pipe->zAdd($tag, str_replace(',', '.', (string) microtime(true)), $id); @@ -115,7 +117,7 @@ public function paginate(string $tag, int $startingAt = 0, int $limit = 25): arr */ public function forgetJobs(array|string $tags, array|string $ids): void { - $this->connection()->pipeline(function ($pipe) use ($tags, $ids) { + $this->pipeline(function ($pipe) use ($tags, $ids) { foreach ((array) $tags as $tag) { foreach ((array) $ids as $id) { $pipe->zrem($tag, $id); diff --git a/src/horizon/src/Repositories/UsesClusterAwarePipeline.php b/src/horizon/src/Repositories/UsesClusterAwarePipeline.php new file mode 100644 index 000000000..f02d3f238 --- /dev/null +++ b/src/horizon/src/Repositories/UsesClusterAwarePipeline.php @@ -0,0 +1,25 @@ +connection(); + + // Horizon hash-tags its Cluster prefix, so this remains one atomic + // single-slot batch. Horizon never uses WATCH, so EXEC cannot abort. + /** @var array $result */ + $result = $connection->isCluster() + ? $connection->transaction($callback) + : $connection->pipeline($callback); + + return $result; + } +} diff --git a/src/horizon/src/Supervisor.php b/src/horizon/src/Supervisor.php index f980add57..1abb7b271 100644 --- a/src/horizon/src/Supervisor.php +++ b/src/horizon/src/Supervisor.php @@ -49,7 +49,10 @@ class Supervisor implements Pausable, Restartable, Terminable */ public ?Closure $output = null; - public bool $shouldExitLoop = false; + /** + * The terminal exit status for this supervisor. + */ + protected ?int $exitStatus = null; /** * Create a new supervisor instance. @@ -188,7 +191,8 @@ public function terminate(int $status = 0): void } } - $this->shouldExitLoop = true; + $this->pendingSignals = []; + $this->exitStatus = $status; } /** @@ -196,14 +200,14 @@ public function terminate(int $status = 0): void */ protected function shouldWait(): bool { - // @phpstan-ignore-next-line - return ! config('horizon.fast_termination') || app(CacheFactory::class)->get('horizon:terminate:wait'); + return ! config()->boolean('horizon.fast_termination') + || app(CacheFactory::class)->store()->get('horizon:terminate:wait'); } /** * Monitor the worker processes. */ - public function monitor(): void + public function monitor(): int { $this->ensureNoDuplicateSupervisors(); @@ -216,8 +220,8 @@ public function monitor(): void $this->loop(); - if ($this->shouldExitLoop) { - break; + if ($this->exitStatus !== null) { + return $this->exitStatus; } } } @@ -246,6 +250,10 @@ public function loop(): void $this->processPendingCommands(); + if ($this->exitStatus !== null) { + return; + } + // If the supervisor is working, we will perform any needed scaling operations and // monitor all of these underlying worker processes to make sure they are still // processing queued jobs. If they have died, we will restart them each here. @@ -280,6 +288,10 @@ protected function ensureParentIsRunning(): void protected function processPendingCommands(): void { foreach (app(HorizonCommandQueue::class)->pending($this->name) as $command) { + if ($this->exitStatus !== null) { + return; + } + app($command->command)->process($this, $command->options); } } diff --git a/src/horizon/src/SupervisorCommandString.php b/src/horizon/src/SupervisorCommandString.php index 382207e52..f0791e2a6 100644 --- a/src/horizon/src/SupervisorCommandString.php +++ b/src/horizon/src/SupervisorCommandString.php @@ -6,10 +6,15 @@ class SupervisorCommandString { + /** + * The default base supervisor command. + */ + protected const DEFAULT_COMMAND = 'exec @php artisan horizon:supervisor'; + /** * The base worker command. */ - public static string $command = 'exec @php artisan horizon:supervisor'; + public static string $command = self::DEFAULT_COMMAND; /** * Get the command-line representation of the options for a supervisor. @@ -38,6 +43,6 @@ public static function toOptionsString(SupervisorOptions $options): string */ public static function flushState(): void { - static::$command = 'exec @php artisan horizon:supervisor'; + static::$command = self::DEFAULT_COMMAND; } } diff --git a/src/horizon/src/SupervisorOptions.php b/src/horizon/src/SupervisorOptions.php index 9f915a118..c0f49f8dc 100644 --- a/src/horizon/src/SupervisorOptions.php +++ b/src/horizon/src/SupervisorOptions.php @@ -94,7 +94,7 @@ public function withQueue(string $queue): static */ public function balancing(): bool { - return in_array($this->balance, ['simple', 'auto']); + return in_array($this->balance, ['simple', 'auto'], true); } /** diff --git a/src/horizon/src/SupervisorProcess.php b/src/horizon/src/SupervisorProcess.php index d8cce64c2..850afc5a7 100644 --- a/src/horizon/src/SupervisorProcess.php +++ b/src/horizon/src/SupervisorProcess.php @@ -98,7 +98,7 @@ public function monitor(): void // If the supervisor exited with a status code that we do not restart on then // we will not attempt to restart it. Otherwise, we will need to provision // it back out based on the latest provisioning information we have now. - if (in_array($exitCode, $this->dontRestartOn)) { + if (in_array($exitCode, $this->dontRestartOn, true)) { return; } diff --git a/src/horizon/src/SystemProcessCounter.php b/src/horizon/src/SystemProcessCounter.php index f7ca03982..8d29c1f6c 100644 --- a/src/horizon/src/SystemProcessCounter.php +++ b/src/horizon/src/SystemProcessCounter.php @@ -8,10 +8,15 @@ class SystemProcessCounter { + /** + * The default base command. + */ + protected const DEFAULT_COMMAND = 'horizon:work'; + /** * The base command to search for. */ - public static string $command = 'horizon:work'; + public static string $command = self::DEFAULT_COMMAND; /** * Get the number of Horizon workers for a given supervisor. @@ -30,6 +35,6 @@ public function get(string $name): int */ public static function flushState(): void { - static::$command = 'horizon:work'; + static::$command = self::DEFAULT_COMMAND; } } diff --git a/src/horizon/src/Tags.php b/src/horizon/src/Tags.php index 8ef34e9be..c1b876d5b 100644 --- a/src/horizon/src/Tags.php +++ b/src/horizon/src/Tags.php @@ -53,13 +53,15 @@ protected static function tagsForListener(CallQueuedListener $job): array static::setEvent($event); - return collect( - [static::extractListener($job), $event] - )->map(function ($job) { - return static::for($job); - })->collapse()->unique()->tap(function () { + try { + return collect([static::extractListener($job), $event]) + ->map(fn ($job) => static::for($job)) + ->collapse() + ->unique() + ->toArray(); + } finally { static::flushEventState(); - })->toArray(); + } } /** @@ -151,6 +153,9 @@ protected static function setEvent(object $event): void CoroutineContext::set(static::CONTEXT_KEY, $event); } + /** + * Get the event currently being handled. + */ protected static function getEvent(): ?object { return CoroutineContext::get(static::CONTEXT_KEY); @@ -161,6 +166,6 @@ protected static function getEvent(): ?object */ protected static function flushEventState(): void { - CoroutineContext::set(static::CONTEXT_KEY, null); + CoroutineContext::forget(static::CONTEXT_KEY); } } diff --git a/src/horizon/src/WorkerCommandString.php b/src/horizon/src/WorkerCommandString.php index 3f8ca79e8..9e9f15d19 100644 --- a/src/horizon/src/WorkerCommandString.php +++ b/src/horizon/src/WorkerCommandString.php @@ -6,10 +6,15 @@ class WorkerCommandString { + /** + * The default base worker command. + */ + protected const DEFAULT_COMMAND = 'exec @php artisan horizon:work'; + /** * The base worker command. */ - public static string $command = 'exec @php artisan horizon:work'; + public static string $command = self::DEFAULT_COMMAND; /** * Get the command-line representation of the options for a worker. @@ -38,6 +43,6 @@ public static function toOptionsString(SupervisorOptions $options): string */ public static function flushState(): void { - static::$command = 'exec @php artisan horizon:work'; + static::$command = self::DEFAULT_COMMAND; } } diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index bcfe8ce2d..3f56937f7 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -12,6 +12,7 @@ use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Query\Builder; +use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Jobs\DatabaseJob; use Hypervel\Queue\Jobs\DatabaseJobRecord; use Hypervel\Queue\Jobs\InspectedJob; @@ -271,10 +272,14 @@ public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixe return $this->getDatabase()->table($this->table)->insert(Collection::make((array) $jobs)->map( function ($job) use ($queue, $data, $now) { + $delay = is_object($job) + ? $this->getAttributeValue($job, Delay::class, 'delay') + : null; + return $this->buildDatabaseRecord( $queue, $this->createPayload($job, $this->getQueue($queue), $data), - isset($job->delay) ? $this->availableAt($job->delay) : $now, + $delay !== null ? $this->availableAt($delay) : $now, ); } )->all()); diff --git a/src/queue/src/QueueServiceProvider.php b/src/queue/src/QueueServiceProvider.php index 864cb19b4..ad746d3da 100644 --- a/src/queue/src/QueueServiceProvider.php +++ b/src/queue/src/QueueServiceProvider.php @@ -7,6 +7,8 @@ use Hypervel\Contracts\Database\ModelIdentifier; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher as EventDispatcher; +use Hypervel\Contracts\Redis\Factory as RedisFactory; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Queue\Connectors\BackgroundConnector; use Hypervel\Queue\Connectors\BeanstalkdConnector; use Hypervel\Queue\Connectors\DatabaseConnector; @@ -229,9 +231,12 @@ protected function registerFailoverConnector(QueueManager $manager): void */ protected function registerDatabaseConnector(QueueManager $manager): void { - $manager->addConnector('database', fn () => new DatabaseConnector( - $this->app['db'], - )); + $manager->addConnector('database', function (): DatabaseConnector { + /** @var ConnectionResolverInterface $connections */ + $connections = $this->app->make('db'); + + return new DatabaseConnector($connections); + }); } /** @@ -239,9 +244,12 @@ protected function registerDatabaseConnector(QueueManager $manager): void */ protected function registerRedisConnector(QueueManager $manager): void { - $manager->addConnector('redis', fn () => new RedisConnector( - $this->app['redis'], - )); + $manager->addConnector('redis', function (): RedisConnector { + /** @var RedisFactory $redis */ + $redis = $this->app->make('redis'); + + return new RedisConnector($redis); + }); } /** diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 4a1083545..097952022 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Queue\Job as JobContract; use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Redis\Factory as Redis; +use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\Jobs\RedisJob; use Hypervel\Redis\RedisConnection; @@ -244,17 +245,29 @@ public function creationTimeOfOldestPendingJob(?string $queue = null): ?int */ public function bulk(array $jobs, mixed $data = '', ?string $queue = null): mixed { - $this->getConnection()->pipeline(function () use ($jobs, $data, $queue) { - $this->getConnection()->transaction(function () use ($jobs, $data, $queue) { - foreach ((array) $jobs as $job) { - if (isset($job->delay)) { - $this->later($job->delay, $job, $data, $queue); - } else { - $this->push($job, $data, $queue); - } + $connection = $this->getConnection(); + + $callback = function () use ($jobs, $data, $queue): void { + foreach ($jobs as $job) { + $delay = is_object($job) + ? $this->getAttributeValue($job, Delay::class, 'delay') + : null; + + if ($delay !== null) { + $this->later($delay, $job, $data, $queue); + } else { + $this->push($job, $data, $queue); } - }); - }); + } + }; + + if ($connection->isCluster()) { + $connection->transaction($callback); + } else { + $connection->pipeline( + fn () => $connection->transaction($callback) + ); + } return null; } diff --git a/src/redis/src/Listeners/RedisConnectionLifecycleListener.php b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php index 914826dd1..6c24c9ff4 100644 --- a/src/redis/src/Listeners/RedisConnectionLifecycleListener.php +++ b/src/redis/src/Listeners/RedisConnectionLifecycleListener.php @@ -5,6 +5,7 @@ namespace Hypervel\Redis\Listeners; use Hypervel\Contracts\Container\Container as ContainerContract; +use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Redis\RedisManager; use Throwable; @@ -25,6 +26,7 @@ public function releaseTaskConnections(): void return; } + /** @var RedisFactory $manager */ $manager = $this->container->make('redis'); if ($manager instanceof RedisManager) { @@ -41,6 +43,7 @@ public function discardProcessConnections(): void if ($this->container->resolved('redis')) { try { + /** @var RedisFactory $manager */ $manager = $this->container->make('redis'); if ($manager instanceof RedisManager) { diff --git a/src/redis/src/RedisServiceProvider.php b/src/redis/src/RedisServiceProvider.php index 13a835152..09d0c87d2 100644 --- a/src/redis/src/RedisServiceProvider.php +++ b/src/redis/src/RedisServiceProvider.php @@ -4,6 +4,7 @@ namespace Hypervel\Redis; +use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Core\Events\BeforeServerFork; use Hypervel\Core\Events\BeforeWorkerStart; use Hypervel\Core\Events\TaskTerminated; @@ -26,7 +27,12 @@ public function register(): void $app->make(RedisSentinelFactory::class), )); - $this->app->bind('redis.connection', fn ($app) => $app->make('redis')->connection()); + $this->app->bind('redis.connection', function ($app) { + /** @var RedisFactory $redis */ + $redis = $app->make('redis'); + + return $redis->connection(); + }); } /** diff --git a/src/redis/src/Traits/MultiExec.php b/src/redis/src/Traits/MultiExec.php index 578c98588..088c7ba67 100644 --- a/src/redis/src/Traits/MultiExec.php +++ b/src/redis/src/Traits/MultiExec.php @@ -18,7 +18,7 @@ trait MultiExec /** * Execute commands in a pipeline. * - * @return array|Redis + * @return ($callback is null ? Redis : array|false) */ public function pipeline(?callable $callback = null) { @@ -28,7 +28,7 @@ public function pipeline(?callable $callback = null) /** * Execute commands in a transaction. * - * @return array|Redis|RedisCluster + * @return ($callback is null ? Redis|RedisCluster : array|false) */ public function transaction(?callable $callback = null) { @@ -38,7 +38,9 @@ public function transaction(?callable $callback = null) /** * Execute multi-exec commands with optional callback. * - * @return array|Redis|RedisCluster + * @return ($callback is null + * ? ($command is 'pipeline' ? Redis : Redis|RedisCluster) + * : array|false) */ private function executeMultiExec(string $command, ?callable $callback = null) { diff --git a/src/reverb/src/ReverbServiceProvider.php b/src/reverb/src/ReverbServiceProvider.php index 57b1edbbb..529254fe0 100644 --- a/src/reverb/src/ReverbServiceProvider.php +++ b/src/reverb/src/ReverbServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Reverb; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcher; +use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Coordinator\Timer; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Core\Events\OnPipeMessage; @@ -89,8 +90,11 @@ public function register(): void $connectionName = $app->make('config') ->string('reverb.servers.reverb.scaling.connection', 'reverb'); + /** @var RedisFactory $redis */ + $redis = $app->make('redis'); + return new WebhookBatchBuffer( - $app->make('redis')->connection($connectionName) + $redis->connection($connectionName) ); }); diff --git a/src/telescope/src/Console/InstallCommand.php b/src/telescope/src/Console/InstallCommand.php index 5db036214..637c28845 100644 --- a/src/telescope/src/Console/InstallCommand.php +++ b/src/telescope/src/Console/InstallCommand.php @@ -5,9 +5,11 @@ namespace Hypervel\Telescope\Console; use Hypervel\Console\Command; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; use Hypervel\Support\ServiceProvider; use Hypervel\Support\Str; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'telescope:install')] @@ -115,25 +117,32 @@ protected function registerTelescopeServiceProvider(): bool $namespace = Str::replaceLast('\\', '', $this->hypervel->getNamespace()); $providerPath = $this->hypervel->path('Providers/TelescopeServiceProvider.php'); - if (! is_file($providerPath) || ! is_readable($providerPath)) { + if (! is_file($providerPath)) { $this->components->error('TelescopeServiceProvider file was not published.'); return false; } - $contents = file_get_contents($providerPath); + $contents = @file_get_contents($providerPath); + $permissions = @fileperms($providerPath); - if ($contents === false) { + if ($contents === false || $permissions === false) { $this->components->error('Unable to read the TelescopeServiceProvider file.'); return false; } - if (file_put_contents($providerPath, str_replace( - 'namespace App\Providers;', - "namespace {$namespace}\\Providers;", - $contents, - )) === false) { + try { + $this->hypervel->make(Filesystem::class)->replace( + $providerPath, + str_replace( + 'namespace App\Providers;', + "namespace {$namespace}\\Providers;", + $contents, + ), + $permissions & 0777, + ); + } catch (RuntimeException) { $this->components->error('Unable to update the TelescopeServiceProvider namespace.'); return false; diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index 4e4b5617e..41f57a294 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -872,20 +872,19 @@ public function testThrowExceptionsForReadStream() $this->fail('Exception was not thrown.'); } - public function testThrowExceptionsForPut() + public function testThrowExceptionsForPut(): void { - if (trim(shell_exec('whoami')) === 'root') { - $this->markTestSkipped('Cannot test file permissions as root user.'); - return; - } - $this->filesystem->write('foo.txt', 'Hello World'); chmod($this->tempDir . '/foo.txt', 0400); - $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['throw' => true]); - try { + if (is_writable($this->tempDir . '/foo.txt')) { + $this->markTestSkipped('The current user can write to files without write permission.'); + } + + $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['throw' => true]); + $adapter->put('/foo.txt', 'Hello World!'); } catch (UnableToWriteFile) { $this->assertTrue(true); @@ -971,37 +970,36 @@ public function testReportExceptionsForReadStream() } } - public function testReportExceptionsForPut() + public function testReportExceptionsForPut(): void { - if (trim(shell_exec('whoami')) === 'root') { - $this->markTestSkipped('Cannot test file permissions as root user.'); - return; - } + $this->filesystem->write('foo.txt', 'Hello World'); - $container = Container::getInstance(); + chmod($this->tempDir . '/foo.txt', 0400); - $exceptionHandler = m::mock(ExceptionHandler::class); + try { + if (is_writable($this->tempDir . '/foo.txt')) { + $this->markTestSkipped('The current user can write to files without write permission.'); + } - $exceptionHandler->shouldReceive('report') - ->once() - ->andReturnUsing(function (UnableToWriteFile $exception) { - self::assertStringContainsString( - 'Unable to write file at location: foo.txt.', - $exception->getMessage(), - ); - }); + $container = Container::getInstance(); - $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { - return $exceptionHandler; - }); + $exceptionHandler = m::mock(ExceptionHandler::class); - $this->filesystem->write('foo.txt', 'Hello World'); + $exceptionHandler->shouldReceive('report') + ->once() + ->andReturnUsing(function (UnableToWriteFile $exception) { + self::assertStringContainsString( + 'Unable to write file at location: foo.txt.', + $exception->getMessage(), + ); + }); - chmod($this->tempDir . '/foo.txt', 0400); + $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { + return $exceptionHandler; + }); - $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['report' => true]); + $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['report' => true]); - try { $adapter->put('/foo.txt', 'Hello World!'); } catch (UnableToWriteFile) { $this->fail('Exception was thrown.'); diff --git a/tests/Fortify/Console/InstallCommandTest.php b/tests/Fortify/Console/InstallCommandTest.php new file mode 100644 index 000000000..6b02f4e76 --- /dev/null +++ b/tests/Fortify/Console/InstallCommandTest.php @@ -0,0 +1,240 @@ +app->getBootstrapProvidersPath(); + $files = new Filesystem; + + if ($files->isFile($path)) { + $this->originalProvidersContents = $files->get($path); + } + } + + protected function tearDown(): void + { + $files = new Filesystem; + $providersPath = $this->app->getBootstrapProvidersPath(); + $actions = []; + + if ($this->originalProvidersContents !== null) { + $actions[] = fn () => $files->replace( + $providersPath, + $this->originalProvidersContents, + ); + } else { + $actions[] = static function () use ($files, $providersPath): void { + if ($files->isFile($providersPath) && ! $files->delete($providersPath)) { + throw new RuntimeException("Unable to delete the owned Fortify providers file [{$providersPath}]."); + } + }; + } + + foreach ($this->publishedFiles() as $file) { + $actions[] = static function () use ($file, $files): void { + if ($files->isFile($file) && ! $files->delete($file)) { + throw new RuntimeException("Unable to delete owned Fortify install test file [{$file}]."); + } + }; + } + + $actions[] = fn () => parent::tearDown(); + + CleanupActions::run(...$actions); + } + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + FortifyServiceProvider::class, + ]; + } + + public function testInstallCommandPublishesFortifyResources(): void + { + $this->artisan('fortify:install')->assertSuccessful(); + + $this->assertFileExists($this->app->configPath('fortify.php')); + + foreach ($this->publishedSupportFiles() as $file) { + $this->assertFileExists($file); + $this->assertStringContainsString('namespace App\\', file_get_contents($file)); + } + + $this->assertCount(2, $this->publishedFortifyMigrations()); + + $providers = require $this->app->getBootstrapProvidersPath(); + $this->assertContains('App\Providers\FortifyServiceProvider', $providers); + } + + public function testInstallCommandFailsWhenPublishingFails(): void + { + $this->app->singleton(InstallCommand::class, FailingFortifyInstallCommand::class); + + $this->artisan('fortify:install') + ->expectsOutputToContain('Unable to publish Fortify Configuration.') + ->assertExitCode(HypervelCommand::FAILURE); + + $providers = require $this->app->getBootstrapProvidersPath(); + $this->assertNotContains('App\Providers\FortifyServiceProvider', $providers); + } + + public function testInstallCommandPreservesPublishedFilePermissions(): void + { + $this->artisan('fortify:install')->assertSuccessful(); + $path = $this->publishedSupportFiles()[0]; + chmod($path, 0640); + + $this->artisan('fortify:install')->assertSuccessful(); + + clearstatcache(true, $path); + $this->assertSame(0640, fileperms($path) & 0777); + } + + public function testInstallCommandFailsWhenPublishedFileIsMissing(): void + { + $path = $this->publishedSupportFiles()[0]; + $this->assertFileDoesNotExist($path); + $this->app->singleton(InstallCommand::class, SuccessfulPublishingFortifyInstallCommand::class); + + $this->artisan('fortify:install') + ->expectsOutputToContain('was not published.') + ->assertExitCode(HypervelCommand::FAILURE); + + $providers = require $this->app->getBootstrapProvidersPath(); + $this->assertNotContains('App\Providers\FortifyServiceProvider', $providers); + } + + public function testInstallCommandFailsWhenPublishedFileCannotBeRead(): void + { + $path = $this->publishedSupportFiles()[0]; + $files = new Filesystem; + $files->ensureDirectoryExists(dirname($path)); + $files->replace($path, 'app->singleton(InstallCommand::class, SuccessfulPublishingFortifyInstallCommand::class); + + try { + if (@file_get_contents($path) !== false) { + $this->markTestSkipped('The current user can read files without read permission.'); + } + + $this->artisan('fortify:install') + ->expectsOutputToContain('Unable to read published Fortify file') + ->assertExitCode(HypervelCommand::FAILURE); + + $providers = require $this->app->getBootstrapProvidersPath(); + $this->assertNotContains('App\Providers\FortifyServiceProvider', $providers); + } finally { + chmod($path, 0644); + } + } + + public function testInstallCommandStopsAfterPublishedFileReplacementFails(): void + { + $this->artisan('fortify:install')->assertSuccessful(); + (new Filesystem)->replace( + $this->app->getBootstrapProvidersPath(), + $this->originalProvidersContents, + ); + + $laterFile = $this->publishedSupportFiles()[1]; + (new Filesystem)->replace($laterFile, 'shouldReceive('replace')->once()->andThrow(new RuntimeException('replace failed')); + $this->app->instance(Filesystem::class, $files); + + $this->artisan('fortify:install') + ->expectsOutputToContain('Unable to update published Fortify file') + ->assertExitCode(HypervelCommand::FAILURE); + + $this->assertSame('app->getBootstrapProvidersPath(); + $this->assertNotContains('App\Providers\FortifyServiceProvider', $providers); + } + + /** + * Get support files published by the install command. + * + * @return list + */ + protected function publishedSupportFiles(): array + { + return [ + $this->app->path('Actions/Fortify/CreateNewUser.php'), + $this->app->path('Actions/Fortify/PasswordValidationRules.php'), + $this->app->path('Actions/Fortify/ResetUserPassword.php'), + $this->app->path('Actions/Fortify/UpdateUserPassword.php'), + $this->app->path('Actions/Fortify/UpdateUserProfileInformation.php'), + $this->app->path('Providers/FortifyServiceProvider.php'), + ]; + } + + /** + * Get migrations published by the install command. + * + * @return list + */ + protected function publishedFortifyMigrations(): array + { + return glob($this->app->databasePath('migrations/*_*.php')) ?: []; + } + + /** + * Get files published by the install command. + * + * @return list + */ + protected function publishedFiles(): array + { + return [ + $this->app->configPath('fortify.php'), + ...$this->publishedSupportFiles(), + ...$this->publishedFortifyMigrations(), + ]; + } +} + +class FailingFortifyInstallCommand extends InstallCommand +{ + /** + * Call another console command silently. + */ + public function callSilent(SymfonyCommand|string $command, array $arguments = []): int + { + return self::FAILURE; + } +} + +class SuccessfulPublishingFortifyInstallCommand extends InstallCommand +{ + /** + * Call another console command silently. + */ + public function callSilent(SymfonyCommand|string $command, array $arguments = []): int + { + return self::SUCCESS; + } +} diff --git a/tests/Horizon/Console/InstallCommandTest.php b/tests/Horizon/Console/InstallCommandTest.php index 559cbac59..91a13d884 100644 --- a/tests/Horizon/Console/InstallCommandTest.php +++ b/tests/Horizon/Console/InstallCommandTest.php @@ -11,6 +11,7 @@ use Hypervel\Horizon\HorizonServiceProvider; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Testing\Fixtures\CleanupActions; +use Mockery as m; use RuntimeException; use Symfony\Component\Console\Command\Command as SymfonyCommand; @@ -116,6 +117,64 @@ public function testInstallCommandFailsWhenBootstrapProvidersFileIsMissing(): vo ->assertExitCode(HypervelCommand::FAILURE); } + public function testInstallCommandPreservesProviderFilePermissions(): void + { + $this->artisan('horizon:install')->assertSuccessful(); + $providerPath = $this->app->path('Providers/HorizonServiceProvider.php'); + chmod($providerPath, 0640); + + $this->artisan('horizon:install')->assertSuccessful(); + + clearstatcache(true, $providerPath); + $this->assertSame(0640, fileperms($providerPath) & 0777); + } + + public function testInstallCommandFailsWhenProviderFileCannotBeRead(): void + { + $this->artisan('horizon:install')->assertSuccessful(); + $providerPath = $this->app->path('Providers/HorizonServiceProvider.php'); + (new Filesystem)->replace( + $this->app->getBootstrapProvidersPath(), + $this->originalProvidersContents, + ); + chmod($providerPath, 0000); + + try { + if (@file_get_contents($providerPath) !== false) { + $this->markTestSkipped('The current user can read files without read permission.'); + } + + $this->artisan('horizon:install') + ->expectsOutputToContain('Unable to read the HorizonServiceProvider file.') + ->assertExitCode(HypervelCommand::FAILURE); + + $providers = require $this->app->getBootstrapProvidersPath(); + $this->assertNotContains('App\Providers\HorizonServiceProvider', $providers); + } finally { + chmod($providerPath, 0644); + } + } + + public function testInstallCommandFailsWhenProviderReplacementFails(): void + { + $this->artisan('horizon:install')->assertSuccessful(); + (new Filesystem)->replace( + $this->app->getBootstrapProvidersPath(), + $this->originalProvidersContents, + ); + + $files = m::mock(Filesystem::class); + $files->shouldReceive('replace')->once()->andThrow(new RuntimeException('replace failed')); + $this->app->instance(Filesystem::class, $files); + + $this->artisan('horizon:install') + ->expectsOutputToContain('Unable to update the HorizonServiceProvider namespace.') + ->assertExitCode(HypervelCommand::FAILURE); + + $providers = require $this->app->getBootstrapProvidersPath(); + $this->assertNotContains('App\Providers\HorizonServiceProvider', $providers); + } + /** * Get files published by the install command. * diff --git a/tests/Horizon/Console/SnapshotCommandTest.php b/tests/Horizon/Console/SnapshotCommandTest.php new file mode 100644 index 000000000..254038b04 --- /dev/null +++ b/tests/Horizon/Console/SnapshotCommandTest.php @@ -0,0 +1,49 @@ +shouldReceive('get')->once()->with('metrics:snapshot', 270)->andReturnFalse(); + + $this->app->make(SnapshotCommand::class)->handle( + $lock, + m::mock(MetricsRepository::class), + ); + } + + public function testSnapshotLockDefaultSurvivesReplaceWholeMetricsConfiguration(): void + { + config(['horizon.metrics' => [ + 'trim_snapshots' => ['job' => 12, 'queue' => 12], + ]]); + + $lock = m::mock(Lock::class); + $lock->shouldReceive('get')->once()->with('metrics:snapshot', 270)->andReturnFalse(); + + $this->app->make(SnapshotCommand::class)->handle( + $lock, + m::mock(MetricsRepository::class), + ); + } +} diff --git a/tests/Horizon/Console/WorkCommandTest.php b/tests/Horizon/Console/WorkCommandTest.php new file mode 100644 index 000000000..4ad307875 --- /dev/null +++ b/tests/Horizon/Console/WorkCommandTest.php @@ -0,0 +1,63 @@ +shouldReceive('setName') + ->once() + ->with('default') + ->andReturnSelf(); + $worker->shouldReceive('setCache') + ->once() + ->andReturnSelf(); + $worker->shouldReceive('runNextJob') + ->once() + ->withArgs(function (string $connection, string $queue, WorkerOptions $options): bool { + $this->assertSame('sync', $connection); + $this->assertSame('default', $queue); + $this->assertSame(7, $options->stopWhenEmptyFor); + + return true; + }) + ->andReturnNull(); + + $this->app->instance(Worker::class, $worker); + + $this->artisan('horizon:work', [ + 'connection' => 'sync', + '--once' => true, + '--stop-when-empty-for' => 7, + ])->assertSuccessful(); + } + + public function testHorizonWorkKeepsTheQueueWorkerOptionSurface(): void + { + $artisan = $this->app->make(Kernel::class)->getArtisan(); + $queueOptions = array_keys($artisan->find('queue:work')->getDefinition()->getOptions()); + $horizonOptions = array_keys($artisan->find('horizon:work')->getDefinition()->getOptions()); + + $this->assertSame([], array_values(array_diff($queueOptions, $horizonOptions))); + $this->assertSame(['supervisor'], array_values(array_diff($horizonOptions, $queueOptions))); + } +} diff --git a/tests/Horizon/HorizonConfigTest.php b/tests/Horizon/HorizonConfigTest.php index cd6852289..e11467934 100644 --- a/tests/Horizon/HorizonConfigTest.php +++ b/tests/Horizon/HorizonConfigTest.php @@ -4,11 +4,67 @@ namespace Hypervel\Tests\Horizon; +use Hypervel\Config\Repository as ConfigRepository; +use Hypervel\Foundation\Application; +use Hypervel\Foundation\Configuration\ConfigMutationTracker; +use Hypervel\Horizon\HorizonServiceProvider; use Hypervel\Support\Env; +use Hypervel\Support\ServiceProvider; use Hypervel\Tests\TestCase; +use Mockery as m; class HorizonConfigTest extends TestCase { + public function testCanonicalDefaultsAreDeclared(): void + { + $config = require dirname(__DIR__, 2) . '/src/horizon/config/horizon.php'; + + $this->assertSame('', $config['proxy_path']); + $this->assertSame(300, $config['metrics']['snapshot_lock']); + $this->assertSame('horizon', $config['path']); + $this->assertSame('default', $config['use']); + $this->assertSame(['web'], $config['middleware']); + $this->assertFalse($config['fast_termination']); + $this->assertSame(64, $config['memory_limit']); + } + + public function testApplicationMetricsConfigurationReplacesPackageDefaults(): void + { + $config = new ConfigRepository([ + 'horizon' => [ + 'metrics' => [ + 'trim_snapshots' => ['job' => 12, 'queue' => 12], + ], + ], + ]); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturnFalse(); + $app->shouldReceive('make')->with('config')->andReturn($config); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn(new ConfigMutationTracker); + + (new HorizonConfigServiceProvider($app))->register(); + + $this->assertSame([ + 'trim_snapshots' => ['job' => 12, 'queue' => 12], + ], $config->get('horizon.metrics')); + } + + public function testOnlyMissingAndBlankNamesUseTheApplicationName(): void + { + foreach ([null, '', '0'] as $name) { + $config = new ConfigRepository([ + 'app' => ['name' => 'Hypervel'], + 'horizon' => ['name' => $name], + ]); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('make')->with('config')->andReturn($config); + + (new HorizonServiceProviderForTesting($app))->normalize(); + + $this->assertSame($name === '0' ? '0' : 'Hypervel', $config->get('horizon.name')); + } + } + public function testMissingAndBlankPrefixUseApplicationScopedDefault(): void { $key = 'HORIZON_PREFIX'; @@ -56,3 +112,22 @@ public function testMissingAndBlankPrefixUseApplicationScopedDefault(): void } } } + +class HorizonConfigServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->mergeConfigFrom( + dirname(__DIR__, 2) . '/src/horizon/config/horizon.php', + 'horizon', + ); + } +} + +class HorizonServiceProviderForTesting extends HorizonServiceProvider +{ + public function normalize(): void + { + $this->normalizeConfig(); + } +} diff --git a/tests/Horizon/HorizonTest.php b/tests/Horizon/HorizonTest.php new file mode 100644 index 000000000..262b74a99 --- /dev/null +++ b/tests/Horizon/HorizonTest.php @@ -0,0 +1,73 @@ +assertStringContainsString('