Closed8

100日チャレンジ day42(Raft を使った自作 NoSQL)

riddle_tecriddle_tec

昨日
https://zenn.dev/gin_nazo/scraps/2965534d23efbc


https://blog.framinal.life/entry/2025/04/14/154104

100日チャレンジに感化されたので、アレンジして自分でもやってみます。

やりたいこと

  • 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
  • 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
  • 生成aiをつかったバイブコーティングになれる
  • 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)

できたもの

https://github.com/lirlia/100day_challenge_backend/tree/main/day42_raft_nosql_simulator

riddle_tecriddle_tec

タイトル: Go言語とRaftライブラリを用いた、結果整合性NoSQLデータベースのCLIシミュレータ開発

目的:
NoSQLデータベースが、特に書き込みスケーラビリティ(の基礎となるリーダーへの集約とログ複製)および結果整合性(ローカルリードによる)をどのように実現しているか、その中核的な概念を理解・学習するためのCLIツールをGo言語で開発したい。
実際の分散システムではなく、ローカル環境でその動作をシミュレートすることを主眼とする。

コア機能要件:

  1. Raftクラスタシミュレーション:

    • hashicorp/raft (または同等のGo用Raftライブラリ) を使用する。
    • 1つのGoプログラム内で、複数のRaftノード(例: 3ノード)をgoroutineとして起動し、クラスタを形成する。
    • 各Raftノードは、自身のデータストア(ローカルファイルシステム上のJSONファイルなど)およびRaftログを持つ。
    • ノード間の通信はローカルTCP/IPまたはメモリ内キューなどでシミュレートする。
  2. データモデルと永続化:

    • テーブルの概念を持つ。テーブル作成時にはパーティションキーと、オプションでソートキーを指定できる(型は文字列、数値、ブールなど)。
    • テーブルメタデータ(スキーマ情報)はRaftを通じて合意され、各ノードのローカルストアに永続化される。
    • テーブルごとのアイテムデータも各ノードのローカルストア(例: data/<node_id>/<table_name>.json)にJSON形式で永続化される。
    • アイテムには、競合解決のための最終更新タイムスタンプ(Last Write Winsのため)を内部的に保持する。
  3. CLIインターフェース:

    • 以下の基本操作をサポートするCLIコマンドを実装する。
      • create-table --table-name <name> --partition-key <pk_name>:<pk_type> [--sort-key <sk_name>:<sk_type>]
      • delete-table --table-name <name>
      • put-item --table-name <name> --item '<json_object_string>'
      • get-item --table-name <name> --key '<json_object_string_for_key>' (パーティションキーと、あればソートキーを指定)
      • delete-item --table-name <name> --key '<json_object_string_for_key>'
      • query-items --table-name <name> --partition-key-value <value> (指定されたパーティションキーに一致するアイテム一覧を取得)
    • CLIは、デフォルトではクラスタ内のいずれかのノードにランダムにリクエストを送信する。
    • オプションで、操作対象のRaftノードを明示的に指定できる機能 (--target-node <node_id>) を設ける。
  4. 書き込みパス (Write Path):

    • CLIから書き込みリクエスト(put-item, delete-item, create-table, delete-table)を受け取ったノードは、自身がRaftリーダーでなければ、現在のリーダーにリクエストを転送する。
    • Raftリーダーは、操作をRaftログエントリーとしてクラスタに提案し、過半数の合意を得る。
    • ログエントリーがコミットされると、各ノードのステートマシン (FSM) がそのログを適用し、ローカルのデータストアを更新する。
  5. 読み取りパス (Read Path - 結果整合性):

    • CLIから読み取りリクエスト(get-item, query-items)を受け取ったノードは、Raftのコミットを待つことなく、自身のローカルデータストアから直接データを読み取り、結果を返す。
    • これにより、タイミングによっては他のノードよりも古いデータが返却される可能性があり、結果整合性をシミュレートする。

学習・シミュレーションしたい主要概念:

  • Raftプロトコルによる分散合意、リーダー選出、ログ複製。
  • 書き込みリクエストのリーダーへの集約とフォワーディング。
  • 各ノードのローカルデータストアからの読み取りによる結果整合性の実現。
  • 最終更新タイムスタンプを用いたLast Write Wins (LWW) によるシンプルな競合解決。

技術スタック:

  • 言語: Go
  • 主要ライブラリ: hashicorp/raft (または同等のRaft実装)、Go標準ライブラリ(flag for CLI, encoding/jsonなど)

