Skip to content

adapter: decompose ZSet storage into per-member wide-column keys#483

Open
bootjp wants to merge 13 commits intomainfrom
feat/reduce-lock
Open

adapter: decompose ZSet storage into per-member wide-column keys#483
bootjp wants to merge 13 commits intomainfrom
feat/reduce-lock

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 11, 2026

No description provided.

@bootjp bootjp force-pushed the feat/reduce-lock branch from 9c1b322 to 37d6099 Compare April 11, 2026 12:49
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions Redis Sorted Set (ZSet) storage from a single legacy blob to a wide-column format, where members and their scores are stored in individual keys alongside a score-based index. The changes include new helper functions for managing these keys, logic to migrate existing data upon access, and updates to ZSet commands to support incremental, diff-based updates. Feedback highlights an issue where empty ZSets might incorrectly persist their metadata key instead of being deleted, and suggests consistent use of the store.PrefixEnd utility to avoid naming inconsistencies.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 11, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates Redis Sorted Set (ZSet) storage from a legacy blob format to a wide-column layout using per-member keys and a score index. The changes include new key construction logic, sortable float encoding, and updated command implementations to support incremental updates and on-the-fly migration. Feedback focuses on optimizing storage operations by utilizing the new score index for ordered access and using prefix deletions to avoid inefficient full-set scans and potential memory exhaustion.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 11, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a wide-column storage format for Redis Sorted Sets, replacing the legacy blob-based storage with a more granular structure of metadata, member, and score-index keys. The changes include new key encoding logic, updated transaction state management for diff-based updates, and modified Redis command handlers to support the new format and facilitate on-the-fly migration. The review feedback highlights several areas for improvement, including optimizing bulk deletions via prefix-based operations, preventing potential OOM issues by bounding member scans, and refining migration logic to handle empty sets more efficiently.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 11, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a wide-column storage format for Redis Sorted Sets, transitioning from a single-blob model to a structure using metadata, member keys, and a score index. The changes include migration support, updated transaction state, and revised command implementations. Feedback focuses on critical atomicity risks in prefix deletions, potential memory exhaustion during unbounded scans, and performance bottlenecks in the O(N) diffing and range-scanning logic.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a wide-column storage format for Redis Sorted Sets (ZSets), utilizing metadata, member, and score-index keys to replace the legacy single-blob format. The implementation includes migration logic and updates to ZSet commands to support the new structure. Feedback identifies a bug where new prefixes were not registered for KEYS and SCAN commands. Additionally, reviewers raised concerns about potential out-of-memory (OOM) vulnerabilities and Raft limit issues due to operations that load entire sets into memory or perform unbounded scans during deletions and updates.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a wide-column storage format for Redis Sorted Sets (ZSets), moving away from the legacy single-blob approach to a per-member key structure with a dedicated score index. The changes include new key construction logic in store/zset_helpers.go, migration logic to handle legacy data, and updates to various Redis commands like ZADD, ZREM, and ZRANGE to support the new format. Feedback focuses on ensuring consistent score encoding for zero values and addressing potential performance and memory issues when deleting large sets by avoiding unbounded scans and excessive mutation batches.

Comment on lines +140 to +151
func EncodeSortableFloat64(dst []byte, f float64) {
if len(dst) < zsetScoreBinarySize {
return
}
bits := math.Float64bits(f)
if bits&(1<<float64SignBitPos) != 0 { // negative
bits = ^bits
} else {
bits ^= 1 << float64SignBitPos
}
binary.BigEndian.PutUint64(dst, bits)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current float64 encoding does not normalize -0.0 to +0.0. In IEEE 754, these have different bit patterns (0x8000000000000000 vs 0x0000000000000000), which results in different sortable byte sequences (0x7FFFFFFFFFFFFFFF vs 0x8000000000000000). Since Redis treats -0.0 and +0.0 as equal scores, they should have the same representation in the score index to ensure consistent range scans and ordering.

func EncodeSortableFloat64(dst []byte, f float64) {
	if len(dst) < zsetScoreBinarySize {
		return
	}
	if f == 0 {
		f = 0.0 // normalize -0.0 to +0.0
	}
	bits := math.Float64bits(f)
	if bits&(1<<float64SignBitPos) != 0 { // negative
		bits = ^bits
	} else {
		bits ^= 1 << float64SignBitPos
	}
	binary.BigEndian.PutUint64(dst, bits)
}

store.ZSetMemberScanPrefix(key),
store.ZSetScoreScanPrefix(key),
} {
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), math.MaxInt, readTS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using math.MaxInt as a limit for ScanAt when collecting keys for deletion can lead to unbounded memory growth and potential OOM issues if a ZSet contains a very large number of members. This violates the general rule to apply fixed bounds to collections that can grow from external requests. Consider using kv.DelPrefix for the member and score prefixes if the storage engine supports it, or implementing a chunked deletion approach to keep memory usage under control.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests.

Comment on lines +199 to +226
func (r *RedisServer) deleteZSetWideColumnElems(ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) {
_, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
if err != nil {
return nil, err
}
if !metaExists {
return nil, nil
}

elems := []*kv.Elem[kv.OP]{
{Op: kv.Del, Key: store.ZSetMetaKey(key)},
}

// Scan member keys and score-index keys for individual deletion.
for _, prefix := range [][]byte{
store.ZSetMemberScanPrefix(key),
store.ZSetScoreScanPrefix(key),
} {
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), math.MaxInt, readTS)
if err != nil {
return nil, errors.WithStack(err)
}
for _, kvp := range kvs {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}
}

return elems, nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The deleteZSetWideColumnElems function generates individual Del operations for every member and score index key. For large ZSets, this results in a massive number of operations being batched into a single Raft request, which can bloat the Raft log, increase apply latency, and potentially exceed message size limits. If kv.DelPrefix cannot be mixed with other mutations, consider if the DEL command logic can be refactored to use prefix deletion for ZSet internal data namespaces.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates Redis Sorted Set (ZSet) storage from a legacy single-blob format to a wide-column structure, utilizing separate keys for metadata, members, and score-based indexing. The changes include new helper functions for incremental updates, member scanning, and migration logic to ensure compatibility with existing data. Review feedback highlights a potential memory risk when scanning large sets with unbounded limits and suggests an optimization to avoid redundant metadata writes when the set's length remains unchanged.

Comment on lines +217 to +224
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), math.MaxInt, readTS)
if err != nil {
return nil, errors.WithStack(err)
}
for _, kvp := range kvs {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The use of math.MaxInt for the scan limit can lead to unbounded memory growth and potential OOM issues when deleting a very large ZSet. To prevent this, apply a fixed bound to collections that can grow from external requests. Consider implementing a batched scan-and-delete approach to process keys in smaller chunks, which would mitigate the memory pressure.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

Comment on lines +311 to +317
} else if len(elems) > 0 {
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The meta key is currently rewritten on every diff that modifies any element, even if the total number of members in the ZSet does not change (e.g., when only a score is updated). Since the meta key only stores the length, this write is redundant. Consider updating the logic to only write the meta key when the length actually changes.

Suggested change
} else if len(elems) > 0 {
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}
} else if len(origMembers) != len(newMembers) {
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a wide-column storage format for Redis Sorted Sets, transitioning from a single legacy blob to a more granular structure using metadata, member, and score-index keys. The changes include new key construction utilities, updated transaction states to support diff-based commits, and refactored Redis commands (e.g., ZADD, ZRANGE, BZPOPMIN) that handle both storage formats. Feedback was provided regarding the zrangeRead implementation, where a slice returned from the storage layer is reversed in-place; it is recommended to copy this slice first to prevent unintended side effects on shared data during the legacy migration phase.

Comment on lines +1551 to 1555
entries, err := r.loadZSetEntriesSorted(context.Background(), key, readTS)
if err != nil {
conn.WriteError(err.Error())
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The reverseZSetEntries function modifies its slice argument in-place. The entries slice returned from loadZSetEntriesSorted might be backed by a shared array, especially when falling back to the legacy blob format which involves unmarshaling. Modifying it directly could lead to unexpected side effects. It's safer to create a copy before performing the in-place reversal, as was done in the previous implementation.

 	entries, err := r.loadZSetEntriesSorted(context.Background(), key, readTS)
 	if err != nil {
 		conn.WriteError(err.Error())
 		return
 	}
 	// Make a copy before potential in-place reversal.
 	entries = append([]redisZSetEntry(nil), entries...)

bootjp added a commit that referenced this pull request Apr 12, 2026
…review

- Address PR #485 gemini review (4094524421):
  - Add stale claim key cleanup in PUSH write path
  - Enforce contiguous-only Head/Tail advancement in compaction
  - Add explicit RPOP claim flow (reverse scan from Tail-1)
  - Add Tail-side GC for RPOP claim keys

- Extend delta metadata pattern to Hash, Set, and ZSet:
  - Hash: wide-column decomposition + delta
  - Set: wide-column decomposition + delta
  - ZSet: delta on existing wide-column (PR #483)
  - Unified compactor, shared delta limits, transition plan per type

- Rename list_metadata_delta_design.md to collection_metadata_delta_design.md
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

/gemini review

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a wide-column storage format for Redis Sorted Sets, replacing the legacy single-blob storage with granular keys for metadata, members, and a score-based index. The implementation includes migration logic and support for standard ZSet commands. Review feedback highlights significant scalability risks, noting that several operations still load entire sets into memory or generate excessively large transaction batches, which could lead to OOM errors or cluster instability with large datasets.

Comment on lines +165 to +187
func (r *RedisServer) loadZSetMembersMap(ctx context.Context, key []byte, readTS uint64) (zsetLoadResult, error) {
// Check wide-column meta first.
meta, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
if err != nil {
return zsetLoadResult{}, err
}
if metaExists {
entries, err := r.scanZSetAllMembers(ctx, key, meta.Len, readTS)
if err != nil {
return zsetLoadResult{}, err
}
return zsetLoadResult{members: zsetEntriesToMap(entries), exists: true}, nil
}
// Fall back to legacy blob format.
value, exists, err := r.loadZSetAt(ctx, key, readTS)
if err != nil {
return zsetLoadResult{}, err
}
if !exists {
return zsetLoadResult{members: map[string]float64{}}, nil
}
return zsetLoadResult{members: zsetEntriesToMap(value.Entries), exists: true, fromLegacy: true}, nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The loadZSetMembersMap function loads the entire sorted set into an in-memory map. For large sets, this can lead to excessive memory consumption and potential OOM (Out of Memory) errors on the adapter node. This monolithic access pattern defeats the scalability benefits of the new wide-column storage format. Consider refactoring commands like ZADD, ZREM, and ZSCORE to use point lookups or range scans instead of loading the full set.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

}

value, _, err := c.server.loadZSetAt(context.Background(), key, c.startTS)
load, err := c.server.loadZSetMembersMap(context.Background(), key, c.startTS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the adapter commands, Lua scripts also load the entire ZSet into memory via loadZSetMembersMap. This poses a significant OOM risk for large sets processed within scripts. Consider providing Lua APIs that can perform partial updates or range scans on ZSets.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

Comment on lines +196 to +228
func (r *RedisServer) deleteZSetWideColumnElems(ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) {
meta, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
if err != nil {
return nil, err
}
if !metaExists {
return nil, nil
}

elems := []*kv.Elem[kv.OP]{
{Op: kv.Del, Key: store.ZSetMetaKey(key)},
}

// Use meta.Len to bound the scan. Each member has one member key and one
// score-index key, so we expect exactly meta.Len entries per prefix.
scanLimit := int(meta.Len)

// Scan member keys and score-index keys for individual deletion.
for _, prefix := range [][]byte{
store.ZSetMemberScanPrefix(key),
store.ZSetScoreScanPrefix(key),
} {
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
if err != nil {
return nil, errors.WithStack(err)
}
for _, kvp := range kvs {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}
}

return elems, nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

deleteZSetWideColumnElems performs a full scan of all member and score index keys and generates individual kv.Del operations for each. For large ZSets, this creates a massive transaction that can exceed Raft message size limits and cause cluster instability. Additionally, the scan itself can lead to OOM if the number of keys is very large. Consider implementing a more scalable deletion strategy, such as asynchronous background cleanup or using a single prefix deletion if the system constraints allow.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

return 0, wrongTypeError()
}
value, _, err := r.loadZSetAt(context.Background(), key, readTS)
load, err := r.loadZSetMembersMap(context.Background(), key, readTS)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

zaddTxn (and similarly zincrby at line 1399) loads the entire ZSet map to perform updates. This is inefficient for large sets. Point lookups using the new wide-column keys should be used to check for member existence and retrieve old scores, allowing for O(1) updates regardless of the set size.

References
  1. To prevent unbounded memory growth and potential OOM issues, apply a fixed bound to collections that can grow from external requests, such as pending configuration changes. Reject new requests when the bound is reached.

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

変更ファイル: adapter/redis.go, redis_compat_commands.go, redis_compat_helpers.go, redis_compat_types.go, redis_lua_context.go, redis_retry.go, redis_retry_test.go, redis_storage_migration_test.go, store/zset_helpers.go(新規)


問題点

1. 新規ファイル store/zset_helpers.go に対するユニットテストが皆無(重要度: 高)

298行に及ぶ新規ファイルに含まれる以下の関数が一切テストされていません:

  • キー構築: ZSetMetaKey(), ZSetMemberKey(), ZSetScoreKey(), ZSetScoreScanPrefix()
  • レンジスキャン: ZSetScoreRangeStart(), ZSetScoreRangeEnd()
  • エンコーディング: EncodeSortableFloat64(), DecodeSortableFloat64()
  • デコーディング: ExtractZSetUserKey(), ExtractZSetScoreAndMember(), ExtractZSetMember()
  • ユーティリティ: PrefixEnd()

提案するテストケース (store/zset_helpers_test.go):

// TestEncodeSortableFloat64RoundTrip: 様々なスコアをエンコード→デコードしても等値を返す
// TestEncodeSortableFloat64SortOrder: エンコード後のバイト列がスコアの昇順でソートされること
//   エッジケース: -∞, -0.0, +0.0, +∞, NaN, math.MinFloat64, math.MaxFloat64
// TestZSetScoreKeyOrdering: 異なるスコアのキーをバイト順にソートすると昇順になること
//   (スコアインデックスの正確な順序が ZRANGE の正確さに直結する)
// TestExtractZSetUserKey: メタ/メンバー/スコアキーからユーザーキーを正しく抽出できる
//   エッジケース: ユーザーキーが空、ユーザーキーが別のプレフィックスを持つ場合
// TestExtractZSetUserKeyShortKey: 短すぎるキーで nil を返すことを確認(クラッシュ防止)
// TestPrefixEnd: 通常バイト列、空スライス、全0xFFバイトのケースを検証
2. ZSet の差分コミット(origMembers / fromLegacy)のロジックにテストなし(重要度: 中)

zsetTxnStateorigMembers (ロード時スナップショット) と fromLegacy フラグが追加されました。差分ベースのコミット(buildZSetElems)は ZSet の正確性に直結しますが、以下のシナリオのテストが見当たりません:

// TestZSetZAddRemoveDiffCommit: ZADD 後に ZREM した場合の差分が正確に Raft にコミットされる
// TestZSetLegacyFlagMigrationPath: fromLegacy=true のとき旧JSONを削除しワイドカラムに書き直す
// TestZSetConcurrentZAddNoLostUpdate: 並行 ZADD でメンバーが失われないことを確認
3. redis_storage_migration_test.go は既存テストの修正のみ

TestRedisZSetLegacyJSONReadThenRewriteToProto の内部検証が更新されていますが、新しいワイドカラムレイアウト(メタキー・メンバーキー・スコアインデックスキーの3種が正しく書かれるか)を直接確認するテストは追加されていません。


まとめ

新機能 テスト有無
store/zset_helpers.go 全関数 なし
EncodeSortableFloat64 のスコートソート順 なし
ZSet ワイドカラム差分コミット なし
fromLegacy マイグレーションパス 部分的(既存テスト更新のみ)

スコアインデックスの正確性は ZRANGE/ZRANGEBYSCORE の正確さに直結するため、特に EncodeSortableFloat64 の境界値テストを強く推奨します。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #483 の性能観点のレビューです。ZSet をワイドカラム形式に分解する設計変更自体は I/O 削減の方向性として正しいですが、以下の問題点があります。


🔴 重大: ZADD/ZINCRBY/ZREM がワイドカラムパスでも O(N) の全メンバーロード

該当箇所: adapter/redis_compat_commands.gozaddTxn / zincrby / zremrangebyrankInner(legacyパス) および adapter/redis_compat_helpers.goloadZSetMembersMap

// zaddTxn — 1メンバー追加でも全件スキャン
load, err := r.loadZSetMembersMap(context.Background(), key, readTS)
// → loadZSetMetaAt(1 I/O) + scanZSetAllMembers(N I/O) + zsetEntriesToMap(O(N) alloc)

ワイドカラム化の主なメリットは「単一メンバー操作を O(1) I/O にする」ことですが、現実装では loadZSetMembersMap が meta → 全件 scan → map 変換と O(N) I/O を実行しています。ZSet に 10,000 メンバーがあれば、ZADD 1件でも 10,001 回の I/O が発生します。

提案: ZADD/ZINCRBY は「古いスコアキーを取得する → member key のみ点読み (O(1) I/O)」で実現できます。zremrangebyrankInner の wide-column パスがすでに scanZSetScoreEntries(limit=e+1) で部分スキャンしている設計を、ZADD/ZINCRBY/ZREM 単体操作にも適用すべきです。


🟠 重要: 書き込みのたびに O(N) のマップコピーが 2 回発生

該当箇所: adapter/redis.go:1517-1520adapter/redis_compat_commands.go 各 write 操作、adapter/redis_lua_context.go:637-640

// loadZSetState — origMembers スナップショット
origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

// 呼び出し元 (例: zaddTxn) — さらにもう 1 コピー
members := make(map[string]float64, len(load.members))
for k, v := range load.members {
    members[k] = v
}

単一メンバー操作でも常に「全メンバーマップ × 2」のメモリを確保しています。N=10,000 なら 16 バイト × 10,000 × 2 = 320KB のアロケーションが毎回発生します。


🟠 重要: deleteZSetWideColumnElems が member/score 各 O(N) スキャンを 2 回実施

該当箇所: adapter/redis_compat_helpers.go

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),  // N 件スキャン
    store.ZSetScoreScanPrefix(key),   // N 件スキャン(1:1 対応なのに別スキャン)
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    ...
}

member キーと score インデックスキーは 1:1 対応しているため、member キーのみスキャンして score キーを再構成すれば 1 回のスキャンで済みます。FLUSHALL や DEL のホットパスで 2× の I/O が発生します。


🟡 軽微: buildZSetDiffElems の初期 cap が 0

該当箇所: adapter/redis_compat_helpers.go

elems := make([]*kv.Elem[kv.OP], 0)  // cap=0、差分サイズが不明

差分の最大サイズは 2*(len(origMembers)+len(newMembers))+1 で上界が取れます。事前確保でリアロケーションを防げます。


🟡 軽微: キー構築関数ごとに 3 アロケーション(ホットパスに多数呼出し)

該当箇所: store/zset_helpers.goZSetMetaKey / ZSetMemberKey / ZSetScoreKey

func ZSetMetaKey(userKey []byte) []byte {
    prefix := []byte(ZSetMetaPrefix)    // alloc 1: string → []byte
    comp := zsetUserKeyComponent(userKey) // alloc 2: intermediate buf
    buf := make([]byte, 0, len(prefix)+len(comp)) // alloc 3: final buf
    ...
}

ZADD 1件で meta/member/score の 3 キーを構築 → 9 アロケーション。len(ZSetMetaPrefix) は定数なのでコンパイル時定数化し、単一バッファに直接書き込む設計にするとアロケーションが 1 回になります。


まとめ

優先度 問題 影響
🔴 重大 ZADD 等が wide-column でも全件 O(N) I/O ロード 大規模 ZSet で性能劣化
🟠 重要 書き込み毎に O(N) map × 2 コピー メモリ圧力・GC 負荷増
🟠 重要 delete で member/score を別々にスキャン I/O 2×
🟡 軽微 buildZSetDiffElems cap=0 realloc によるパフォーマンスロス
🟡 軽微 キー構築で 3 alloc/キー ホットパスでの GC 圧

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

問題: スコアのみ変更時にメタキーが更新されず、競合検出が機能しない(線形化可能性違反)

buildZSetDiffElems のメタキー更新ロジック(store/zset_helpers.go の呼び出し元):

} else if len(origMembers) != len(newMembers) {
    // Only update meta if cardinality changed
    elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})
}

