Skip to content

adapter: Retry forwarded transaction conflicts - #1121

Merged
bootjp merged 4 commits into
mainfrom
feature/retry-wire-txn-errors
Jul 19, 2026
Merged

adapter: Retry forwarded transaction conflicts#1121
bootjp merged 4 commits into
mainfrom
feature/retry-wire-txn-errors

Conversation

@bootjp

@bootjp bootjp commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

  • restore transaction-error typing after internal gRPC leader forwarding
  • normalize internal storage keys before returning Redis errors
  • retry forwarded write conflicts only through exact write-set reuse for XADD, MULTI/EXEC, and list push
  • keep raw forwarded write conflicts fail-closed for callers without dedup state
  • add regression coverage for landed, non-landed, and genuine-conflict retry outcomes

Root cause

Internal leader forwarding converts typed transaction errors into gRPC statuses. The Redis retry layer originally recognized only the typed error chain, so a forwarded conflict could escape instead of being handled.

Blindly making every forwarded write conflict retryable is unsafe: if the leader applied an operation but its response was lost, a later forwarded self-conflict is ambiguous. Recomputing XADD with * can then append a second entry, while EXEC and list-push reuse paths can retain stale pending state unless the wire error is normalized before their typed conflict handling.

Behavior

  • Wire TxnLocked errors remain retryable because the lock rejection did not apply the transaction.
  • Generic wire WriteConflict errors are normalized and returned without replay.
  • Dedup-enabled XADD captures the generated ID and exact write set, then retries with PrevCommitTS.
  • EXEC and list-push normalize wire conflicts before reuse conflict probing and stale-state invalidation.
  • The dedup-disabled rollback path fails closed after one ambiguous wire conflict.

Risk

Wire parsing remains restricted to Unknown or Aborted statuses with the exact server-generated keyed envelope. Retry promotion is limited to operations that retain an exact write set and use the one-phase exact-timestamp probe.

Validation

  • go test -race ./adapter -run '^(TestRedisXAddRetriesWireWriteConflict|TestRedisXAddDedupsLandedWireWriteConflict|TestRedisXAddDedupDisabledDoesNotReplayLandedWireConflict|TestRetryRedisWriteRetriesWireTxnLocked|TestRetryRedisWriteDoesNotRetryUnclassifiedWireErrors|TestRetryRedisWriteDoesNotReplayOrLeakWireWriteConflict|TestNormalizeRetryableRedisTxnErrWireErrors|TestListPushDedup_GenuineConflictRecomputes|TestExecDedup_GenuineConflictRebuildsAndApplies)$' -count=10
  • go test ./adapter
  • go test ./...
  • golangci-lint run ./... --timeout=5m

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

gRPC経由で型情報が失われたRedisトランザクションエラーを解析し、書き込み競合・ロック中エラーとして復元する処理を追加しました。復元結果をリトライ判定、ポリシー選択、エラー正規化へ反映し、関連する再試行とキー秘匿のテストを追加しています。

Changes

Redis wireエラーリトライ対応

Layer / File(s) Summary
wireエラー復元とリトライ判定
adapter/redis_retry.go
gRPCステータスとkey: 形式のメッセージを解析し、論理キーから書き込み競合またはトランザクションロックの型付きエラーを再構成します。復元した種別をリトライ判定、バックオフ、最大試行回数、エラー正規化に反映します。
wireエラーリトライの検証
adapter/redis_retry_test.go
wireエラーによる再試行、未分類エラーの非再試行、再試行上限、ストリームメタデータ、正規化されたキー・詳細情報と内部キー非漏洩を検証します。

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Redis as Redis操作
  participant Adapter as Redisリトライ処理
  participant Parser as wireエラー解析
  Redis->>Adapter: gRPCステータス付きトランザクションエラー
  Adapter->>Parser: エラー種別と論理キーを解析
  Parser-->>Adapter: 型付きエラーを返却
  Adapter->>Adapter: リトライポリシーを選択
  Adapter->>Redis: 再試行
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 転送されたトランザクション競合のリトライ復元という変更内容を簡潔に表しており、主旨と一致しています。
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

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.

Code Review

