Skip to content

feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL#486

Merged
bootjp merged 16 commits intomainfrom
feat/redis-str-prefix
Apr 12, 2026
Merged

feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL#486
bootjp merged 16 commits intomainfrom
feat/redis-str-prefix

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented Apr 12, 2026

Summary

  • Add !redis|str| prefix to all Redis string key storage paths, making them distinguishable from DynamoDB, S3, and other namespaces
  • Scope FLUSHALL to only delete Redis-related keys (!redis|* and !lst|*), preventing accidental deletion of DynamoDB/S3/distribution data
  • Add FLUSHLEGACY command to scan and delete old unprefixed string keys written before the migration

Test plan

  • All existing tests pass (go test ./...)
  • golangci-lint reports 0 issues
  • Manual test: SET/GET/DEL/INCR with new prefix
  • Manual test: FLUSHALL only clears Redis data
  • Manual test: FLUSHLEGACY clears old bare keys

…to Redis data

Redis string keys were stored as bare keys (e.g., "foo"), making them
indistinguishable from DynamoDB, S3, and other namespaces. This caused
FLUSHALL to delete all data across all services.

- Add !redis|str| prefix to all Redis string key read/write/delete paths
- Update FLUSHALL to only delete !redis|* and !lst|* keys
- Add FLUSHLEGACY command to scan and delete old unprefixed string keys
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 string keys to use a "!redis|str|" prefix and introduces a FLUSHLEGACY command to remove old, unprefixed keys. Feedback identifies critical logic errors in the transaction load and buildKeyElems functions where unconditional prefixing causes double-prefixing for internal keys. Furthermore, the FLUSHLEGACY command requires batching to prevent potential OOM crashes and Raft message size violations caused by unbounded key scanning.

- Fix txnContext.load() to avoid double-prefixing internal keys (e.g.,
  !redis|hash|) — only apply !redis|str| prefix to bare user keys
- Fix buildKeyElems() to use storage keys from working map as-is
- Fix stagedStringType() to look up by storage key
- Batch FLUSHLEGACY scan+delete in groups of 1000 to avoid OOM and
  oversized Raft proposals
@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 prefixing scheme for Redis string keys using the !redis|str| prefix and adds a FLUSHLEGACY command to remove old, unprefixed keys. Various Redis operations, internal state loading, and tests have been updated to use the new prefixed key format. A review comment suggests increasing the redisFlushLegacyTimeout from 60 seconds to 10 minutes to ensure sufficient time for cleaning up large legacy datasets.

adapter/redis.go Outdated
const (
redisLatestCommitTimeout = 5 * time.Second
redisDispatchTimeout = 10 * time.Second
redisFlushLegacyTimeout = 60 * time.Second
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 redisFlushLegacyTimeout of 60 seconds might be insufficient for large datasets. While increasing this value to 10 minutes provides a buffer, ensure that the underlying operation uses context.Context for deadline management as per repository standards. This avoids ambiguity and allows for better cancellation control, rather than relying solely on a hardcoded time.Duration constant or parameter.

Suggested change
redisFlushLegacyTimeout = 60 * time.Second
redisFlushLegacyTimeout = 10 * time.Minute
References
  1. When designing interfaces, use context.Context for managing deadlines and cancellation. Avoid providing a separate time.Duration timeout parameter in method signatures to prevent ambiguity regarding which timeout takes precedence.

@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 migration to prefix Redis string keys with "!redis|str|" to separate them from other internal metadata. It introduces a FLUSHLEGACY command for one-time cleanup of unprefixed keys and updates core operations like GET, SET, and INCR to use the new prefixed keys. Feedback highlights a logic error in the FLUSHLEGACY scan range which would skip most legacy keys, and suggests a more robust check for internal keys in the load method to avoid incorrectly prefixing other internal namespaces.

Comment on lines +481 to +508
var cursor []byte
for {
kvs, err := r.store.ScanAt(ctx, cursor, []byte("!"), batchSize, readTS)
if err != nil {
conn.WriteError(fmt.Sprintf("ERR scan: %s", err))
return
}
if len(kvs) == 0 {
break
}

elems := make([]*kv.Elem[kv.OP], 0, len(kvs))
for _, pair := range kvs {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: pair.Key})
}

if err := r.dispatchElems(ctx, false, readTS, elems); err != nil {
conn.WriteError(err.Error())
return
}
totalDeleted += len(elems)

// Advance cursor past the last key in this batch by appending a
// zero byte to create a key strictly greater than the last one.
lastKey := kvs[len(kvs)-1].Key
cursor = make([]byte, len(lastKey)+1)
copy(cursor, lastKey)
}
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 flushlegacy command as implemented will fail to delete most legacy keys. The scan range [nil, "!") only covers keys that are lexicographically smaller than !. Since most user keys (e.g., starting with alphanumeric characters) are greater than !, they will be skipped.

To correctly identify all legacy unprefixed keys, the scan should cover the entire keyspace and filter out keys starting with the internal prefix !. For better performance in large stores, you could perform two separate scans: [nil, "!") and ["\"", nil], skipping the internal ! block entirely.

	var cursor []byte
	for {
		kvs, err := r.store.ScanAt(ctx, cursor, nil, batchSize, readTS)
		if err != nil {
			conn.WriteError(fmt.Sprintf("ERR scan: %s", err))
			return
		}
		if len(kvs) == 0 {
			break
		}

		elems := make([]*kv.Elem[kv.OP], 0, len(kvs))
		for _, pair := range kvs {
			if len(pair.Key) == 0 || pair.Key[0] != '!' {
				elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: pair.Key})
			}
		}

		if len(elems) > 0 {
			if err := r.dispatchElems(ctx, false, readTS, elems); err != nil {
				conn.WriteError(err.Error())
				return
			}
			totalDeleted += len(elems)
		}

		lastKey := kvs[len(kvs)-1].Key
		cursor = make([]byte, len(lastKey)+1)
		copy(cursor, lastKey)
	}

