Skip to content

feat(txn-dedup): M3 EXEC reuse — option-2 dedup for MULTI/EXEC#884

Merged
bootjp merged 1 commit into
docs/txn-idempotency-designfrom
feat/m3-exec-reuse
May 30, 2026
Merged

feat(txn-dedup): M3 EXEC reuse — option-2 dedup for MULTI/EXEC#884
bootjp merged 1 commit into
docs/txn-idempotency-designfrom
feat/m3-exec-reuse

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented May 30, 2026

Summary

Follow-up to #796 (M1 + M2 + M3 RPUSH/LPUSH). Extends option-2 write-set reuse + exact-ts dedup probe to MULTI/EXEC dispatched through runTransaction. Mirrors listPushCoreWithDedup at the EXEC granularity. Gated on the existing onePhaseTxnDedup flag — default off — so no mixed-version divergence window.

This PR is stacked on #796: the base branch is docs/txn-idempotency-design. Once #796 merges this rebases onto main.

Mechanism

  1. txnContext.commit() refactor — split build-and-dispatch into prepareDispatch() returning a preparedTxnDispatch{elems, commitTS, readKeys, ctx, cancel}. commit() now calls prepareDispatch() then dispatches; runTransactionWithDedup calls them separately so it can intercept between prepare and dispatch. External behavior of commit() is unchanged.
  2. reusableExecTxn — EXEC analogue of reusableListPush. The cached results array is the M3 R1 result reconstruction: computed once from attempt 1's startTS snapshot, returned as-is on any reuse. Same invariance argument as RPUSH/LPUSH length.
  3. dispatchExecReuse — one reuse iteration. Dispatches with PrevCommitTS=pending.commitTS. On WriteConflict, the self-inflicted-conflict guard probes CommittedVersionAt(probeKey, freshCommitTS); if hit, return cached results (codex P1 round-10 class). Otherwise drop pending.
  4. runTransactionWithDedup + firstExecAttempt — the option-2 retry loop. First iteration builds the txn and dispatches; retryable failure stashes pending; subsequent iterations call dispatchExecReuse with PrevCommitTS.
  5. runTransaction gate — when onePhaseTxnDedup is on, route to runTransactionWithDedup; otherwise keep the legacy retry loop byte-identical.

Caller audit (per /loop semantic-change rule)

  • prepareDispatch (new): callers are commit() and firstExecAttempt; both honor the defer prepared.cancel() contract.
  • commit(): internal structure changed; external behavior preserved (no test directly invokes it).
  • runTransactionWithDedup / firstExecAttempt / dispatchExecReuse / reusableExecTxn: all new symbols, exercised only from the gated runTransaction path.

Tests (adapter/redis_exec_dedup_test.go)

Test Scenario Pins
TestExecDedup_LandedPriorAttempt_ReturnsCachedResults Attempt 1 lands then errors Reuse FSM probe hits; cached "OK" returned; value matches
TestExecDedup_PriorAttemptDidNotLand_Applies Attempt 1 pre-rejects Reuse applies fresh; results match
TestExecDedup_GenuineConflictRebuildsAndApplies Concurrent SET advances key past pending.startTS Reuse OCC-conflicts; probe misses; pending dropped; fresh attempt succeeds
TestExecDedup_SelfInflictedReuseConflict_ReturnsSuccess Reuse lands then surfaces WriteConflict Self-conflict guard probes fresh commitTS; cached results returned (no double-apply)
TestExecDedup_DisabledKeepsLegacyPath Gate off No probe; same result as legacy runTransaction

Validation

  • go test ./adapter/ -run 'Txn|MULTI|EXEC|Dedup|TxnStartTS' passes
  • go test ./kv/ ./store/ both pass
  • gofmt, go vet, golangci-lint run all clean (0 issues across adapter/kv/store)

Scope

  • Single-mop EXEC is the conservative scope per the design doc's "Open questions". The mechanism (cache results array; OCC fence on readKeys) works the same for multi-mop EXEC under the existing proof, but the test matrix doubles, so multi-mop validation is a follow-up.
  • Per-command reconstruction hooks for SET/INCR/HSET etc. when dispatched outside MULTI are still a follow-up; those go through individual command paths, not runTransaction.

Design doc reference

docs/design/2026_05_21_proposed_txn_secondary_idempotency.md §M3.

