検索/データベースのインデックス拡張を、テストを通し、クラッシュに強く作る

データベースのインデックス拡張を、テストを通し、クラッシュに強く作る

データベースのインデックス拡張を、AIに作らせたり直させたりする時に使う。テストの失敗をすべて本物として扱い、コミット前の決まった順番を守り、クラッシュからの復旧とスタンバイが動くように、ページの変更をすべてログに残す。

このAIルールを使うには
## 入れ直したらサーバーを再起動し、先にストレージの仕様書を読む あなたは、データベース自身のリレーションファイル内に置かれた階層型ストレージを基盤とし、インデックスアクセスメソッドを実装するデータベース拡張の開発に携わる。この作業は耐久性・リカバリ・並行性のすぐ近くに位置するため、あらゆる判断において、正しさが提供の速さより優先される。 常に念頭に置くべき運用上の前提は次のとおり。 - 拡張はサーバー起動時にロードされる。拡張を作成する前に、サーバーのプリロード設定へ拡張を追加してから再起動する必要があり、さらに開発中にバイナリを再インストールした後にも再起動が必要となる。古いままロードされたバイナリは誤解を招くテスト結果を生む。ビルドしたばかりのソースと挙動が食い違う場合は、まず再起動の漏れを疑うこと。 - プロジェクトが許す限り、拡張のオブジェクトはデフォルトの public 名前空間ではなく専用スキーマへインストールし、名前空間を整理された状態に保つ。 - 問題を完全に解決できる最小の変更を選ぶ。タスクが求めていない周辺コードのリファクタリングは行わない。 - リポジトリ内の設計仕様書には拘束力がある。書き込み経路・読み取り経路・スピル経路に手を入れる前にストレージ仕様書を読み、設計そのものが動く場合は同じ変更の中で仕様書も更新する。 ## テストの失敗を「無関係」で済ませず、調べて直す main ブランチは常にグリーンを維持する。すべてのテスト失敗に意味がある。 - 失敗を「以前から存在するもの」「今回の変更とは無関係」と片づけない。実際には、ほぼ確実にそうではない。 - 自分の変更と本当に無関係だと考えるなら、それを検証する。main ブランチをチェックアウトし、そこで同じテストを実行すること。 - main で再現したからといって、その失敗が免責されるわけではない。依然として調査して修正すべきものであり、棚上げしたり「既知」と注記したりして済ませてはならない。 - レッドの実行結果をグリーンに変えるためだけに、テストをスキップ扱いにする・アサーションを緩める・期待出力ファイルを書き換える、といったことは決してしない。期待値の変更は、新しい出力がなぜ正しいのかを正確に説明できるようになってから初めて行う。 - 失敗は正直に、そして早い段階で報告する。自分が引き起こした失敗も含む。レッドの実行結果を隠すことは、バグそのものより悪い。 ## コミットの前に、リグレッションテストを流して差分ファイルまで見る 変更をコミットする前に、以下のすべての手順を完了させる。変更が「些細に見える」という理由で順序を入れ替えたり手順を省いたりしない。 1. 拡張をコンパイルする。 2. インストール済みビルドに対して宣言的なリグレッションスイートを実行する。 3. 合否のサマリーだけでなく、実行によって生成されたリグレッション差分ファイルを確認する。スイートは、自分が持ち込んだ差分を覆い隠したまま成功を報告することがある。 4. ユーザーから見えるメッセージを変更した場合は、対応する期待出力ファイルを一致するよう更新し、なぜメッセージが変わったのかをコミットに記載する。 5. フォーマットチェックを実行する。 6. 期待値ファイルを更新した後はリグレッションスイートを再実行し、最終的に記録される実行結果がコミットされるツリーを反映したものになるようにする。 日常的なスイートに加えて、ロック・書き込み経路・リカバリ・セグメントのライフサイクルに触れる変更では、並行性、クラッシュリカバリ、マルチバックエンド、ストレスの各層を必ず実行する。どの層を実行し、どの層を実行しなかったのかを明確に述べること。 ## 変更は、それを直せる一番下のコードの層に置く ソースは層で構成されており、あらゆる変更は、それを完全に解決できる最も低い層に属する。ストレージの詳細をデータベースインターフェース層へ漏らしてはならず、修正の近道として抽象化を飛び越えて下層へ手を伸ばしてもならない。 - 第1層、データベースインターフェース: アクセスメソッドハンドラ、インデックス構築・スキャン・バキュームの各エントリポイント、カスタムデータ型、プランナフックとコスト見積もり。 - 第2層、インデックス調整: ランキングとスコアリング、top-k 最適化、インデックス状態のライフサイクル、レジストリ、メタページ処理、ポスティングソースに対する抽象化。 - 第3層、ストレージ: レベルゼロの書き込みバッファ、イミュータブルなセグメント、マージ、圧縮。 - 横断的要素: デバッグおよびダンプ用ユーティリティ、モジュール初期化。 変更が複数の層にまたがる場合は、層の境界をまたぐことを変更の説明で明示的に記述する。インターフェースを黙って広げる修正は設計変更であり、設計変更としてレビューされなければならない。 ## ストレージを変える前に、書き込みバッファとセグメントの仕組みをつかむ このインデックスはログ構造化された階層型ストレージ方式に従う。いずれかの部分を変更する前に、このモデルを理解すること。 - レベルゼロは書き込みバッファである。インデックスリレーション自体の内部に格納され、メタページから辿れる、追記指向のページ連鎖として構成される。書き込みは、行識別子・長さ・パックされたペイロードを持つレコードを末尾ページへ追記する。すべての変更は標準のバッファロックの下で行われ、先行書き込みログに記録される。 - セグメントはイミュータブルでブロック構造を持つオンディスク構造であり、スキップエントリを備えるため、スキャンはすべてのポスティングを辿らずに目的のブロックへシークできる。 - 書き込みバッファから新しいセグメントへのスピルはしきい値によって駆動され、あるレベルに十分な数のセグメントが溜まると、そのレベル単位でコンパクションが行われる。 - top-k クエリはブロック単位の上限値を用い、結果に寄与し得ないブロックを完全にスキップする。残りの上限値が現在の結果集合を上回れなくなった時点で早期終了する。 クエリの読み取りは速度のためにインメモリキャッシュが担うことがあるが、耐久性のあるオンディスク連鎖が唯一の正本であり続ける。読み取り専用のスタンバイは、キャッシュではなく常に耐久性のある連鎖を読む。 ## ページの変更は、独自の仕組みを作らず標準の先行書き込みログで記録する 拡張独自のリソースマネージャを登録するのではなく、データベースの汎用的な先行書き込みログ機構を通じてすべてのページ変更を記録する。 この制約が存在する理由: 汎用ログを使えば、データベース標準のリプレイ機構が、クラッシュリカバリ時およびストリーミングスタンバイ上で、書き込みバッファ連鎖のページ・セグメントのページ・メタページを含むすべてのページを再構築する。これこそが、単一ページの redo ヘルパーをはじめ、拡張の共有ライブラリをロードできないあらゆるリプレイコンテキストを正しく動作させる根拠となる。 守るべき帰結は次のとおり。 - 独自のリソースマネージャの導入は、実装の詳細ではなく重大な設計判断である。それは、拡張をロードできないすべてのリプレイコンテキストを壊す。無関係な変更の一部として暗黙に導入してはならず、独立した提案として起票し、個別にレビューを受けること。 - 新しいオンディスク構造は、記録されたレコードのみから標準のリプレイによって再構築できなければならない。拡張のコードによってしか再構築できない構造があるなら、その設計は誤りである。 - 使い捨てだと考えている構造であっても、バッファマネージャを迂回したり、記録された変更の外でページを書き込んだりしてはならない。 ## マージで空いたページは、スタンバイの読み取りが終わるまで戻さない マージは、置き換えられた元のページを直ちに空き領域マップへ返してはならない。これはプライマリ上では安全だが、ホットスタンバイ上で実行中の読み取りクエリにとっては安全ではない。独自のリソースマネージャがない以上、リプレイ中にリカバリ競合を解決する仕組みが存在せず、スタンバイは実行中のクエリがまだ読んでいるページを再利用してしまうからである。 必要となるパターンは次のとおり。 - 置き換えられたページは、メタページから辿れる、ログに記録されたトゥームストーン連鎖に退避し、マージのトランザクション識別子を刻む。 - 退避したページを空き領域マップへ返すのは、後続のバキュームまたは次のマージが、その刻印が削除不可能な最古のトランザクション地平より前であると確認した後に限る。この地平が、スタンバイ安全な回収の境界となる。 - クエリを処理するホットスタンバイからのフィードバックを、そのデプロイメントで有効にすることを必須とする。これにより、スタンバイの最古のスナップショットが、退避ページの読み取りを終えるまでプライマリの地平を押しとどめる。 - 現在どれだけのページが退避されているかを報告する診断関数を提供し、運用者が滞留の増加を推測ではなく観測できるようにする。 この原則を一般化すること。あるコンポーネントが、リモートまたは遅延した読み手がまだ保持している可能性のある共有リソースを解放する場合は、観測可能な地平の背後まで解放を遅延させ、遅延中の集合を運用者が計測できる手段を用意する。 ## ランキングのスコアは絞り込みに使わず、並べ替えに使う ランキングスコアは順序付けのために存在するのであって、絞り込みのためではない。その数値は不透明であり、意味のあるしきい値を持たない。したがって、テスト・サンプル・ドキュメントにおいて手軽な絞り込み手段としてスコアを定数と比較することは、アンチパターンである。 ルール: - 「該当する」行を選ぶ目的で、ランキングスコアを数値と比較する絞り込み条件を書かない。 - ランキング演算子を含む絞り込み条件は、インデックススキャンではなくシーケンシャルスキャンによる単独スコアリングを引き起こす。インデックススキャンには順序付け句が必要である。 - 正しいパターンは、ランキング式で順序付けし、行数の上限を適用することである。 - 該当ドキュメント数を数えるには、スコアで絞り込むのではなく、順序付けしたサブクエリをラップしてその行数を数える。 - 例外: 単独スコアリング経路そのものを明示的な対象とするテスト、たとえばインデックススキャンの結果と単独スコアリングの結果を比較するテストでは、絞り込み条件の中で当該演算子を用いてよい。そうしたテストは、後から読む人が誤ってそのパターンを真似しないよう、意図をコメントに明記しなければならない。 一般化された教訓: API に意図された唯一の形があるなら、チームが提供するすべての例において誤った形が誤りだと目に見えるようにし、例外は意図を文書化した場合にのみ切り出す。 ## すべての設定を、安全な初期値と一緒に1つの表に書く すべての実行時調整パラメータは、名称・説明・デフォルト値とともに1つの表に文書化する。文書化されていない調整パラメータは欠陥として扱う。 - 通常のデプロイメントにとって安全で、挙動を変えないデフォルト値を選ぶ。しきい値系の設定ではゼロを「無効」の意味に、予算系の設定ではゼロを「無制限」の意味に用い、そのことを説明にも記す。 - 障害を注入したり失敗を強制したりする設定はテスト専用として存在するものであり、特権ユーザーに限定しなければならず、説明の中でテスト専用であると明示しなければならない。 - スコアリング詳細・最適化統計・キャッシュ結果に関するログ出力スイッチは、デフォルトでオフとする。これらは診断のための補助であって、通常運用のものではない。 - メモリ予算は単一の固定上限ではなく段階的に設ける。インデックス単位のソフト上限は当該インデックスを耐久性のあるフォールバック経路へ降格させ、グローバルなソフト上限は呼び出し元以外で最大の消費者を退避させ、グローバルなハード上限は新規の割り当てを直ちに拒否する。各段階はログ上で観測可能でなければならない。 - テキスト解析設定や、ランキングモデルの飽和パラメータおよび長さ正規化パラメータといったインデックス単位のオプションも、同じ厳密さで文書化する。必須のオプションは、暗黙のデフォルト値を与えるのではなく必須として明示する。 ## 不要になった文書の扱いと、次のマージまで統計が古くなる点を書き残す バキュームは、セグメントを作り直すのではなく、セグメントごとの生存ビットセット(1ドキュメントにつき1ビット)に死んだドキュメントを記録する。コストはコーパス全体ではなく、死んだドキュメントの数に比例する。死んだドキュメントはスコアリング時に除外され、次のセグメントマージで物理的に取り除かれる。 既知のトレードオフは、隠さずに文書化し続けなければならない。ドキュメントを死んだものとして記録しても、セグメントの総ドキュメント数・総長統計・語ごとのドキュメント頻度は更新されない。そのためランキングは、次のマージまたはコンパクションが補正するまで、わずかに古いコーパス統計を使い続ける。削除が少数であれば影響は無視できるが、大量削除は次のマージまでランキング品質に目に見える形で影響し得る。 これをテンプレートとして扱うこと。ある最適化が精度や鮮度をコストと引き換えにする場合は、保守担当者が見つけられる場所にそのトレードオフを書き残し、それが無視できなくなる条件を述べ、それを解消するイベントの名前を示す。文書化されない近似は、後になって原因不明のバグ報告となって現れる。 ## テストを段階ごとに分け、中身を調べる診断用の関数を用意する 明確に区別されたテストの層を維持し、それぞれが何を守っているのかを把握する。 - 宣言的なリグレッションテスト: コア機能、スコアリングの正しさ、ストレージとセグメント、クエリ最適化の経路、パーティションテーブルや継承テーブルといったテーブルのバリエーション、エッジケースを対象とし、参照出力はバージョン管理下に置く。 - スクリプト駆動のテスト: 複数セッションの並行性、クラッシュリカバリ、マルチバックエンドでのセグメント挙動、長時間のストレス。 継続的インテグレーションは、サポート対象のすべてのデータベースメジャーバージョン、サニタイザビルド、フォーマット検証、バージョンアップグレードテスト、リリースパッケージング、ベンチマーク、そしてメモリリーク検出を伴う夜間ストレス実行を対象とする。 診断機能は場当たり的な出力ではなく、第一級の関数として提供する。内部構造を、その語彙辞書・ポスティングリスト・ドキュメントエントリとともにダンプする、インデックス全体をファイルへダンプする、高水準の統計を要約する、スピルを強制していくつのエントリが移動したかを報告する、退避中のページ数を報告する、ページレイアウトを可視化する、といった機能である。 ベンチマークは再現可能に保つ。素早く傾向をつかむための小規模な古典的情報検索コレクション、規模を確認するための大規模な公開データセット、メモリと容量のストレススクリプト、そして結果が自己完結的に整合しているだけではないことを検証するための、確立された外部エンジンとの比較を用意する。 ## 整形はツールに任せ、pushの前にmainへリベースする - フォーマットは自動化する。ツリー全体を整形するコマンド、変更せずにフォーマットを検査するコマンド、差分を表示するコマンド、単一ファイルを整形するコマンドを用意し、それらを使う。ツールに逆らって手作業で整形しない。 - ホストとなるデータベースプロジェクトのコーディング規約とメモリ割り当てパターンに従い、独自のやり方を発明しない。行はプロジェクトで設定された幅で折り返す。 - 並行性に関する不変条件: すべての共有構造は適切なロックで保護され、インターン化のための構造はハッシュテーブルロックによって安全に保たれ、トランザクション分離はコミット経路とアボート経路の双方における規律あるクリーンアップによって維持される。 - プルリクエストのマージ後は、たとえ他の人がマージした場合でも、main ブランチが動いたかどうかを必ず確認する。作業中のブランチはフェッチして更新後の main へリベースし、コンフリクトの蓄積を避ける。 - プッシュ前には、そのブランチが main に追随していることを確認し、履歴の乖離によって小さな変更がコンフリクトだらけのマージに変わることを防ぐ。 - タグや成果物を即興で作るのではなく、リポジトリに文書化されたリリース手順に従う。