開発スコープと簡略化:

  • 初期フォーカス: put-itemget-item を中心に、Raft上での書き込みと結果整合性読み取りのコアロジックを優先的に実装する。テーブル作成/削除はその後でも可(最初は固定スキーマで開発も可)。
  • シングルRaftグループ: 複雑なシャーディングの実装は不要。システム全体で1つのRaftグループが動作するイメージ。
  • 堅牢性より概念理解: 本番環境レベルのエラーハンドリング、永続化の堅牢性、セキュリティ対策は簡略化し、コアロジックの理解に注力する。
  • テスト: 基本的なコマンドの動作確認レベルで可。

期待する成果物:
上記の機能を実装したGo言語のソースコード一式。
簡単なビルド方法とCLIの基本的な使用方法を記載した README.md

riddle_tecriddle_tec

PROGRESS.md - Day42: Go Raft NoSQL Simulator

フェーズ1: プロジェクト初期化とRaftクラスタ基盤

  • プロジェクトディレクトリ作成 (day42_raft_nosql_simulator) と template からのコピー (完了)
  • package.jsonnameday42_raft_nosql_simulator に更新 (完了)
  • Goモジュールの初期化 (go mod init github.com/lirlia/100day_challenge_backend/day42_raft_nosql_simulator) (完了)
  • 必要なGoライブラリのインストール (hashicorp/raft, github.com/hashicorp/raft-boltdb, github.com/spf13/cobra, github.com/peterh/liner, github.com/stretchr/testify) (完了)
  • README.md にプロジェクト概要とビルド/実行方法を記述 (初期版) (完了)
  • PROGRESS.md に作業工程を記載 (このファイル) (完了)
  • 基本的なRaftノード構造の定義 (internal/raft_node/node.go) (完了)
  • 単一Raftノードの起動と停止処理の実装 (複数ノード起動の中で確認) (完了)
  • 複数Raftノード (3ノード想定) をTCPトランスポートでクラスタを形成する処理の実装 (完了)
  • リーダー選出の確認 (ログ出力などで) (完了)
  • テスト作成: クラスタ起動、リーダー選出、シャットダウン (internal/raft_node/node_test.go) (完了)
  • テスト実施 (go test ./internal/raft_node/...) と確認
  • コミット: day42: step 1/7 Raft cluster foundation setup and initial tests

フェーズ2: データストアとFSM (Finite State Machine)

  • FSM実装 (internal/store/fsm.go)
    • TableMetadata 構造体定義と FSM へのテーブルメタデータマップ追加
    • Apply メソッドでの CreateTableCommand, DeleteTableCommand 処理実装
    • Snapshot / Restore メソッドでのテーブルメタデータ永続化・復元実装
    • GetTableMetadata, ListTables リード専用メソッド実装
    • Apply メソッドでの PutItemCommand, DeleteItemCommand 処理実装 (アイテム操作)
  • ローカルデータストア実装 (internal/store/kv_store.go)
    • KVStore 構造体定義と初期化 (ベースディレクトリ管理)
    • EnsureTableDir, RemoveTableDir (テーブルディレクトリ操作) 実装
    • PutItem: アイテムをJSONファイルとして保存 (LWW考慮)
    • GetItem: アイテムをJSONファイルから読み込み
    • DeleteItem: アイテムのJSONファイルを削除
    • QueryItems: パーティションキーとソートキープレフィックスでのスキャン実装
  • Raftノード拡張 (internal/raft_node/node.go)
    • Node への KVStore 参照追加と NewNode での初期化
    • ProposeCreateTable, ProposeDeleteTable 実装
    • ProposePutItem, ProposeDeleteItem 実装
    • ローカルリード用 GetItemFromLocalStore, QueryItemsFromLocalStore メソッド実装 (KVStoreを直接呼び出し)
  • 単体テスト
    • commands_test.go: コマンド (デ)シリアライズテスト
    • fsm_test.go: FSMのテーブル操作、アイテム操作、スナップショット/リストアのテスト
    • kv_store_test.go: KVStoreのディレクトリ操作、アイテムCRUD操作、クエリ操作のテスト
  • 統合テスト (internal/raft_node/integration_test.go)
    • クラスタ経由でのテーブル作成・削除・一覧取得のテスト
    • クラスタ経由でのアイテムPut・Get・Delete・Queryのテスト
    • リーダー障害時のスナップショットからの復旧テスト (発展)
  • コミット: day42: step 2/7 Data store, FSM, and initial integration tests

