Skip to content

База данных, миграции, резервные копии и репликация

На этой странице описано, как dinary хранит данные, как запускаются миграции схемы, как устроено полное резервное копирование (локальные холодные копии, горячая репликация на VM 2 через Litestream, ежедневные офсайт-бэкапы на Яндекс.Диск) и как восстанавливаться из каждого из этих слоёв.

Файл базы данных

Сервер хранит всё в одной SQLite-базе:

  • data/dinary.db — живая база данных (категории, группы, теги, магазины, mapping-таблицы, расходы, доходы и метаданные sync-задач)
  • data/dinary.db-wal и data/dinary.db-shm — вспомогательные файлы SQLite в режиме WAL, в которых лежат ещё не заchekpointенные транзакции, прежде чем они будут слиты обратно в dinary.db

Все три файла принадлежат друг другу. Не копируйте только dinary.db: в WAL-сайдкаре могут быть закоммиченные, но ещё не перенесённые в основной файл транзакции. Любой способ резервного копирования, не использующий API SQLite .backup, должен сначала остановить сервис dinary.

Миграции

Изменения схемы управляются миграциями yoyo в каталоге src/dinary/migrations/.

Когда миграции запускаются автоматически

  • При старте приложения SQLite-файл открывается и yoyo применяет все ожидающие миграции до того, как будет обслужен первый запрос.
  • Во время inv deploy deploy-скрипт вызывает inv migrate до перезапуска сервиса — так сломанная миграция роняет deploy, а не уже работающий сервер.

Для свежей установки отдельного ручного шага миграции не требуется.

Ручной запуск

Миграции применяются автоматически при каждом старте сервера — yoyo отслеживает уже применённые миграции и накатывает только новые. Отдельный ручной шаг не нужен: достаточно задеплоить и перезапустить.

Проверка целостности

SQLite предоставляет две прагмы для пост-миграционной и пост-восстановительной проверки:

  • PRAGMA integrity_check обходит все btree-страницы и сообщает о структурных повреждениях (порванные страницы, рассогласование индекса и таблицы, осиротевшие записи freelist).
  • PRAGMA foreign_key_check перечисляет каждую строку, нарушающую объявленный внешний ключ.

Обе — только для чтения и дешёвые. dinary оборачивает их в:

inv verify-db            # локальная data/dinary.db
inv verify-db --remote   # снапшот prod-базы, проверяемый по SSH

--remote сначала делает на сервере снапшот через sqlite3 .backup в /tmp, а затем проверяет его, чтобы случайно не прочитать живой файл в середине checkpoint-а WAL.

Резервные копии

SQLite — однофайловая база, поэтому любой бэкап в итоге сводится к «получить транзакционно-согласованную копию data/dinary.db». dinary предлагает два механизма в зависимости от требований к свежести и допустимой задержке.

Холодная копия: inv backup

inv backup                        # копия в ./backups/<timestamp>/
inv backup --dest=./my-backups

Команда подключается по SSH к серверу, запускает sqlite3 .backup на живой БД для получения согласованного снапшота в /tmp, стримит байты локально и записывает ./backups/<timestamp>/dinary.db. Снапшот атомарен даже когда сервис пишет — онлайн-бэкап API SQLite копирует страницы под коротким замком и повторяет срыв чтения.

Запускайте inv backup перед:

  • inv deploy (обёртка deploy-а сама делает такой же pre-deploy снапшот автоматически)
  • ручной миграцией схемы
  • любой ad-hoc хирургией над БД

Горячая реплика: Litestream

inv backup — pull-based и срабатывает по требованию. Для непрерывной потоковой репликации — «VM 1 только что исчезла, сколько данных я потерял» — dinary поддерживает Litestream (v0.5.x) как sidecar, который постоянно шлёт LTX-сегменты на SFTP-получатель.