Follow-up to PR #796 (M1 + M2 + M3-RPUSH/LPUSH). Extends option-2
write-set reuse + exact-ts dedup probe to MULTI/EXEC dispatched
through runTransaction. Mirrors listPushCoreWithDedup at the EXEC
granularity. Gated on the existing onePhaseTxnDedup flag — default
off — so no mixed-version divergence window.

Mechanism
=========

1. txnContext.commit() refactor: split the build-and-dispatch shape
   into prepareDispatch() returning a preparedTxnDispatch carrying
   (elems, commitTS, readKeys, ctx, cancel). commit() now calls
   prepareDispatch() and dispatches; runTransactionWithDedup calls
   them separately so it can intercept between prepare and dispatch.
   External behavior of commit() is unchanged (same defer-cancel
   discipline, same error mapping); the refactor is purely to expose
   the dispatch-able state to the dedup loop without code dup.

2. reusableExecTxn: EXEC analogue of reusableListPush. Captures
   (elems, startTS, commitTS, readKeys, results). The cached results
   array is the M3 R1 result reconstruction: computed once from
   attempt 1's startTS snapshot, returned as-is on any reuse. Same
   invariance argument as RPUSH/LPUSH length — the write set is fixed,
   so apply-vs-no-op is invisible to the client. Reads in the EXEC
   body returned values from attempt 1's snapshot; those values are
   what the client would have observed had attempt 1 not returned an
   ambiguous error, so caching them is the correct semantics.

3. dispatchExecReuse: one reuse iteration. Dispatches with
   PrevCommitTS=pending.commitTS; on success returns cached results.
   On WriteConflict, the self-inflicted-conflict guard probes
   CommittedVersionAt(probeKey, freshCommitTS) — if hit, the apply
   landed despite the conflict surface (codex P1 round-10 class),
   return cached results. Otherwise drop pending so the retry
   rebuilds from a fresh snapshot.

4. runTransactionWithDedup: the option-2 retry loop. First iteration
   calls firstExecAttempt to build the txn, capture results,
   dispatch. Retryable failure stashes pending; subsequent iterations
   call dispatchExecReuse with PrevCommitTS. Drop-on-conflict falls
   back to a fresh first attempt.

5. runTransaction gate: when onePhaseTxnDedup is on, route to
   runTransactionWithDedup; otherwise keep the legacy retry loop
   byte-identical — pinned by TestExecDedup_DisabledKeepsLegacyPath.

Caller audit (per /loop semantic-change rule)
==============================================

- prepareDispatch (new): callers are commit() and firstExecAttempt;
  both honor the defer-cancel contract.
- commit(): internal structure changed; external behavior preserved
  (no test directly invokes it; legacy runTransaction continues to
  call it through the same path).
- runTransactionWithDedup / firstExecAttempt / dispatchExecReuse /
  reusableExecTxn: all new symbols, exercised only from the gated
  runTransaction path.
- prepareDispatchRetry in kv/coordinator.go: unrelated existing
  helper with a similar name, different package — no collision.

Tests (adapter/redis_exec_dedup_test.go)
=========================================

1. TestExecDedup_LandedPriorAttempt_ReturnsCachedResults — attempt 1
   lands then errors; reuse FSM probe hits; cached "OK" returned;
   value matches.
2. TestExecDedup_PriorAttemptDidNotLand_Applies — attempt 1
   pre-rejects; reuse applies fresh; results match.
3. TestExecDedup_GenuineConflictRebuildsAndApplies — concurrent SET
   advances key past pending.startTS; reuse OCC-conflicts;
   self-conflict probe misses; pending drops; fresh attempt
   succeeds.
4. TestExecDedup_SelfInflictedReuseConflict_ReturnsSuccess — reuse
   lands then surfaces WriteConflict; self-conflict guard probes
   fresh commitTS; cached results returned (no double-apply).
5. TestExecDedup_DisabledKeepsLegacyPath — gate off; no probe; same
   result as legacy runTransaction.

Validation
==========

- go test ./adapter/ -run 'Txn|MULTI|EXEC|Dedup|TxnStartTS' passes.
- go test ./kv/ ./store/ both pass.
- gofmt, go vet, golangci-lint run all clean (0 issues across
  adapter/kv/store).

Scope of this PR
================