フェーズ3: CLIインターフェースと書き込み/読み取りパス (コア機能)

  • CLIフレームワーク (spf13/cobra) を用いた基本的なCLI構造の作成 (cmd/cli/main.go, cmd/cli/root.go, cmd/cli/table.go, cmd/cli/item.go)
  • create-table コマンドのスタブ実装 (--table-name <name> --partition-key <pk_name> [--sort-key <sk_name>:<pk_type>])
  • put-item コマンドのスタブ実装 (--table-name <name> --item '<json_object_string>')
  • get-item コマンドのスタブ実装 (--table-name <name> --key '<json_object_string_for_key>')
  • delete-itemquery-items コマンドのスタブ実装 (item.go)
  • --target-addr グローバル永続フラグの実装 (root.go)
  • RaftノードへのHTTP APIエンドポイントの実装 (internal/server/http_api.go)
    • /create-table (POST)
    • /put-item (POST)
    • /get-item (POST)
    • /delete-item (POST)
    • /query-items (POST)
    • /status (GET)
    • リーダーシップチェックとフォロワーへのエラー応答 (Misdirected Request)
    • raft_node.Node にHTTPサーバー起動・停止処理の組み込み
    • 循環参照の解消 (RaftNodeProxyインターフェース導入)
  • CLIからHTTP APIを呼び出すクライアントロジックの実装 (internal/client/client.go)
    • テーブル作成リクエスト
    • アイテムPutリクエスト
    • アイテムGetリクエスト
    • アイテムDeleteリクエスト
    • アイテムQueryリクエスト
    • ステータス取得リクエスト
    • cmd/cli/table.go, cmd/cli/item.go からクライアントを利用
  • コミット: day42: step 3/7 CLI command stubs and initial structure

フェーズ4: その他のテーブル・アイテム操作

  • delete-table コマンドの実装 (--table-name <name>)
  • delete-item コマンドの実装 (--table-name <name> --key '<json_object_string_for_key>')
  • query-items コマンドの実装 (--table-name <name> --partition-key-value <value>)
  • テスト:
    • アイテム削除 (delete-item) 後に get-item で取得できないこと
    • query-items が正しくパーティションキーでフィルタされたアイテム一覧を返すこと
    • テーブル削除 (delete-table) 後、そのテーブルへの操作がエラーになること
  • コミット: day42: step 4/7 Additional item/table operations (delete-table, delete-item, query-items)

フェーズ5: Last Write Wins (LWW) とフォワーディング

  • put-item および delete-item (論理削除の場合) 時に内部的に最終更新タイムスタンプを記録・更新する処理の確認と強化
  • FSMの Applyput-item を処理する際に、既存アイテムのタイムスタンプと比較し、新しい場合のみ更新するロジックを実装・確認 (LWW)
  • 書き込み系コマンド (create-table, delete-table, put-item, delete-item) を非リーダーノードが受け取った場合にリーダーへ転送するロジックを実装・確認
  • テスト:
    • 同じキーに対して異なるノードからほぼ同時に put-item を行い (シミュレート)、LWWが機能することを確認
    • 非リーダーノードへの書き込みリクエストがリーダーに転送され処理されることを確認
  • コミット: day42: step 5/7 LWW conflict resolution and request forwarding

フェーズ6: 安定化とリファクタリング

  • エラーハンドリングの改善 (CLIでのエラー表示、ノード間通信エラーなど)
  • ログ出力の整備 (デバッグ用、通常運用時用)
  • コード全体のリファクタリング、可読性向上
  • Makefile の作成 (ビルド、実行、クリーンなど)
  • テスト: 主要コマンドの動作を一通り再確認
  • コミット: day42: step 6/7 Stabilization and refactoring

フェーズ7: ドキュメントと最終化

  • README.md のビルド方法、CLI使用方法を詳細に更新
  • コード全体の最終確認、不要なコメントやログの削除
  • 簡単な動作デモシナリオを README.md に記載
  • .cursor/rules/knowledge.mdc の更新
  • コミット: day42: step 7/7 Documentation and finalization

フェーズ 2: テーブル管理とキーバリュー操作のためのFSMロジック実装

riddle_tecriddle_tec

E2E テストのシェルを書いてもらって、テスト通るようにするのが手軽で良いことに気づいた

#!/bin/bash