Comment on lines +1441 to +1446
storageKey := key
userKey := extractRedisInternalUserKey(key)
if userKey == nil && !isRedisTTLKey(key) {
storageKey = redisStrKey(key)
userKey = 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.

medium

The logic for determining if a key is an internal Redis key is incomplete and fragile. It currently relies on extractRedisInternalUserKey, which only checks for !redis| prefixes, and an explicit check for TTL keys. This will incorrectly prefix other internal namespaces like !lst| (lists) or !txn| (transaction metadata) with !redis|str|.

A more robust approach is to check if the key starts with the internal prefix character ! directly.

Suggested change
storageKey := key
userKey := extractRedisInternalUserKey(key)
if userKey == nil && !isRedisTTLKey(key) {
storageKey = redisStrKey(key)
userKey = key
}
storageKey := key
userKey := extractRedisInternalUserKey(key)
if !bytes.HasPrefix(key, []byte("!")) {
storageKey = redisStrKey(key)
userKey = key
}

- Fix FLUSHLEGACY to scan full keyspace and filter out !-prefixed keys,
  instead of scanning [nil, "!" which missed alphanumeric user keys
- Simplify load() internal key detection to bytes.HasPrefix(key, !)
  to cover all internal namespaces (!lst|, !txn|, etc.)
EOF
)
@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 migration to prefix Redis string keys with "!redis|str|" to distinguish them from internal metadata. It introduces a FLUSHLEGACY command for one-time cleanup of unprefixed keys and updates various operations (GET, SET, INCR, etc.) and the Lua context to handle the new prefixing. Review feedback identified issues in the key detection logic within the load and flushlegacy functions, noting that the current implementation is too broad and could lead to incorrect storage paths or accidental deletion of non-Redis data in shared environments.

Comment on lines +1441 to +1446
storageKey := key
userKey := extractRedisInternalUserKey(key)
if !bytes.HasPrefix(key, []byte("!")) {
storageKey = redisStrKey(key)
userKey = 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.

high

The logic to distinguish between user keys and internal keys in load is too broad. By checking only for the ! prefix, it incorrectly treats user keys that start with ! (e.g., !mykey) as internal keys, bypassing the !redis|str| prefixing. This leads to incorrect storage paths and missing TTL tracking for such keys. Additionally, it still allows potential collisions if a user key matches a known internal prefix (e.g., !redis|hash|...).

Suggested change
storageKey := key
userKey := extractRedisInternalUserKey(key)
if !bytes.HasPrefix(key, []byte("!")) {
storageKey = redisStrKey(key)
userKey = key
}
storageKey := key
userKey := extractRedisInternalUserKey(key)
if userKey == nil && !isRedisTTLKey(key) && !bytes.HasPrefix(key, []byte("!lst|")) && !bytes.HasPrefix(key, redisTxnKeyPrefix) {
storageKey = redisStrKey(key)
userKey = key
}
References
  1. Avoid incomplete handling paths for entries to ensure they are preserved correctly and avoid state inconsistencies.

Comment on lines +494 to +496
if len(pair.Key) == 0 || pair.Key[0] != '!' {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: pair.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.

high

The flushlegacy command logic for identifying legacy keys is too simplistic. It skips any key starting with !, which means legacy bare keys that happen to start with ! will not be cleaned up. Furthermore, in a shared KV store environment, this command is extremely dangerous as it will delete any non-prefixed data belonging to other services (e.g., DynamoDB or S3 keys that don't start with !). The check should be more specific to known internal prefixes.

			keyStr := string(pair.Key)
			if len(pair.Key) == 0 || (!strings.HasPrefix(keyStr, "!redis|") && !strings.HasPrefix(keyStr, "!lst|") && !strings.HasPrefix(keyStr, kv.TxnKeyPrefix)) {
				elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: pair.Key})
			}
References
  1. Provide specific handling paths for entries to prevent data loss or state inconsistencies in shared environments.

- Replace bytes.HasPrefix(key, "!") in load() with isKnownInternalKey()
  that checks all known prefixes (!redis|, !lst|, !txn|, !ddb|, !s3|,
  !dist|) — user keys starting with "!" are now correctly prefixed
- Use the same isKnownInternalKey() in FLUSHLEGACY to skip only keys
  belonging to known internal namespaces, not all "!"-prefixed keys
@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 migration for Redis string keys by introducing a "!redis|str|" prefix and updates various commands to use this new scheme. It also adds a FLUSHLEGACY command for one-time cleanup of unprefixed keys. Feedback indicates that the current implementation is a breaking change because the load method lacks a fallback for existing bare keys. Additionally, FLUSHLEGACY leaves orphaned TTL keys, and the revised FLUSHALL command fails to clear legacy data, both of which could lead to state inconsistencies.

Comment on lines +1443 to +1446
if !isKnownInternalKey(key) {
storageKey = redisStrKey(key)
userKey = 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.

high

The migration to the !redis|str| prefix for string keys is a breaking change for existing data. The load method now only looks for the prefixed key, making existing bare string keys inaccessible. To avoid state inconsistencies and ensure data is preserved on disk, consider implementing a fallback mechanism to check for the bare key when the prefixed key is not found.

References
  1. Avoid silently dropping entries during serialization or normalization. If placeholder or incomplete entries are valid in some contexts, provide a specific, more lenient handling path for them to ensure they are preserved on disk and avoid state inconsistencies.

elems := make([]*kv.Elem[kv.OP], 0, len(kvs))
for _, pair := range kvs {
if !isKnownInternalKey(pair.Key) {
elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: pair.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.

medium

FLUSHLEGACY only deletes the bare user keys but leaves behind their associated TTL keys (prefixed with !redis|ttl|). This leads to orphaned storage entries and state inconsistencies. Ensure that all associated metadata is handled to avoid leaving incomplete or orphaned entries in the store.

References
  1. Avoid silently dropping entries during serialization or normalization. If placeholder or incomplete entries are valid in some contexts, provide a specific, more lenient handling path for them to ensure they are preserved on disk and avoid state inconsistencies.

Comment on lines +539 to +540
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
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

Scoping FLUSHALL to !redis| and !lst| prefixes means that any remaining legacy bare keys will not be cleared, resulting in state inconsistencies. To maintain consistency, ensure that legacy entries are either handled or that the command's scope is clearly defined to prevent data remnants.

References
  1. Avoid silently dropping entries during serialization or normalization. If placeholder or incomplete entries are valid in some contexts, provide a specific, more lenient handling path for them to ensure they are preserved on disk and avoid state inconsistencies.

@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 migration for Redis string keys to use a new "!redis|str|" prefix, improving namespace isolation within the storage layer. It introduces a new FLUSHLEGACY command to clean up old unprefixed keys and updates various read/write paths—including transactions, Lua scripts, and standard commands—to support the new prefix while maintaining fallback compatibility for legacy data. The review feedback correctly identifies a bug in the transaction loading logic where keys could be double-prefixed, leading to failed lookups, and suggests correcting the return value of FLUSHLEGACY to reflect user-visible key counts rather than internal storage operations.

adapter/redis.go Outdated
Comment on lines 1457 to 1466
if userKey != nil {
// For user string keys, use the fallback-aware reader.
val, _ = t.server.readRedisStringAt(key, t.startTS)
} else {
var err error
val, err = t.server.readValueAt(storageKey, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
}
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 logic for loading string values in transactions has several issues: 1. It calls readRedisStringAt for all Redis internal keys (like hashes or sets) because userKey != nil is true for them, which is inefficient. 2. It passes the potentially prefixed key to readRedisStringAt, which will then attempt to prefix it again, leading to incorrect storage lookups (e.g., !redis|str|!redis|str|key). The condition should specifically check if the key is a Redis string (either bare or prefixed) and use the bare userKey for the fallback-aware read. Note that explicit conflict tracking for the bare key is not required per system architecture.

Suggested change
if userKey != nil {
// For user string keys, use the fallback-aware reader.
val, _ = t.server.readRedisStringAt(key, t.startTS)
} else {
var err error
val, err = t.server.readValueAt(storageKey, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
}
isString := !isKnownInternalKey(key) || bytes.HasPrefix(key, []byte(redisStrPrefix))
if isString {
// For user string keys, use the fallback-aware reader with the bare key.
val, _ = t.server.readRedisStringAt(userKey, t.startTS)
} else {
var err error
val, err = t.server.readValueAt(storageKey, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
}
References
  1. Optimistic Concurrency Control (OCC) conflict checks are not required at the store layer because the leader is responsible for issuing consistent timestamps, which is expected to prevent write conflicts.

conn.WriteError(err.Error())
return
}
totalDeleted += len(elems)
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

totalDeleted is counting the number of internal storage operations (data key + TTL key) rather than the number of user-visible keys deleted. This makes the return value of FLUSHLEGACY inconsistent with standard Redis commands like DEL. It should count the number of legacy user keys found and processed.

Suggested change
totalDeleted += len(elems)
totalDeleted += len(elems) / 2

@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 prefix migration for Redis string keys, introducing the !redis|str| prefix and a fallback mechanism to support legacy unprefixed keys. Key changes include the addition of a FLUSHLEGACY command for one-time cleanup, updates to core Redis operations to use the new prefixing logic, and modifications to FLUSHALL to target specific internal namespaces. Feedback focuses on ensuring transactional consistency by tracking legacy keys in read sets, correcting logic and error handling in the transaction load function, addressing semantic inconsistencies in FLUSHALL regarding legacy data, and suggesting throttling for the FLUSHLEGACY scan to prevent cluster instability.

Comment on lines +1451 to 1454
t.trackReadKey(storageKey)
if userKey != nil {
t.trackReadKey(redisTTLKey(userKey))
} else if !bytes.HasPrefix(key, []byte(redisTTLPrefix)) {
t.trackReadKey(redisTTLKey(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.

high

Consistency issue in load. When loading a bare user key that might require a fallback read from the unprefixed path, the bare key itself must be added to the transaction's read set. Without this, the transaction won't detect conflicts if the legacy key is modified or deleted by a concurrent transaction.

	t.trackReadKey(storageKey)
	if !isKnownInternalKey(key) {
		t.trackReadKey(key)
	}
	if userKey != nil {
		t.trackReadKey(redisTTLKey(userKey))
	}
References
  1. In transactional read-modify-write operations, ensure a consistent snapshot to prevent race conditions.

adapter/redis.go Outdated
Comment on lines 1457 to 1466
if userKey != nil {
// For user string keys, use the fallback-aware reader.
val, _ = t.server.readRedisStringAt(key, t.startTS)
} else {
var err error
val, err = t.server.readValueAt(storageKey, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
}
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

There are two issues here:

  1. Logic Error: userKey is non-nil for all internal Redis keys (e.g., !redis|hash|...). Calling readRedisStringAt on these keys is incorrect as it will attempt a prefixed fallback. The fallback reader should only be used for bare user keys.
  2. Error Handling: The error from readRedisStringAt is silently ignored. Transient errors from the storage layer will result in the key being treated as non-existent, which can lead to data corruption during a read-modify-write cycle.
Suggested change
if userKey != nil {
// For user string keys, use the fallback-aware reader.
val, _ = t.server.readRedisStringAt(key, t.startTS)
} else {
var err error
val, err = t.server.readValueAt(storageKey, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
}
if !isKnownInternalKey(key) {
// For bare user string keys, use the fallback-aware reader.
var err error
val, err = t.server.readRedisStringAt(key, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
} else {
var err error
val, err = t.server.readValueAt(storageKey, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
}
References
  1. Avoid silently dropping entries or ignoring errors to ensure data is preserved and avoid state inconsistencies.

Comment on lines +541 to +542
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
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

Scoping FLUSHALL to only !redis| and !lst| prefixes is problematic because it leaves legacy bare string keys untouched. This breaks the expected semantics of FLUSHALL (clearing all Redis data) and creates a data integrity risk: if a prefixed key is deleted by FLUSHALL but its legacy bare counterpart remains, the old value will "reappear" on the next GET. FLUSHALL should be updated to also clear the bare keyspace.

References
  1. Ensure all relevant entries are handled during operations to avoid state inconsistencies.

Comment on lines +1430 to 1431
redisStrKey(key),
redisTTLKey(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.

medium

In trackTypeReadKeys, the bare key should also be tracked to ensure snapshot isolation when checking the type of legacy keys. If only the prefixed key is tracked, concurrent modifications to the legacy bare key by other transactions will not be detected, potentially leading to inconsistent typing decisions.

		redisStrKey(key),
		key,
		redisTTLKey(key),
References
  1. Avoid silently dropping entries or incomplete handling to ensure they are preserved and avoid state inconsistencies.

// regardless of their byte range.
var cursor []byte
for {
kvs, err := r.store.ScanAt(ctx, cursor, nil, batchSize, 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

The flushlegacy command performs a full keyspace scan and dispatches deletions in batches without any throttling. On a large production dataset, this could saturate the Raft log and cause significant latency spikes or even instability for other cluster operations. Consider implementing a mechanism to throttle the deletion rate or adding a small sleep between batches.

References
  1. Apply bounds to operations that can grow from external requests to prevent resource exhaustion and system instability.

@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 prefix migration for Redis string keys, introducing the !redis|str| prefix and a FLUSHLEGACY command for cleanup. The changes update read and write operations to support both new prefixed keys and legacy bare keys. Feedback focuses on ensuring transaction consistency by tracking both key types in trackTypeReadKeys and load to prevent write skew, and correctly handling errors when reading string values during transactions.

redisStreamKey(key),
redisHLLKey(key),
key,
redisStrKey(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.

high

The trackTypeReadKeys function is now missing the bare key which is still used as a fallback for legacy string data in rawKeyTypeAt. To ensure correct conflict detection and snapshot consistency during transactions, both the prefixed and bare keys must be tracked.

Suggested change
redisStrKey(key),
redisStrKey(key),
key,
References
  1. Avoid silently dropping entries or metadata to ensure they are preserved on disk and avoid state inconsistencies.

Comment on lines +1451 to 1467
t.trackReadKey(storageKey)
if userKey != nil {
t.trackReadKey(redisTTLKey(userKey))
} else if !bytes.HasPrefix(key, []byte(redisTTLPrefix)) {
t.trackReadKey(redisTTLKey(key))
}
tv := &txnValue{}
val, err := t.server.readValueAt(key, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
var val []byte
isString := !isKnownInternalKey(key) || bytes.HasPrefix(key, []byte(redisStrPrefix))
if isString {
// For user string keys, use the fallback-aware reader with the bare key.
val, _ = t.server.readRedisStringAt(userKey, t.startTS)
} else {
var err error
val, err = t.server.readValueAt(storageKey, t.startTS)
if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
return nil, errors.WithStack(err)
}
}
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

When loading a potential string key, the transaction must track the bare userKey in addition to the storageKey. Since readRedisStringAt falls back to reading the bare key if the prefixed one is missing, failing to track it means concurrent modifications to legacy keys won't be detected, leading to write skew or lost updates. Additionally, errors from readRedisStringAt should be handled and propagated to avoid inconsistent state.

	t.trackReadKey(storageKey)
	isString := !isKnownInternalKey(key) || bytes.HasPrefix(key, []byte(redisStrPrefix))
	if userKey != nil {
		t.trackReadKey(redisTTLKey(userKey))
		if isString && !bytes.Equal(storageKey, userKey) {
			t.trackReadKey(userKey)
		}
	}
	tv := &txnValue{}
	var val []byte
	if isString {
		// For user string keys, use the fallback-aware reader with the bare key.
		var err error
		val, err = t.server.readRedisStringAt(userKey, t.startTS)
		if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
			return nil, errors.WithStack(err)
		}
	} else {
		var err error
		val, err = t.server.readValueAt(storageKey, t.startTS)
		if err != nil && !errors.Is(err, store.ErrKeyNotFound) {
			return nil, errors.WithStack(err)
		}
	}
References
  1. Avoid silently dropping entries to ensure they are preserved on disk and avoid state inconsistencies.
  2. In transactional read-modify-write operations, set the transaction's start timestamp to the read timestamp to ensure a consistent snapshot and prevent race conditions.

@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 prefix migration for Redis string keys, introducing the "!redis|str|" namespace and updating read/write paths to support both prefixed and legacy bare keys. It adds a FLUSHLEGACY command for one-time cleanup and integrates the new prefixes into standard flush operations. Review feedback identifies a potential inconsistency in key normalization within the transaction context compared to other write paths, concerns regarding timestamp consistency in transactional operations, and recommends implementing leader proxying for the FLUSHLEGACY command to maintain consistency with existing write operations.

Comment on lines 1438 to +1447
func (t *txnContext) load(key []byte) (*txnValue, error) {
k := string(key)
// If the key is already an internal key (e.g., !redis|hash|...,
// !lst|..., !txn|..., !ddb|..., !s3|..., !dist|...), use it as-is.
// Otherwise, it's a bare user key for a string value — prefix it.
storageKey := key
userKey := extractRedisInternalUserKey(key)
if !isKnownInternalKey(key) {
storageKey = redisStrKey(key)
userKey = 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.

high

The logic in txnContext.load to automatically prefix keys based on isKnownInternalKey creates an inconsistency between transactional and non-transactional paths. While load skips prefixing if the key already looks like an internal key, other paths like saveString and incr always apply the prefix. This inconsistency can lead to data silos and collisions within the transaction context. To maintain state consistency, ensure that entries are not silently dropped or misrouted during normalization. Additionally, for these transactional read-modify-write operations, the transaction's start timestamp must be set to the read timestamp and acquired after the linearizable read fence to guarantee a consistent snapshot view.

References
  1. Avoid silently dropping or misrouting entries during normalization to ensure they are preserved on disk and avoid state inconsistencies.
  2. In transactional read-modify-write operations, set the transaction's start timestamp to the read timestamp to ensure a consistent snapshot and prevent race conditions.
  3. When implementing linearizable reads in an MVCC system, ensure that the timestamp used for the MVCC read is acquired after the linearizable read fence has completed to guarantee a consistent snapshot view.

Comment on lines +513 to +516
if !r.coordinator.IsLeader() {
conn.WriteError("ERR FLUSHLEGACY must be run on the leader")
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 flushlegacy command should proxy the request to the leader if the current node is a follower, consistent with FLUSHALL and other write operations. When implementing this proxying logic, ensure that leadership status is verified after acquiring the lock that protects the operation to prevent race conditions and ensure the operation is handled by the correct node.

References
  1. To prevent race conditions in read-modify-write operations that require leadership, verify leadership status after acquiring the lock that protects the operation.

@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 migration for Redis string keys by introducing a new !redis|str| prefix. It includes a fallback mechanism in readRedisStringAt and txnContext.load to support reading legacy unprefixed keys, and adds a FLUSHLEGACY command for manual cleanup. Additionally, FLUSHALL has been updated to target specific Redis prefixes and perform a scan for legacy keys. Review feedback identifies critical concerns regarding the completeness of the legacy scan in sharded environments, a performance regression in FLUSHALL due to the full keyspace scan, and a potential issue where legacy keys are not explicitly deleted when overwritten within a transaction.

// deleted. If deleteTTL is true, also deletes associated TTL keys for each
// legacy key (when called standalone); callers that already cleared TTL keys
// via DEL_PREFIX should pass false.
func (r *RedisServer) deleteLegacyKeys(ctx context.Context, readTS uint64, deleteTTL bool) (int, error) {
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

In a sharded environment, r.store.ScanAt typically only scans the local shard or the shard managed by the default group (when using LeaderRoutedStore). Since legacy unprefixed keys are distributed across all shards based on their key value, this scan will miss legacy keys on all other shards. This makes FLUSHALL and FLUSHLEGACY incomplete in sharded deployments. A cluster-wide cleanup would require coordinating scans across all shard leaders.

// Also clean up any legacy bare keys so they don't "reappear" via the
// fallback read path. TTL keys are already deleted by the !redis| prefix
// delete above, so pass deleteTTL=false.
if _, err := r.deleteLegacyKeys(ctx, r.readTS(), false); err != 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

FLUSHALL (via flushDatabase) now performs a full keyspace scan using deleteLegacyKeys to remove unprefixed keys. This is a significant performance regression from the previous DelPrefix(nil) implementation, especially for large datasets in other namespaces (e.g., DynamoDB). Since FLUSHLEGACY is provided for one-time migration, FLUSHALL should ideally be scoped to Redis prefixes only, or the legacy cleanup should be made optional to maintain the expected performance of flush operations.

Comment on lines 1438 to +1448
func (t *txnContext) load(key []byte) (*txnValue, error) {
k := string(key)
// If the key is already an internal key (e.g., !redis|hash|...,
// !lst|..., !txn|..., !ddb|..., !s3|..., !dist|...), use it as-is.
// Otherwise, it's a bare user key for a string value — prefix it.
storageKey := key
userKey := extractRedisInternalUserKey(key)
if !isKnownInternalKey(key) {
storageKey = redisStrKey(key)
userKey = key
}
k := string(storageKey)
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

Mapping bare user keys to prefixed storage keys in the t.working map enables fallback reads but prevents the transaction from ever explicitly addressing or deleting the bare legacy key. Consequently, legacy keys will persist in the store even after being logically overwritten or deleted within a transaction (e.g., via SET or DEL), leading to stale data remaining until a manual FLUSHLEGACY is performed. To avoid state inconsistencies and ensure that entries are correctly handled during normalization/migration, consider explicitly marking the bare key for deletion in t.working when a legacy string is encountered.

References
  1. Avoid silently dropping entries during serialization or normalization to ensure they are preserved on disk and avoid state inconsistencies.

…sion

FLUSHALL now only deletes prefixed Redis keys via DEL_PREFIX.
Legacy bare keys should be cleaned up separately via FLUSHLEGACY.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented Apr 12, 2026

@claude review

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #486 — feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL

軽微な問題が 3 点あります。


1. FLUSHALL 後のレガシーベアキー残存(設計上の意図的決定だが運用リスク)

flushDatabase!redis|!lst| のみを削除するようになりました。マイグレーション前に書かれたベアキー(例: foo)は FLUSHALL 後も残存します。readRedisStringAt がフォールバック読み込みを持つため、FLUSHALL 後もそれらのキーが GET で返ってくる可能性があります。FLUSHLEGACY を事前に実行する必要があることをドキュメントに明記することを推奨します。


2. FLUSHLEGACY 実行後の TTL キー孤立

deleteLegacyKeys はベアキー(例: foo)を削除しますが、対応する TTL キー !redis|ttl|fooknownInternalPrefixes に含まれるため削除されません。

レガシーキーに TTL が設定されていた場合、FLUSHLEGACY 後に TTL キーだけが残存します。

データキーが存在しなければ GET は null を返すため読み取り結果への影響は限定的です。ただし、後から !redis|str|foo が書き込まれたとき TTL が引き継がれる恐れがあります。その場合、本来 TTL なしで書いたはずのキーが孤立 TTL エントリの影響を受ける可能性があります(saveString で TTL=nil の場合に Del redisTTLKey(key) が発行されるので実害は薄いですが、競合ウィンドウは残ります)。


3. FLUSHLEGACY の非トランザクション batch dispatch

deleteLegacyKeysdispatchElems(ctx, false, readTS, elems)isTxn=false で呼んでいます。バッチ間で 1ms スリープを挟んでいる間に書かれたキーがある場合、競合検出なしで削除される可能性があります。新コードではベアキーへの書き込みは行わないため実害は限定的ですが、混在バージョン運用(ローリングアップデート中)は注意が必要です。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

P1: ホットパスでのダブルストレージ読み取り

** — **

func (r *RedisServer) readRedisStringAt(key []byte, readTS uint64) ([]byte, error) {
    v, err := r.readValueAt(redisStrKey(key), readTS)
    if err == nil {
        return v, nil
    }
    if !errors.Is(err, store.ErrKeyNotFound) {
        return nil, err
    }
    return r.readValueAt(key, readTS) // ← 2回目のストレージ読み取り
}

GET / GETDEL / INCR / TYPE チェックなど、すべての文字列読み取りパスがこの関数を通るようになった。
マイグレーション前のキー(legacy bare key)が存在する限り、1リクエストにつき2回のストレージ I/Oが発生する。
MVCC ストアがディスクバックエンド(Pebble 等)の場合、未マイグレーションキーへのアクセスはすべてレイテンシが2倍になる。

FLUSHLEGACY 実行後にフォールバックパスを削除する計画・期限を明示するか、完了まで監視するメトリクスを設けることを推奨する。


P2: トランザクション競合追跡セットの肥大化

** — **

// 変更前: key を1つだけ追跡
key,

// 変更後: prefixed + bare の2キーを追跡
redisStrKey(key),
key, // legacy bare key for fallback reads

文字列型の TYPE チェック時にリードセットへ登録するキー数が1→2に増加した。トランザクションごとに扱うリードセットのマップエントリが増え、競合検出のメモリ消費とスキャンコストが増加する。
同様に 内でも bare key への追加 が呼ばれている。
FLUSHLEGACY 完了後は両者を削除できるため、技術的負債として追跡することを推奨する。


P3: での不要な3アロケーション

** — **

// 変更後
tv, ok := t.working[string(redisStrKey([]byte(key)))]
//                          ^^^^^^^^^^^^^^^^^^^^^^^^^
//  1. []byte(key)       → ヒープアロケーション
//  2. redisStrKey(...)  → append で新スライス
//  3. string(...)       → 文字列コピー

はすでに 型のため、以下のように文字列連結1回に置き換えられる:

tv, ok := t.working[redisStrPrefix+key]

これにより3アロケーションが0(コンパイラが定数+変数連結を最適化)または1に削減される。
この関数は SET / GET / DEL の型チェックで呼ばれるホットパスである。


P4: の DEL ごとの余分なアロケーション

** — **

bareK := string(key)
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{} // ← DEL ごとに必ずアロケーション
}
t.working[bareK].deleted = true
t.working[bareK].dirty = true

legacy bare key が working map にない場合、毎回 をアロケートして挿入する。
DEL のホットパスで発生するため、GC 圧力に影響する。
の zero value は のため、直接 と初期化すれば無条件1アロケーションで済む(マップ内既存チェックも不要)。


まとめ

優先度 箇所 内容
P1 legacy キーで2倍の I/O(マイグレーション完了まで継続)
P2 / リードセット肥大化によるメモリ・競合検出コスト増
P3 の3アロケーション → で解消可
P4 DEL ごとの不要なアロケーション → 初期化一本化で解消可

P1・P2 はマイグレーション期間の意図的なトレードオフと理解できるが、FLUSHLEGACY 完了後に確実にフォールバックコードを除去するためのチケット・TODO を残すことを強く推奨する。P3・P4 は即修正可能。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

確認事項

✅ 問題なし: readRedisStringAt のフォールバック読み取り

prefixed key (!redis|str|<key>) を先に試み、ErrKeyNotFound の場合のみ bare key にフォールバックする実装は正しい。既存の legacy データを喪失せずに読める。

✅ 問題なし: stageKeyDeletion での legacy bare key 削除

DEL 操作時に prefixed key と bare key の両方を working map でマークすることで、legacy データの残留を防いでいる。

✅ 問題なし: FLUSHLEGACY の TTL キー保護

!redis|ttl|!redis| プレフィックスに含まれるため isKnownInternalKey()true を返し、誤削除されない。


⚠️ 要注意: PR #483 (feat/reduce-lock) との相互依存によるデータロストリスク

PR #483 が導入する wide-column ZSet ストレージは !zs|meta|, !zs|mem|, !zs|scr| プレフィックスを使用しますが、本 PR の knownInternalPrefixes にはこれらが未登録です。

var knownInternalPrefixes = [][]byte{
    []byte("!redis|"),
    []byte("!lst|"),
    []byte("!txn|"),
    []byte("!ddb|"),
    []byte("!s3|"),
    []byte("!dist|"),
    // !zs| が未登録 ← PR #483 のデータが FLUSHLEGACY で全消えする
}

両 PR がマージされた後に FLUSHLEGACY を実行すると:

  • !zs|meta|*, !zs|mem|*, !zs|scr|* が legacy bare key と誤判定され全削除される → ZSet データロスト

また FLUSHALLDelPrefix!zs| が含まれていないため、PR #483 マージ後は FLUSHALL が ZSet wide-column データを削除できなくなる(FLUSHALL が不完全)。

推奨対応

// knownInternalPrefixes に追加
[]byte("!zs|"),

// flushDatabase の DelPrefix リストに追加
{Op: kv.DelPrefix, Key: []byte("!zs|")},

PR #483 がマージされるタイミングに合わせて本 PR も更新することを推奨します。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象: PR #486 - feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


変更の概要

Redis 文字列キーに !redis|str| プレフィックスを追加するマイグレーション。FLUSHALL の対象を既知のプレフィックスのみに限定し、レガシーキーの削除には新設の FLUSHLEGACY コマンドを使用する設計。


指摘事項

[High] FLUSHALL がレガシー素キーを削除しない → 見かけ上の削除後にデータが残存

場所: adapter/redis_compat_commands.go:flushDatabase

変更前は DelPrefix(nil) で全キーを削除していたが、変更後は !redis|!lst| プレフィックスのみ削除。

問題シナリオ:

  1. 旧システムで SET foo "secret" → 素キー foo="secret" が保存される
  2. FLUSHALL 実行 → !redis|!lst| プレフィックスのみ削除される
  3. GET foo"secret" が返ってくる(FLUSHALL で消えたはずのデータ)

readRedisStringAt のフォールバック機能により、プレフィックスキーが見つからない場合は素キーを読み取るため、FLUSHALL 後もレガシー値が返り続けます。「全データ削除済み」のつもりでアプリケーションを使い続けると、秘匿すべきデータや古いデータが意図せずアクセス可能なままになります。

推奨: FLUSHALL 内で自動的に deleteLegacyKeys も実行するか、レスポンスにレガシーキー残存の警告を含める。


[Medium] deleteLegacyKeys が OCC チェックをバイパスして削除

場所: adapter/redis_compat_commands.go:deleteLegacyKeys

dispatchElems(ctx, false, readTS, elems)isTxn=false のため OCC(楽観的並行性制御)の競合チェックが行われません。スキャン時の readTS と削除ディスパッチの間に、別クライアントが素キーに新しい値を書き込んだ場合、その新しい値が削除される可能性があります。

管理操作であり頻度は低いですが、バッチ処理(1000件ループ)の性質上、複数バッチ間でこの競合が発生しうる点に注意が必要です。


[Low] INCR がレガシー素キーを削除しない

場所: adapter/redis_compat_commands.go:incr

INCR 実行時、旧素キーからの読み取り後に !redis|str|key へ書き込みますが、元の素キーを Del しません。読み取りは正確ですが、素キーが Store に残り続けます。直接的なデータロストではないものの、上記の FLUSHALL 問題と組み合わさると古いデータが残存し続けます。


データロスト リスク: 中〜高(FLUSHALL が旧形式の Redis 文字列データを削除せず、意図しないデータ残存が発生する)

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


🔴 Issue 1: FLUSHALL/FLUSHDB がレガシー素キーを削除しない(意味論的不整合)

場所: adapter/redis_compat_commands.goflushDatabase

問題: 変更前の FLUSHALLDelPrefix(nil)(全キー削除)を発行していたが、変更後は !redis|!lst| プレフィックスのみを対象とする。

// 変更後
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
// レガシーな素キー(例: "mykey")は削除されない

Redis の仕様では FLUSHALL はすべてのデータを削除するが、マイグレーション前の素キーが存在する環境では FLUSHALL 後もデータが残存する。たとえば:

  1. 旧バージョンで SET foo bar → 素のキー foo がストアに保存される
  2. 新バージョンにアップグレード後 FLUSHALL を実行
  3. GET fooreadRedisStringAt の legacy fallback により "bar" が返る(期待: nil)

Jepsen などのテストで FLUSHALL を使ってデータをリセットするシナリオで誤検知が発生しうる。

推奨: FLUSHALL / FLUSHDB の前に FLUSHLEGACY を自動実行するか、DelPrefix(nil)(全消し)に戻してプレフィックスフィルタをストア側の FSM に委ねることを検討。


🟡 Issue 2: deleteLegacyKeys のバッチ間でコンフリクト検出なし

場所: adapter/redis_compat_commands.godeleteLegacyKeys

totalDeleted, err := r.deleteLegacyKeys(ctx, r.readTS())
// readTS はここで固定される

// ループ内:
if err := r.dispatchElems(ctx, false, readTS, elems); err != nil { ... }
//                               ^^^^^ isTxn=false → コンフリクト検出なし

readTS は最初の1回のみ取得され、複数バッチにわたる操作でそのまま使い回される。バッチ間で time.Sleep(time.Millisecond) を入れているが、この間に並行書き込みが入っても検出されない。

マイグレーション操作としては許容範囲だが、ドキュメントに「進行中は他の書き込みを停止することを推奨」と明記することを推奨。


🟡 Issue 3: stageKeyDeletion でのレガシーキー tombstone の無条件生成

場所: adapter/redis.gostageKeyDeletion

// 存在確認なしに working set に Del をセット
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{}
}
t.working[bareK].deleted = true
t.working[bareK].dirty = true

存在しない素キーに対しても Del オペレーションが buildKeyElems 経由で Raft ログに書き込まれる。削除は冪等なので正確性には影響しないが、不要な Raft エントリが生成される。必要に応じて r.store.ExistsAt で存在確認してから追加を検討。


⚠️ マージ競合の注意

PR #483deleteLogicalKeyElems と本PRの同関数の変更が競合している。マージ時は両方の変更(redisStrKey(key) の追加と ZSet ワイドカラム削除)を統合する必要がある。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL

指摘事項


[MEDIUM] FLUSHALL のセマンティクス変更によるファントムデータリスク

flushDatabase!redis|, !lst|, !zs| の3プレフィックスのみを対象とするようになったため、マイグレーション前に書かれたレガシー裸キー(プレフィックスなし)が残存する。

// 旧: 全キー削除
{Op: kv.DelPrefix, Key: nil}

// 新: 既知プレフィックスのみ削除
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},

FLUSHALL 後に FLUSHLEGACY を実行しないと裸キーが残り、同名キーへの GET で旧データが返る可能性がある。この2段階操作は強制されておらず、利用者が知らずに FLUSHALL だけ実行するケースで一貫性が損なわれる。

提案: FLUSHALL 内で FLUSHLEGACY 相当の処理を自動実行するか、または FLUSHALL 時に裸キーが残っている場合は警告を返す。


[MEDIUM] deleteLegacyKeys のバッチ間競合

deleteLegacyKeys 内でバッチごとに独立して dispatchElems(ctx, false, readTS, elems) を発行している(isTxn=false)。各バッチ間に time.Sleep(time.Millisecond) を挟むため、この間に別のトランザクションがレガシー裸キーに書き込んだ場合、そのキーが後続バッチのスキャン範囲に入ると tombstone で上書きされるリスクがある。

マイグレーション完了後に新規裸キーが書かれることは想定外だが、移行期間中(移行途中のノードが混在するクラスタ)は発生し得る。

提案: FLUSHLEGACY はリーダー昇格直後ではなく、全ノードのマイグレーション完了を確認後に実行する運用ガイドを明記する。


[LOW] stageKeyDeletion が未ロードの裸キーを working に追加

// adapter/redis.go
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{}
}
t.working[bareK].deleted = true
t.working[bareK].dirty = true

tv.loaded = false のまま dirty=true にしているため、buildKeyElems が存在しない裸キーへの DEL を Raft ログに投入する。MVCC では no-op だが、Raft ログを不要に膨らませる。


[確認済み - 問題なし]

  • readRedisStringAt のフォールバック読み取り(プレフィックスキー→裸キー): MVCC 読み取りは同一 readTS で行われるため一貫性あり ✓
  • trackTypeReadKeys での両キー追跡: 衝突検出に必要な読み取りセットが正しく管理されている ✓
  • stagedStringType のプレフィックスキーによるルックアップ: load() が裸ユーザーキーをプレフィックスキーに変換して working に格納するため整合している ✓

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL

要注意: FLUSHALL のセマンティクス変更によるデータ残留リスク


問題1 (HIGH): FLUSHALL がレガシー bare キーを削除しない

変更前:

{Op: kv.DelPrefix, Key: nil}  // 全キーを削除

変更後:

{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},

リスク:

  • !redis|str| 移行前に書かれたレガシー bare キー(例: キー名 foo がそのまま格納されたもの)は FLUSHALL 後も残留する。
  • 「FLUSHALL = 全データ消去」と想定している運用スクリプトやテストコードが、旧データをそのまま参照してしまう。
  • CI/CD やテスト間の isolation で FLUSHALL を使っている場合、テスト汚染が発生しうる。

コメントに「FLUSHLEGACY を先に実行せよ」とあるが、オペレーターが知らずに FLUSHALL だけ実行した場合にレガシーデータが消えないことを ドキュメント・エラーメッセージ レベルで明示する仕組みがない点が懸念です。


問題2 (LOW): FLUSHLEGACY の TTL キー残留による将来キーへの誤 expire

deleteLegacyKeysisKnownInternalKey に合致するキー(!redis|ttl| を含む)を保護するため、レガシー bare キーを削除しても対応する TTL キー(!redis|ttl|foo)は残留する。

シナリオ:

  1. FLUSHLEGACY 実行 → bare キー foo 削除、TTL キー !redis|ttl|foo(過去の期限)残留
  2. 新規 SET foo bar!redis|str|foo 書き込み+TTLキー削除(saveStringDel redisTTLKey が走る)

saveString は常に TTL を明示的に書き換えるため実害は出にくいが、直接 dispatchElems 経由で TTL キーをスキップして bare キーだけ削除するパスが残る場合は注意が必要です。実際のコードフローでは影響がないことを確認していますが、今後 TTL を持つキーへ FLUSHLEGACY を適用する際は再検証を推奨します。


その他 (問題なし)

  • readRedisStringAt の prefixed → bare フォールバック読み: 正しいマイグレーション設計です。
  • stageKeyDeletion での bare キー tombstone 追加: 非存在キーへの Del は MVCC では無害なため問題なし。
  • INCR の書き込み先変更 (cmd.Args[1]redisStrKey(cmd.Args[1])): 正しい変更です。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


1. stagedStringType: マップルックアップごとに3アロケーション

// adapter/redis.go
func (t *txnContext) stagedStringType(key string) (redisValueType, bool) {
-   tv, ok := t.working[key]
+   tv, ok := t.working[string(redisStrKey([]byte(key)))]

string(redisStrKey([]byte(key))) の変換で:

  1. []byte(key) — string → []byte コピー
  2. redisStrKey(...) — プレフィックス付き新規 []byte
  3. string(...) — []byte → string コピー

の3アロケーションがホットパスで毎回発生します。txnContextstring 型で prefixed key を保持するか、working マップのキーを正規化済みの形で統一することを検討してください。


2. stageKeyDeletion: 同一マップキーを2回参照

// adapter/redis.go
bareK := string(key)
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{}
}
t.working[bareK].deleted = true  // 2回目のマップルックアップ
t.working[bareK].dirty = true    // 3回目のマップルックアップ

ポインタをローカル変数に取り出すことで、マップへのアクセスを1回に減らせます:

tv := t.working[bareK]
if tv == nil {
    tv = &txnValue{}
    t.working[bareK] = tv
}
tv.deleted = true
tv.dirty = true

3. deleteLegacyKeys: バッチごとにカーソル用 []byte をアロケーション

// adapter/redis_compat_commands.go
cursor = make([]byte, len(lastKey)+1)
copy(cursor, lastKey)

バッチ数だけ新しいスライスを確保しています。メンテナンス操作なので致命的ではありませんが、cursor を事前に十分な容量で確保してから cursor = cursor[:len(lastKey)+1] と再スライスすることでアロケーションを削減できます。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


問題1(重要): FLUSHALL後にレガシーベアキーが読み出せる — スタールデータの露出

パス: adapter/redis_compat_commands.go flushDatabase / adapter/redis_compat_helpers.go readRedisStringAt

FLUSHALL!redis|!lst|!zs| プレフィックスのキーのみ削除し、レガシーのベアキー(例: foo)は削除しません。
一方、readRedisStringAt!redis|str|foo が見つからない場合にベアキー foo へフォールバックします。

再現シナリオ:

  1. 旧クライアントが SET foo value1 → ベアキー foo として保存
  2. FLUSHALL 実行 → !redis|* は削除されるが、ベアキー foo残存
  3. 新クライアントが GET foo!redis|str|foo は存在しないためフォールバック → ベアキー foo が返る

FLUSHALL 後にデータベースがクリアされたと見なしたアプリケーションが、フラッシュ前のデータを読み取ってしまいます。

対処案: FLUSHALL の前に FLUSHLEGACY の実行をドキュメントで必須要件として明記するか、FLUSHALL 自体がレガシーキー削除を含むよう (FLUSHLEGACY を内部呼び出し) 実装を検討してください。


問題2(軽微): FLUSHLEGACY がバッチ間で非アトミック

パス: adapter/redis_compat_commands.go deleteLegacyKeys

削除は 1000 件ごとのバッチに分割され、各バッチが個別の Raft ログエントリとして発行されます。サーバークラッシュやタイムアウト発生時、バッチの途中で停止するとレガシーキーが一部削除・一部残存する中間状態になります。

FLUSHLEGACY 自体は冪等な操作(再実行可能)なので真のデータロストにはなりませんが、中間状態の期間に「問題1」のスタールデータ露出が継続します。進捗や完了状態をクライアントへ明示する仕組みがあると安全です。


確認済み(問題なし)

  • stageKeyDeletion での存在確認なしのベアキー削除マーク → MVCC 上の非存在キーへの DEL は no-op なので実害なし
  • saveString(nil TTL) / INCR はいずれも redisTTLKey を削除するため、FLUSHLEGACY 後の孤立 TTL キーは次回書き込み時にクリーンアップされる

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

対象PR: #486 feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL

問題点


[MEDIUM] FLUSHALL がレガシーベアキーを削除しない(データ残留)

場所: adapter/redis_compat_commands.go flushDatabase

// Before: nil(全キー削除)
{Op: kv.DelPrefix, Key: nil}

// After: 既知プレフィックスのみ削除
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},

FLUSHLEGACY を実行していない環境で FLUSHALL を呼ぶと、レガシー裸キー(! プレフィックスなし)が残留する。FLUSHALL 後もデータが読めてしまい、ユーザーには予期しない動作に見える。

コードコメントに「Run FLUSHLEGACY first」とあるが、実運用でこの手順漏れは起きやすい。起動時の自動マイグレーション、または FLUSHALL 時に FLUSHLEGACY を内包する処理を検討されたい。


[LOW] deleteLegacyKeys でのリーダー切り替え

場所: adapter/redis_compat_commands.go deleteLegacyKeys

if err := r.dispatchElems(ctx, false, readTS, elems); err != nil {
    return totalDeleted, err
}

flushlegacy はエントリ時点で IsLeader() を確認してフォロワーをプロキシへ誘導しているが、バッチループ中のリーダー切り替えはカバーしていない。切り替え発生時は dispatchElems がエラーを返し、処理が途中で中断される。

これは データ整合性の問題にはならない(途中で止まるだけで中途半端な削除は MVCC で安全)が、部分的なマイグレーション状態が残る。クライアントにはエラーとして返されるため、再実行で対処可能。許容範囲内だが、バッチ内でのリーダー確認ループを追加すると堅牢になる。


[INFO] time.Sleep(time.Millisecond) の使用

場所: deleteLegacyKeys のバッチループ

プロダクションコードに time.Sleep を直書きしている。Raft ログのスロットリング目的との記載はあるが、テストでの制御が難しくなる。time.After や ticker を使う、またはインターフェース経由でモック可能にする方が望ましい。ただし機能的な問題はなし。


問題なし(確認済み)

  • deleteLogicalKeyElems の文字列削除: redisStrKey(key)(新フォーマット)と key(レガシー)の両方を削除対象に含めており正しい
  • deleteLegacyKeysreadTS 固定: マイグレーション開始後に書き込まれた新フォーマットキーは readTS 以降のコミットに存在するため、誤って削除されない
  • プロキシの timeout 設定: proxyFlushLegacyredisFlushLegacyTimeout + 30s を使用しており、ネットワーク遅延を考慮した適切な値
  • isTxn=false の意味: 非トランザクション dispatc であり、リーダーチェックのバイパスではない

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

対象PR: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


問題あり (2件)


問題1 (重大): FLUSHALL がレガシー裸キーを削除しないため、フラッシュ後も GET でデータが返る

箇所: adapter/redis_compat_commands.goflushDatabase

概要:

FLUSHALL は !redis|!lst|!zs| プレフィックスのキーのみを DelPrefix で削除する。レガシー裸キー(プレフィックスなしの Redis 文字列キー)は削除されない。

一方、readRedisStringAt は以下の順でフォールバック読み取りをする:

!redis|str|<key> → 見つからなければ <key> (裸キー)

結果として、FLUSHLEGACY を先に実行しないまま FLUSHALL を実行した場合:

  • SET foo bar を裸キーで書き込んだ既存データが <key> に残る
  • FLUSHALL 後に GET foo!redis|str|foo は削除済み → foo (裸キー) からヒット → 旧値が返る

これは FLUSHALL が成功したにも関わらず古いデータが読めてしまう という線形化可能性の違反です。

影響: FLUSHLEGACY を先行実行しないオペレーターが FLUSHALL でデータが消えたと思いつつ、実際にはレガシーキーが残り続けるリスクがある。

提案: FLUSHALL のコメントやドキュメントにより強い警告を加えるか、FLUSHALL 内で deleteLegacyKeys を呼び出す(タイムアウトは調整)ことを検討してください。


問題2 (軽微): INCR がレガシー裸キーをクリーンアップしない

箇所: adapter/redis_compat_commands.goincr

概要:

INCR はレガシー裸キーから値を読み取り (readRedisStringAt のフォールバック)、更新値を !redis|str|<key> に書き込む。しかし裸キー <key> は削除されない。

  • 次回以降の GET!redis|str|<key> を先に見るため 正しい値 を返す ✓
  • ただし裸キーにはインクリメント前の古い値が残り続ける(ストレージの無駄)
  • FLUSHLEGACY または DEL を実行するまでゴミデータとして存在し続ける

提案: INCR の書き込み時に裸キーの Del 操作を elems に追加することを検討してください(DEL はすでに stageKeyDeletion で両方を削除しているため、同様の対処が可能)。


その他 (問題なし)

  • TTL との整合性: TTL キーは !redis|ttl|<userKey> 形式で変更なし。読み取り・書き込みともに userKey を元に TTL を参照しており、プレフィックス追加の影響を受けない ✓
  • トランザクション読み取りセット: txnContext.load() で裸キーと !redis|str|<key> の両方を trackReadKey し、競合検出をカバーしている ✓
  • stageKeyDeletion: Del 操作を裸キー・プレフィックス付きキー双方に発行しており、DEL コマンドは正しくクリーンアップされる ✓
  • Raft 経由: deleteLegacyKeysdispatchElems 経由でRaftに送られており、レプリカ間同期は維持される ✓
  • isKnownInternalKey の網羅性: !zs| が含まれており、ZSet wide-column キーがレガシー判定されることはない ✓

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL

概要

Redis文字列キーに !redis|str| プレフィックスを追加し、FLUSHALL のスコープを限定。新コマンド FLUSHLEGACY を追加。

カバーされているケース ✅

  • redis_retry_test.go の既存テストをキー変換 (redisStrKey) に対応させる修正のみ

不足しているテストケース ❌

1. readRedisStringAt フォールバックパス(重要度: 高)

readRedisStringAt はプレフィックス付きキーで読み込めなければベアキー(レガシー)にフォールバックする。このフォールバック経路のテストがない。

func TestReadRedisStringAtFallsBackToLegacyBareKey(t *testing.T) {
    st := store.NewMVCCStore()
    // レガシー: bare キーに書き込む
    _ = st.PutAt(ctx, []byte("mykey"), []byte("v1"), 1, 0)

    srv := newTestRedisServer(st)
    val, err := srv.readRedisStringAt([]byte("mykey"), st.LastCommitTS())
    require.NoError(t, err)
    require.Equal(t, []byte("v1"), val) // フォールバック経由で取得できること
}

2. FLUSHLEGACY コマンド(重要度: 高)

新コマンド FLUSHLEGACY (deleteLegacyKeys) に対するテストが皆無。

提案するテストケース:

func TestFlushLegacyDeletesBareKeysPreservingPrefixedKeys(t *testing.T) {
    // セットアップ: bare key と prefixed key を両方書き込む
    // FLUSHLEGACY を実行
    // bare key が削除されていることを確認
    // !redis|str|mykey は残っていることを確認
    // !redis|ttl|mykey (TTL キー) は残っていることを確認
}

func TestFlushLegacyBatchBoundary(t *testing.T) {
    // batchSize(1000)を超えるレガシーキーを書き込んで、複数バッチが正しく処理されることを確認
}

3. FLUSHALL スコープ変更(重要度: 高)

FLUSHALL が nil (全消去) から !redis|, !lst|, !zs| プレフィックスのみ削除に変更された。この変更に対するテストがない。

func TestFlushAllDoesNotDeleteNonRedisNamespaces(t *testing.T) {
    // !txn|, !ddb|, !s3| など他ネームスペースのキーを書き込む
    // FLUSHALL を実行
    // Redisキー (!redis|str|, !lst|, !zs|) は削除されていること
    // 他ネームスペースのキーが残っていることを確認
}

4. isKnownInternalKey ユニットテスト(重要度: 中)

txnContext.load() の分岐に使われる重要な述語だがテストなし。空キー・境界値が未検証。

5. stageKeyDeletion のレガシーキー削除ロジック(重要度: 中)

stageKeyDeletion に追加されたベアキーの dirty/deleted マーキングにテストなし。
DEL コマンドで旧フォーマットのキーが確実に消えることを確認する統合テストが必要。

6. レガシー→新フォーマットの共存シナリオ(重要度: 高)

旧データ (bare key) が存在する状態で:
  GET → フォールバックで読める
  SET → !redis|str| に書き込む
  GET → 今度は新キーで読める
  旧 bare key が残っている (上書きではなく追加)
  DEL → 両キーが消える

このシナリオ全体のテストが欠落している。

判定: 重大な不足あり

FLUSHLEGACY と FLUSHALL の動作変更・readRedisStringAt のフォールバックはデータ損失リスクに直結するため、テストを追加してからマージを推奨する。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


[P1] readRedisStringAt: レガシーキーへの読み取りで常に 2 回の GetAt が発生

adapter/redis_compat_helpers.go

func (r *RedisServer) readRedisStringAt(key []byte, readTS uint64) ([]byte, error) {
    v, err := r.readValueAt(redisStrKey(key), readTS)
    if err == nil {
        return v, nil
    }
    if !errors.Is(err, store.ErrKeyNotFound) {
        return nil, err
    }
    return r.readValueAt(key, readTS)   // フォールバック
}

FLUSHLEGACY を実行するまで、未移行のレガシーキーへのすべての GET / INCR / GETDEL 等が 2 回のストアアクセス(!redis|str|<key> のミス + 裸キーのヒット)を引き起こす。GET はホットパスのため、移行期間が長引くほどスループットに影響する。

推奨: バックグラウンドでの段階的移行 or FLUSHLEGACY 実行後のフォールバック削除計画を明示してください。


[P2] isKnownInternalKey: txnContext.load ホットパスで線形スキャン

adapter/redis_compat_helpers.go

func isKnownInternalKey(key []byte) bool {
    if len(key) == 0 || key[0] != '!' {
        return false
    }
    for _, prefix := range knownInternalPrefixes {  // 7 プレフィックスを線形探索
        if bytes.HasPrefix(key, prefix) {
            return true
        }
    }
    return false
}

txnContext.load のすべてのキーアクセスで呼ばれる。key[0] != '!'! の早期終了でユーザーキーはほぼコストゼロだが、内部キー(全コマンドのリード追跡で頻繁に現れる)は最大 7 回の bytes.HasPrefix 比較になる。

現状の 7 プレフィックスは許容範囲だが、プレフィックスが増えた場合に備え、第 2 文字(r, l, t, d, s, z など)でのスイッチ分岐に変更するとスケーラブル。


[P2] trackTypeReadKeys: 追跡キー数が 1 件増加

adapter/redis.go

redisStrKey(key),
key, // legacy bare key for fallback reads

型確認を行うすべてのコマンドで、読み取りセットに裸キーを追加で登録するようになった。マルチキートランザクションでは読み取りセットのサイズが増大し、コンフリクト検出のスキャンコストが上がる。移行完了後に削除することを推奨。


[P3] deleteLegacyKeys: バッチ間 time.Sleep(time.Millisecond) がある

adapter/redis_compat_commands.go

time.Sleep(time.Millisecond)

Raft ログの過負荷防止が目的と理解できるが、1000 件バッチ × スリープのループは全体のスループットが低く大規模データで非常に遅い。レート制限が必要なら time.Tick / rate.Limiter などでスリープを制御するか、バッチサイズを大きくする方が合理的。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

PR: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL

問題あり


[HIGH] FLUSHLEGACY が並行書き込みとのコンフリクト検出なしに削除を実行する

deleteLegacyKeysadapter/redis_compat_commands.go):

if err := r.dispatchElems(ctx, false, readTS, elems); err != nil {
    return totalDeleted, err
}

dispatchElems の第2引数 isTxn=false はMVCCの書き込み競合チェック(latestTS > startTS)をスキップする。FLUSHLEGACYはスキャン→ディスパッチの間に並行writeが完了したbare keyを検知できず、そのまま削除してしまう。

データロストシナリオ:

  1. FLUSHLEGACYがスキャン時点のreadTSでbare key foo を検出
  2. クライアントが SET foo newvalue を実行し、新しいbare keyが書き込まれる
  3. FLUSHLEGACYが Del(foo)isTxn=false でディスパッチ → 競合未検出のまま削除

推奨対応: バッチ毎に retryRedisWrite を使って isTxn=true でディスパッチするか、または FLUSHLEGACY のドキュメントとエラーレスポンスで「書き込みを停止した状態で実行すること(maintenance window必須)」を明示する。


[MEDIUM] FLUSHALL がlegacyベアキーを削除しなくなった(動作変更)

変更後のFLUSHALLは !redis|!lst|!zs| プレフィックスのキーのみ削除する。移行前のベアキー(例: foo)はFLUSHALL後も残存する。

コード内コメントに記述されているが、ユーザーが「FLUSHALL後に全データが削除された」と認識して後続の処理を行うと、残存するlegacyキーが予期せず参照される可能性がある。FLUSHALLの前にFLUSHLEGACYの実行を義務付けるガードや警告が望ましい。


[LOW] DEL操作ごとにベアキーのtombstoneが不要に書かれる

stageKeyDeletionadapter/redis.go)は移行完了後もすべてのDEL操作でベアキーの Del エントリを作業セットに追加する。Raftログに不要なエントリが蓄積されるが、MVCC上は無害。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

対象: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


問題1(中): FLUSHALL がレガシーベアキーを削除しない — データ残留リスク

// adapter/redis_compat_commands.go
_, err := r.coordinator.Dispatch(ctx, &kv.OperationGroup[kv.OP]{
    Elems: []*kv.Elem[kv.OP]{
        {Op: kv.DelPrefix, Key: []byte("!redis|")},
        {Op: kv.DelPrefix, Key: []byte("!lst|")},
        {Op: kv.DelPrefix, Key: []byte("!zs|")},
    },
})

変更前の FLUSHALL は DelPrefix: nil(全キー空間)でしたが、変更後は 3 つの既知プレフィックスのみを削除します。移行前に書き込まれたレガシーベアキー(例: ユーザーが SET foo bar を旧バージョンで実行した場合)は、FLUSHLEGACY を別途実行しない限り残存します。

懸念: アプリケーションが「FLUSHALL で全データが消える」という前提で動作している場合(テスト環境の初期化、隔離など)、レガシーデータが混入したまま後続処理が走りうる。コードコメントには注記がありますが、強制メカニズムがないため運用上の事故が起きやすい。

推奨対策: FLUSHALL 実行時にレガシーキーが存在するかどうかを一度チェックし、存在する場合は警告レスポンス(または自動的に FLUSHLEGACY を先行実行)する仕組みを検討してください。


問題2(低): stageKeyDeletion でレガシーベアキーを存在チェックなしに DEL 生成

// adapter/redis.go - stageKeyDeletion
t.trackReadKey(key)
bareK := string(key)
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{}
}
t.working[bareK].deleted = true
t.working[bareK].dirty = true

ベアキーが実際に存在しない場合でも dirty=true でマークされるため、buildKeyElems が不要な kv.Del を生成し、Raft ログに余分なエントリが積まれます。正確性への影響はありませんが(冪等操作)、高頻度の DEL コマンドが走る環境ではログの肥大化が起きます。

推奨対策: r.store.ExistsAt(ctx, key, t.startTS) でベアキーの存在を確認してから working map に登録することを検討してください。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL

問題が 1 件、注意点が 1 件 あります。


[問題あり] deleteLegacyKeys のコンテキストキャンセル未確認

場所: adapter/redis.go deleteLegacyKeys

// Yield briefly between batches to avoid saturating the Raft log.
time.Sleep(time.Millisecond)

バッチ間の time.Sleep がコンテキストキャンセルを確認していません。ScanAt / dispatchElemsctx を使用するため次のイテレーションでキャンセルは検出されますが、タイムアウト直前に最大 1ms の余分な遅延が発生します。以下の変更を推奨します:

select {
case <-ctx.Done():
    return totalDeleted, ctx.Err()
default:
    time.Sleep(time.Millisecond)
}

[注意] FLUSHALL のセマンティクス変更

{Op: kv.DelPrefix, Key: nil} (全消去) から "!redis|", "!lst|", "!zs|" の 3 プレフィックス削除に変更されました。レガシー裸キーは FLUSHALL で削除されなくなります。コメントに記載はありますが、FLUSHLEGACY 未実行のクラスターで FLUSHALL を実行したユーザーが「すべて消えた」と誤認するリスクがあります。レスポンスに警告を含めるか、既存のレガシーキーが存在する場合に通知することを検討してください。


[OK] 確認済み事項

  • flushlegacy のリーダールーティング: proxyFlushLegacy 経由で正しくリーダーへ転送される(非リーダーノードからの呼び出しも安全)
  • isKnownInternalKey の完全性: !redis|, !lst|, !txn|, !ddb|, !s3|, !dist|, !zs| をすべてカバー
  • readRedisStringAt フォールバック: プレフィックスキー優先 → 裸キーフォールバックの順序は正しく、レガシーデータの読み取りも保証される
  • deleteLegacyKeys の冪等性: 同一キーの二重削除はトゥームストーン追加のみで問題なし

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

対象PR: #486 feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


概要

このPRは大きなストレージ移行を含む変更です。新しいキープレフィックス (!redis|str|)、フォールバック読み取りロジック、FLUSHLEGACY コマンド、FLUSHALL スコープ変更など、重要な変更が複数あります。しかし、これらに対応するテストが著しく不足しています


問題1: isKnownInternalKey() のユニットテストなし(重要度: 高)

新しい isKnownInternalKey() 関数(redis_compat_types.go)はレガシーキーの判定に使われる中核ロジックですが、対応するユニットテストが存在しません。誤判定があるとデータ削除に直結します。

提案テストケース:

func TestIsKnownInternalKey(t *testing.T) {
    cases := []struct {
        key      []byte
        expected bool
    }{
        {[]byte("!redis|str|foo"), true},
        {[]byte("!redis|hash|foo"), true},
        {[]byte("!lst|foo"), true},
        {[]byte("!txn|foo"), true},
        {[]byte("!ddb|foo"), true},
        {[]byte("!s3|foo"), true},
        {[]byte("!dist|foo"), true},
        {[]byte("!zs|foo"), true},
        {[]byte("foo"), false},           // bare user key
        {[]byte("!unknown|foo"), false},  // unknown prefix
        {[]byte("!"), false},             // lone exclamation mark
        {[]byte(""), false},              // empty key
    }
    for _, c := range cases {
        require.Equal(t, c.expected, isKnownInternalKey(c.key))
    }
}

問題2: readRedisStringAt() のフォールバック動作テストなし(重要度: 高)

readRedisStringAt() はプレフィックス付きキーで読み試みた後、存在しない場合にベアキーへフォールバックします。このフォールバックロジックはデータ移行の根幹ですが、テストが一切ありません。

redis_storage_migration_test.go には Hash/Set/HLL/ZSet/Stream の移行テストがあるのに、文字列キーの移行テストが欠けています

提案テストケース:

func TestRedisStringLegacyBareKeyFallback(t *testing.T) {
    // プレフィックスなしのベアキーで保存 → readRedisStringAt がフォールバック読み取りできること
}

func TestRedisStringPrefixedKeyReadFirst(t *testing.T) {
    // プレフィックス付きキーが優先して読まれること(ベアキーとの共存時)
}

func TestRedisStringLegacyReadThenRewriteWithPrefix(t *testing.T) {
    // ベアキーで保存 → GET で読める → SET で上書き → !redis|str| プレフィックスで保存されること
}

func TestReadRedisStringAt_NotFoundReturnsSentinel(t *testing.T) {
    // プレフィックスキーもベアキーも存在しない場合 ErrKeyNotFound が返ること
}

func TestReadRedisStringAt_NonNotFoundErrorNoFallback(t *testing.T) {
    // プレフィックスキー読み取りで ErrKeyNotFound 以外のエラー → ベアキーに fallback しないこと
}

問題3: FLUSHLEGACY コマンドのテストなし(重要度: 高)

flushlegacy / deleteLegacyKeys() は新規追加の移行コマンドですが、テストがゼロです。特に以下が未検証です:

  • ベアキーを削除すること
  • !redis|*, !lst|*, !ddb|* などの内部キーを保持すること(誤削除がないこと)
  • 1000件を超えるバッチ処理のページネーション
  • 削除件数の正確な返却

提案テストケース:

func TestDeleteLegacyKeys_DeletesBareKeysOnly(t *testing.T) {
    // ベアキーのみ削除し、内部プレフィックスキーは残ること
}

func TestDeleteLegacyKeys_EmptyStore(t *testing.T) {
    // 空のストア → 0 返却
}

func TestDeleteLegacyKeys_LargeBatch(t *testing.T) {
    // 1001件のベアキー → 全て削除され、正しいカウントが返ること(ページネーション検証)
}

func TestFlushLegacyCommand_Response(t *testing.T) {
    // FLUSHLEGACY コマンドが削除件数 (integer) を返すこと
}

問題4: スコープ変更後の FLUSHALL テストなし(重要度: 高)

FLUSHALL は以前「全キー削除」でしたが、今回 !redis|*, !lst|*, !zs|* のみ削除するよう変更されました。この変更を検証するテストがありません。

提案テストケース:

func TestFlushAllScopedToRedisNamespaces(t *testing.T) {
    // !ddb|*, !s3|*, !dist|* キーがFLUSHALL後も残ること
    // !redis|str|*, !redis|hash|*, !lst|* キーが削除されること
    // ベアレガシーキーはFLUSHALLで削除されないこと(FLUSHLEGACY が必要)
}

問題5: DEL コマンドがレガシーベアキーも削除するテストなし(重要度: 中)

stageKeyDeletion() でレガシーベアキーも削除マークされるよう変更されていますが、この動作を検証するテストがありません。

提案テストケース:

func TestRedisDelAlsoDeletesLegacyBareKey(t *testing.T) {
    // ベアキーで書き込み → DEL → ベアキーが削除されること
    // プレフィックスキーで書き込み → DEL → プレフィックスキーが削除されること
}

問題6: トランザクション文脈でのレガシーキー読み取り未テスト(重要度: 中)

txnContext.load() は bare キーを !redis|str| にマッピングしてから読み取る処理に変更されています。MULTI/EXEC トランザクション内でのレガシーキー読み取りが正しく動作するかを検証するテストがありません。

提案テストケース:

func TestMultiExecWithLegacyStringKey(t *testing.T) {
    // MULTI/EXEC 内で、ベアキーで保存されたレガシー文字列を GET できること
}

既存テストの更新(問題なし)

redis_retry_test.go の3箇所は正しく redisStrKey() に更新されており、既存テストの破損はありません。


まとめ

カテゴリ 状況
isKnownInternalKey() ユニットテスト なし
文字列キー移行テスト (redis_storage_migration_test.go) なし
FLUSHLEGACY コマンドテスト なし
スコープ変更後の FLUSHALL テスト なし
DEL によるレガシーキー削除テスト なし
既存テストの破損 なし(更新済み)

移行ロジックの正確性はデータの永続性に直結するため、特に readRedisStringAt フォールバック・FLUSHLEGACYFLUSHALL スコープのテストを追加することを強くお勧めします。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Test Coverage] レビュー結果

PR #486「feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL」のテスト網羅性をレビューしました。


概要

テスト変更は adapter/redis_retry_test.go の既存テスト3箇所のキー参照修正のみです。今回の変更で追加された新しいロジックに対するテストが大幅に不足しています。


不足しているテスト

1. readRedisStringAt() のフォールバック読み取りパス

最も重要な新関数ですが、ユニットテストがありません。

提案するテストケース:

// TestReadRedisStringAtFallback
// - prefixedキーに値があるケース → prefixedキーから読む
// - bare key(legacy)にのみ値があるケース → fallbackでbare keyから読む
// - 両方にキーがある場合 → prefixedキーを優先する
// - どちらにも値がない場合 → ErrKeyNotFound を返す

2. isKnownInternalKey() のエッジケース

// TestIsKnownInternalKey
// - 空キー(len==0)
// - '!' から始まるが既知プレフィックスに一致しないキー (例: "!unknown|")
// - 各既知プレフィックス (!redis|, !lst|, !txn|, !ddb|, !s3|, !dist|, !zs|) の確認
// - 通常のユーザーキー(プレフィックスなし)

3. flushlegacy / deleteLegacyKeys() のテストが存在しない

新コマンドですが、テストが一切ありません。

提案するテストケース:

// TestFlushLegacy
// - legacyキー(bare key)のみ存在 → 全て削除、削除件数を返す
// - 内部プレフィックスキーのみ存在 → 何も削除しない(0を返す)
// - legacyキーと内部キーが混在 → legacyキーのみ削除
// - legacyキーが存在しない(空DB)→ 0を返す
// - batchSize(1000)を超えるlegacyキー → ページネーションが正しく機能する

4. スコープ変更後の flushall のテストがない

変更前: 全キーを削除
変更後: !redis|, !lst|, !zs| のみ削除

提案するテストケース:

// TestFlushAllScopeIsolation
// - FLUSHALL後もDynamoDBキー(!ddb|*)が残っている
// - FLUSHALL後もS3キー(!s3|*)が残っている
// - FLUSHALL後もdistributionキー(!dist|*)が残っている
// - FLUSHALL後もlegacyのbare keyが残っている(FLUSHLEGACYで別途削除が必要)
// - FLUSHALL後にRedisキー(!redis|*, !lst|*, !zs|*)が削除されている

5. migration期間中の読み書き整合性テスト

提案するテストケース:

// TestLegacyKeyMigrationCompat
// - bare keyでSET(legacy)、その後GETが正しく値を返す(fallback動作)
// - prefixed keyでSET後、bare keyで書かれたlegacy値が上書きされない
// - DEL実行時にprefixed keyとbare key両方が削除される
// - INCR実行時にlegacy bare keyの値を正しく読み、prefixed keyに書き込む

6. トランザクションコンテキスト (txnContext) の変更テスト

txnContext.load()txnContext.stageKeyDeletion()txnContext.stagedStringType() の変更にテストがありません。

提案するテストケース:

// TestTxnContextWithLegacyKeys
// - トランザクション内でlegacy bare keyを読み、更新できる
// - stageKeyDeletionでprefixed keyとbare key両方がdirtyになる
// - buildKeyElemsがlegacy bare keyのDEL要素を正しく生成する

7. Luaスクリプトコンテキストの変更テスト

stringState()stringCommitElems() が修正されましたが、テストがありません。

// TestLuaStringWithPrefix
// - Lua EVAL内でSETした値がprefixed keyに保存される
// - Lua EVAL内でlegacy bare keyを読める(fallback)

8. 並行アクセステスト

deleteLegacyKeys() は長時間走るバッチ処理で、並行書き込みとの競合が考えられます。

提案するテストケース:

// TestFlushLegacyConcurrentWrites
// - FLUSHLEGACY実行中に新しいキーへのSETが混入しても、新キーが削除されない

軽微な懸念点

  • deleteLegacyKeys() 内の time.Sleep(time.Millisecond) はテスト時に遅延を生じさせます。依存性注入やテスト用のモック化を検討してください。
  • FLUSHLEGACY後にFLUSHALLを実行する操作順序のテストシナリオも有用です。

まとめ

対象 テスト有無
readRedisStringAt() fallback ❌ なし
isKnownInternalKey() ❌ なし
flushlegacy コマンド ❌ なし
deleteLegacyKeys() ❌ なし
flushall スコープ変更 ❌ なし
legacy key migration 互換性 ❌ なし
txnContext の変更 ❌ なし
Lua context の変更 ❌ なし
既存テストの修正 ✅ 3件修正済み

新機能・変更ロジックのほとんどにテストが存在しません。特に readRedisStringAt() のfallback動作flushlegacy コマンドflushall のスコープ変更は本番環境でのデータ損失リスクがあるため、優先的にテストを追加することを推奨します。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

1. レガシーキーへのダブルリード【中】

対象: adapter/redis_compat_helpers.go readRedisStringAt

readRedisStringAt は GET・GETDEL・INCR・Lua コンテキストなどホットパス全体で呼ばれる。まず !redis|str|<key> を読み、ErrKeyNotFound の場合に裸の <key> を再読みする設計のため、FLUSHLEGACY 完了前はすべてのレガシー文字列 GET で 2 回のディスク I/O が発生する。移行完了後にフォールバックを削除できるよう、TODO コメントや段階的削除計画を明示しておくと将来の tech debt 化を防げる。


2. stageKeyDeletion が全 DEL で無条件にベアキーを working map に挿入【低】

対象: adapter/redis.go

bareK := string(key)
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{}   // 毎回アロケーション
}
t.working[bareK].deleted = true
t.working[bareK].dirty = true

ポスト移行環境ではレガシー文字列キーは存在しないが、すべての DEL トランザクションでベアキーの txnValue 確保とマップ書き込みが走る。移行完了後にこのブロックを除去することで不要な確保を防げる。それまでの間は ExistsAt による存在確認を挟んで不要な挿入を回避することも検討できる。


3. trackTypeReadKeys が 1 回の型チェックで 2 キー追加【低】

対象: adapter/redis.go

型チェックのたびに読み取りセットへ redisStrKey(key) と裸の key の 2 エントリが追加される。コンフリクト検出の精度向上のための意図的な措置だが、大量のキーを扱うトランザクションでは読み取りセットが肥大化する点は留意。


4. isKnownInternalKey のホットパス線形スキャン【低】

対象: adapter/redis_compat_types.go

7 要素のスライスを bytes.HasPrefix で線形スキャンしており、txnContext.load() 内の全キーロードで毎回呼ばれる。key[0] != '!' の早期リターンで大半をスキップしているため実害は限定的。ただし、すべてのプレフィックスが ! で始まるという構造を活かし、2 文字目以降の比較に切り替えるか switch key[1] 形式にすると分岐コストをさらに下げられる。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

PR #486: !redis|str| プレフィックス追加と FLUSHALL スコープ変更


🔴 高リスク: FLUSHALL がレガシーキーを削除しない

ファイル: adapter/redis_compat_commands.go

変更前の FLUSHALL は {Op: kv.DelPrefix, Key: nil} で全キーを削除していたが、変更後は以下の3プレフィックスのみを削除する:

{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},

問題: マイグレーション前に書き込まれたレガシー裸キー(例: foo)は FLUSHALL 後も残存する。
readRedisStringAt!redis|str|foo を試みて NotFound の場合、裸キー foo にフォールバックするため、FLUSHALL 後も GET が値を返し続ける

シナリオ:

  1. 旧コードで SET foo bar → 裸キー foo = bar が保存
  2. 新コードにデプロイ
  3. FLUSHALL 実行 → 裸 foo は削除されない
  4. GET foo"bar" が返る(フラッシュ済みのはずのデータ)

コメントには「FLUSHLEGACY を先に実行せよ」と記載されているが、運用上この前提条件が守られない場合にデータが予期せず生き残る。FLUSHALL の意味論的な保証が損なわれており、FLUSHLEGACY を未実行のまま FLUSHALL しても安全という誤解を招く可能性がある。

推奨: FLUSHALL 実行時に自動的に deleteLegacyKeys を呼び出すか、または FLUSHALL が不完全であることをエラーレスポンスまたはログで明示的に警告する。


🟡 中リスク: stageKeyDeletion での裸キー削除が存在確認なしに行われる

ファイル: adapter/redis.go

bareK := string(key)
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{}
}
t.working[bareK].deleted = true
t.working[bareK].dirty = true

裸キーが実際に存在するかどうかを確認せずに削除操作を t.working に登録している。buildKeyElems がこれを {Op: kv.Del, Key: bareKey} として Raft ログに書き込む。

存在しないキーへの Del は no-op であり即時のデータロストには繋がらないが、Raft ログを不必要に汚染する。防御的に readRedisStringAt での読み取り後、値が存在する場合のみ登録する設計を推奨する。


🟢 問題なし: その他の観点

  • readRedisStringAt のフォールバック読み取り (!redis|str|key → 裸キーの順): 正しい設計
  • deleteLogicalKeyElemsredisStrKey(key) と裸キーの両方を追加: 正しい
  • txnContext.load での TTL 追跡変更: 裸ユーザーキーに対して redisTTLKey(userKey) を追跡する動作は変更前と等価
  • deleteLegacyKeys のバッチ削除: MVCC スナップショット readTS を固定して使用しており、途中書き込みによるデータロストはない
  • Lua コンテキストでのキー書き込みを redisStrKey に変更: 正しい

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


1. [中リスク] FLUSHALL がレガシーベアキーを削除しない

該当箇所: adapter/redis_compat_commands.goflushDatabase 関数

変更後のコード:

{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},
// Legacy bare keys are NOT deleted here

問題: FLUSHALL を実行しても、旧フォーマット(プレフィックスなし)のベアキーは削除されない。 がレガシーフォールバックを持つため、FLUSHALL 後も GET でデータが返り続ける。ユーザーは「全削除された」と思っているが実際にはデータが残存する。

シナリオ:

  1. 旧バージョンのノードが をベアキーで書き込む
  2. を実行 → , , のみ削除
  3. → レガシーフォールバック経由で が返る
  4. データが「削除されたはず」なのに読める状態に

コメントに記載はあるが、ユーザーへの警告なし。FLUSHALL のセマンティクス変更として要注意。


2. [低リスク] が非トランザクションモードで削除を実行

該当箇所: adapter/redis_compat_commands.godeleteLegacyKeys 関数

if err := r.dispatchElems(ctx, false, readTS, elems); err != nil {

問題: isTxn=false で dispatch しているため、スキャン後〜削除間に旧ノードが同じベアキーに書き込んだ場合、OCC 競合検出なしにそのデータが削除される。ローリングアップグレード中に FLUSHLEGACY を実行すると、旧ノードが書き込んだ有効なデータが無言で失われる可能性がある。


3. 問題なし: でのレガシーキー削除

で bare key を read セットに追加しつつ削除対象としてマークしており、競合検出も正しく機能する。

4. 問題なし: でのレガシーキー削除

redisStrKey(key)key(ベアキー)の両方を削除対象に含めており、論理削除として正しい。


まとめ

# リスク 内容
1 FLUSHALL 後もレガシーデータが読み取れる (FLUSHALL の不完全なフラッシュ)
2 FLUSHLEGACY のローリングアップグレード中の競合なし削除

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Data Loss] レビュー結果

対象PR: #486 feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


🔴 問題1: FLUSHALL がレガシーベアキーを削除しない(中〜高リスク)

場所: adapter/redis_compat_commands.goflushDatabase()

// 変更前
{Op: kv.DelPrefix, Key: nil}  // 全データ削除

// 変更後
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},

!redis|str| 移行前に書き込まれたベアキー(例: foo)は FLUSHALL で削除されない。ユーザーが FLUSHALL 後にデータベースが空になると期待している場合、旧データが残存し予期しない動作を引き起こす可能性がある。

FLUSHLEGACY の事前実行が前提だが、強制する仕組みがなく、コマンドの存在をユーザーが知らなければ見落とす。

提案: FLUSHALL 実行時にレガシーキーが存在すれば警告を返す、またはドキュメント/RESP エラーメッセージで FLUSHLEGACY が必要な旨を明示する。


🟡 問題2: INCR/INCRBY がレガシーベアキーを削除しない(低〜中リスク)

場所: adapter/redis_compat_commands.goincr()

return r.dispatchElems(ctx, true, readTS, []*kv.Elem[kv.OP]{
    {Op: kv.Put, Key: redisStrKey(cmd.Args[1]), Value: ...},
    {Op: kv.Del, Key: redisTTLKey(cmd.Args[1])},
    // ← レガシーベアキー (cmd.Args[1]) の Del がない
})

INCR はレガシーベアキーから値を読み取り (readRedisStringAt のフォールバック)、新しい !redis|str| プレフィックスキーに書き込むが、元のベアキーを削除しない。

一方、SET は deleteLogicalKeyElems 経由でベアキーと !redis|str| キーの両方を削除する。この非対称性により、INCR を使ったキーはゴーストベアキーが残存し続ける(DEL または FLUSHLEGACY まで)。

SET・DEL 系コマンドとの挙動の不整合であり、統一が望ましい。


🟡 問題3: FLUSHLEGACY が TTL キーを削除しない(低リスク)

場所: adapter/redis_compat_commands.godeleteLegacyKeys()

// TTL keys are intentionally NOT deleted...

レガシーベアキーに TTL が設定されていた場合、FLUSHLEGACY はデータキー(ベア foo)を削除するが !redis|ttl|foo は残す。

通常、次回の saveString(SET など)が TTL キーをクリアするため問題ないケースが多い。ただし、FLUSHLEGACY 後に同名キーを TTL 付きで書き込み直すと古い TTL と競合する可能性がある(replaceWithStringTxn は TTL を上書きするため実害は低い)。


総評: FLUSHALL の挙動変更(問題1)が最も影響範囲が広く、既存クライアントが FLUSHALL で全データクリアを期待している場合に意図しないデータ残存を引き起こすリスクがある。レガシーキーの削除漏れ(問題2)も INCR 多用ケースで徐々にストレージが汚染される点で注意が必要。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Performance] レビュー結果

対象PR: #486 feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


⚠️ 1. readRedisStringAt でマイグレーション完了後も毎回2回ストアアクセス

場所: adapter/redis_compat_helpers.go

func (r *RedisServer) readRedisStringAt(key []byte, readTS uint64) ([]byte, error) {
    v, err := r.readValueAt(redisStrKey(key), readTS)
    if err == nil {
        return v, nil
    }
    if !errors.Is(err, store.ErrKeyNotFound) {
        return nil, err
    }
    return r.readValueAt(key, readTS) // ← 毎回このパスも試みる
}

GET / GETDEL / INCR / Lua string read など全ての文字列読み取りで、!redis|str|<key> が存在しない場合は bare key にも追加アクセスする。マイグレーション(FLUSHLEGACY)完了後も同じコードパスが走るため、NotFoundが返るだけの余分なストアアクセスが恒久的に残る。
改善案: レプリカ全体でマイグレーション完了を示すフラグ(例: ストア内の sentinel key)を導入し、完了後は fallback パスをスキップする。または、FLUSHLEGACY 完了時にサーバー内フラグを立てる。


⚠️ 2. rawKeyTypeAt でstring型の判定プローブが2回分に増加

場所: adapter/redis_compat_helpers.go

{typ: redisTypeString, key: redisStrKey(key)},
{typ: redisTypeString, key: key},  // legacy bare key fallback

TYPE コマンド(rawKeyTypeAt を呼ぶすべての操作)が、stringの存在確認に2回の ExistsAt を実行するようになった。マイグレーション完了後も両方のprobeが走り続ける。


✅ 良い点

  • flushallDelPrefix: nil(全件削除)から !redis|, !lst|, !zs| の3プレフィックスに絞ったことで、意図しないデータ削除を防ぎつつスコープが明確になった
  • deleteLegacyKeys のバッチ処理(batchSize=1000)と1ms sleepにより、Raftログの飽和を防いでいる
  • isKnownInternalKey の冒頭 key[0] != '!' アーリーリターンで、非プレフィックスキーの線形探索をスキップ

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #486: feat: add `!redis|str|` prefix to Redis string keys and scope FLUSHALL


🔴 高: FLUSHALL のセマンティクス変更による一貫性リグレッション

場所: adapter/redis_compat_commands.goflushDatabase

変更前:
```go
{Op: kv.DelPrefix, Key: nil} // 全キースペースを削除
```

変更後:
```go
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},
```

FLUSHALL 後も migration 前のレガシーベアキー がストアに残留します。FLUSHALL はクライアントに対し「全データ削除」を保証するコマンドです。この変更後、FLUSHALL を実行してもレガシーキーが残存するため、例えば以下のシーケンスで問題が発生します:

  1. SET foo bar(旧コードで書き込まれたベアキー foo
  2. FLUSHALL
  3. GET foonil を期待するが "bar" が返る可能性

PR のコメントに「FLUSHLEGACY を先に実行せよ」と書かれていますが、この前提条件はクライアントから不可視であり、FLUSHALL の線形化可能性(Linearizability)契約を破ります。

提案: FLUSHALL 内でも DelPrefix(nil) または DelPrefix("") を使うか、FLUSHLEGACY が完了済みかどうかをチェックして未完了の場合エラーを返す仕組みが必要です。


🟡 中: FLUSHLEGACY の非トランザクショナル dispatch によるデータ消失リスク

場所: adapter/redis_compat_commands.godeleteLegacyKeys

```go
if err := r.dispatchElems(ctx, false, readTS, elems); err != nil {
```

isTxn=false のため 競合検出が無効です。固定された readTS = r.readTS() でスキャンした後、バッチ dispatch 前に別クライアントが同じレガシーベアキーに新たな書き込みを行った場合、その書き込みが 無条件で削除 されます。

具体的なシナリオ:

  1. FLUSHLEGACY が readTS=T1 でキー foo をスキャン(値: "bar")
  2. バッチ1の dispatch 前に別クライアントが SET foo "new" を実行(timestamp T2 > T1)
  3. FLUSHLEGACY が Del foo を dispatch → T2 の新しい書き込みがトゥームストーンで隠蔽
  4. GET foonil (データ消失)

また、readTS はオペレーション開始時に1回だけ取得されますが、複数バッチにまたがる処理(各バッチ間に time.Sleep(1ms))の間も同一 readTS が使われます。


🟡 中: txnContext.load() における TTL tracking の変更

場所: adapter/redis.gotxnContext.load

旧コード:
```go
} else if !bytes.HasPrefix(key, []byte(redisTTLPrefix)) {
t.trackReadKey(redisTTLKey(key))
}
```

このブランチが新コードから削除されました。既知の internal key でも extractRedisInternalUserKey が nil を返すケース(例: 将来追加される新プレフィックス)では、TTL キーが read-set から漏れる可能性があります。現状の既知プレフィックスでは問題ありませんが、防御的な観点から注意が必要です。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

問題1 [HIGH] FLUSHALL/FLUSHDB が legacy bare key を削除せず、線形化可能性が破れる

ファイル: adapter/redis_compat_commands.go

変更前は DelPrefix: nil で全キーをアトミックに削除していたが、変更後は以下の3プレフィックスのみ対象となった。

// After
{Op: kv.DelPrefix, Key: []byte("!redis|")},
{Op: kv.DelPrefix, Key: []byte("!lst|")},
{Op: kv.DelPrefix, Key: []byte("!zs|")},

移行前に書き込まれた legacy bare key(プレフィックスなしキー)はこの操作に含まれないため、FLUSHALL/FLUSHDB 完了後も GET で値が返る状態が発生する。

線形化可能性の問題:

  • 「FLUSHALL 完了 → それ以降の読み取りはすべて空を返す」という契約が legacy bare key に対して成立しない
  • 推奨されている 2ステップ操作(FLUSHLEGACY → FLUSHALL)は非アトミックであり、ステップ間の障害でシステムが部分フラッシュ状態に留まる可能性がある

影響範囲: FLUSHDB も同じ flushDatabase を経由するため同様に影響を受ける。


問題2 [LOW] stageKeyDeletion が bare key を存在確認なしに常に Raft 経由で削除マーク

ファイル: adapter/redis.go

t.trackReadKey(key)
bareK := string(key)
if _, ok := t.working[bareK]; !ok {
    t.working[bareK] = &txnValue{}
}
t.working[bareK].deleted = true
t.working[bareK].dirty = true
  • dirty=true によって buildKeyElems() が bare key に対して kv.Del を生成し、Raft ログに余分なエントリが追加される(legacy key が存在しない場合でも)
  • trackReadKey(key) により bare key が読み取りセットに追加され、本来不要な書き込み競合が発生しうる

ExistsAt で存在確認してから dirty=true にするか、buildKeyElems 側で bare key を kv.Del する前にガードを設けることを検討。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Concurrency] レビュー結果

対象: PR #486 feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


問題 1 [中] FLUSHLEGACY の同時実行ガードがない

flushlegacy / deleteLegacyKeys には、同一リーダーで複数クライアントが同時に FLUSHLEGACY を発行した際の保護機構がない。

// flushlegacy (redis_compat_commands.go)
ctx, cancel := context.WithTimeout(context.Background(), redisFlushLegacyTimeout)
defer cancel()
totalDeleted, err := r.deleteLegacyKeys(ctx, r.readTS())

2つのゴルーチンが同じ readTSdeleteLegacyKeys を並走させると、同一キーへの kv.Del が重複して Raft ログに書き込まれる。Del は冪等なので 安全性の問題はない が、最大 batchSize=1000 × バッチ数 × 2 の Raft エントリが発生しうる。
atomic フラグや sync.Mutex によるワンショット保護を検討すること。


問題 2 [中] flushlegacyVerifyLeader() を呼ばない

flushDatabase はディスパッチ直前に r.coordinator.VerifyLeader() でリーダーであることを確認している:

// flushDatabase (既存)
if err := r.coordinator.VerifyLeader(); err != nil {
    return fmt.Errorf("verify leader: %w", err)
}

一方、deleteLegacyKeys はこのチェックなしで最大10分間バッチ処理を続ける。リーダー交代が発生すると、coordinator.Dispatch が内部で新リーダーへリダイレクトするため ディスパッチ自体は成功する が、スキャン (r.store.ScanAt) は旧ノードのローカルストアを引き続き読む。スキャンとディスパッチが別ノードで行われるこの非対称な状態は意図したものかどうかを明確にすべき。少なくともコメントで意図を説明することを推奨する。


問題 3 [低] proxyFlushLegacy がクライアントコンテキストを引き継がない

ctx, cancel := context.WithTimeout(context.Background(), redisFlushLegacyTimeout+30*time.Second)

context.Background() を使っているため、Redis クライアントが切断しても最大 10分30秒 プロキシが動き続ける。既存の proxyFlushDatabase と同パターンだが、FLUSHLEGACY は特に長時間であることを考慮し、クライアント接続のコンテキストを伝播させることが望ましい。


その他

  • deleteLegacyKeys のカーソル進め方 (make([]byte, len(lastKey)+1)) はバイナリキーに null バイトが含まれる場合に lastKey\x00 というキーをスキップしうるが、ユーザーキーとして null バイトが使われることは稀であり実害は小さい。
  • IsLeader() チェックとその後の処理の間に生じる TOCTOU はエラーとして安全に伝播されており問題ない。
  • isTxn=false かつ startTS=readTSdispatchElems を呼ぶが、dispatchRawstartTS を無視して c.clock.Next() を使うため、スキャン TS が書き込み TS に影響しないことを確認済み。

@claude
Copy link
Copy Markdown

claude bot commented Apr 12, 2026

[Consistency] レビュー結果

PR #486: feat: add !redis|str| prefix to Redis string keys and scope FLUSHALL


⚠️ 問題1: FLUSHALL がレガシーベアキーを削除しないため Linearizability が崩れる

場所: adapter/redis_compat_commands.goflushDatabase()

変更後の FLUSHALL は !redis|!lst|!zs| プレフィックスのキーのみを DelPrefix で削除します。しかし readRedisStringAt はプレフィックスキーが存在しない場合にレガシーのベアキーへ fallback するため、FLUSHALL 実行後も GET/MGET でレガシーデータが参照可能な状態になります。

FLUSHALL        → OK
GET "foo"       → "bar"  ← レガシーベアキーが残っているため依然返る
KEYS *          → (empty) ← プレフィックス空間しかスキャンしない

FLUSHLEGACY を事前に実行するという前提はアプリケーションレベルで強制されず、FLUSHALL に「キーが見えないが取得できる」という不整合な状態が残ります。

提案: flushDatabase 内でも legacy bare keyspace のスキャン削除を行うか、FLUSHALL 実行時に legacy キーが存在した場合はエラーを返して FLUSHLEGACY 未実施を明示的に通知するか、少なくともドキュメント・コメントで FLUSHLEGACY の事前実行を必須要件として記載してください。


⚠️ 問題2: stageKeyDeletion でレガシーキーの存在確認なしに Raft ログへ Del エントリを生成

場所: adapter/redis.gostageKeyDeletion()

// 追加されたコード
t.working[bareK] = &txnValue{}
t.working[bareK].deleted = true
t.working[bareK].dirty = true

レガシーベアキーが実際に存在するか確認せずに dirty=true でエントリを作成しているため、buildKeyElems がすべての削除トランザクションでベアキーに対する kv.Del を Raft ログへ出力します。データ整合性に直接の影響はありませんが、マイグレーション完了後も各削除操作で不要な Raft エントリが生成し続けます。


✅ 問題なし: Raft を経由しないローカル書き込みなし

すべての書き込みは dispatchElemscoordinator.Dispatch を経由して Raft を通過しています。

✅ 問題なし: TTL とデータ本体の整合性

replaceWithStringTxnsaveString ともに redisStrKey(key)redisTTLKey(key) を同一 OperationGroup でアトミックに操作しており、TTL とデータの整合性は維持されています。

✅ 問題なし: Read-set トラッキング

trackTypeReadKeysredisStrKey(key) とレガシー key の両方を read set に追加しており、どちらへの並行書き込みも競合として検出されます。

✅ 問題なし: txnContext.load() の TTL トラッキング

ベアユーザーキー・既知内部キーいずれのパスでも redisTTLKey が read set に追加されています。

@bootjp bootjp merged commit 06ad365 into main Apr 12, 2026
12 checks passed
@bootjp bootjp deleted the feat/redis-str-prefix branch April 12, 2026 13:55
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