This pull request introduces a mechanism to classify and retry transaction errors (such as write conflicts and transaction locks) that cross gRPC boundaries as untyped gRPC status errors. It adds wireRedisTxnErrKind to parse these errors and includes comprehensive unit tests. The review feedback highlights a potential security issue where internal keys could be leaked to clients if the retry limit is exceeded, and suggests parsing and normalizing these wire errors before returning them.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread adapter/redis_retry.go Outdated
Comment on lines +72 to +96
func wireRedisTxnErrKind(err error) redisTxnWireErrKind {
type grpcStatusCarrier interface {
GRPCStatus() *status.Status
}
var carrier grpcStatusCarrier
if !errors.As(err, &carrier) {
return redisTxnWireErrNone
}
st := carrier.GRPCStatus()
if st == nil || (st.Code() != codes.Unknown && st.Code() != codes.Aborted) {
return redisTxnWireErrNone
}
msg := st.Message()
if !strings.HasPrefix(msg, "key: ") {
return redisTxnWireErrNone
}
switch {
case strings.HasSuffix(msg, ": "+store.ErrWriteConflict.Error()):
return redisTxnWireErrWriteConflict
case strings.HasSuffix(msg, ": "+kv.ErrTxnLocked.Error()):
return redisTxnWireErrLocked
default:
return redisTxnWireErrNone
}
}

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

Issue: Leaking Internal Keys to Clients on Retry Limit Exceeded

When a forwarded transaction conflict occurs, the error returned over gRPC is a codes.Unknown or codes.Aborted status containing the internal key (e.g., !txn|cmt|... or !redis|...).

Currently, normalizeRetryableRedisTxnErr only matches typed TxnLockedError and WriteConflictError using kv.TxnLockedDetails and store.WriteConflictKey. It does not match or normalize these gRPC status errors. As a result, if the retry limit is exceeded, the raw gRPC status error containing the internal key is returned directly to the client, leaking internal storage layout details.

Solution

We can implement a parser parseWireRedisTxnErr that extracts the key and detail from the gRPC status message, normalizes the key, and returns a typed, normalized error.

To fully resolve this, please also update normalizeRetryableRedisTxnErr (located around line 153) to call this parser at the beginning:

func normalizeRetryableRedisTxnErr(err error) error {
	if parsed := parseWireRedisTxnErr(err); parsed != nil {
		return parsed
	}
	// ... existing typed checks ...
}
func parseWireRedisTxnErr(err error) error {
	type grpcStatusCarrier interface {
		GRPCStatus() *status.Status
	}
	var carrier grpcStatusCarrier
	if !errors.As(err, &carrier) {
		return nil
	}
	st := carrier.GRPCStatus()
	if st == nil || (st.Code() != codes.Unknown && st.Code() != codes.Aborted) {
		return nil
	}
	msg := st.Message()
	if !strings.HasPrefix(msg, "key: ") {
		return nil
	}
	if strings.HasSuffix(msg, ": "+store.ErrWriteConflict.Error()) {
		keyStr := msg[len("key: ") : len(msg)-len(": "+store.ErrWriteConflict.Error())]
		logicalKey := normalizeRetryableRedisTxnKey([]byte(keyStr))
		return errors.WithStack(store.NewWriteConflictError(logicalKey))
	}
	if strings.HasSuffix(msg, ": "+kv.ErrTxnLocked.Error()) {
		content := msg[len("key: ") : len(msg)-len(": "+kv.ErrTxnLocked.Error())]
		var keyStr string
		var detail string
		if strings.HasSuffix(content, ")") {
			if idx := strings.LastIndex(content, " ("); idx != -1 {
				keyStr = content[:idx]
				detail = content[idx+2 : len(content)-1]
			} else {
				keyStr = content
			}
		} else {
			keyStr = content
		}
		logicalKey := normalizeRetryableRedisTxnKey([]byte(keyStr))
		if detail != "" {
			return errors.WithStack(kv.NewTxnLockedErrorWithDetail(logicalKey, detail))
		}
		return errors.WithStack(kv.NewTxnLockedError(logicalKey))
	}
	return nil
}

