Coolify 的 Horizon 队列告警实战:waits 阈值、通知路由与 JobFailed 的边界
本文基于 Coolify 仓库内的 Horizon 告警参考文档(.agents/skills/configuring-horizon/references/notifications.md)展开,讲清三件实战中最容易被混淆的事:config/horizon.php 中的 waits 数组如何决定 LongWaitDetected 事件何时触发、如何通过 HorizonServiceProvider 把告警路由到邮件/Slack/短信、以及为什么"失败任务告警"不属于 Horizon 官方通知路由的范畴。读完可掌握 Coolify(Laravel + Redis 队列 + Laravel Horizon)场景下队列积压告警的正确配置路径与排错思路。
一、核心概念:LongWaitDetected 事件与 waits 阈值
Laravel Horizon 内置了一个名为 LongWaitDetected 的事件:当某个队列中的任务在等待时间超过设定阈值仍未被消费时,Horizon 会触发该事件,并把它交给自己的通知发送器(notification sender)处理。阈值不在通知路由里配置,而是写在 config/horizon.php 的 waits 数组中,每一个 连接:队列 组合可以拥有独立的等待秒数。
文档给出的结论很直接:
如果告警触发得太频繁或太晚,应该调整
waits,而不是去改通知路由配置。
在 Coolify 仓库中可以直接验证这一机制的存在方式。config/horizon.php 中对应的配置段:
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
要点拆解:
- 键的格式是
connection:queue,例如'redis:default' => 60表示 redis 连接的 default 队列;如果你的业务任务派发到其他队列(Coolify 默认队列由HORIZON_QUEUES环境变量控制,见下文),需要为对应组合单独声明阈值,否则该组合不产生 LongWaitDetected 告警。 - 单位是秒,60 即任务在队列中积压等待超过 60 秒才告警。
- 这个值是"触发事件"的开关,与"事件发生后通知谁"完全是两回事——前者在
waits,后者在 Service Provider 的路由方法里。
值得结合仓库环境说明的一点:Coolify 锁定的 Horizon 版本是 laravel/horizon: ^5.48.2(见 composer.json 的 require 段)。LongWaitDetected 属于 Laravel 队列事件体系(Illuminate\Queue\Events\LongWaitDetected),Horizon 在其内部将其接入通知发送链路,因此上述机制在当前仓库的实现事实层面成立;而"12.x 文档"之类的版本表述指的是 Laravel Horizon 文档版本线,与仓库实际锁定的包版本无关,配置行为以仓库内 config/horizon.php 为准。
二、通知路由:在 HorizonServiceProvider 中接入邮件 / Slack / SMS
文档的第二个要点:不要把 LongWaitDetected 当普通 Laravel 事件手动注册 listener。Horizon 已经把该事件接入了自己的通知发送器,正确做法是在 App\Providers\HorizonServiceProvider 的 boot() 方法里使用内置路由方法:
Horizon::routeMailNotificationsTo($email)—— 邮件告警Horizon::routeSlackNotificationsTo($webhookUrl)—— Slack webhookHorizon::routeSmsNotificationsTo($phone)—— 短信告警
也就是说,"配置告警" = 配 waits(何时触发)+ 配路由(触发后发给谁),两者缺一不可;如果你发现告警"没人收到",先检查路由是否配置,而不是怀疑事件没触发。
Coolify 仓库中的 HorizonServiceProvider 实际结构
app/Providers/HorizonServiceProvider.php 继承自 Laravel\Horizon\HorizonApplicationServiceProvider,这是理解整套机制的关键:父类在 boot() 中完成了 LongWaitDetected 到通知发送器的接线与路由注册,子类 boot() 首行调用 parent::boot()(第 34 行)之后,才能在其后安全叠加自定义逻辑。
Coolify 的 boot() 中实际叠加了两段与队列事件相关的自定义监听,可作为"在 Horizon 官方路由之外追加自定义处理"的参照实现:
- JobReserved 监听(第 35–53 行):当
App\Jobs\ApplicationDeploymentJob被 worker 领取时,从任务 tags 中解析出App\Models\ApplicationDeploymentQueue的 ID,把horizon_job_id回写到部署队列记录上——这是 Coolify 部署流程与 Horizon 任务 ID 的关联机制,与告警无关,但展示了同一 Service Provider 内事件监听的典型写法。 - JobFailed 监听(第 55–73 行):仅在云端环境(
isCloud())下,当失败异常为DeploymentException或TimeoutExceededException时,尽力从 Horizon 的失败任务存储中删除该任务(app(JobRepository::class)->deleteFailed($uuid)),并用 try/catch 包裹保证"清理失败不影响原始失败语义"。
注意第二段监听不是告警,而是数据清理。它恰好佐证了文档第三节的观点——Coolify 需要额外行为时,走的是"自定义队列事件处理"路线,而不是指望 Horizon 官方通知路由覆盖失败场景。
此外,该 Provider 的 gate() 方法(第 76–89 行)通过 viewHorizon Gate 限制面板访问:仅用户 ID 为 0 或邮箱在 horizon.allowed_emails 配置(对应 config/horizon.php 的 HORIZON_ALLOWED_EMAILS 环境变量,逗号分隔)中的用户可访问。这与通知路由同属"接入层"配置,排障时可一并核对。
路由配置的落地位置
在你自己的应用(或扩展 Coolify 的派生项目)中,推荐写法:
namespace App\Providers;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
public function boot(): void
{
parent::boot();
// 告警触发后,路由到邮件
Horizon::routeMailNotificationsTo('ops@example.com');
// 可选:Slack webhook / 短信
// Horizon::routeSlackNotificationsTo('https://hooks.slack.com/services/...');
// Horizon::routeSmsNotificationsTo('+8613800138000');
}
}
两个实践提示(与文档口径一致):
- 路由调用放在
boot()且必须在parent::boot()之后,确保父类完成事件接线; - 如果告警"时灵时不灵",优先用
waits中目标队列组合是否存在、阈值是否合理来解释,路由配置本身不控制触发时机。
三、失败任务(JobFailed)告警:不属于 Horizon 官方通知路由
文档第三节划清了边界:Horizon 官方文档覆盖的是长等待(LongWaitDetected)告警,不要假设文档里存在 JobFailed 的 listener 示例。任务失败(Illuminate\Queue\Events\JobFailed)属于 Laravel 队列事件体系,若需要"任务失败即通知",应当:
- 作为自定义队列事件处理实现——监听
JobFailed(如 HorizonServiceProvider.php 第 55 行 的做法),在其内部自行拼装通知逻辑(发邮件、推 webhook 等); - 参考的是 Laravel 队列(Queue)文档,而非 Horizon 的通知路由 API。
Coolify 仓库本身即为这一路线提供了现成证据:它的 JobFailed 监听只处理"删除特定失败任务"这一业务需求,并未接入任何通知渠道;失败任务对用户的可见性由 Coolify 自身的应用层通知体系承担(如 app/Jobs/ApplicationDeploymentJob.php 所在的部署流程与 bootstrap/helpers/notifications.php 对应的通知工具),这正是"Horizon 官方路由 + 应用自定义事件处理"分工的典型形态。
排错对照表:
| 现象 | 应排查的位置 | 依据 |
|---|---|---|
| 告警太频繁 / 太迟 | config/horizon.php 的 waits 阈值 |
config/horizon.php |
| 告警从未送达 | HorizonServiceProvider::boot() 中的 Horizon::route*NotificationsTo() 是否配置 |
HorizonServiceProvider.php |
| 想要"任务失败"告警 | 自定义监听 JobFailed,走队列事件文档 |
HorizonServiceProvider.php(清理型监听的参照) |
四、结合 Coolify 队列现状的配置建议
从 config/horizon.php 的 supervisor 定义可以看到,Coolify 使用单个 supervisor(键名 s6),队列为 HORIZON_QUEUES 环境变量指定的 high,default(默认值),tries 为 1、maxJobs 为 400,并带有按 ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600 抬高的动态 timeout 下限。由此可给出针对本仓库的告警落地建议:
- 为真实消费队列设置 waits。仓库当前
waits只有redis:default,而 supervisor 实际消费high,default两个队列——如果high队列上的任务也需积压告警,应在waits中补充'redis:high' => <秒数>,而不是调整路由。 - 阈值与任务耗时匹配。Coolify 队列中存在长任务(部署、备份),
timeout被刻意抬高;若把 waits 设得远小于长任务的实际执行前置等待,可能出现"任务还在正常排程中"的误报,建议以队列 P95 排队时长为基线上浮设置。 - 不要为失败告警寻找 Horizon 开关。按第三节路线,在
boot()内叠加JobFailed监听(注意保持 try/catch 兜底风格),通知渠道复用应用层已有的通知设施。
五、小结
围绕 .agents/skills/configuring-horizon/references/notifications.md 的三个要点,可以归纳为一条清晰的排障决策链:
- 触发层:
waits(config/horizon.php)按connection:queue设定 LongWaitDetected 秒数阈值,告警时机只在这里调整; - 路由层:
HorizonServiceProvider::boot()中Horizon::routeMailNotificationsTo()/routeSlackNotificationsTo()/routeSmsNotificationsTo(),Horizon 已内置 LongWaitDetected 与通知发送器的接线,无需手动注册 listener; - 边界层:JobFailed 告警不在 Horizon 官方通知路由范围内,属于自定义队列事件处理,应参照 Laravel 队列文档实现(Coolify 的 JobFailed 清理监听 是该模式的仓库内实例)。
配套参考文档位于同一 skill 目录下:supervisor 与扩容策略见 references/supervisors.md,指标快照与保留策略见 references/metrics.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00