メンバー数が変わらない場合(スコア更新: ZADD/ZINCRBY で既存メンバーのスコアだけ変わるケース)、メタキー (!zs|meta|...) が書き込まれません。

一方、txnContext.loadZSetState で追跡される読み取りキーはメタキーのみです:

t.trackReadKey(store.ZSetMetaKey(key))

個々のメンバーキー (!zs|mem|...) やスコアインデックスキー (!zs|scr|...) は読み取りセットに追加されません。

問題のシナリオ(Lost Update):

時刻 T1 T2
t=10 ZSet {a:1.0, b:2.0} を読む。readSet: {ZSetMetaKey} ZSet {a:1.0, b:2.0} を読む。readSet: {ZSetMetaKey}
t=11 a のスコアを 3.0 に更新。メンバー数不変→メタキー書き込みなしZSetMemberKey(a)=3.0, ZSetScoreKey(a,3.0) を書き込み、ZSetScoreKey(a,1.0) を削除
t=12 a のスコアを 5.0 に更新。競合チェック: latestTS(ZSetMetaKey) > 10? → T2 がメタを更新していないため False → 競合未検出! ZSetScoreKey(a,1.0) を削除(T2 がすでに削除済み: noop)。ZSetScoreKey(a,5.0) を書き込み

結果: ZSetMemberKey(a)=5.0(T1 が上書き)だが、ZSetScoreKey(a,3.0)(T2 が書いたもの)が残留してスコアインデックスに不整合が生じます。ZRANGEBYSCORE などでゴーストエントリとして現れます。

修正案: メンバー数が変わらない場合でも、スコア変更があれば常にメタキーを更新する(値を同一のままでも書き込む)か、個々のメンバーキー・スコアインデックスキーも読み取りセットに追加する。

例えばメタキーを常に書き込む場合:

// len(origMembers) != len(newMembers) の条件を削除し、len(newMembers) > 0 なら常に更新
metaBytes, err := store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(newMembers))})
if err != nil {
    return nil, errors.WithStack(err)
}
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})

この変更により、ZINCRBY/ZADD(スコア更新)でもメタキーへの書き込みが競合検出のアンカーとなり、線形化可能性が保たれます。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

P1: キー構築関数での多重アロケーション(ホットパス・ループ内)

store/zset_helpers.go:43–103

zsetUserKeyComponent() が中間バッファを毎回 make し、その後各キー構築関数(ZSetMemberKey, ZSetScoreKey 等)がさらに []byte(ZSetMemberPrefix) の変換バッファと最終バッファを確保している。1キー構築あたり 3アロケーション(prefix変換・中間component・最終buf)が発生する。

buildZSetWriteElemsbuildZSetDiffElems でメンバーループ内から ZSetMemberKey + ZSetScoreKey + MarshalZSetScore を呼ぶため、N メンバーの書き込みで 約 7N 回のアロケーションになる。

// 現状: ZSetMemberKey の例
func ZSetMemberKey(userKey, member []byte) []byte {
    prefix := []byte(ZSetMemberPrefix)     // alloc 1
    comp := zsetUserKeyComponent(userKey)  // alloc 2 (内部make)
    buf := make(...)                        // alloc 3
    ...
}

改善案:zsetUserKeyComponent を廃止し、各キー構築関数で 1 回の make に統合する。

func ZSetMemberKey(userKey, member []byte) []byte {
    prefixLen := len(ZSetMemberPrefix)
    buf := make([]byte, 0, prefixLen+zsetUserKeyLenSize+len(userKey)+len(member))
    buf = append(buf, ZSetMemberPrefix...)
    binary.BigEndian.AppendUint32(buf[len(buf):len(buf)+zsetUserKeyLenSize], uint32(len(userKey)))
    buf = buf[:len(buf)+zsetUserKeyLenSize]
    buf = append(buf, userKey...)
    buf = append(buf, member...)
    return buf
}

P2: buildZSetDiffElems の初期容量未指定

adapter/redis_compat_helpers.go:264

elems := make([]*kv.Elem[kv.OP], 0)  // cap指定なし

差分サイズに応じて複数回リアロケーションが発生する。削除/追加数は事前に分からないが、上限は len(origMembers)*3 + len(newMembers)*2 + 1 で見積もれる。あるいは小さい値(例 len(newMembers)*2+1)で事前確保するだけでも大幅に改善できる。


P3: 書き込みホットパスでの O(N) マップフルコピー

adapter/redis_compat_commands.go:1359–1362, 1406–1408 および redis_compat_commands.go zrem

zaddTxn, zincrby, zrem のいずれも、loadZSetMembersMap で全メンバーロード後にさらに全コピーを実行している:

members := make(map[string]float64, len(load.members))
for k, v := range load.members {
    members[k] = v
}

1メンバーのみ変更する ZADD/ZINCRBY/ZREM でも ZSet 全件のコピーが走る。差分計算(buildZSetDiffElems)には load.members(変更前)と newMembers(変更後)の両方が必要なので、コピー自体を完全に省くことは難しいが、origMembers として load.members を参照渡しのまま扱い、変更後のマップだけを別途構築する設計(例: 変更エントリのみ追跡する小さなデルタ構造)にすれば O(delta) に下げられる。


P4: MarshalZSetScore のループ内毎回アロケーション

store/zset_helpers.go:194

func MarshalZSetScore(score float64) []byte {
    buf := make([]byte, zsetScoreBinarySize)  // 毎回ヒープアロケーション
    binary.BigEndian.PutUint64(buf, math.Float64bits(score))
    return buf
}

buildZSetWriteElemsbuildZSetDiffElems のメンバーループ内で毎回呼ばれる。スコアバイト列を呼び出し元バッファに直接書く([8]byte スタック配列 + Value フィールドへのスライス)か、kv.Elem.Value の確保と統合することで GC 圧力を削減できる。


P5: localKeysPattern でのフルプレフィックススキャン(KEYS/SCAN コマンド)

adapter/redis.go:1190–1197

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {

ZSetMeta キーの length-prefix エンコーディングにより、他の内部プレフィックスと異なりパターンでスキャン範囲を絞れない。そのため KEYS/SCAN 時に 全 ZSet メタキーをフルスキャンしてメモリ上でフィルタリングしている。コード内コメントで言及されているが、ZSet 数が多い環境では他の型(list, hash 等)と比べて KEYS/SCAN コストが著しく高くなる。設計上の制限として既知であれば TODO コメントや issue として記録を残すことを推奨。


P6: rawKeyTypeAt における ZSet の常時デュアルルックアップ

adapter/redis_compat_helpers.go:25–26

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},
{typ: redisTypeZSet, key: redisZSetKey(key)}, // legacy blob fallback

型判定のたびに常に2回のストアアクセスが発生する。マイグレーション完了後もこのオーバーヘッドが残る。マイグレーション完了フラグ(ノード再起動後等)によるショートサーキット、または rawKeyTypeAt の呼び出し元で ZSet が wide-column 形式かどうかをキャッシュする仕組みがあると改善できる。


総評

今回の変更は「全件 blob 書き込み → 差分書き込み」への移行であり、ストレージ I/O の観点では大きな改善です。上記 P1〜P4 はそのメリットを活かすために メモリアロケーション数を削減する 方向の改善点です。P5・P6 は現状でも機能上問題ないですが、規模が大きくなると顕在化しやすいポイントです。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

対象: adapter: decompose ZSet storage into per-member wide-column keys

指摘事項: 2件


[重大] !zs| プレフィックスが PR #486knownInternalPrefixes に含まれていない (cross-PR 整合性問題)

関連箇所: store/zset_helpers.goZSetMetaPrefix, ZSetMemberPrefix, ZSetScorePrefix

本PRは !zs|meta|, !zs|mem|, !zs|scr| という新しいプレフィックスでZSetデータを格納する。
しかし PR #486adapter/redis_compat_types.go にある knownInternalPrefixes にはこれらが含まれていない。

両PRがマージされると、FLUSHLEGACY!zs|* キーを「レガシーな裸キー」と誤認識してZSetデータ全体を削除してしまう。

// PR #486 の knownInternalPrefixes(現状)
var knownInternalPrefixes = [][]byte{
    []byte("!redis|"),
    []byte("!lst|"),
    []byte("!txn|"),
    []byte("!ddb|"),
    []byte("!s3|"),
    []byte("!dist|"),
    // !zs| が欠けている → FLUSHLEGACY でZSetデータが消える
}

提案: PR #486knownInternalPrefixes[]byte("!zs|") を追加する。マージ順序に依存しないよう、双方のPRを調整してからマージすることを推奨。


[中] deleteZSetWideColumnElemsscanLimit がメタカウントに依存

箇所: adapter/redis_compat_helpers.godeleteZSetWideColumnElems

scanLimit := int(meta.Len)

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    // ...
}

meta.LenbuildZSetDiffElems により メンバー数が変化しない更新(スコア変更のみ)では更新されない
これ自体は正しい設計だが、もし何らかの理由でメタカウントが実際のメンバー数より小さくなった場合(例: 将来のバグ)、scanLimit によりメンバーキーやスコアインデックスキーの削除が不完全になる。

ScanAt のlimitを meta.Len * 2 とするか、limitなしスキャンを使う防御的な実装を検討されたい(パフォーマンストレードオフあり)。


確認できた正しい動作

  • マルチキー操作の原子性: buildZSetWriteElems / buildZSetDiffElems / buildZSetRemoveEntryElems が生成するKV操作群は単一の OperationGroup としてRaft経由でdispatchされるため、原子性が保たれている。
  • レガシー移行の原子性: wide-columnフル書き込みとlegacy blobの削除が同一バッチで行われるため、移行中の中間状態は発生しない。
  • スコアインデックスのエンコーディング: EncodeSortableFloat64 によりIEEE 754 float64がバイト比較可能な順序に正しく変換されている。negative/positive/zeroの符号ビット反転処理も正しい。
  • TTL追跡: ZSetキーに対して trackReadKey(redisTTLKey(store.ExtractZSetUserKey(key))) を追加しており、TTLの競合検出が正しく機能する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

1 件の重大なリスクと 1 件の軽微なリスクを確認しました。