# テスト設定
CLI_BIN="./day42_raft_nosql_simulator"
LOG_DIR="logs_e2e_test"
SERVER_LOG_FILE_NODE0="$LOG_DIR/server_node0.log" # 個別ログは現在使用していない
SERVER_LOG_FILE_NODE1="$LOG_DIR/server_node1.log" # 個別ログは現在使用していない
SERVER_LOG_FILE_NODE2="$LOG_DIR/server_node2.log" # 個別ログは現在使用していない
SERVER_COMBINED_LOG_FILE="$LOG_DIR/server_combined.log"
TEST_DB_NODE0="node0_data" # Go側でパスが調整される想定
TEST_DB_NODE1="node1_data"
TEST_DB_NODE2="node2_data"

# 各ノードのHTTP APIアドレス
NODE0_API_ADDR="localhost:8100" # HTTP APIのポートをGo側の実装に合わせる
NODE1_API_ADDR="localhost:8101" # HTTP APIのポートをGo側の実装に合わせる
NODE2_API_ADDR="localhost:8102" # HTTP APIのポートをGo側の実装に合わせる

# テストで使用するテーブル名とアイテム
TEST_TABLE="TestItemsE2E"
PARTITION_KEY="Artist"
SORT_KEY="SongTitle"

ITEM1_PK="Journey"
ITEM1_SK="Don't Stop Believin'" # シェル変数内ではリテラルとして扱う
ITEM1_ATTRS_JSON_PART='"Album":"Escape","Year":1981'

ITEM2_PK="Journey"
ITEM2_SK="Separate Ways (Worlds Apart)" # シングルクオートなし
ITEM2_ATTRS_JSON_PART='"Album":"Frontiers","Year":1983'

ITEM3_PK="Queen"
ITEM3_SK="Bohemian Rhapsody" # シングルクオートなし
ITEM3_ATTRS_JSON_PART='"Album":"A Night at the Opera","Year":1975'

# クリーンアップ関数
cleanup() {
  echo "Cleaning up..."
  if [ -n "$SERVER_PID" ] && ps -p $SERVER_PID >/dev/null; then
    echo "Stopping server (PID: $SERVER_PID)..."
    kill $SERVER_PID
    # kill だけだとサブプロセスが残る場合があるので、pkill も試みる
    pkill -P $SERVER_PID              # macOSでは pkill -P は動作しないことがあるので、より汎用的な方法も検討
    pgrep -P $SERVER_PID | xargs kill # pkill の代替
    wait $SERVER_PID 2>/dev/null      # プロセスが終了するまで待つ
    echo "Server stopped."
  else
    echo "Server process not found or already stopped."
  fi
  # Goプログラムが day42_raft_nosql_simulator の中にデータディレクトリを作るので、
  # ここではワークスペースルートからの相対パスで削除
  echo "Removing main data directory: data"
  /bin/rm -rf data
  echo "Removing log directory: $LOG_DIR"
  /bin/rm -rf $LOG_DIR
  echo "Cleanup finished."
}

# テスト失敗時のハンドラ
handle_error() {
  echo "--------------------------------------------------"
  echo "ERROR: Test failed at step: $1"
  echo "--------------------------------------------------"
  if [ -f "$SERVER_COMBINED_LOG_FILE" ]; then
    echo "Combined Server Log ($SERVER_COMBINED_LOG_FILE) - Last 50 lines:"
    tail -n 50 "$SERVER_COMBINED_LOG_FILE"
  else
    echo "Combined Server Log ($SERVER_COMBINED_LOG_FILE) not found."
  fi
  echo "--------------------------------------------------"
  # cleanup # ここで cleanup を呼ぶと exit trap も実行されるので二重になる可能性
  # exit 1 # trap EXIT が処理するので不要
}

# trap 'handle_error "UNKNOWN"' ERR # ERR trap は時々意図しない挙動をするので、コマンド毎のチェックを優先
trap cleanup EXIT SIGINT SIGTERM

# 色付け用
RESET_COLOR="\e[0m"
GREEN_COLOR="\e[32m"
RED_COLOR="\e[31m"
YELLOW_COLOR="\e[33m"

echo_section() {
  echo -e "\n${YELLOW_COLOR}=== $1 ===${RESET_COLOR}"
}

check_command_success() {
  local step_name="$1"
  local exit_code=$?
  if [ $exit_code -ne 0 ]; then
    echo -e "${RED_COLOR}FAILURE: Command for '$step_name' failed (Exit Code: $exit_code)${RESET_COLOR}"
    # 標準エラー出力も表示する(もしあれば)
    # cat stderr_temp.log # (もし標準エラーをファイルにリダイレクトしていれば)
    handle_error "$step_name command failed"
    exit 1 # ERR trap を使わないので明示的に exit
  fi
  echo -e "${GREEN_COLOR}SUCCESS: $step_name command executed successfully.${RESET_COLOR}"
}

