Фоновая работа
Приложение, которому нужно что-то делать вне запроса (отправить уведомление после того, как ответ ушёл, каждый час вычищать старые строки, повторить неудавшийся вызов), регистрирует это во фреймворке, который выполняет его в одном фоновом потоке обслуживающего процесса:
# 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
# в действии, после сохранения пользователя
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: — источником.
./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 отдельным процессом:
# 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, пока оно не
закончится.
# 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.