Предусловия

  • Вторая VM, которой вы управляете (VM 2 — хост Litestream-реплики). Референсная цель — Oracle Cloud Free Tier VM.Standard.E2.1.Micro c Ubuntu 22.04 Minimal, тот же шейп, что и VM 1.
  • На операторской машине в .deploy/.env задан DINARY_REPLICA_HOST (например, ubuntu@dinary-replica через Tailscale MagicDNS).
  • ~/.ssh/id_ed25519.pub с VM 1 добавлен в ~/.ssh/authorized_keys на VM 2 — это и есть то доверие, которое позволяет litestream.service на VM 1 пушить WAL- сегменты по SFTP. Разовый ssh-copy-id придётся сделать руками: кросс-хостовое доверие автоматизировать с операторской машины нельзя.

Бутстрап VM 2

Разово, с операторской машины против VM 2:

inv setup-replica

Задача ставит unattended-upgrades, выделяет 1 GB swap, создаёт /var/lib/litestream/ с правильным владельцем, чтобы SFTP-приёмник смог писать туда WAL-сегменты, и закрывает публичный SSH (см. Cloud security notes — там обоснование). Идемпотентна — повторный запуск после перезагрузки или смены IP в Tailscale сходится чисто.

setup-replica НЕ ставит на VM 2 сервис приложения dinary, публичный тоннель или любой Python-рантайм — VM 2 сознательно остаётся минимальным SFTP-sink-ом. Всё, что нужно ежедневному офсайт-бэкапу (rclone, sqlite3, zstd, бинарник Litestream для локального restore), добавляет inv setup-replica — см. раздел «Офсайт-бэкап: Яндекс.Диск» ниже.

Разовая настройка Litestream на VM 1

  1. Скопируйте пример конфига локально и заполните SFTP-цель:
cp .deploy.example/litestream.yml .deploy/litestream.yml
# отредактируйте .deploy/litestream.yml — проставьте host, user, path, key-path
  1. Установите sidecar Litestream на VM 1 (теперь часть inv setup-replica):
inv setup-replica

Задача ставит бинарник Litestream, заливает конфиг в /etc/litestream.yml, создаёт systemd-unit litestream.service и запускает его. Операция идемпотентна — повторный inv setup-replica обновит бинарник и перезагрузит конфиг.

  1. Проверьте здоровье репликации:
inv status --remote

На здоровом sidecar-е systemd-unit активен, а litestream databases печатает путь к управляемой БД. Пустой вывод означает, что sidecar либо не смог добраться до SFTP-хоста, либо ещё делает первый снапшот (первый снапшот появляется в течение секунд после первой записи в БД после старта sidecar-а).

inv setup-server НЕ запускает Litestream автоматически даже при наличии .deploy/litestream.yml: sidecar требует уже доступного SFTP-хоста, чьи authorized_keys доверяют ed25519-ключу VM 1, — это межхостовое доверие deploy-скрипт поднять за вас не может. Запустите inv setup-replica вручную, когда это предусловие выполнено.

Что делает sidecar

Litestream v0.5 — пассивный репликатор: он открывает БД только на чтение, забирает LTX-сегменты из WAL SQLite, уплотняет их в уровневые файлы и отправляет на SFTP-цель. Приложение никогда с ним не общается. Если sidecar падает, приложение продолжает писать в WAL как обычно — вы просто перестаёте накапливать состояние реплики до следующего запуска sidecar-а. Обратного давления нет; цикл checkpoint-а SQLite не затрагивается.

По умолчанию в примере конфига полный снапшот делается раз в час, хранится одна неделя LTX-истории (глобальный блок snapshot: { interval: 1h, retention: 168h }). Это ограничивает «насколько далеко назад я могу откатить БД» одной неделей и длину проигрыша LTX при восстановлении — одним часом. Эти поля в v0.5 — глобальные; помещать их внутрь per-replica блока нельзя, Litestream не запустится.

Восстановление из реплики

На любом хосте с установленным Litestream и SSH-доступом к цели-реплике:

litestream restore -config /path/to/litestream.yml /path/to/output/dinary.db

