ヘッドレスモード(サーバー)
注釈
このセクションでは、サーバーへのデプロイ、マルチユーザー、自動化を対象とした、blunderDB の高度かつ任意のモードについて説明します。blunderDB の通常かつ推奨される使用方法は、前章で説明したデスクトップアプリケーションのままです。自分のコンピューター上で blunderDB を単独で使用する場合、このモードは必要ありません:分析機能を一切失うことなく、この章を読み飛ばすことができます。
概要
同じバイナリ blunderdb は、デスクトップアプリケーションやコマンドライン(コマンドラインインターフェース(CLI) を参照)に加えて、ヘッドレスモード で動作することができます:グラフィカルインターフェースなしで、コマンドラインまたはネットワークから完全に操作されます。このモードは3つの用途をまとめています:
デーモン
serve— blunderDB のエンジンを HTTP + JSON サービスとして公開し、共有データベースをサーバー上で稼働させ、複数人でアクセスできるようにします。汎用ディスパッチャ
call— スクリプティングやテストのために、任意のストレージ操作をローカルで直接呼び出します。migrateコマンド — シングルユーザーの SQLite データベースをマルチユーザーの PostgreSQL バックエンドに転送します。
これら3つの用途は、2つのバックエンドと対話できる共通のストレージレイヤーに依存しています:SQLite(デスクトップアプリケーションの通常の .db ファイル形式)と PostgreSQL(マルチユーザーのサーバーデプロイ向け)です。
デーモン serve
blunderdb serve は、JSON で応答する HTTP サービスとしてエンジンを起動します。これにより、ポジションのデータベースを1台のマシン上でホストし、複数のクライアントからアクセスできるようになります。
# 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、 |
|
|
テナントごと、UTC の 1 日ごとのエンジン計算の CPU 秒数(429、 |
|
|
同一テナントで同時に実行中のインポート数(429、 |
|
|
PostgreSQL:テナントごとの Row-Level Security を有効にする(多層防御、オプション) |
|
|
|
|
– |
EPC エンドポイントのレース分析のために TS-06-06 の表を広げる、任意の両面ベアオフ・データベース( |
|
– |
デーモンの署名用識別情報のディレクトリ(初回使用時に作成される)。 |
|
– |
|
|
– |
|
ほとんどのオプションは環境変数(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)でも指定できます。明示的なフラグは、対応する変数より優先されます。
デーモンにはデータディレクトリのオプションがありません。ベアオフの表は $XDG_DATA_HOME/blunderdb、なければ ~/.local/share/blunderdb に書き込まれます。したがって、それらを移動させるのは XDG_DATA_HOME です——ベアオフのデータベース を参照してください。
レート制限器自身のバケットテーブルにもハードキャップ(テナント10,000件まで)があります。それを超えると、テーブルを無制限に増やす代わりに、新しいテナントごとに最も長く使われていないバケットを追い出します。これは、非アクティブなバケットの定期的な掃除の合間に、クライアントが X-Tenant-ID の異なる値を、意図的かどうかに関わらず大量に送ってくる場合に役立ちます。
blunderdb serve は、予期しない位置引数をすべて拒否するようになりました(すでに素のバイナリに切り詰められた ENTRYPOINT が通す、先頭の serve 一語だけは例外です)。このチェックがなければ、そのような引数の後に置かれたフラグは黙って無視されていました。イメージの ENTRYPOINT がすでに serve であるため自然にやってしまいがちな docker run image serve --addr :9090 は、一言の警告もなく: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 です。これが Docker イメージの HEALTHCHECK であり、スクリプトや systemd ユニットからも同様に使えます:
blunderdb healthcheck --addr 127.0.0.1:8080 && echo ready
業務面は POST /v1/<ファミリー>.<メソッド>という形をとります(たとえば/v1/positions.save、/v1/matches.get)。ファミリーはポジション、分析、マッチ、コメント、コレクション、トーナメント、Anki カード、フィルター、セッション、履歴(検索とコマンド)、検索、メタデータ、ライブラリ設定、統計、インポートとエクスポートを扱います。一覧のエンドポイントは NDJSON ストリーム(1 行に 1 つの 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 として返し、それを指定した If-None-Match には 304 で応答します。
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 はルートごとに 1 メソッドを持ち、go run ./cmd/openapi-gen がデーモンのルート表から生成します。手書きの面はルートが追加された日にずれ始め、利用者が気づくまで誰も気づきません。client.py は転送——セッション、テナントのヘッダ、エラーの封筒、NDJSON の読み取り——を担い、こちらは手書きです。API とともに変わるものは生成し、判断とともに変わるものは生成しません。
メソッド名は snake_case の ファミリ_操作 です。/v1/positions.loadByIds は positions_load_by_ids() になります。list や delete のように操作名を共有するファミリが複数あるため、裸の list() では衝突するので、ファミリを残しています。
events()は /v1/events をたどり、メッセージごとに 1 つの辞書を返します(操作の通知を受け取る: /v1/events を参照)。
失敗は APIError を送出し、デーモンの封筒をそのまま運びます。code(プログラムが分岐する対象)、message(人が読むもの)、HTTP ステータス、詳細です。
エンジンを Go のプログラムに組み込む
pkg/blunderdb/server.Bootstrap はストレージを開き、ポートを待ち受けずに呼び出し元のプロセス内でハンドラ一式を返します。信頼できる親——gammonGo——が、隣でデーモンを走らせたり自分自身と HTTP で話したりせずに局面ライブラリを使うための入口です。
前提ははっきり述べられています。親は信頼できるものです。確認すべきテナントも、検証すべきヘッダも、レート制限もありません。それらはネットワークに面するデーモンのものであり、理由は ADR-0005 が述べています。エンジンを組み込むプログラムは自らテナントを選び、自らの呼び出しに責任を持ちます。
大会の運営とイベント
ワークステーションで運営される大会と、それらをまとめるイベント(API では rencontre、ルートは /v1/rencontres.*)は、ワークステーションと同じコードで、呼び出し元のテナントの下で API から読み取れます。読み取りは常に提供されます。操作(結果の入力、組み合わせ、イベントの作成)は 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のあと、{"id": N}を指定してrencontres.getとrencontres.pageHtmlを呼びます。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 の有効期間は最長で 1 分です。したがって、再読み込みするクライアントは、期限や中断が 1 分以内に過ぎるのを確認できます。
これらのルートは POST です。このメソッドについて、RFC 9110 (§13.1.2) は、一致した If-None-Match に 412 で応答すると定めています。それでもデーモンは 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 の他のルートと同様、これらのルートは誰も認証しません。プロキシ(認証プロキシの背後への配置)の背後では、tenant の /v1/directions. プレフィックスに到達できる人は誰でも、プレイヤー名を含むそのトーナメントを読み取れます。これらの読み取りを特定のユーザーに限定するプロキシは、このプレフィックスと /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。プロパティを持つテーブルごとに1項目で、番号、名前、会場、予約済み、割り当て先)、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 件です。再起動すると忘れられ、別のインスタンスはそれを知りません。
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)です。テナントの確定した操作ごとに 1 つのメッセージが、データベースへの書き込み後に発行され、拒否または取り消された操作では発行されません。メッセージは状態ではなく、何が変わったかとその新しいバージョンを伝えます。クライアントは表示している内容を 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 が必要で、購読者は自分のテナントのメッセージだけを受け取ります。1 つのテナントが同時に開けるストリームは最大 16 本で、それを超えると 429 を返します。ワークステーションも同じサービスを使いますが、バスは接続しません。その操作は通知されません。
tournament、rencontre、transcription パラメータ(カンマ区切り、または繰り返した識別子)で購読を絞り込みます。メッセージはそのいずれかを指していれば通過します。イベント内の大会は、そのイベントのメッセージを受け取ります。未知のパラメータや無効な識別子は 400 を返します。
curl -N http://127.0.0.1:8080/v1/events?rencontre=2 -H 'X-Tenant-ID: 1'
履歴はありません。 デーモンはメッセージを一切保持しません。すべてのストリームは id 付きの event: resync で始まります。クライアントは接続前や 2 つの接続の間に操作を取りこぼした可能性があるため、表示している内容をすべて読み直します。理由は、リクエストに Last-Event-ID がある場合は reconnected、ない場合は subscribed です。64 件のキューが満杯になった遅すぎる購読者は、同じ resync の後に切断されます。操作を遅らせることはありません。ストリームは 3 秒の再接続待ち時間を通知します。
プロキシ経由。 プロキシが無通信のストリームを切断しないように、25 秒ごとに : ping コメントが送られます。X-Accel-Buffering: no は nginx にバッファリングしないよう指示します。ストリームは圧縮されず、通常のリクエストのタイムアウトの対象外で、レート制限では 1 件のリクエストとしてのみ数えられます。デーモンを停止するとすべてのストリームが閉じられ、停止中に要求された購読は 503 を受け取ります。
複数のインスタンス。 SQLite では、データベースを保持するインスタンスは 1 つだけなので、メモリ内のバスで十分です。PostgreSQL では、--direction または --transcription が有効になると、各インスタンスは LISTEN/NOTIFY を使い、チャネル blunderdb_events で自分の操作を他のインスタンスに中継します。あるインスタンスに接続した購読者は、別のインスタンスで確定した操作や、同じデータベースに対して call で実行された操作を受け取ります。テナントは通知に含まれて運ばれ、通知を受け取ったインスタンスはそのテナントの購読者にのみ配信します。各インスタンスは追加で 2 本の接続を開きます(待ち受け用は application_name が blunderdb-events-…、送信用は blunderdb-notify-…)。起動時に待ち受けできないインスタンスは起動を拒否します。call は待ち受けずに通知だけを送り、通知を送れない場合でも要求には応答します。
接続を許可された任意のロールがこのチャネルに送信でき、--rls でも同様です。受信した通知は、そのテナントが有効で種類が既知の場合にのみ信用され、それ以外はログに記録されて無視されます。偽造された通知が起こせるのは、せいぜいテナントの購読者にデータを再読み込みさせることです。
通知は、ローカルのメッセージと同様に、データベースへの書き込みの後に送られます。
resyncなしで残る損失は 2 つあります。書き込みと通知の間に強制終了されたインスタンスと、キューに残ったものを 2 秒以内に送信できない停止です。操作は確定していますが、他のインスタンスで既に開いているストリームがそれを知るのは、クライアントが再接続したときです。切断された待ち受け接続は、250 ms から 30 秒まで待ち時間を増やしながら再確立されます。切断中に他のインスタンスで行われた操作は失われます。復旧時には、インスタンスの各購読者が理由
missedのresyncを受け取ります。PostgreSQL に対して長すぎる通知(8 000 バイト)や、インスタンスが送信できなかった通知は、該当するテナントについて、同じresyncとして他のインスタンスに届きます。ストリームの
idは各インスタンス固有です。ロードバランサーが別のインスタンスに振り分けたクライアントは、それらを利用できません。あらゆるストリームの冒頭で送られるresyncにより、クライアントは表示中の内容を読み直します。
ベアオフのデータベース
デーモンは起動時に既定の 2 つの表を背後で計算します(キューブの判定用の TS-06-06、EPC 用の OS-06)。1 コアで約 6 秒、一度だけ、自身のデータディレクトリ——$XDG_DATA_HOME/blunderdb、なければ ~/.local/share/blunderdb——の中で行われます。ダウンロードも実行ファイルへの埋め込みもありません(ADR-0027)。そのフォルダーが読み取り専用なら、表はプロセスの生存中メモリに保持されます。サービスは起動し、再起動のたびに計算の代金を払うだけです。
より広い領域は起動時には計算されません。TS-06-11 は 1.2 GB あり数分かかります。サービスが独りで決めることではありません。デーモンが読むボリュームの中で、コマンドラインを使って作るのは運用者の仕事です。
# 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 ブロックの 1 行で済み、Caddy ならサイトの 2 行で済みます:
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、またはヘッダーなし — それ以外の型の本文は、わかりにくい JSON 解析エラーで失敗する代わりに 400 invalid で拒否される)。既知のメソッドを誤った HTTP 動詞で呼び出すと 405 が返り、Allow ヘッダーが受け付ける唯一の動詞を示す。limit を受け取る一覧系メソッドは、1 ページあたり 1000 行を超える値を、無制限に受け入れる代わりに 400 invalid で拒否する。
一覧を返す系統はいずれも limit と offset を受け取ります。positions.list、positions.listIds、matches.list、search.find、anki.reviewLog、comments.listAll、tournaments.list、そして collections.positions です。どちらも既定は 0 で、これは以前と同じ「すべて」を意味します。暗黙の上限はありません。ストリームはメモリに保持されないので、無制限の一覧は時間と帯域を使いますがデーモンの安定を損なうことはありません。一方で既定の上限をひそかに設ければ、切り詰められた一覧を完全なものとして読ませてしまいます。この 2 つの引数が与えるのは、望む者にページ送りができるという一点です。
各 TCP 接続はリクエストごとに読み書きの時間が制限される — 通常の呼び出しには余裕のある予算、ストリーミング系のルート(NDJSON 一覧、インポート/エクスポート、gammonNet の追いつきスイープ)にはさらに大きな予算が与えられる — そして同時に開ける接続数には上限があり、これを超えると新たな接続は既存のいずれかが解放されるのを待つ。すべての接続が無条件に専用の実行スレッドを得るわけではない。正常なシャットダウン(SIGINT/SIGTERM)は、まず進行中のすべてのインポートと gammonNet の追いつきスイープをキャンセルする — それぞれは接続を説明なく切断される代わりに、最後に {"event":"cancelled"} イベントで応答する — その後、通常の猶予期間内にサーバーを閉じる。アップロードされたインポートの一時ファイルは、元の拡張子のうちデーモンが認識するもの(.xg、.xgp、.sgf、.mat、.bgf、.ogxm、.txt、.db、.dbx)だけを保持する。また、同時に実行中のすべてのインポート — テナントを問わず — はディスクに退避されるバイト数について共通のグローバルな上限を共有し、これを超えると新しいインポートは、$TMPDIR の使用量を無制限に増やす代わりに(too many requests)で拒否される。
/v1/imports.jsonは blunderDB の JSON エクスポートを空きを埋める形で読み込みます。含まれる分析はまだ分析のないポジションにのみ書き込まれ、既存の分析を置き換えることはなく、双方のロールアウトは保持されます。
search ファミリーは、同じ検索に三つの入口を用意しています。search.find はフィルターのオブジェクト全体をフィールド単位で受け取ります。search.query はアプリケーションのコマンドバーの言語で書かれたクエリ(s cube p>30 E>50。コマンド一覧 を参照)を受け取り、同じポジションをストリームします。ネットワーク越しに、明確なフィールドを持たないフィルター — 手のパターン、コメント本文、プレイヤー、日付、除外するダイス、ゾーン、ブロット — に届く唯一の方法です。search.parse は何も検索しません。クエリが何を意味するかを答えます。すなわち、それが表すフィルター、その正規形(意味の同じ二つのクエリは同じ正規形を持ち、保存した検索を比較可能にします)、そして診断です。
どの規則も認識しないトークンを含むクエリは、検索を黙って絞り込みながら実行されるのではなく、拒否されます(400 invalid。トークン名を明示します)。理解はされるがここでは効果を持たないトークン — x は除外構造を有効にしますが、その構造はテキストではなく盤面です — は X-BlunderDB-Query-Diagnostics ヘッダーで運ばれ、本体は既存のすべてのクライアントにとってポジションの NDJSON のままです。
positions ファミリーの2つのメソッドは、ポジションを保存せずにデコードします。positions.fromXGID は XGID 文字列からポジションを再構築し、positions.fromXGP は単一ポジションファイル .xgp から再構築します。
POST /v1/exports.sqlite は現在のテナント全体——ポジション、コレクション、マッチ、トーナメント、分析、コメント、打たれた着手、フィルターライブラリ、Anki デッキ——を、デスクトップ機でそのまま開ける SQLite ファイルにエクスポートします。リクエストの JSON 本文は省略可能です。watermarkOrigin / watermarkNote は、デーモン自身の識別情報(--identity-dir)で署名した透かしを付けます——これらのフィールドがなければエクスポートに透かしは付きません。識別情報が設定されていない状態でこれらを指定すると、invalid コードで失敗します。collectionIds は、エクスポートをそれらのコレクションとそのポジション(分析、コメント、打たれた着手を含む)に限定し、フィルターライブラリと Anki デッキは含めません。
テナント間でコレクションを共有するには、あるテナントから別のテナントを読むのではなく、必ずクライアントを経由します。渡す側のテナントが collectionIds を付けて(受け取る側がファイルの出どころを分かるよう、透かしも付けて)exports.sqlite を呼び出し、受け取る側のテナントがそのファイルを 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)を拡張する6つの新しいメソッドが加わります: anki.reviewLog(各復習の記録 — 評価と FSRS の結果 — 保持率の統計と正確な履歴のため)、anki.forecast(今後数日間に期限が来るカード数の予測、期限超過のカードを含む)、anki.suspendCard / anki.buryCard / anki.removeCard(カードを復習キューから一時的または完全に取り除く)、および anki.retention(デッキの復習で測定された成功率を、その所有者が設定した目標と照らし合わせたもの)。
注釈
anki.retention は anki.optimizeParams を置き換えるものです。後者は目標を観測された割合に近づけ、それを書き込むことができました。保持率の目標は負荷と品質のトレードオフに関する選択であり、測定された割合はその結果にすぎません。一方をもう一方に従属させることこそ、FSRS の作者たちが退けている仕組みです。このメソッドは測定するだけで、決して書き込みません。
stats ファミリーは stats.playerTable を提供します。これは、渡されたフィルタが残した対戦について、プレイヤーごとに1行の統計(対戦数、勝敗、集計された決定数、PR の全体/チェッカー/キューブ、Snowie Error Rate、エラー数、ブランダー数、運)を返します。グラフィカルインターフェースと同様に、この表がフィルタから参照するのは期間、トーナメント、マッチ長だけです。プレイヤーの選択と決定の種別は無視されます。表はすべてのプレイヤーを対象とし、チェッカーとキューブはすでに別々の列に分けているからです。luck_known フィールドはそのプレイヤーの運が測定されたかどうかを示します。false のときに luck_rate_mp を読んではいけません。運が不明であることは運がゼロであることではありません。
stats の各メソッドに渡すフィルタは、PlayerName に加えて PlayerAliases フィールドを受け付けます。これは同じ人物が用いた別の表記のことです。プレイヤー名は各ファイルに手で入力されるため、同一人物が複数の表記で現れるのは珍しくなく、そのうち1つだけを見るフィルタは、何も異常に見えないまま一部の対戦だけで計算してしまいます。このフィールドは純粋に加算的で、いずれかの名前に該当する決定がすべて残ります。もう一つの答えはデータベース上で名前を統合すること(MergePlayers)ですが、これは他人から受け取ったものではないデータベースに限るべきです。全員の対戦を書き換えてしまうからです。
2つのメソッドがグラフィカルインターフェースとの対等性を完成させます。stats.tournamentBadges はデータベース内の各トーナメントについて、そのカードに表示される指標(基準プレイヤーの PR)を返します。matches.findByHash は重複検出用の2つのフィンガープリントから、その対戦がすでに存在するかどうかを示します。重複したインポートを、始める前に避けるのに十分です。
ゲームの winner フィールドは、matches.createGame が受け取り matches.games が返しますが、コーディングは一つだけです。プレイヤー 1 は 1、プレイヤー 2 は -1、未完了のゲームは 0 です。gnubg の意味 (プレイヤー 1 が 0、プレイヤー 2 が 1) で 0、1、-1 を送り続けるクライアントは、逆の勝者を記録します。
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(started イベントで受け取った job_id を添えて)は進行中の補完分析をキャンセルし、補完分析にも再解析(下記)にも区別なく使えます。これはインポート後の自動起動や GUI の明示的な操作、そして blunderdb analyze サブコマンド(コマンドラインインターフェース(CLI) を参照)と同じ操作です——三つの形、一つのロジック。
gammonnet.sweepStale は、穴埋めではなく再解析のための analyzeMissing の対になるものです:その解析が完全に gammonNet 自身のものでありながら古くなっているポジション——現在実行中のものより古いエンジンバージョン、または ply と異なる深度——は、要求された深度で再評価されます。この陳腐化の述語は、GUI の同じロットと blunderdb analyze --stale とで共有されています(三つのモードの間でロジックが重複することはありません)。XG、GNUbg、BGBlitz のいずれかの解析を持つポジションは、その gammonNet の内容にかかわらず決して触れられません——ADR-0013 の保護は無条件のままです。analyzeMissing と同じ NDJSON 形式であり、二つのルートそれぞれの最終イベントは evaluated/refused/failed の内訳を持ちます:gammonNet が評価を拒否するポジション(そのテーブルの範囲を超えたマッチスコア、モデルが拒否するダブリングの決定など)は refused として数えられ、failed としては数えられません——本当に失敗したポジションとは異なり、次のパスで無駄に再試行されることは決してありません。
rollout.position はライブラリの局面(positionId)をロールアウトでプレイし、候補ごとにエクイティ、その 95 % 区間、JSD を返します。rollout は設定(fast、standard、standard,ply=1 など)を渡し、store は完了したロールアウトを、局面が持つ解析の隣に二つ目の解析として記録します(それを置き換えることはありません)。単独の局面(XGID)は拒否されます。デーモンはライブラリを対象に動作するためです。rollout.filter は blunderdb analyze --rollout のバッチ形式です。query(検索の言語)が選んだ局面のうち、同じ設定のロールアウトをまだ持たないものが一つずつプレイされ、進行に合わせて記録され、NDJSON ストリーム(started、ゲームの各シリーズ後の progress、続いて done、cancelled または quota_exceeded)として返されます。rollout.filter.cancel は job_id でこれをキャンセルします。テナントが一度に実行できるバッチは一つだけで、ロールアウトか gammonNet のいずれかです。rollout.list は局面に記録されたロールアウトを読み取ります。
相関付けと業務メトリクス
すべての要求に相関識別子が付きます。クライアント(またはリバースプロキシ)が 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 ファイルのサイズ、PostgreSQL ではpg_database_size(下の接続プールのゲージと同じく、テナント別ではなくデータベース全体)。最初の測定が公開されるまでは現れません。
プロセスのメモリまたは CPU のプロファイルは、--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 バックエンドにはテナントの列がありません。すべてのデータが同じテーブルに、仕切りなく入っています。そのためこのバックエンドでは、デーモンは 1 以外の X-Tenant-ID をすべて拒みます。ほかを受け入れることは、そうでないと称するヘッダーの裏で、全員に全員の行を返すことになるからです。本当に複数のテナントを持つ配備には 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 で、一覧にないテナントはここでは拒否されます。ID は所属するテナント内でのみ一意なので、各結果には元のテナント("tenant": "2")が付きます。局面にはさらに Zobrist ハッシュ("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 を超えるテナント)や複数行で送られたヘッダーは、ルートを問わずリクエスト全体を拒否します。テナントが 1 つしかない SQLite では、リストには 1 しか含められないため、ヘッダーは何も広げません。これらのルートはサーバー固有であり、デスクトップアプリと call にはテナントが 1 つしかありません。
across.* リクエストは最大 64 回のストレージ読み取りを要しますが、レート制限(--rate-limit-rps)は X-Tenant-ID に対して 1 回だけ数えます。データベースとこの制限をそれに合わせて見積もるか、プロキシにリストの上限を設けさせてください。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 へ移行する を参照)。
テナントをまたぐ読み取りは存在しません。仕切りは完全です。あるテナントが保存したものは、どのルートからも他のテナントには見えず、テナントを引数に取る呼び出しもありません。各リクエストが知るのは、プロキシがそれに付けたテナントだけです。したがって、生徒のマッチを見たいコーチには、いずれも明示的な二つの道があります。
生徒のテナントに紐づく追加のアカウントを、プロキシの中に開設する。セッションがどのテナントを見るかを決めるのは、デーモンではなく常にプロキシの対応表です。
エクスポートを頼む——
exports.sqliteまたはデスクトップアプリケーションのエクスポート画面が生成した.dbを受け取り、自分のワークステーションで開きます。
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)で設定します。イメージは 30 秒ごとに blunderdb healthcheck を実行する 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 だけです。他の二つのサービスは internal: true と宣言された Docker ネットワーク上で稼働し、このネットワークはホストにもインターネットにも経路を持ちません。後から変更で 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 は脅威モデルと、決してしてはならないことを述べています。
Caddyfile の HTTP Basic 認証はデモンストレーションであり、本番環境向けの推奨ではありません。実際の ID プロバイダー(OIDC、企業の SSO など)への forward_auth に置き換えることができ、そちらが認証を行ったうえで、ファイルの同じ箇所に ID を引き渡します。対応表にある二つのパスワードと二つのアカウントも、同様に置き換えてください。
deploy/Caddyfile.oidc は OpenID Connect のレシピです。Caddy が oauth2-proxy に問い合わせ( /oauth2/auth への forward_auth )、oauth2-proxy は 202 を返して X-Auth-Request-Email にログイン中アカウントのアドレスを載せるか、プロバイダーのログインページへ誘導します。 map ブロックがこのアドレスをテナントの整数に対応付け、注入の前には同じ header_up X-Tenant-ID "" のガードが置かれます。Compose ファイルに追加する oauth2-proxy サービスは、ファイルの先頭にあります。
テナントごとのクォータ
共有インスタンスでは、 --quota-positions 、 --quota-analysis-seconds 、 --quota-imports で各テナントが使える量を制限します(オプションがなければ何も制限されません)。計算時間には、テナントが要求したエンジンの計算がすべて計上されます: gammonnet.analyzeMissing 、 gammonnet.sweepStale 、 gammonnet.compare 、 gammonnet.cubeMatrix 、 gammonnet.evaluate 、 rollout.position 、 rollout.filter 。計算時間は CPU 秒で数えられ、経過時間に同時に行われた探索の数を掛けたものです。そのため、すべてのコアに分散した計算は、同じ作業を局面ごとに行った場合と同じだけ消費します。その日の時間を使い切ると、これらのルートはコード quota_exceeded とともに 429 を返します。実行中のスイープや rollout.filter は記録済みの結果を保持し、 done ではなくイベント quota_exceeded で終了します。中断された rollout.position は 429 を返し、何も記録しません。中断された比較は、集約済みの結果を quotaExceeded: true とともに返し、 gathered に調べる予定だった局面数を返します。カウントは UTC の午前 0 時にゼロに戻り、メモリ上にのみ存在するため、デーモンを再起動するとゼロに戻ります。局面数のクォータはインポートの開始時に確認され、インポートは途中で中断されません。そのため、テナントは実行中のインポートが追加する分だけ超過することがあります。 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 の blunderdb-serve サービスにある三行の build: を image: の一行に置き換え、--build なしで docker compose up -d を実行します。
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 は SQLite ファイルではなく PostgreSQL にデータを保存できます。バックエンドは --backend postgres と接続文字列 --dsn で選択します。スキーマは起動時に自動的に作成・マイグレーションされます。
データはテナント(利用者)ごとに分離されています。各リクエストには自身のテナントの識別子(ヘッダー X-Tenant-ID、1 や 42 のような正の 10 進整数)が付与され、これにより複数のユーザーが他者のデータを見ることなく同じインスタンスを共有できます。そのような整数でない識別子——alice や default のような名前、0、007——は 400 invalid で拒否されます。アカウントをその整数に対応付けるのはリバースプロキシであり、デーモンは決して推測しません。
Row-Level Security
--rls オプションは、補完的に PostgreSQL のRow-Level Securityを有効にします。デーモンは起動のたびに、tenant_id を持つ各テーブルに tenant_isolation ポリシーを設置します。このポリシーは、セッションパラメーター current_setting('app.tenant_id') が指すテナントの行だけを通し、テーブルの所有者に対してもそれを強制します(FORCE ROW LEVEL SECURITY)。このパラメーターは接続がプールから出るときに設定され、戻るときにリセットされます。テナントのない接続はどの行も見えず、どの行も挿入できません。これは任意の多層防御であり、既定では無効です。アプリケーションコードによるテナントの絞り込みは、どちらの場合も残ります。
接続に使うロールは通常のものでなければなりません。スーパーユーザーでも
BYPASSRLSでもいけません。PostgreSQL はこの二つには何も言わずすべてのポリシーを通過させてしまい、分離はアプリケーションコードだけのものに戻ります。一方で、この同じロールがテーブルを所有している必要があります。ALTER TABLEとCREATE POLICYを実行するのはこのロールだからです。すでにデータの入ったデータベースでも、移行すべきものは何もありません。ポリシーの設置は冪等な DDL であり、スキーマ移行のあと起動のたびに実行し直されます。データは移動されず、行が書き直されることもありません。
--rlsを有効にするのも外すのも、再起動するだけのことです。コストは測定されています。ポジションの読み取りで、なしなら 101,8 µs、ありなら 177,0 µs、すなわち+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 — これを超えると、到達不能なデータベースは OS の 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 データベースを、選択したテナント — このユーザーのためにリバースプロキシが X-Tenant-ID で送る整数 — の下で PostgreSQL バックエンドにコピーします。これはデスクトップのライブラリをサーバー環境に「アップロード」する手段です。
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 データベース( |
|
– |
宛先の PostgreSQL DSN( |
|
– |
移行先のテナント。正の10進整数( |
|
– |
何も書き込まずに、コピーされる内容を数える |
|
|
|
注釈
アプリケーションの状態は(まだ)移行されません: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 ファイル( |
|
|
|
|
|
バックエンドの接続文字列 |
|
|
テナント。正の10進整数( |
|
|
JSON 形式のリクエストボディ |
|
– |
ファイルからリクエストボディを読み込む |
|
– |
すべての |
|
– |
call はフラグなしで転記操作を提供します。CLI と同様にローカルファイルを対象に動作します。呼び出しごとに新しいプロセスとなり、したがって独自のセッションを持つため、sessionId は省略でき、呼び出しをまたいだ取り消しはできません。
JSON レスポンス(または *.list エンドポイントの場合は NDJSON ストリーム)は標準出力に書き込まれます。エラーが発生した場合、プロセスは非ゼロのコードで終了し、解析可能な状態を保つために(例えば jq で)、{"error":{…}} というエンベロープが標準出力に出力されます。Direction-Versionヘッダーを持つ応答は、それを標準エラー出力に表示します。これが次の操作で--if-matchに渡す値です。callはローカルで動作するため、CLI と同様にフラグなしでディレクションの操作を提供します。
AI アシスタント向けツール(MCP)
blunderDB は言語モデルを内蔵していません。すでにお使いのアシスタント(Claude Code、Claude Desktop、ローカルクライアント)に、Model Context Protocol を通じてツールを提供します。アシスタントが検索・読み取り・説明を行い、blunderDB は自身の数値で答えます。
ツールは /v1 や call と同じハンドラーを通ります。
ツール |
返すもの |
|---|---|
|
件数、マッチの期間、スキーマのバージョン、よく登場するプレイヤー |
|
コマンドバーの文法による検索で見つかったポジションとその正規形(文法はツールの説明に記載) |
|
指定した語を含むコメント、保存済みの検索 |
|
ポジション、その解析(ベストムーブまたはキューブ)、指した手、コメント |
|
ミスのテーマ、ミリポイントでのコスト、最善の判断 |
|
近いポジション、XGID の読み取り、合法手、レースの EPC |
|
プレイヤー、全体の PR、チェッカー、キューブ、フェーズ別、繰り返される誤り、実際の PR に対するクイズの PR と Anki の定着率 |
|
マッチ、マッチの詳細、トーナメント |
|
コレクションとそのポジション、学習デッキ |
|
答えを伏せたポジションを出題し、返された答えを採点する |
|
テキストで与えられたポジションの gammonNet 評価(保存はしません):最善手またはキューブの判断 |
|
復習デッキの次の期限が来たカード |
|
マッチの文字起こし、文字起こしの詳細、その |
|
運営済みの大会、大会の順位表、シーズンランキング |
|
ライブラリの局面のロールアウト:候補ごとのエクイティ、95 % 区間、JSD |
書き込むツールは五つだけです——save_position、comment_position、create_collection、add_to_collection、そして anki_next が出したカードを採点する anki_review——で、要求があったときだけ提供されます。ローカルでは --write、デーモンでは --mcp-write です。ほかのツールはすべて読み取りのみですが、書き込みが提供されているときは rollout に引数 store が加わり、ポジションの分析の隣にロールアウトを記録します。どのツールも削除はしません。
ローカルでは、アシスタントがファイルに対して blunderdb mcp を起動します(コマンドラインインターフェース(CLI) を参照)。Claude Code の場合:
claude mcp add blunderdb -- blunderdb mcp --db /chemin/vers/base.db
デーモンでは、同じツールが POST /mcp で HTTP 応答します(streamable HTTP トランスポート、セッションなし)。/v1 と同様に /mcp は X-Tenant-ID を必要とし、各ツールはそのテナントの中で動作します。pkg/blunderdb/server を組み込むプログラムも同様に提供できます。デーモンは誰も認証しません(ADR-0005)。/mcp は /v1 と同様にプロキシで保護し、--mcp-write も --direction と同様にそこで決めます。ツールが行う各 /v1 呼び出しはデーモンのチェーン全体をもう一度通ります。ログに記録され、メトリクスに数えられ、テナントのレート制限に計上されます。それを運ぶ /mcp リクエストとは別にです。したがってツール呼び出し 1 回は複数のリクエスト分のコストになり、免除されるものはありません。
call と同じく、blunderdb mcp は --write なしでも、開くときに古いデータベースのスキーマを移行します。