check_grep_success() {
  local step_name="$1"
  local pattern="$2"
  local input_text="$3"

  echo "$input_text" | grep -qE "$pattern"
  local exit_code=$?

  if [ $exit_code -ne 0 ]; then
    echo -e "${RED_COLOR}FAILURE: Grep check for '$step_name' failed. Pattern '$pattern' not found in output:${RESET_COLOR}"
    echo "$input_text"
    handle_error "Grep check for '$step_name' failed"
    exit 1
  fi
  echo -e "${GREEN_COLOR}SUCCESS: Grep check for '$step_name' passed. Pattern '$pattern' found.${RESET_COLOR}"
}

# --- テストのメイン処理 ---
main() {
  echo "Preparing for E2E tests..."
  mkdir -p $LOG_DIR
  # 既存のテストデータとログを削除
  /bin/rm -rf data # ここでも削除
  /bin/rm -f $SERVER_COMBINED_LOG_FILE

  # サーバーのビルド
  echo_section "Building server"
  go build -o $CLI_BIN ./cmd/cli
  check_command_success "Server build"
  echo "Build successful."

  # サーバー起動
  echo_section "Starting server cluster"
  # サーバーは内部で3ノードを起動する。
  # データディレクトリは Go プログラム側で node0_data, node1_data, node2_data のように固定で設定されている。
  # ログは combined log へ
  echo "Executing command: $CLI_BIN server > "$SERVER_COMBINED_LOG_FILE" 2>&1 &"
  $CLI_BIN server >"$SERVER_COMBINED_LOG_FILE" 2>&1 &
  SERVER_PID=$!
  echo "Server cluster potentially started with PID $SERVER_PID."                   # "potentially" に変更
  echo "Waiting for server to initialize and elect a leader (approx 10 seconds)..." # 待機時間を延長
  sleep 10                                                                          # リーダー選出と初期化のための十分な待機時間

  # サーバーが起動しているか確認
  if ! ps -p $SERVER_PID >/dev/null; then
    echo -e "${RED_COLOR}ERROR: Server process $SERVER_PID not found after startup.${RESET_COLOR}"
    cat "$SERVER_COMBINED_LOG_FILE"
    exit 1
  fi
  echo "Server process $SERVER_PID is running."

  # リーダーノードを特定する (ここではnode0が初期リーダーだと仮定)
  # 実際には /status エンドポイントで確認するのが望ましいが、ここでは簡略化
  LEADER_ADDR=$NODE0_API_ADDR
  FOLLOWER_ADDR=$NODE1_API_ADDR      # 書き込み失敗テスト用
  READ_FOLLOWER_ADDR=$NODE2_API_ADDR # 読み込みテスト用フォロワー

  echo "Assuming Node 0 ($LEADER_ADDR) is the initial leader for writes."
  echo "Follower for testing write rejection: Node 1 ($FOLLOWER_ADDR)."
  echo "Follower for testing reads: Node 2 ($READ_FOLLOWER_ADDR)."

  # 1. テーブル作成
  echo_section "Test 1: Create Table '$TEST_TABLE'"
  OUTPUT_CREATE_TABLE=$($CLI_BIN create-table --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --partition-key "$PARTITION_KEY" --sort-key "$SORT_KEY" 2>&1)
  check_command_success "Create Table '$TEST_TABLE'"
  check_grep_success "Create Table '$TEST_TABLE' response" "CreateTable API call successful" "$OUTPUT_CREATE_TABLE"

  # 2. アイテム登録 (Item1)
  echo_section "Test 2: Put Item 1 ('$ITEM1_PK'/'$ITEM1_SK') into '$TEST_TABLE'"
  ITEM1_SK_JSON_ESCAPED=$(echo "$ITEM1_SK" | sed 's/\\/\\\\/g; s/"/\\"/g') # JSON文字列値用にエスケープ
  ITEM1_DATA_JSON_CONTENT="\"$PARTITION_KEY\":\"$ITEM1_PK\",\"$SORT_KEY\":\"$ITEM1_SK_JSON_ESCAPED\",$ITEM1_ATTRS_JSON_PART"
  ITEM1_DATA_FOR_CLI="{${ITEM1_DATA_JSON_CONTENT}}"
  OUTPUT_PUT_ITEM1=$($CLI_BIN put-item --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --item-data "$ITEM1_DATA_FOR_CLI" 2>&1)
  check_command_success "Put Item 1"
  check_grep_success "Put Item 1 response" "PutItem API call successful" "$OUTPUT_PUT_ITEM1"

  # 3. アイテム登録 (Item2)
  echo_section "Test 3: Put Item 2 ('$ITEM2_PK'/'$ITEM2_SK') into '$TEST_TABLE'"
  ITEM2_SK_JSON_ESCAPED=$(echo "$ITEM2_SK" | sed 's/\\/\\\\/g; s/"/\\"/g')
  ITEM2_DATA_JSON_CONTENT="\"$PARTITION_KEY\":\"$ITEM2_PK\",\"$SORT_KEY\":\"$ITEM2_SK_JSON_ESCAPED\",$ITEM2_ATTRS_JSON_PART"
  ITEM2_DATA_FOR_CLI="{${ITEM2_DATA_JSON_CONTENT}}"
  OUTPUT_PUT_ITEM2=$($CLI_BIN put-item --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --item-data "$ITEM2_DATA_FOR_CLI" 2>&1)
  check_command_success "Put Item 2"
  check_grep_success "Put Item 2 response" "PutItem API call successful" "$OUTPUT_PUT_ITEM2"

  # 4. アイテム登録 (Item3)
  echo_section "Test 4: Put Item 3 ('$ITEM3_PK'/'$ITEM3_SK') into '$TEST_TABLE'"
  ITEM3_SK_JSON_ESCAPED=$(echo "$ITEM3_SK" | sed 's/\\/\\\\/g; s/"/\\"/g')
  ITEM3_DATA_JSON_CONTENT="\"$PARTITION_KEY\":\"$ITEM3_PK\",\"$SORT_KEY\":\"$ITEM3_SK_JSON_ESCAPED\",$ITEM3_ATTRS_JSON_PART"
  ITEM3_DATA_FOR_CLI="{${ITEM3_DATA_JSON_CONTENT}}"
  OUTPUT_PUT_ITEM3=$($CLI_BIN put-item --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --item-data "$ITEM3_DATA_FOR_CLI" 2>&1)
  check_command_success "Put Item 3"
  check_grep_success "Put Item 3 response" "PutItem API call successful" "$OUTPUT_PUT_ITEM3"

  # 5. アイテム取得 (Item1) - リーダーから
  echo_section "Test 5: Get Item 1 from leader ($LEADER_ADDR)"
  OUTPUT_GET_ITEM1=$($CLI_BIN get-item --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --partition-key "$ITEM1_PK" --sort-key "$ITEM1_SK" 2>&1)
  check_command_success "Get Item 1 from leader"
  check_grep_success "Get Item 1 PK" "\"Artist\":\"$ITEM1_PK\"" "$OUTPUT_GET_ITEM1"
  check_grep_success "Get Item 1 SK" "\"SongTitle\":\"$ITEM1_SK\"" "$OUTPUT_GET_ITEM1" # SKのシングルクオートエスケープはJSON内では不要
  check_grep_success "Get Item 1 Attribute (Album)" "\"Album\":\"Escape\"" "$OUTPUT_GET_ITEM1"

  # 6. アイテム取得 (Item1) - フォロワーから (読み込みはフォロワーでも可能)
  echo_section "Test 6: Get Item 1 from follower (${READ_FOLLOWER_ADDR})"
  OUTPUT_GET_ITEM1_FOLLOWER=$($CLI_BIN get-item --target-addr "${READ_FOLLOWER_ADDR}" --table-name "$TEST_TABLE" --partition-key "$ITEM1_PK" --sort-key "$ITEM1_SK" 2>&1)
  check_command_success "Get Item 1 from follower"
  check_grep_success "Get Item 1 PK (follower)" "\"Artist\":\"$ITEM1_PK\"" "$OUTPUT_GET_ITEM1_FOLLOWER"
  check_grep_success "Get Item 1 SK (follower)" "\"SongTitle\":\"$ITEM1_SK\"" "$OUTPUT_GET_ITEM1_FOLLOWER"
  check_grep_success "Get Item 1 Attribute (Album) (follower)" "\"Album\":\"Escape\"" "$OUTPUT_GET_ITEM1_FOLLOWER"

  # 7. アイテムクエリ (PartitionKey = "Journey") - リーダーから
  echo_section "Test 7: Query Items (PK='$ITEM1_PK') from leader ($LEADER_ADDR)"
  OUTPUT_QUERY_JOURNEY=$($CLI_BIN query-items --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --partition-key "$ITEM1_PK" 2>&1)
  check_command_success "Query Items PK='$ITEM1_PK' (leader)"
  check_grep_success "Query Result Item 1 SK" "$ITEM1_SK" "$OUTPUT_QUERY_JOURNEY"
  check_grep_success "Query Result Item 2 SK" "$ITEM2_SK" "$OUTPUT_QUERY_JOURNEY"
  ITEM_COUNT_JOURNEY=$(echo "$OUTPUT_QUERY_JOURNEY" | grep -c '"Artist":') # Assuming each item is a JSON object on its own line or identifiable
  if [ "$ITEM_COUNT_JOURNEY" -ne 2 ]; then
    echo -e "${RED_COLOR}FAILURE: Query '$ITEM1_PK' returned $ITEM_COUNT_JOURNEY items, expected 2.${RESET_COLOR}"
    echo "$OUTPUT_QUERY_JOURNEY"
    handle_error "Query '$ITEM1_PK' count mismatch"
    exit 1
  fi
  echo "Query '$ITEM1_PK' returned 2 items as expected."

  # 8. アイテムクエリ (PartitionKey = "Journey", SortKeyPrefix = "Don't") - リーダーから
  echo_section "Test 8: Query Items (PK='$ITEM1_PK', SKPrefix='Don\'t') from leader ($LEADER_ADDR)"
  # SKPrefix のシングルクオートエスケープに注意
  OUTPUT_QUERY_JOURNEY_DONT=$($CLI_BIN query-items --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --partition-key "$ITEM1_PK" --sort-key-prefix "Don\'t" 2>&1)
  check_command_success "Query Items PK='$ITEM1_PK', SKPrefix='Don\'t' (leader)"
  check_grep_success "Query SK Prefix Result Item 1 SK" "$ITEM1_SK" "$OUTPUT_QUERY_JOURNEY_DONT"
  if echo "$OUTPUT_QUERY_JOURNEY_DONT" | grep -q "$ITEM2_SK"; then
    echo -e "${RED_COLOR}FAILURE: Query PK='$ITEM1_PK', SKPrefix='Don\'t' unexpectedly found '$ITEM2_SK'.${RESET_COLOR}"
    echo "$OUTPUT_QUERY_JOURNEY_DONT"
    handle_error "Query PK='$ITEM1_PK', SKPrefix='Don\'t' found unexpected item"
    exit 1
  fi
  ITEM_COUNT_JOURNEY_DONT=$(echo "$OUTPUT_QUERY_JOURNEY_DONT" | grep -c '"Artist":')
  if [ "$ITEM_COUNT_JOURNEY_DONT" -ne 1 ]; then
    echo -e "${RED_COLOR}FAILURE: Query PK='$ITEM1_PK', SKPrefix='Don\'t' returned $ITEM_COUNT_JOURNEY_DONT items, expected 1.${RESET_COLOR}"
    echo "$OUTPUT_QUERY_JOURNEY_DONT"
    handle_error "Query PK='$ITEM1_PK', SKPrefix='Don\'t' count mismatch"
    exit 1
  fi
  echo "Query PK='$ITEM1_PK', SKPrefix='Don\'t' returned 1 item as expected."

  # 9. フォロワーへの書き込み試行 (アイテム登録) - 失敗するはず
  echo_section "Test 9: Attempt Put Item on follower ($FOLLOWER_ADDR) - Should Fail"
  FAIL_ITEM_DATA_JSON_CONTENT="\"$PARTITION_KEY\":\"FailPK\",\"$SORT_KEY\":\"FailSK\",\"data\":\"dummy\""
  FAIL_DATA_FOR_CLI="{${FAIL_ITEM_DATA_JSON_CONTENT}}"
  OUTPUT_PUT_FOLLOWER_ERR=$($CLI_BIN put-item --target-addr "$FOLLOWER_ADDR" --table-name "$TEST_TABLE" --item-data "$FAIL_DATA_FOR_CLI" 2>&1)
  # このコマンドは失敗を期待するので、check_command_success は使わない
  if echo "$OUTPUT_PUT_FOLLOWER_ERR" | grep -qE "(421 Misdirected Request|not the leader|Failed to forward request|no leader|Unable to apply command: not the leader)"; then
    echo -e "${GREEN_COLOR}SUCCESS: Put item on follower failed with expected message.${RESET_COLOR}"
    echo "$OUTPUT_PUT_FOLLOWER_ERR"
  else
    echo -e "${RED_COLOR}FAILURE: Put item on follower did NOT fail with expected message (421 or 'not the leader' variants).${RESET_COLOR}"
    echo "$OUTPUT_PUT_FOLLOWER_ERR"
    handle_error "Put item on follower failure message mismatch"
    exit 1
  fi

  # 10. アイテム削除 (Item2)
  echo_section "Test 10: Delete Item 2 ('$ITEM2_PK'/'$ITEM2_SK') from '$TEST_TABLE'"
  OUTPUT_DELETE_ITEM2=$($CLI_BIN delete-item --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --partition-key "$ITEM2_PK" --sort-key "$ITEM2_SK" 2>&1)
  check_command_success "Delete Item 2"
  check_grep_success "Delete Item 2 response" "DeleteItem API call successful" "$OUTPUT_DELETE_ITEM2"

  # 11. アイテム取得 (Item2) - 削除されたことを確認
  echo_section "Test 11: Get Item 2 (should not be found)"
  OUTPUT_GET_ITEM2_DELETED=$($CLI_BIN get-item --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" --partition-key "$ITEM2_PK" --sort-key "$ITEM2_SK" 2>&1)
  # 成功しないことを期待 (エラーメッセージが出るはず)
  if echo "$OUTPUT_GET_ITEM2_DELETED" | grep -qE "(Item not found|GetItem API call failed: status 404)"; then # 404エラーも許容
    echo -e "${GREEN_COLOR}SUCCESS: Item 2 not found after deletion, as expected.${RESET_COLOR}"
  else
    echo -e "${RED_COLOR}FAILURE: Get Item 2 after deletion did not result in 'Item not found' or 404 error.${RESET_COLOR}"
    echo "$OUTPUT_GET_ITEM2_DELETED"
    handle_error "Get Item 2 after deletion - unexpected result"
    exit 1
  fi

  # # 12. テーブル削除 (Go側に未実装のためコメントアウト)
  # echo_section "Test 12: Delete Table '$TEST_TABLE'"
  # OUTPUT_DELETE_TABLE=$($CLI_BIN delete-table --target-addr "$LEADER_ADDR" --table-name "$TEST_TABLE" 2>&1)
  # check_command_success "Delete Table '$TEST_TABLE'"
  # check_grep_success "Delete Table '$TEST_TABLE' response" "Table '$TEST_TABLE' deleted successfully" "$OUTPUT_DELETE_TABLE"

  # # 13. (オプション) テーブルがリストから消えたことを確認 (Go側に未実装のためコメントアウト)
  # echo_section "Test 13: Verify table deletion via status (optional)"
  # sleep 2 # FSM適用までのラグを考慮
  # OUTPUT_STATUS_AFTER_DELETE=$($CLI_BIN status --target-addr "$LEADER_ADDR" 2>&1)
  # check_command_success "Get status after table deletion"
  # if echo "$OUTPUT_STATUS_AFTER_DELETE" | grep -q "$TEST_TABLE"; then
  #     echo -e "${RED_COLOR}FAILURE: Table '$TEST_TABLE' still found in status after deletion.${RESET_COLOR}"
  #     echo "$OUTPUT_STATUS_AFTER_DELETE"
  #     handle_error "Table found in status after deletion"
  #     exit 1
  # fi
  # echo -e "${GREEN_COLOR}SUCCESS: Table '$TEST_TABLE' not found in status after deletion, as expected.${RESET_COLOR}"

  echo -e "\n${GREEN_COLOR}=====================================${RESET_COLOR}"
  echo -e "${GREEN_COLOR}All E2E tests passed successfully! (Delete Table tests skipped)${RESET_COLOR}" # メッセージ変更
  echo -e "${GREEN_COLOR}=====================================${RESET_COLOR}"

}

# スクリプトの実行
main
# cleanup は trap EXIT で実行される
riddle_tecriddle_tec

学んだこと

  • nosql の write node は raft によって選出されている(今回は raft を使っただけ)
  • write はすべてリーダーで受けるが、read はどのノードでもよい。そのため結果整合性となる
  • hashicorp/raft は NewRaft をする際に raft.FSM (ステートマシン) を要求する。ここで実際にストレージに書き込むための処理を渡す必要があり、raft 側から Apply を実行してくれる。
  • pk / sk ごとにパーティションが通常は作られるが、それごとに raft クラスターがおり write を分散しているらしい。(自動でシャーディングしているってこと)
このスクラップは4ヶ月前にクローズされました