Litestream читает самый свежий снапшот в реплике, проигрывает LTX до последней закоммиченной транзакции и пишет свежий dinary.db. Восстановленная БД транзакционно согласована — PRAGMA integrity_check не обязательна, но и не помешает.

Офсайт-бэкап: Яндекс.Диск (ежедневно, GFS-ретеншен)

Litestream-реплика на VM 2 — горячая (RPO — секунды), но живёт в том же регионе того же облачного провайдера, что и VM 1. Для сценария «обе VM исчезли» есть ежедневный офсайт-бэкап, который VM 2 сама пушит на Яндекс.Диск. Оркестрируется через inv setup-replica.

Что именно происходит

Ежедневно в 03:17 UTC (+ 30 мин jitter) systemd-oneshot на VM 2:

  1. Материализует локальную Litestream-реплику из /var/lib/litestream/dinary в плоский SQLite-файл через litestream restore.
  2. Валидирует восстановленный файл через PRAGMA integrity_check. Если проверка не прошла — ран прерывается, ничего не загружается: перезаписывать историю на Яндексе заведомо битым снапшотом мы отказываемся.
  3. Сжимает через zstd -19 (соотношение у повторяющегося page-layout SQLite близко к оптимальному; CPU на входе < 1 МБ пренебрежимо мал).
  4. Заливает в yandex:Backup/dinary/dinary-<UTC-ISO>.db.zst через rclone.
  5. Подрезает Яндекс.Диск по GFS-политике (см. ниже).

На Яндексе лежат обычные сжатые SQLite-файлы, а не непрозрачный repo-формат — любая машина с zstd и sqlite3 откроет снапшот напрямую, без тулинга dinary.

GFS-ретеншен

  • 7 свежайших daily снапшотов.
  • 4 свежайших weekly снапшота (последний в каждой ISO-неделе).
  • 12 свежайших monthly (последний в каждом календарном месяце).
  • Все yearly снапшоты — бессрочно (закрытые годы неизменяемы, расхождение между двумя годовыми снапшотами одного закрытого года — сигнал коррапшена, и его стоит хранить вечно).

Корзины пересекаются — снапшот удаляется только если он не попал ни в одну keeper-корзину. За 10 лет это ~29 файлов суммарно (~9 МБ на диске).

Разовый бутстрап

Разово с операторской машины против VM 2 (включает и настройку офсайт-бэкапа):

inv setup-replica