- Single-mop EXEC is the conservative scope per the design doc's
  "Open questions". The mechanism (cache results array; OCC fence on
  readKeys) works the same for multi-mop EXEC under the existing
  proof, but the test matrix doubles, so multi-mop validation is a
  follow-up.
- Per-command reconstruction hooks for SET/INCR/HSET etc. when
  dispatched OUTSIDE MULTI are still a follow-up; those go through
  individual command paths, not runTransaction.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2572988-2943-4f72-a41f-ee4e896e9023

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/m3-exec-reuse

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 and usage tips.

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 deduplication and retry mechanism for MULTI/EXEC transactions in the Redis adapter. It refactors the transaction commit logic by splitting out a 'prepareDispatch' step and introduces a new 'runTransactionWithDedup' path (gated by 'onePhaseTxnDedup') that allows reusing a transaction's write set under a fresh commit timestamp with 'prev_commit_ts' set. This prevents double-application of commands and returns cached results when a prior attempt has already landed. Comprehensive unit tests are added in 'adapter/redis_exec_dedup_test.go' to verify various success, failure, and conflict scenarios. There are no review comments, so no additional feedback is provided.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 30, 2026

@claude review

Initial request — PR is stacked on #796 (base branch docs/txn-idempotency-design). M3 EXEC reuse extends option-2 dedup to MULTI/EXEC mirroring listPushCoreWithDedup.

Status:

  • mergeStateStatus: CLEAN
  • gemini-code-assist: no findings
  • codex-connector: quota exhausted (no review)
  • coderabbitai: skipped (non-default base)
  • go test ./adapter/ -run 'Txn|MULTI|EXEC|Dedup|TxnStartTS' passes
  • go test ./kv/ ./store/ both pass
  • gofmt + go vet + golangci-lint clean (0 issues)

Please verify:

  1. The commit()prepareDispatch() + dispatch split is externally indistinguishable (defer-cancel discipline, error mapping, single-shot ctx).
  2. reusableExecTxn.results caching is safe when the FSM probe hits (cached results describe attempt 1's intent against attempt 1's snapshot — invariant under reuse).
  3. The self-conflict guard in dispatchExecReuse matches PR docs(design): proposed txn secondary-commit idempotency (Jepsen 26198185540) #796 round-11's contract.
  4. runTransaction gate (onePhaseTxnDedup on/off) keeps the legacy path byte-identical when off.

@claude
Copy link
Copy Markdown

claude Bot commented May 30, 2026

Claude finished @bootjp's task in 6m 7s —— View job


Code Review — M3 EXEC reuse (option-2 dedup for MULTI/EXEC)

Todo:


Verification Results

#1commit()prepareDispatch() + dispatch split

The split is externally indistinguishable. Cancel discipline is correct in all paths:

  • zset error path (no ctx allocated yet): prepareDispatch returns cancel: func(){} (no-op). commit() returns early before reaching defer prepared.cancel(). No leak.
  • stream error path (ctx was allocated): prepareDispatch calls cancel() explicitly, then returns cancel: func(){}. commit() again returns early. No double-cancel, no leak.
  • success path: prepared.cancel is the real cancel. defer prepared.cancel() in commit() and firstExecAttempt fires on all exit routes.

One deliberate behavior change worth confirming is intentional: readKeys is now assembled inside prepareDispatch() unconditionally, before the caller checks len(prepared.elems) == 0. In the original commit(), readKeys was assembled only after the non-empty elems guard (adapter/redis.go:2693). This is harmless — the slice is discarded in the empty path — but it is a small semantic reordering that the reviewer should note is known.

firstExecAttempt correctly mirrors commit()'s single-shot dispatch semantics: prepared.ctx is the per-attempt redisDispatchTimeout context derived from handlerContext(), not from the outer dispatchCtx. This means each attempt (both first and reuse) gets a full fresh timeout for the actual Dispatch call, matching the old commit() behavior where each invocation created its own timeout context.

#2reusableExecTxn.results caching invariant

