CyberTrainРуководстваФоновая работа и эксплуатация

Фоновая работа и эксплуатация

Фоновая работа

Приложение, которому нужно что-то делать вне запроса (отправить уведомление после того, как ответ ушёл, каждый час вычищать старые строки, повторить неудавшийся вызов), регистрирует это во фреймворке, который выполняет его в одном фоновом потоке обслуживающего процесса:

ruby
# config/app.rb (или файл, который bin/<app>.rb подключает до Cybertrain::Main.run)
Cybertrain::Scheduler.every(3600, "prune sessions") do
  Cybertrain::DB.with { |c| c.execute("DELETE FROM sessions WHERE updated_at < datetime('now', '-30 days')") }
end

Cybertrain::Job.handle("welcome_mail") do |payload|
  data = Cybertrain::Cast.hash(JSON.parse(payload))
  WelcomeMail.deliver(data["email"].to_s)   # возбудите исключение, чтобы задача была повторена
end
ruby
# в действии, после сохранения пользователя
Cybertrain::Job.enqueue("welcome_mail", JSON.generate({ "email" => @user.email }))
Cybertrain::Job.enqueue("reminder", @order.id.to_s, run_at: Time.now + 600)
  • Когда это выполняется. Поток запускается, когда бинарный файл обслуживает запросы (./<app>, ./<app> server), после того как при запуске подключена база данных, занят порт и задан SPINEL_WORKERS; команды migrate, db ... и Main.command никогда его не запускают, как и Cybertrain::Test::Client. Задача выполняется в первый раз через seconds после старта, а затем через seconds после начала каждого выполнения. Каждое выполнение обёрнуто в rescue, который пишет в лог [ERROR] Scheduled task "name" failed: ..., и цикл продолжается. Блок не принимает аргументов (в Spinel нет instance_eval). Cybertrain::Scheduler.last_tick — секунда эпохи последнего такта, для вашей собственной проверки работоспособности.
  • Задачи. Job.enqueue добавляет строку в таблицу cybertrain_jobs, которую фреймворк создаёт сам (CREATE TABLE IF NOT EXISTS, при первом использовании) и не включает в db/schema.rb. Полезная нагрузка — строка (String): структурированные данные кодируйте в JSON. Внутри DB.transaction строка фиксируется или откатывается вместе с остальным. Обработчик — задача планировщика, опрашивающая каждые job_poll_interval секунд (5), — забирает самую старую задачу, срок которой наступил, в короткой транзакции BEGIN IMMEDIATE, выполняет её обработчик вне этой транзакции и помечает её как done. Обработчик, возбудивший исключение, повторяется через 1, 5, 15, а затем 60 минут, с Class: message (до 1000 символов) в last_error, пока не завершатся неудачей job_max_attempts (5) попыток: тогда строка получает состояние failed и остаётся, чтобы вы на неё посмотрели. Задача, для вида которой нет обработчика, сразу получает failed; Job.enqueue отвергает незарегистрированный вид. Строки, которые упавший процесс оставил в состоянии running, возвращаются в очередь при запуске обработчика. Job.state_count("failed") и Job.prune_done(before_epoch) помогают со страницей состояния и уборкой.
  • Один обработчик на базу данных. Каждый обслуживающий процесс запускает поток, если не задано CYBERTRAIN_WORKER=0 (config.worker = false): когда несколько процессов делят один файл SQLite, отключите его во всех, кроме одного, иначе каждая задача по расписанию будет выполняться по разу в каждом процессе.
  • Делайте задачи короткими. Задачи и обработчики выполняются по одной в этом единственном потоке, а вызов SQLite удерживает свой рабочий поток ОС до возврата: при SPINEL_WORKERS=1 (по умолчанию) долгий запрос на столько же задерживает каждый запрос. Разбивайте большую работу на части (удаляйте по тысяче строк за выполнение, ставьте в очередь по задаче на элемент), а не делайте её за один раз.
  • Тестирование. Cybertrain::Scheduler.run_due(now) выполняет один такт, а Cybertrain::Job.work_one(now) — одну задачу, с выбранным вами временем эпохи, поэтому тест никогда не спит.

Синтаксиса cron, приоритетов и уникальности задач и deliver_later нет: задача, которая отправляет почту, сама вызывает Cybertrain::Mailer (Отправка электронной почты). Это покрывает сценарии «отправить позже» и «выполнять каждые N секунд» (docs/design.md D28).

Эксплуатация: резервные копии, VACUUM, проверка работоспособности

Данные развёрнутого приложения — это один файл SQLite, поэтому бинарный файл содержит три инструмента эксплуатации, нужные такому приложению. Приложение с загрузкой файлов также хранит их байты в своём корне загрузок (upload_root, по умолчанию storage/uploads/): создавайте резервную копию этого каталога и восстанавливайте его вместе с базой данных, иначе восстановленные записи будут указывать на отсутствующие файлы (Добавление загрузки файла).