Задача:

  • Ставит на VM 2 apt-пакеты rclone, sqlite3, zstd и пин-версию бинарника Litestream.
  • На первом запуске интерактивно настраивает удалённый yandex: в rclone. Если удалённого ещё нет, задача печатает ссылку на страницу Яндекс-app-паролей (https://id.yandex.ru/security/app-passwords), спрашивает Яндекс-логин и читает app-пароль через getpass (без эха).

Яндекс WebDAV НЕ принимает обычный пароль от аккаунта Яндекса. Нужен отдельный app-password, создаваемый в категории «Файлы» (Files / WebDAV) на странице выше. Пароли из категорий «Почта», «Календарь» и общего назначения WebDAV- endpoint отвергает. Этот app-password отзывается с той же страницы в любой момент и не влияет на основной пароль аккаунта.

Открытый пароль проходит только по SSH-каналу до VM 2, отправляется на stdin rclone obscure -, и в ~ubuntu/.config/rclone/rclone.conf записывается уже только obscured-форма. Открытый текст не попадает ни в argv (в листинг ps), ни в историю шелла, ни на диск.

После записи конфига задача делает rclone lsd yandex: как smoke-тест. Любой провал (неверный пароль, неверный scope, нет сети) прерывает задачу и откатывает только что записанный [yandex]-раздел — следующий запуск увидит, что remote'а нет, и спросит креды заново. На зелёном smoke-тесте prompt на последующих запусках пропускается. - Пишет /usr/local/bin/dinary-backup и парный ему retention-скрипт; ставит и включает dinary-backup.timer; запускает один немедленный прогон, чтобы первый снапшот появился на Яндекс.Диске в течение минуты после бутстрапа.

Почему app-пароль, а не полный OAuth: VM 2 без монитора, а интерактивный rclone config ожидает связку «авторизоваться на ноутбуке → скопировать токен на сервер». App-пароль эквивалентен для нашего шаблона доступа (PUT/DELETE загружаемых файлов), отзывается из UI аккаунта Яндекса в любой момент, и бутстрап остаётся end-to-end неинтерактивным, если не считать ввода пароля.

Идемпотентна: apt-install-ы — no-op на повторе, rclone-remote не перенастраивается повторно, пока yandex: существует, скрипты и systemd-unit-ы перезатираются, enable --now можно звать повторно безопасно.

Наблюдение за ежедневным прогоном

ssh ubuntu@dinary-replica sudo journalctl -u dinary-backup.service -n 50 --no-pager
ssh ubuntu@dinary-replica sudo systemctl list-timers dinary-backup.timer

Проверка свежести: inv backup-cloud-status

Самый неприятный сценарий — таймер тихо перестал отрабатывать, а всё остальное выглядит здоровым. inv backup-cloud-status — внешний зонд, который живёт не на VM 2:

inv backup-cloud-status                     # одна строка, exit 0/1
inv backup-cloud-status --json-output       # машиночитаемый вердикт
inv backup-cloud-status --max-age-hours 3   # понижаем порог во время инцидента

Задача заходит по SSH на VM 2, вызывает там rclone lsjson для ремоута yandex:, извлекает UTC-метку из имени самого свежего файла dinary-YYYY-MM-DDTHHMMZ.db.zst и сравнивает с --max-age-hours (по умолчанию 26 ч — 24 ч + 1 ч на джиттер таймера в 30 мин + 30 мин запаса). Примеры вывода:

OK: newest dinary-2026-04-22T0317Z.db.zst, age 7.1h, size 198.5 KB (threshold: 26h)
STALE: newest dinary-2026-04-20T0317Z.db.zst, age 49.3h, size 198.1 KB (threshold: 26h)
STALE: no snapshots on yandex:Backup/dinary/ (threshold: 26h)

Поскольку зонд живёт отдельно от VM 2, и смерть самой VM 2 (SSH падает), и тихо остановившийся таймер (снапшот устарел) дают один и тот же ненулевой код возврата — одна проверка ловит оба режима отказа.

Для крона на ноутбуке подключается через linux-conf/osx/dinary_backup_check.sh (копируется в ~/scripts/ скриптом linux-conf/osx/copy_scripts.sh). Обёртка делает cd в ~/projects/dinary, вызывает uv run inv backup-cloud-status, и при ненулевом коде возврата передаёт захваченный вывод в send_fail_email (macOS-овский msmtp → Yandex SMTP). Рекомендуемая crontab-строка:

17 */6 * * * /Users/<you>/scripts/dinary_backup_check.sh

Четыре проверки в сутки дёшевы и ловят пропущенный прогон 03:17 UTC уже через несколько часов, а не следующим утром.

Восстановление на конкретную дату из Яндекс.Диска

inv restore-cloud-backup --list-only                     # список снапшотов
inv restore-cloud-backup                                 # восстановить самый свежий
inv restore-cloud-backup --snapshot 2026-03-15           # конкретную дату
inv restore-cloud-backup --yes                           # без подтверждения

Два предполагаемых сценария

  • Бутстрап отладочной БД на ноутбуке. Материализовать свежий прод-снапшот в ./data/dinary.db на рабочей станции, чтобы воспроизвести баг на реальных данных. Низкий риск: любой перезаписанный отладочный файл восстанавливается из автосохранённого data/dinary.db.before-restore-<ts>.
  • Disaster recovery прод-БД. Запускать задачу на самой VM 1 (через SSH), когда и локальная БД, и Litestream-реплика на VM 2 непригодны. Тройная защита «SSH + cd ~/dinary + интерактивное подтверждение» — это намеренное трение, чтобы одним словом inv restore-cloud-backup в случайном терминале нельзя было молча затереть прод.

restore-cloud-backuplocal-only: пишет в ./data/dinary.db относительно cwd, режима --remote нет. Запустить задачу на удалённом хосте с операторской машины невозможно.

Флаги

Флаг По умолчанию Значение
--snapshot DATE latest Префикс даты в имени файла, например 2026-04-22 матчит полный dinary-2026-04-22T0317Z.db.zst
--list-only off Read-only: показать инвентарь снапшотов и выйти
--yes off Пропустить гейт «напечатайте yes» (preserve-before-restore всё равно отработает)

Предусловия на операторской машине (разово)

  • Установлен rclone (apt install rclone на Ubuntu, brew install rclone на macOS). На VM 1 уже преднастроен через inv setup-server, так что в стрессе disaster recovery ставить руками ничего не нужно.
  • На операторской машине настроен удалённый yandex: в rclone, указывающий на тот же аккаунт Яндекс.Диска, что и у inv setup-replica. Если inv setup-replica ни разу не запускался с этой машины, настроить его разово: rclone config create yandex webdav url=https://webdav.yandex.ru vendor=other user=<login> (запросит app-пароль через rclone obscure).
  • Установлены sqlite3 и zstd (на VM 1 — через inv setup-server, на macOS — через brew install sqlite zstd).

Гарантии безопасности

  • Снапшот сначала разжимается во временной директории и прогоняется через PRAGMA integrity_check до того, как вообще тронут существующий data/dinary.db. Битый снапшот прерывает ран с нетронутой живой БД.
  • Если data/dinary.db существует и непустой, он переименовывается в data/dinary.db.before-restore-<UTC-ISO> до того, как на его место ляжет снапшот. Предыдущее состояние всегда восстановимо из той же директории, даже при --yes.

Раннбук disaster recovery на проде

Когда живая БД на VM 1 потеряна, И Litestream-реплика на VM 2 тоже непригодна:

ssh ubuntu@dinary                       # или публичный IP / Tailscale IP
sudo systemctl stop dinary litestream   # чтобы не получить наполовину переписанную БД
cd ~/dinary
inv restore-cloud-backup --snapshot 2026-03-15
# промпт подтверждения: печатает row count / size / mtime текущей
# БД плюс сжатый размер входящего снапшота и требует напечатать
# буквально 'yes'.
sudo systemctl start litestream dinary  # вернуть запись + репликацию
inv verify-db                           # integrity + FK check

Если живая БД просто устарела, но цела, и Яндекс-снапшот нужен только для сравнения, запустите задачу в отдельной scratch- директории (чтобы ./data/dinary.db стал снапшотом, а не прод-БД):

cd /tmp/restore-preview
mkdir -p data
inv restore-cloud-backup --snapshot 2026-03-15 --yes
sqlite3 data/dinary.db 'SELECT COUNT(*) FROM expense'

Восстановление из холодной копии

  1. Остановите работающий сервис: ssh $HOST 'sudo systemctl stop dinary'.
  2. Замените data/dinary.db файлом из бэкапа.
  3. Удалите устаревшие WAL-сайдкары: rm -f data/dinary.db-wal data/dinary.db-shm.
  4. Запустите сервис: inv restart-server.
  5. По желанию запустите inv verify-db для проверки целостности.

Практические рекомендации

  • Три слоя избыточности, каждый под свой сценарий отказа: inv backup закрывает «ой, я сломал БД руками во время ручной хирургии»; Litestream на VM 2 — «ой, я потерял VM 1»; Яндекс.Диск (inv setup-replica) — «ой, я потерял обе VM / потерял весь облачный провайдер».
  • Относитесь к data/dinary.db как к источнику истины. Не редактируйте его, пока сервис запущен, и вообще никогда не правьте WAL/SHM-сайдкары руками.
  • Аналитический workflow на лэптопе через DuckDB-over-SQLite (фаза 5 плана .plans/storage-migration.md) читает ту же реплику Litestream — отдельного пайплайна бэкапов под аналитику не нужно.