├── 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 |

Laravel Horizon

2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 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 | 112 | 113 | 126 | -------------------------------------------------------------------------------- /resources/js/components/LineChart.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 76 | -------------------------------------------------------------------------------- /resources/js/components/SchemeToggler.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 66 | -------------------------------------------------------------------------------- /resources/js/components/Stacktrace.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 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 | 38 | -------------------------------------------------------------------------------- /resources/js/screens/metrics/jobs.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 80 | -------------------------------------------------------------------------------- /resources/js/screens/metrics/queues.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 80 | -------------------------------------------------------------------------------- /resources/js/screens/monitoring/job-row.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 74 | -------------------------------------------------------------------------------- /resources/js/screens/monitoring/tag.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 31 | -------------------------------------------------------------------------------- /resources/js/screens/recentJobs/job-row.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------