[高] FLUSHALL/FLUSHDB が !zs| プレフィックスを削除しない (#486 との相互作用)

該当箇所: PR #486adapter/redis_compat_commands.go flushDatabase × 本 PR の store/zset_helpers.go

本 PR は ZSet データを以下の新プレフィックスに分解して格納します。

プレフィックス 用途
`!zs meta
`!zs mem
`!zs scr

一方、PR #486 で変更された FLUSHALL/FLUSHDB は !redis|!lst| のみを削除対象とし、!zs| プレフィックスは対象外です。

// PR #486 の flushDatabase(変更後)
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
// !zs|meta|, !zs|mem|, !zs|scr| は削除されない

両 PR がマージされると、FLUSHALL/FLUSHDB を実行しても ZSet データが残存します。ユーザーは Redis の FLUSHALL がデータを完全に消去すると期待するため、これは深刻な動作不一致です。

対処案: PR #486flushDatabase{Op: kv.DelPrefix, Key: []byte("!zs|")} を追加する。


[低] deleteZSetWideColumnElems のスキャン上限が meta.Len に依存

該当箇所: adapter/redis_compat_helpers.godeleteZSetWideColumnElems

scanLimit := int(meta.Len)
for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    // ← meta.Len より多くキーが存在した場合、末尾のキーが削除されない

通常は各書き込みトランザクション内でメンバー数と meta.Len が同期されるため問題になりませんが、万一 meta.Len が実キー数を下回る状態(例: 将来のバグ等による不整合)になると、削除しきれない孤立キーがストレージに残ります。ユーザーからは ZSet が削除されたように見えますが、内部には古いキーが残存します(ストレージリーク)。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

PR: adapter: decompose ZSet storage into per-member wide-column keys

問題あり(中程度)


1. ミュータブルな map を別名で渡している(latent aliasing bug)

該当箇所: bzPopMinLegacy および zremrangebyrankInner(レガシーフォールバックパス)

コード例(bzPopMinLegacy):

delete(load.members, popped.Member)  // load.members を直接変更
if err := r.persistZSetMembersTxn(ctx, key, readTS, load, load.members); err != nil {

問題: load.members をインプレースで変更したうえで、同じマップを load(origMembers として使用)と newMembers の両方として persistZSetMembersTxn に渡しています。

現在は安全な理由: レガシーパスでは load.fromLegacy == true が保証されるため、persistZSetMembersTxn 内の buildZSetDiffElems(key, load.members, newMembers) ブランチは実行されず、full-write パスが使われます。

潜在的リスク: 将来のリファクタリングや、データ不整合によって fromLegacy == false になった場合、buildZSetDiffElems は同一マップオブジェクト(origMembers == newMembers)を受け取り差分なし→要素数ゼロ→書き込みなし、という サイレントな no-op になります。結果としてメンバーが削除されずにコミットされ、ロストアップデート が発生します。

同様のパターン: zremrangebyrankInner レガシーパスでも同じ問題があります。

推奨修正:

// bzPopMinLegacy: newMembers は load.members のコピーを削除後に作成する
newMembers := make(map[string]float64, len(load.members))
for k, v := range load.members {
    newMembers[k] = v
}
delete(newMembers, popped.Member)
if err := r.persistZSetMembersTxn(ctx, key, readTS, load, newMembers); err != nil {

2. context.Background() の使用(既存の問題だが範囲拡大)

該当箇所: zaddTxn, zincrby, zrem など内部の loadZSetMembersMap(context.Background(), ...)

外部から渡された ctx(タイムアウト付き)がストレージ読み取りに伝播されていません。ディスパッチ側には伝播されているため致命的ではありませんが、ストレージ読み取りがハングした場合にキャンセルされません。既存のパターンの踏襲ではあるものの、今回の変更で影響範囲が広がっています。


3. meta.Len を scan limit に使用(軽微)

scanZSetAllMembersmeta.Len をそのままスキャン上限として使用しています。meta.Len が実際のメンバー数より小さい場合(例: 移行中の不整合)、全メンバーが返されない可能性があります。Raft の単一 OperationGroup 保証により通常は一致しますが、防御的に取り扱う価値があります。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


[P1] 読み取り専用アクセスでの不要な O(N) マップコピー

場所: adapter/redis.gotxnContext.loadZSetState

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

origMembersbuildZSetElemsbuildZSetStateElemsbuildZSetDiffElems でのみ参照され、dirty == true のときだけ実行される。ZSCORE・ZCARD・ZRANK 等の読み取り専用コマンドは dirty をセットしないため、このフルコピーは毎回無駄になる。メンバー数が多い ZSet では無視できないアロケーションコストとなる。

改善案: dirty フラグが立ったとき(最初の変更時点)に遅延コピーするか、origMembers を nil のまま保持して commit 時に buildZSetWriteElems(full write)にフォールバックする。


[P2] zaddTxn / zincrby での二重マップアロケーション

場所: adapter/redis_compat_commands.gozaddTxnzincrby

load, err := r.loadZSetMembersMap(...)        // map を新規確保
members := make(map[string]float64, len(load.members))  // さらにコピー
for k, v := range load.members {
    members[k] = v
}

loadZSetMembersMapzsetEntriesToMap で既に新規マップを確保して返す。その直後に呼び出し元が同サイズのマップを作って全要素をコピーしている。O(N) アロケーション + コピーが2回発生している。persistZSetMembersTxnload.members を diff の基準として使う設計上、コピーが必要なこと自体は理解できるが、loadZSetMembersMap が「変更用コピー」と「元スナップショット」を分けて返せばアロケーションを1回に削減できる。


[P3] deleteZSetWideColumnElems での二回の ScanAt

場所: adapter/redis.godeleteZSetWideColumnElems

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    ...
}

各プレフィックスが連続した範囲を占めていれば1回の広めのスキャンで代替できる可能性がある。構造上2スキャン不可避であれば問題なし。ただし scanLimitint(meta.Len) を使っている点は正しく有界化されており問題なし。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

問題なし。

確認した主要ポイント

Wide-column ZSet の meta/member/score キー整合性:

  • buildZSetWriteElems: meta.Len = len(members) を一括書き込み → 正しい
  • buildZSetDiffElems: カウント変化時のみ meta を更新。スコアのみ変更(カウント不変)の場合 meta を書かないのは Len の意味上正しい
  • buildZSetRemoveEntryElems: newLen = currentLen - len(toRemove) → 正しい

Conflict detection の正確性(zremrangebyrankInner wide-column パス):

  • 並行 ZADD がメンバー追加した場合: meta キーが更新される → 競合検出 ✓
  • 並行 ZADD が削除対象メンバーのスコアを変更した場合: member キーが更新される → 競合検出 ✓
  • 並行 ZADD が削除対象外メンバーのスコアを変更した場合: 異なるデータへの操作なので競合不要 → 正しい
  • dispatchElems(ctx, true, readTS, elems) の write-set ベース競合チェックで十分にカバーされている

Legacy blob → wide-column マイグレーションのアトミック性:

  • buildZSetStateElemsfromLegacy パス: 新フォーマット書き込みと旧 redisZSetKey 削除を同一 Raft operation group として送信 → アトミック ✓

TTL と ZSet データ本体の整合性:

  • loadZSetState が TTL チェックをメンバー読み出しより先に行い、期限切れの場合はメンバーを空として扱う → 一貫している ✓

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys

問題1 (MEDIUM): deleteZSetWideColumnElems のスキャン上限が meta.Len に依存しており、孤立キーが残存するリスク

該当箇所: adapter/redis_compat_helpers.go deleteZSetWideColumnElems()

scanLimit := int(meta.Len)
kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)

メンバーキー (!zs|mem|) とスコアインデックスキー (!zs|scr|) のスキャン上限を meta.Len で制限している。meta.Len が実際のメンバー数より小さい場合(例:将来的なバグや部分適用による不整合が起きた場合)、超過分のメンバーキー・スコアキーが削除対象に含まれず、ZSetを削除したにもかかわらず孤立キーが残存する。

現状、すべての書き込みはRaftを通じてアトミックにコミットされるため、通常運用中にmetaが不正確になることは想定されない。ただし、将来的な不整合に対して防御的に PrefixEnd スキャン(上限なし)で全削除する設計の方が安全性は高い。


問題2 (MEDIUM): zremrangebyrankInner での meta.Len を用いた新規メタ計算

該当箇所: adapter/redis_compat_commands.go zremrangebyrankInner()

entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS)
...
s, e = normalizeRankRange(start, stop, len(entries))
toRemove := entries[s : e+1]
elems, err := buildZSetRemoveEntryElems(key, toRemove, meta.Len)  // ← meta.Len を currentLen として使用

buildZSetRemoveEntryElemsnewLen = currentLen - len(toRemove) でメタを更新する。
スキャンが返す len(entries)meta.Len より小さい場合(メタ不整合状態)、newLen = meta.Len - len(toRemove) は実際の残存メンバー数より大きくなり、メタの不整合が次の操作へ伝播する。

正確には newLen = len(entries) - len(toRemove) を用いるか、スキャン結果の実測値をmetaに反映すべき。


問題3 (確認済み/問題なし): bzPopMinLegacy のpop順序

zsetMapToEntries(load.members)sortZSetEntries でスコア昇順ソートされるため、entries[0] が最小スコアメンバーであることを確認済み。データロストなし。


問題4 (確認済み/問題なし): レガシー→ワイドカラム移行のアトミック性

persistZSetMembersTxn + buildZSetWriteElems + Del redisZSetKey は単一の dispatchElems 呼び出しでRaftにアトミック送信される。移行途中での中断によるデータロストはない。


問題5 (確認済み/問題なし): buildZSetDiffElems のスコア変更時のメタ更新

スコア変更でメンバー数が変わらない場合、メタ (Len) を更新しない条件 len(origMembers) != len(newMembers) は正しい。メタはカーディナリティのみを保持するため、スコアのみの変更でメタ更新は不要。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: adapter: decompose ZSet storage into per-member wide-column keys


1. レガシーBLOB → ワイドカラム移行のアトミック性

問題なし

  • 移行は単一の MVCC トランザクション内で「ワイドカラムキーの書き込み + レガシー BLOB の削除」を一括コミットする設計
  • persistZSetMembersTxn および buildZSetStateElems の両パスとも、fromLegacy = true の場合にレガシーキー削除と新規書き込みを同一 dispatchElems に含めている
  • トランザクション失敗時は MVCC のロールバックにより元のレガシーデータが保持される

2. meta.Len の整合性維持

問題なし(ただし構造上の注意あり)

  • buildZSetWriteElems / buildZSetDiffElems / buildZSetRemoveEntryElems のすべての書き込みパスで、メンバー数の変化に応じて meta.Len を同一トランザクション内でアトミックに更新している
  • 注意点: scanZSetAllMembersmeta.Len をスキャン上限として使用するため、万一 meta.Len が実際のメンバー数より小さくなった場合、超過分のメンバーがエラーなく不可視になりデータロストに見える可能性がある。ただし現状の書き込みパスが正しく機能する限り、この不整合は発生しない

3. 削除パスの網羅性

問題なし

  • deleteLogicalKeyElemsdeleteZSetWideColumnElems の呼び出しを追加しており、DEL コマンドや TTL 期限切れ等の全削除パスでメタキー・メンバーキー・スコアインデックスキーの完全削除が保証されている
  • deleteZSetWideColumnElemsmeta.Len に依存せず math.MaxInt でフルスキャンして削除するため、孤立キーが残るリスクなし
  • レガシーBLOB のみの ZSet に対して空削除(fromLegacy=true かつ members={})が発生した場合も、ワイドカラムキーは存在しないため過剰削除は起きない

4. diff ベースコミット(buildZSetDiffElems)の正確性

問題なし

  • スコア変更時(メンバー数不変): 旧スコアインデックスを削除し新スコアインデックスと更新後メンバーキーを書き込む。カウント不変のため meta は更新不要で正しい
  • origMembersstartTS 時点のスナップショットとして独立コピーを保持しており、トランザクション内の変更が混入しない
  • 並行書き込みによる競合は MVCC の競合検知でリトライが走るため、古い origMembers を基にした誤差分適用のリスクなし

5. zremrangebyrankInner のワイドカラムパス

軽微な注意点(データロストには至らない)

entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS)
// ...
elems, err := buildZSetRemoveEntryElems(key, toRemove, meta.Len)
  • buildZSetRemoveEntryElemsnewLen = meta.Len - len(toRemove) で残存カウントを計算する
  • meta.Len が実際のメンバー数と一致している正常系では問題なし
  • 万一 meta.Len が過小であった場合、newLen がマイナスになり得るが、newLen <= 0 の場合はメタキーを削除する分岐があり、メタキーが残留する方向へのデータロストは発生しない

総評

データロストリスク: なし(正常運用時)

レガシーからワイドカラムへの移行はアトミックに設計されており、書き込みパス全体で meta.Len の整合性が保たれている。削除パスも網羅的に更新されており、ワイドカラムキーの孤立リスクは低い。唯一の構造的弱点は meta.Len を信頼してスキャン上限とする箇所だが、現状のコードでは meta.Len が不整合になるパスは確認されない。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

対象: #483 adapter: decompose ZSet storage into per-member wide-column keys


問題1 (中リスク): loadZSetState における書き込み競合検出キーの変更

場所: adapter/redis.gotxnContext.loadZSetState

- t.trackReadKey(redisZSetKey(key))
+ t.trackReadKey(store.ZSetMetaKey(key))

trackTypeReadKeys はこのPRで store.ZSetMetaKey(key) を追加しており、通常の MULTI/EXEC フローでは型チェック → 状態ロードの順に呼ばれるため問題ない。ただし、loadZSetStatetrackTypeReadKeys を経由せず直接呼ばれるコードパスが存在する場合、レガシーblobキー (redisZSetKey) の書き込み競合を検出できなくなる。

以前の実装では loadZSetState 自体が redisZSetKey を追跡することでフォールバックとして機能していたが、その安全網が失われた。MVCC + Raft で並行書き込みがある状況でレガシー形式のZSetに対してロストアップデートが発生するシナリオを確認してほしい。


問題2 (中リスク): zremrangebyrankInnerbzPopMinLegacy でのエイリアスパターン

場所: adapter/redis_compat_commands.go

// zremrangebyrankInner のレガシーフォールバック
for _, entry := range entries[s : e+1] {
    delete(load.members, entry.Member)  // load.members を直接変異
}
return r.persistZSetMembersTxn(ctx, key, readTS, load, load.members)
//                                              ^^^^           ^^^^
//                                         origが入るload    同じポインタ

persistZSetMembersTxn の第4引数 load.members フィールドと第5引数 newMembers が同一のマップポインタを指す。

現状は fromLegacy=true のパスのみ到達可能であり、buildZSetWriteElems(key, newMembers) が使われるため 今は 正しく動作する。しかし将来 fromLegacy=false のパスが通るようになった場合、buildZSetDiffElems(key, load.members, newMembers)origMembers == newMembers となり差分が空になって削除が無視されるサイレントバグになる。

bzPopMinLegacy (adapter/redis_compat_commands.godelete(load.members, popped.Member)persistZSetMembersTxn(ctx, key, readTS, load, load.members)) も同様のパターン。

修正案: 変異前にスナップショットを取るか、zaddTxn/zrem と同様に明示的なコピーを渡す。

// 修正例
remaining := make(map[string]float64, len(load.members))
for k, v := range load.members {
    remaining[k] = v
}
for _, entry := range entries[s : e+1] {
    delete(remaining, entry.Member)
}
return r.persistZSetMembersTxn(ctx, key, readTS, load, remaining)

問題3 (低リスク): deleteZSetWideColumnElems の大量スキャン + Raftバッチサイズ

場所: adapter/redis_compat_helpers.go

scanLimit := math.MaxInt

ZSet の全メンバーを1回のRaftエントリとして削除する設計だが、メンバー数が多いキーで DELEXPIRE を呼ぶと、全メンバー分の個別 Del オペレーションが1つのRaftバッチに積まれる。Raftのログエントリサイズ上限やアプライのタイムアウト次第で、大きなZSetの削除操作が可用性に影響する可能性がある。ログエントリサイズの上限と照合して問題がないか確認を推奨する。


問題4 (低リスク): int(memberCount) のキャスト

場所: adapter/redis_compat_helpers.goscanZSetAllMembers

return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)

memberCountint64 。32bit環境では int が32bitなので 2^31 超のZSetでサイレントに切り捨てが起きる。64bit環境のみサポートであればコメント追記で十分。


その他 (問題なし)

  • Raftリーダー切り替え時の安全性: レガシー→wide-column移行は単一Raftバッチで原子的に書かれており、retryRedisWrite のリトライ時も再読取り → 再書き込みになるため安全。
  • MVCCによるスナップショット一貫性: loadZSetMetaAt + scanZSetAllMembers は同一 readTS で読むため、メタとメンバー間でスナップショット不整合は起きない。
  • goroutineリーク / チャネルブロッキング: このPRに新規goroutineの生成はなく、問題なし。
  • コンテキストキャンセル: context.Background() の多用は既存パターンであり、今回の変更での新規導入はない。
  • buildZSetDiffElems スコア変更時のメタ非更新: 件数変化なしはメタ不要であり正しい動作。
  • フォロワー→リーダープロキシ: proxyToLeader の呼び出し箇所に変更なし。

総評: 正確性の観点では問題1・2が主要な懸念点。問題2は現時点でのバグではないが将来的なデグレのリスクが高いため、防御的なコピーを導入することを推奨する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #483「ZSet ストレージをper-member wide-column キーに分解」を、データ一貫性の観点からレビューしました。


問題1: loadZSetState の read tracking 対象キー変更による linearizability 違反リスク【重大】

場所: adapter/redis.goloadZSetState

- t.trackReadKey(redisZSetKey(key))
+ t.trackReadKey(store.ZSetMetaKey(key))

旧来形式(legacy blob)のZSetでは実データは redisZSetKey(key) に格納されており、store.ZSetMetaKey(key) はまだ存在しない。OCC(楽観的並行制御)の競合検出は追跡キーへの concurrent write を検出することで機能するため、存在しないメタキーを追跡しても競合は検出されない。

別のトランザクションが同一 legacy ZSet に対し並行書き込みをしても競合として検出されず、**lost update(更新消失)**が発生するリスクがある。

補足: MULTI/EXEC パスでは trackTypeReadKeys がその前に redisZSetKey(key) を追跡するため影響は限定的だが、型チェックが先行しない呼び出しパスでは保護が効かない。


問題2: zremrangebyrankInner での二重 normalizeRankRange によるランクずれ【高】

場所: adapter/redis_compat_commands.gozremrangebyrankInner の wide-column パス

s, e := normalizeRankRange(start, stop, int(meta.Len))  // ① meta.Len を基準に e を決定
// ...
entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS)  // ② e+1 でスキャン上限を設定
s, e = normalizeRankRange(start, stop, len(entries))           // ③ 実スキャン件数で再正規化

①で meta.Len を前提に e を計算し、②でそれを上限としてスキャンする。③では実際のスキャン件数 len(entries) で再度 normalizeRankRange を呼ぶが、meta.Len が実エントリ数と乖離している場合、②のスキャン上限と③の正規化の前提が食い違い、削除対象のランク範囲がずれる

ZREMRANGEBYRANK はスコア順序に依存するため、誤ったメンバーが削除される可能性がある。


問題3: scanZSetAllMembersmeta.Len 依存によるサイレント切り捨てリスク【中】

場所: adapter/redis_compat_helpers.goscanZSetAllMembers

func (r *RedisServer) scanZSetAllMembers(ctx context.Context, key []byte, memberCount int64, readTS uint64) ([]redisZSetEntry, error) {
    return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)
}

スキャン上限を meta.Len に設定しているため、何らかの原因(バグや将来の変更)で meta.Len < 実エントリ数 になった場合、超過分のメンバーがサイレントに切り捨てられる。Raft による atomic write のおかげで通常はメタドリフトは発生しないが、スキャン後に取得件数と meta.Len を照合する防御的チェックがないため、不整合が無音で伝播するリスクがある。


問題4: wide-column ZSet を txn パスで空にした際の TTL キー未削除【中】

場所: adapter/redis.gobuildZSetStateElems

case len(st.members) == 0 && st.fromLegacy:
    // Legacy パス: TTL キーを明示的に削除 ✓
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},
    }, nil
case len(st.members) == 0:
    // Wide-column パス: buildZSetDiffElems を呼ぶが TTL は削除されない ✗
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members)

Legacy パスでは TTL キーを明示削除するが、wide-column パスの buildZSetDiffElems は TTL キーを削除しない。TTL が設定された ZSet を MULTI/EXEC や Lua スクリプト内で全メンバー削除した場合、TTL エントリがストアに残留する

非 txn パス(persistZSetMembersTxndeleteLogicalKeyElems)では TTL が正しく削除されるため、パス間で整合性が取れていない。


問題なし(確認済み)

  • プレフィックス・エンコーディングの一貫性: zsetUserKeyComponent による 4byte big-endian 長プレフィックスが全キー種別で統一されており、ユーザーキーが別ユーザーキーのプレフィックスになる場合の曖昧性も正しく防止されている。
  • Raft 経由のコミット: buildZSetWriteElems / buildZSetDiffElems で生成した全 elems は dispatchElems 経由で Raft にまとめて送信されており、ローカル直接書き込みは行われていない。
  • レプリカ間同期: メタ・メンバー・スコアインデックスの各キーが一括で Raft ログに含まれるため、部分適用は発生しない。
  • diff ベースコミットの正確性: buildZSetDiffElemsorigMembers スナップショットとの差分のみを書き込み、スコア変更時の旧スコアインデックス削除も正しく処理されている。
  • リードインデックスと読み取りの一貫性: startTS を全読み取りで一貫して使用しており、txn 開始後のスナップショット読み取りが保たれている。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

1. [要注意] TTLキーの削除漏れ — MULTI/EXECトランザクションパスにおける非対称性

場所: adapter/redis_compat_commands.gobuildZSetStateElemsadapter/redis_lua_context.gozsetCommitElems

問題:
buildZSetStateElems のレガシー空ケース(len(st.members) == 0 && st.fromLegacy)は redisTTLKey(keyBytes) を明示的に削除しているが、wide-column 空ケース(len(st.members) == 0 の else 分岐)は buildZSetDiffElems を呼ぶだけで TTL キーを削除しない。

// レガシー空ケース(TTLを削除する)
case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},  // ← TTL削除あり
    }, nil

// wide-column 空ケース(TTLを削除しない)
case len(st.members) == 0:
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members)  // ← TTL削除なし

データロストシナリオ:

  1. wide-column ZSet に EXPIRE で TTL を設定
  2. MULTI ブロック内で ZREM により全メンバーを削除(ZSet が空になる)
  3. wide-column パスでは meta・member・score キーが削除されるが、TTL キーが残存(ゴーストTTL)
  4. その後 ZADD で同じキーに新たなデータを書き込む(TTL は設定しない)
  5. ゴーストTTLが期限切れになると、新しく書いたデータが「期限切れ」として扱われ、loadZSetState 内の TTL チェックで非存在扱いされてデータが消失

補足: 非トランザクション系の ZREM(persistZSetMembersTxn)では空時に deleteLogicalKeyElems を呼ぶため TTL は正しく削除されている。また、PR以前のトランザクションパスも TTL を削除しなかった(既存の問題)が、本PRでレガシーケースが TTL を削除するようになったことで、レガシー・wide-column 間の挙動が 非対称 になった点が新たに生じている。redis_lua_context.gozsetCommitElems でも同様の非対称性がある。


2. [軽微] buildZSetRemoveEntryElems での meta.Len ドリフト

場所: adapter/redis_compat_helpers.gobuildZSetRemoveEntryElems

newLen := currentLen - int64(len(entries))

currentLen(= meta.Len)と実際のスコアインデックスに存在するメンバー数がズレている場合(孤立キーや部分書き込みによる不整合)、newLen が実際と乖離する。特に zremrangebyrankInner では meta.Len を元にスキャン上限 e+1 を計算するため、meta.Len < 実際のメンバー数 の場合、削除対象エントリのランク計算がずれ、一部のエントリが削除されずに残る可能性がある。

Raft によるアトミックコミットが正常に機能している限り meta.Len は常に整合しているため、通常運用では問題は発生しにくい。ただし、クラッシュリカバリやスナップショット復元後に不整合が生じた場合のリスクとして認識しておく価値がある。


3. [問題なし] その他の確認事項

  • レガシー→wide-column マイグレーションの原子性: persistZSetMembersTxn では新 wide-column キーの書き込みとレガシーblobの削除が単一の dispatchElems 呼び出しで Raft ログに一括コミットされる。中断による部分書き込みのリスクなし。
  • deleteLogicalKeyElems の wide-column 対応: deleteZSetWideColumnElems を呼ぶことで DEL コマンド等が wide-column ZSet の全キー(meta・member・score)を正しく削除するよう変更されており、孤立キーが残らない設計になっている。
  • buildZSetDiffElems のスコア変更処理: スコア変更時に旧スコアインデックスキーを削除し、新スコアインデックスキーとメンバーキーを書き込む処理が正しく実装されている。
  • 差分書き込みパス(ZADD/ZINCRBY/ZREM 等): origMembers を確実にコピーしてから操作しており、差分計算のベースが汚染される問題は見当たらない。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

対象PR: adapter: decompose ZSet storage into per-member wide-column keys

全体的に diff-based commit による書き込み最小化は良い改善ですが、以下の性能上の懸念点があります。


[高] ホットパスでの O(N) マップコピー

ファイル: adapter/redis_compat_commands.gozaddTxn, zincrby, zrem

members := make(map[string]float64, len(load.members))
for k, v := range load.members {
    members[k] = v
}

ZSet の全メンバーを毎回フルコピーしてから差分を計算している。1 件の ZADD/ZREM/ZINCRBY でも N メンバー分のコピーが走る。buildZSetDiffElems に渡すために「変更前スナップショット」が必要なのは理解できるが、同じ呼び出し箇所が 3 か所あり、各コマンドで毎回 O(N) のコストが発生する。

提案: ロードした load.members を origMembers として直接保持し、mutations(追加・スコア変更・削除)を個別に記録するか、もしくは zaddTxn など単一メンバー操作では buildZSetDiffElems ではなく差分を直接組み立てることで O(1) コストにできる。


[中] key builder の多重アロケーション(N メンバー書き込み時に顕著)

ファイル: store/zset_helpers.goZSetMetaKey, ZSetMemberKey, ZSetScoreKey

各ビルダーで以下の 3 アロケーションが発生:

prefix := []byte(ZSetMetaPrefix)  // 文字列定数→[]byte 変換で毎回アロケーション
comp := zsetUserKeyComponent(userKey)  // 2回目
buf := make([]byte, 0, ...)            // 3回目

buildZSetWriteElems では 1 メンバーあたり ZSetMemberKey + ZSetScoreKey の 2 回呼び出し → N メンバーで約 6N 回のアロケーション(+ MarshalZSetScore で N 回追加)。

提案:

  • パッケージレベルの var zsetMetaPrefixBytes = []byte(ZSetMetaPrefix) でキャッシュ、もしくは各関数内で最終バッファに直接 append する 1 アロケーション実装にする。
  • MarshalZSetScore の 8 バイトアロケーションも、呼び出し側でスタック上の [8]byte を使いまわせると GC 圧力を下げられる。

[中] deleteZSetWideColumnElems での無制限スキャン

ファイル: adapter/redis_compat_helpers.go

scanLimit := math.MaxInt
for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)

メンバー数が多い ZSet を DEL したとき、全メンバーキー + 全スコアインデックスキーを一括でメモリに読み込む(2N エントリ)。コメントに「kv.DelPrefix をトランザクションに混在させられないため個別削除が必要」とあるが、例えばチャンク単位(例: 1,000 件ずつ)のスキャン+バッチ削除を複数回のトランザクションに分けるか、上位で件数上限を meta.Len ベースに制限することでメモリ消費を抑えられる。


[低] rawKeyTypeAt での ZSet 二重ルックアップ

ファイル: adapter/redis_compat_helpers.go

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},
{typ: redisTypeZSet, key: redisZSetKey(key)}, // legacy blob fallback

型判定ごとに ZSet 用のストレージルックアップが 2 回走るようになった(旧: 1 回)。すべての ZSet 操作のホットパスで毎回 2 回 ExistsAt が呼ばれる。Wide-column 移行が完了するまでの過渡的コストとして許容できるが、移行完了後は legacy 行を削除すべき。


まとめ

# 箇所 深刻度 概要
1 zaddTxn/zincrby/zrem 毎書き込みで全メンバーマップを O(N) コピー
2 ZSetMemberKey 等キービルダー N メンバー書き込み時に 6N+ アロケーション
3 deleteZSetWideColumnElems 無制限スキャンで大 ZSet の DEL が OOM リスク
4 rawKeyTypeAt ZSet 型判定の二重ルックアップ

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

対象PR: adapter: decompose ZSet storage into per-member wide-column keys (#483)


✅ 十分にテストされている箇所

対象 テストファイル
EncodeSortableFloat64 / DecodeSortableFloat64 (ラウンドトリップ・順序・-0.0正規化) store/zset_helpers_test.go
MarshalZSetMeta / UnmarshalZSetMeta (正常系・負値エラー・不正長エラー) store/zset_helpers_test.go
PrefixEnd (境界値含む) store/zset_helpers_test.go
ExtractZSetUserKey (3種類のキー全て) store/zset_helpers_test.go
ExtractZSetScoreAndMember store/zset_helpers_test.go
IsZSetInternalKey / IsZSetMetaKey etc. store/zset_helpers_test.go
buildZSetDiffElems (スコア変更・削除・追加・変更なし・全削除の5ケース) adapter/redis_zset_test.go
normalizeRetryableRedisTxnKey のZSetキー対応 adapter/redis_retry_test.go
レガシーJSON→Wide-column マイグレーション後の検証 adapter/redis_storage_migration_test.go

⚠️ テストが不足している箇所

1. buildZSetRemoveEntryElems — テストなし

zremrangebyrankInnerbzPopMinWideColumn から使用される重要な関数だが、単体テストが存在しない。

提案テストケース:

// newLen <= 0 の場合: Delメタキー
func TestBuildZSetRemoveEntryElemsDeleteAll(t *testing.T) { ... }

// newLen > 0 の場合: Putメタキー(新カウント)
func TestBuildZSetRemoveEntryElemsPartial(t *testing.T) { ... }

2. buildZSetStateElems — テストなし

5分岐のロジック(fromLegacyかつ空・空・fromLegacy・新規・差分更新)が全てテストされていない。

提案テストケース:

func TestBuildZSetStateElemsLegacyRemoveAll(t *testing.T) { ... } // fromLegacy && empty
func TestBuildZSetStateElemsLegacyMigrate(t *testing.T)  { ... } // fromLegacy && non-empty
func TestBuildZSetStateElemsNewZSet(t *testing.T)         { ... } // origMembers == 0
func TestBuildZSetStateElemsDiff(t *testing.T)            { ... } // default(diff path)
func TestBuildZSetStateElemsDeleteAll(t *testing.T)       { ... } // empty && !fromLegacy

3. deleteZSetWideColumnElems — テストなし

メタキー + メンバーキー + スコアインデックスキーを全スキャンして削除Elemを生成する複雑な関数。

提案テストケース:

// Wide-columnデータを持つキーのDEL: メタ+メンバー+スコアキーが全てDel対象になるか
func TestDeleteZSetWideColumnElemsFullSet(t *testing.T) { ... }

// メタが存在しない場合: nil返却
func TestDeleteZSetWideColumnElemsNotExists(t *testing.T) { ... }

4. loadZSetMembersMap — テストなし

Wide-column優先・レガシーフォールバックの分岐を持つ中心的API。

提案テストケース:

func TestLoadZSetMembersMapWideColumn(t *testing.T)   { ... } // メタあり → スキャン結果を返す
func TestLoadZSetMembersMapLegacyFallback(t *testing.T) { ... } // メタなし・レガシーあり
func TestLoadZSetMembersMapNotExists(t *testing.T)     { ... } // キーなし → exists=false

5. scanZSetScoreEntries — テストなし

ZRANGEBZPOPMINZREMRANGEBYRANK のwide-columnパスで使用される。limitの境界やスコア順序が正しいかの検証が必要。

提案テストケース:

// N件挿入しlimit=2でスキャン → スコア昇順で2件返る
func TestScanZSetScoreEntriesLimit(t *testing.T) { ... }
// 空ZSet → 空スライス
func TestScanZSetScoreEntriesEmpty(t *testing.T) { ... }

6. localKeysPattern のZSetキー対応 — テストなし

KEYS * でwide-column形式のZSetユーザキーが返るかどうかの検証がない。既存の TestLocalKeysPattern_FindsListKeysForUserPrefix にZSetキーが含まれていない。

提案テストケース:

// ZSetMetaKeyをストアに入れた状態でlocalKeysPatternを呼ぶとユーザキーが返る
func TestLocalKeysPattern_FindsZSetKeys(t *testing.T) { ... }

7. collectUserKeys のZSetキー対応 — テストなし

TestCollectUserKeys_FiltersByPattern にZSet内部キー(ZSetMetaKeyなど)が含まれていない。

8. MarshalZSetScore / UnmarshalZSetScore — テストなし

スコア値のバイナリシリアライズ。現在 store/zset_helpers_test.go にラウンドトリップテストが存在しない。

提案テストケース:

func TestMarshalZSetScoreRoundTrip(t *testing.T) {
    for _, score := range []float64{-1.0, 0.0, 1.5, math.Inf(1)} {
        got, err := UnmarshalZSetScore(MarshalZSetScore(score))
        require.NoError(t, err)
        require.Equal(t, score, got)
    }
}
func TestUnmarshalZSetScoreInvalidLength(t *testing.T) { ... }

9. ZSetScoreRangeStart / ZSetScoreRangeEnd / ExtractZSetMember — テストなし

store/zset_helpers.go に追加されているが、テストが一切ない。(現時点でPR内での使用はないが、公開APIとして追加されている)

10. redisVisibleUserKey のZSetブランチ — テストなし

新たに追加した IsZSetInternalKeyExtractZSetUserKey 分岐の単体テストがない。


🔴 特に優先度が高い不足テスト

  1. buildZSetRemoveEntryElemsBZPOPMINZREMRANGEBYRANK のwide-columnパスの正確性に直結
  2. buildZSetStateElems — トランザクションコミットパスの5分岐が未検証
  3. deleteZSetWideColumnElemsDELコマンドでwide-column ZSetが完全に削除されるかが未検証
  4. loadZSetMembersMap + scanZSetScoreEntries — wide-column読み取りパスの統合テスト不足

備考

  • 並行アクセス: Raftベースのトランザクションでシリアライズされるため、buildZSetDiffElems 等への直接的な並行テストは不要と判断。ただし zaddTxnzincrby のリトライ競合シナリオ(wide-columnストアに対する)のテストは今後追加が望ましい。
  • マルチノードテスト: 今回の変更はストレージフォーマットの変更であり、Raftログのレプリケーション自体は変更していないため、既存のマルチノードテストが間接的にカバーしている範囲で許容範囲内。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

Issue 1 (Medium): bzPopMinLegacy / zremrangebyrankInnerload.members を変更後に同一参照を persistZSetMembersTxn に渡している

該当箇所: adapter/redis_compat_commands.go L1843〜1844, L1744〜1748

// bzPopMinLegacy
delete(load.members, popped.Member)   // ← load.members を in-place 変更
r.persistZSetMembersTxn(ctx, key, readTS, load, load.members)
//                                             ^^^^  ^^^^^^^^^^^^ 同じ変更済みマップ

// zremrangebyrankInner (legacy path)
for _, entry := range entries[s : e+1] {
    delete(load.members, entry.Member) // ← in-place 変更
}
r.persistZSetMembersTxn(ctx, key, readTS, load, load.members)

現状は両関数とも !metaExists パス専用であり、loadZSetMembersMap が返す fromLegacy=true のブランチ (buildZSetWriteElems) に必ず進むため、動作は正しいです。

しかし persistZSetMembersTxn 内の !fromLegacy ブランチ:

elems, err = buildZSetDiffElems(key, load.members, newMembers)
// origMembers == newMembers となりdiffが空 → 変更が無視される

に到達するコードパスが(例えば将来の移行フェーズ変更や、非存在キーへの稀なタイミング)で生じた場合、ZSet操作がサイレントに no-op になる 潜在バグです。

推奨修正: 変更前のスナップショットを明示的に保持してから渡す。

// 変更前スナップショット
origMembers := maps.Clone(load.members)
delete(load.members, popped.Member)
r.persistZSetMembersTxn(ctx, key, readTS,
    zsetLoadResult{members: origMembers, exists: load.exists, fromLegacy: load.fromLegacy},
    load.members)

Issue 2 (Low): zremrangebyrankInner wide-column パスで meta.Len をそのまま newLen 計算に使用

該当箇所: adapter/redis_compat_commands.go L1716, L1726

entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS) // 上限 e+1 件
...
toRemove := entries[s : e+1]
elems, err := buildZSetRemoveEntryElems(key, toRemove, meta.Len)
// → newLen = meta.Len - len(toRemove)

正常動作時は meta.Len と実メンバー数は一致するため問題になりません。ただし meta.Len が実際のスコアインデックスキー数と乖離している不整合状態(中断した書き込みなど)が万一存在した場合、newLen 計算が誤り、不整合をさらに悪化させる可能性があります。

deleteZSetWideColumnElemsmath.MaxInt でフルスキャンして安全を確保しているのと対称的に、こちらも実際のスキャン件数ベースで newLen を算出する方がより堅牢です。


問題なし (確認済み項目)

観点 評価
Raftリーダー切り替え proxyToLeader は全ZSet書き込みコマンドに存在、変更なし ✓
write conflict 検出 retryRedisWrite + MVCC readTS による競合検出は適切 ✓
競合トラッキングキー trackTypeReadKeysstore.ZSetMetaKey を追加、migration中の競合も検出可能 ✓
トランザクション原子性 legacy→wide-column migration は単一 Raft エントリで原子的に実行 ✓
ロック順序 Mutex/RWMutex の使用パターンに変更なし ✓
goroutine リーク 新規 goroutine 導入なし ✓
コンテキストキャンセル context.Background() 使用は既存コードからの踏襲(新規リグレッションなし)✓
フォロワー→リーダー プロキシ 全 ZSet コマンドで proxyToLeader 呼び出し済み ✓
TOCTOU (meta vs legacy 二重チェック) 同一 readTS による snapshot isolation で保護済み ✓

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

対象 PR

#483 adapter: decompose ZSet storage into per-member wide-column keys


問題あり(要確認・要対処)

[C1] 重大: scanZSetAllMembersmeta.Len をスキャン上限として使用しており、データ欠損の可能性がある

redis_compat_helpers.go

func (r *RedisServer) scanZSetAllMembers(..., memberCount int64, ...) {
    return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)
}

meta.Len をスキャン上限に使用しているため、何らかの原因で meta.Len が実際のエントリ数より小さい場合、一部のメンバーが読み取られない
その状態で buildZSetDiffElems によるインクリメンタル更新を行うと、読み込まれなかったメンバーは origMembers にも newMembers にも含まれず、メタキーの Len が過小な値で上書きされる。
これが繰り返されると meta.Len のアンダーカウントが雪だるま式に進行し、正常なメンバーが永続的に不可視になる

具体的トリガー: Raft コミット直後の障害、または後述 [C2] による不整合。


[C2] 重大: buildZSetRemoveEntryElemsmeta.Len を信頼して newLen を計算している

redis_compat_helpers.go

func buildZSetRemoveEntryElems(key []byte, entries []redisZSetEntry, currentLen int64) ... {
    newLen := currentLen - int64(len(entries))   // currentLen は meta.Len から来る
    ...
    elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ZSetMetaKey(key), Value: metaBytes})

bzPopMinWideColumnzremrangebyrankInner のワイドカラムパスいずれも meta.Len をそのまま currentLen に渡している。
deleteZSetWideColumnElems のコメントでは「orphaned keys from partial writes or meta.Len inconsistencies」を考慮したフルスキャンを行うとあるが、buildZSetRemoveEntryElems では meta.Len を無条件に正として扱い、実際のエントリ数を再確認せずに新しい Len を書き込む。
meta.Len が実際より大きい場合は正のままメタが更新され、 [C1] の問題を誘発する。

bzPopMinWideColumn の例:

// scanZSetScoreEntries は実際に得られた件数(1件)しか返さないが、
// metaLen は meta.Len の値がそのまま渡される
elems, err := buildZSetRemoveEntryElems(key, entries, metaLen)

Popして1件減った後の Len が「metaLen - 1」になるが、metaLen が実態と乖離していれば誤った値が永続化される。

対処案: buildZSetRemoveEntryElems 内、または呼び出し前に ScanAt で実際のエントリ数を確認してから Len を更新する。あるいは、Len の更新は差分ベースではなく実際のスキャン結果から算出する。


[C3] 軽微: loadZSetState での read key 追跡が ZSetMetaKey のみに変更されたことによる OCC 影響

adapter/redis.go

- t.trackReadKey(redisZSetKey(key))
+ t.trackReadKey(store.ZSetMetaKey(key))

ワイドカラム形式では、スコアのみの更新(ZINCRBY など)は buildZSetDiffElems の設計上 ZSetMetaKey を更新しない(メンバー数が変わらないため)。
txnContext(Lua EVAL / MULTI EXEC)内の ZSet 読み取りは ZSetMetaKey のみを read set に追跡するため、並行するスコア更新で ZSetMetaKey が更新されなければ OCC が競合を検出できない可能性がある

ただし、スコア更新は必ず ZSetMemberKey への Put を含むため、write-write 競合として store レベルで検出されることが期待される。store の OCC が write-write conflict を検出する実装になっていれば実害は生じないが、read-write conflict のみに依存している場合は Lost Update になりうる。store の conflict detection 方式の明示的な確認を推奨する。


問題なし(確認済み)

  • Raft 経由の書き込み: 全書き込みは dispatchElems(ctx, true, readTS, elems) 経由でリーダーに転送されており、ローカル直接実行なし ✓
  • TTL 整合性: fromLegacy=true, members=0 ケースでのレガシーキー + TTL の削除は正しく実装されている ✓
  • キーエンコーディングの一貫性: sortable float64 エンコード・デコードのラウンドトリップが正しく実装されており、バイト列の辞書順がスコアの数値順と一致する ✓
  • プレフィックス衝突防止: 4バイト big-endian の userKey 長プレフィックスにより、userKey がお互いのプレフィックスになるケースでの境界スキャン誤りを防いでいる ✓
  • レプリカ間同期: Raft ログを介した書き込みで全レプリカへの伝搬が保証されている ✓
  • レガシー → ワイドカラム移行の整合性: fromLegacy フラグによる分岐と legacy blob の削除が正しく組み合わされている ✓

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

このPRは ZSet ストレージをレガシーブロブ形式からワイドカラム形式(per-member キー)へ分解する変更です。
データロスの観点から以下の点を確認しました。


1. [Medium] scanZSetAllMembersmeta.Len を上限として使用している点

該当箇所: adapter/redis_compat_helpers.go - scanZSetAllMembers / loadZSetMembersMap

func (r *RedisServer) scanZSetAllMembers(ctx context.Context, key []byte, memberCount int64, readTS uint64) ([]redisZSetEntry, error) {
    return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)
}

meta.Len が実際のメンバーキー数より少ない場合(例: クラッシュによる部分書き込みで meta だけ更新されなかったケース)、スキャン結果が途中で打ち切られ、一部のメンバーが読み取り不能になります。

  • Raft の原子的コミットにより 通常運用では発生しない ため許容範囲内
  • ただし deleteZSetWideColumnElems では scanLimit = math.MaxInt で全キースキャンしており、こちらは安全に設計されている点は評価できます
  • 潜在リスクとして、将来のバグや移行バグが重なった際に ユーザーから見えないメンバーが発生する 可能性があります

推奨: meta.Len と実際のスキャン結果件数に差異があった場合にログ警告を出す、あるいは修復パスを設けると防衛的になります(必須ではありません)。


2. [Low] レガシー→ワイドカラム移行時の partial orphan リスク

該当箇所: adapter/redis.go - buildZSetStateElems

case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},
    }, nil

このケースはレガシーブロブが削除対象になった場合ですが、万一 前回の移行が部分成功(ワイドカラムキーが一部書き込まれた後にレガシーブロブも残存している状態)であった場合、ワイドカラムキーが孤立します。

ただし:

  • 移行は dispatchElems による 単一 Raft バッチ で行われるため、部分成功は基本的に起きない
  • 孤立したとしても TTL や削除の論理的可視性には影響しない

推奨: 理論上は問題ないが、防衛的に deleteZSetWideColumnElems を呼ぶことも検討できます。


3. [Low] TTL 期限切れ後にワイドカラムキーが物理的に残存

該当箇所: adapter/redis.go - loadZSetState

if ttlSt.value != nil && !ttlSt.value.After(time.Now()) {
    st := &zsetTxnState{
        members:     map[string]float64{},
        origMembers: map[string]float64{},
        exists:      false,
    }
    t.zsetStates[k] = st
    return st, nil
}

TTL 期限切れ時、論理的に空のステートを返しますが、物理的な meta/member/score キーは削除されません。これはレガシーブロブ形式と 同じ動作 であり、意図的な設計(遅延GC)と思われます。実際のデータロスではありません。


総評

重大なデータロスパスは確認されませんでした。

  • ワイドカラムへの移行は単一 Raft バッチで原子的に行われており、安全です
  • で空になった場合は を呼び出し、TTL キー・ワイドカラムキー双方が正しく削除されます
  • のメタ更新ロジック(カウント変化時のみ更新)は正しい設計です
  • Lua スクリプトパスでは全メンバー削除時に が呼ばれ、TTL も含めて正しくクリーンアップされます

上記の Medium 指摘は運用上の防衛的改善提案であり、現時点での致命的なデータロスリスクではありません。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

🔴 高優先度

1. localKeysPattern — ZSet メタキーの全件スキャン(O(total-ZSets))

ファイル: adapter/redis.go

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {
    return nil, err
}

KEYS/SCAN コマンド実行時、ユーザー指定のパターンに関係なく ストア内の全 ZSetMetaKey を無条件でスキャンしている。ZSet 数が多いストアでは、KEYS myspecifickey のような絞り込みパターンであっても O(全ZSet数) の I/O が発生する。

他の内部プレフィックス(Hash/Set/List など)はパターンから scan bounds を導出して絞り込みしているが、ZSet のみ全件スキャンになっている。ZSetMeta のエンコーディングが 4-byte length-prefixed であるため単純なパターンマッチが困難であることは理解できるが、mergeScannedKeys に渡す後段フィルタ (collectUserKeys) での絞り込みに全件 I/O が先行するため、ホットパスでのスケーラビリティに影響する。


2. 非トランザクション系コマンドでの O(N) フルマップコピー

ファイル: adapter/redis_compat_commands.gozaddTxn, zincrby, zrem

load, err := r.loadZSetMembersMap(...)
members := make(map[string]float64, len(load.members))  // 全件コピー O(N)
for k, v := range load.members {
    members[k] = v
}
// 1〜数件だけ変更
return r.persistZSetMembersTxn(ctx, key, readTS, load, members)

loadZSetMembersMap が内部で zsetEntriesToMap によりマップを 1 つ確保した後、呼び出し側がさらにそのマップを丸ごとコピーしている。diff 計算のため「原本」と「新状態」を別々に保持する必要があることは理解できるが、1 件の ZADD/ZINCRBY/ZREM のために N メンバー分 × 2 マップ が確保される。

ZSet が大きくなるほど、単一メンバー操作のコストが O(N) になる点は wide-column 化の設計トレードオフだが、diff-based commit を維持しつつコピーを 1 回に抑える実装(例: load.members を working copy として直接使い、変更前の値だけを changedBefore map[string]optionalFloat64 に記録する)を検討する価値がある。


🟡 中優先度

3. キー構築関数での多重アロケーション

ファイル: store/zset_helpers.go

ZSetMetaKey, ZSetMemberKey, ZSetScoreKey, ZSetScoreScanPrefix, ZSetMemberScanPrefix の各関数が、呼び出しごとに次の 3 回のアロケーションを行っている:

func ZSetMetaKey(userKey []byte) []byte {
    prefix := []byte(ZSetMetaPrefix)      // alloc 1: string→[]byte
    comp := zsetUserKeyComponent(userKey) // alloc 2: 中間バッファ
    buf := make([]byte, 0, ...)           // alloc 3: 結果バッファ
    ...
}

zsetUserKeyComponent は中間バッファを確保してから結果バッファにコピーしているが、これをインライン化すれば 1 アロケーションに削減できる。これらの関数は ZADD / ZRANGEBYSCORE / 削除などのホットパスで N メンバー分ループ内から呼ばれるため、削減効果が高い。

// 改善例
func ZSetMetaKey(userKey []byte) []byte {
    buf := make([]byte, 0, len(ZSetMetaPrefix)+zsetUserKeyLenSize+len(userKey))
    buf = append(buf, ZSetMetaPrefix...)
    buf = binary.BigEndian.AppendUint32(buf, uint32(len(userKey)))
    buf = append(buf, userKey...)
    return buf
}

4. MarshalZSetScore が N メンバー書き込み時に N 回 8-byte アロケーション

ファイル: store/zset_helpers.goadapter/redis_compat_helpers.gobuildZSetWriteElems

func MarshalZSetScore(score float64) []byte {
    buf := make([]byte, zsetScoreBinarySize) // 毎回 alloc
    binary.BigEndian.PutUint64(buf, math.Float64bits(score))
    return buf
}

buildZSetWriteElems では N メンバー分のループ内で呼ばれ、8-byte スライスを毎回確保する。スコア値は kv.ElemValue フィールドに格納されるため、kv.Elem ごとに小さなスライスが散在することになる。事前に make([]byte, N*8) で一括確保してスライスを切り出す方式に変更すると、アロケーション回数を O(N)→O(1) に削減できる。


🟢 低優先度

5. rawKeyTypeAt での ZSet 二重 ExistsAt チェック

ファイル: adapter/redis_compat_helpers.go

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},
{typ: redisTypeZSet, key: redisZSetKey(key)}, // legacy blob fallback

ZSet のみ 2 エントリが登録されており、wide-column meta が存在する場合でも legacy blob キーの ExistsAt が呼ばれる(ループが short-circuit しない実装であれば)。型チェック実装次第だが、wide-column が見つかった時点で early-return する最適化が可能か確認を推奨。


6. loadZSetState / luaZSetStateorigMembers スナップショットが dirty チェック前に確保

ファイル: adapter/redis.goloadZSetState)、adapter/redis_lua_context.go

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

このスナップショットはコミット時の diff 計算にのみ使われるが、読み取り専用トランザクション(dirty=false のまま終了)でも確保される。遅延初期化(初めて書き込みが発生したときにスナップショットを取る)に変更すると、read-heavy なワークロードでのアロケーションを削減できる。


まとめ: 大きな設計変更(diff-based commit による書き込み範囲の最小化、wide-column による細粒度ロック)はロック保持時間の短縮という観点で効果的です。ただし ①KEYS コマンドでの全ZSetスキャン と ②非トランザクション系コマンドでの O(N) 二重マップ は、ZSet 数・メンバー数が増加したときにホットパスのボトルネックになりうるため、優先的に対処を検討してください。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

概要

PR #483 の ZSet ストレージ分解(blob 形式 → wide-column 形式)に対するテスト網羅性レビューです。


✅ 適切にカバーされている箇所

  • store/zset_helpers_test.go: EncodeSortableFloat64 のラウンドトリップ・負のゼロ・順序保証、MarshalZSetMeta / UnmarshalZSetMeta のエッジケース(0, 1, MaxInt64, 負値)、PrefixEndExtractZSetUserKeyIsZSetInternalKey をカバー
  • adapter/redis_zset_test.go: buildZSetDiffElems の 5 パターン(スコア変更・削除・追加・変化なし・全削除)をカバー
  • adapter/redis_retry_test.go: ZSet 内部キー(meta/member/score 全種)のエラー正規化をカバー
  • adapter/redis_storage_migration_test.go: legacy blob → wide-column マイグレーション後のストレージ検証を更新済み

❌ 不足しているテスト(要対応)

1. (adapter/redis.go)— 直接テストなし

4 つの分岐が存在するが、いずれも直接テストされていない。

2. (adapter/redis_compat_helpers.go)— 直接テストなし

newLen <= 0(meta を Del)と newLen > 0(meta を Put)の 2 分岐が未テスト。

3. (adapter/redis_compat_helpers.go)— テストなし

meta + member + score のすべてを削除するロジックが無テスト。orphaned キーのスキャン境界も未検証。

4. の wide-column パス — テストなし

wide-column パス(metaExists=true)と legacy フォールバックの両方が未テスト。特に「削除範囲が全体」「単一メンバー」などの境界ケースが必要。

5. TTL × wide-column ZSet の相互作用 — テストなし

wide-column 形式の ZSet に TTL をセットし、期限切れ後のアクセスが「存在しない」を返すことが未検証。

6. の ZSet meta prefix スキャン — テストなし

adapter/redis.go に追加された ZSetMetaPrefix のスキャンパスが未テスト。 でユーザーキーが返り、内部の meta/member/score キーが漏れないことを検証すべき。

7. NaN スコアのエンコード — テスト対象外

EncodeSortableFloat64+Inf / -Inf を含めてテスト済みだが、NaN の扱いが未定義のまま。Redis は NaN を ZADD で拒否するが、zset_helpers.go レイヤーでは検証なし。API 境界での拒否テストを追加するか、本関数でパニックではなくエラーを返す設計にする場合はそのテストを追加すること。

8. Lua コンテキスト()の各分岐 — テストなし

redis_lua_context.go 内の 4 分岐(legacy 削除・legacy→wide-column マイグレーション・新規作成・diff 更新)が Lua 経由で明示的にテストされていない。


⚠️ 分散・並行性の観点

  • origMembers スナップショットによる diff コミットは、同一 ZSet への並行書き込み時の競合検出を trackReadKey(store.ZSetMetaKey(key)) に依存している。この競合検出が正しく機能することを示す並行アクセステストが存在しない。
  • 既存の TestRedisExecLuaCompatRetriesWriteConflict は 1 シナリオをカバーするが、複数のメンバーを同時に追加する競合ケースは未検証。

結論

コアとなる pure 関数(key encoding, meta serialization, diff 計算)のテストは十分。一方、コマンドレイヤーとの結合部分(、、、)と副作用を伴う新しい wide-column パスは直接テストが存在しない。上記の提案するテストケースの追加を推奨する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

問題1 [重大]: スコア変更時の ZSetMetaKey 未更新によるコンフリクト検出漏れ

場所: adapter/redis_compat_helpers.gobuildZSetDiffElemsadapter/redis.goloadZSetState

loadZSetState はコンフリクト検出のために ZSetMetaKey のみを readKeys に追跡します(PRの変更後):

t.trackReadKey(store.ZSetMetaKey(key))

一方 buildZSetDiffElems は、メンバー数が変わらない場合(スコアのみ変更)は ZSetMetaKey を書き込みません:

} else if len(origMembers) != len(newMembers) {
    // count が変わらなければ ZSetMetaKey は書き込まれない
}

validateReadSetreadKeys に対して LatestCommitTS > startTS かどうかだけを確認するため、次のシナリオで線形化可能性が壊れます:

  1. Tx A (MULTI/EXEC) が startTS=t0 で ZSet を読み、ZSetMetaKey を readKeys に追跡
  2. Tx B (単体 ZINCRBY) が member "x" のスコアを変更し、ZSetMemberKey + ZSetScoreKey のみ書き込む(ZSetMetaKey は変更なし)→ ts=t1 でコミット
  3. Tx A の validateReadSet: 「ZSetMetaKey は t0 以降に書き込まれたか?」 → No → コンフリクト未検出
  4. Tx A が Tx B の変更を無視した古いスコアで ZINCRBY を適用 → 更新の消失

修正案: スコアのみの変更であっても常に ZSetMetaKey を書き込む(バージョンセンチネルとして機能させる)か、あるいは loadZSetMembersMap でロードした各メンバーの ZSetMemberKey も readKeys に追跡する。


問題2 [中]: 新フォーマットで ZSet を空にした際の TTL キー残存

場所: adapter/redis.gobuildZSetStateElems

ZSet が空になった場合の処理の非対称性があります:

新フォーマットで全メンバーを ZREM した後、redisTTLKey がそのまま残ります。その後 ZADD すると、削除されたはずの古い TTL が新しい ZSet に引き継がれ、Redis のセマンティクス(最後のメンバー削除でキー自体が消え TTL もリセット)と乖離します。

修正案: case len(st.members) == 0: のブランチにも redisTTLKey の削除を追加する。


補足情報: レガシーキーのコンフリクト追跡削除について

loadZSetStateredisZSetKey(key)ZSetMetaKey(key) に変更されたため、移行期間中に旧フォーマットで書き込んだ並行トランザクションを MULTI/EXEC が検出しない可能性があります。ただし trackTypeReadKeys では redisZSetKey が引き続きトラックされているため、影響範囲は型チェック済みの操作に限定されています。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys


問題1: load.members のエイリアス渡し(中〜高リスク)

bzPopMinLegacyzremrangebyrankInner(レガシーパス)の両所で、load.members をインプレース変更した後、同じマップを loadnewMembers 両方として渡している。

bzPopMinLegacy(redis_compat_commands.go):

delete(load.members, popped.Member)
if err := r.persistZSetMembersTxn(ctx, key, readTS, load, load.members); err != nil {

zremrangebyrankInner(redis_compat_commands.go):

for _, entry := range entries[s : e+1] {
    delete(load.members, entry.Member)
}
return r.persistZSetMembersTxn(ctx, key, readTS, load, load.members)

persistZSetMembersTxn 内で buildZSetDiffElems(key, load.members, newMembers) が呼ばれる際、 load.members == newMembers(同一マップ)のため diff が空になる。現状は fromLegacy=true なので buildZSetWriteElems(全量書き込み)が使われて問題は顕在化しないが、この暗黙のカップリングは非常に脆弱

zaddTxnzremzincrby が明示的に make(map[string]float64) でコピーしているのとは対照的であり、将来の変更で削除が無音で失われるリスクがある。

修正案: 変更前の状態を別マップに保持するか、削除後に呼び出すことを明示したヘルパーに分離する。


問題2: deleteLogicalKeyElems と migration race(中リスク)

deleteLogicalKeyElems 内の deleteZSetWideColumnElemsreadTS 時点でのメタキー存在チェックに依存する:

zsetElems, err := r.deleteZSetWideColumnElems(ctx, key, readTS)

競合シナリオ:

  1. DEL keyreadTS = T1 でスナップショットを取得 → readTS=T1 時点ではレガシー形式のため metaExists=false → wide-column の削除 elem が生成されない
  2. 同時に別トランザクションが T2 > T1 で legacy → wide-column マイグレーション(新メタキー書き込み + レガシーキー削除)
  3. DEL がコミット試行: レガシーキーの Del が MVCC のwrite-write conflict を引き起こす(T2 でレガシーキーが削除済み)かどうかは MVCC 実装が Del-Del 競合を追跡するかどうかに依存

Delete がレガシーキーへの write-write conflict を検出してリトライすれば、再読み時に wide-column メタが見えて正しく削除できる。ただし、MVCC の実装が削除済みキーへの Del 操作を「write」として追跡しない場合、wide-column データが残存する。現在の store 実装でこの点が保証されているか確認を推奨。


問題3: ストレージ読み込みへの context.Background() 使用(低〜中リスク)

retryRedisWrite コールバック内で loadZSetMembersMaploadZSetEntriesSortedcontext.Background() で呼び出している:

// zaddTxn, zincrby, zrem, zrangeRead 等
load, err := r.loadZSetMembersMap(context.Background(), key, readTS)

外側の ctxredisDispatchTimeout 付き)が dispatchElems には渡されているが、ストレージ読み込みには伝播しない。wide-column への移行でスキャン操作(scanZSetAllMembers, scanZSetScoreEntries)が追加され、ストレージ読み込みの重みが増したため、ハング時にタイムアウトが機能しない問題が以前より顕著になった。


問題4: deleteZSetWideColumnElems の無制限スキャン(低リスク)

scanLimit := math.MaxInt

メンバー数が多いキーへの DEL コマンドで全件スキャンが走り、メモリ逼迫の可能性がある。これ自体は goroutine リークではないが、大規模 ZSet に対する DEL が長時間 Raft をブロックする可能性がある。meta.Len を上限として使用することを検討。


問題がない点

  • txnContext.loadZSetState での store.ZSetMetaKey(key) のread key追跡は適切
  • normalizeRetryableRedisTxnKey への ZSet 内部キー対応は正しい
  • buildZSetDiffElems でのメタ更新ロジック(カウント変化時のみ更新)は正しい
  • trackTypeReadKeys での store.ZSetMetaKey(key) 追加は適切
  • Raft エントリへのバッチ書き込みにより部分書き込みは発生しない設計は維持されている

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

概要

PR #483 の ZSet ストレージ分解(blob 形式 → wide-column 形式)に対するテスト網羅性レビューです。


✅ 適切にカバーされている箇所

  • store/zset_helpers_test.go: EncodeSortableFloat64 のラウンドトリップ・負のゼロ・順序保証、MarshalZSetMeta / UnmarshalZSetMeta のエッジケース(0, 1, MaxInt64, 負値)、PrefixEndExtractZSetUserKeyIsZSetInternalKey をカバー
  • adapter/redis_zset_test.go: buildZSetDiffElems の 5 パターン(スコア変更・削除・追加・変化なし・全削除)をカバー
  • adapter/redis_retry_test.go: ZSet 内部キー(meta/member/score 全種)のエラー正規化をカバー
  • adapter/redis_storage_migration_test.go: legacy blob → wide-column マイグレーション後のストレージ検証を更新済み

❌ 不足しているテスト(要対応)

1. buildZSetStateElems(adapter/redis.go)— 直接テストなし

4 つの分岐が存在するが、いずれも直接テストされていない。

提案するテストケース:

  • TestBuildZSetStateElemsLegacyDeletefromLegacy=true かつ members 空 → redisZSetKey + TTL キーの Del が返ること
  • TestBuildZSetStateElemsNewFormatDeletefromLegacy=false かつ members 空 → buildZSetDiffElems が呼ばれること
  • TestBuildZSetStateElemsLegacyMigrationfromLegacy=true かつ members 非空 → wide-column の全書き込み + legacy Del が返ること
  • TestBuildZSetStateElemsNewZSetorigMembers 空・新規作成 → buildZSetWriteElems が呼ばれること

2. buildZSetRemoveEntryElems(adapter/redis_compat_helpers.go)— 直接テストなし

newLen <= 0(meta を Del)と newLen > 0(meta を Put)の 2 分岐が未テスト。

提案するテストケース:

  • TestBuildZSetRemoveEntryElemsToEmpty — entries を全削除すると meta が Del されること
  • TestBuildZSetRemoveEntryElemsPartial — 一部削除すると meta の Len が更新されること

3. deleteZSetWideColumnElems(adapter/redis_compat_helpers.go)— テストなし

meta + member + score のすべてを削除するロジックが無テスト。orphaned キーのスキャン境界も未検証。

提案するテストケース:

  • TestDeleteZSetWideColumnElemsDeletesAllKeys — wide-column 形式で書き込まれた ZSet を削除後、meta/member/score キーがすべて消えることを検証

4. zremrangebyrankInner の wide-column パス — テストなし

wide-column パス(metaExists=true)と legacy フォールバックの両方が未テスト。特に「削除範囲が全体」「単一メンバー」などの境界ケースが必要。

提案するテストケース:

  • TestZRemRangeByRankWideColumnPath
  • TestZRemRangeByRankBoundaryDeleteAll

5. TTL × wide-column ZSet の相互作用 — テストなし

wide-column 形式の ZSet に TTL をセットし、期限切れ後のアクセスが「存在しない」を返すことが未検証。

提案するテストケース:

  • TestZSetWithTTLExpiresInWideColumnFormat

6. localKeysPattern の ZSet meta prefix スキャン — テストなし

adapter/redis.go に追加された ZSetMetaPrefix のスキャンパスが未テスト。KEYS * でユーザーキーが返り、内部の meta/member/score キーが漏れないことを検証すべき。

提案するテストケース:

  • TestLocalKeysPatternReturnsZSetUserKeysOnly

7. NaN スコアのエンコード — テスト対象外

EncodeSortableFloat64+Inf / -Inf を含めてテスト済みだが、NaN の扱いが未定義のまま。Redis は NaN を ZADD で拒否するが、zset_helpers.go レイヤーでは検証なし。API 境界での拒否テストを追加するか、本関数でパニックではなくエラーを返す設計にする場合はそのテストを追加すること。

8. Lua コンテキスト(zsetCommitElems)の各分岐 — テストなし

redis_lua_context.go 内の 4 分岐(legacy 削除・legacy→wide-column マイグレーション・新規作成・diff 更新)が Lua 経由で明示的にテストされていない。


⚠️ 分散・並行性の観点

  • origMembers スナップショットによる diff コミットは、同一 ZSet への並行書き込み時の競合検出を trackReadKey(store.ZSetMetaKey(key)) に依存している。この競合検出が正しく機能することを示す並行アクセステストが存在しない。
  • 既存の TestRedisExecLuaCompatRetriesWriteConflict は 1 シナリオをカバーするが、複数のメンバーを同時に追加する競合ケースは未検証。

結論

コアとなる pure 関数(key encoding, meta serialization, diff 計算)のテストは十分。一方、コマンドレイヤーとの結合部分buildZSetStateElemsbuildZSetRemoveEntryElemsdeleteZSetWideColumnElemszremrangebyrankInner)と副作用を伴う新しい wide-column パスは直接テストが存在しない。上記の提案するテストケースの追加を推奨する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

PR #483: ZSet ワイドカラムストレージ分解

明確なデータロストに直結する問題は見当たりませんでした。
ただし、データ欠損につながりうる 脆弱性 を 1 件確認しました。


[中] scanZSetAllMembersmeta.Len を上限として使用

ファイル: adapter/redis_compat_helpers.go

func (r *RedisServer) loadZSetMembersMap(...) (zsetLoadResult, error) {
    meta, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
    ...
    if metaExists {
        entries, err := r.scanZSetAllMembers(ctx, key, meta.Len, readTS)  // meta.Len を上限に使用
        ...
    }
}

→ は ScanAt のリミットに を渡します。
Raft による原子的コミット(メタキー+メンバーキー+スコアインデックスキーが単一エントリで一括書き込み)のもとでは と実際のスコアインデックスキー数は常に一致するため、通常運用ではデータロストは発生しません。

ただし、将来バグ等で が実際のキー数より小さくなった場合、超過分のメンバーがスキャン対象外となり、ユーザーから不可視になるうえ、その後の差分コミット () でも origMembers に含まれないため、当該メンバーのスコア/メンバーキーが永続的に孤立します。

対策候補: が を使ってすべてのキーを安全に削除するのと同様に、デバッグ用途で のログ警告を追加することで不整合を早期検知できます。


[確認済み・問題なし]

  • マイグレーションの原子性: 時のワイドカラム書き込み+レガシーblob削除は単一 Raft エントリで原子的に適用される。
  • ****: ワイドカラムキー全体を で網羅的に削除( スキャン)している。
  • TTL キーの扱い: で空になった場合は 経由で TTL も削除されている(txnContext 経由の空 ZSet に TTL が残るのは既存の問題であり本 PR 由来ではない)。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys


結論: 潜在的なデータ可視性リスクあり(要確認)


懸念事項

⚠️ scanZSetAllMembersmeta.Len を上限として使用(adapter/redis_compat_helpers.go

func (r *RedisServer) scanZSetAllMembers(ctx context.Context, key []byte, memberCount int64, readTS uint64) ([]redisZSetEntry, error) {
    return r.scanZSetScoreEntries(ctx, key, int(memberCount), readTS)
}

meta.Len が実際のメンバー数より小さい値になった場合(将来のバグや手動操作などで不整合が生じた場合)、スキャン結果が途中で打ち切られ、超過分のメンバーが読み取り・更新操作からサイレントに見えなくなります。該当メンバーはストアには存在し続けるため、アプリケーション層から見ると「消えた」状態になります。

なお、削除パス(deleteZSetWideColumnElems)は math.MaxInt を使用しており、この問題を回避しています。読み取り・更新パスも同様に完全スキャンする方法を検討するか、meta.Len との不整合を検出する仕組みがあるとより安全です。

⚠️ buildZSetRemoveEntryElems が読み取り時の meta.Len に依存(adapter/redis_compat_helpers.go

zremrangebyrankInner および bzPopMinWideColumn では、readTS 時点の meta.LencurrentLen として使い、新しいメタ値を計算しています:

func buildZSetRemoveEntryElems(key []byte, entries []redisZSetEntry, currentLen int64) ([]*kv.Elem[kv.OP], error) {
    newLen := currentLen - int64(len(entries))

currentLen(= meta.Len)が既に不正確な値であった場合、newLen も不正確になり、以降の操作でのスキャン上限が誤った値に設定されます。不整合が連鎖的に伝播するリスクがあります。


問題なし(設計が正しいと確認した箇所)

✅ レガシー→ワイドカラム移行のアトミック性

buildZSetStateElemsst.fromLegacy パスは、ワイドカラムキーの書き込みとレガシーblobの削除を単一の Pebble バッチで実行します。Pebble バッチはアトミックなので、部分的な移行状態(ワイドカラムとレガシーが混在する状態)は発生しません。

✅ 削除パスの完全性

deleteZSetWideColumnElemsmath.MaxInt でスキャンするため、meta.Len の不整合に関わらず、すべてのメンバーキー・スコアインデックスキーを削除対象にできます。

buildZSetDiffElems の競合検出

ZSet 操作は meta キーを常に読み取り(loadZSetMetaAt)、trackReadKey(store.ZSetMetaKey(key)) で read-set に登録します。PR #499 の SSI 検証と組み合わせることで、並行書き込みによる競合は検出されます。

✅ TTL キーの扱い

レガシー削除時(len(st.members) == 0 && st.fromLegacy)は TTL キーも削除。ワイドカラム移行時(st.fromLegacy && len(st.members) > 0)は TTL キーを保持。いずれも意図した動作と一致しています。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys


1. [高影響] 削除時の無制限スキャン (, deleteZSetWideColumnElems)

scanLimit := math.MaxInt

for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    ...
    for _, kvp := range kvs {
        elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
    }
}

メンバー数が多い ZSet を削除する際に、member prefix + score prefix を全件スキャンして全キーをメモリに展開する。ZSet が 100万メンバーあれば 200万エントリを elems としてアロケーションする。meta.Len が取得済みであれば、それを scanLimit として上限を設けるか、FSM の kv.DelPrefix を使ったプレフィックス削除に切り替えるべき(コメントには「atomicity のため kv.DelPrefix は混在不可」と書かれているが、2フェーズに分けるか専用のバッチ命令として扱うことを検討してほしい)。


2. [中影響] ZSet ロード時の O(N) map コピー (複数箇所)

loadZSetStatezaddTxnzincrbyzrembzPopMinLegacyluaScriptContext.zsetState のそれぞれで、ロードしたメンバーを origMembers としてフルコピーしている:

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

ZSet に N メンバーあれば、トランザクションが参照するたびに O(N) のアロケーションとコピーが発生する。read-only なアクセス(ZRANGE等)でも loadZSetState 経由でスナップショットコピーが走る。差分検出用のスナップショットが必要なのは書き込みを行った場合のみなので、「dirty になった時点で初めてスナップショットを取る」遅延コピー戦略が有効。


3. [中影響] localKeysPattern でのパターン未絞り込みスキャン ( ~1194行)

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {
    return nil, err
}

他の型(List 等)が listPatternScanBounds(prefix, pattern) でパターンに応じてスキャン範囲を絞っているのに対し、ZSet meta は ZSetMetaPrefix 全体をスキャンする。KEYS/SCAN コマンドのたびに ZSet を持つ全キーを走査することになり、ZSet キー数が多いストアでは著しく遅くなる。ZSet meta のキーエンコーディングが長さプレフィックス付きのため単純なパターンマッチが難しい事情があるが、少なくとも上限 (limit) を設けるか、meta キーからユーザーキーを抽出したあとにパターンフィルタリングを行う構成が望ましい。


4. TYPE コマンドで ZSet に対して 2回読み ( ~588行)

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},   // 新フォーマット
{typ: redisTypeZSet, key: redisZSetKey(key)},         // legacy blob fallback

関数が最初にマッチした型を返す構造であれば、移行済みキー(meta あり)では meta を見て返るためオーバーヘッドは小さい。一方、未移行キー(legacy blob のみ)では meta ミス + legacy ヒットで 2 reads になる。移行が完全に終わったあとは legacy エントリの削除と合わせてこの 2重チェックも除去できる点を記録しておくこと(移行期限の目安があればコードにコメントを)。


5. buildZSetDiffElems の過剰な cap 事前確保

elems := make([]*kv.Elem[kv.OP], 0, (len(origMembers)+len(newMembers))*2+1)

全メンバーが変更される最悪ケースを見越した容量だが、1メンバーだけ変更する典型ケース(ZINCRBY 等)では確保容量が実際の使用量を大幅に上回る。len(origMembers)len(newMembers) の差の絶対値 + 変更数をおおまかに見積もるか、固定の小さめ初期容量 + 必要に応じた grow で対応する方がメモリ効率が良い。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: adapter: decompose ZSet storage into per-member wide-column keys (#483)


問題 1 (重大): zremrangebyrankInnermeta.Len がずれると zombie ZSet が生成される

ファイル: adapter/redis_compat_commands.go

func (r *RedisServer) zremrangebyrankInner(...) error {
    meta, metaExists, err := r.loadZSetMetaAt(ctx, key, readTS)
    // ...
    s, e := normalizeRankRange(start, stop, int(meta.Len))  // ← meta.Len を使う
    entries, err := r.scanZSetScoreEntries(ctx, key, e+1, readTS)
    s, e = normalizeRankRange(start, stop, len(entries))    // ← 実際のスキャン結果で再計算
    toRemove := entries[s : e+1]
    elems, err := buildZSetRemoveEntryElems(key, toRemove, meta.Len)  // ← meta.Len 基準でnewLen計算

buildZSetRemoveEntryElems の内部:

newLen := currentLen - int64(len(entries))  // currentLen = meta.Len

シナリオ: meta.Len = 10、実際のメンバー数 = 6(過去の不整合で発散している場合)で ZREMRANGEBYRANK 0 9(全削除)を実行すると:

  • スキャンで6エントリ取得 → 全削除
  • newLen = 10 - 6 = 4(正は4だが実際のメンバーは0)
  • meta キーに Len=4 で書き込まれて残る

結果: 全メンバーが削除されたのに meta が残り、ZSet が "4メンバー存在"と見なされる zombie 状態になる。以降の loadZSetMembersMap はメタを見てwidecol pathを選び、スキャン結果が空のzsetLoadResultを返す。ZCARDは0を返すが、TYPEzsetを返し、DELを呼ぶまで論理的に存在し続ける。

対策案: buildZSetRemoveEntryElems に渡す currentLen として meta.Len ではなく len(entries) を用いてrescan後の実態に合わせる、もしくは全エントリ削除時は meta.Len <= len(toRemove) を確認して meta を必ず削除する。


問題 2: buildZSetDiffElems は orphaned キーをクリーンアップしない

ファイル: adapter/redis_compat_helpers.go

func buildZSetDiffElems(key []byte, origMembers, newMembers map[string]float64) ([]*kv.Elem[kv.OP], error) {
    // origMembers にないキーは無視
    for member, oldScore := range origMembers {
        // origMembers に含まれるものだけ削除
    }
}

origMembers はトランザクション開始時点でロードしたスナップショットであるため、KVストア内に orphaned な wide-column キー(過去の partial write や migration の失敗で残ったもの)がある場合、それらは diff の対象外となりクリーンアップされない。
これは直接的なデータロストではないが、score index の不整合(スコアキーが残るが member キーが消えるなど)により 範囲スキャン系コマンドが余分なエントリを返したり、Len カウントとスキャン結果がずれる原因となる。

deleteLogicalKeyElemsdeleteZSetWideColumnElems は prefix scan で全削除するため DEL 時は安全だが、部分操作(ZREM等)時は前述の問題が残る。


問題 3: Legacy → wide-column 移行中にメタキーと TTL 削除が分離している

ファイル: adapter/redis_compat_commands.go, buildZSetStateElems

case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},
    }, nil

Legacy ZSet が空になった場合、legacy blob と TTL キーを削除するが、TTL キーを持つ wide-column ZSet(legacy からの移行途中で TTL が付いていた場合)のメタキーが残る可能性がある。このパスは fromLegacy=true かつメンバー0なので wide-column キーは存在しないはずだが、移行途中の状態(legacy blob + wide-column meta が両方存在)のケースが考慮されているか不明。


問題なし (確認済み)

  • deleteLogicalKeyElems での wide-column ZSet 全削除は deleteZSetWideColumnElems が prefix scan により orphaned キーも含めて削除するため適切
  • buildZSetWriteElems はメタ + メンバーキー + スコアキーを1トランザクションで書き込むため atomicity は保たれている
  • legacy blob の削除と wide-column の書き込みは同一トランザクションに含まれており、移行の中間状態は観測されない

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

✅ 正しい実装

meta/member/score キーの原子的コミット
buildZSetWriteElems / buildZSetDiffElems で生成された全 elem が単一の Raft エントリ内に収められ、ApplyMutations で一括適用される。部分適用によるメタとメンバーキーの乖離は発生しない。

deleteLogicalKeyElems でのワイドカラムキー削除
DEL_PREFIX との混在を避け、メタ・メンバー・スコアインデックスを個別スキャンして明示的に Del elem に変換している。Raft ログの意味論上正しい。


⚠️ 問題点

[重大] スコアのみ変更時にメタキーが更新されず、SSI 検出が機能しない

buildZSetDiffElems (adapter/redis_compat_helpers.go) では、メタキーの更新条件が len(origMembers) != len(newMembers) のみ。メンバー数が変わらずスコアだけ変更された場合、ZSetMetaKey への書き込みが発生しない。

} else if len(origMembers) != len(newMembers) {
    // ← スコアのみ変更時はここに来ない → meta key への write なし
}

一方、loadZSetState (adapter/redis.go) では t.trackReadKey(store.ZSetMetaKey(key)) のみをリードセットに追加している。

結果として:

Txn A: ZSet を読み取り (ZSetMetaKey をトラッキング)
Txn B: 同 ZSet のスコアを更新 (メンバー数不変 → ZSetMetaKey 未更新)
Txn A: コミット → checkReadConflicts が ZSetMetaKey の変化を見ないため競合未検知

PR #499 の SSI 検証 (checkReadConflicts) が ZSet スコア変更に対して盲目になる。

対策案: スコア変更時にも ZSetMetaKey に「バージョンスタンプ」として書き込む(Len は変えずともダミー Put する)か、または loadZSetState で読み取った各メンバーキー (ZSetMemberKey) もリードセットに追加する。

[要確認] レガシー blob キーの read-set トラッキング除外

変更前: loadZSetStatet.trackReadKey(redisZSetKey(key)) (レガシー blob をトラッキング)
変更後: t.trackReadKey(store.ZSetMetaKey(key)) のみ

レガシー形式 ZSet(ZSetMetaKey が存在しない)に対して loadZSetState が呼ばれた場合、redisZSetKey が read set に含まれない。trackTypeReadKeys では両キーが追加されているため、型チェックを経由するパスは保護されているが、型チェックを経由しないコードパスが将来追加された場合に競合検知漏れが生じるリスクがある。

loadZSetState 内で fromLegacy の場合も t.trackReadKey(redisZSetKey(key)) を追加することを推奨する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: #483 adapter: decompose ZSet storage into per-member wide-column keys


要確認: 1件の潜在的データ損失パス + 1件の事前存在バグ


[要確認] buildZSetStateElems の非legacyな空ZSet処理でTTLキーが残存する

場所: adapter/redis_compat_helpers.gobuildZSetStateElems

case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)}, // ← TTL削除あり
    }, nil
case len(st.members) == 0:
    // wide-columnのDiff削除: meta + member/score keys を削除
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members) // ← TTLキー削除なし

シナリオ:

  1. ZSetに TTL を設定 → メンバーを追加
  2. トランザクション内で全メンバーを ZREM → コミット時に meta/member/score キーは削除されるが TTLキー (!redis|ttl|{key}) は残存
  3. 同一キーに再び ZADD(TTL 未設定)
  4. loadZSetState が TTL チェックを先行実行 → 古い TTL が期限切れならば新ZSetのデータが「存在しない」扱いになりデータが不可視化される

補足: 旧コード(buildZSetElems)も同様に TTL を削除していなかったため、本PRが導入した新規バグではなく事前存在の問題です。ただし、wide-column化によりデータが複数キーに分散したことで問題のインパクトが変わる可能性があります。legacy パス(st.fromLegacy == true)では TTL 削除が追加されており一貫性がないため、非legacy パスにも同様の処理を追加することを推奨します。


[確認済み/問題なし] その他の項目

1. buildZSetRemoveEntryElemsmeta.Len 依存
newLen = currentLen - int64(len(entries)) の計算で meta.Len の正確性に依存しています。ただし、すべての書き込みパス (buildZSetWriteElems, buildZSetDiffElems) が同一 Raft エントリ内で meta.Len を原子的に更新しており、通常運用でのズレは発生しません。deleteZSetWideColumnElems が防御的に math.MaxInt スキャンを使用している点も確認しました。

2. legacy → wide-column マイグレーションの原子性
buildZSetStateElems の legacy マイグレーションケース(wide-column書き込み + legacy blob削除)が単一の Raft エントリで発行されており、部分的な移行状態が永続化されるリスクはありません。

3. scanZSetAllMembersmeta.Len を上限とするスキャン
meta.Len が実際のスコアインデックスキー数と不整合が生じた場合、余剰エントリが不可視になるリスクがありますが、(2) の通り原子性が保証されているため通常発生しません。

4. deleteLogicalKeyElems による完全削除パス
persistZSetMembersTxn の空ケースは deleteLogicalKeyElems を経由しており、TTL・legacy blob・wide-columnキーすべてが削除されます。明示的な DEL コマンド経由では問題ありません。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

PR #483adapter: decompose ZSet storage into per-member wide-column keys


✅ 正しい点

  • origMembers スナップショット: zsetTxnState.origMembers はロード時に取得するスナップショットで、per-transaction state(txnContext)内での使用のため並行アクセスなし。diff-based commit のアプローチは正しい。
  • レガシー移行の原子性: blob→wide-column 移行は単一トランザクション内で行われるため、失敗時に中間状態が残らない。トランザクション retry 時も fresh な txnContext で再ロードされる。
  • trackTypeReadKeys への ZSetMetaKey 追加: SSI(PR store: add read-set validation to ApplyMutations for SSI #499)との連携で ZSet メタキーを read set に追加することで、ZSet 操作に対する read-write 競合検出が正しく機能する。

⚠️ 懸念: routeKey()!zs| プレフィックスを認識しない

ファイル: kv/shard_key.go(変更なし、但し PR #483 の新キー形式に影響)

normalizeRouteKey は以下のプレフィックスのみ対応:

  • !redis| → ユーザキー抽出
  • !ddb| → DynamoDB テーブルキー
  • S3 キー
  • !lst| → リストユーザキー

!zs|meta|!zs|mem|!zs|scr| に対応するハンドラが存在しない。

func normalizeRouteKey(key []byte) []byte {
    if user := redisRouteKey(key); user != nil { return user }
    if table := dynamoRouteKey(key); table != nil { return table }
    if user := s3keys.ExtractRouteKey(key); user != nil { return user }
    if user := store.ExtractListUserKey(key); user != nil { return user }
    return key  // ← !zs| キーはそのままルーティングキーとして使われる
}

マルチシャード構成での影響:

  1. ZSetMetaKey(!zs|meta|<len><userKey>) がユーザキーとは別のシャードにルーティングされる可能性がある
  2. ZSet 書き込みミューテーション(!zs|meta|!zs|mem|!zs|scr|)も同じルーティングロジックを使うため、書き込みと SSI read-key チェックは同じシャードに行く(一貫性はある)
  3. ただし、ユーザキー自体とは別シャードに ZSet データが分散する可能性があり、localKeysPattern などの KEYS スキャンや TTL 処理に影響する

提案: shard_key.gonormalizeRouteKey に ZSet 内部キーのルーティング正規化を追加する:

if user := store.ExtractZSetUserKey(key); user != nil {
    return user
}

ℹ️ 軽微な注意点

  • KEYS パターンスキャン: localKeysPattern での ZSet メタプレフィックスのフルスキャンは、ZSet 数が多い場合にレイテンシが増加する可能性があるが、既存の他スキャンと一貫した実装である。
  • bzPopMinWideColumn のスコアエントリスキャン: scanZSetScoreEntries で取得した結果を元に削除 ops を構築するが、trackTypeReadKeys で ZSet メタキーが read set に追加されるため、SSI により他トランザクションの介入が検出される。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

⚠️ 指摘事項


1. loadZSetState — 読み取り専用ロードでも無条件に O(N) の origMembers コピーが発生

場所: adapter/redis.go / txnContext.loadZSetState

ZSet を読み取るたびに全メンバー分のマップをディープコピーしている。トランザクション内で ZSet を参照するだけで dirtyfalse のまま終わるケース(例: ZRANGE のみ)でも N 個の float64 値のアロケーション+コピーが走る。origMembers が実際に必要になるのは初回書き込み時だけなので、コピーをその時点まで遅延させる(コピーオンライト)ことで、読み取り専用パスのアロケーションをゼロにできる。


2. zaddTxn / zremloadZSetMembersMap が返す新規マップを即座に再コピー

場所: adapter/redis_compat_commands.go

loadZSetMembersMap は内部で zsetEntriesToMap により新規マップを生成して返す。呼び出し元でさらに同じサイズのマップを make してループコピーしているが、load.members は呼び出し元が所有する fresh なマップであるため再コピーは不要。そのまま使えばアロケーション 1 回分を削減できる。


3. deleteZSetWideColumnElemsmath.MaxInt 上限の 2 回フルプレフィックススキャン

場所: adapter/redis_compat_helpers.go

メンバーキーとスコアインデックスキーの 2 プレフィックスを上限なしでスキャンし、全キーをメモリ上のスライスに展開している。10K メンバー超の ZSet では 20K 超の要素を一度にメモリに乗せることになる。DelPrefix と他のミューテーションを同一 Raft リクエストに混在できないという制約は理解できるが、スキャン上限として meta.Len * 2 + バッファ を使うことで孤立キー数を限定しつつメモリ使用量を抑えられる(孤立キー除去を犠牲にするトレードオフあり)。


(参考) 改善点

  • zrangeRead: 以前は常にエントリをコピーしていたが、reverse 時のみコピーするよう修正されており無駄なアロケーションが削減されている。
  • buildZSetDiffElems: 差分のみを書き込む設計により、変更量が少ない場合の書き込み I/O を大幅に削減。レガシーブロブの全書き込みと比べて正しい方向性。
  • scanZSetScoreEntries: スコアインデックスキーが自然ソート済みのため、インメモリソートが不要になっている点は性能上の利点。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

カバーされているテスト ✅

  • store/zset_helpers_test.go (新規): エンコーディング・キー構築・抽出関数を網羅(EncodeSortableFloat64 の順序・ラウンドトリップ・-0.0 正規化、ZSetMeta の encode/decode、PrefixEndExtractZSetUserKeyExtractZSetScoreAndMemberIsZSetInternalKey
  • adapter/redis_zset_test.go (新規): buildZSetDiffElems を 5 ケースでカバー(スコア変更・メンバー削除・追加・無変更・全削除)
  • TestNormalizeRetryableRedisTxnErrZSetKey: ZSet 内部キー (meta/mem/scr) のエラー正規化
  • TestRedisZSetLegacyJSONReadThenRewriteToProto: ZADD による legacy blob → wide-column マイグレーション

テスト不足 ❌

1. buildZSetRemoveEntryElems にテストなし(重大)

BZPOPMINZREMRANGEBYRANK の wide-column パスで使用されますが、単体テストがありません。

提案するテストケース:

// TestBuildZSetRemoveEntryElems_UpdatesMeta (newLen > 0 → meta の Len が更新される)
// TestBuildZSetRemoveEntryElems_DeletesMeta (newLen == 0 → meta キーが Del される)
// TestBuildZSetRemoveEntryElems_DeletesMemberAndScore (member キーと score-index キーが両方 Del される)
2. buildZSetStateElems にテストなし

マイグレーションの 4 分岐(legacy 空非 legacy 空legacy 非空新規 ZSet既存 ZSet の差分)に対する単体テストがありません。

// TestBuildZSetStateElems_LegacyEmpty (legacy+空 → legacy blob と TTL キーを Del)
// TestBuildZSetStateElems_NewZSet (origMembers 空 + 非 legacy → buildZSetWriteElems 相当)
// TestBuildZSetStateElems_LegacyMigration (fromLegacy=true + 非空 → wide-column 書き込み + legacy blob Del)
// TestBuildZSetStateElems_Diff (既存 wide-column → buildZSetDiffElems 相当)
3. deleteZSetWideColumnElems にテストなし
  • meta が存在しない場合に nil, nil を返すことの確認
  • meta が存在する場合に meta・member・score-index キー全てが Del されることの確認
4. loadZSetMembersMap / loadZSetMetaAt / scanZSetScoreEntries に単体テストなし

wide-column の load パスがテストされていません:

// TestLoadZSetMembersMap_WideColumn (wide-column meta あり → members を返す)
// TestLoadZSetMembersMap_LegacyFallback (meta なし + legacy blob あり → fromLegacy=true)
// TestLoadZSetMembersMap_NotExists (meta なし + legacy なし → exists=false)
5. ZREMRANGEBYRANKBZPOPMIN の wide-column パスに統合テストなし

zremrangebyrankInner の wide-column ブランチと bzPopMinWideColumn は、loadZSetMetaAtscanZSetScoreEntriesbuildZSetRemoveEntryElems の新しいコードパスを通りますが、integration test が存在しません。

6. KEYS コマンドでの ZSet meta キー取得にテストなし

localKeysPattern に追加された ZSet meta prefix のスキャン (!zs|meta|... のスキャン + collectUserKeys によるフィルタリング) が、adapter/redis_keys_pattern_test.go に含まれていません。

// TestLocalKeysPattern_ZSetKeys
// ZSet (wide-column) を作成した後 KEYS * が正しくユーザーキーを返すことを確認
7. マイグレーションパスのテストが ZADD のみ

ZINCRBYZREMZREMRANGEBYRANK の legacy → wide-column マイグレーションパスに対するテストがありません。TestRedisZSetLegacyJSONReadThenRewriteToProto 相当のテストをこれらのコマンドにも追加することを推奨します。

8. redisVisibleUserKey の ZSet 対応にテストなし

adapter/redis_keys_pattern_test.go や内部テストに、ZSet 内部キー (ZSetMetaKey/ZSetMemberKey/ZSetScoreKey) に対して redisVisibleUserKey がユーザーキーを返すことを確認するケースがありません。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

PR #483: adapter: decompose ZSet storage into per-member wide-column keys

問題なし。

以下の点を確認しました:

  • origMembers スナップショットのスレッド安全性: loadZSetState 内でのスナップショット作成は for mk, mv := range load.members による明示的なコピーであり、その後の変更と分離されている。txnContext はコネクションごとの単一ゴルーチンで処理されるため競合なし。
  • ロック順序: leaderClientsMu の RLock/Lock パターンにダブルチェックロックが実装されており、一貫したロック順序を維持。新規のデッドロックリスクなし。
  • diff ベースのコミットパス (buildZSetStateElems): origMembers と現在の members の差分から削除・追加を生成。ワイドカラムキー(メタ・メンバー・スコアインデックス)の更新が一括でディスパッチされ、アトミック性が保たれている。
  • 読み取りキーの追跡変更: trackTypeReadKeysredisZSetKey から store.ZSetMetaKey に変更。すべての変更がメタキーを経由するため、競合検出に漏れなし。
  • ゴルーチンリーク / チャネルブロック: 新規ゴルーチン生成なし。コンテキストキャンセルは既存パターンを踏襲。
  • Raft リーダー切り替え: txnContext はリトライループ内で毎回再生成され、startTS も取り直されるため、リーダー切り替え時の一貫性を維持。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

問題あり


1. 単一メンバー操作でも全メンバーをロードする(重大)

場所: adapter/redis_compat_commands.gozaddTxn, zincrby, zrem が呼ぶ loadZSetMembersMap

ZADD/ZINCRBY/ZREM で1件だけ追加・更新・削除する場合も、loadZSetMembersMapscanZSetAllMembers によって 全 N メンバーをスキャンしている。wide-column 形式に分解した目的(メンバー単位の O(1) アクセス)が、ミューテーション系パスでは実現できていない。

例: 10,000 件のメンバーを持つ ZSet への ZADD key 1.0 member は、10,000 件の KV 読み取りを伴う。

改善案: diff-based commit を維持しつつ、差分対象メンバーだけをロードする専用パス(loadZSetMember(key, member, readTS))を導入する。全ロードが必要なのはコマンド仕様上避けられない ZRANGEZREMRANGEBYRANK 系のみで十分。


2. ZSet ロード時に origMembers の二重マップ確保(中程度)

場所: adapter/redis.goloadZSetState, adapter/redis_lua_context.gozsetState

ZSet をロードするたびに:

  1. loadZSetMembersMap 内で zsetEntriesToMap により members マップを確保(O(N))
  2. 直後に全件ループで origMembers マップをコピー(O(N) × 2)

read-only な ZSet アクセス(ZSCORE, ZRANK, ZRANGE 等)では origMembers は使われないため、このコピーは無駄になる。

改善案: dirty フラグが立った時点で初めて origMembers を確保する遅延コピー (copy-on-write) にするか、origMembers を持たず commit 時に現在値を再取得する方式を検討する。


3. localKeysPattern の ZSet メタキースキャンがパターン非考慮(中程度)

場所: adapter/redis.golocalKeysPattern 内の追加ブロック

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {

他のキータイプが listPatternScanBounds(prefix, pattern) でスキャン範囲をパターンに絞るのに対し、ZSet メタキーは 全プレフィックス範囲 をスキャンしてから collectUserKeys でフィルタリングしている。KEYS "foo*" のような限定パターンであっても全 ZSet メタキーを走査するため、ZSet 数が多い環境では KEYS コマンドのレスポンスタイムが悪化する。

改善案: store.ZSetMetaPrefix 向けにも listPatternScanBounds と同等のパターン境界計算を追加する。


4. deleteZSetWideColumnElemsmath.MaxInt でスキャン(軽微・正確性トレードオフ)

場所: adapter/redis_compat_helpers.godeleteZSetWideColumnElems

member/score 両プレフィックスを上限なしでスキャンし、個別削除エレメントを O(N) 件メモリ上に構築してから1トランザクションで送信している。大規模 ZSet(数万件)では単一の Raft エントリが非常に大きくなり、スナップショットサイズや apply レイテンシに影響する可能性がある。

現状はアトミック削除のためにこの方式が必要と理解しているが、meta.Len を使った事前確保(make([]*kv.Elem, 0, meta.Len*2+1))でリアロケーションを削減できる。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

問題あり: TTL 整合性バグ(Critical)


1. [Critical] 期限切れキーへの ZADD が TTL キーをクリアしない

問題箇所: buildZSetStateElemslen(st.origMembers) == 0 パス

loadZSetState は TTL 期限切れのキーを「存在しない ZSet」として扱い、origMembers = {} を返します。その後 ZADD でメンバーが追加されてコミットされると、buildZSetStateElems は次のパスを通ります:

case len(st.origMembers) == 0:
    // Brand new ZSet: full write
    return buildZSetWriteElems(keyBytes, st.members)  // TTL キーに触れない

buildZSetWriteElems はメタ・メンバー・スコアキーを書き込みますが、古い期限切れ TTL キー(!redis|ttl|<userKey>)を削除しません

結果として:

  1. ZADD が正常にコミットされ、wide-column キーが書き込まれる
  2. 次の読み取り時に readValueAthasExpiredTTLAt が期限切れ TTL キーを検出
  3. 新しく書き込んだデータが即座に「期限切れ」として扱われ見えなくなる

対比: 同じファイル内の fromLegacy + empty パスでは TTL を明示的に削除しており、設計意図はあったことが伺えます:

case len(st.members) == 0 && st.fromLegacy:
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},  // ← こちらは正しく削除
    }, nil

修正案: buildZSetStateElemsorigMembers == 0 パスで、TTL state が期限切れかどうかを確認し、期限切れであれば redisTTLKey(keyBytes) の削除 elem を追加する。


2. キーエンコーディングの一貫性

  • !zs|meta|, !zs|mem|, !zs|scr| の各プレフィックスは重複なし
  • 4バイト大端 userKey 長プレフィックスにより、あるユーザーキーが別のユーザーキーのプレフィックスになるケースでの誤マッチを防止
  • EncodeSortableFloat64 によりスコアのバイト順がスコア順に対応 — 問題なし

3. diff ベースコミットのアトミック性

buildZSetDiffElems が生成する全 Put/Del オペレーションは 1 つの OperationGroup に含まれ、Raft 経由でアトミックに適用されます。部分書き込みは発生しません — 問題なし。

4. メタカウントとメンバー数の整合性

buildZSetDiffElems 内でカーディナリティ変化時のみメタキーを更新。スコア変更時はカウント不変でメタ更新をスキップする設計も正しいです — 問題なし。

5. レガシー移行のアトミック性

fromLegacy == true 時は wide-column 書き込み + レガシー blob 削除が同一バッチ内で実行。SSI の readKeys トラッキングに redisZSetKey(key)store.ZSetMetaKey(key) の両方が含まれるため、concurrent 移行の競合も正しく検出されます — 問題なし。


まとめ

観点 結果
キーエンコーディングの一貫性 ✅ 問題なし
diff コミットのアトミック性 ✅ 問題なし
レガシー移行の安全性 ✅ 問題なし
TTL とデータ本体の整合性 要修正

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Consistency] レビュー結果

対象PR: #483 (adapter: decompose ZSet storage into per-member wide-column keys)
※ PR #499 (SSI read-set validation) との関連で分析した内容も含みます。


🔴 [重要] buildZSetStateElems でのTTL削除の非対称性

adapter/redis_compat_helpers.gobuildZSetStateElems にて:

case len(st.members) == 0 && st.fromLegacy:
    // Legacy: TTLキーを明示的に削除 ✅
    return []*kv.Elem[kv.OP]{
        {Op: kv.Del, Key: redisZSetKey(keyBytes)},
        {Op: kv.Del, Key: redisTTLKey(keyBytes)},
    }, nil

case len(st.members) == 0:
    // Wide-column: buildZSetDiffElems に委譲 → TTL削除なし ⚠️
    return buildZSetDiffElems(keyBytes, st.origMembers, st.members)

legacyケースは redisTTLKey を明示的に削除するが、wide-columnケースは buildZSetDiffElems を使用しTTLキーを削除しない。TTL削除は buildTTLElems() に委ねられており、ttlStates が適切に更新されている場合のみ機能する。

懸念: ZREM で全メンバーを削除するパスで ttlStates[key].value = nil がセットされない場合、TTLキーが孤立する。孤立したTTLキーが残ると、後で同じキーに作成されたZSetが古いTTLエントリを継承するリスクがある。

確認事項: 全メンバーが削除される全コードパスで ttlStates が更新されているか確認してください。buildZSetStateElems のwide-columnケースに redisTTLKey の削除を追加することも検討してください。


⚠️ [軽微] buildZSetDiffElems による削除は origMembers のキーのみ対象

ZSet削除時(len(st.members) == 0 ケース)に buildZSetDiffElems を使うと、origMembers に含まれるキーのみが削除される。一方、deleteZSetWideColumnElems はフルプレフィックススキャンで孤立キーも削除する(コメント: "including any orphaned keys from partial writes or meta.Len inconsistencies")。

正常なSSI動作下では問題ないが、防御的観点から txnContextパスのZSet削除も deleteZSetWideColumnElems のフルスキャン方式に統一することを検討してください。


✅ ZSet wide-column read-set追跡

trackTypeReadKeys()store.ZSetMetaKey(key) を追加したことは正しい。ZSetへの全書き込みはメタキーを更新するため、メタキーの追跡でZSet全体の競合検知に十分。


deleteZSetWideColumnElems のフルスキャン削除

deleteLogicalKeyElems 経由のZSet削除(DEL コマンド等)がフルプレフィックススキャンで全wide-columnキーを削除する実装は正しく、TTLキーも含めて削除されている。


buildZSetDiffElems のメタLen更新

差分更新時、len(origMembers) != len(newMembers) の場合のみメタを更新する実装は正しい。スコアのみ変更の場合はLen不変であるため効率的かつ正確。


✅ レガシー→wide-column マイグレーション原子性

buildZSetStateElemsfromLegacy=true かつ members 非空のケースで、wide-columnへの全データ書き込みとlegacy blobの削除が同一 OperationGroup(単一Raftエントリ)内で行われるため、原子的に実行される。


まとめ

# 分類 内容
1 🔴 重要 wide-columnケースでのTTL削除非対称性 — 孤立TTLキーのリスク
2 ⚠️ 軽微 buildZSetDiffElems 削除が origMembers のみ対象(孤立キー対策として deleteZSetWideColumnElems の使用を検討)

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

[高] deleteZSetWideColumnElems の無制限スキャン (math.MaxInt)

場所: adapter/redis_compat_helpers.go (deleteZSetWideColumnElems)

scanLimit := math.MaxInt
for _, prefix := range [][]byte{
    store.ZSetMemberScanPrefix(key),
    store.ZSetScoreScanPrefix(key),
} {
    kvs, err := r.store.ScanAt(ctx, prefix, store.PrefixEnd(prefix), scanLimit, readTS)
    for _, kvp := range kvs {
        elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
    }
}

ZSet が大量メンバー(例: 100 万件)を持つ場合、全エントリをヒープ上に展開してから削除 Elem を構築する。メンバーキーとスコアインデックスキーで 2 倍のスキャンが走るため、最悪ケースで 200 万件以上の bytes.Clone が発生する。

コメントに kv.DelPrefix が使えない制約が記載されているのは理解できるが、meta.Len から上限 (meta.Len * 2 + 余裕分) を算出してスキャン上限に使うことで、メタ不整合があっても合理的な上限を設けられる。


[中] origMembers の全コピーが読み取り専用ケースでも毎回発生

場所: adapter/redis.go:126-129 (loadZSetState)、adapter/redis_lua_context.go:919-922 (zsetState)

origMembers := make(map[string]float64, len(load.members))
for mk, mv := range load.members {
    origMembers[mk] = mv
}

ZSet をロードするたびに全メンバーをコピーしてスナップショットを保持する。dirty = false のまま終わる読み取り専用トランザクション(ZRANGE、ZCARD 等)でも O(N) コピーが必ず発生する。ZSet が 10 万メンバーを持つ場合は 10 万要素のマップコピーが常に走る。

改善案: origMembersdirty = true になった時点で遅延コピーする(copy-on-write)。もしくは diff 計算側で members のインプレース変更を追跡するログを持つ設計にする。


[中] zsetUserKeyComponent がホットループ内で二重アロケーション

場所: store/zset_helpers.go (ZSetMemberKey, ZSetScoreKey)、adapter/redis_compat_helpers.go (buildZSetRemoveEntryElems, buildZSetDiffElems)

// buildZSetRemoveEntryElems のループ内
for _, e := range entries {
    memberBytes := []byte(e.Member)
    elems = append(elems,
        &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetMemberKey(key, memberBytes)}, // zsetUserKeyComponent(key) → alloc
        &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetScoreKey(key, e.Score, memberBytes)}, // zsetUserKeyComponent(key) → alloc x2
    )
}

ZSetMemberKeyZSetScoreKey はそれぞれ内部で zsetUserKeyComponent(key) を呼び、毎回 make([]byte, ...) でバッファを確保する。同一 key に対して両関数を呼ぶループでは、zsetUserKeyComponent(key) の結果(!zs|xxx|<len><key> 相当)が毎エントリ 2 回計算・アロケートされる。

改善案: ループの前に comp := store.ZSetUserKeyComponent(key) を一度だけ計算してキャッシュする内部ヘルパーを用意するか、key を引数に取る代わりに comp ([]byte) を受け取る低レベル関数を作成することで、1 エントリあたりのアロケーションを半減できる。


[低] rawKeyTypeAt で新旧両フォーマットを常に 2 回チェック

場所: adapter/redis_compat_helpers.go:589-590 (rawKeyTypeAt)

{typ: redisTypeZSet, key: store.ZSetMetaKey(key)},
{typ: redisTypeZSet, key: redisZSetKey(key)}, // legacy blob fallback

型判定のたびに新フォーマット (!zs|meta|...) とレガシーフォーマット (!redis|zset|...) の両方に ExistsAt / GetAt が発行される。新フォーマット移行後もレガシーキーへのアクセスが常に走るため、移行完了後は不要なディスクアクセスとなる。

移行完了前は許容範囲だが、移行完了フラグ or メタが存在した場合はレガシーキーチェックをスキップするショートサーキットを検討してほしい。

@claude
Copy link
Copy Markdown

claude bot commented Apr 13, 2026

[Performance] レビュー結果

[高] localKeysPattern で ZSet メタプレフィックス全体をスキャン

adapter/redis.golocalKeysPattern:

zsetMetaPrefix := []byte(store.ZSetMetaPrefix)
if err := mergeScannedKeys(zsetMetaPrefix, prefixScanEnd(zsetMetaPrefix)); err != nil {

list / hash / set 等は listPatternScanBounds(prefix, pattern) でスキャン範囲をパターンに絞り込むのに対し、ZSet メタキーは !zs|meta| プレフィックス全体をスキャンして collectUserKeys で後処理フィルタリングする実装になっています。KEYS user:1* のような選択的パターンでも DB 内の全 ZSet エントリを走査する O(total ZSet count) になり、他の型と比べて一貫性を欠きます。listPatternScanBounds 相当の境界計算を ZSet メタキーにも適用することを検討してください。


[中] deleteZSetWideColumnElems の初期スライス容量なし + math.MaxInt スキャン

adapter/redis_compat_helpers.go:

elems := []*kv.Elem[kv.OP]{
    {Op: kv.Del, Key: store.ZSetMetaKey(key)},
}
// ...
for _, kvp := range kvs {
    elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(kvp.Key)})
}

cap=1 のスライスに対して member scan・score scan の2ループで 2N 件を追加するため、N が大きいと複数回のリアロケーションが発生します。meta.Len が既知であれば make([]*kv.Elem[kv.OP], 0, 1+meta.Len*2) で事前確保できます。

また scanLimit := math.MaxInt は巨大 ZSet の全メンバーを一度にメモリへロードします。FSM の制約(DelPrefix を他 mutation と混在できない)上、逐次削除は避けられませんが、使用量が多いケースでは注意が必要です。


[小] buildZSetDiffElems の過剰キャパシティ確保

adapter/redis_compat_helpers.go:

elems := make([]*kv.Elem[kv.OP], 0, (len(origMembers)+len(newMembers))*2+1)

1000 メンバー中 1 つのスコアだけ変更するケースでは cap=4001 を確保しますが実際の使用量は 3 です。スコア変更のみや少数変更が多い実ワークロードでは大幅な過剰確保になります。len(origMembers)*2+1 程度が現実的な上限です。


[小] zsetUserKeyComponent のループ内重複アロケーション

store/zset_helpers.goZSetMemberKeyZSetScoreKey はそれぞれ内部で zsetUserKeyComponent(userKey) を呼ぶため、buildZSetWriteElemsbuildZSetDiffElems の member ループ内で同一 userKey に対して毎イテレーション 2 回アロケートします。ループ外で comp := zsetUserKeyComponent(key) を一度計算して再利用すれば、N メンバーあたり 2N-1 件の余分なアロケーションを削減できます。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant