Skip to content

Fix replace transaction revert via up-front oldRunning capture - #500

Open
steiler wants to merge 2 commits into
mainfrom
replace-revert-fix
Open

steiler wants to merge 2 commits into
mainfrom
replace-revert-fix

Conversation

@steiler

@steiler steiler commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Make a replace transaction's timeout/cancel actually revert the device, instead of being a silent no-op.

The bug: a replace transaction's pre-replace Running snapshot was captured too late (after replaceIntent had already written back), via a separate re-derivation that was never even wired into rollback. GetRollbackTransaction() only ever replayed oldIntents, which is empty for a replace-only call — so canceling/timing out a replace did nothing.

Changes

  • Datastore.replaceThenMerge: new shared "replace-then-merge" orchestration. Fetches Running once, up front, before either the replace or merge phase runs; captures it as the transaction's oldRunning snapshot; then conditionally runs replaceIntent (now taking that snapshot as a parameter instead of fetching it itself) followed by lowlevelTransactionSet.
  • lowlevelTransactionSet's late, post-replace oldRunning re-derivation (via ops.LeafsOfOwner) removed.
  • Transaction.GetRollbackTransaction() now sets the rollback's .replace from oldRunning, but only when the original transaction had .replace set. oldIntents replay is unchanged.
  • DatastoreRollbackAdapter.TransactionRollback now goes through replaceThenMerge instead of calling lowlevelTransactionSet directly, so a rollback transaction's own .replace is honored instead of silently dropped.
  • TransactionSet reordered so intents/replace are attached to the transaction and IsNoOp() is evaluated before any replace/merge work runs (prerequisite for ticket 08's Synced gate, not itself a gate).
  • Guarded the new unconditional up-front Running fetch against a not-yet-persisted Running intent (e.g. the very first transaction on a fresh datastore) via IntentExists, to avoid regressing plain intent-only transactions that previously never touched the cache for this purpose.
  • pkg/datastore/CONTEXT.md updated with Replace transaction / oldRunning entries.

Out of scope

Per the ticket, no Synced gate / ErrNotSynced / RunningStore.MarkSynced (see ticket 08-synced-gate.md, a separate PR).

Tests

  • Replace transaction cancel restores pre-replace device state.
  • Plain intents-only transaction rollback remains oldIntents-only (unaffected).
  • Rollback transactions don't start their own rollback timer (rollback-of-a-rollback not attempted) — confirmed unchanged.
  • Transaction.GetRollbackTransaction() unit tests for the conditional .replace wiring.

go build ./..., go vet ./..., go test ./... all pass.

Reviewed with the repo's code-review skill (Standards + Spec sub-agents); one minor duplicated-code smell was found and fixed (extracted newRunningSnapshotTreeRoot).

- TransactionSet/TransactionRollback now share a replaceThenMerge
  orchestration that fetches Running once, up front, and captures it
  as the transaction's oldRunning snapshot before either the replace
  or merge phase runs.
- replaceIntent takes that snapshot as a parameter instead of
  fetching Running itself, closing the TOCTOU gap between two
  separate fetches.
- lowlevelTransactionSet's late (and, for replace transactions,
  post-replace) oldRunning re-derivation is removed.
- GetRollbackTransaction() sets the rollback's .replace from
  oldRunning when the original transaction had .replace set, so a
  replace transaction's timeout/cancel actually reverts the device
  instead of being a silent no-op. oldIntents replay is unchanged.
- DatastoreRollbackAdapter.TransactionRollback now goes through
  replaceThenMerge instead of calling lowlevelTransactionSet
  directly, so a rollback transaction's own .replace is honored.
- TransactionSet reordered so intents/replace are attached and
  IsNoOp() is evaluated before any replace/merge work runs.
- Guard the new up-front Running fetch against a not-yet-persisted
  Running intent (e.g. the very first transaction on a fresh
  datastore), disambiguating via IntentExists instead of treating
  every fetch error as fatal.

Tests: TransactionSet/GetRollbackTransaction coverage for replace
rollback restoring pre-replace device state, plain intents-only
rollback remaining oldIntents-only, and rollback transactions not
starting their own rollback timer.
…pshotUpdates

Addresses code-review feedback: both replaceIntent and runningSnapshotUpdates
built their own TreeRoot + ImportConfig(ProtoTreeImporter(runningProto)) from
scratch; factor that into one shared helper.
@steiler
steiler requested a review from a team as a code owner September 23, 2026 16:31
@codecov

codecov Bot commented Sep 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.90909% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pkg/datastore/transaction_rpc.go 61.53% 8 Missing and 7 partials ⚠️

📢 Thoughts on this report? Let us know!

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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