The invariant is sound under the stated assumption. Cached results are returned only in two cases:

  1. Reuse dispatch succeeds (dispErr == nil): FSM either no-op'd (prior attempt landed, PrevCommitTS probe hit) or applied fresh. Either way the committed state in MVCC is exactly attempt 1's write set, so pending.results is the correct client view.
  2. Self-conflict guard fires: FSM applied at the fresh commitTS but surfaced WriteConflict due to leadership churn. The probe confirms our write landed. OCC on readKeys guarantees no cross-txn write to any read key slipped through between attempt 1's startTS and this fresh commitTS — if it had, the OCC check at the FSM would have generated a genuine WriteConflict (probe-misses → drop = true), and we would have fallen through to a fresh rebuild instead.

The correctness boundary is that txnContext.apply records ALL keys read during EXEC body evaluation into t.readKeys. This is a pre-existing invariant of the txn machinery (the new code inherits it, not introduces it). If any read escapes readKeys, a stale cached GET/LLEN/etc. result could be returned without OCC catching the staleness — but that would be a bug in the read-recording layer, not in this PR.

No concern with the readPin lifecycle: it is held via defer readPin.Release() inside firstExecAttempt, which is correct since all snapshot reads complete before firstExecAttempt returns. The pending.elems bytes are raw key/value payloads that don't reference the pinned snapshot.

#3 — Self-conflict guard in dispatchExecReuse vs round-11 contract

The guard (adapter/redis.go:3122–3140) correctly matches round-11's contract:

landed, perr := r.store.CommittedVersionAt(ctx, probeKey, commitTS)  // probes fresh commitTS, not pending.commitTS
if perr == nil && landed {
    pending.commitTS = commitTS
    return pending.results, false, nil
}

Key points:

  • Probe target is the fresh commitTS, not pending.commitTS. This correctly detects whether the reuse attempt (not attempt 1) landed — matching the scenario where the FSM committed the fresh write but leadership churn surfaced it as WriteConflict.
  • No resolveLengthAfterFreshApply analog needed. Round-11's resolveLengthAfterFreshApply was required because list pending.length could be stale relative to non-conflicting intervening list-modifying writes (P2: a concurrent RPUSH at a different sequence index wouldn't conflict with our write key but would change the list length). For EXEC, there is no equivalent summary value outside the OCC fence: pending.results are derived from reads all fenced by readKeys OCC, so a non-conflicting write to a key the EXEC body read would surface as WriteConflict and cause a drop, not a silent stale-result return.
  • Ambiguous (non-WriteConflict) error path correctly advances pending.commitTS = commitTS only when isRetryableRedisTxnErr(dispErr) (line 3148), matching the dispatchListPushReuse contract.

One structural difference from listPushCoreWithDedup worth noting: the reuse path in runTransactionWithDedup creates a fresh reuseCtx from handlerContext() rather than reusing dispatchCtx. This gives each reuse attempt a full redisDispatchTimeout, whereas listPushCoreWithDedup passes the caller's ctx straight through to dispatchListPushReuse. The EXEC behavior is strictly more conservative (prevents a half-expired context cutting a reuse attempt short) and is consistent with the prepareDispatch() fresh-ctx pattern. Not a bug — but it is a deliberate deviation from the M1/M2 template that the doc should acknowledge.

#4runTransaction gate keeps legacy path byte-identical when off

