Безголовый режим (сервер)
Примечание
Этот раздел описывает продвинутый и необязательный режим blunderDB, предназначенный для серверных развёртываний, многопользовательской работы и автоматизации. Обычным и рекомендуемым способом использования blunderDB остаётся настольное приложение, описанное в предыдущих главах. Если вы используете blunderDB в одиночку, на своём компьютере, этот режим вам не нужен: вы можете пропустить эту главу, не теряя ни одной функции анализа.
Обзор
Тот же исполняемый файл blunderdb может, помимо настольного приложения и команд командной строки (см. Интерфейс командной строки (CLI)), работать в безголовом режиме: без графического интерфейса, полностью управляемый из командной строки или по сети. Этот режим объединяет три варианта использования:
демон
serve— предоставляет движок blunderDB как сервис HTTP + JSON, чтобы держать общую базу на сервере и обращаться к ней с нескольких клиентов;универсальный диспетчер
call— вызывает любую операцию хранения напрямую, локально, для написания скриптов и тестов;команда
migrate— переносит однопользовательскую базу SQLite в многопользовательский бэкенд PostgreSQL.
Эти три варианта использования опираются на общий слой хранения, который умеет работать с двумя бэкендами: SQLite (привычный формат файла .db настольного приложения) и PostgreSQL (для многопользовательских серверных развёртываний).
Демон serve
blunderdb serve запускает движок как сервис HTTP, отвечающий в формате JSON. Он позволяет разместить базу позиций на одной машине и обращаться к ней с нескольких клиентов.
# sqlite
blunderdb serve --db database.db --addr 127.0.0.1:8080
# postgres
blunderdb serve --backend postgres \
--dsn "postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
--addr 127.0.0.1:8080
Примечание
sslmode=disable подходит только для доверенной частной сети — база в соседнем контейнере, в сети, не имеющей маршрута ни к хосту, ни в Интернет. Для удалённой базы sslmode=require шифрует соединение, а verify-full дополнительно проверяет сертификат сервера и его имя хоста. Остальные строки подключения на этой странице несут sslmode=disable по той же причине: все они описывают частную сеть.
Предупреждение
Демон не выполняет никакой аутентификации. Он доверяет заголовку запроса X-Tenant-ID и должен работать за обратным прокси (nginx, Caddy…), отвечающим за аутентификацию. Никогда не выставляйте его напрямую в публичный Интернет.
X-Tenant-ID — это целое число тенанта (1, 2, 42…): сопоставить аутентифицированную учётную запись с этим числом — задача обратного прокси. Имя (alice) отклоняется с 400 invalid и никогда не преобразуется.
Опции:
Опция |
По умолчанию |
Значение |
|---|---|---|
|
– |
файл SQLite (сокращение для |
|
|
бэкенд хранения: |
|
|
строка подключения к бэкенду |
|
|
адрес прослушивания |
|
|
уровень журналирования: |
|
|
предоставляет |
|
|
отдаёт веб-страницу для просмотра по |
|
|
обслуживает действия по руководству турниром и мероприятием; по умолчанию отключены, см. Действия руководства |
|
|
предоставляет инструменты записи |
|
|
обслуживает жесты транскрипции ( |
|
|
закрывает сессию транскрипции, неактивную дольше этого срока |
|
– |
включает CORS для этого источника, списка источников через запятую или |
|
|
лимит запросов в секунду на одного тенанта (0 = отключено); включён по умолчанию на щедрое значение, а не по выбору, чтобы файл compose, думающий только о базе данных, не унаследовал демон вообще без ограничения |
|
|
размер корзины токенов для пиков запросов |
|
|
число позиций, которое может хранить тенант, проверяется в начале импорта: по достижении предела импорт отклоняется (413, |
|
|
секунды CPU вычислений движка на тенанта и на сутки UTC (429, |
|
|
одновременно выполняемые импорты одного тенанта (429, |
|
|
PostgreSQL: включает Row-Level Security по тенантам (глубокая защита, по выбору) |
|
|
учитывает заголовок |
|
– |
необязательная двусторонняя база бироффа ( |
|
– |
каталог подписывающей идентичности демона (создаётся при первом использовании); необходим, чтобы |
|
– |
обслуживает семейство |
|
– |
публикует |
Большинство опций можно задать и переменной окружения (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_LOG_LEVEL, BLUNDERDB_METRICS, BLUNDERDB_CORS_ALLOW_ORIGIN, BLUNDERDB_RATE_LIMIT_RPS, BLUNDERDB_RATE_LIMIT_BURST, BLUNDERDB_RLS, BLUNDERDB_READ_TENANTS, BLUNDERDB_TS_PATH, BLUNDERDB_IDENTITY_DIR, BLUNDERDB_OPS_ADDR, BLUNDERDB_PPROF_ADDR): явно указанный флаг имеет приоритет над соответствующей переменной.
У демона нет опции каталога данных: он пишет свои таблицы bearoff в $XDG_DATA_HOME/blunderdb, а за неимением её — в ~/.local/share/blunderdb. Значит, переносит их именно XDG_DATA_HOME — см. Базы бироффа.
У самой таблицы корзин ограничителя скорости есть жёсткий предел (10 000 различных тенантов): при его превышении каждый новый тенант вытесняет наименее давно использованную корзину, вместо того чтобы позволить таблице расти без ограничений — это полезно, если клиент отправляет много различных значений X-Tenant-ID, намеренно или нет, между двумя периодическими очистками неактивных корзин.
blunderdb serve теперь отклоняет любой непредвиденный позиционный аргумент (кроме единственного начального serve, которое пропускает ENTRYPOINT, уже сведённый к голому бинарнику): без этой проверки флаг, помещённый после такого аргумента, молча игнорировался — docker run image serve --addr :9090, естественный рефлекс, ведь ENTRYPOINT образа уже равен serve, запускался на :8080 без единого слова.
Точки доступа
Сервис предоставляет эксплуатационные точки доступа, всегда присутствующие:
GET /healthz— живучесть (процесс работает);GET /readyz— готовность (хранилище отвечает и его схема ожидаемой версии);GET /metrics— метрики Prometheus (если включён--metrics);GET /app/— веб-страница для просмотра (если включён--web).
Веб-страница
blunderdb serve --web отдаёт страницу по /app/: библиотеку, которую можно просматривать с планшета или телефона, ничего не устанавливая.
Она умеет три вещи, и этот список — решение, а не этап:
просматривать позицию, её анализ и доску;
искать той же грамматикой токенов, что и командная строка приложения;
повторять колоду Anki — с раскрытием ответа и выставлением оценки.
Она не умеет редактировать позицию, импортировать, удалять, управлять подборками, матчами, турнирами или настройками, и не научится. Отсутствующая здесь возможность — не пробел, а границы.
По умолчанию она выключена, и это умолчание и есть решение. Демон никого не аутентифицирует: он доверяет заголовку X-Tenant-ID и должен работать за аутентифицирующим прокси. Поставить доступный из браузера интерфейс включённым из коробки значило бы пригласить ровно то развёртывание, которое это правило запрещает.
Страница не отправляет никакого тенанта: заголовок ставит прокси, как и для любого другого клиента. В локальной разработке, и только там, /app/?tenant=1 называет его — что ничего не меняет в безопасности демона, который и так принимает этот заголовок от кого угодно.
Файлы страницы отдаются без тенанта, намеренно: браузер должен суметь загрузить страницу до того, как прокси что-либо ей назначит, а страница не содержит данных.
Живучесть и готовность отвечают на два разных вопроса. /healthz всегда отвечает 200, как только процесс обслуживает запросы, никогда не обращаясь к хранилищу: оркестратор перезапускает контейнер, у которого не проходит проверка живучести, и временно недоступная база не должна перезапускать здоровый демон по кругу. /readyz отвечает 503 (со status равным down или version_mismatch), пока база не отвечает или её схема не совпадает со схемой двоичного файла: трафик просто отводится, пока она не вернётся.
Подкоманда blunderdb healthcheck (есть и в двоичном файле serve образа контейнера) выполняет запрос GET /readyz к локальному демону и возвращает 0, если он готов, иначе 1; адрес берётся из --addr или BLUNDERDB_ADDR, по умолчанию :8080. Это HEALTHCHECK образа Docker, и она так же пригодна для скрипта или юнита systemd:
blunderdb healthcheck --addr 127.0.0.1:8080 && echo ready
Бизнес-поверхность следует схеме POST /v1/<семейство>.<метод> (например, /v1/positions.save, /v1/matches.get). Семейства охватывают позиции, анализы, матчи, комментарии, коллекции, турниры, карточки Anki, фильтры, сессии, историю (поиска и команд), поиск, метаданные, настройки библиотеки, статистику, импорт и экспорт. Точки со списками возвращают поток NDJSON (по одному объекту JSON на строку). Сервер корректно завершается по SIGINT / SIGTERM.
Ошибка возвращает конверт {"error":{"code":…,"message":…}}. Код not_found означает, что названный ресурс не существует; unknown_route, тоже 404, означает, что демон не обслуживает вызванный метод: клиент и демон разных версий или семейство, которое демон обслуживает только с флагом. Клиент делает вывод об отсутствии данных только по not_found.
positions.save возвращает {"id":…,"created":…}. created равно true только для того вызова, который вставил позицию, и об этом сообщает сама запись: клиент, копирующий позицию, а затем её анализ, и вынужденный отменить копию после сбоя, удаляет позицию, только если создал её сам, без гонки предварительного positions.exists.
Что обещает /v1
Клиент, написанный под /v1, должен продолжать работать. Правило умещается в три строки, и записанное оно полезнее, чем угаданное:
Существующее не меняет смысла. Маршрут
/v1не переименовывается, не удаляется и не переосмысляется. Поле запроса или ответа не переименовывается, не убирается и не меняет тип.Добавляемое добавляется. Новый маршрут, необязательное поле запроса, новое поле в ответе: клиент, который их игнорирует, продолжает работать — таково принятое здесь определение «совместимости». Поэтому клиент должен игнорировать незнакомые поля, а не отвергать их.
Всё остальное — это
/v2. Сделать обязательным поле, которое им не было, поменять единицу, изменить смысл кода ошибки: это разрывы, и они живут под другим префиксом, рядом с/v1, пока клиенты переходят.
Два важных уточнения. Маршруты /ops/ не покрываются: они служат эксплуатации развёртывания, меняются вместе с ним и не являются API для сторонних программ. И сам контракт порождается из таблицы маршрутов демона (openapi.yaml, Контракт API): он не может описывать ничего, кроме того, что отдаёт сервер.
Транскрипция через API
Семейство transcriptions.* позволяет внешнему клиенту транскрибировать матч жест за жестом, с той же логикой, что и в десктопе. Чтения (list, get, exportMat, losses) обслуживаются всегда. Жесты (create, open, editMatch, apply, undo, redo, close, finish, abandon) — только с serve --transcription: без этого флага эти маршруты отвечают 404.
create и open возвращают состояние черновика, его revision и sessionId. apply, undo, redo, close и finish называют этот sessionId: отсутствует → 400, истёкшая или неизвестная сессия → 410; тогда клиент заново открывает черновик (open), курсор в конце документа. abandon не называет сессию: он удаляет черновик только по ревизии из If-Match. Каждый жест, который пишет, несёт последнюю увиденную ревизию в заголовке If-Match и возвращает следующую:
If-Matchотсутствует → 428;устаревшая ревизия → 409; конверт ошибки сообщает текущую ревизию (
details.revision) и свежее состояние черновика (details.state: документ, ревизия, сессия и курсор), которое клиент показывает перед повтором жеста, если тот ещё актуален.
Ревизия растёт только при изменении документа (заголовок и действия): перемещение курсора или ввод кости текущего действия ничего не пишет и возвращает ту же ревизию. Сессия принадлежит черновику, а не клиенту: open возвращает живую сессию, если она есть, а вкладки или рабочие места, которые её разделяют, разделяют также курсор и стек отмены.
Сессия хранит только стек отмены, курсор и текущий ввод: черновик записывается после каждого жеста, который его меняет, поэтому потерянная сессия (бездействие, перезапуск, другой экземпляр) не теряет ни одного жеста. transcriptions.get возвращает ревизию в ETag и отвечает 304 на If-None-Match, который её называет.
finish сохраняет матч и удаляет черновик, abandon удаляет его без матча, close лишь освобождает сессию. editMatch открывает черновик для существующего матча и для импортированного матча возвращает число анализов и комментариев, которые транскрипция не сохраняет (losses.lossy). Анализ сохранённого матча запускается через gammonnet.analyzeMissing.
Предупреждение
Демон никого не аутентифицирует: открыть запись — значит доверить её прокси (Развёртывание за аутентифицирующим прокси). Роль «транскриптор» — это правило прокси для префикса /v1/transcriptions., а не понятие демона.
Клиент на Python
В clients/python/ лежит минимальный клиент без зависимостей вне стандартной библиотеки — демон говорит POST и JSON, что urllib и json покрывают полностью:
from blunderdb import Client
api = Client("http://127.0.0.1:8080", tenant=1)
print(api.metadata_counts())
for position in api.positions_list({"limit": 10}):
print(position["id"])
Он состоит из двух половин, и это намеренно. _generated.py несёт по методу на маршрут, порождаемых из таблицы маршрутов демона командой go run ./cmd/openapi-gen: написанная вручную поверхность разошлась бы в день добавления маршрута, и никто бы не заметил раньше пользователя. client.py несёт транспорт — сессию, заголовок тенанта, конверт ошибки, чтение NDJSON — и написан вручную. То, что меняется вместе с API, порождается; то, что меняется вместе с суждением, — нет.
Имена методов — семейство_операция в snake_case: /v1/positions.loadByIds становится positions_load_by_ids(). Семейство сохраняется, потому что несколько семейств делят имя операции (list, delete), и голый list() вызвал бы конфликт.
events() следует за /v1/events и возвращает по одному словарю на сообщение (см. Уведомления о действиях: /v1/events).
Сбой поднимает APIError, несущий конверт демона как есть: code (то, по чему ветвится программа), message (то, что читает человек), HTTP-статус и детали.
Встраивание движка в программу на Go
pkg/blunderdb/server.Bootstrap открывает хранилище и возвращает набор обработчиков в вызывающем процессе, не слушая порт. Это вход для доверенного родителя — gammonGo, — которому нужна библиотека позиций без запуска демона рядом и без разговора по HTTP с самим собой.
То, что при этом предполагается, сказано прямо: родитель доверенный. Нет тенанта для проверки, нет заголовка для валидации, нет ограничителя частоты — всё это принадлежит демону, потому что он обращён к сети, и ADR-0005 объясняет почему. Программа, встраивающая движок, сама выбирает свой тенант и отвечает за свои вызовы.
Проведение турниров и мероприятия
Турниры, которыми руководят на рабочем месте, и объединяющие их мероприятия (rencontre в API и его маршруты /v1/rencontres.*) читаются через API, под tenant вызывающего, тем же кодом, что и на рабочем месте. Чтение обслуживается всегда; действия (ввод результата, составление пар, создание мероприятия) обслуживаются только при serve --direction (Действия руководства).
directions.listиdirections.directoryчитают весь tenant: список проводимых турниров, справочник игроков.Остальные
directions.*принимают{"tournamentId": N}:directions.get(полный вид: предложения, таблица результатов, текущие матчи),directions.participants,directions.freeParticipants,directions.tableGrid,directions.brackets,directions.standings,directions.standingsCsv,directions.history(необязательные фильтрыplayerиmatch),directions.clock,directions.slots,directions.lastDecision,directions.pageHtmlиdirections.pairingSheetHtml(сround).rencontres.list, затемrencontres.getиrencontres.pageHtmlс{"id": N}.rencontres.pageHtmlформирует настенную страницу зала, автономный HTML-документ в полеhtml: настенный экран отображает его и периодически перечитывает.rencontres.rankingвозвращает сезонный рейтинг, какblunderdb tournament ranking --season:rencontreId,from,to,points,participationиelo, все необязательные; безrencontreIdи периода учитываются все проведённые турниры тенанта.
Страницы формируются на французском, языке движка проведения. Турнир, который не проводится или принадлежит другому tenant, отвечает 404.
Условные чтения. Каждый из этих маршрутов возвращает заголовок ETag. Если отправить его обратно в If-None-Match, будет получен 304 без тела, пока не изменилось ничто из того, что читает маршрут. Любая запись немедленно меняет ETag: действие в турнире или в турнире того же мероприятия, привязка матча, черновик, начатый из слота, переименование турнира, изменение мероприятия. Ответ 304 не воспроизводит ни одного турнира, поэтому настенная страница, опрашивающая каждые несколько секунд, обходится дёшево. Исключение составляет только то, что зависит от времени: предложения, часы и страницы вычисляются в момент чтения, поэтому ETag действителен не более минуты. Клиент, перечитывающий данные, таким образом увидит истечение срока или паузу в течение минуты.
Эти маршруты являются POST. Для этого метода RFC 9110 (§13.1.2) отвечает 412 на сработавший If-None-Match. Тем не менее демон отвечает 304: тело запроса содержит лишь параметры чтения без побочных эффектов, которое ведёт себя как GET. Форма If-None-Match: * отклоняется (400), так как она не указывает ни на какой ответ, который уже был бы у клиента. Недопустимый запрос (например, отрицательный round) отклоняется до проверки любых условий.
curl -si -X POST http://127.0.0.1:8080/v1/rencontres.pageHtml \
-H 'X-Tenant-ID: 1' -d '{"id":1}' | grep -i '^etag'
curl -si -X POST http://127.0.0.1:8080/v1/rencontres.pageHtml \
-H 'X-Tenant-ID: 1' -H 'If-None-Match: W/"…"' -d '{"id":1}'
# HTTP/1.1 304 Not Modified
Как и остальные маршруты /v1, эти маршруты никого не аутентифицируют: за прокси (Развёртывание за аутентифицирующим прокси) любой, кто достигает префикса /v1/directions. tenant, читает его турниры, включая имена игроков. Прокси, ограничивающий эти чтения определёнными пользователями, делает это правилом на этот префикс и на /v1/rencontres..
Действия руководства
blunderdb serve --direction открывает действия, которые рабочее место выполняет над турниром под руководством и над мероприятием. Без этого флага эти маршруты отвечают 404, как будто их нет. call обслуживает их всегда.
directions.create(tournamentId,config,seed),directions.setConfigиdirections.previewConfig(config, конфигурация в JSON-формате движка);регистрации:
directions.enterParticipants(players),directions.addParticipant(name,club,rating; сsectionиkeyопоздавший занимает место с пропуском тура),directions.updateParticipant,directions.withdraw,directions.reinstate,directions.makeAbsent,directions.makeAvailable,directions.addPair,directions.updatePair;ход турнира:
directions.confirmProposal(action, как её предлагаетdirections.get),directions.confirmAllProposals,directions.startMatch,directions.enterResult,directions.enterForfeit,directions.moveMatchToTable,directions.cancelMatch,directions.correctResult,directions.close,directions.reopen,directions.addNote,directions.attachMatch,directions.detachMatch;мероприятие:
rencontres.create,rencontres.update,rencontres.attach,rencontres.detach,rencontres.trash,rencontres.setTableOutOfService,rencontres.setBreaks;свойства столов:
rencontres.setTables(id,tableSettings, по одной записи на стол, имеющий их: номер, название, зал, зарезервирован, закреплён за),rencontres.setEventRooms(id,tournamentId,rooms, залы, где играет состязание; ни одного означает все столы) иdirections.setTables(tournamentId,tableSettings) для состязания, которое играет отдельно.
Действие турнира возвращает полное представление турнира, как directions.get; действие мероприятия возвращает мероприятие. Затем служба перезаписывает страницы отображения в папке, которую указывает база, как на рабочем месте. Страница, которую нельзя записать (папка исчезла, диск заполнен), не отменяет действие: ответ содержит заголовок Direction-Page-Warning для каждой незаписанной страницы (tournament 3, rencontre 2), без пути на сервере, а рабочее место показывает его в строке состояния.
Действие, которое правила отклоняют (пустое имя, занятый стол, не начавшийся турнир, конфигурация, отклонённая движком), возвращает 400 с причиной. Сбой демона или его базы возвращает 500 без подробностей: причина остаётся в журнале демона.
Версия обязательна. Каждое чтение турнира или мероприятия возвращает заголовок Direction-Version, и каждое действие передаёт его обратно в If-Match:
без
If-Match(или со*) действие отклоняется:428;если после этого чтения кто-то записал данные, действие отклоняется:
409. Полеdetailsошибки содержит актуальное состояние и егоversion: клиент перечитывает и повторяет действие, если оно остаётся допустимым;иначе действие применяется и возвращает новую версию в
Direction-Version.
Сравнение выполняется в транзакции действия, под блокировкой базы (рекомендательная блокировка PostgreSQL на турнир или на мероприятие, блокировка записи SQLite): из двух действий, отправленных по одному и тому же чтению, применяется только одно, идут ли они через один демон, через два демона на одной базе PostgreSQL или через рабочее место и call на одном файле. Действие записывается целиком или не записывается вовсе. Турнир, сыгранный в мероприятии, имеет версию своего мероприятия, так что действие в соседнем состязании тоже её меняет. directions.create и rencontres.create не направлены на что-либо существующее и не принимают версию.
Идемпотентность. Действие с заголовком Idempotency-Key применяется только один раз: отправленное повторно с тем же ключом, оно возвращает первый ответ с его заголовками (включая Direction-Version) и Idempotency-Replayed: true. Двойной щелчок или сетевой повтор не вводит два результата; две одновременные отправки одного ключа выполняют действие только один раз. Сохраняется только успешный ответ.
Ключ привязан к телу запроса: тот же ключ с другим телом возвращает
422.Повтор выполняется до проверки версии: он возвращает сохранённый ответ без
428и409, даже если версия с тех пор изменилась.Ключи хранятся в памяти каждого экземпляра демона 24 часа, не более 1 000 на tenant: перезапуск их забывает, а другой экземпляр их не знает.
curl -si -X POST http://127.0.0.1:8080/v1/directions.get \
-H 'X-Tenant-ID: 1' -d '{"tournamentId":3}' | grep -i '^direction-version'
curl -s -X POST http://127.0.0.1:8080/v1/directions.enterResult \
-H 'X-Tenant-ID: 1' -H 'If-Match: "…"' -H 'Idempotency-Key: t4-r2' \
-d '{"tournamentId":3,"matchId":"m7","winner":"aa","scoreA":7,"scoreB":3}'
Предупреждение
Демон никого не аутентифицирует (ADR-0005). При --direction результаты вводит любой, кого пропускает прокси. Движок не знает ролей (директор, арбитр, читатель): роль — это правило прокси, который закрепляет /v1/directions. и /v1/rencontres. за директорами или пропускает только чтение. Никогда не запускайте --direction на доступном демоне без такого прокси, даже в Wi-Fi клуба.
Уведомления о действиях: /v1/events
GET /v1/events — поток Server-Sent Events (text/event-stream): одно сообщение на каждое подтверждённое действие tenant, публикуемое после записи в базу, никогда для отклонённого или отменённого действия. Сообщение сообщает, что изменилось, и новую версию, а не состояние: клиент перечитывает то, что показывает, с If-None-Match.
event: rencontre—rencontreId,tournamentIds(состязания мероприятия, до и после действия) иversion;event: direction—tournamentIdиversion, для турнира, сыгранного вне какого-либо мероприятия;event: transcription—transcriptionIdиrevision; брошенный или завершённый черновик несётremoved(иmatchIdдля Завершения).
removed: true указывает на то, чего больше нет. Маршрут обслуживается только с --direction или --transcription: без них демон не пишет ничего, что ему пришлось бы объявлять, и /v1/events отвечает 404. Как и любой маршрут /v1/, он требует X-Tenant-ID: подписчик слышит только свой tenant. Один tenant держит не более 16 открытых потоков одновременно; сверх этого 429. Рабочее место использует тот же сервис, но не подключает к нему никакой шины: его действия не объявляются.
Параметры tournament, rencontre и transcription (идентификаторы через запятую или повторяющиеся) сужают подписку: сообщение проходит, если называет один из них. Турнир мероприятия получает сообщения своего мероприятия. Неизвестный параметр или недопустимый идентификатор даёт 400.
curl -N http://127.0.0.1:8080/v1/events?rencontre=2 -H 'X-Tenant-ID: 1'
Без истории. Демон не хранит ни одного сообщения. Каждый поток открывается с event: resync и id: клиент мог пропустить действия до подключения или между двумя подключениями и перечитывает всё, что показывает. Причина — reconnected, если запрос содержит Last-Event-ID, иначе subscribed. Слишком медленный подписчик, у которого очередь из 64 сообщений заполнена, отключается после того же resync: он никогда не задерживает действие. Поток объявляет задержку переподключения в 3 секунды.
Через прокси. Каждые 25 секунд отправляется комментарий : ping, чтобы прокси не разрывал молчащий поток; X-Accel-Buffering: no просит nginx не буферизовать его. Поток не сжимается, не подпадает под тайм-аут обычных запросов и считается при ограничении частоты одним запросом. Остановка демона закрывает все потоки; подписка, запрошенная во время остановки, получает 503.
Несколько экземпляров. В SQLite базу удерживает один экземпляр: достаточно шины в памяти. В PostgreSQL, как только включён --direction или --transcription, каждый экземпляр передаёт свои действия остальным через LISTEN/NOTIFY по каналу blunderdb_events: подписчик, подключённый к одному экземпляру, слышит действие, подтверждённое на другом, или выполненное через call на той же базе. Тенант передаётся в уведомлении, и принявший его экземпляр доставляет его только подписчикам этого тенанта. Каждый экземпляр открывает ещё два соединения (application_name blunderdb-events-… для прослушивания, blunderdb-notify-… для отправки); экземпляр, который не может начать прослушивание при запуске, отказывается запускаться. call объявляет, не слушая, и обслуживает свой запрос, даже если не может объявить.
Любая роль, которой разрешено подключаться, может отправлять уведомления в этот канал, в том числе при --rls. Полученному уведомлению верят, только если его тенант допустим, а тип известен; остальное записывается в журнал и игнорируется. Поддельное уведомление в худшем случае заставит подписчиков тенанта перечитать свои данные.
Уведомление отправляется после записи в базу, как и локальное сообщение. Остаются две потери без
resync: экземпляр, завершённый между записью и уведомлением, и остановка, которая не успевает за 2 секунды отправить то, что осталось в очереди. Действие подтверждено, но потоки, уже открытые на других экземплярах, узнают о нём только при переподключении своего клиента.Потерянное соединение прослушивания восстанавливается с нарастающей задержкой от 250 мс до 30 с. Действия других экземпляров, произошедшие во время обрыва, теряются: при восстановлении каждый подписчик экземпляра получает
resyncс причинойmissed. Уведомление, слишком длинное для PostgreSQL (8 000 байт), или такое, которое экземпляр не смог отправить, приходит остальным как тот жеresyncдля соответствующего тенанта.Значения
idпотока свои у каждого экземпляра. Клиенту, которого балансировщик нагрузки направил на другой экземпляр, они ничего не дают:resync, открывающий любой поток, заставляет его перечитать отображаемое.
Базы бироффа
Демон вычисляет две свои таблицы по умолчанию при запуске, в фоне (TS-06-06 для вердикта куба, OS-06 для EPC): около шести секунд одного ядра, один раз, в своём каталоге данных — $XDG_DATA_HOME/blunderdb, а за неимением её — ~/.local/share/blunderdb. Ничего не загружается и ничего не встроено в двоичный файл (ADR-0027). Если этот каталог доступен только для чтения, таблицы держатся в памяти на время жизни процесса: служба стартует, она просто платит за вычисление при каждом перезапуске.
Более широкая область при запуске не вычисляется — TS-06-11 весит 1,2 ГБ и занимает минуты, такое служба не решает сама. Изготовить её — дело оператора, через CLI, в томе, который будет читать демон:
# generate
blunderdb bearoff generate --ts 6x11 --data-dir /srv/data/blunderdb
# serve
XDG_DATA_HOME=/srv/data blunderdb serve --db database.db
blunderdb serve --db database.db \
--bearoff-ts /srv/data/blunderdb/gnubg_ts6x11.bd
Первый запуск даёт демону самому найти таблицу в своём каталоге данных; второй указывает её путём, где бы она ни лежала. --data-dir — опция подкоманд bearoff, но никогда не serve.
blunderdb bearoff list --data-dir /srv/data/blunderdb говорит, что содержит том и во что обошлась бы каждая область; blunderdb bearoff verify завершается с ошибкой на повреждённой таблице, что делает её готовой пробой при старте. Подробности см. в Интерфейс командной строки (CLI).
Эксплуатационные маршруты
Два вызова не ограничиваются тенантом, который их делает, и потому живут под собственным префиксом POST /ops/<семейство>.<метод>:
/ops/maintenance.vacuum(бэкенд SQLite) переписывает весь файл, включая данные всех тенантов, и всё это время держит блокировку записи;/ops/tenant.purge(бэкенд PostgreSQL) уничтожает данные тенанта, и уничтожается тот тенант, который назван в заголовке, контролируемом вызывающей стороной.
Демон никого не аутентифицирует (см. ниже): маршрут, доступный одному тенанту, — это маршрут, который может вызвать любой тенант. Префикс существует для того, чтобы прокси мог отклонить оба одним правилом. Никогда не открывайте /ops/ через публичный прокси. В nginx правило умещается в одну строку блока server; в Caddy — в две строки описания сайта:
location /ops/ { return 403; }
location /metrics { return 403; }
@closed path /ops/* /metrics
respond @closed 403
Параметр --ops-addr <хост:порт> идёт дальше: два маршрута тогда покидают адрес --addr и обслуживаются только этим вторым слушателем, который следует привязать к административному интерфейсу. Без него они остаются на основном слушателе, и блокировать их — дело прокси.
Эти маршруты требуют заголовок X-Tenant-ID, как и все прочие: очистка называет тенанта, которого уничтожает, и нуждается в нём больше всех. Без него обходятся только пробы (/healthz, /readyz) и /metrics.
Поэтому приведённое выше правило отказа покрывает и /metrics: не требуя никакого тенанта, он читается всяким, кто дотянулся до демона, и публикует размер базы и текущую работу, по всем тенантам сразу. Его смотрят с машины демона или по пути, который прокси оставляет для эксплуатации. Третья точка, которую нельзя выставлять наружу, — не маршрут, а слушатель: тот, что задаёт --pprof-addr; он ничего не знает о тенантах и выдаёт профиль всего процесса. Его привязывают к административному интерфейсу и никогда не публикуют через прокси.
Что не перешло под /ops/: /v1/gammonnet.sweepStale. Догоняющий проход дорог, но ограничен вызывающим тенантом; его сдерживают ограничение частоты и показатели выполняемой работы, а не граница доверия.
Полный контракт — каждый метод, его запрос и его ответ — порождается из исходного кода и хранится в репозитории: openapi.yaml в корне (формат OpenAPI, вместе со схемами) и его читаемое приложение Контракт API (по таблице на семейство). Оба пересоздаются командой go run ./cmd/openapi-gen, и отдельный тест падает, если любой из них отстал от фактически зарегистрированных маршрутов.
Каждый запрос /v1 принимает тело JSON (Content-Type: application/json либо вовсе без заголовка — тело другого типа отклоняется с 400 invalid, а не приводит к запутанной ошибке разбора JSON); известный метод, вызванный с неверным HTTP-глаголом, отвечает 405, а заголовок Allow называет единственный принимаемый глагол. Методы списков, принимающие limit, отклоняют значения свыше 1000 строк на страницу (400 invalid), вместо того чтобы учитывать неограниченное значение.
Все перечисляющие семейства принимают limit и offset: positions.list, positions.listIds, matches.list, search.find, anki.reviewLog, comments.listAll, tournaments.list и collections.positions. Оба по умолчанию равны нулю, что значит то же, что и всегда: всё. Неявного потолка нет — поток не держится в памяти, поэтому неограниченный список стоит времени и полосы, но никогда — устойчивости демона, тогда как молчаливый предел по умолчанию заставил бы клиента прочитать усечённый список, считая его полным. Эти два параметра дают возможность листать — тому, кто этого хочет.
Каждое TCP-соединение ограничено по времени чтения/записи на запрос — щедрый бюджет для обычных вызовов и гораздо больший для потоковых маршрутов (списки NDJSON, импорт/экспорт, дозагонная развёртка gammonNet) — а число одновременно открытых соединений ограничено: сверх этого предела новое соединение ждёт освобождения одного из существующих, вместо того чтобы каждое соединение безусловно получало собственный поток исполнения. Штатное завершение работы (SIGINT/SIGTERM) сначала отменяет все выполняющиеся импорты и развёртки gammonNet — каждая из них в ответ отправляет финальное событие {"event":"cancelled"} вместо того, чтобы её соединение обрывалось без объяснений — и лишь затем закрывает сервер в течение обычного периода отсрочки. Временный файл загруженного импорта сохраняет из исходного расширения только те, что известны демону (.xg, .xgp, .sgf, .mat, .bgf, .ogxm, .txt, .db, .dbx), а все одновременные импорты — по всем тенантам — используют общую глобальную квоту байт, сохраняемых на диске: сверх неё новый импорт отклоняется (too many requests), вместо того чтобы позволить занятости $TMPDIR расти без ограничений.
/v1/imports.json читает экспорт JSON из blunderDB, заполняя пробелы: содержащийся в нём анализ записывается только в позицию, у которой его ещё нет, никогда не заменяя существующий анализ, а роллауты обеих сторон сохраняются.
Семейство search предлагает три двери к одному и тому же поиску. search.find принимает полный объект фильтров, поле за полем. search.query принимает запрос на языке командной строки приложения (s cube p>30 E>50, описан в Список команд) и передаёт те же позиции; это единственный способ добраться по сети до фильтров, у которых нет очевидного поля — шаблон хода, текст комментария, игрок, дата, исключённые броски, зоны и блоты. search.parse ничего не ищет: она отвечает, что означает запрос — какие фильтры он задаёт, его каноническую форму (два равнозначных запроса имеют одну и ту же, что делает сохранённый поиск сравнимым) и его диагностику.
Запрос с токеном, который ничем не распознан, отклоняется (400 invalid, с указанием токена), а не выполняется, молча сужая поиск. Токен, который понят, но здесь не действует — x, включающий структуру исключения, а она является доской, а не текстом — передаётся в заголовке X-BlunderDB-Query-Diagnostics, чтобы тело оставалось NDJSON позиций для всех существующих клиентов.
Два метода семейства positions декодируют позицию, не сохраняя её: positions.fromXGID восстанавливает позицию из строки XGID, а positions.fromXGP — из файла одиночной позиции .xgp.
POST /v1/exports.sqlite экспортирует всего текущего тенанта — позиции, коллекции, матчи, турниры, анализы, комментарии, сыгранные ходы, библиотеку фильтров и пакеты Anki — в файл SQLite, открываемый как есть на рабочей станции. Тело JSON запроса необязательно: watermarkOrigin / watermarkNote проставляют водяной знак, подписанный собственной идентичностью демона (--identity-dir) — без этих полей экспорт не несёт никакого водяного знака; запрос этих полей без настроенной идентичности завершается ошибкой с кодом invalid. collectionIds ограничивает экспорт этими коллекциями и их позициями, с анализами, комментариями и сыгранными ходами, без библиотеки фильтров и пакетов Anki.
Поделиться коллекцией между тенантами можно только через клиента, но никогда чтением одного тенанта из другого: передающий тенант вызывает exports.sqlite с collectionIds (и водяным знаком, чтобы получатель знал, откуда файл), принимающий тенант отправляет файл в imports.db. Каждый запрос несёт свой X-Tenant-ID; прокси решает, кто вправе сделать то и другое. При импорте коллекция объединяется с одноимённой коллекцией получателя или создаётся; её позиции добавляются в конец без дубликатов. Живая коллекция получателя не получает ни одной позиции: её содержимое задаёт её запрос. Импорт базы в настольном приложении следует тому же правилу.
curl -X POST http://127.0.0.1:8080/v1/exports.sqlite \
-H 'X-Tenant-ID: club-lyon' -H 'Content-Type: application/json' \
-d '{"collectionIds":[4],"watermarkOrigin":"Club de Lyon"}' -o ouvertures.db
curl -X POST http://127.0.0.1:8080/v1/imports.db \
-H 'X-Tenant-ID: alice' -F file=@ouvertures.db
Семейство training ведёт журнал вкладки Тренировка: training.save добавляет занятие (exercise, seedSource, счётчики, items) и возвращает его id (Idempotency-Key принимается); training.sessions перечитывает занятия, самое свежее первым (exercise и limit необязательны); training.numberStats агрегирует элементы упражнения по типу числа. Сами вопросы выбирает клиент.
gammonnet.evaluate оценивает голую позицию (position или xgid), ничего не читая и не записывая в тенанте: с костями — лучшие ходы (candidates, по умолчанию 5, не более 20); без костей — решение по кубу. ply принимает значения от 0 до 2 (по умолчанию 2); более глубокий поиск — дело analyzeMissing.
Семейство anki получает шесть методов, расширяющих планировщик интервального повторения (FSRS): anki.reviewLog (журнал каждого повторения — оценка и результат FSRS — для статистики удержания и достоверной истории), anki.forecast (прогноз числа карточек, наступающих к повторению в ближайшие дни, включая просроченные), anki.suspendCard / anki.buryCard / anki.removeCard (убрать карточку из очереди повторения временно или навсегда) и anki.retention (процент успешных повторений, измеренный по колоде, сверяемый с целью, заданной её владельцем).
Примечание
anki.retention заменяет anki.optimizeParams, который подстраивал целевое значение к наблюдаемому проценту и мог его записывать. Целевой уровень удержания — это выбор в компромиссе между нагрузкой и качеством, измеренный процент — лишь его результат, и подчинение одного другому — как раз тот механизм, который отвергают авторы FSRS. Метод только измеряет и никогда не записывает.
Семейство stats предоставляет stats.playerTable: по одной строке статистики на игрока (матчи, победы/поражения, учтённые решения, PR общий / шашки / куб, Snowie Error Rate, ошибки, бландеры и удача) по матчам, которые оставил переданный фильтр. Как и в графическом интерфейсе, эта таблица учитывает из фильтра только период, турниры и длину матчей: выбор игрока и тип решения игнорируются, поскольку таблица охватывает всех игроков и уже разносит шашки и куб по отдельным столбцам. Поле luck_known говорит, измерялась ли удача для этого игрока; при значении false читать luck_rate_mp нельзя — неизвестная удача не есть нулевая удача.
Фильтр, передаваемый методам stats, принимает рядом с PlayerName поле PlayerAliases — другие написания, под которыми подписывался тот же человек. Имя игрока набирается вручную в каждом файле, поэтому один человек регулярно встречается в нескольких написаниях, и фильтр, оставляющий лишь одно, считает по части матчей, причём ничто не выглядит подозрительно. Поле чисто аддитивно: сохраняются решения по любому из имён. Другой ответ — слияние имён в базе (MergePlayers), и его стоит приберечь для баз, полученных не от кого-то другого: оно переписывает матчи всех.
Два метода довершают паритет с графическим интерфейсом: stats.tournamentBadges возвращает для каждого турнира базы показатель, выводимый на его карточке (PR опорного игрока), а matches.findByHash по двум отпечаткам обнаружения дубликатов сообщает, есть ли уже данный матч, — этого достаточно, чтобы не начинать лишний импорт.
Поле winner партии, принимаемое matches.createGame и возвращаемое matches.games, имеет единственную кодировку: 1 для игрока 1, -1 для игрока 2, 0 для незавершённой партии. Клиент, который по-прежнему отправляет 0, 1 или -1 в смысле gnubg (0 для игрока 1, 1 для игрока 2), записывает противоположного победителя.
analyses.repair пересчитывает денормализованные столбцы анализа (в том числе cube_error) по его полному анализу и возвращает число действительно исправленных строк. Эти столбцы — всего лишь проекция, поэтому ошибка проекции чинится без повторного импорта исходных файлов. Операция явная и никогда не запускается сама: ни при открытии базы, ни при миграции, ведь схема тут ни при чём. Нечитаемый анализ оставляют как есть, а не обнуляют. Известный случай: недаблы, помеченные gnuBG как «Double No», которые до версии 0.33.0 читались неверно и несли ошибку дабла, которого не было.
gammonnet.analyzeMissing запускает догоняющий анализ gammonNet для текущего тенанта: записать анализ для каждой позиции, у которой его нет (ADR-0013, ADR-0015). Это операция над библиотекой — она читает и записывает хранимые позиции и анализы — а не просто движок оценки: blunderdb serve работает с библиотекой, gammonnet serve оценивает позицию. Ответ — поток NDJSON (started, progress, затем done или error/cancelled), по той же модели, что и точки доступа импорта; gammonnet.analyzeMissing.cancel (с job_id, полученным в событии started) отменяет идущий догоняющий анализ и одинаково служит как для догоняющего анализа, так и для повторного анализа (ниже). Это та же операция, что и автоматический запуск после импорта и явное действие в графическом интерфейсе, и что подкоманда blunderdb analyze (см. Интерфейс командной строки (CLI)) — три формы, одна логика.
gammonnet.sweepStale — это аналог analyzeMissing для повторного анализа, а не для заполнения пробелов: каждая позиция, чей анализ целиком принадлежит gammonNet, но устарел — более старая версия движка, чем работающая сейчас, или глубина, отличная от ply, — переоценивается на запрошенной глубине. Предикат устаревания разделяется с той же партией в графическом интерфейсе и с blunderdb analyze --stale (никакого дублирования логики между тремя режимами); позиция, несущая анализ XG, GNUbg или BGBlitz, никогда не затрагивается, независимо от её содержимого gammonNet — защита ADR-0013 остаётся безусловной. Та же форма NDJSON, что и у analyzeMissing, и итоговое событие каждого из двух маршрутов несёт разбивку evaluated/refused/failed: позиция, которую gammonNet отказывается оценивать (счёт матча за пределами диапазона его таблицы, решение об удвоении, которое модель отклоняет), считается как refused, а не failed — она никогда не повторяется впустую на следующем проходе, в отличие от позиции, которая действительно потерпела неудачу.
rollout.position играет позицию библиотеки (positionId) через rollout и возвращает для каждого кандидата эквити, его 95-процентный интервал и JSD; rollout несёт настройки (fast, standard или standard,ply=1…), store записывает завершённый rollout как второй анализ рядом с тем, который несёт позиция, и никогда его не заменяет. Голая позиция (XGID) отклоняется: демон работает с библиотекой. rollout.filter — пакетная форма blunderdb analyze --rollout: позиции, выбранные query (язык поиска), которые ещё не несут rollout с теми же настройками, играются одна за другой и записываются по ходу дела, потоком NDJSON (started, progress после каждой серии партий, затем done, cancelled или quota_exceeded); rollout.filter.cancel отменяет его по job_id. Тенант выполняет только один пакет за раз, rollout или gammonNet. rollout.list читает записанные rollout позиции.
Корреляция и бизнес-метрики
Каждый запрос получает идентификатор корреляции: тот, который клиент (или обратный прокси) прислал в заголовке X-Request-Id, иначе созданный — в обоих случаях он возвращается в том же заголовке ответа и добавляется в завершающую строку журнала запроса (поле request_id). Присутствующий traceparent (W3C Trace Context) передаётся в ту же строку журнала как есть — демон его не разбирает и не проверяет и не включает никакой библиотеки трассировки: это мост, чтобы связать эти журналы с трассировкой, работающей выше по потоку, и не более того.
Помимо количества запросов и их задержки, /metrics публикует показатели о выполняемой работе, иначе невидимой при застрявшем импорте или пакете gammonNet (один очень долгий запрос, а не много запросов):
blunderdb_imports_inflight— идущие импорты, по всем тенантам;blunderdb_import_spool_bytes— байты, сейчас зарезервированные в квоте буфера импорта (аналог для запросов в секунду —--rate-limit-*выше);blunderdb_gammonnet_sweep_inflight— идущие догоняющие проходы gammonNet, по всем тенантам;blunderdb_database_size_bytes— размер основного файла SQLite илиpg_database_sizeпод PostgreSQL (вся база, а не по тенантам, как и показатели пула соединений ниже); отсутствует, пока не опубликовано ни одного измерения.
Профиль памяти или процессора можно получить, запустив с --pprof-addr <хост:порт> (net/http/pprof): по умолчанию выключено и намеренно на адресе, отдельном от --addr, поскольку эти точки ничего не знают о тенантах.
Сжатие потоков
Списки NDJSON повторяют одни и те же имена полей в каждой строке. Демон сжимает их, если клиент это принимает: отправьте Accept-Encoding: gzip, и ответ вернётся с Content-Encoding: gzip. Измерено на списке матчей: 13,5 % исходного размера на тысяче строк, 14,6 % на ста.
Сжатие ничего не меняет в постепенности потока — каждая запись уходит клиенту как прежде, только сжатой по пути. Оно применяется лишь к ответам NDJSON, JSON и тексту: экспорт базы или контейнер .dbx уже сжаты, и повторный gzip только увеличит их. Accept-Encoding: gzip;q=0 отказывается от него явно.
Только один тенант на SQLite
У бэкенда SQLite нет столбца тенанта: все данные лежат в одних и тех же таблицах, без перегородок. Поэтому на этом бэкенде демон отклоняет любой X-Tenant-ID, кроме 1: принимать остальные означало бы отдавать каждому строки всех за заголовком, утверждающим обратное. Развёртыванию, где тенанты действительно есть, нужен бэкенд PostgreSQL.
Чтение нескольких тенантов
Тренер, который читает матчи своих учеников, клуб, который делится библиотекой: связь между этими учётными записями хранится у хоста, который их аутентифицирует, и никогда в демоне. Прокси выражает её заголовком X-Read-Tenants — списком тенантов через запятую (X-Read-Tenants: 2, 3), который он ставит рядом с X-Tenant-ID. Демон доверяет ему так же, как X-Tenant-ID, и сам ничего не разрешает (ADR-0063).
Функция отключена по умолчанию, а отключена означает отклоняется: пока демон не запущен с --read-tenants (или BLUNDERDB_READ_TENANTS=true; Config.TrustReadTenants для хоста, встраивающего движок), любой запрос с непустым X-Read-Tenants отклоняется (400) независимо от маршрута. Включайте её только после того, как прокси настроен удалять любое значение, присланное клиентом, и сам задавать список.
Этот заголовок учитывают только чтения /v1/across.*. Полный список: across.searchFind, across.matchesList, across.statsCompute и across.playerTable; они читают сначала X-Tenant-ID, затем каждый указанный тенант в порядке заголовка, всего не более 64 различных тенантов. На тенанте из списка, названном по id: across.matchesGet, across.matchMovePositions (позиции матча, ход за ходом) и across.analysesLoadByIds; тенант, отсутствующий в списке, там отклоняется. Каждый результат несёт свой исходный тенант ("tenant": "2"), так как id уникален только внутри своего тенанта; позиция несёт также свой хеш Зобриста ("zobrist"), который обозначает одну и ту же доску во всех тенантах. limit применяется к каждому тенанту; 0 означает 1000, а большее значение отклоняется. В потоке NDJSON ошибка на позднем тенанте приходит последней строкой, после результатов уже прочитанных тенантов: весь поток тогда считается неудачным.
curl -s http://127.0.0.1:8080/v1/across.matchesList \
-H 'X-Tenant-ID: 1' -H 'X-Read-Tenants: 2, 3' -d '{"limit":20}'
Любая запись остаётся в X-Tenant-ID: ни один другой маршрут не читает X-Read-Tenants. Без заголовка чтение across.* затрагивает только X-Tenant-ID. Некорректный заголовок (имя, пустой элемент, более 64 тенантов) или заголовок, присланный в нескольких строках, отклоняет весь запрос независимо от маршрута. В SQLite, где только один тенант, список может содержать лишь 1: заголовок там ничего не расширяет. Эти маршруты свойственны серверу: у десктопного приложения и call только один тенант.
Запрос across.* обходится до 64 чтений из хранилища, но ограничение частоты (--rate-limit-rps) учитывает его лишь один раз, для X-Tenant-ID: соответственно рассчитайте размер базы и это ограничение или поручите прокси ограничить список. Журнал доступа маршрута across.* содержит полученный список (поле read_tenants). Заголовка нет среди разрешённых заголовков CORS: его пишет только прокси, никогда браузер.
Резервное копирование и восстановление
Четыре способа — в зависимости от того, что нужно восстановить.
Всё, под PostgreSQL — инструмент это pg_dump, и blunderDB нечего к нему добавить:
pg_dump --format=custom --file=blunderdb.dump "postgres://…"
pg_restore --dbname="postgres://…" blunderdb.dump
Всё целиком, под SQLite в контейнере — файл открыт в режиме WAL (демон кодирует journal_mode(WAL) в своей строке подключения, для всех соединений пула): рядом с blunderdb.db живут -wal и -shm, и самые свежие записи находятся в -wal. Скопировать у работающего демона один лишь .db — значит получить неполный файл, причём ничто об этом не предупредит. Два надёжных способа:
остановить демона, затем скопировать том целиком — при остановке все три файла согласованы, и единицей резервного копирования является том, а не один
.db;вообще не копировать файл:
/v1/exports.sqlite(ниже) записывает полный.db, пока демон работает, и это единственный способ, не требующий никакой остановки.
/ops/maintenance.vacuum действительно сворачивает WAL в основной файл, прежде чем переписать его, но базу он не замораживает: следующая запись снова уходит в WAL. Это команда сжатия, а не метод резервного копирования.
Один тенант отдельно — /v1/exports.sqlite записывает базу тенанта в обычный файл .db, тот самый, который открывает настольное приложение:
curl -X POST http://127.0.0.1:8080/v1/exports.sqlite \
-H "X-Tenant-ID: 42" -o tenant-42.db
Эта команда выполняется на машине демона: она обращается к локальному слушателю, минует прокси и потому сама выставляет заголовок тенанта. Снаружи обращаются к прокси, и тенант — это тенант аутентифицированной учётной записи; заголовок задавать не нужно, прокси стирает клиентский, прежде чем вставить свой:
curl -u alice:… -X POST \
https://blunderdb.example.com/v1/exports.sqlite -o tenant-alice.db
Вернуть этот файл на место — migrate копирует его под нужный тенант:
./blunderdb migrate --from tenant-42.db --to "postgres://…" --tenant-id 42
migrate отказывается писать в тенант, где уже что-то есть, и говорит что именно («128 позиций, 3 матча»); --on-conflict skip продолжает всё равно и позволяет дедупликации по Zobrist объединить позиции.
Что migrate не копирует и о чём сообщает в конце с точным числом: колоды Anki и их карточки, библиотеку фильтров, истории поиска и команд, состояние сессии. Это данные использования настольного приложения; сами позиции, на которые они ссылаются, перенесены.
Пороги ошибки и бландера, напротив, копируются: это не данные использования, а привычка чтения, от которой зависят подсчёты, и тенант, который считал бы иначе, чем файл, откуда он пришёл, превратил бы миграцию в немое изменение смысла.
Тенант задаёт свои через POST /v1/librarySettings.load и /v1/librarySettings.save. В отличие от metadata, которая является глобальной инфраструктурой, открытой только на чтение, таблица настроек несёт tenant_id и живёт под Row-Level Security: тенант, записывающий свои пороги, достигает лишь собственных строк.
Рабочее место и сервер
Настольное приложение открывает файлы .db, а не URL: оно не подключается ни к какому демону serve, и нигде нет поля для ввода адреса. Сервер и рабочее место обмениваются файлами, двумя симметричными действиями:
с сервера на рабочее место —
POST /v1/exports.sqliteзаписывает весь текущий тенант в.db, который настольное приложение открывает как есть (см. Резервное копирование и восстановление);с рабочего места на сервер —
blunderdb migrateпереписывает.dbпод нужный тенант (см. Миграция базы SQLite в PostgreSQL).
Межтенантного чтения не существует. Разделение полное: ничто из того, что хранит один тенант, не видно другому — ни по одному маршруту, и ни один вызов не принимает тенанта параметром: каждый запрос знает только того, которого выставил ему прокси. Поэтому у тренера, желающего видеть матчи своих учеников, есть два пути, оба явные:
завести ему в прокси дополнительную учётную запись, связанную с тенантом ученика: именно таблица соответствия прокси, а не демон, решает, какого тенанта видит сессия;
попросить у него экспорт —
.db, произведённыйexports.sqliteили окном экспорта настольного приложения, — и открыть его на своём рабочем месте.
Развёртывание с помощью Docker
Репозиторий содержит Dockerfile.serve, который собирает минимальный контейнерный образ демона: компилируется только двоичный файл serve (чистый Go, без графического интерфейса и без CGO, то есть со статической компоновкой), после чего он помещается в образ distroless.
# build
docker build -f Dockerfile.serve -t blunderdb-serve .
# run
docker run --rm -p 127.0.0.1:8080:8080 \
-e BLUNDERDB_DSN="postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
blunderdb-serve
Сборка запускается из корня репозитория, а бэкенд образа по умолчанию — postgres.
Образ слушает порт 8080 и настраивается через переменные окружения (BLUNDERDB_BACKEND, BLUNDERDB_DSN, BLUNDERDB_ADDR, BLUNDERDB_RLS). Он объявляет HEALTHCHECK, который каждые 30 секунд запускает blunderdb healthcheck (запрос к /readyz — в образе distroless нет ни curl, ни оболочки): docker ps показывает состояние контейнера healthy или unhealthy, а Compose или оркестратор могут дождаться готовности демона, прежде чем запускать то, что от него зависит.
Публикуемый образ
Собирать образ самостоятельно не нужно: каждая опубликованная версия blunderDB отправляет свой образ в реестр GitHub (GHCR) под именем ghcr.io/kevung/blunderdb-serve. Доступны два тега: номер версии, закреплённый за этим образом навсегда, и latest, следующий за последней опубликованной версией. Вся документация записывает их как ghcr.io/kevung/blunderdb-serve:<version>: место <version> занимает номер опубликованной версии, и именно эту форму, а не latest, закрепляет за собой промышленное развёртывание. Образ предоставляется для linux/amd64 и linux/arm64; Docker сам выбирает архитектуру хоста.
# pull
docker pull ghcr.io/kevung/blunderdb-serve:<version>
# postgres
docker run --rm -p 127.0.0.1:8080:8080 \
-e BLUNDERDB_DSN="postgres://user:pass@host:5432/blunderdb?sslmode=disable" \
ghcr.io/kevung/blunderdb-serve:<version>
# sqlite
docker run --rm -p 127.0.0.1:8080:8080 \
-v blunderdb-data:/data \
-e BLUNDERDB_BACKEND=sqlite -e BLUNDERDB_DSN=/data/blunderdb.db \
ghcr.io/kevung/blunderdb-serve:<version>
/data — это точка монтирования, которую готовит образ, с правами его непривилегированного пользователя, и его XDG_DATA_HOME: том, который туда монтируют, служит не только базе — таблицы бироффа вычисляются в нём один раз, в /data/blunderdb, и находятся при последующих запусках. Без тома они пересчитываются при каждом запуске контейнера — несколько секунд — и демон сообщает об этом при старте, если не может их записать (could not prepare the bearoff tables; the exact regime will be unavailable); в этом случае он обслуживает запросы как обычно, но с одним лишь оценочным режимом на позициях бироффа.
Образ несёт обычные метки OCI (org.opencontainers.image.source, .version, .revision, .licenses): docker inspect показывает, из какого коммита и какой версии он собран. Он собирается непрерывной интеграцией из Dockerfile.serve репозитория, точно так же, как показано выше; локальная сборка или загрузка опубликованного образа дают один и тот же двоичный файл.
Предупреждение
Как и сам демон, контейнер не выполняет никакой аутентификации (ADR-0005): он доверяет заголовку X-Tenant-ID в том виде, в каком он его получает. Его следует размещать за обратным прокси, отвечающим за аутентификацию и самостоятельно задающим этот заголовок, и никогда не выставлять напрямую в публичный Интернет. Именно поэтому примеры выше публикуют порт только на 127.0.0.1, и --addr привязывается так же к 127.0.0.1: прокси находится на той же машине.
Развёртывание за аутентифицирующим прокси
ADR-0005 делает обратный прокси всей границей безопасности демона: только он аутентифицирует вызывающую сторону, только ему разрешено устанавливать заголовок X-Tenant-ID, и он должен систематически удалять любое значение, отправленное клиентом, прежде чем внедрить аутентифицированного тенанта — иначе кто угодно сможет выдать себя за любого тенанта, просто назвав его. Модель угроз умещается в одну фразу: демон предполагает доверенную внутреннюю сеть, и всякий, кто обращается к нему напрямую, для него — тот тенант, за которого себя выдаёт. Репозиторий предоставляет полный, готовый к запуску пример в каталоге deploy/. Он живёт в git-репозитории, а не в контейнерном образе: поэтому нужно клонировать репозиторий либо скачать в один каталог два воспроизведённых ниже файла, а также deploy/.env.example.
Файл Compose ставит Caddy — с демонстрационной HTTP Basic-аутентификацией — перед blunderdb-serve и PostgreSQL с включённым Row-Level Security. Порт публикует только Caddy: две другие службы живут во внутренней сети Docker, объявленной internal: true, у которой нет маршрута ни к хосту, ни в Интернет, какие бы ports: ни добавила им последующая правка.
# Example deployment of `blunderdb serve` behind an authenticating reverse
# proxy — the security model ADR-0005 requires and, until now, that no example
# in this repository actually showed. See deploy/README.md for the threat
# model and doc/source/mode_headless.rst for the full walkthrough.
#
# Try it from the repository root:
# POSTGRES_PASSWORD=changeme docker compose -f deploy/docker-compose.yml up -d --build
# curl -u alice:demo-password http://localhost:8080/v1/metadata.counts -d '{}'
# docker compose -f deploy/docker-compose.yml down -v
services:
# Caddy is the ENTIRE security boundary (ADR-0005): it is the only service
# with a published port, it authenticates every request, and it is the
# only thing allowed to set X-Tenant-ID — see Caddyfile. Any reverse proxy
# capable of stripping and re-setting a header works equally well; Caddy is
# used here for its one-file config and built-in Basic Auth with no extra
# modules. deploy/nginx-tenant-proxy.conf shows the equivalent nginx
# snippet for an existing nginx deployment.
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "8080:80" # the ONLY port this compose project exposes to the host
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
networks:
- edge # the published port lives here — "backend" is internal-only
- backend
depends_on:
blunderdb-serve:
condition: service_healthy
# No `ports:` here — on purpose (ADR-0005). The daemon performs no
# authentication of its own, so it must be reachable only from Caddy, over
# the "backend" network, and never published to the host.
blunderdb-serve:
build:
context: ..
dockerfile: Dockerfile.serve
restart: unless-stopped
environment:
BLUNDERDB_BACKEND: postgres
BLUNDERDB_DSN: "postgres://blunderdb:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD, see deploy/.env.example}@postgres:5432/blunderdb?sslmode=disable"
BLUNDERDB_ADDR: ":8080"
# Row-Level Security: defence-in-depth *inside* the trust boundary
# Caddy draws above — it does not replace the proxy (ADR-0005).
BLUNDERDB_RLS: "true"
volumes:
# The bearoff tables are computed on first start and kept under
# $XDG_DATA_HOME/blunderdb, which the image sets to /data: without a
# volume they are recomputed at every restart of the container.
- blunderdb-data:/data
networks:
- backend
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: blunderdb
POSTGRES_USER: blunderdb
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD, see deploy/.env.example}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blunderdb -d blunderdb"]
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres-data:
# Bearoff tables computed by blunderdb-serve on first start (see
# XDG_DATA_HOME above): a few megabytes, worth keeping across restarts.
blunderdb-data:
caddy-data:
caddy-config:
networks:
# Caddy's own network, carrying the one published port. A container on
# "backend" alone (blunderdb-serve, postgres) is never reachable through it.
edge: {}
# internal: true means this network has no route to the outside world and
# accepts no published ports — blunderdb-serve and postgres can only ever
# be reached by another container attached to it (here, only Caddy),
# never from the host or the public internet, regardless of what `ports:`
# a future edit might add to either service.
backend:
internal: true
Caddyfile аутентифицирует, сопоставляет аутентифицированную учётную запись с целым числом тенанта (map), а затем внедряет его в X-Tenant-ID после явной очистки любого значения, полученного от клиента: защита header_up X-Tenant-ID "" предшествует внедрению, так что заголовок, отправленный клиентом, не может достичь демона, какими бы ни были последующие изменения файла.
То же относится к X-Read-Tenants (Чтение нескольких тенантов): прокси удаляет заголовок клиента и ставит свой, только если знает связь между учётными записями; примеры из репозитория не знают ни одной и всегда его удаляют.
# Demonstration reverse-proxy for `blunderdb serve` (ADR-0005).
#
# This is the WHOLE security boundary of the daemon: it authenticates the
# caller (here, HTTP Basic Auth — swap for forward_auth to a real identity
# provider, or an OIDC plugin, in production) and is the only thing allowed
# to set X-Tenant-ID. blunderdb-serve trusts that header completely and
# performs no authentication of its own.
#
# Demo credentials — CHANGE THESE before using this anywhere but a laptop:
# alice / demo-password
# bob / demo-password
# Generate a real hash with:
# docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>'
{
# This demo terminates plain HTTP on a fixed port instead of Caddy's
# automatic HTTPS, which needs a real public domain name to obtain a
# certificate for. Point a domain at this host, replace ":80" below with
# that domain, and delete these two lines to get HTTPS for free.
auto_https off
admin off
}
:80 {
basic_auth {
alice $2a$14$7yGnM3/IY8G/.mcBHMbDveecDQbnJnvHPcJQZcbWqn.H.mpttw9/.
bob $2a$14$7yGnM3/IY8G/.mcBHMbDveecDQbnJnvHPcJQZcbWqn.H.mpttw9/.
}
# Map the authenticated login (Caddy sets {http.auth.user.id} once
# basic_auth succeeds) to the tenant's positive integer — the only
# spelling of X-Tenant-ID the daemon accepts (ADR-0005, amendment
# 2026-09-03). This is the identity-to-tenant mapping ADR-0005 says is
# the proxy's job: the daemon never sees "alice", only "1".
map {http.auth.user.id} {tenant_id} {
alice 1
bob 2
default 0
}
# Never reach the daemon's operator routes or its metrics through the
# public proxy: /ops/ (vacuum, tenant purge) acts beyond the calling
# tenant, /metrics needs no X-Tenant-ID and describes the whole daemon.
@private path /ops/* /metrics
respond @private 403
reverse_proxy blunderdb-serve:8080 {
# Guard, then inject: clear whatever the client sent BEFORE setting
# the authenticated value, so a client-supplied X-Tenant-ID can never
# reach the daemon no matter how this file is edited later — the
# second line is the only one that can still be in effect once both
# have run.
header_up X-Tenant-ID ""
header_up X-Tenant-ID {tenant_id}
# X-Read-Tenants widens a read to other tenants (ADR-0063): only a
# proxy that knows the relation (coach, club) may set it. This demo
# knows none, so it drops whatever the client sent.
header_up -X-Read-Tenants
}
}
Каталог дополняют ещё два файла: deploy/nginx-tenant-proxy.conf повторяет ту же схему в виде фрагмента для nginx (proxy_set_header X-Tenant-ID "", затем proxy_set_header X-Tenant-ID $tenant_id, с блоком map $remote_user $tenant_id) — для тех, у кого nginx уже стоит; deploy/README.md излагает модель угроз и то, чего делать нельзя никогда.
HTTP Basic-аутентификация в Caddyfile — это демонстрация, а не рекомендация для промышленной эксплуатации: она заменяется на forward_auth к настоящему поставщику идентификации (OIDC, корпоративный SSO…), который аутентифицирует и затем передаёт идентификацию в том же месте файла. Оба пароля и обе учётные записи таблицы соответствия следует заменить точно так же.
deploy/Caddyfile.oidc — это рецепт OpenID Connect: Caddy обращается к oauth2-proxy (forward_auth на /oauth2/auth), который отвечает 202 с адресом вошедшей учётной записи в X-Auth-Request-Email либо перенаправляет на страницу входа провайдера. Блок map сопоставляет этот адрес целому числу тенанта, а та же защита header_up X-Tenant-ID "" предшествует подстановке. Сервис oauth2-proxy, который нужно добавить в файл Compose, находится в начале файла.
Квоты на тенанта
Общий экземпляр ограничивает то, что каждый тенант у него берёт, с помощью --quota-positions, --quota-analysis-seconds и --quota-imports (без опции ничто не ограничивается). Время вычислений учитывает каждое вычисление движка, запрошенное тенантом: gammonnet.analyzeMissing, gammonnet.sweepStale, gammonnet.compare, gammonnet.cubeMatrix, gammonnet.evaluate, rollout.position и rollout.filter. Оно считается в секундах CPU: прошедшее время, умноженное на число одновременно ведущихся поисков, так что вычисление, распределённое по всем ядрам, стоит столько же, сколько та же работа, выполняемая позиция за позицией. Когда время дня исчерпано, эти маршруты отвечают 429 с кодом quota_exceeded. Выполняющийся обход или rollout.filter сохраняет уже записанное и завершается событием quota_exceeded вместо done; прерванный rollout.position отвечает 429 и ничего не записывает; прерванное сравнение возвращает то, что успело свести, с quotaExceeded: true и, в gathered, число позиций, которые ему предстояло изучить. Счётчик обнуляется в полночь UTC и хранится в памяти: перезапуск демона обнуляет его. Квота позиций проверяется в начале импорта, который не прерывается на полпути: тенант может превысить её на столько, сколько добавляют его выполняющиеся импорты. positions.save и другие единичные записи её не проверяют. Каждый отказ несёт в details предел (quota, limit) и использование (used). tenants.quota возвращает вызывающему тенанту его пределы и использование: сохранённые позиции, секунды вычислений за день, выполняющиеся импорты.
Квоты — это учёт демона, а не граница: они применяются к тенанту, которого прокси поставил в X-Tenant-ID.
Полный сценарий: от нуля до отвечающего демона:
git clone https://github.com/kevung/blunderDB.git
cd blunderDB/deploy
cp .env.example .env # POSTGRES_PASSWORD
docker compose up -d --build
# 401
curl -i http://localhost:8080/v1/metadata.counts -d '{}'
# 200
curl -u alice:demo-password http://localhost:8080/v1/metadata.counts -d '{}'
curl -u alice:demo-password -H "X-Tenant-ID: 999" \
http://localhost:8080/v1/metadata.counts -d '{}'
docker compose logs blunderdb-serve
docker compose down -v
Первый запрос отклоняется Caddy, даже не достигнув демона. Два следующих аутентифицируются как «alice», которую таблица соответствия связывает с тенантом 1: они возвращают одно и то же тело ({"positions":0,"analyses":0,"matches":0,…}), и журнал демона несёт tenant=1 и для одного, и для другого — значение 999, отправленное клиентом, не пережило защиту в Caddyfile. Этот сценарий был воспроизведён в точности.
Чтобы тянуть опубликованный образ, а не собирать его, замените в docker-compose.yml три строки build: службы blunderdb-serve одной строкой image:, затем запустите docker compose up -d без --build:
blunderdb-serve:
image: ghcr.io/kevung/blunderdb-serve:<version>
restart: unless-stopped
Файл Compose публикует порт Caddy на всех интерфейсах (8080:80): именно этого и ждут от прокси, который для того и стоит, чтобы к нему обращались. Публиковать никогда нельзя демона — и он не опубликован, у него нет ни одного ports:.
Обновление развёртывания
Схема мигрируется автоматически при запуске, и эта миграция односторонняя: база, переведённая на свежую схему, больше не читается предыдущей версией blunderDB (см. Приложение: Схема базы данных). Поэтому порядок действий важен.
Сначала сделать резервную копию, прежде всего остального: это единственный путь назад (см. Резервное копирование и восстановление).
Тянуть тег нужной версии, никогда
latestв промышленной эксплуатации.latestследует за последней опубликованной версией: развёртывание, закреплённое на нём, меняет версию при перезапусках, хотя этого никто не решал и хотя резервная копия из шага 1 не обязательно свежая.Перезапустить демона на новом образе. Он мигрирует схему прежде, чем обслужит хоть один запрос; если миграция не удалась, он останавливается на ошибке, а не обслуживает наполовину мигрированную базу.
Проверить пробу готовности.
GET /readyzотвечает200и{"status":"ready","version":"…"}, когда хранилище отвечает и его схема — это схема двоичного файла;503и{"status":"down"}, когда база недостижима;503и{"status":"version_mismatch","version":"…","expected":"…"}, когда две схемы расходятся — ответ называет схему базы и ту, которую ожидает двоичный файл.blunderdb healthcheckвыдаёт тот же вердикт кодом возврата.
version_mismatch, сохраняющийся после перезапуска, — это откат назад: более старый двоичный файл перед уже мигрированной базой. Миграции вниз не существует; восстанавливать нужно резервную копию из шага 1.
Важно
Перед включением --read-tenants в существующем развёртывании обновите прокси: прокси, настроенный до появления этого заголовка, удаляет только X-Tenant-ID и пропустил бы как есть X-Read-Tenants, присланный клиентом, который тогда читал бы других тенантов. Без опции демон отклоняет этот заголовок: прокси, который его пропускает, выдаёт себя ответами 400.
Бэкенд PostgreSQL и многопользовательский режим
Для общего развёртывания blunderDB может хранить данные в PostgreSQL, а не в файле SQLite. Бэкенд выбирается опцией --backend postgres и строкой подключения --dsn. Схема создаётся и мигрируется автоматически при запуске.
Данные разделены по тенантам (тенантам): каждый запрос несёт идентификатор своего тенанта (заголовок X-Tenant-ID, положительное десятичное целое число, например 1 или 42), что позволяет нескольким пользователям совместно использовать один экземпляр, не видя данных друг друга. Идентификатор, не являющийся таким числом — имя вроде alice или default, 0, 007, — отклоняется с 400 invalid: именно обратный прокси сопоставляет учётную запись с её числом, демон никогда не угадывает.
Row-Level Security
Опция --rls дополнительно включает Row-Level Security PostgreSQL. При каждом запуске демон устанавливает на каждой таблице, несущей tenant_id, политику tenant_isolation, которая пропускает только строки тенанта, названного параметром сессии current_setting('app.tenant_id'), и распространяет её вплоть до владельца таблицы (FORCE ROW LEVEL SECURITY). Этот параметр выставляется на соединении при выходе из пула и обнуляется при возврате в него; соединение без тенанта не видит ни одной строки и ни одной не вставляет. Это необязательная эшелонированная защита, по умолчанию отключённая: фильтрация по тенанту в прикладном коде остаётся на месте в обоих случаях.
Роль подключения должна быть обычной: ни суперпользователь, ни
BYPASSRLS. PostgreSQL молча пропускает этих двоих через все политики, и изоляция снова сводится к одному лишь прикладному коду. Зато та же роль должна владеть таблицами, поскольку именно она выполняетALTER TABLEиCREATE POLICY.На уже заполненной базе мигрировать нечего: установка политик — идемпотентный DDL, воспроизводимый при каждом запуске после миграции схемы. Никакие данные не перемещаются, ни одна строка не переписывается; включить или убрать
--rls— это всего лишь перезапуск.Стоимость измерена: на чтении одной позиции 101,8 мкс без неё и 177,0 мкс с ней, то есть +73,8 % — тот же контейнер, те же строки, два пула, различающиеся только этим флагом. Она платится при каждом взятии соединения из пула (установка, затем обнуление параметра) и на предикате, через который дополнительно проходит каждый запрос, но никогда не зависит от объёма данных.
Открытие и закрытие тенанта
На стороне сервера создавать нечего: тенант — это не запись, а целое число, которое несут его строки. В базе нет таблицы тенантов, и демон не ведёт их списка — открыть учётную запись значит добавить строку в таблицу соответствия прокси, а первая запись участника заставляет его тенант существовать.
Пустой тенант отвечает как пустая база, без ошибки: metadata.counts возвращает нули, а списки не возвращают ничего.
Когда тенант выводится из эксплуатации, POST /ops/tenant.purge безвозвратно удаляет все его данные (позиции, матчи, коллекции, историю и т. д.) для текущего тенанта (того, что передан в X-Tenant-ID), а также его состояние сессии (последний поиск, последняя позиция, открытые вкладки — строки таблицы session_state, относящиеся к этому тенанту): операция выполняется в единой транзакции, идемпотентна (нет ошибки при очистке уже пустого тенанта или при повторном вызове) и не затрагивает ни одного другого тенанта. Она стирает строки этого тенанта во всех таблицах, которые его несут, и оставляет лишь то, что никому не принадлежит: таблицу metadata с её глобальной строкой версии схемы и журнал миграций. Очищенный тенант, таким образом, снова становится ровно пустым тенантом, а его число может быть назначено заново. Она доступна только с бэкендом PostgreSQL — на бэкенде SQLite, у которого нет понятия тенанта, она возвращает ошибку invalid.
Сжатие и пул соединений
POST /ops/maintenance.vacuum сжимает файл SQLite демона — аналог кнопки «Сжать базу» в интерфейсе и команды blunderdb vacuum (см. Интерфейс командной строки (CLI)), с той же защитой по дисковому пространству — и возвращает размеры до и после (sizeBefore, sizeAfter, в байтах). Она доступна только с бэкендом SQLite; на PostgreSQL, у которого нет файла для сжатия, она возвращает ошибку invalid.
Пул соединений PostgreSQL настраивается через переменные окружения: BLUNDERDB_POSTGRES_MAX_CONNS (по умолчанию 50), BLUNDERDB_POSTGRES_MIN_CONNS (5), BLUNDERDB_POSTGRES_MAX_CONN_LIFETIME (1h), BLUNDERDB_POSTGRES_HEALTH_CHECK_PERIOD (30s), BLUNDERDB_POSTGRES_CONNECT_TIMEOUT (5s — сверх этого недоступная база данных быстро завершается с ошибкой вместо зависания на TCP-таймауте операционной системы) и BLUNDERDB_POSTGRES_MAX_CONN_IDLE_TIME (30m — соединение, открытое для всплеска трафика, не остаётся в пуле бесконечно после его окончания). Каждое значение — это длительность в формате Go (5s, 30m, 1h); при отсутствии или некорректности используется значение по умолчанию. Когда --metrics включён, состояние пула непрерывно публикуется в /metrics: blunderdb_pg_pool_acquired (соединения, используемые в данный момент), _idle (свободные), _max (настроенный предел) и _wait_count (совокупное число вызовов Acquire, которым пришлось ждать свободного соединения).
Миграция базы SQLite в PostgreSQL
blunderdb migrate копирует однопользовательскую базу SQLite в backend PostgreSQL под выбранным тенантом — целым числом, которое обратный прокси будет передавать в X-Tenant-ID для этого пользователя — это путь для « загрузки » настольной библиотеки в серверное развёртывание.
blunderdb migrate \
--from sqlite:///path/to/database.db \
--to "postgres://user:pass@host:5432/db?sslmode=disable" \
--tenant-id 42
# --dry-run
blunderdb migrate --from sqlite:///path/to/database.db \
--tenant-id 42 --dry-run
Миграция копирует позиции, их анализы и комментарии, матчи (партии + ходы), турниры (с их связями матчей) и коллекции (с их составом), переназначая первичные и внешние ключи, всё в одной транзакции на стороне назначения: операция атомарна (сбой оставляет назначение нетронутым, достаточно повторить запуск). Прогресс и итоговый отчёт выводятся в NDJSON на стандартный вывод. Если исходная база достаточно старая и требует собственного обновления схемы на месте, оно выполняется первым и выдаёт собственные события "schema-migration" (фаза/выполнено/всего) до начала построчного копирования.
Опция |
По умолчанию |
Значение |
|---|---|---|
|
– |
исходная база SQLite ( |
|
– |
DSN PostgreSQL назначения ( |
|
– |
тенант назначения, положительное десятичное целое число (обязательно, кроме |
|
– |
подсчитывает, что было бы скопировано, ничего не записывая |
|
|
|
Примечание
Пока (ещё) не мигрируются прикладные состояния: колоды/карточки Anki, библиотека фильтров, история поиска и команд, а также метаданные сессии. Приоритет — миграция библиотеки позиций и истории матчей.
Универсальный диспетчер call
В дополнение к историческим подкомандам (Интерфейс командной строки (CLI)), blunderdb call предоставляет все операции хранения напрямую, локально. Он проходит через те же обработчики, что и демон serve: поэтому поведение идентично POST /v1/<семейство>.<метод>. Это полезно для написания скриптов и интеграционного тестирования.
# --list
blunderdb call --list
# read
blunderdb call metadata.counts --db database.db
blunderdb call positions.list --db database.db --json '{"limit":10}'
blunderdb call matches.get --db database.db --json '{"id":1}'
# write
blunderdb call positions.save --db database.db --json '{"position":{...}}'
blunderdb call matches.delete --db database.db --json '{"id":42}'
# a gesture of a tournament Direction, with the version a read printed
blunderdb call directions.enterResult --db database.db --if-match '…' \
--json '{"tournamentId":3,"matchId":"m7","winner":"aa"}'
Опции:
Опция |
По умолчанию |
Значение |
|---|---|---|
|
– |
файл SQLite (сокращение для |
|
|
|
|
|
строка подключения к бэкенду |
|
|
тенант, положительное десятичное целое число (передаётся как |
|
|
тело запроса в формате JSON |
|
– |
читает тело запроса из файла |
|
– |
выводит все методы |
|
– |
версия, отправляемая в |
call обслуживает жесты транскрипции без флага: он работает с локальным файлом, как CLI. Каждый вызов — новый процесс, а значит, своя сессия: sessionId можно опустить, и отмены от одного вызова к другому нет.
Ответ JSON (или поток NDJSON для эндпоинтов *.list) записывается на стандартный вывод. В случае ошибки процесс завершается с ненулевым кодом, а оболочка {"error":{…}} выводится на стандартный вывод, чтобы оставаться разбираемой (например, с помощью jq). Ответ с заголовком Direction-Version печатает его в стандартный поток ошибок: это значение, которое следующий жест передаёт в --if-match. call обслуживает жесты управления турниром без флага, как CLI, поскольку выполняется локально.
Инструменты для ИИ-ассистента (MCP)
blunderDB не содержит языковой модели: он предоставляет свои инструменты ассистенту, которым вы уже пользуетесь (Claude Code, Claude Desktop, локальный клиент), через Model Context Protocol. Ассистент ищет, читает и объясняет; blunderDB отвечает собственными цифрами.
Инструменты проходят через те же обработчики, что и /v1 и call:
Инструмент |
Что возвращает |
|---|---|
|
счётчики, период матчей, версия схемы, частые игроки |
|
позиции поиска в грамматике командной строки (описана в инструменте) с их канонической формой |
|
комментарии, содержащие слова; сохранённые поиски |
|
позиция, её анализ (лучшие ходы или куб), сыгранный ход и комментарий |
|
тема ошибки, её стоимость в миллипунктах и лучшее решение |
|
близкие позиции; чтение XGID; допустимые ходы; EPC гонки |
|
игроки; общий PR, шашки, куб, по фазам; повторяющиеся ошибки; PR викторины и удержание Anki против реального PR |
|
матчи, подробности матча, турниры |
|
коллекции и их позиции; колоды для повторения |
|
выбирает позицию без ответа, затем оценивает данный ответ |
|
оценка gammonNet позиции, заданной текстом, без её сохранения: лучшие ходы или решение по кубу |
|
следующая карточка колоды повторения, срок которой подошёл |
|
расшифровки матчей; подробности расшифровки; её текст |
|
проведённые турниры; итоги турнира; сезонный рейтинг |
|
rollout позиции базы: эквити, 95-процентный интервал и JSD для каждого кандидата |
Пишут только пять инструментов — save_position, comment_position, create_collection, add_to_collection и anki_review, который оценивает карточку, выбранную anki_next, — и предлагаются только по запросу: --write локально, --mcp-write на демоне. Все остальные только читают; rollout же получает, когда запись предложена, аргумент store, который записывает rollout рядом с анализом позиции. Ни один инструмент ничего не удаляет.
Локально ассистент запускает blunderdb mcp на файле (см. Интерфейс командной строки (CLI)). Для Claude Code:
claude mcp add blunderdb -- blunderdb mcp --db /chemin/vers/base.db
На демоне те же инструменты отвечают по HTTP на POST /mcp (транспорт streamable HTTP, без сессий). Как и /v1, /mcp требует X-Tenant-ID, и каждый инструмент работает в этом тенанте; программа, встраивающая pkg/blunderdb/server, тоже его обслуживает. Демон никого не аутентифицирует (ADR-0005): /mcp защищается на прокси, как /v1, а --mcp-write решается там же, как --direction. Каждый вызов /v1, который делает инструмент, снова проходит всю цепочку демона: он журналируется, учитывается в метриках и списывается с лимита запросов тенанта, сверх запроса /mcp, который его несёт. Поэтому вызов инструмента стоит нескольких запросов; ни один не освобождается.
Как и call, blunderdb mcp при открытии переносит схему старой базы, даже без --write.