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/.

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

Миграции применяются автоматически при каждом старте сервера — yoyo применяет все ожидающие миграции до обслуживания первого запроса. Отдельного ручного шага не нужно: inv deploy --ref=<version> перезапускает сервис, yoyo делает всё остальное.

При локальной разработке inv dev запускает тот же путь при старте. Для применения миграций без перезапуска сервера используйте inv migrate.

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

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.

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

dinary использует три слоя резервного копирования под разные сценарии отказа.

Снапшот перед деплоем (автоматически)

inv deploy всегда делает снапшот через sqlite3 .backup перед любыми изменениями на сервере и сохраняет его в data/backups/pre-deploy-<timestamp>/dinary.db на вашей рабочей станции. Ручного шага не требуется.

Чтобы вручную скачать живой снапшот для локальной отладки:

inv restore-primary          # скачать прод-БД в data/dinary.db

Горячая реплика: 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 — там обоснование). Идемпотентна — повторный запуск после перезагрузки или смены 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 setup-yadisk

restore-yadisk читает из yandex: на той машине, где запущена. inv setup-yadisk настраивает этот remote локально — на VM 1 при disaster recovery или на ноутбуке для отладочного бутстрапа:

inv setup-yadisk

Использует ту же схему WebDAV + app-пароль, что и inv setup-replica, — браузерный OAuth не нужен. Идемпотентна: пропускает промпт, если yandex: уже настроен и работает.

restore-yadisk вызывает её автоматически при отсутствии yandex:, так что ручной запуск заранее необязателен.

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

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

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

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

restore-yadisklocal-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, sqlite3, zstd. На VM 1 все три уже есть через inv setup-server. На macOS: brew install rclone sqlite zstd.
  • Настроен удалённый yandex: в rclone. Если его нет, restore-yadisk сам запросит авторизацию (та же схема WebDAV + app-пароль, что у inv setup-replica). Для предварительной настройки до первого restore: inv setup-yadisk.

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

  • Снапшот сначала разжимается во временной директории и прогоняется через 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-yadisk --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-yadisk --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 deploy --ref=v0.4.0 --no-start   # задеплоить код, сервис не запускать
inv restore-yadisk              # восстановить БД с Яндекс.Диска
inv restart-server                    # запустить; yoyo применит прямые миграции

Координированный сброс импорта

Для пересборки расходов и доходов из Google Sheets (например, после восстановления из бэкапа, опережающего актуальные изменения в таблицах). Каталог категорий заполняется автоматически при запуске сервиса:

inv import-budget-all --yes
inv import-income-all --yes
inv import-verify-bootstrap-all
inv import-verify-income-all

Обновление ключа хоста VM2

После переустановки VM2 обновите доверие с операторской машины:

inv setup-reset-trust

Команда удаляет старую запись VM2 из known_hosts на VM1, повторно сканирует хост и переустанавливает pubkey VM1 в authorized_keys на VM2. Не применяйте для устранения неожиданного несоответствия ключа хоста без физического подтверждения переустановки VM2 — неожиданное несоответствие является сигналом безопасности.

Аварийный доступ при Tailscale-only sshd

Если при запуске inv setup-server был передан --tailscale (привязывает sshd только к Tailscale) и доступ потерян:

  1. Oracle Cloud Console → VM Instance → Console connectionLaunch Cloud Shell
  2. sudo rm /etc/ssh/sshd_config.d/10-tailscale-only.conf
  3. sudo systemctl reload ssh

Мониторинг

inv healthcheck            # локальная data/dinary.db
inv healthcheck --remote   # прод по SSH

Проверяет: активность systemd-сервисов, совпадение page_count реплики с первичной БД, наличие вчерашнего обменного курса в кеше, статус логгинга последнего расхода в Google Sheets. Возвращает ненулевой код при первом сбое.

inv backup-cloud-status                    # возраст последнего снапшота на Яндекс.Диске
inv backup-cloud-status --max-age-hours 3  # ужесточить порог
inv backup-cloud-status --json-output      # машиночитаемый вывод

Отчёты и доступ к данным

inv report-expenses                   # расходы по (категория, событие, теги)
inv report-expenses --year 2025       # фильтр по году
inv report-expenses --month 2025-03   # фильтр по месяцу
inv report-expenses --csv             # CSV в stdout
inv report-expenses --remote          # запрос к прод-БД по SSH

inv report-income --remote            # доходы по годам

inv sql -q "SELECT * FROM app_metadata ORDER BY key"            # только чтение по умолчанию
inv sql -q "DELETE FROM expenses WHERE id = 999" --write        # разрешить мутации
inv sql -f my_query.sql --csv > out.csv
inv sql -q "SELECT COUNT(*) FROM expenses" --remote             # снапшот прода по SSH

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

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