func (r *RedisServer) runTransaction(queue []redcon.Command) ([]redisResult, error) {
    if r.onePhaseTxnDedup {
        return r.runTransactionWithDedup(queue)
    }
    // legacy body below — unchanged

The guard is at the very top, before any local variable declarations or context allocations. The legacy body (adapter/redis.go:3023–3066) is textually identical to the pre-PR code: same dispatchCtx creation, same retryRedisWrite closure structure, same txnContext construction, same txn.commit() call. txn.commit() now delegates to prepareDispatch() internally (Verification #1 above confirms this is externally equivalent). No observable difference when the gate is off.


Minor Observations (non-blocking)

  1. firstExecAttempt readPin scope: The read pin is released when firstExecAttempt returns, before reusableExecTxn is stashed as pending. This is correct — pending.elems holds encoded bytes, not live references to the pinned snapshot. Just worth confirming it's deliberate.

  2. No PrevCommitTS on firstExecAttempt's dispatch group: The OperationGroup in firstExecAttempt correctly omits PrevCommitTS (it's zero). Only dispatchExecReuse sets it. This is the right design: the first attempt should never carry a probe hint.

  3. TestExecDedup_GenuineConflictRebuildsAndApplies assertion: require.GreaterOrEqual(t, coord.dispatches, 3) is correct (the rebuild attempt may succeed on its first try, giving exactly 3, or may need more). The test correctly validates the drop-and-rebuild path without over-constraining the retry count.


Summary

All four verification points pass. The commit()prepareDispatch() refactor is a clean surgical extraction with correct cancel discipline in all error paths. The reusableExecTxn.results caching invariant holds under the OCC fence. The self-conflict guard matches round-11's contract with a justified simplification (no list-meta staleness analog for EXEC). The gate is a pure early-return that leaves the legacy path untouched. No issues requiring changes.

@bootjp bootjp merged commit cbbde3d into docs/txn-idempotency-design May 30, 2026
6 checks passed
@bootjp bootjp deleted the feat/m3-exec-reuse branch May 30, 2026 07:00
bootjp added a commit that referenced this pull request May 30, 2026
gemini HIGH (adapter/redis.go:3112 dispatchExecReuse):
  Clock().Next() bypasses the HLC-4 physical-ceiling fence;
  persistence-grade commit_ts allocation must use NextFenced() to
  avoid issuing a timestamp that collides with a subsequent leader's
  window after renewal. Switched dispatchExecReuse to NextFenced()
  with the standard error-propagation shape (matches prepareDispatch
  from PR #884 cherry-pick).

gemini MEDIUM (adapter/redis.go:3175 reuseCtx propagation):
  The original "fresh ctx from handlerContext per reuse attempt"
  pattern ignored outer cancellation -- a disconnected client would
  wait the full 10 s before the reuse returned. Derived reuseCtx from
  dispatchCtx (the caller's retry-loop ctx) so outer cancellation
  interrupts mid-attempt. Per-attempt redisDispatchTimeout still caps
  the dispatch the same way commit does for the first attempt.
  Matches listPushCoreWithDedup's caller-ctx threading.

gemini MEDIUM (adapter/redis.go:3215 readPin.Release on nil):
  False positive. ActiveTimestampToken.Release is documented
  nil-safe at kv/active_timestamp_tracker.go:58. The pattern
  readPin := r.pinReadTS(...); defer readPin.Release() is used
  unchanged in the existing runTransaction (legacy path); no change
  needed.

Caller audit per /loop semantic-change rule:

- dispatchExecReuse signature unchanged; the new error return is
  the same (bool, error) tuple position. Single caller is
  runTransactionWithDedup which already returns dispErr via dropping
  to the retry loop's error path -- the new NextFenced error reaches
  the same dispErr branch.
- reuseCtx parent change is local to runTransactionWithDedup; the
  only observable effect is faster cancellation propagation, which
  retryRedisWrite already gates on ctx.Done between attempts.

Note on pre-existing Clock().Next() callers:

dispatchListPushReuse (adapter/redis.go:3508) and the
listPushCoreWithDedup first-attempt site (adapter/redis.go:3679) --
both shipped on main as part of PR #796 -- still use Clock().Next()
and have the same HLC-4 ceiling-bypass exposure. Out of scope for
this PR (those are pre-existing on main, not introduced by PR-A's
diff), but should be fixed in a follow-up cleanup PR for parity.

Design doc:
  M3 "fresh reuseCtx from handlerContext" deviation note is struck
  and replaced with the dispatchCtx-derived rationale; future
  readers see why the earlier framing was wrong and what the
  current pattern is.

Validation:
  go test ./adapter/ -run 'ExecDedup|TxnMULTI' passes.
  go build ./adapter/... clean.
  golangci-lint run ./adapter/... 0 issues.
bootjp added a commit that referenced this pull request May 30, 2026
coderabbitai (1) test (adapter/redis_exec_dedup_test.go:119):
  TestExecDedup_GenuineConflictRebuildsAndApplies asserted
  GreaterOrEqual(coord.dispatches, 3) but with the single injected
  pre-reject + single concurrent SET the retry topology is fully
  deterministic: attempt 1 (pre-reject), reuse (OCC-conflict), fresh
  retry (success). Tightened to Equal(3) so a regression that adds
  an extra dispatch is caught.

coderabbitai (2) doc (docs/design/...:481):
  "LANDED via PR #884" was ambiguous after the re-land on main via
  PR #887. Updated to "LANDED via PR #887 (originally PR #884,
  re-landed against main)" so future readers see the canonical
  landing PR with the lineage preserved.

No Go callers touched. go test ./adapter/ -run ExecDedup passes.
bootjp added a commit that referenced this pull request May 30, 2026
…i-mop (#887)

## Summary

PR #884 was merged into the stacked branch `docs/txn-idempotency-design`
(at `cbbde3d7`) but never reached `main` — main has only PR #796's M1 +
M2 + M3 RPUSH/LPUSH content. This PR re-lands the M3 EXEC reuse code on
top of main and bundles three follow-ups that move the design doc's
"still open" / "follow-up" items into "landed".

Base: `main`.

## Changes

### 1. Re-land M3 EXEC reuse (PR #884's content)

- `adapter/redis.go`: `txnContext.prepareDispatch()` split out of
`commit()`; `reusableExecTxn`; `dispatchExecReuse`;
`runTransactionWithDedup` + `firstExecAttempt`; gate at the top of
`runTransaction`.
- `adapter/redis_exec_dedup_test.go` (originally added in PR #884): 5
tests pinning all four reuse outcomes plus the gate-off legacy
equivalence.
- Cherry-pick adaptation: `prepareDispatch()` uses `Clock().NextFenced()
(uint64, error)` on current main; the PR #884 version targeted
`Clock().Next() (uint64)`. Same downstream semantics, error wired
through `preparedTxnDispatch`.

### 2. Close M2 open item — FSM other-txn exactness test

`TestOnePhaseDedup_OtherTxnVersionDoesNotMaskRetry`
(`kv/fsm_onephase_dedup_test.go`) pins exactness at the FSM apply layer:
a third-party version at `T_other=20` must not satisfy the FSM probe at
`T1=30`, so the retry falls through and applies at the fresh `T2=40`.
The store-layer pin already covers the primitive; this test covers the
dispatch path that uses it.

### 3. M3 multi-mop EXEC dedup test

`TestExecDedup_MultiMopLandedPriorAttempt_ReturnsCachedResults` extends
single-mop dedup to a 3-command MULTI/EXEC body (`SET a + SET b + DEL
c`). Validates that cached results + OCC `readKeys` fence are
mop-count-agnostic. Without dedup the DEL would re-execute to 0 on the
second pass — the test rejects that.

### 4. Design doc updates

- §M2 "still open" → "LANDED" with the new FSM test reference
- §M3 "runTransaction (MULTI/EXEC) — Still open" → "LANDED via PR #884"
with multi-mop test reference and acknowledgement of two intentional
deviations from the M1/M2 template that claude[bot] flagged on #884
(`readKeys` assembly order, fresh per-attempt `reuseCtx`)
- §M3 "standalone SET/INCR/HSET" called out as PR-B follow-up

## Caller audit (per /loop semantic-change rule)

- `prepareDispatch` (newly added): callers are `commit()` and
`firstExecAttempt`; both honor `defer prepared.cancel()`. External
behavior of `commit()` preserved.
- `commit()`: internal structure changed; external behavior preserved
(no test directly invokes it).
- `runTransactionWithDedup` / `firstExecAttempt` / `dispatchExecReuse` /
`reusableExecTxn`: all new symbols, exercised only from the gated
`runTransaction` path.

## Validation

- `go test ./adapter/ -run 'Dedup|Txn|MULTI|EXEC'` passes
- `go test ./kv/ ./store/` both pass
- `gofmt`, `go vet`, `golangci-lint run` all clean (0 issues)

## Relation to prior work

| PR | Merged into | Content |
|---|---|---|
| #796 (`f481f2b7`) | main | M1 + M2 + M3 RPUSH/LPUSH |
| #884 (`cbbde3d7`) | stacked branch, NOT main | M3 EXEC reuse
(stranded) |
| **This PR** | main | Re-land #884 + M2 cross-txn FSM test + multi-mop
test + doc updates |
| PR-B (next) | main | Standalone SET / INCR / HSET reuse paths |
| PR-C (next) | main | M4 Jepsen validation infrastructure |


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Tests**
* Added comprehensive regression test suite for transaction retry and
deduplication handling, covering cached result reuse, conflict
detection, and legacy behavior paths
* Extended test coverage for standalone command transaction consistency

* **Documentation**
* Updated design documentation to reflect transaction handling
improvements and recent test coverage additions

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/bootjp/elastickv/pull/887?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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