raftengine/etcd: clean up orphaned snapshot spool files on startup#498
raftengine/etcd: clean up orphaned snapshot spool files on startup#498
Conversation
Snapshot spool files (elastickv-etcd-snapshot-*) were left behind when the engine crashed before snapshotSpool.Close() could run. Since the spool directory was moved from os temp to cfg.DataDir, these files accumulated without bound, eventually filling the disk. Add cleanupStaleSnapshotSpools() and call it from openDiskState() so orphaned spools are removed on every engine restart.
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
[Performance] レビュー結果問題なし。 はスタートアップ時の1回限りの処理であり、ホットパスではありません。 → のループはO(N)ですが、孤立したスプールファイル数は限定的であり、I/Oコストも許容範囲です。 |
[Test Coverage] レビュー結果PR #498: raftengine/etcd: clean up orphaned snapshot spool files on startup✅ カバーされているテスト
|
[Concurrency] レビュー結果対象PR: #498 raftengine/etcd: clean up orphaned snapshot spool files on startup 検討項目1. クリーンアップとアクティブなスプールファイルの競合
2. TOCTOU (Glob → Remove 間の競合)
3.
4. エンジン再起動中の snapshot 操作との競合
結論問題なし。クリーンアップロジックは startup シーケンスに正しく配置されており、想定される競合ケースも適切にハンドリングされている。 |
The etcd Snapshotter.SaveSnap() writes new .snap files but never removes old ones. In upstream etcd this is handled by fileutil.PurgeFile in the etcd server layer, which elastickv does not use. Add purgeOldSnapFiles() that retains the most recent 3 .snap files and call it from both persistLocalSnapshotPayload and persistCreatedSnapshot. This prevents unbounded disk growth on all cluster nodes.
- Fix gosec G306: use 0o600 instead of 0o644 for test file permissions - Add TestCleanupStaleSnapshotSpoolsNonExistentDir for non-existent directory edge case
[Test Coverage] レビュー結果概要新規追加された2つのユーティリティ関数 (, ) のユニットテストは網羅性が高く、基本的な品質は良好です。ただし、以下の 4件の未カバー箇所 を検出しました。 ✅ カバーされているケース
|
[Data Loss] レビュー結果要約データロストに繋がる可能性のある問題を 2件 検出しました。 【中リスク】① でのエラー伝播 (engine.go:1322-1325)問題点: 呼び出し元 (engine.go:1263) がこのエラーを受け取ると、エンジンが applied index を進めない可能性があり、既にスナップショット化されたエントリを再適用しようとする状態に陥るリスクがあります。 推奨対処: 【中リスク】② で 時にも purge を実行 (engine.go:2268-2281)問題点: ストレージが不安定な状態で古いスナップショットファイルを削除すると、その不安定さが解消されてリカバリが必要になった際に、リカバリに使えるはずだった ファイルが既に削除済みという状況が発生する可能性があります。 推奨対処: 問題なし: その他の変更点
|
[Performance] レビュー結果
|
[Data Loss] レビュー結果データロストに繋がる可能性のある問題を 2件 検出しました。 【中リスク】①
|
| 変更箇所 | 評価 |
|---|---|
cleanupStaleSnapshotSpools をスタートアップ時に呼び出し |
安全。パターン elastickv-etcd-snapshot-* はコミット済みの .snap ファイルと完全に別であり、エンジン起動前に実行されるため競合なし |
defaultMaxSnapFiles = 3 |
etcd 標準の慣行に沿っており、WAL が差分をカバーするため問題なし |
purgeOldSnapFiles のソートロジック |
16進数ゼロパディングによる辞書順=時系列順は正しい |
[Performance] レビュー結果PR #498: raftengine/etcd: clean up orphaned snapshot spool files on startup
|
[Test Coverage] レビュー結果対象PR: raftengine/etcd: clean up orphaned snapshot spool files on startup 新規テストファイル ( 問題1:
|
[Test Coverage] レビュー結果対象PR: raftengine/etcd: clean up orphaned snapshot spool files on startup 変更ファイル: 追加されたテストの評価新規テストファイル
不足しているテストケース1.
|
[Consistency] レビュー結果対象PR: #498 raftengine/etcd: clean up orphaned snapshot spool files on startup 問題なし。 スナップショットスプールファイルの清掃とスナップファイルのパージはいずれも Raft コンセンサス状態の外で行われる補助的な操作であり、データ一貫性への影響はない。
|
[Performance] レビュー結果PR #498: raftengine/etcd: clean up orphaned snapshot spool files on startup対象ファイル: 指摘事項[軽微] // engine.go:1318
snapDir := filepath.Join(e.dataDir, snapDirName)
if purgeErr := purgeOldSnapFiles(snapDir, defaultMaxSnapFiles); purgeErr != nil {
[極小] var snaps []string
for _, e := range entries {
if !e.IsDir() && filepath.Ext(e.Name()) == ".snap" {
snaps = append(snaps, e.Name())
}
}
総評: 機能的にはスナップショット書き込みパスへの追加 I/O が懸念されるが、スナップショット生成自体が低頻度かつすでに I/O ヘビーな操作であるため、実用上の性能影響は限定的。問題は軽微レベル。 |
[Concurrency] レビュー結果対象PR: raftengine/etcd: clean up orphaned snapshot spool files on startup 総評並行性・分散障害の観点において、実装に問題となる点はありません。以下に各観点の詳細を記します。 1. による相互排他 — ✅ 問題なし新たに追加された の呼び出し元はすべて を保持済みです。
両関数が同時にパージを実行する競合は発生しません。また、 チェックにより、他ゴルーチンによる先行削除も安全に吸収されます。 2. Raft リーダー→フォロワー スナップショット転送との競合 — ✅ 問題なしファイルが読み込まれるのは起動時の / のみです。通常運用時のリーダー→フォロワー転送では (メモリ上の値)が使われるため、 が稼働中に ファイルを削除してもスナップショット転送には影響しません。 3. switch 文リファクタリングの正確性 — ✅ 問題なしこれらのソフトエラーは または から発生し、 が成功する前に返るケースが大半です。いずれも「新しい ファイルを書いていない状態でのパージ」であり、既存ファイルを最新 3 件に整理するだけなので副作用はありません。 4. のタイミング — ✅ 問題なし(起動時・単一スレッド)内でエンジンゴルーチン開始前に呼び出されており、並行アクセスは発生しません。スプールファイル()と ファイルは完全に別ものであり、相互干渉もありません。 5. vs のエラーハンドリング差異 — ℹ️ 参考情報
以上。並行性・分散障害の観点で 問題となる点は検出されませんでした。 |
- Fix data-loss risk: purge only runs on successful snapshot persistence (err == nil), not on ErrUnavailable/ErrCompacted - Make purge best-effort: log warning instead of returning error, so purge failure does not block snapshot progression - Fix unparam: remove unused maxSnap parameter from purgeOldSnapFiles, use defaultMaxSnapFiles constant directly - Fix unparam: remove unused return value from createSnapFile test helper
[Data Loss] レビュー結果対象PR: raftengine/etcd: clean up orphaned snapshot spool files on startup 調査範囲以下の変更点をデータロスト観点で精査しました。
指摘事項[中] スナップ作成失敗(エラーsilence)後にpurgeが実行される場所: 問題の詳細: は の が返します。この時点では (同332行より前)は成功しており、対象indexの と が成功した後でも、 がエラーを返すと呼び出し元にエラーが伝搬されます。スナップ自体はディスクに正しく永続化されているため、データロストは発生しません。しかし呼び出し元がこのエラーをスナップ永続化失敗として扱いリトライを発生させた場合、無駄な再スナップが試みられます。 [問題なし] 起動時のスプールファイル削除は WAL や [問題なし] のソート順と削除対象スナップファイル名のゼロパディングhex形式によりlexicographicソート=chronologicalソートが成立しており、最古から順に削除・最新3件を保持する実装は正しいです。WALリカバリに必要なスナップは最新1件あれば足りるため、3件保持は十分なマージンです。 総評重大なデータロストリスクは確認されませんでした。 中程度の懸念として、 などのsilenceされたエラー後にpurgeが実行されるケースで、リリースされていないスナップファイルがpurge対象になり得る点があります。ただしそのファイルは無効なスナップ(古いindex)であり、有効な最新スナップは の保護により削除されません。 実害として顕在化する可能性が低い指摘ですが、 のケースではpurgeを実行しない(または呼び出し元で明示的にスキップする)設計にするとより安全です。 |
[Data Loss] レビュー結果対象PR: #498 「raftengine/etcd: clean up orphaned snapshot spool files on startup」 データロスの観点から差分をレビューした結果、致命的なデータロスリスクは検出されませんでした。ただし、以下の注意点があります。 注意点1:
|
[Concurrency] レビュー結果対象PR: raftengine/etcd: clean up orphaned snapshot spool files on startup 総評並行性・分散障害の観点において、実装に問題となる点はありません。以下に各観点の詳細を記します。 1.
|
| 呼び出し経路 | ロック取得箇所 |
|---|---|
persistLocalSnapshotPayload(メソッド)→ purgeOldSnapFiles |
engine.go:2257-2258 |
persistConfigState → persistConfigSnapshotPayloadLocked → persistCreatedSnapshot → purgeOldSnapFiles |
engine.go:1215-1216 |
persistConfigSnapshotPayload → persistConfigSnapshotPayloadLocked → persistCreatedSnapshot → purgeOldSnapFiles |
engine.go:1246-1247 |
両関数が同時にパージを実行する競合は発生しません。また、os.IsNotExist チェックにより、他ゴルーチンによる先行削除も安全に吸収されます。
2. Raft リーダー→フォロワー スナップショット転送との競合 — ✅ 問題なし
.snap ファイルが読み込まれるのは起動時の openDiskState / loadWalState のみです。通常運用時のリーダー→フォロワー転送では raftpb.Snapshot.Data(メモリ上の値)が使われるため、purgeOldSnapFiles が稼働中に .snap ファイルを削除してもスナップショット転送には影響しません。
3. switch 文リファクタリングの正確性 — ✅ 問題なし
// Before: ErrCompacted / ErrUnavailable / ErrSnapOutOfDate は即 return nil
// After: これらも fall-through してパージを実行
switch {
case err == nil:
case errors.Is(err, etcdraft.ErrCompacted):
case errors.Is(err, etcdraft.ErrUnavailable):
case errors.Is(err, etcdraft.ErrSnapOutOfDate):
default:
return err
}
purgeOldSnapFiles(...)これらのソフトエラーは buildLocalSnapshot または storage.CreateSnapshot から発生し、SaveSnap が成功する前に返るケースが大半です。いずれも「新しい .snap ファイルを書いていない状態でのパージ」であり、既存ファイルを最新 3 件に整理するだけなので副作用はありません。
4. cleanupStaleSnapshotSpools のタイミング — ✅ 問題なし
openDiskState(起動時・単一スレッド)内でエンジンゴルーチン開始前に呼び出されており、並行アクセスは発生しません。スプールファイル(elastickv-etcd-snapshot-*)と .snap ファイルは完全に別ものであり、相互干渉もありません。
5. filepath.Glob vs os.ReadDir のエラーハンドリング差異 — ℹ️ 参考情報
cleanupStaleSnapshotSpools:filepath.Globは存在しないディレクトリに対してnil, nilを返すため、ディレクトリ不在でも正常終了します。purgeOldSnapFiles:os.ReadDirは存在しないディレクトリに対してエラーを返します。ただしsnapDirはopenDiskState内のos.MkdirAllで必ず作成されるため、実運用上の問題は生じません。
以上。並行性・分散障害の観点で 問題となる点は検出されませんでした。
[Performance] レビュー結果PR #498: raftengine/etcd: clean up orphaned snapshot spool files on startup問題1: スナップショット永続化パスへの同期ディスクI/O追加
// engine.go:1322
snapDir := filepath.Join(e.dataDir, snapDirName)
if purgeErr := purgeOldSnapFiles(snapDir, defaultMaxSnapFiles); purgeErr != nil {
return errors.Wrap(purgeErr, "purge old snap files")
}
スナップショット件数が少ない( 問題2: ソフトエラーケース(ErrCompacted等)でも purge を実行
startup の |
[Concurrency] レビュー結果対象ファイル: 問題なし。 確認事項:
|
[Concurrency] レビュー結果PR #498: raftengine/etcd: clean up orphaned snapshot spool files on startup1.
|
[Test Coverage] レビュー結果全体的にテストは丁寧に書かれており、主要なケースはカバーされています。以下、不足している点を報告します。 指摘1:
|
| # | 対象関数 | 欠けているケース | 重要度 |
|---|---|---|---|
| 1 | purgeOldSnapFiles |
境界値(ちょうど3件) | 中 |
| 2 | purgeOldSnapFiles |
存在しないディレクトリ | 中 |
| 3 | purgeOldSnapFiles |
複数ターム混在 | 低 |
| 4 | cleanupStaleSnapshotSpools |
一部削除失敗のエラー結合 | 低 |
指摘1・2 は実装バグを隠す可能性があるため、対応を推奨します。
[Performance] レビュー結果[軽微]
|
[Test Coverage] レビュー結果PR #498: raftengine/etcd - orphaned snapshot spool ファイルのクリーンアップ✅ カバーされているテスト
❌ 不足テスト(3件)1.
func TestPurgeOldSnapFilesNonExistentDir(t *testing.T) {
err := purgeOldSnapFiles(filepath.Join(t.TempDir(), "no-such-dir"), defaultMaxSnapFiles)
require.Error(t, err) // os.ReadDir は ENOENT を返す
}2. 現在のテストは「上限超え (6>3)」と「上限未満 (2<3)」のみ。 func TestPurgeOldSnapFilesAtLimit(t *testing.T) {
dir := t.TempDir()
for i := uint64(1); i <= uint64(defaultMaxSnapFiles); i++ {
createSnapFile(t, dir, 1, i*10000)
}
require.NoError(t, purgeOldSnapFiles(dir, defaultMaxSnapFiles))
entries, _ := os.ReadDir(dir)
require.Len(t, entries, defaultMaxSnapFiles) // 削除ゼロ
}3.
// 例: ErrCompacted 時にも purge が走ることを確認するユニットテスト
// (または engine_test.go にある既存の統合テストで副作用を検証) |
[Data Loss] レビュー結果問題なし 確認した観点と判断根拠1. スナップショットスプールファイルの削除 ()
2. 古い ファイルの削除 ()
3. パターンマッチの安全性
|
[Concurrency] レビュー結果対象PR: #498 1.
|
[Data Loss] レビュー結果対象PR: #498 (snapshot spool cleanup) 1.
|
[Consistency] レビュー結果PR #498: raftengine/etcd: clean up orphaned snapshot spool files on startupデータ一貫性の観点では 問題なし。 変更内容はスナップショットスプールファイル(起動前のクラッシュで残存したテンポラリファイル)と古い 確認ポイント:
|
os.ReadDir returns entries sorted by name per Go spec. Since snap file names are zero-padded hex, directory order already equals chronological order, making the explicit sort unnecessary.
[Consistency] レビュー結果評価: 問題なし ✓ このPRはRaftエンジンのスナップショットスプールファイルのクリーンアップに関するものです。データ一貫性への影響はありません。
|
[Test Coverage] レビュー結果対象PR: #498 概要新規テストファイル 不足しているテスト1.
|
| テスト | 対象 |
|---|---|
TestCleanupStaleSnapshotSpools |
正常系・無関係ファイル保持 |
TestCleanupStaleSnapshotSpoolsEmptyDir |
空ディレクトリ |
TestCleanupStaleSnapshotSpoolsNonExistentDir |
存在しないディレクトリ |
TestPurgeOldSnapFiles |
6件→3件保持・非snapファイル保持 |
TestPurgeOldSnapFilesUnderLimit |
上限未満(2件)は削除しない |
TestPurgeOldSnapFilesEmptyDir |
空ディレクトリ |
優先度まとめ
| 優先度 | 項目 |
|---|---|
| 高 | 起動時クリーンアップのインテグレーションテスト(本PRの主要機能) |
| 中 | purgeOldSnapFiles の非存在ディレクトリ挙動の明示 |
| 低 | ちょうど3件の境界値テスト |
| 低 | エラー蓄積パスのテスト |
[Concurrency] レビュー結果検査対象PR #498: 結論: 概ね問題なし。1点指摘あり。各観点の確認1. Race condition / 並行アクセス ✓
2箇所の
2. Raftリーダー切り替え時の安全性 ✓
3. ノード障害・再起動時の挙動 ✓クラッシュ前のスプールファイルを起動時に削除する設計は正しい。 4. ソート順の前提 ✓
指摘事項[WARN]
|
[Concurrency] レビュー結果🟡 注意:
|
[Performance] レビュー結果PR #498: raftengine/etcd: clean up orphaned snapshot spool files on startup指摘事項[軽微] スナップショット永続化パスへの同期的なディスクI/O追加
// engine.go
snapDir := filepath.Join(e.dataDir, snapDirName)
if purgeErr := purgeOldSnapFiles(snapDir); purgeErr != nil {
slog.Warn(...)
}
[軽微]
全体として性能への影響は小さく、実用上の問題は生じにくい変更です。 |
[Data Loss] レビュー結果対象PR: raftengine/etcd: clean up orphaned snapshot spool files on startup1. cleanupStaleSnapshotSpools(起動時のスプールファイル削除)問題なし
2. purgeOldSnapFiles(古い .snap ファイルの削除)問題なし
3. ソート順の前提(スナップショット選別ロジック)問題なし
総評データロストリスク: なし スプールファイルはコミット済みスナップショットとは独立した一時ファイルであり、その削除はデータロストにつながらない。スナップファイルのパージは新スナップショット確定後に実施される設計で、削除順序も正しい。 |
[Data Loss] レビュー結果対象PR: raftengine/etcd: clean up orphaned snapshot spool files on startup1. cleanupStaleSnapshotSpools(起動時のスプールファイル削除)問題なし
2. purgeOldSnapFiles(古い .snap ファイルの削除)問題なし
3. ソート順の前提(スナップショット選別ロジック)問題なし
総評データロストリスク: なし スプールファイルはコミット済みスナップショットとは独立した一時ファイルであり、その削除はデータロストにつながらない。スナップファイルのパージは新スナップショット確定後に実施される設計で、削除順序も正しい。 |
Snapshot spool files (elastickv-etcd-snapshot-*) were left behind when the engine crashed before snapshotSpool.Close() could run. Since the spool directory was moved from os temp to cfg.DataDir, these files accumulated without bound, eventually filling the disk.
Add cleanupStaleSnapshotSpools() and call it from openDiskState() so orphaned spools are removed on every engine restart.