Резервные копии. ./<app> db backup PATH записывает согласованную копию настроенной базы данных (CYBERTRAIN_DATABASE), пока сервер продолжает работать: VACUUM INTO SQLite (3.27 или новее; бинарный файл это проверяет) читает один снимок, а при WAL записи сервера тем временем продолжаются. Копия пишется во временный файл рядом с PATH, создаваемый эксклюзивно с режимом 0600 (в файле хранятся дайджесты паролей и токены сессий), сбрасывается на диск через fsync и только затем переименовывается в PATH, поэтому неудачное или прерванное копирование никогда не оставляет неполный файл под настоящим именем (временный удаляется). Существующий PATH отвергается, если не передан --force; сама база данных никогда не бывает целью, а база данных :memory: — источником.

sh
./blog db backup /srv/blog/backups/before-upgrade.sqlite3
./blog db backup /srv/blog/backups/ --keep 14    # blog-20261025T031500Z.sqlite3, хранятся 14 самых новых

Цель, оканчивающаяся на / (или существующий каталог), получает имя с датой, <app>-<YYYYmmddTHHMMSSZ>.sqlite3 в UTC, в каталоге, который создаётся с режимом 0700, если его нет; затем --keep N удаляет самые старые файлы ровно такого вида сверх N и больше ничего там не трогает. Восстановление — это копирование резервной копии поверх файла базы данных при остановленном сервере (и удаление лежащих рядом -wal и -shm этого файла).

VACUUM. Удаление строк оставляет в файле свободные страницы. ./<app> db vacuum выполняет VACUUM, а затем PRAGMA wal_checkpoint(TRUNCATE), поэтому перестроенные страницы попадают в файл базы данных, файл обрезается до нового размера, а WAL опустошается; команда выводит размер на диске (база данных плюс WAL) до и после. VACUUM переписывает весь файл и всё это время держит блокировку записи, поэтому запускайте его, когда приложение не нагружено. Если читатель не даёт контрольной точке завершиться, команда об этом сообщает, и WAL уменьшится на одной из следующих контрольных точек.

Делайте резервные копии вне сервера. Вызов SQLite удерживает свой рабочий поток ОС до возврата, поэтому большой VACUUM INTO в задаче Scheduler задерживал бы запросы (при SPINEL_WORKERS=1 — все) на всё время копирования. Вместо этого запускайте команду из cron или таймера systemd отдельным процессом:

sh
# crontab пользователя приложения: каждую ночь в 03:15, хранить две недели
15 3 * * * cd /srv/blog/current && ./blog db backup /srv/blog/backups/ --keep 14

Приложение, которое хочет само делать резервные копии, может из задачи по расписанию запустить собственный бинарный файл дочерним процессом, вместо того чтобы выполнять SQL в своём потоке: system ждёт дочерний процесс только в потоке планировщика, а запросы тем временем продолжают обслуживаться (в Spinel нет Process.wait, а Process.spawn без него оставляет зомби, поэтому используйте system со списком аргументов, никогда — со строкой, содержащей данные запроса). Копирование, которое длится дольше health_worker_stale, заставляет проверку работоспособности отвечать worker stale, пока оно не закончится.

ruby
# config/app.rb, в обслуживающем бинарном файле, запущенном из своего каталога (dist/)
Cybertrain::Scheduler.every(86_400, "nightly backup") do
  system("./blog", "db", "backup", "storage/backups/", "--keep", "14")
end

Проверка работоспособности. Пока проверка работоспособности включена (по умолчанию она включена; пустой config.health_path её отключает), обслуживающее приложение отвечает на GET /up (и HEAD) до статических файлов, сессии, CSRF и маршрутизатора: контроллер не участвует, никакой before_action входа не может сделать редирект, и cookie не устанавливается. Она выполняет SELECT count(*) FROM sqlite_master, которому приходится прочитать страницу схемы из файла (голый SELECT 1 его никогда не трогает), и отвечает в text/plain с Cache-Control: no-store:

Статус Тело Когда
200 ok запрос выполнен успешно
503 database unavailable он возбудил Cybertrain::DB::Error (файл отсутствует или не читается, пул закрыт)
503 worker stale фоновый Scheduler работает, и его последний такт старше health_worker_stale секунд (300): задача зависла или выполняется слишком долго

Опрос пишется в лог, как любой запрос (две строки). Путь — config.health_path (CYBERTRAIN_HEALTH_PATH, по умолчанию /up; пустое значение отключает проверку, и тогда путь, как и раньше, уходит в public/ и маршрутизатор). Её может опрашивать балансировщик нагрузки, kamal-proxy или скрипт развёртывания; public/up.html, который пишет cybertrain new и который по-прежнему опрашивает его конфигурация Kamal, показывает лишь то, что процесс отвечает по HTTP.