├── LICENSE.md
├── README.md
├── art
└── logo.svg
├── composer.json
├── config
└── horizon.php
├── dist
├── app.css
├── app.js
├── styles-dark.css
└── styles.css
├── package.json
├── resources
├── js
│ ├── app.js
│ ├── base.js
│ ├── components
│ │ ├── Alert.vue
│ │ ├── LineChart.vue
│ │ ├── SchemeToggler.vue
│ │ └── Stacktrace.vue
│ ├── routes.js
│ └── screens
│ │ ├── batches
│ │ ├── index.vue
│ │ └── preview.vue
│ │ ├── dashboard.vue
│ │ ├── failedJobs
│ │ ├── index.vue
│ │ └── job.vue
│ │ ├── metrics
│ │ ├── index.vue
│ │ ├── jobs.vue
│ │ ├── preview.vue
│ │ └── queues.vue
│ │ ├── monitoring
│ │ ├── index.vue
│ │ ├── job-row.vue
│ │ ├── tag-jobs.vue
│ │ └── tag.vue
│ │ └── recentJobs
│ │ ├── index.vue
│ │ ├── job-row.vue
│ │ └── job.vue
├── sass
│ ├── _colors.scss
│ ├── base.scss
│ ├── styles-dark.scss
│ ├── styles.scss
│ └── syntaxhighlight.scss
└── views
│ └── layout.blade.php
├── routes
└── web.php
├── src
├── AutoScaler.php
├── BackgroundProcess.php
├── Connectors
│ └── RedisConnector.php
├── Console
│ ├── ClearCommand.php
│ ├── ClearMetricsCommand.php
│ ├── ContinueCommand.php
│ ├── ContinueSupervisorCommand.php
│ ├── ForgetFailedCommand.php
│ ├── HorizonCommand.php
│ ├── InstallCommand.php
│ ├── ListCommand.php
│ ├── PauseCommand.php
│ ├── PauseSupervisorCommand.php
│ ├── PublishCommand.php
│ ├── PurgeCommand.php
│ ├── SnapshotCommand.php
│ ├── StatusCommand.php
│ ├── SupervisorCommand.php
│ ├── SupervisorStatusCommand.php
│ ├── SupervisorsCommand.php
│ ├── TerminateCommand.php
│ ├── TimeoutCommand.php
│ └── WorkCommand.php
├── Contracts
│ ├── HorizonCommandQueue.php
│ ├── JobRepository.php
│ ├── LongWaitDetectedNotification.php
│ ├── MasterSupervisorRepository.php
│ ├── MetricsRepository.php
│ ├── Pausable.php
│ ├── ProcessRepository.php
│ ├── Restartable.php
│ ├── Silenced.php
│ ├── SupervisorRepository.php
│ ├── TagRepository.php
│ ├── Terminable.php
│ └── WorkloadRepository.php
├── EventMap.php
├── Events
│ ├── JobDeleted.php
│ ├── JobFailed.php
│ ├── JobPushed.php
│ ├── JobReleased.php
│ ├── JobReserved.php
│ ├── JobsMigrated.php
│ ├── LongWaitDetected.php
│ ├── MasterSupervisorDeployed.php
│ ├── MasterSupervisorLooped.php
│ ├── MasterSupervisorOutOfMemory.php
│ ├── MasterSupervisorReviving.php
│ ├── RedisEvent.php
│ ├── SupervisorLooped.php
│ ├── SupervisorOutOfMemory.php
│ ├── SupervisorProcessRestarting.php
│ ├── UnableToLaunchProcess.php
│ └── WorkerProcessRestarting.php
├── Exceptions
│ └── ForbiddenException.php
├── Exec.php
├── Horizon.php
├── HorizonApplicationServiceProvider.php
├── HorizonServiceProvider.php
├── Http
│ ├── Controllers
│ │ ├── BatchesController.php
│ │ ├── CompletedJobsController.php
│ │ ├── Controller.php
│ │ ├── DashboardStatsController.php
│ │ ├── FailedJobsController.php
│ │ ├── HomeController.php
│ │ ├── JobMetricsController.php
│ │ ├── JobsController.php
│ │ ├── MasterSupervisorController.php
│ │ ├── MonitoringController.php
│ │ ├── PendingJobsController.php
│ │ ├── QueueMetricsController.php
│ │ ├── RetryController.php
│ │ ├── SilencedJobsController.php
│ │ └── WorkloadController.php
│ └── Middleware
│ │ └── Authenticate.php
├── JobPayload.php
├── Jobs
│ ├── MonitorTag.php
│ ├── RetryFailedJob.php
│ └── StopMonitoringTag.php
├── Listeners
│ ├── ExpireSupervisors.php
│ ├── ForgetJobTimer.php
│ ├── MarkJobAsComplete.php
│ ├── MarkJobAsFailed.php
│ ├── MarkJobAsReleased.php
│ ├── MarkJobAsReserved.php
│ ├── MarkJobsAsMigrated.php
│ ├── MarshalFailedEvent.php
│ ├── MonitorMasterSupervisorMemory.php
│ ├── MonitorSupervisorMemory.php
│ ├── MonitorWaitTimes.php
│ ├── PruneTerminatingProcesses.php
│ ├── SendNotification.php
│ ├── StartTimingJob.php
│ ├── StoreJob.php
│ ├── StoreMonitoredTags.php
│ ├── StoreTagsForFailedJob.php
│ ├── TrimFailedJobs.php
│ ├── TrimMonitoredJobs.php
│ ├── TrimRecentJobs.php
│ └── UpdateJobMetrics.php
├── ListensForSignals.php
├── Lock.php
├── LuaScripts.php
├── MasterSupervisor.php
├── MasterSupervisorCommands
│ └── AddSupervisor.php
├── Notifications
│ └── LongWaitDetected.php
├── PhpBinary.php
├── ProcessInspector.php
├── ProcessPool.php
├── ProvisioningPlan.php
├── QueueCommandString.php
├── RedisHorizonCommandQueue.php
├── RedisQueue.php
├── Repositories
│ ├── RedisJobRepository.php
│ ├── RedisMasterSupervisorRepository.php
│ ├── RedisMetricsRepository.php
│ ├── RedisProcessRepository.php
│ ├── RedisSupervisorRepository.php
│ ├── RedisTagRepository.php
│ └── RedisWorkloadRepository.php
├── ServiceBindings.php
├── Stopwatch.php
├── Supervisor.php
├── SupervisorCommandString.php
├── SupervisorCommands
│ ├── Balance.php
│ ├── ContinueWorking.php
│ ├── Pause.php
│ ├── Restart.php
│ ├── Scale.php
│ └── Terminate.php
├── SupervisorFactory.php
├── SupervisorOptions.php
├── SupervisorProcess.php
├── SystemProcessCounter.php
├── Tags.php
├── WaitTimeCalculator.php
├── WorkerCommandString.php
└── WorkerProcess.php
├── stubs
└── HorizonServiceProvider.stub
├── testbench.yaml
├── vite.config.js
└── workbench
├── app
└── Providers
│ └── HorizonServiceProvider.php
└── database
├── factories
└── .gitkeep
├── migrations
└── .gitkeep
└── seeders
└── DatabaseSeeder.php
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) Taylor Otwell
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | ## Introduction
11 |
12 | Horizon provides a beautiful dashboard and code-driven configuration for your Laravel powered Redis queues. Horizon allows you to easily monitor key metrics of your queue system such as job throughput, runtime, and job failures.
13 |
14 | All of your worker configuration is stored in a single, simple configuration file, allowing your configuration to stay in source control where your entire team can collaborate.
15 |
16 |
17 |
18 |
19 |
20 | ## Official Documentation
21 |
22 | Documentation for Horizon can be found on the [Laravel website](https://laravel.com/docs/horizon).
23 |
24 | ## Contributing
25 |
26 | Thank you for considering contributing to Horizon! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
27 |
28 | ## Code of Conduct
29 |
30 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
31 |
32 | ## Security Vulnerabilities
33 |
34 | Please review [our security policy](https://github.com/laravel/horizon/security/policy) on how to report security vulnerabilities.
35 |
36 | ## License
37 |
38 | Laravel Horizon is open-sourced software licensed under the [MIT license](LICENSE.md).
39 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "laravel/horizon",
3 | "description": "Dashboard and code-driven configuration for Laravel queues.",
4 | "keywords": ["laravel", "queue"],
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Taylor Otwell",
9 | "email": "taylor@laravel.com"
10 | }
11 | ],
12 | "require": {
13 | "php": "^8.0",
14 | "ext-json": "*",
15 | "ext-pcntl": "*",
16 | "ext-posix": "*",
17 | "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
18 | "illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
19 | "illuminate/support": "^9.21|^10.0|^11.0|^12.0",
20 | "nesbot/carbon": "^2.17|^3.0",
21 | "ramsey/uuid": "^4.0",
22 | "symfony/console": "^6.0|^7.0",
23 | "symfony/error-handler": "^6.0|^7.0",
24 | "symfony/polyfill-php83": "^1.28",
25 | "symfony/process": "^6.0|^7.0"
26 | },
27 | "require-dev": {
28 | "mockery/mockery": "^1.0",
29 | "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
30 | "phpstan/phpstan": "^1.10",
31 | "phpunit/phpunit": "^9.0|^10.4|^11.5",
32 | "predis/predis": "^1.1|^2.0"
33 | },
34 | "suggest": {
35 | "ext-redis": "Required to use the Redis PHP driver.",
36 | "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0)."
37 | },
38 | "autoload": {
39 | "psr-4": {
40 | "Laravel\\Horizon\\": "src/"
41 | }
42 | },
43 | "autoload-dev": {
44 | "psr-4": {
45 | "Laravel\\Horizon\\Tests\\": "tests/",
46 | "Workbench\\App\\": "workbench/app/",
47 | "Workbench\\Database\\Factories\\": "workbench/database/factories/",
48 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/"
49 | }
50 | },
51 | "extra": {
52 | "branch-alias": {
53 | "dev-master": "6.x-dev"
54 | },
55 | "laravel": {
56 | "providers": [
57 | "Laravel\\Horizon\\HorizonServiceProvider"
58 | ],
59 | "aliases": {
60 | "Horizon": "Laravel\\Horizon\\Horizon"
61 | }
62 | }
63 | },
64 | "config": {
65 | "sort-packages": true
66 | },
67 | "minimum-stability": "dev",
68 | "prefer-stable": true,
69 | "scripts": {
70 | "post-autoload-dump": "@prepare",
71 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
72 | "prepare": "@php vendor/bin/testbench package:discover --ansi",
73 | "build": "@php vendor/bin/testbench workbench:build --ansi",
74 | "serve": [
75 | "@build",
76 | "@php vendor/bin/testbench serve"
77 | ],
78 | "lint": [
79 | "@php vendor/bin/phpstan analyse"
80 | ],
81 | "test": [
82 | "@php vendor/bin/phpunit"
83 | ]
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/dist/app.css:
--------------------------------------------------------------------------------
1 | .vjs-tree-brackets{cursor:pointer}.vjs-tree-brackets:hover{color:#1890ff}.vjs-check-controller{position:absolute;left:0}.vjs-check-controller.is-checked .vjs-check-controller-inner{background-color:#1890ff;border-color:#0076e4}.vjs-check-controller.is-checked .vjs-check-controller-inner.is-checkbox:after{transform:rotate(45deg) scaleY(1)}.vjs-check-controller.is-checked .vjs-check-controller-inner.is-radio:after{transform:translate(-50%,-50%) scale(1)}.vjs-check-controller .vjs-check-controller-inner{display:inline-block;position:relative;border:1px solid #bfcbd9;border-radius:2px;vertical-align:middle;box-sizing:border-box;width:16px;height:16px;background-color:#fff;z-index:1;cursor:pointer;transition:border-color .25s cubic-bezier(.71,-.46,.29,1.46),background-color .25s cubic-bezier(.71,-.46,.29,1.46)}.vjs-check-controller .vjs-check-controller-inner:after{box-sizing:content-box;content:"";border:2px solid #fff;border-left:0;border-top:0;height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg) scaleY(0);width:4px;transition:transform .15s cubic-bezier(.71,-.46,.88,.6) .05s;transform-origin:center}.vjs-check-controller .vjs-check-controller-inner.is-radio{border-radius:100%}.vjs-check-controller .vjs-check-controller-inner.is-radio:after{border-radius:100%;height:4px;background-color:#fff;left:50%;top:50%}.vjs-check-controller .vjs-check-controller-original{opacity:0;outline:none;position:absolute;z-index:-1;top:0;left:0;right:0;bottom:0;margin:0}.vjs-carets{position:absolute;right:0;cursor:pointer}.vjs-carets svg{transition:transform .3s}.vjs-carets:hover{color:#1890ff}.vjs-carets-close{transform:rotate(-90deg)}.vjs-tree-node{display:flex;position:relative;line-height:20px}.vjs-tree-node.has-carets{padding-left:15px}.vjs-tree-node.has-carets.has-selector,.vjs-tree-node.has-selector{padding-left:30px}.vjs-tree-node.is-highlight,.vjs-tree-node:hover{background-color:#e6f7ff}.vjs-tree-node .vjs-indent{display:flex;position:relative}.vjs-tree-node .vjs-indent-unit{width:1em}.vjs-tree-node .vjs-indent-unit.has-line{border-left:1px dashed #bfcbd9}.vjs-tree-node.dark.is-highlight,.vjs-tree-node.dark:hover{background-color:#2e4558}.vjs-node-index{position:absolute;right:100%;margin-right:4px;-webkit-user-select:none;user-select:none}.vjs-colon{white-space:pre}.vjs-comment{color:#bfcbd9}.vjs-value{word-break:break-word}.vjs-value-null,.vjs-value-undefined{color:#d55fde}.vjs-value-boolean,.vjs-value-number{color:#1d8ce0}.vjs-value-string{color:#13ce66}.vjs-tree{font-family:Monaco,Menlo,Consolas,Bitstream Vera Sans Mono,monospace;font-size:14px;text-align:left}.vjs-tree.is-virtual{overflow:auto}.vjs-tree.is-virtual .vjs-tree-node{white-space:nowrap}#alertModal{z-index:99999;background:#00000080}#alertModal svg{display:block;margin:0 auto;width:4rem;height:4rem}
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "laravel-horizon",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "build": "vite build",
7 | "watch": "vite build --watch"
8 | },
9 | "devDependencies": {
10 | "@vitejs/plugin-vue": "^5.1.3",
11 | "axios": "^1.8.2",
12 | "bootstrap": "~5.1.3",
13 | "chart.js": "^2.9.4",
14 | "highlight.js": "^10.7.3",
15 | "md5": "^2.3.0",
16 | "moment": "^2.30.1",
17 | "moment-timezone": "^0.5.45",
18 | "phpunserialize": "^1.3.0",
19 | "sass": "^1.74.1",
20 | "sql-formatter": "^4.0.2",
21 | "vite": "^5.4.14",
22 | "vue": "^3.5.4",
23 | "vue-json-pretty": "^2.4.0",
24 | "vue-router": "^4.4.4"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/resources/js/app.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { createApp } from 'vue/dist/vue.esm-bundler.js';
3 | import { createRouter, createWebHistory } from 'vue-router';
4 | import VueJsonPretty from 'vue-json-pretty';
5 | import 'vue-json-pretty/lib/styles.css';
6 | import Base from './base';
7 | import Routes from './routes';
8 | import Alert from './components/Alert.vue';
9 | import SchemeToggler from './components/SchemeToggler.vue';
10 |
11 | let token = document.head.querySelector("meta[name='csrf-token']");
12 |
13 | axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
14 |
15 | if (token) {
16 | axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
17 | }
18 |
19 | const app = createApp({
20 | data() {
21 | return {
22 | alert: {
23 | type: null,
24 | autoClose: 0,
25 | message: '',
26 | confirmationProceed: null,
27 | confirmationCancel: null,
28 | },
29 | autoLoadsNewEntries: localStorage.autoLoadsNewEntries === '1',
30 | };
31 | },
32 | });
33 |
34 | app.config.globalProperties.$http = axios.create();
35 |
36 | let proxyPath = window.Horizon.proxy_path;
37 | window.Horizon.basePath = proxyPath + '/' + window.Horizon.path;
38 |
39 | let routerBasePath = window.Horizon.basePath + '/';
40 |
41 | if (window.Horizon.path === '' || window.Horizon.path === '/') {
42 | routerBasePath = proxyPath + '/';
43 | window.Horizon.basePath = proxyPath;
44 | }
45 |
46 | const router = createRouter({
47 | history: createWebHistory(routerBasePath),
48 | routes: Routes,
49 | });
50 |
51 | app.use(router);
52 |
53 | app.component('vue-json-pretty', VueJsonPretty);
54 | app.component('alert', Alert);
55 | app.component('scheme-toggler', SchemeToggler);
56 |
57 | app.mixin(Base);
58 |
59 | app.mount('#horizon');
60 |
--------------------------------------------------------------------------------
/resources/js/base.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment-timezone';
2 |
3 | export default {
4 | computed: {
5 | Horizon() {
6 | return Horizon;
7 | },
8 | },
9 |
10 | methods: {
11 | /**
12 | * Format the given date with respect to timezone.
13 | */
14 | formatDate(unixTime) {
15 | return moment(unixTime * 1000).add(new Date().getTimezoneOffset() / 60);
16 | },
17 |
18 | /**
19 | * Format the given date with respect to timezone.
20 | */
21 | formatDateIso(date) {
22 | return moment(date).add(new Date().getTimezoneOffset() / 60);
23 | },
24 |
25 | /**
26 | * Extract the job base name.
27 | */
28 | jobBaseName(name) {
29 | if (!name.includes('\\')) return name;
30 |
31 | var parts = name.split('\\');
32 |
33 | return parts[parts.length - 1];
34 | },
35 |
36 | /**
37 | * Autoload new entries in listing screens.
38 | */
39 | autoLoadNewEntries() {
40 | if (!this.autoLoadsNewEntries) {
41 | this.autoLoadsNewEntries = true;
42 | localStorage.autoLoadsNewEntries = 1;
43 | } else {
44 | this.autoLoadsNewEntries = false;
45 | localStorage.autoLoadsNewEntries = 0;
46 | }
47 | },
48 |
49 | /**
50 | * Convert to human readable timestamp.
51 | */
52 | readableTimestamp(timestamp) {
53 | return this.formatDate(timestamp).format('YYYY-MM-DD HH:mm:ss');
54 | },
55 |
56 | /**
57 | * Uppercase the first character of the string.
58 | */
59 | upperFirst(string) {
60 | return string.charAt(0).toUpperCase() + string.slice(1);
61 | },
62 |
63 | /**
64 | * Group array entries by a given key.
65 | */
66 | groupBy(array, key) {
67 | return array.reduce(
68 | (grouped, entry) => ({
69 | ...grouped,
70 | [entry[key]]: [...(grouped[entry[key]] || []), entry],
71 | }),
72 | {}
73 | );
74 | },
75 | },
76 | };
77 |
--------------------------------------------------------------------------------
/resources/js/components/Alert.vue:
--------------------------------------------------------------------------------
1 |
79 |
80 |
81 |
82 |
83 |
84 |
87 |
88 |
89 |
108 |
109 |
110 |
111 |
112 |
113 |
126 |
--------------------------------------------------------------------------------
/resources/js/components/LineChart.vue:
--------------------------------------------------------------------------------
1 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/resources/js/components/SchemeToggler.vue:
--------------------------------------------------------------------------------
1 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/resources/js/components/Stacktrace.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {{line}}
29 |
30 |
31 |
32 | Show All
33 |
34 |
35 |
36 |
37 |
38 |
39 |
42 |
--------------------------------------------------------------------------------
/resources/js/routes.js:
--------------------------------------------------------------------------------
1 | import dashboard from './screens/dashboard.vue';
2 | import monitoring from './screens/monitoring/index.vue';
3 | import monitoringTag from './screens/monitoring/tag.vue';
4 | import monitoringTagJobs from './screens/monitoring/tag-jobs.vue';
5 | import metrics from './screens/metrics/index.vue';
6 | import metricsJobs from './screens/metrics/jobs.vue';
7 | import metricsQueues from './screens/metrics/queues.vue';
8 | import metricsPreview from './screens/metrics/preview.vue';
9 | import recentJobs from './screens/recentJobs/index.vue';
10 | import recentJobsJob from './screens/recentJobs/job.vue';
11 | import failedJobs from './screens/failedJobs/index.vue';
12 | import failedJobsJob from './screens/failedJobs/job.vue';
13 | import batches from './screens/batches/index.vue';
14 | import batchesPreview from './screens/batches/preview.vue';
15 |
16 | export default [
17 | { path: '/', redirect: '/dashboard' },
18 |
19 | {
20 | path: '/dashboard',
21 | name: 'dashboard',
22 | component: dashboard,
23 | },
24 |
25 | {
26 | path: '/monitoring',
27 | children: [
28 | {
29 | path: '',
30 | name: 'monitoring',
31 | component: monitoring,
32 | },
33 | {
34 | path: ':tag',
35 | component: monitoringTag,
36 | children: [
37 | {
38 | path: 'jobs',
39 | name: 'monitoring-jobs',
40 | component: monitoringTagJobs,
41 | props: { type: 'jobs' },
42 | },
43 | {
44 | path: 'failed',
45 | name: 'monitoring-failed',
46 | component: monitoringTagJobs,
47 | props: { type: 'failed' },
48 | },
49 | ],
50 | },
51 | ],
52 | },
53 |
54 | {
55 | path: '/metrics',
56 | redirect: '/metrics/jobs',
57 | children: [
58 | {
59 | path: 'jobs',
60 | component: metrics,
61 | children: [{ path: '', name: 'metrics-jobs', component: metricsJobs }],
62 | },
63 | {
64 | path: 'queues',
65 | component: metrics,
66 | children: [{ path: '', name: 'metrics-queues', component: metricsQueues }],
67 | },
68 | {
69 | path: ':type/:slug',
70 | name: 'metrics-preview',
71 | component: metricsPreview,
72 | },
73 | ],
74 | },
75 |
76 | {
77 | path: '/jobs/:type',
78 | children: [
79 | {
80 | path: '',
81 | name: 'jobs',
82 | component: recentJobs,
83 | },
84 | {
85 | path: ':jobId',
86 | name: 'job-preview',
87 | component: recentJobsJob,
88 | },
89 | ],
90 | },
91 |
92 | {
93 | path: '/failed',
94 | children: [
95 | {
96 | path: '',
97 | name: 'failed-jobs',
98 | component: failedJobs,
99 | },
100 | {
101 | path: ':jobId',
102 | name: 'failed-jobs-preview',
103 | component: failedJobsJob,
104 | },
105 | ],
106 | },
107 |
108 | {
109 | path: '/batches',
110 | children: [
111 | {
112 | path: '',
113 | name: 'batches',
114 | component: batches,
115 | },
116 | {
117 | path: ':batchId',
118 | name: 'batches-preview',
119 | component: batchesPreview,
120 | },
121 | ],
122 | },
123 | ];
124 |
--------------------------------------------------------------------------------
/resources/js/screens/metrics/index.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
18 |
19 |
20 |
21 |
22 | Jobs
23 |
24 |
25 |
26 |
27 |
28 | Queues
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/resources/js/screens/metrics/jobs.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
Loading...
51 |
52 |
53 |
54 |
55 | There aren't any jobs.
56 |
57 |
58 |
59 |
60 |
61 | Job
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | {{ job }}
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/resources/js/screens/metrics/queues.vue:
--------------------------------------------------------------------------------
1 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
Loading...
51 |
52 |
53 |
54 |
55 | There aren't any queues.
56 |
57 |
58 |
59 |
60 |
61 | Queue
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | {{ queue }}
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/resources/js/screens/monitoring/job-row.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ jobBaseName(job.name) }}
6 |
7 |
8 |
10 | Delayed
11 |
12 |
13 |
14 |
15 |
16 | Queue: {{job.queue}}
17 |
18 |
19 | | Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.slice(0,3).join(', ') : '' }} ({{ job.payload.tags.length - 3 }} more)
20 |
21 |
22 |
23 |
24 |
25 | {{ readableTimestamp(job.payload.pushedAt) }}
26 |
27 |
28 |
29 | {{ job.completed_at ? readableTimestamp(job.completed_at) : '-' }}
30 |
31 |
32 |
33 | {{ job.completed_at ? (job.completed_at - job.reserved_at).toFixed(2)+'s' : '-' }}
34 |
35 |
36 |
37 | {{ readableTimestamp(job.failed_at) }}
38 |
39 |
40 |
41 |
42 |
74 |
--------------------------------------------------------------------------------
/resources/js/screens/monitoring/tag.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
11 |
12 |
13 |
14 |
15 | Recent Jobs
16 |
17 |
18 |
19 |
20 |
21 | Failed Jobs
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/resources/js/screens/recentJobs/job-row.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ jobBaseName(job.name) }}
6 |
7 |
8 |
11 | Delayed
12 |
13 |
14 |
15 |
16 |
17 | Queue: {{job.queue}}
18 |
19 |
20 | | Tags: {{ job.payload.tags && job.payload.tags.length ? job.payload.tags.slice(0,3).join(', ') : '' }} ({{ job.payload.tags.length - 3 }} more)
21 |
22 |
23 |
24 |
25 |
26 | {{ readableTimestamp(job.payload.pushedAt) }}
27 |
28 |
29 |
30 | {{ readableTimestamp(job.completed_at) }}
31 |
32 |
33 |
34 | {{ job.completed_at ? (job.completed_at - job.reserved_at).toFixed(2)+'s' : '-' }}
35 |
36 |
37 |
38 |
39 |
74 |
--------------------------------------------------------------------------------
/resources/sass/_colors.scss:
--------------------------------------------------------------------------------
1 | $white: #ffffff;
2 | $black: #000000;
3 |
4 | $gray-50: #f9fafb;
5 | $gray-100: #f3f4f6;
6 | $gray-200: #e5e7eb;
7 | $gray-300: #d1d5db;
8 | $gray-400: #9ca3af;
9 | $gray-500: #6b7280;
10 | $gray-600: #4b5563;
11 | $gray-700: #374151;
12 | $gray-800: #1f2937;
13 | $gray-900: #111827;
14 |
15 | $red-50: #fef2f2;
16 | $red-100: #fee2e2;
17 | $red-200: #fecaca;
18 | $red-300: #fca5a5;
19 | $red-400: #f87171;
20 | $red-500: #ef4444;
21 | $red-600: #dc2626;
22 | $red-700: #b91c1c;
23 | $red-800: #991b1b;
24 | $red-900: #7f1d1d;
25 |
26 | $amber-50: #fffbeb;
27 | $amber-100: #fef3c7;
28 | $amber-200: #fde68a;
29 | $amber-300: #fcd34d;
30 | $amber-400: #fbbf24;
31 | $amber-500: #f59e0b;
32 | $amber-600: #d97706;
33 | $amber-700: #b45309;
34 | $amber-800: #92400e;
35 | $amber-900: #78350f;
36 |
37 | $emerald-50: #ecfdf5;
38 | $emerald-100: #d1fae5;
39 | $emerald-200: #a7f3d0;
40 | $emerald-300: #6ee7b7;
41 | $emerald-400: #34d399;
42 | $emerald-500: #10b981;
43 | $emerald-600: #059669;
44 | $emerald-700: #047857;
45 | $emerald-800: #065f46;
46 | $emerald-900: #064e3b;
47 |
48 | $blue-50: #eff6ff;
49 | $blue-100: #dbeafe;
50 | $blue-200: #bfdbfe;
51 | $blue-300: #93c5fd;
52 | $blue-400: #60a5fa;
53 | $blue-500: #3b82f6;
54 | $blue-600: #2563eb;
55 | $blue-700: #1d4ed8;
56 | $blue-800: #1e40af;
57 | $blue-900: #1e3a8a;
58 |
59 | $violet-50: #f5f3ff;
60 | $violet-100: #ede9fe;
61 | $violet-200: #ddd6fe;
62 | $violet-300: #c4b5fd;
63 | $violet-400: #a78bfa;
64 | $violet-500: #8b5cf6;
65 | $violet-600: #7c3aed;
66 | $violet-700: #6d28d9;
67 | $violet-800: #5b21b6;
68 | $violet-900: #4c1d95;
69 |
--------------------------------------------------------------------------------
/resources/sass/styles-dark.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 |
3 | $font-family-base: Figtree, sans-serif;
4 | $font-weight-bold: 600;
5 | $font-size-base: 1rem;
6 | $badge-font-size: 0.875rem;
7 |
8 | $link-decoration: none;
9 | $link-hover-decoration: underline;
10 |
11 | $primary: $violet-500;
12 | $secondary: $gray-500;
13 | $success: $emerald-500;
14 | $info: $blue-500;
15 | $warning: $amber-500;
16 | $danger: $red-500;
17 |
18 | $body-bg: $gray-900;
19 | $body-color: $gray-100;
20 |
21 | $text-muted: $gray-400;
22 |
23 | $border-radius-lg: 6px;
24 |
25 | $logo-color: $gray-200;
26 |
27 | $link-color: $violet-400;
28 | $link-hover-color: $violet-300;
29 |
30 | $sidebar-nav-color: $gray-400;
31 | $sidebar-nav-hover-color: $gray-300;
32 | $sidebar-nav-hover-bg: $gray-800;
33 | $sidebar-nav-icon-color: $gray-500;
34 | $sidebar-nav-active-bg: $gray-800;
35 | $sidebar-nav-active-color: $violet-400;
36 | $sidebar-nav-active-icon-color: $violet-500;
37 |
38 | $pill-link: $gray-400;
39 | $pill-link-active: $violet-400;
40 | $pill-link-hover: $gray-200;
41 |
42 | $border-color: $gray-600;
43 | $table-border-color: $gray-700;
44 | $table-headers-color: $gray-800;
45 | $table-hover-bg: $gray-700;
46 |
47 | $header-border-color: $table-border-color;
48 |
49 | $input-bg: $gray-800;
50 | $input-color: $gray-200;
51 | $input-border-color: $border-color;
52 |
53 | $card-cap-bg: $gray-700;
54 | $card-bg-secondary: $gray-800;
55 | $card-bg: $gray-800;
56 | $card-border-radius: $border-radius-lg;
57 |
58 | $code-bg: #292d3e;
59 |
60 | $modal-content-bg: $table-headers-color;
61 | $modal-backdrop-bg: $gray-600;
62 | $modal-footer-border-color: $input-border-color;
63 | $modal-header-border-color: $input-border-color;
64 |
65 | $new-entries-bg: $violet-900;
66 | $new-code-entries-bg: $gray-600;
67 |
68 | $control-action-icon-color: $gray-500;
69 | $control-action-icon-hover: $violet-400;
70 |
71 | $nav-pills-link-active-bg: $gray-800;
72 |
73 | $dropdown-bg: $gray-700;
74 | $dropdown-link-color: $white;
75 |
76 | $btn-muted-color: $gray-400;
77 | $btn-muted-bg: $gray-800;
78 | $btn-muted-hover-color: $gray-300;
79 | $btn-muted-hover-bg: $gray-700;
80 | $btn-muted-active-color: $white;
81 | $btn-muted-active-bg: $primary;
82 |
83 | $badge-secondary-bg: $gray-300;
84 | $badge-secondary-color: $gray-700;
85 | $badge-success-bg: $emerald-500;
86 | $badge-success-color: $white;
87 | $badge-info-bg: $blue-500;
88 | $badge-info-color: $white;
89 | $badge-warning-bg: $amber-500;
90 | $badge-warning-color: $white;
91 | $badge-danger-bg: $red-500;
92 | $badge-danger-color: $white;
93 |
94 | $grid-breakpoints: (
95 | xs: 0,
96 | sm: 2px,
97 | md: 8px,
98 | lg: 9px,
99 | xl: 10px,
100 | ) !default;
101 |
102 | $container-max-widths: (
103 | sm: 1137px,
104 | md: 1138px,
105 | lg: 1139px,
106 | xl: 1140px,
107 | ) !default;
108 |
109 | @import 'base';
110 |
111 | .btn-primary {
112 | color: rgb(255, 255, 255);
113 | }
114 |
--------------------------------------------------------------------------------
/resources/sass/styles.scss:
--------------------------------------------------------------------------------
1 | @import 'colors';
2 |
3 | $font-family-base: Figtree, sans-serif;
4 | $font-weight-bold: 600;
5 | $font-size-base: 1rem;
6 | $badge-font-size: 0.875rem;
7 |
8 | $link-decoration: none;
9 | $link-hover-decoration: underline;
10 |
11 | $primary: #7746ec;
12 | $secondary: $gray-500;
13 | $success: $emerald-500;
14 | $info: $blue-500;
15 | $warning: $amber-500;
16 | $danger: $red-500;
17 |
18 | $body-bg: $gray-100;
19 | $body-color: $gray-900;
20 |
21 | $text-muted: $gray-500;
22 |
23 | $border-radius-lg: 6px;
24 |
25 | $btn-focus-width: 0;
26 |
27 | $logo-color: $gray-700;
28 |
29 | $sidebar-nav-color: $gray-600;
30 | $sidebar-nav-hover-color: $primary;
31 | $sidebar-nav-hover-bg: $gray-200;
32 | $sidebar-nav-icon-color: $gray-400;
33 | $sidebar-nav-active-bg: $gray-200;
34 | $sidebar-nav-active-color: $primary;
35 | $sidebar-nav-active-icon-color: $primary;
36 |
37 | $pill-link: $gray-600;
38 | $pill-link-active: $violet-600;
39 | $pill-link-hover: $gray-800;
40 |
41 | $border-color: $gray-300;
42 | $table-headers-color: $gray-100;
43 | $table-border-color: $gray-200;
44 | $table-hover-bg: $gray-100;
45 |
46 | $header-border-color: $table-border-color;
47 |
48 | $input-bg: $white;
49 | $input-color: $gray-800;
50 | $input-border-color: $border-color;
51 |
52 | $card-cap-bg: $white;
53 | $card-bg-secondary: $gray-100;
54 | $card-bg: $white;
55 | $card-border-radius: $border-radius-lg;
56 |
57 | $code-bg: #292d3e;
58 |
59 | $new-entries-bg: $violet-50;
60 | $new-code-entries-bg: $gray-600;
61 |
62 | $control-action-icon-color: $gray-300;
63 | $control-action-icon-hover: $violet-600;
64 |
65 | $nav-pills-link-active-bg: $gray-200;
66 |
67 | $dropdown-bg: $white;
68 | $dropdown-link-color: $gray-700;
69 |
70 | $btn-muted-color: $gray-600;
71 | $btn-muted-bg: $gray-200;
72 | $btn-muted-hover-color: $gray-900;
73 | $btn-muted-hover-bg: $gray-300;
74 | $btn-muted-active-color: $white;
75 | $btn-muted-active-bg: $primary;
76 |
77 | $badge-secondary-bg: $gray-200;
78 | $badge-secondary-color: $gray-600;
79 | $badge-success-bg: $emerald-100;
80 | $badge-success-color: $emerald-600;
81 | $badge-info-bg: $blue-100;
82 | $badge-info-color: $blue-600;
83 | $badge-warning-bg: $amber-100;
84 | $badge-warning-color: $amber-600;
85 | $badge-danger-bg: $red-100;
86 | $badge-danger-color: $red-600;
87 |
88 | $grid-breakpoints: (
89 | xs: 0,
90 | sm: 2px,
91 | md: 8px,
92 | lg: 9px,
93 | xl: 10px
94 | ) !default;
95 |
96 | $container-max-widths: (
97 | sm: 1137px,
98 | md: 1138px,
99 | lg: 1139px,
100 | xl: 1140px
101 | ) !default;
102 |
103 | @import 'base';
104 |
--------------------------------------------------------------------------------
/resources/sass/syntaxhighlight.scss:
--------------------------------------------------------------------------------
1 | .vjs-tree {
2 | font-family: 'Monaco', 'Menlo', 'Consolas', 'Bitstream Vera Sans Mono', monospace !important;
3 | &.is-root {
4 | position: relative;
5 | }
6 | .vjs-tree-node {
7 | display: flex;
8 | position: relative;
9 | &:hover {
10 | background-color: unset;
11 | }
12 | .vjs-indent-unit {
13 | &.has-line {
14 | border-left: 1px dotted rgba(204, 204, 204, 0.28) !important;
15 | }
16 | }
17 | &.has-carets {
18 | padding-left: 15px;
19 | }
20 | .has-carets.has-selector,
21 | .has-selector {
22 | padding-left: 30px;
23 | }
24 | }
25 | .vjs-indent {
26 | display: flex;
27 | position: relative;
28 | }
29 | .vjs-indent-unit {
30 | width: 1em;
31 | }
32 | .vjs-tree-brackets {
33 | cursor: pointer;
34 | &:hover {
35 | color: #20a0ff;
36 | }
37 | }
38 | .vjs-key {
39 | color: #c3cbd3 !important;
40 | padding-right: 10px;
41 | }
42 | .vjs-value {
43 | @extend .text-break;
44 | }
45 | .vjs-value-string {
46 | color: #c3e88d !important;
47 | }
48 | .vjs-value-null,
49 | .vjs-value-number,
50 | .vjs-value-boolean,
51 | .vjs-value-undefined {
52 | color: #a291f5 !important;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/routes/web.php:
--------------------------------------------------------------------------------
1 | group(function () {
6 | // Dashboard Routes...
7 | Route::get('/stats', 'DashboardStatsController@index')->name('horizon.stats.index');
8 |
9 | // Workload Routes...
10 | Route::get('/workload', 'WorkloadController@index')->name('horizon.workload.index');
11 |
12 | // Master Supervisor Routes...
13 | Route::get('/masters', 'MasterSupervisorController@index')->name('horizon.masters.index');
14 |
15 | // Monitoring Routes...
16 | Route::get('/monitoring', 'MonitoringController@index')->name('horizon.monitoring.index');
17 | Route::post('/monitoring', 'MonitoringController@store')->name('horizon.monitoring.store');
18 | Route::get('/monitoring/{tag}', 'MonitoringController@paginate')->name('horizon.monitoring-tag.paginate');
19 | Route::delete('/monitoring/{tag}', 'MonitoringController@destroy')
20 | ->name('horizon.monitoring-tag.destroy')
21 | ->where('tag', '.*');
22 |
23 | // Job Metric Routes...
24 | Route::get('/metrics/jobs', 'JobMetricsController@index')->name('horizon.jobs-metrics.index');
25 | Route::get('/metrics/jobs/{id}', 'JobMetricsController@show')->name('horizon.jobs-metrics.show');
26 |
27 | // Queue Metric Routes...
28 | Route::get('/metrics/queues', 'QueueMetricsController@index')->name('horizon.queues-metrics.index');
29 | Route::get('/metrics/queues/{id}', 'QueueMetricsController@show')->name('horizon.queues-metrics.show');
30 |
31 | // Batches Routes...
32 | Route::get('/batches', 'BatchesController@index')->name('horizon.jobs-batches.index');
33 | Route::get('/batches/{id}', 'BatchesController@show')->name('horizon.jobs-batches.show');
34 | Route::post('/batches/retry/{id}', 'BatchesController@retry')->name('horizon.jobs-batches.retry');
35 |
36 | // Job Routes...
37 | Route::get('/jobs/pending', 'PendingJobsController@index')->name('horizon.pending-jobs.index');
38 | Route::get('/jobs/completed', 'CompletedJobsController@index')->name('horizon.completed-jobs.index');
39 | Route::get('/jobs/silenced', 'SilencedJobsController@index')->name('horizon.silenced-jobs.index');
40 | Route::get('/jobs/failed', 'FailedJobsController@index')->name('horizon.failed-jobs.index');
41 | Route::get('/jobs/failed/{id}', 'FailedJobsController@show')->name('horizon.failed-jobs.show');
42 | Route::post('/jobs/retry/{id}', 'RetryController@store')->name('horizon.retry-jobs.show');
43 | Route::get('/jobs/{id}', 'JobsController@show')->name('horizon.jobs.show');
44 | });
45 |
46 | // Catch-all Route...
47 | Route::get('/{view?}', 'HomeController@index')->where('view', '(.*)')->name('horizon.index');
48 |
--------------------------------------------------------------------------------
/src/BackgroundProcess.php:
--------------------------------------------------------------------------------
1 | redis, $config['queue'],
21 | Arr::get($config, 'connection', $this->connection),
22 | Arr::get($config, 'retry_after', 60),
23 | Arr::get($config, 'block_for', null),
24 | Arr::get($config, 'after_commit', null)
25 | );
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Console/ClearCommand.php:
--------------------------------------------------------------------------------
1 | confirmToProceed()) {
43 | return 1;
44 | }
45 |
46 | if (! method_exists(RedisQueue::class, 'clear')) {
47 | $this->components->error('Clearing queues is not supported on this version of Laravel.');
48 |
49 | return 1;
50 | }
51 |
52 | $connection = $this->argument('connection')
53 | ?: Arr::first($this->laravel['config']->get('horizon.defaults'))['connection'] ?? 'redis';
54 |
55 | if (method_exists($jobRepository, 'purge')) {
56 | $jobRepository->purge($queue = $this->getQueue($connection));
57 | }
58 |
59 | $count = $manager->connection($connection)->clear($queue);
60 |
61 | $this->components->info('Cleared '.$count.' jobs from the ['.$queue.'] queue.');
62 |
63 | return 0;
64 | }
65 |
66 | /**
67 | * Get the queue name to clear.
68 | *
69 | * @param string $connection
70 | * @return string
71 | */
72 | protected function getQueue($connection)
73 | {
74 | return $this->option('queue') ?: $this->laravel['config']->get(
75 | "queue.connections.{$connection}.queue",
76 | 'default'
77 | );
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/Console/ClearMetricsCommand.php:
--------------------------------------------------------------------------------
1 | clear();
35 |
36 | $this->components->info('Metrics cleared successfully.');
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Console/ContinueCommand.php:
--------------------------------------------------------------------------------
1 | all())->filter(function ($master) {
38 | return Str::startsWith($master->name, MasterSupervisor::basename());
39 | })->all();
40 |
41 | collect(Arr::pluck($masters, 'pid'))
42 | ->whenNotEmpty(fn () => $this->components->info('Sending CONT signal to processes.'))
43 | ->whenEmpty(fn () => $this->components->info('No processes to continue.'))
44 | ->each(function ($processId) {
45 | $result = true;
46 |
47 | $this->components->task("Process: $processId", function () use ($processId, &$result) {
48 | return $result = posix_kill($processId, SIGCONT);
49 | });
50 |
51 | if (! $result) {
52 | $this->components->error("Failed to kill process: {$processId} (".posix_strerror(posix_get_last_error()).')');
53 | }
54 | })->whenNotEmpty(fn () => $this->output->writeln(''));
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Console/ContinueSupervisorCommand.php:
--------------------------------------------------------------------------------
1 | all())->first(function ($supervisor) {
38 | return Str::startsWith($supervisor->name, MasterSupervisor::basename())
39 | && Str::endsWith($supervisor->name, $this->argument('name'));
40 | }))->pid;
41 |
42 | if (is_null($processId)) {
43 | $this->components->error('Failed to find a supervisor with this name');
44 |
45 | return 1;
46 | }
47 |
48 | $this->components->info("Sending CONT signal to process: {$processId}");
49 |
50 | if (! posix_kill($processId, SIGCONT)) {
51 | $this->components->error("Failed to send CONT signal to process: {$processId} (".posix_strerror(posix_get_last_error()).')');
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/Console/ForgetFailedCommand.php:
--------------------------------------------------------------------------------
1 | option('all')) {
34 | $totalFailedCount = $repository->totalFailed();
35 |
36 | do {
37 | $failedJobs = collect($repository->getFailed());
38 |
39 | $failedJobs->pluck('id')->each(function ($failedId) use ($repository): void {
40 | $repository->deleteFailed($failedId);
41 |
42 | if ($this->laravel['queue.failer']->forget($failedId)) {
43 | $this->components->info('Failed job (id): '.$failedId.' deleted successfully!');
44 | }
45 | });
46 | } while ($repository->totalFailed() !== 0 && $failedJobs->isNotEmpty());
47 |
48 | if ($totalFailedCount) {
49 | $this->components->info($totalFailedCount.' failed jobs deleted successfully!');
50 | } else {
51 | $this->components->info('No failed jobs detected.');
52 | }
53 |
54 | return;
55 | }
56 |
57 | if (! $this->argument('id')) {
58 | $this->components->error('No failed job ID provided.');
59 | }
60 |
61 | $repository->deleteFailed($this->argument('id'));
62 |
63 | if ($this->laravel['queue.failer']->forget($this->argument('id'))) {
64 | $this->components->info('Failed job deleted successfully!');
65 | } else {
66 | $this->components->error('No failed job matches the given ID.');
67 |
68 | return 1;
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/Console/HorizonCommand.php:
--------------------------------------------------------------------------------
1 | find(MasterSupervisor::name())) {
37 | return $this->components->warn('A master supervisor is already running on this machine.');
38 | }
39 |
40 | $environment = $this->option('environment') ?? config('horizon.env') ?? config('app.env');
41 |
42 | $master = (new MasterSupervisor($environment))->handleOutputUsing(function ($type, $line) {
43 | $this->output->write($line);
44 | });
45 |
46 | ProvisioningPlan::get(MasterSupervisor::name())->deploy($environment);
47 |
48 | $this->components->info('Horizon started successfully.');
49 |
50 | pcntl_async_signals(true);
51 |
52 | pcntl_signal(SIGINT, function () use ($master) {
53 | $this->output->writeln('');
54 |
55 | $this->components->info('Shutting down.');
56 |
57 | return $master->terminate();
58 | });
59 |
60 | $master->monitor();
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Console/InstallCommand.php:
--------------------------------------------------------------------------------
1 | components->info('Installing Horizon resources.');
35 |
36 | collect([
37 | 'Service Provider' => fn () => $this->callSilent('vendor:publish', ['--tag' => 'horizon-provider']) == 0,
38 | 'Configuration' => fn () => $this->callSilent('vendor:publish', ['--tag' => 'horizon-config']) == 0,
39 | ])->each(fn ($task, $description) => $this->components->task($description, $task));
40 |
41 | $this->registerHorizonServiceProvider();
42 |
43 | $this->components->info('Horizon scaffolding installed successfully.');
44 | }
45 |
46 | /**
47 | * Register the Horizon service provider in the application configuration file.
48 | *
49 | * @return void
50 | */
51 | protected function registerHorizonServiceProvider()
52 | {
53 | $namespace = Str::replaceLast('\\', '', $this->laravel->getNamespace());
54 |
55 | if (file_exists($this->laravel->bootstrapPath('providers.php'))) {
56 | ServiceProvider::addProviderToBootstrapFile("{$namespace}\\Providers\\HorizonServiceProvider");
57 | } else {
58 | $appConfig = file_get_contents(config_path('app.php'));
59 |
60 | if (Str::contains($appConfig, $namespace.'\\Providers\\HorizonServiceProvider::class')) {
61 | return;
62 | }
63 |
64 | file_put_contents(config_path('app.php'), str_replace(
65 | "{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL,
66 | "{$namespace}\\Providers\EventServiceProvider::class,".PHP_EOL." {$namespace}\Providers\HorizonServiceProvider::class,".PHP_EOL,
67 | $appConfig
68 | ));
69 | }
70 |
71 | file_put_contents(app_path('Providers/HorizonServiceProvider.php'), str_replace(
72 | "namespace App\Providers;",
73 | "namespace {$namespace}\Providers;",
74 | file_get_contents(app_path('Providers/HorizonServiceProvider.php'))
75 | ));
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Console/ListCommand.php:
--------------------------------------------------------------------------------
1 | all();
35 |
36 | if (empty($masters)) {
37 | return $this->components->info('No machines are running.');
38 | }
39 |
40 | $this->output->writeln('');
41 |
42 | $this->table([
43 | 'Name', 'PID', 'Supervisors', 'Status',
44 | ], collect($masters)->map(function ($master) {
45 | return [
46 | $master->name,
47 | $master->pid,
48 | $master->supervisors ? collect($master->supervisors)->map(function ($supervisor) {
49 | return explode(':', $supervisor, 2)[1];
50 | })->implode(', ') : 'None',
51 | $master->status,
52 | ];
53 | })->all());
54 |
55 | $this->output->writeln('');
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Console/PauseCommand.php:
--------------------------------------------------------------------------------
1 | all())->filter(function ($master) {
38 | return Str::startsWith($master->name, MasterSupervisor::basename());
39 | })->all();
40 |
41 | collect(Arr::pluck($masters, 'pid'))
42 | ->whenNotEmpty(fn () => $this->components->info('Sending USR2 signal to processes.'))
43 | ->whenEmpty(fn () => $this->components->info('No processes to pause.'))
44 | ->each(function ($processId) {
45 | $result = true;
46 |
47 | $this->components->task("Process: $processId", function () use ($processId, &$result) {
48 | return $result = posix_kill($processId, SIGUSR2);
49 | });
50 |
51 | if (! $result) {
52 | $this->components->error("Failed to kill process: {$processId} (".posix_strerror(posix_get_last_error()).')');
53 | }
54 | })->whenNotEmpty(fn () => $this->output->writeln(''));
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Console/PauseSupervisorCommand.php:
--------------------------------------------------------------------------------
1 | all())->first(function ($supervisor) {
38 | return Str::startsWith($supervisor->name, MasterSupervisor::basename())
39 | && Str::endsWith($supervisor->name, $this->argument('name'));
40 | }))->pid;
41 |
42 | if (is_null($processId)) {
43 | $this->components->error('Failed to find a supervisor with this name');
44 |
45 | return 1;
46 | }
47 |
48 | $this->components->info("Sending USR2 signal to process: {$processId}");
49 |
50 | if (! posix_kill($processId, SIGUSR2)) {
51 | $this->components->error("Failed to send USR2 signal to process: {$processId} (".posix_strerror(posix_get_last_error()).')');
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/Console/PublishCommand.php:
--------------------------------------------------------------------------------
1 | components->warn('Horizon no longer publishes its assets. You may stop calling the `horizon:publish` command.');
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Console/SnapshotCommand.php:
--------------------------------------------------------------------------------
1 | get('metrics:snapshot', config('horizon.metrics.snapshot_lock', 300) - 30)) {
37 | $metrics->snapshot();
38 |
39 | $this->components->info('Metrics snapshot stored successfully.');
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Console/StatusCommand.php:
--------------------------------------------------------------------------------
1 | all()) {
35 | $this->components->error('Horizon is inactive.');
36 |
37 | return 2;
38 | }
39 |
40 | if (collect($masters)->contains(function ($master) {
41 | return $master->status === 'paused';
42 | })) {
43 | $this->components->warn('Horizon is paused.');
44 |
45 | return 1;
46 | }
47 |
48 | $this->components->info('Horizon is running.');
49 |
50 | return 0;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Console/SupervisorStatusCommand.php:
--------------------------------------------------------------------------------
1 | argument('name');
38 |
39 | $supervisorStatus = optional(collect($supervisors->all())->first(function ($supervisor) use ($name) {
40 | return Str::startsWith($supervisor->name, MasterSupervisor::basename()) &&
41 | Str::endsWith($supervisor->name, $name);
42 | }))->status;
43 |
44 | if (is_null($supervisorStatus)) {
45 | $this->components->error('Unable to find a supervisor with this name.');
46 |
47 | return 1;
48 | }
49 |
50 | $this->components->info("{$name} is {$supervisorStatus}");
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Console/SupervisorsCommand.php:
--------------------------------------------------------------------------------
1 | all();
35 |
36 | if (empty($supervisors)) {
37 | return $this->components->info('No supervisors are running.');
38 | }
39 |
40 | $this->output->writeln('');
41 |
42 | $this->table([
43 | 'Name', 'PID', 'Status', 'Workers', 'Balancing',
44 | ], collect($supervisors)->map(function ($supervisor) {
45 | return [
46 | $supervisor->name,
47 | $supervisor->pid,
48 | $supervisor->status,
49 | collect($supervisor->processes)->map(function ($count, $queue) {
50 | return $queue.' ('.$count.')';
51 | })->implode(', '),
52 | $supervisor->options['balance'],
53 | ];
54 | })->all());
55 |
56 | $this->output->writeln('');
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/Console/TerminateCommand.php:
--------------------------------------------------------------------------------
1 | forever(
45 | 'horizon:terminate:wait', $this->option('wait')
46 | );
47 | }
48 |
49 | $masters = collect($masters->all())->filter(function ($master) {
50 | return Str::startsWith($master->name, MasterSupervisor::basename());
51 | })->all();
52 |
53 | collect(Arr::pluck($masters, 'pid'))
54 | ->whenNotEmpty(fn () => $this->components->info('Sending TERM signal to processes.'))
55 | ->whenEmpty(fn () => $this->components->info('No processes to terminate.'))
56 | ->each(function ($processId) {
57 | $result = true;
58 |
59 | $this->components->task("Process: $processId", function () use ($processId, &$result) {
60 | return $result = posix_kill($processId, SIGTERM);
61 | });
62 |
63 | if (! $result) {
64 | $this->components->error("Failed to kill process: {$processId} (".posix_strerror(posix_get_last_error()).')');
65 | }
66 | })->whenNotEmpty(fn () => $this->output->writeln(''));
67 |
68 | $this->laravel['cache']->forever('illuminate:queue:restart', $this->currentTime());
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Console/TimeoutCommand.php:
--------------------------------------------------------------------------------
1 | plan;
42 |
43 | $environment = $this->argument('environment');
44 |
45 | $timeout = collect($plan[$this->argument('environment')] ?? [])->max('timeout') ?? 60;
46 |
47 | $this->components->info('Maximum timeout for '.$environment.' environment: '.$timeout.' seconds.');
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Console/WorkCommand.php:
--------------------------------------------------------------------------------
1 | }>
11 | */
12 | public function get();
13 | }
14 |
--------------------------------------------------------------------------------
/src/EventMap.php:
--------------------------------------------------------------------------------
1 | [
14 | Listeners\StoreJob::class,
15 | Listeners\StoreMonitoredTags::class,
16 | ],
17 |
18 | Events\JobReserved::class => [
19 | Listeners\MarkJobAsReserved::class,
20 | Listeners\StartTimingJob::class,
21 | ],
22 |
23 | Events\JobReleased::class => [
24 | Listeners\MarkJobAsReleased::class,
25 | ],
26 |
27 | Events\JobDeleted::class => [
28 | Listeners\MarkJobAsComplete::class,
29 | Listeners\UpdateJobMetrics::class,
30 | ],
31 |
32 | Events\JobsMigrated::class => [
33 | Listeners\MarkJobsAsMigrated::class,
34 | ],
35 |
36 | \Illuminate\Queue\Events\JobExceptionOccurred::class => [
37 | Listeners\ForgetJobTimer::class,
38 | ],
39 |
40 | \Illuminate\Queue\Events\JobFailed::class => [
41 | Listeners\ForgetJobTimer::class,
42 | Listeners\MarshalFailedEvent::class,
43 | ],
44 |
45 | Events\JobFailed::class => [
46 | Listeners\MarkJobAsFailed::class,
47 | Listeners\StoreTagsForFailedJob::class,
48 | ],
49 |
50 | Events\MasterSupervisorLooped::class => [
51 | Listeners\TrimRecentJobs::class,
52 | Listeners\TrimFailedJobs::class,
53 | Listeners\TrimMonitoredJobs::class,
54 | Listeners\ExpireSupervisors::class,
55 | Listeners\MonitorMasterSupervisorMemory::class,
56 | ],
57 |
58 | Events\SupervisorLooped::class => [
59 | Listeners\PruneTerminatingProcesses::class,
60 | Listeners\MonitorSupervisorMemory::class,
61 | Listeners\MonitorWaitTimes::class,
62 | ],
63 |
64 | Events\WorkerProcessRestarting::class => [
65 | //
66 | ],
67 |
68 | Events\SupervisorProcessRestarting::class => [
69 | //
70 | ],
71 |
72 | Events\LongWaitDetected::class => [
73 | Listeners\SendNotification::class,
74 | ],
75 | ];
76 | }
77 |
--------------------------------------------------------------------------------
/src/Events/JobDeleted.php:
--------------------------------------------------------------------------------
1 | job = $job;
24 |
25 | parent::__construct($payload);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Events/JobFailed.php:
--------------------------------------------------------------------------------
1 | job = $job;
32 | $this->exception = $exception;
33 |
34 | parent::__construct($payload);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Events/JobPushed.php:
--------------------------------------------------------------------------------
1 | payloads = collect($payloads)->map(function ($job) {
39 | return new JobPayload($job);
40 | });
41 | }
42 |
43 | /**
44 | * Set the connection name.
45 | *
46 | * @param string $connectionName
47 | * @return $this
48 | */
49 | public function connection($connectionName)
50 | {
51 | $this->connectionName = $connectionName;
52 |
53 | return $this;
54 | }
55 |
56 | /**
57 | * Set the queue name.
58 | *
59 | * @param string $queue
60 | * @return $this
61 | */
62 | public function queue($queue)
63 | {
64 | $this->queue = $queue;
65 |
66 | return $this;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/Events/LongWaitDetected.php:
--------------------------------------------------------------------------------
1 | queue = $queue;
42 | $this->seconds = $seconds;
43 | $this->connection = $connection;
44 | }
45 |
46 | /**
47 | * Get a notification representation of the event.
48 | *
49 | * @return \Laravel\Horizon\Notifications\LongWaitDetected
50 | */
51 | public function toNotification()
52 | {
53 | return Container::getInstance()->make(LongWaitDetectedNotification::class, [
54 | 'connection' => $this->connection,
55 | 'queue' => $this->queue,
56 | 'seconds' => $this->seconds,
57 | ]);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/Events/MasterSupervisorDeployed.php:
--------------------------------------------------------------------------------
1 | master = $master;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Events/MasterSupervisorLooped.php:
--------------------------------------------------------------------------------
1 | master = $master;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Events/MasterSupervisorOutOfMemory.php:
--------------------------------------------------------------------------------
1 | master = $master;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Events/MasterSupervisorReviving.php:
--------------------------------------------------------------------------------
1 | master = $master;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/Events/RedisEvent.php:
--------------------------------------------------------------------------------
1 | payload = new JobPayload($payload);
39 | }
40 |
41 | /**
42 | * Set the connection name.
43 | *
44 | * @param string $connectionName
45 | * @return $this
46 | */
47 | public function connection($connectionName)
48 | {
49 | $this->connectionName = $connectionName;
50 |
51 | return $this;
52 | }
53 |
54 | /**
55 | * Set the queue name.
56 | *
57 | * @param string $queue
58 | * @return $this
59 | */
60 | public function queue($queue)
61 | {
62 | $this->queue = $queue;
63 |
64 | return $this;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Events/SupervisorLooped.php:
--------------------------------------------------------------------------------
1 | supervisor = $supervisor;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Events/SupervisorOutOfMemory.php:
--------------------------------------------------------------------------------
1 | supervisor = $supervisor;
32 | }
33 |
34 | /**
35 | * Get the memory usage that triggered the event.
36 | *
37 | * @return int|float
38 | */
39 | public function getMemoryUsage()
40 | {
41 | return $this->memoryUsage ?? $this->supervisor->memoryUsage();
42 | }
43 |
44 | /**
45 | * Set the memory usage that was recorded when the event was dispatched.
46 | *
47 | * @param int|float $memoryUsage
48 | * @return $this
49 | */
50 | public function setMemoryUsage($memoryUsage)
51 | {
52 | $this->memoryUsage = $memoryUsage;
53 |
54 | return $this;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Events/SupervisorProcessRestarting.php:
--------------------------------------------------------------------------------
1 | process = $process;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Events/UnableToLaunchProcess.php:
--------------------------------------------------------------------------------
1 | process = $process;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Events/WorkerProcessRestarting.php:
--------------------------------------------------------------------------------
1 | process = $process;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Exceptions/ForbiddenException.php:
--------------------------------------------------------------------------------
1 | authorization();
18 | }
19 |
20 | /**
21 | * Configure the Horizon authorization services.
22 | *
23 | * @return void
24 | */
25 | protected function authorization()
26 | {
27 | $this->gate();
28 |
29 | Horizon::auth(function ($request) {
30 | return Gate::check('viewHorizon', [$request->user()]) || app()->environment('local');
31 | });
32 | }
33 |
34 | /**
35 | * Register the Horizon gate.
36 | *
37 | * This gate determines who can access Horizon in non-local environments.
38 | *
39 | * @return void
40 | */
41 | protected function gate()
42 | {
43 | Gate::define('viewHorizon', function ($user) {
44 | return in_array($user->email, [
45 | //
46 | ]);
47 | });
48 | }
49 |
50 | /**
51 | * Register any application services.
52 | *
53 | * @return void
54 | */
55 | public function register()
56 | {
57 | //
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/Http/Controllers/BatchesController.php:
--------------------------------------------------------------------------------
1 | batches = $batches;
31 | }
32 |
33 | /**
34 | * Get all of the batches.
35 | *
36 | * @param \Illuminate\Http\Request $request
37 | * @return array
38 | */
39 | public function index(Request $request)
40 | {
41 | try {
42 | $batches = $this->batches->get(50, $request->query('before_id') ?: null);
43 | } catch (QueryException $e) {
44 | $batches = [];
45 | }
46 |
47 | return [
48 | 'batches' => $batches,
49 | ];
50 | }
51 |
52 | /**
53 | * Get the details of a batch by ID.
54 | *
55 | * @param string $id
56 | * @return array
57 | */
58 | public function show($id)
59 | {
60 | $batch = $this->batches->find($id);
61 |
62 | if ($batch) {
63 | $failedJobs = app(JobRepository::class)
64 | ->getJobs($batch->failedJobIds);
65 | }
66 |
67 | return [
68 | 'batch' => $batch,
69 | 'failedJobs' => $failedJobs ?? null,
70 | ];
71 | }
72 |
73 | /**
74 | * Retry the given batch.
75 | *
76 | * @param string $id
77 | * @return void
78 | */
79 | public function retry($id)
80 | {
81 | $batch = $this->batches->find($id);
82 |
83 | if ($batch) {
84 | app(JobRepository::class)
85 | ->getJobs($batch->failedJobIds)
86 | ->reject(function ($job) {
87 | $payload = json_decode($job->payload);
88 |
89 | return isset($payload->retry_of);
90 | })->each(function ($job) {
91 | dispatch(new RetryFailedJob($job->id));
92 | });
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/Http/Controllers/CompletedJobsController.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
28 | }
29 |
30 | /**
31 | * Get all of the completed jobs.
32 | *
33 | * @param \Illuminate\Http\Request $request
34 | * @return array
35 | */
36 | public function index(Request $request)
37 | {
38 | $jobs = $this->jobs->getCompleted($request->query('starting_at', -1))->map(function ($job) {
39 | $job->payload = json_decode($job->payload);
40 |
41 | return $job;
42 | })->values();
43 |
44 | return [
45 | 'jobs' => $jobs,
46 | 'total' => $this->jobs->countCompleted(),
47 | ];
48 | }
49 |
50 | /**
51 | * Decode the given job.
52 | *
53 | * @param object $job
54 | * @return object
55 | */
56 | protected function decode($job)
57 | {
58 | $job->payload = json_decode($job->payload);
59 |
60 | return $job;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Http/Controllers/Controller.php:
--------------------------------------------------------------------------------
1 | middleware(Authenticate::class);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Http/Controllers/DashboardStatsController.php:
--------------------------------------------------------------------------------
1 | app(JobRepository::class)->countRecentlyFailed(),
22 | 'jobsPerMinute' => app(MetricsRepository::class)->jobsProcessedPerMinute(),
23 | 'pausedMasters' => $this->totalPausedMasters(),
24 | 'periods' => [
25 | 'failedJobs' => config('horizon.trim.recent_failed', config('horizon.trim.failed')),
26 | 'recentJobs' => config('horizon.trim.recent'),
27 | ],
28 | 'processes' => $this->totalProcessCount(),
29 | 'queueWithMaxRuntime' => app(MetricsRepository::class)->queueWithMaximumRuntime(),
30 | 'queueWithMaxThroughput' => app(MetricsRepository::class)->queueWithMaximumThroughput(),
31 | 'recentJobs' => app(JobRepository::class)->countRecent(),
32 | 'status' => $this->currentStatus(),
33 | 'wait' => collect(app(WaitTimeCalculator::class)->calculate())->take(1),
34 | ];
35 | }
36 |
37 | /**
38 | * Get the total process count across all supervisors.
39 | *
40 | * @return int
41 | */
42 | protected function totalProcessCount()
43 | {
44 | $supervisors = app(SupervisorRepository::class)->all();
45 |
46 | return collect($supervisors)->reduce(function ($carry, $supervisor) {
47 | return $carry + collect($supervisor->processes)->sum();
48 | }, 0);
49 | }
50 |
51 | /**
52 | * Get the current status of Horizon.
53 | *
54 | * @return string
55 | */
56 | protected function currentStatus()
57 | {
58 | if (! $masters = app(MasterSupervisorRepository::class)->all()) {
59 | return 'inactive';
60 | }
61 |
62 | return collect($masters)->every(function ($master) {
63 | return $master->status === 'paused';
64 | }) ? 'paused' : 'running';
65 | }
66 |
67 | /**
68 | * Get the number of master supervisors that are currently paused.
69 | *
70 | * @return int
71 | */
72 | protected function totalPausedMasters()
73 | {
74 | if (! $masters = app(MasterSupervisorRepository::class)->all()) {
75 | return 0;
76 | }
77 |
78 | return collect($masters)->filter(function ($master) {
79 | return $master->status === 'paused';
80 | })->count();
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/Http/Controllers/FailedJobsController.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
37 | $this->tags = $tags;
38 | }
39 |
40 | /**
41 | * Get all of the failed jobs.
42 | *
43 | * @param \Illuminate\Http\Request $request
44 | * @return array
45 | */
46 | public function index(Request $request)
47 | {
48 | $jobs = ! $request->query('tag')
49 | ? $this->paginate($request)
50 | : $this->paginateByTag($request, $request->query('tag'));
51 |
52 | $total = $request->query('tag')
53 | ? $this->tags->count('failed:'.$request->query('tag'))
54 | : $this->jobs->countFailed();
55 |
56 | return [
57 | 'jobs' => $jobs,
58 | 'total' => $total,
59 | ];
60 | }
61 |
62 | /**
63 | * Paginate the failed jobs for the request.
64 | *
65 | * @param \Illuminate\Http\Request $request
66 | * @return \Illuminate\Support\Collection
67 | */
68 | protected function paginate(Request $request)
69 | {
70 | return $this->jobs->getFailed($request->query('starting_at') ?: -1)->map(function ($job) {
71 | return $this->decode($job);
72 | });
73 | }
74 |
75 | /**
76 | * Paginate the failed jobs for the request and tag.
77 | *
78 | * @param \Illuminate\Http\Request $request
79 | * @param string $tag
80 | * @return \Illuminate\Support\Collection
81 | */
82 | protected function paginateByTag(Request $request, $tag)
83 | {
84 | $jobIds = $this->tags->paginate(
85 | 'failed:'.$tag, ($request->query('starting_at') ?: -1) + 1, 50
86 | );
87 |
88 | $startingAt = $request->query('starting_at', 0);
89 |
90 | return $this->jobs->getJobs($jobIds, $startingAt)->map(function ($job) {
91 | return $this->decode($job);
92 | });
93 | }
94 |
95 | /**
96 | * Get a failed job instance.
97 | *
98 | * @param string $id
99 | * @return mixed
100 | */
101 | public function show($id)
102 | {
103 | return (array) $this->jobs->getJobs([$id])->map(function ($job) {
104 | return $this->decode($job);
105 | })->first();
106 | }
107 |
108 | /**
109 | * Decode the given job.
110 | *
111 | * @param object $job
112 | * @return object
113 | */
114 | protected function decode($job)
115 | {
116 | $job->payload = json_decode($job->payload);
117 |
118 | $job->exception = mb_convert_encoding($job->exception, 'UTF-8');
119 |
120 | $job->context = json_decode($job->context ?? '');
121 |
122 | $job->retried_by = collect(! is_null($job->retried_by) ? json_decode($job->retried_by) : [])
123 | ->sortByDesc('retried_at')->values();
124 |
125 | return $job;
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/Http/Controllers/HomeController.php:
--------------------------------------------------------------------------------
1 | App::isDownForMaintenance(),
18 | ]);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Http/Controllers/JobMetricsController.php:
--------------------------------------------------------------------------------
1 | metrics = $metrics;
27 | }
28 |
29 | /**
30 | * Get all of the measured jobs.
31 | *
32 | * @return array
33 | */
34 | public function index()
35 | {
36 | return $this->metrics->measuredJobs();
37 | }
38 |
39 | /**
40 | * Get metrics for a given job.
41 | *
42 | * @param string $id
43 | * @return \Illuminate\Support\Collection
44 | */
45 | public function show($id)
46 | {
47 | return collect($this->metrics->snapshotsForJob($id))->map(function ($record) {
48 | $record->runtime = round($record->runtime / 1000, 3);
49 | $record->throughput = (int) $record->throughput;
50 |
51 | return $record;
52 | });
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/Http/Controllers/JobsController.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
27 | }
28 |
29 | /**
30 | * Get the details of a recent job by ID.
31 | *
32 | * @param string $id
33 | * @return array
34 | */
35 | public function show($id)
36 | {
37 | return (array) $this->jobs->getJobs([$id])->map(function ($job) {
38 | return $this->decode($job);
39 | })->first();
40 | }
41 |
42 | /**
43 | * Decode the given job.
44 | *
45 | * @param object $job
46 | * @return object
47 | */
48 | protected function decode($job)
49 | {
50 | $job->payload = json_decode($job->payload);
51 |
52 | return $job;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/Http/Controllers/MasterSupervisorController.php:
--------------------------------------------------------------------------------
1 | all())->keyBy('name')->sortBy('name');
22 |
23 | $supervisors = collect($supervisors->all())->sortBy('name')->groupBy('master');
24 |
25 | return $masters->each(function ($master, $name) use ($supervisors) {
26 | $master->supervisors = ($supervisors->get($name) ?? collect())
27 | ->merge(
28 | collect(ProvisioningPlan::get($name)->plan[$master->environment ?? config('horizon.env') ?? config('app.env')] ?? [])
29 | ->map(function ($value, $key) use ($name) {
30 | return (object) [
31 | 'name' => $name.':'.$key,
32 | 'master' => $name,
33 | 'status' => 'inactive',
34 | 'processes' => [],
35 | 'options' => [
36 | 'queue' => array_key_exists('queue', $value) && is_array($value['queue']) ? implode(',', $value['queue']) : ($value['queue'] ?? ''),
37 | 'balance' => $value['balance'] ?? null,
38 | ],
39 | ];
40 | })
41 | )
42 | ->unique('name')
43 | ->values();
44 | });
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Http/Controllers/MonitoringController.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
39 | $this->tags = $tags;
40 | }
41 |
42 | /**
43 | * Get all of the monitored tags and their job counts.
44 | *
45 | * @return \Illuminate\Support\Collection
46 | */
47 | public function index()
48 | {
49 | return collect($this->tags->monitoring())->map(function ($tag) {
50 | return [
51 | 'tag' => $tag,
52 | 'count' => $this->tags->count($tag) + $this->tags->count('failed:'.$tag),
53 | ];
54 | })->sortBy('tag')->values();
55 | }
56 |
57 | /**
58 | * Paginate the jobs for a given tag.
59 | *
60 | * @param \Illuminate\Http\Request $request
61 | * @return array
62 | */
63 | public function paginate(Request $request)
64 | {
65 | $tag = $request->query('tag');
66 |
67 | $jobIds = $this->tags->paginate(
68 | $tag, $startingAt = $request->query('starting_at', 0),
69 | $request->query('limit', 25)
70 | );
71 |
72 | return [
73 | 'jobs' => $this->getJobs($jobIds, $startingAt),
74 | 'total' => $this->tags->count($tag),
75 | ];
76 | }
77 |
78 | /**
79 | * Get the jobs for the given IDs.
80 | *
81 | * @param array $jobIds
82 | * @param int $startingAt
83 | * @return \Illuminate\Support\Collection
84 | */
85 | protected function getJobs($jobIds, $startingAt = 0)
86 | {
87 | return $this->jobs->getJobs($jobIds, $startingAt)->map(function ($job) {
88 | $job->payload = json_decode($job->payload);
89 |
90 | return $job;
91 | })->values();
92 | }
93 |
94 | /**
95 | * Start monitoring the given tag.
96 | *
97 | * @param \Illuminate\Http\Request $request
98 | * @return void
99 | */
100 | public function store(Request $request)
101 | {
102 | dispatch(new MonitorTag($request->tag));
103 | }
104 |
105 | /**
106 | * Stop monitoring the given tag.
107 | *
108 | * @param string $tag
109 | * @return void
110 | */
111 | public function destroy($tag)
112 | {
113 | dispatch(new StopMonitoringTag($tag));
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/Http/Controllers/PendingJobsController.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
28 | }
29 |
30 | /**
31 | * Get all of the pending jobs.
32 | *
33 | * @param \Illuminate\Http\Request $request
34 | * @return array
35 | */
36 | public function index(Request $request)
37 | {
38 | $jobs = $this->jobs->getPending($request->query('starting_at', -1))->map(function ($job) {
39 | $job->payload = json_decode($job->payload);
40 |
41 | return $job;
42 | })->values();
43 |
44 | return [
45 | 'jobs' => $jobs,
46 | 'total' => $this->jobs->countPending(),
47 | ];
48 | }
49 |
50 | /**
51 | * Decode the given job.
52 | *
53 | * @param object $job
54 | * @return object
55 | */
56 | protected function decode($job)
57 | {
58 | $job->payload = json_decode($job->payload);
59 |
60 | return $job;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Http/Controllers/QueueMetricsController.php:
--------------------------------------------------------------------------------
1 | metrics = $metrics;
27 | }
28 |
29 | /**
30 | * Get all of the measured queues.
31 | *
32 | * @return array
33 | */
34 | public function index()
35 | {
36 | return $this->metrics->measuredQueues();
37 | }
38 |
39 | /**
40 | * Get metrics for a given queue.
41 | *
42 | * @param string $id
43 | * @return \Illuminate\Support\Collection
44 | */
45 | public function show($id)
46 | {
47 | return collect($this->metrics->snapshotsForQueue($id))->map(function ($record) {
48 | $record->runtime = round($record->runtime / 1000, 3);
49 | $record->throughput = (int) $record->throughput;
50 |
51 | return $record;
52 | });
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/Http/Controllers/RetryController.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
28 | }
29 |
30 | /**
31 | * Get all of the silenced jobs.
32 | *
33 | * @param \Illuminate\Http\Request $request
34 | * @return array
35 | */
36 | public function index(Request $request)
37 | {
38 | $jobs = $this->jobs->getSilenced($request->query('starting_at', -1))->map(function ($job) {
39 | $job->payload = json_decode($job->payload);
40 |
41 | return $job;
42 | })->values();
43 |
44 | return [
45 | 'jobs' => $jobs,
46 | 'total' => $this->jobs->countSilenced(),
47 | ];
48 | }
49 |
50 | /**
51 | * Decode the given job.
52 | *
53 | * @param object $job
54 | * @return object
55 | */
56 | protected function decode($job)
57 | {
58 | $job->payload = json_decode($job->payload);
59 |
60 | return $job;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/Http/Controllers/WorkloadController.php:
--------------------------------------------------------------------------------
1 | get())->sortBy('name')->values()->toArray();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Http/Middleware/Authenticate.php:
--------------------------------------------------------------------------------
1 | tag = $tag;
25 | }
26 |
27 | /**
28 | * Execute the job.
29 | *
30 | * @param \Laravel\Horizon\Contracts\TagRepository $tags
31 | * @return void
32 | */
33 | public function handle(TagRepository $tags)
34 | {
35 | $tags->monitor($this->tag);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Jobs/RetryFailedJob.php:
--------------------------------------------------------------------------------
1 | id = $id;
28 | }
29 |
30 | /**
31 | * Execute the job.
32 | *
33 | * @param \Illuminate\Contracts\Queue\Factory $queue
34 | * @param \Laravel\Horizon\Contracts\JobRepository $jobs
35 | * @return void
36 | */
37 | public function handle(Queue $queue, JobRepository $jobs)
38 | {
39 | if (is_null($job = $jobs->findFailed($this->id))) {
40 | return;
41 | }
42 |
43 | $queue->connection($job->connection)->pushRaw(
44 | $this->preparePayload($id = Str::uuid(), $job->payload), $job->queue
45 | );
46 |
47 | $jobs->storeRetryReference($this->id, $id);
48 | }
49 |
50 | /**
51 | * Prepare the payload for queueing.
52 | *
53 | * @param string $id
54 | * @param string $payload
55 | * @return string
56 | */
57 | protected function preparePayload($id, $payload)
58 | {
59 | $payload = json_decode($payload, true);
60 |
61 | return json_encode(array_merge($payload, [
62 | 'id' => $id,
63 | 'uuid' => $id,
64 | 'attempts' => 0,
65 | 'retry_of' => $this->id,
66 | 'retryUntil' => $this->prepareNewTimeout($payload),
67 | ]));
68 | }
69 |
70 | /**
71 | * Prepare the timeout.
72 | *
73 | * @param array $payload
74 | * @return int|null
75 | */
76 | protected function prepareNewTimeout($payload)
77 | {
78 | $retryUntil = $payload['retryUntil'] ?? $payload['timeoutAt'] ?? null;
79 |
80 | $pushedAt = $payload['pushedAt'] ?? microtime(true);
81 |
82 | return $retryUntil
83 | ? CarbonImmutable::now()->addSeconds(ceil($retryUntil - $pushedAt))->getTimestamp()
84 | : null;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/Jobs/StopMonitoringTag.php:
--------------------------------------------------------------------------------
1 | tag = $tag;
26 | }
27 |
28 | /**
29 | * Execute the job.
30 | *
31 | * @param \Laravel\Horizon\Contracts\JobRepository $jobs
32 | * @param \Laravel\Horizon\Contracts\TagRepository $tags
33 | * @return void
34 | */
35 | public function handle(JobRepository $jobs, TagRepository $tags)
36 | {
37 | $tags->stopMonitoring($this->tag);
38 |
39 | $monitored = $tags->paginate($this->tag);
40 |
41 | while (count($monitored) > 0) {
42 | $jobs->deleteMonitored($monitored);
43 |
44 | $offset = array_keys($monitored)[count($monitored) - 1] + 1;
45 |
46 | $monitored = $tags->paginate($this->tag, $offset);
47 | }
48 |
49 | $tags->forget($this->tag);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/Listeners/ExpireSupervisors.php:
--------------------------------------------------------------------------------
1 | flushExpired();
20 |
21 | app(SupervisorRepository::class)->flushExpired();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Listeners/ForgetJobTimer.php:
--------------------------------------------------------------------------------
1 | watch = $watch;
25 | }
26 |
27 | /**
28 | * Handle the event.
29 | *
30 | * @param \Illuminate\Queue\Events\JobExceptionOccurred|\Illuminate\Queue\Events\JobFailed $event
31 | * @return void
32 | */
33 | public function handle($event)
34 | {
35 | $this->watch->forget($event->job->getJobId());
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Listeners/MarkJobAsComplete.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
35 | $this->tags = $tags;
36 | }
37 |
38 | /**
39 | * Handle the event.
40 | *
41 | * @param \Laravel\Horizon\Events\JobDeleted $event
42 | * @return void
43 | */
44 | public function handle(JobDeleted $event)
45 | {
46 | $this->jobs->completed($event->payload, $event->job->hasFailed(), $event->payload->isSilenced());
47 |
48 | if (! $event->job->hasFailed() && count($this->tags->monitored($event->payload->tags())) > 0) {
49 | $this->jobs->remember($event->connectionName, $event->queue, $event->payload);
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Listeners/MarkJobAsFailed.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobFailed $event
32 | * @return void
33 | */
34 | public function handle(JobFailed $event)
35 | {
36 | $this->jobs->failed(
37 | $event->exception, $event->connectionName,
38 | $event->queue, $event->payload
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Listeners/MarkJobAsReleased.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobReleased $event
32 | * @return void
33 | */
34 | public function handle(JobReleased $event)
35 | {
36 | $this->jobs->released($event->connectionName, $event->queue, $event->payload);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Listeners/MarkJobAsReserved.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobReserved $event
32 | * @return void
33 | */
34 | public function handle(JobReserved $event)
35 | {
36 | $this->jobs->reserved($event->connectionName, $event->queue, $event->payload);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Listeners/MarkJobsAsMigrated.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobsMigrated $event
32 | * @return void
33 | */
34 | public function handle(JobsMigrated $event)
35 | {
36 | $this->jobs->migrated($event->connectionName, $event->queue, $event->payloads);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Listeners/MarshalFailedEvent.php:
--------------------------------------------------------------------------------
1 | events = $events;
28 | }
29 |
30 | /**
31 | * Handle the event.
32 | *
33 | * @param \Illuminate\Queue\Events\JobFailed $event
34 | * @return void
35 | */
36 | public function handle(LaravelJobFailed $event)
37 | {
38 | if (! $event->job instanceof RedisJob) {
39 | return;
40 | }
41 |
42 | $this->events->dispatch((new JobFailed(
43 | $event->exception, $event->job, $event->job->getReservedJob()
44 | ))->connection($event->connectionName)->queue($event->job->getQueue()));
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Listeners/MonitorMasterSupervisorMemory.php:
--------------------------------------------------------------------------------
1 | master;
19 |
20 | $memoryLimit = config('horizon.memory_limit', 64);
21 |
22 | if ($master->memoryUsage() > $memoryLimit) {
23 | event(new MasterSupervisorOutOfMemory($master));
24 |
25 | $master->output('error', 'Memory limit exceeded: Using '.ceil($master->memoryUsage()).'/'.$memoryLimit.'MB. Consider increasing horizon.memory_limit.');
26 |
27 | $master->terminate(12);
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Listeners/MonitorSupervisorMemory.php:
--------------------------------------------------------------------------------
1 | supervisor;
19 |
20 | if (($memoryUsage = $supervisor->memoryUsage()) > $supervisor->options->memory) {
21 | event((new SupervisorOutOfMemory($supervisor))->setMemoryUsage($memoryUsage));
22 |
23 | $supervisor->terminate(12);
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Listeners/MonitorWaitTimes.php:
--------------------------------------------------------------------------------
1 | metrics = $metrics;
35 | }
36 |
37 | /**
38 | * Handle the event.
39 | *
40 | * @param \Laravel\Horizon\Events\SupervisorLooped $event
41 | * @return void
42 | */
43 | public function handle()
44 | {
45 | if (! $this->dueToMonitor()) {
46 | return;
47 | }
48 |
49 | // Here we will calculate the wait time in seconds for each of the queues that
50 | // the application is working. Then, we will filter the results to find the
51 | // queues with the longest wait times and raise events for each of these.
52 | $results = app(WaitTimeCalculator::class)->calculate();
53 |
54 | $long = collect($results)->filter(function ($wait, $queue) {
55 | return config("horizon.waits.{$queue}") !== 0
56 | && $wait > (config("horizon.waits.{$queue}") ?? 60);
57 | });
58 |
59 | // Once we have determined which queues have long wait times we will raise the
60 | // events for each of the queues. We'll need to separate the connection and
61 | // queue names into their own strings before we will fire off the events.
62 | $long->each(function ($wait, $queue) {
63 | [$connection, $queue] = explode(':', $queue, 2);
64 |
65 | event(new LongWaitDetected($connection, $queue, $wait));
66 | });
67 | }
68 |
69 | /**
70 | * Determine if monitoring is due.
71 | *
72 | * @return bool
73 | */
74 | protected function dueToMonitor()
75 | {
76 | // We will keep track of the amount of time between attempting to acquire the
77 | // lock to monitor the wait times. We only want a single supervisor to run
78 | // the checks on a given interval so that we don't fire too many events.
79 | if (! $this->lastMonitored) {
80 | $this->lastMonitored = CarbonImmutable::now();
81 | }
82 |
83 | if (! $this->timeToMonitor()) {
84 | return false;
85 | }
86 |
87 | // Next we will update the monitor timestamp and attempt to acquire a lock to
88 | // check the wait times. We use Redis to do it in order to have the atomic
89 | // operation required. This will avoid any deadlocks or race conditions.
90 | $this->lastMonitored = CarbonImmutable::now();
91 |
92 | return $this->metrics->acquireWaitTimeMonitorLock();
93 | }
94 |
95 | /**
96 | * Determine if enough time has elapsed to attempt to monitor.
97 | *
98 | * @return bool
99 | */
100 | protected function timeToMonitor()
101 | {
102 | return CarbonImmutable::now()->subMinutes(1)->lte($this->lastMonitored);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/Listeners/PruneTerminatingProcesses.php:
--------------------------------------------------------------------------------
1 | supervisor->pruneTerminatingProcesses();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Listeners/SendNotification.php:
--------------------------------------------------------------------------------
1 | toNotification();
20 |
21 | if (! app(Lock::class)->get('notification:'.$notification->signature(), 300)) {
22 | return;
23 | }
24 |
25 | Notification::route('slack', Horizon::$slackWebhookUrl)
26 | ->route('nexmo', Horizon::$smsNumber)
27 | ->route('mail', Horizon::$email)
28 | ->notify($notification);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Listeners/StartTimingJob.php:
--------------------------------------------------------------------------------
1 | watch = $watch;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobReserved $event
32 | * @return void
33 | */
34 | public function handle(JobReserved $event)
35 | {
36 | $this->watch->start($event->payload->id());
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Listeners/StoreJob.php:
--------------------------------------------------------------------------------
1 | jobs = $jobs;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobPushed $event
32 | * @return void
33 | */
34 | public function handle(JobPushed $event)
35 | {
36 | $this->jobs->pushed(
37 | $event->connectionName, $event->queue, $event->payload
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Listeners/StoreMonitoredTags.php:
--------------------------------------------------------------------------------
1 | tags = $tags;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobPushed $event
32 | * @return void
33 | */
34 | public function handle(JobPushed $event)
35 | {
36 | $monitoring = $this->tags->monitored($event->payload->tags());
37 |
38 | if (! empty($monitoring)) {
39 | $this->tags->add($event->payload->id(), $monitoring);
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Listeners/StoreTagsForFailedJob.php:
--------------------------------------------------------------------------------
1 | tags = $tags;
26 | }
27 |
28 | /**
29 | * Handle the event.
30 | *
31 | * @param \Laravel\Horizon\Events\JobFailed $event
32 | * @return void
33 | */
34 | public function handle(JobFailed $event)
35 | {
36 | $tags = collect($event->payload->tags())->map(function ($tag) {
37 | return 'failed:'.$tag;
38 | })->all();
39 |
40 | $this->tags->addTemporary(
41 | config('horizon.trim.failed', 2880), $event->payload->id(), $tags
42 | );
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Listeners/TrimFailedJobs.php:
--------------------------------------------------------------------------------
1 | lastTrimmed)) {
34 | $this->frequency = max(1, intdiv(
35 | config('horizon.trim.failed', 10080), 12
36 | ));
37 |
38 | $this->lastTrimmed = CarbonImmutable::now()->subMinutes($this->frequency + 1);
39 | }
40 |
41 | if ($this->lastTrimmed->lte(CarbonImmutable::now()->subMinutes($this->frequency))) {
42 | app(JobRepository::class)->trimFailedJobs();
43 |
44 | $this->lastTrimmed = CarbonImmutable::now();
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Listeners/TrimMonitoredJobs.php:
--------------------------------------------------------------------------------
1 | lastTrimmed)) {
34 | $this->frequency = max(1, intdiv(
35 | config('horizon.trim.monitored', 10080), 12
36 | ));
37 |
38 | $this->lastTrimmed = CarbonImmutable::now()->subMinutes($this->frequency + 1);
39 | }
40 |
41 | if ($this->lastTrimmed->lte(CarbonImmutable::now()->subMinutes($this->frequency))) {
42 | app(JobRepository::class)->trimMonitoredJobs();
43 |
44 | $this->lastTrimmed = CarbonImmutable::now();
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Listeners/TrimRecentJobs.php:
--------------------------------------------------------------------------------
1 | lastTrimmed)) {
34 | $this->lastTrimmed = CarbonImmutable::now()->subMinutes($this->frequency + 1);
35 | }
36 |
37 | if ($this->lastTrimmed->lte(CarbonImmutable::now()->subMinutes($this->frequency))) {
38 | app(JobRepository::class)->trimRecentJobs();
39 |
40 | $this->lastTrimmed = CarbonImmutable::now();
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Listeners/UpdateJobMetrics.php:
--------------------------------------------------------------------------------
1 | watch = $watch;
35 | $this->metrics = $metrics;
36 | }
37 |
38 | /**
39 | * Stop gathering metrics for a job.
40 | *
41 | * @param \Laravel\Horizon\Events\JobDeleted $event
42 | * @return void
43 | */
44 | public function handle(JobDeleted $event)
45 | {
46 | if ($event->job->hasFailed()) {
47 | return;
48 | }
49 |
50 | $time = $this->watch->check($id = $event->payload->id()) ?: 0;
51 |
52 | $this->metrics->incrementQueue(
53 | $event->job->getQueue(), $time
54 | );
55 |
56 | $this->metrics->incrementJob(
57 | $event->payload->displayName(), $time
58 | );
59 |
60 | $this->watch->forget($id);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/ListensForSignals.php:
--------------------------------------------------------------------------------
1 | pendingSignals['terminate'] = 'terminate';
27 | });
28 |
29 | pcntl_signal(SIGUSR1, function () {
30 | $this->pendingSignals['restart'] = 'restart';
31 | });
32 |
33 | pcntl_signal(SIGUSR2, function () {
34 | $this->pendingSignals['pause'] = 'pause';
35 | });
36 |
37 | pcntl_signal(SIGCONT, function () {
38 | $this->pendingSignals['continue'] = 'continue';
39 | });
40 | }
41 |
42 | /**
43 | * Process the pending signals.
44 | *
45 | * @return void
46 | */
47 | protected function processPendingSignals()
48 | {
49 | while ($this->pendingSignals) {
50 | $signal = Arr::first($this->pendingSignals);
51 |
52 | $this->{$signal}();
53 |
54 | unset($this->pendingSignals[$signal]);
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Lock.php:
--------------------------------------------------------------------------------
1 | redis = $redis;
25 | }
26 |
27 | /**
28 | * Execute the given callback if a lock can be acquired.
29 | *
30 | * @param string $key
31 | * @param \Closure $callback
32 | * @param int $seconds
33 | * @return void
34 | */
35 | public function with($key, $callback, $seconds = 60)
36 | {
37 | if ($this->get($key, $seconds)) {
38 | try {
39 | call_user_func($callback);
40 | } finally {
41 | $this->release($key);
42 | }
43 | }
44 | }
45 |
46 | /**
47 | * Determine if a lock exists for the given key.
48 | *
49 | * @param string $key
50 | * @return bool
51 | */
52 | public function exists($key)
53 | {
54 | return $this->connection()->exists($key) === 1;
55 | }
56 |
57 | /**
58 | * Attempt to get a lock for the given key.
59 | *
60 | * @param string $key
61 | * @param int $seconds
62 | * @return bool
63 | */
64 | public function get($key, $seconds = 60)
65 | {
66 | $result = $this->connection()->setnx($key, 1);
67 |
68 | if ($result === 1) {
69 | $this->connection()->expire($key, $seconds);
70 | }
71 |
72 | return $result === 1;
73 | }
74 |
75 | /**
76 | * Release the lock for the given key.
77 | *
78 | * @param string $key
79 | * @return void
80 | */
81 | public function release($key)
82 | {
83 | $this->connection()->del($key);
84 | }
85 |
86 | /**
87 | * Get the Redis connection instance.
88 | *
89 | * @return \Illuminate\Redis\Connections\Connection
90 | */
91 | public function connection()
92 | {
93 | return $this->redis->connection('horizon');
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/LuaScripts.php:
--------------------------------------------------------------------------------
1 | supervisors[] = new SupervisorProcess(
24 | $options, $this->createProcess($master, $options), function ($type, $line) use ($master) {
25 | $master->output($type, $line);
26 | }
27 | );
28 | }
29 |
30 | /**
31 | * Create the Symfony process instance.
32 | *
33 | * @param \Laravel\Horizon\MasterSupervisor $master
34 | * @param \Laravel\Horizon\SupervisorOptions $options
35 | * @return \Symfony\Component\Process\Process
36 | */
37 | protected function createProcess(MasterSupervisor $master, SupervisorOptions $options)
38 | {
39 | $command = $options->toSupervisorCommand();
40 |
41 | return Process::fromShellCommandline($command, $options->directory ?? base_path())
42 | ->setTimeout(null)
43 | ->disableOutput();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/PhpBinary.php:
--------------------------------------------------------------------------------
1 | exec = $exec;
27 | }
28 |
29 | /**
30 | * Get the IDs of all Horizon processes running on the system.
31 | *
32 | * @return array
33 | */
34 | public function current()
35 | {
36 | return array_diff(
37 | $this->exec->run('pgrep -f [h]orizon'),
38 | $this->exec->run('pgrep -f horizon:purge')
39 | );
40 | }
41 |
42 | /**
43 | * Get an array of running Horizon processes that can't be accounted for.
44 | *
45 | * @return array
46 | */
47 | public function orphaned()
48 | {
49 | return array_diff($this->current(), $this->monitoring());
50 | }
51 |
52 | /**
53 | * Get all of the process IDs Horizon is actively monitoring.
54 | *
55 | * @return array
56 | */
57 | public function monitoring()
58 | {
59 | return collect(app(SupervisorRepository::class)->all())
60 | ->pluck('pid')
61 | ->pipe(function ($processes) {
62 | $processes->each(function ($process) use (&$processes) {
63 | $processes = $processes->merge($this->exec->run("pgrep -P {$process}"));
64 | });
65 |
66 | return $processes;
67 | })
68 | ->merge(
69 | Arr::pluck(app(MasterSupervisorRepository::class)->all(), 'pid')
70 | )->all();
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/QueueCommandString.php:
--------------------------------------------------------------------------------
1 | workersName,
17 | $options->name,
18 | static::toOptionsString($options)
19 | );
20 | }
21 |
22 | /**
23 | * Get the additional option string for the supervisor command.
24 | *
25 | * @param \Laravel\Horizon\SupervisorOptions $options
26 | * @return string
27 | */
28 | public static function toSupervisorOptionsString(SupervisorOptions $options)
29 | {
30 | return sprintf('--workers-name=%s --balance=%s --max-processes=%s --min-processes=%s --nice=%s --balance-cooldown=%s --balance-max-shift=%s --parent-id=%s --auto-scaling-strategy=%s %s',
31 | $options->workersName,
32 | $options->balance,
33 | $options->maxProcesses,
34 | $options->minProcesses,
35 | $options->nice,
36 | $options->balanceCooldown,
37 | $options->balanceMaxShift,
38 | $options->parentId,
39 | $options->autoScalingStrategy,
40 | static::toOptionsString($options)
41 | );
42 | }
43 |
44 | /**
45 | * Get the additional option string for the command.
46 | *
47 | * @param \Laravel\Horizon\SupervisorOptions $options
48 | * @param bool $paused
49 | * @return string
50 | */
51 | public static function toOptionsString(SupervisorOptions $options, $paused = false)
52 | {
53 | $string = sprintf('--backoff=%s --max-time=%s --max-jobs=%s --memory=%s --queue="%s" --sleep=%s --timeout=%s --tries=%s --rest=%s',
54 | $options->backoff, $options->maxTime, $options->maxJobs, $options->memory,
55 | $options->queue, $options->sleep, $options->timeout, $options->maxTries, $options->rest
56 | );
57 |
58 | if ($options->force) {
59 | $string .= ' --force';
60 | }
61 |
62 | if ($paused) {
63 | $string .= ' --paused';
64 | }
65 |
66 | return $string;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/RedisHorizonCommandQueue.php:
--------------------------------------------------------------------------------
1 | redis = $redis;
26 | }
27 |
28 | /**
29 | * Push a command onto a given queue.
30 | *
31 | * @param string $name
32 | * @param string $command
33 | * @param array $options
34 | * @return void
35 | */
36 | public function push($name, $command, array $options = [])
37 | {
38 | $this->connection()->rpush('commands:'.$name, json_encode([
39 | 'command' => $command,
40 | 'options' => $options,
41 | ]));
42 | }
43 |
44 | /**
45 | * Get the pending commands for a given queue name.
46 | *
47 | * @param string $name
48 | * @return array
49 | */
50 | public function pending($name)
51 | {
52 | $length = $this->connection()->llen('commands:'.$name);
53 |
54 | if ($length < 1) {
55 | return [];
56 | }
57 |
58 | $results = $this->connection()->pipeline(function ($pipe) use ($name, $length) {
59 | $pipe->lrange('commands:'.$name, 0, $length - 1);
60 |
61 | $pipe->ltrim('commands:'.$name, $length, -1);
62 | });
63 |
64 | return collect($results[0])->map(function ($result) {
65 | return (object) json_decode($result, true);
66 | })->all();
67 | }
68 |
69 | /**
70 | * Flush the command queue for a given queue name.
71 | *
72 | * @param string $name
73 | * @return void
74 | */
75 | public function flush($name)
76 | {
77 | $this->connection()->del('commands:'.$name);
78 | }
79 |
80 | /**
81 | * Get the Redis connection instance.
82 | *
83 | * @return \Illuminate\Redis\Connections\Connection
84 | */
85 | protected function connection()
86 | {
87 | return $this->redis->connection('horizon');
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/Repositories/RedisProcessRepository.php:
--------------------------------------------------------------------------------
1 | redis = $redis;
27 | }
28 |
29 | /**
30 | * Get all of the orphan process IDs and the times they were observed.
31 | *
32 | * @param string $master
33 | * @return array
34 | */
35 | public function allOrphans($master)
36 | {
37 | return $this->connection()->hgetall(
38 | "{$master}:orphans"
39 | );
40 | }
41 |
42 | /**
43 | * Record the given process IDs as orphaned.
44 | *
45 | * @param string $master
46 | * @param array $processIds
47 | * @return void
48 | */
49 | public function orphaned($master, array $processIds)
50 | {
51 | $time = CarbonImmutable::now()->getTimestamp();
52 |
53 | $shouldRemove = array_diff($this->connection()->hkeys(
54 | $key = "{$master}:orphans"
55 | ), $processIds);
56 |
57 | if (! empty($shouldRemove)) {
58 | $this->connection()->hdel($key, ...$shouldRemove);
59 | }
60 |
61 | $this->connection()->pipeline(function ($pipe) use ($key, $time, $processIds) {
62 | foreach ($processIds as $processId) {
63 | $pipe->hsetnx($key, $processId, $time);
64 | }
65 | });
66 | }
67 |
68 | /**
69 | * Get the process IDs orphaned for at least the given number of seconds.
70 | *
71 | * @param string $master
72 | * @param int $seconds
73 | * @return array
74 | */
75 | public function orphanedFor($master, $seconds)
76 | {
77 | $expiresAt = CarbonImmutable::now()->getTimestamp() - $seconds;
78 |
79 | return collect($this->allOrphans($master))->filter(function ($recordedAt, $_) use ($expiresAt) {
80 | return $expiresAt > $recordedAt;
81 | })->keys()->all();
82 | }
83 |
84 | /**
85 | * Remove the given process IDs from the orphan list.
86 | *
87 | * @param string $master
88 | * @param array $processIds
89 | * @return void
90 | */
91 | public function forgetOrphans($master, array $processIds)
92 | {
93 | $this->connection()->hdel(
94 | "{$master}:orphans", ...$processIds
95 | );
96 | }
97 |
98 | /**
99 | * Get the Redis connection instance.
100 | *
101 | * @return \Illuminate\Redis\Connections\Connection
102 | */
103 | protected function connection()
104 | {
105 | return $this->redis->connection('horizon');
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/Repositories/RedisWorkloadRepository.php:
--------------------------------------------------------------------------------
1 | queue = $queue;
55 | $this->masters = $masters;
56 | $this->waitTime = $waitTime;
57 | $this->supervisors = $supervisors;
58 | }
59 |
60 | /**
61 | * Get the current workload of each queue.
62 | *
63 | * @return array}>
64 | */
65 | public function get()
66 | {
67 | $processes = $this->processes();
68 |
69 | return collect($this->waitTime->calculate())
70 | ->map(function ($waitTime, $queue) use ($processes) {
71 | [$connection, $queueName] = explode(':', $queue, 2);
72 |
73 | $totalProcesses = $processes[$queue] ?? 0;
74 |
75 | $length = ! Str::contains($queue, ',')
76 | ? collect([$queueName => $this->queue->connection($connection)->readyNow($queueName)])
77 | : collect(explode(',', $queueName))->mapWithKeys(function ($queueName) use ($connection) {
78 | return [$queueName => $this->queue->connection($connection)->readyNow($queueName)];
79 | });
80 |
81 | $splitQueues = Str::contains($queue, ',') ? $length->map(function ($length, $queueName) use ($connection, $totalProcesses, &$wait) {
82 | return [
83 | 'name' => $queueName,
84 | 'length' => $length,
85 | 'wait' => $wait += $this->waitTime->calculateTimeToClear($connection, $queueName, $totalProcesses),
86 | ];
87 | }) : null;
88 |
89 | return [
90 | 'name' => $queueName,
91 | 'length' => $length->sum(),
92 | 'wait' => $waitTime,
93 | 'processes' => $totalProcesses,
94 | 'split_queues' => $splitQueues,
95 | ];
96 | })->values()->toArray();
97 | }
98 |
99 | /**
100 | * Get the number of processes of each queue.
101 | *
102 | * @return array
103 | */
104 | private function processes()
105 | {
106 | return collect($this->supervisors->all())->pluck('processes')->reduce(function ($final, $queues) {
107 | foreach ($queues as $queue => $processes) {
108 | $final[$queue] = isset($final[$queue]) ? $final[$queue] + $processes : $processes;
109 | }
110 |
111 | return $final;
112 | }, []);
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/ServiceBindings.php:
--------------------------------------------------------------------------------
1 | RedisHorizonCommandQueue::class,
16 | Listeners\TrimRecentJobs::class,
17 | Listeners\TrimFailedJobs::class,
18 | Listeners\TrimMonitoredJobs::class,
19 | Lock::class,
20 | Stopwatch::class,
21 |
22 | // Repository services...
23 | Contracts\JobRepository::class => Repositories\RedisJobRepository::class,
24 | Contracts\MasterSupervisorRepository::class => Repositories\RedisMasterSupervisorRepository::class,
25 | Contracts\MetricsRepository::class => Repositories\RedisMetricsRepository::class,
26 | Contracts\ProcessRepository::class => Repositories\RedisProcessRepository::class,
27 | Contracts\SupervisorRepository::class => Repositories\RedisSupervisorRepository::class,
28 | Contracts\TagRepository::class => Repositories\RedisTagRepository::class,
29 | Contracts\WorkloadRepository::class => Repositories\RedisWorkloadRepository::class,
30 |
31 | // Notifications...
32 | Contracts\LongWaitDetectedNotification::class => Notifications\LongWaitDetected::class,
33 | ];
34 | }
35 |
--------------------------------------------------------------------------------
/src/Stopwatch.php:
--------------------------------------------------------------------------------
1 | timers[$key] = microtime(true);
23 | }
24 |
25 | /**
26 | * Check a given timer and get the elapsed time in milliseconds.
27 | *
28 | * @param string $key
29 | * @return float|null
30 | */
31 | public function check($key)
32 | {
33 | if (isset($this->timers[$key])) {
34 | return round((microtime(true) - $this->timers[$key]) * 1000, 2);
35 | }
36 | }
37 |
38 | /**
39 | * Forget a given timer.
40 | *
41 | * @param string $key
42 | * @return void
43 | */
44 | public function forget($key)
45 | {
46 | unset($this->timers[$key]);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/SupervisorCommandString.php:
--------------------------------------------------------------------------------
1 | name} {$options->connection} %s",
26 | $command,
27 | static::toOptionsString($options)
28 | );
29 | }
30 |
31 | /**
32 | * Get the additional option string for the command.
33 | *
34 | * @param \Laravel\Horizon\SupervisorOptions $options
35 | * @return string
36 | */
37 | public static function toOptionsString(SupervisorOptions $options)
38 | {
39 | return QueueCommandString::toSupervisorOptionsString($options);
40 | }
41 |
42 | /**
43 | * Reset the base command back to its default value.
44 | *
45 | * @return void
46 | */
47 | public static function reset()
48 | {
49 | static::$command = 'exec @php artisan horizon:supervisor';
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/SupervisorCommands/Balance.php:
--------------------------------------------------------------------------------
1 | balance($options);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/SupervisorCommands/ContinueWorking.php:
--------------------------------------------------------------------------------
1 | continue();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/SupervisorCommands/Pause.php:
--------------------------------------------------------------------------------
1 | pause();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/SupervisorCommands/Restart.php:
--------------------------------------------------------------------------------
1 | restart();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/SupervisorCommands/Scale.php:
--------------------------------------------------------------------------------
1 | scale($options['scale']);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/SupervisorCommands/Terminate.php:
--------------------------------------------------------------------------------
1 | terminate($options['status'] ?? 0);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/SupervisorFactory.php:
--------------------------------------------------------------------------------
1 | '2000']);
25 |
26 | $process->run();
27 |
28 | return substr_count($process->getOutput(), 'supervisor='.$name);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/WorkerCommandString.php:
--------------------------------------------------------------------------------
1 | connection} %s",
26 | $command,
27 | static::toOptionsString($options)
28 | );
29 | }
30 |
31 | /**
32 | * Get the additional option string for the command.
33 | *
34 | * @param \Laravel\Horizon\SupervisorOptions $options
35 | * @return string
36 | */
37 | public static function toOptionsString(SupervisorOptions $options)
38 | {
39 | return QueueCommandString::toWorkerOptionsString($options);
40 | }
41 |
42 | /**
43 | * Reset the base command back to its default value.
44 | *
45 | * @return void
46 | */
47 | public static function reset()
48 | {
49 | static::$command = 'exec @php artisan horizon:work';
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/stubs/HorizonServiceProvider.stub:
--------------------------------------------------------------------------------
1 | email, [
32 | //
33 | ]);
34 | });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/testbench.yaml:
--------------------------------------------------------------------------------
1 | providers:
2 | - Laravel\Horizon\HorizonServiceProvider
3 | - Workbench\App\Providers\HorizonServiceProvider
4 |
5 | env:
6 | - REDIS_CLIENT="predis"
7 |
8 | migrations: true
9 | seeders:
10 | - Workbench\Database\Seeders\DatabaseSeeder
11 |
12 | workbench:
13 | start: '/horizon'
14 | user: 'horizon@laravel.com'
15 | install: true
16 | welcome: true
17 | build:
18 | - asset-publish
19 | - create-sqlite-db
20 | - db-wipe
21 | - migrate-fresh
22 | assets: []
23 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import vue from '@vitejs/plugin-vue';
2 |
3 | /** @type {import('vite').UserConfig} */
4 | export default {
5 | plugins: [vue()],
6 | build: {
7 | assetsDir: '',
8 | rollupOptions: {
9 | input: ['resources/js/app.js', 'resources/sass/styles.scss', 'resources/sass/styles-dark.scss'],
10 | output: {
11 | entryFileNames: '[name].js',
12 | chunkFileNames: '[name].js',
13 | assetFileNames: '[name].[ext]',
14 | },
15 | },
16 | },
17 | resolve: {
18 | alias: {
19 | '@': '/resources/js',
20 | },
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/workbench/app/Providers/HorizonServiceProvider.php:
--------------------------------------------------------------------------------
1 | 'Laravel Horizon',
19 | 'email' => 'horizon@laravel.com',
20 | 'password' => $password,
21 | ]);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------