Перейти к содержимому

Уведомления о падении фоновых задач

Фоновая задача упала — и об этом легко не узнать: пользователь ничего не видел, в логах воркера тихо, а письмо про сбойный экспорт так и не ушло. Хуже всего, когда джоба исчерпала все ретраи и просто «умерла». Ловим именно этот момент — финальный провал — и шлём пуш с именем задачи и ошибкой.

  • Только на сервере. Хук вешаем внутри воркера, notify() вызываем оттуда.
  • Ловим финальный провал, а не каждый ретрай. Иначе одна задача с 5 ретраями устроит 5 пушей. Нужен момент, когда попытки кончились.
  • Дедуп по классу задачи. Если «упало 200 писем», хочется один пуш «SendEmail × 200», а не 200 отдельных. Дедуп по имени класса джобы.

Берём notify() из обзорной страницы:

import os, requests
def notify(title, message, priority=5):
requests.post(
f"{os.environ['NOTIFLY_URL']}/message",
params={"token": os.environ["NOTIFLY_TOKEN"]},
json={"title": title, "message": message, "priority": priority},
timeout=5,
)

Сигнал task_failure срабатывает, когда задача упала окончательно (ретраи кидают Retry и сюда не попадают). Дедуп — по имени задачи:

import time, traceback
from collections import defaultdict
from celery.signals import task_failure
from notifly import notify
_last = defaultdict(float) # имя задачи -> время последнего пуша
_COOLDOWN = 300 # 5 минут на один и тот же класс задачи
@task_failure.connect
def on_task_failure(sender=None, task_id=None, exception=None, **kw):
name = sender.name if sender else "unknown"
if time.time() - _last[name] < _COOLDOWN:
return # дедуп по классу задачи
_last[name] = time.time()
tb = "".join(traceback.format_exception(exception))[-1200:]
notify(
f"🔧 Джоба упала: {name}",
f"task_id={task_id}\n{type(exception).__name__}: {exception}\n\n{tb}",
8,
)

Слушаем событие failed, но шлём пуш только когда исчерпаны все попытки (attemptsMade >= opts.attempts):

const {Worker} = require('bullmq');
const {notify} = require('./notifly');
const lastSent = new Map(); // имя джобы -> timestamp
const COOLDOWN_MS = 300_000; // 5 минут на класс
const worker = new Worker('emails', async (job) => {
await processJob(job);
}, {connection: {host: 'localhost', port: 6379}});
worker.on('failed', (job, err) => {
if (!job) return;
// шлём только на финальный провал, а не на каждый ретрай
if (job.attemptsMade < (job.opts.attempts || 1)) return;
const key = job.name;
if (Date.now() - (lastSent.get(key) || 0) < COOLDOWN_MS) return; // дедуп по классу
lastSent.set(key, Date.now());
notify(
`🔧 Джоба упала: ${job.name}`,
`id=${job.id}, попыток=${job.attemptsMade}\n${err.message}\n\n${err.stack?.slice(0, 1200)}`,
8
);
});

У Sidekiq есть death_handlers — вызываются, когда задача исчерпала все ретраи и уезжает в Dead set. Это ровно тот момент, который нам нужен:

require 'net/http'
require 'json'
$notifly_last = {} # класс джобы -> время последнего пуша
def notifly(title, message, priority = 5)
uri = URI("#{ENV['NOTIFLY_URL']}/message?token=#{ENV['NOTIFLY_TOKEN']}")
Net::HTTP.post(uri, {title: title, message: message, priority: priority}.to_json,
'Content-Type' => 'application/json')
rescue => e
warn "notifly failed: #{e.message}"
end
Sidekiq.configure_server do |config|
config.death_handlers << ->(job, ex) {
klass = job['class']
# дедуп по классу задачи: не чаще раза в 5 минут
next if $notifly_last[klass] && Time.now - $notifly_last[klass] < 300
$notifly_last[klass] = Time.now
notifly(
"🔧 Джоба умерла: #{klass}",
"jid=#{job['jid']}\n#{ex.class}: #{ex.message}",
8
)
}
end
  • Растёт глубина очереди. Если необработанных задач > N — воркер не справляется; тихий алерт (приоритет 5).
  • Долгая задача зависла. Джоба выполняется дольше обычного в разы — стоит посмотреть; сюда же хорошо ложится heartbeat.
  • Обработка ретраев. Отдельно можно сообщать «задача ушла на 3-й ретрай» — тихо (приоритет 3), чтобы поймать нестабильность раньше финального провала.
  • Тихие падения становятся видимыми. Фоновая задача не кричит пользователю — теперь она кричит вам.
  • Контекст в сообщении. Имя задачи, id и ошибка — обычно этого достаточно, чтобы понять, куда смотреть.
  • Без шторма. Дедуп по классу: один битый деплой не превращается в сотни пушей.