func wireRedisTxnErrKind(err error) redisTxnWireErrKind {
	parsed := parseWireRedisTxnErr(err)
	if parsed == nil {
		return redisTxnWireErrNone
	}
	if errors.Is(parsed, store.ErrWriteConflict) {
		return redisTxnWireErrWriteConflict
	}
	if errors.Is(parsed, kv.ErrTxnLocked) {
		return redisTxnWireErrLocked
	}
	return redisTxnWireErrNone
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 962ea1f. Forwarded transaction statuses are now parsed into normalized typed errors before retry policy selection or terminal return, so retry-limit errors expose only the logical Redis key. I also audited every retryRedisWrite caller; they propagate the returned error and none depends on the original gRPC status or internal key text. Added direct retry-limit and TxnLocked-detail regression coverage; targeted race tests pass 10 times and adapter lint reports 0 issues.

@bootjp
bootjp marked this pull request as ready for review July 19, 2026 06:03
@bootjp

bootjp commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Please review the latest HEAD 962ea1f. The current revision normalizes forwarded transaction errors before terminal return and includes retry-limit leak regression coverage. Targeted race tests passed 10 times and adapter lint reports 0 issues.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
adapter/redis_retry_test.go (1)

370-389: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

サブテストの名前付け(t.Run)がない点が他のテーブル駆動テストと不整合。

同ファイルの他のテーブル駆動テスト(Line 327-368、410-455)は t.Run(tt.name, ...) でケースを分離しているが、ここは for _, wireErr := range tests のみでケース名がなく、失敗時にどのケースが落ちたか特定しにくい。As per coding guidelines, **/*_test.go において「Unit tests must be co-located with packages in *_test.go files; prefer table-driven test cases」とされているため、他ケースと同様に名前付きサブテストにすると一貫性が高まる。

♻️ 提案
-	tests := []error{
-		status.Error(codes.Unknown, "write conflict"),
-		status.Error(codes.Unknown, "key: user-visible failure"),
-		status.Error(codes.Internal, "key: k: write conflict"),
-	}
-	for _, wireErr := range tests {
-		attempts := 0
-		srv := &RedisServer{}
-		err := srv.retryRedisWrite(context.Background(), func() error {
-			attempts++
-			return wireErr
-		})
-
-		require.ErrorIs(t, err, wireErr)
-		require.Equal(t, 1, attempts)
-	}
+	tests := []struct {
+		name    string
+		wireErr error
+	}{
+		{"plain unknown", status.Error(codes.Unknown, "write conflict")},
+		{"unknown non-matching envelope", status.Error(codes.Unknown, "key: user-visible failure")},
+		{"internal code with matching message", status.Error(codes.Internal, "key: k: write conflict")},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			attempts := 0
+			srv := &RedisServer{}
+			err := srv.retryRedisWrite(context.Background(), func() error {
+				attempts++
+				return tt.wireErr
+			})
+
+			require.ErrorIs(t, err, tt.wireErr)
+			require.Equal(t, 1, attempts)
+		})
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapter/redis_retry_test.go` around lines 370 - 389, Update
TestRetryRedisWriteDoesNotRetryUnclassifiedWireErrors to use named table-driven
cases and execute each with t.Run. Add descriptive names for each wire error
scenario, then move the per-case setup, retry invocation, and assertions into
the subtest so failures identify the specific case.

Source: Coding guidelines

adapter/redis_retry.go (1)

59-133: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

wire封筒の復元ロジックは妥当。

wireRedisTxnStatus/parseWireRedisTxnErr/wireRedisTxnErrKindkv/txn_errors.gostore/store.go のエラーフォーマット(key: %s: %v / key: %s (%s): %v)と正確に一致しており、コードも Unknown/Aborted に限定した厳密一致方式になっている。設計として妥当。

1点、Line 107-114 の detail 分離ロジックは、論理キー自体が " (" を含んだり ")" で終わったりするケースを想定していない(稀な入力では detail 抽出を誤り、キーに丸括弧が残る可能性がある)。実害は小さく、クラッシュもしない安全なフォールバックだが、コメントで明記しておくと将来のメンテで気づきやすい。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapter/redis_retry.go` around lines 59 - 133, Add a concise comment near the
detail-splitting logic in parseWireRedisTxnErr documenting that keys containing
" (" or ending with ")" can be ambiguously parsed, leaving parentheses in the
key as a safe fallback. Preserve the current parsing behavior and error
reconstruction.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@adapter/redis_retry_test.go`:
- Around line 370-389: Update
TestRetryRedisWriteDoesNotRetryUnclassifiedWireErrors to use named table-driven
cases and execute each with t.Run. Add descriptive names for each wire error
scenario, then move the per-case setup, retry invocation, and assertions into
the subtest so failures identify the specific case.

In `@adapter/redis_retry.go`:
- Around line 59-133: Add a concise comment near the detail-splitting logic in
parseWireRedisTxnErr documenting that keys containing " (" or ending with ")"
can be ambiguously parsed, leaving parentheses in the key as a safe fallback.
Preserve the current parsing behavior and error reconstruction.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8cefc1db-7613-4785-a2a0-01d1cc6baff0

📥 Commits

Reviewing files that changed from the base of the PR and between 5d81bfa and 962ea1f.

📒 Files selected for processing (2)
  • adapter/redis_retry.go
  • adapter/redis_retry_test.go

@bootjp

bootjp commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Addressed the latest CodeRabbit nitpicks in b5b15b1: the negative wire-error table now uses named subtests, and the unescaped TxnLocked wire key/detail boundary is documented without changing behavior. Targeted race tests passed 10 times and adapter lint reports 0 issues.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b5b15b16f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread adapter/redis_retry.go Outdated
return errors.Is(err, store.ErrWriteConflict) || errors.Is(err, kv.ErrTxnLocked)
return errors.Is(err, store.ErrWriteConflict) ||
errors.Is(err, kv.ErrTxnLocked) ||
wireRedisTxnErrKind(err) != redisTxnWireErrNone

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize wire conflicts before preserving retry state

When a forwarded write conflict reaches the Redis transaction/list dedup paths, this new boolean classification lets firstExecAttempt/list-push save and retry the old pending write set, but the reuse handlers still test errors.Is(dispErr, store.ErrWriteConflict) before dropping stale state (dispatchExecReuse and dispatchListPushReuse). The wire status does not satisfy that typed check, so a genuine conflict on a follower-forwarded reuse attempt is treated as “ambiguous”, advances pending.commitTS, and keeps retrying the stale snapshot until the retry limit instead of recomputing from a fresh read.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 7c4e8a6. The EXEC and list-push first/reuse dispatch paths now normalize forwarded transaction errors before retry gating and before their typed WriteConflict branches. A wire conflict on reuse therefore runs the existing self-commit probe; a probe miss drops the stale pending write set and rebuilds from a fresh snapshot. The genuine-conflict EXEC/list regression tests now inject keyed gRPC wire statuses and still require the exact three-dispatch rebuild topology. I also audited every retryRedisWrite caller: only reuse-aware EXEC, list push, and XADD explicitly promote wire write conflicts to retryable typed errors; all other callers fail closed after key normalization.

Comment thread adapter/redis_retry.go

if content, ok := strings.CutSuffix(strings.TrimPrefix(msg, "key: "), ": "+store.ErrWriteConflict.Error()); ok {
logicalKey := normalizeRetryableRedisTxnKey([]byte(content))
return errors.WithStack(store.NewWriteConflictError(logicalKey))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid replaying XADD after ambiguous forwarded commits

When a follower-forwarded XADD hits the ambiguous path where kv.LeaderProxy.runForwardCycle loses a response after the leader applied the request and a later internal retry reports the self-conflict as a gRPC status, this reconstruction turns that final wire write conflict into a normal retryable ErrWriteConflict. retryRedisWrite then reruns xaddTxn, which resolves * from the advanced stream meta and appends a second entry instead of surfacing the uncertain first attempt; the list/EXEC paths have reusable pending state for this class, but XADD has no equivalent dedup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in 7c4e8a6. Raw wire write conflicts are no longer replayed by the generic retry loop. With one-phase dedup enabled, XADD now captures its generated ID, exact write set, commitTS, and coordinator primary probe key; retries reuse that write set with PrevCommitTS, and a fresh self-conflict is probed before any recompute. The landed-then-wire-error regression returns the original ID after an exact-ts no-op and leaves stream length at one. The rollback path without dedup returns a normalized error after one dispatch instead of replaying. Targeted race tests passed 10 times, go test ./... passed, and repository lint reports 0 issues.

bootjp commented Jul 19, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Please review the latest HEAD 7c4e8a6. The two P1 findings are addressed: forwarded write conflicts are fail-closed in the generic retry loop, while EXEC/list push normalize them inside exact write-set reuse, and XADD now retries through fixed-ID write-set reuse with PrevCommitTS and a self-conflict probe. All retryRedisWrite callers were audited.

Validation: targeted race regressions passed 10 times, go test ./adapter passed, go test ./... passed, and repository lint reports 0 issues.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 7c4e8a660f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@bootjp
bootjp merged commit 37277ea into main Jul 19, 2026
13 checks passed
@bootjp
bootjp deleted the feature/retry-wire-txn-errors branch July 19, 2026 07:42
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