Skip to content

fix(restore): keep a restore's source, refuse orphan targets, take over stale rollback marks - #218

Merged
Andrei Kvapil (kvaps) merged 8 commits into
mainfrom
fix/restore-source-and-rollback
Nov 2, 2026
Merged

Andrei Kvapil (kvaps) merged 8 commits into
mainfrom
fix/restore-source-and-rollback

Conversation

@kvaps

@kvaps Andrei Kvapil (kvaps) commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Three fixes on the restore and rollback paths.

Restore source (#197)

A restored or cloned definition keeps its restore marker, and while it is there every new replica is brought up from that snapshot and placed on its nodes. Nothing protected the snapshot: s d deleted it straight away and the auto-snapshot prune deleted by age, so the definition's next replica came up blank and never reached UpToDate. A snapshot delete, on the REST door and in the CLI, now ends the dependency first: a definition whose restore is finished, by the same rule an operator's own delete of the marker passes, has the marker taken off, and its later replicas sync from their peers. One whose restore is still running keeps it, and the delete answers a retryable 409 for as long as that lasts. The prune skips such a snapshot for the pass. The clone door already refused rewriting the marker through override_props.

A delete and a restore also race: the delete could miss a definition the restore was about to create, and the restore answered 201 over a snapshot that was gone. Now the delete marks the snapshot, lists definitions past the cache and only then deletes, and the restore, once its definition exists, reads the snapshot back past the cache and withdraws through its rollback on a live delete mark (409, retryable) or a missing snapshot (404). Deleting a snapshot that does not exist answers as before, without a 409.

Orphan replicas under the target name (#203)

A restore or clone into a name with no definition but with a diskless replica or a witness under it created the definition over that replica, failed the stamp on its node, and the rollback cascade then deleted a replica the request had not made. Both restore doors, REST and CLI, now refuse such a name with 409 before writing anything and name the replicas. A replica holding data is still taken as placed, as #190 does, and the controller's tie-breaker witness is still promoted.

Stale rollback mark (#213)

An apiserver replica killed between writing the in-progress rollback mark and taking it off left the mark for good, and linstor-csi retried against the 409 until somebody cleared the prop. The mark now carries the time it was written, and a retry on either door takes over a mark older than two minutes, well past the rollback's 15.6-second budget, and resumes the leftover. The mark only ever stands over a definition left whole, and a rollback still alive past its budget finds the mark gone at its next step and stands down without deleting. The CLI rollback, which had no deadline, now runs on a one-minute budget of its own, so the two-minute rule holds on both doors. A mark written before this carries no time and is never taken over.

Fixes #197
Fixes #203
Fixes #213

Testing

  • Every new guard was reverted in turn and its named test fails: the release in the REST and CLI deletes, the marker removal, the prune skip with and without a store, the orphan refusal on both doors, the stale takeover in the REST gate and on the CLI, and the staleness check.
  • go test ./...: green apart from the known pkg/rest shutdown flake.
  • go test -tags=integration ./tests/integration/...: green.
  • golangci-lint on the changed lines: clean.
  • Not run on a stand.

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 9 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2c6f8411-c090-49ca-a7bd-e99f97a1e8ae
Commits

Reviewing files that changed from the base of the PR and between e49b274 and 1150d36.

Files selected for processing: 41
  • cmd/controller/main.go
  • docs/cli-parity-known-deltas.md
  • internal/cli/rollback_budget_test.go
  • internal/cli/snapshot.go
  • internal/cli/snapshot_delete_race_test.go
  • internal/cli/snapshot_delete_restore_source_test.go
  • internal/cli/snapshot_restore_orphans_test.go
  • internal/cli/write_more.go
  • internal/controller/autosnapshot_controller.go
  • internal/controller/autosnapshot_restore_source_test.go
  • pkg/rest/nodes.go
  • pkg/rest/rd_clone.go
  • pkg/rest/rd_modify_server_owned_props_test.go
  • pkg/rest/restore_orphan_replicas_test.go
  • pkg/rest/restore_source_lifetime_test.go
  • pkg/rest/rg_deleted_race.go
  • pkg/rest/rollback_handshake_failclosed_test.go
  • pkg/rest/rollback_mark_stale_test.go
  • pkg/rest/rollback_mark_time_test.go
  • pkg/rest/snapshot_delete_restore_race_test.go
  • pkg/rest/snapshot_restore.go
  • pkg/rest/snapshots.go
  • pkg/store/clone_snapshot.go
  • pkg/store/inmemory_snapshot.go
  • pkg/store/k8s/restore_source_spelling_test.go
  • pkg/store/k8s/snapshot_delete_cache_miss_test.go
  • pkg/store/k8s/snapshots.go
  • pkg/store/local_props.go
  • pkg/store/restore_orphans_test.go
  • pkg/store/restore_source.go
  • pkg/store/restore_target.go
  • pkg/store/rollback_mark_time_test.go
  • pkg/store/rollback_stale_test.go
  • pkg/store/snapshot_delete_reap_mark_test.go
  • pkg/store/snapshot_delete_test.go
  • site/content/en/docs/cli.md
  • site/content/en/docs/operations.md
  • tests/e2e/cli-matrix/README.md
  • tests/e2e/cli-matrix/snap-d-restore-source.sh
  • tests/integration/harness/manager.go
  • tests/operator-harness/replay/snap-d-restore-source.yaml
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@kvaps
Andrei Kvapil (kvaps) force-pushed the fix/restore-source-and-rollback branch 4 times, most recently from 52d0763 to 2bbc42c Compare October 24, 2026 22:14
@kvaps
Andrei Kvapil (kvaps) marked this pull request as ready for review October 24, 2026 22:14
@kvaps
Andrei Kvapil (kvaps) force-pushed the fix/restore-source-and-rollback branch from 2bbc42c to d9d2c29 Compare October 26, 2026 13:52
@kvaps
Andrei Kvapil (kvaps) changed the base branch from fix/csi-clone-and-restore-idempotency to main October 26, 2026 13:52

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Andrei Kvapil (@kvaps) NOT LGTM. A concurrent delete can still take a restore's source, deleting a snapshot that is already gone now returns 409, and nothing tests that the real rollback writers put the time on the mark.

The PR covers three problems. A restored definition keeps reading new replicas from its source snapshot while the marker stays (#197). An in-progress rollback mark can outlive a killed writer forever (#213). A restore into a name with orphan diskless replicas cascades over them (#203).

I tested on a trial merge with main at 1652387. That is the merge base, so the tree is the head. pkg/store, internal/cli and internal/controller pass, and pkg/rest fails only the known "server did not stop within 2s" shutdown tests. CI is red only on E2E lane 3 (recovery-deleting-convert). The same scenario failed on 2026-10-24 on two unrelated branches, fix/create-spawn-prop-fields and fix/apiserver-plain-localhost, so it is not this PR.

Blockers

1. A restore can still lose its source to a concurrent delete

The delete lists dependents once and the restore reads the snapshot once, so neither side sees the other. ReleaseRestoreSource (pkg/store/restore_source.go:54) lists definitions, then the caller deletes the snapshot. materializeRestoredRD reads the snapshot before it creates the marked definition and never reads it again. A delete that lands between those two steps finds no dependent, and the restore then writes a marker naming a snapshot that no longer exists. On the k8s store the list also goes through the informer cache, so it can miss a definition created just before it.

Repro: I wrapped the in-memory store so that creating a marked definition first sends DELETE /v1/resource-definitions/src/snapshots/snap to the running server, then ran a normal restore. The delete answered 200 and the restore 201. The snapshot is gone and dst carries BlockstorRestoreFromSnapshot=src:snap, which is the #197 end state. CSI may be shielded by the snapshotter, but the CLI and the auto-snapshot prune are not.

#200 already describes this gap on ReapClonedSnapshot ("no list taken here alone can see one that is between them") and closes it there. The reap marks the snapshot, lists past the cache, then deletes. The restore reads the snapshot back after it creates the definition (RestoreSourceWithdrawn). The general delete here, on REST, in the CLI and in the prune, has neither half. I'd use the same protocol for every snapshot delete. If #200 is supposed to bring the restore half, this PR still needs the delete half, and #200 has to land first.

2. Deleting a snapshot that does not exist now returns 409

The release runs before the existence check, so a missing snapshot can be refused. In handleSnapshotDelete the call is at pkg/rest/snapshots.go:1262, above the not-found path. A definition whose marker names an absent snapshot, with an unfinished restore, turns the idempotent 200 into a 409. That restore can never finish because its source is gone, so linstor-csi retries DeleteSnapshot forever. I checked it: definition dst with marker src:snap-gone, no such snapshot, and DELETE .../snapshots/snap-gone returns 409. The CLI snapshotDelete behaves the same way. The race in point 1 produces exactly this state, and so does any snapshot removed before this change.

Read the snapshot past the cache first and answer success when it is absent. Only a snapshot that exists needs holding.

3. The time on the mark is not tested where it is written

Every takeover test seeds the mark with store.MarkRollback directly. I reverted the two real writers to the old raw prop write without a time. One is markRollbackAbandoned in pkg/rest/rg_deleted_race.go. The other is setRollbackMark in pkg/store/restore_target.go, which the CLI's HoldRollback uses. All tests stayed green. In that state no real mark ever goes stale, so #213 is not fixed. A test should run a REST rollback and a CLI rollback up to the in-progress mark and check that Blockstor/RollbackSince is on it.

These mutations survived too:

  • removing RollbackSinceProp from serverOwnedDefinitionProps, the guard that stops an operator from setting the time and forcing a takeover of a live mark
  • UnmarkRollback leaving the time behind
  • MarkRollback keeping the time on a step mark
  • the skip of definitions flagged DELETE in ReleaseRestoreSource
  • the skip of replicas flagged DELETE in RefuseReplicasWithoutDefinition, which the d298ee0 message promises ("one already being deleted is no obstacle")

The PR body says every new guard was reverted and its test failed. That holds for the other guards, not for these.

4. Commit d9d2c29 describes the review instead of the change

Main squashes with the commit messages, so this text lands as it is. The subject is "close the gaps review found in the last three fixes". The body talks about earlier states of the branch ("The orphan refusal moves to the store", "sits above handleSnapshotDelete's doc comment rather than under it, and the unused RestoreSourceInUse is gone"). Please fold each part into the commit it belongs to (7d5a63c, d298ee0, 44e250d), so every message describes the change itself.

Non-blocking

  1. The takeover depends on clocks. The CLI stamps the mark with the operator machine's time, so a clock more than 60s behind makes a live CLI rollback look stale to the server. The check in EnterDestructiveRollback and enterDestructiveRollback compares only the value in-progress, so it also accepts a mark another rollback wrote after a takeover. Now that the mark has a time, the rollback could compare against the RollbackSince it wrote itself, and the same in releaseInProgress. Then clock skew would stop mattering.
  2. Some facts in the PR body are off. The REST rollback budget is 15.6s (detachedRollbackBudget); 21s is about the termination grace. #190 is merged, so "Stacked on #190" is stale. The conflict with #200 is bigger than two lines: git merge-tree shows four conflicting hunks in internal/cli/snapshot.go and row 87 of docs/cli-parity-known-deltas.md.
  3. The site in #205 shows snapshot resource restore followed by snapshot delete of the same snapshot, in two places. After this change that delete is refused until the restored replica is UpToDate, so the page should say so.

@kvaps

Copy link
Copy Markdown
Member Author

Thanks, head 6cdd037. The history is reworked into one commit per change, so no commit describes a fix to another.

  • The delete and restore race: a snapshot delete now marks the snapshot, lists definitions past the cache through a new ResourceDefinitions().ListUncached, and only then deletes; the restore, on both doors and for prepared targets and leftovers, reads the snapshot back past the cache after its definition exists and withdraws through the rollback on a live delete mark (409) or a missing snapshot (404). REST, the CLI s d and the autosnapshot prune all go through the same store.DeleteSnapshot.
  • s d of a snapshot that does not exist reads the snapshot first and answers the idempotent success again, before any restore check.
  • On the stale rollback takeover: both writers already stamp the time through MarkRollback, but nothing pinned it. The real REST compensating rollback, the CLI hold, enter and stand-down, and UnmarkRollback now each have a test that the in-progress mark carries the time and the step marks do not.

18 mutations over these paths each turn a test red. The L6 cell snap-d-restore-source.sh and the L7 replay snap-d-restore-source.yaml are added but not run on a stand. The conflict with #200 grew, and both now carry RestoreSourceWithdrawn; I'll fold them into one check that reads both marks on whichever lands second.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Andrei Kvapil (@kvaps) NOT LGTM. The four blockers from my last review are fixed, but the new snapshot delete has a regression: deleting a snapshot that the apiserver's cache has not seen yet answers 200 and deletes nothing.

I tested on a trial merge with main at 7bb6606. The branch sits on 1652387 and main has #206 on top, with no conflicts. pkg/store, pkg/store/k8s (envtest), internal/cli and internal/controller pass. pkg/rest fails only the known "server did not stop within 2s" shutdown tests. CI on af59bdd is green.

Previous blockers

  1. The delete and restore race is fixed. The delete marks the snapshot, lists definitions past the cache and only then deletes. The restore reads the snapshot back once its definition exists, on REST (new and adopted targets) and in the CLI (new, prepared and leftover targets).
  2. A delete of a missing snapshot answers 200 again. The snapshot is read first, before any restore is judged.
  3. The time on the mark is now tested where it is written. Every mutation I listed last time now turns a test red: both real writers back to a raw write, RollbackSinceProp out of the server-owned list, UnmarkRollback keeping the time, MarkRollback keeping it on a step, and both DELETE-flag skips.
  4. Commit d9d2c29 is gone and its parts live in the commits they belong to.

I mutated 22 points in total: the deleting mark, the uncached list, each read-back on both doors, the missing-snapshot order, the mark TTL, the mark leaving TravellingProps, the keep path dropping the mark, the CLI rollback budget, the prune skip, both directions of the staleness check and the step write in EnterDestructiveRollback. Every one turned at least one test red.

Blocker: a delete of a fresh snapshot answers 200 and keeps it

DeleteSnapshot finds the snapshot past the cache, then writes the deleting mark with Snapshots().Update (pkg/store/restore_source.go:109). On the k8s store Update starts with a cached Get (pkg/store/k8s/snapshots.go:204), and on the apiserver the stores get the manager's cached client (NewWithAPIReader(mgr.GetClient(), ...) in field_index.go:67). When the replica's cache has not seen the snapshot yet, Update returns ErrNotFound. DeleteSnapshot wraps it, and handleSnapshotDelete answers it as "snapshot already absent" with 200.

Nothing is deleted. Before this PR the handler called Snapshots().Delete, which is a direct DELETE and does not depend on the cache. linstor-csi takes 200 on DeleteSnapshot as final, so the snapshot and its storage stay behind with nothing pointing at them. The window is a delete soon after the create, and it is wider when the two land on different apiserver replicas.

Repro in pkg/store/k8s with envtest: a store from NewWithAPIReader whose client returns NotFound for every Snapshot Get and an empty Snapshot List, with the real client as the reader. I created the snapshot through the real client and called store.DeleteSnapshot:

DeleteSnapshot err=mark snapshot snap1 of w194src as being deleted: update Snapshot w194src/snap1: snapshot "snap1" on RD "w194src": object not found
after delete: 1 snapshot(s) remain

The mark write has to read past the cache like the rest of the protocol does. At minimum, a NotFound from it after the uncached read found the snapshot must not reach the caller as ErrNotFound. dropDeletingMark has the same shape: there a cache miss leaves the mark on for up to five minutes, and restores from that snapshot get 409 with nothing in the delete's answer saying so.

Non-blocking

  1. The ABA point from last time is still open. EnterDestructiveRollback and enterDestructiveRollback still compare only the value in-progress. The two-minute margin covers normal skew, so this is mostly about an operator machine with a bad clock.
  2. Commit de6d5b8 ends with "the two branches meet on one definition", which is about #200. That sentence will be stale in main whichever lands first.
  3. With #200, git merge-tree shows conflicts in five files. On top of that, RestoreSourceWithdrawn and ErrRestoreSourceWithdrawn are defined in both branches but in different files (restore_source.go here, clone_snapshot.go in #200). Git merges that cleanly and the build then fails, so it will not show up as a conflict.
  4. Row 93 in docs/cli-parity-known-deltas.md says the behaviour is pinned by snap-d-restore-source.sh and the replay. Neither runs in CI and neither has run on a stand yet, so today only the unit tests pin it.
  5. #205 still shows snapshot resource restore followed by snapshot delete of the same snapshot (site/content/en/docs/cli.md and operations.md). After this PR that delete is refused until the restored replica is UpToDate.

@kvaps

Copy link
Copy Markdown
Member Author

Thanks, head 06f6775 (rebased onto main, which now has #206, #215 and #219).

Blocker: the snapshot store's Update reads the snapshot through the direct reader when it has one, so the deleting mark, and taking it off, no longer depend on the cache. TestDeletingASnapshotTheCacheHasNotSeenDeletesIt is your repro with a fake client: a cache that answers NotFound for every Snapshot Get, the real client as the reader. The snapshot is gone after DeleteSnapshot, and with the update back on the cache the test fails with the same "mark snapshot snap1 of src as being deleted: ... object not found" you saw.

Non-blocking: row 93 now says the unit tests pin it and the L6/L7 cells have not run on a stand, and the sentence about #200 is out of the de6d5b8 message. The ABA point I left for now. The RestoreSourceWithdrawn overlap with #200 I'll fold into one definition on whichever lands second, and the site's restore-then-delete examples change with the release that ships this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Andrei Kvapil (@kvaps) LGTM. A delete of a snapshot the apiserver's cache has not seen yet now deletes it. Snapshots().Update reads through the direct reader when the store has one, so the deleting mark lands and the delete no longer answers 200 "already absent" with the snapshot left behind. Taking the mark off goes through the same Update, so dropDeletingMark is covered too.

I tested at 06f6775, which already sits on the current main (ffd7b90). pkg/store, pkg/store/k8s with envtest, internal/cli and internal/controller pass. pkg/rest fails only the known "server did not stop within 2s" shutdown tests, and the PR's own tests there pass when run by name. CI on 06f6775 is green.

The new test holds the fix. With the cached Get back in Update, TestDeletingASnapshotTheCacheHasNotSeenDeletesIt fails with the same "object not found" as my repro. The branch moved onto #206, #215 and #219, so I repeated two earlier mutations as well. Dropping the read-back in materializeRestoredRD turns TestSnapshotDeleteRacingARestoreLeavesNoMarkerOnAGoneSnapshot red. Skipping releaseRestoreSource turns seven tests red in pkg/store, internal/cli, internal/controller and pkg/rest.

The switch to the direct reader adds no apiserver reads elsewhere. The only production callers of Snapshots().Update are the two mark writes in restore_source.go.

Earlier non-blocking points:

  1. ABA on in-progress is unchanged. Still the bad-clock case, not blocking.
  2. The sentence about #200 in the ListUncached commit is gone.
  3. With #200, git merge-tree still conflicts in the same five files. RestoreSourceWithdrawn and ErrRestoreSourceWithdrawn are still defined in restore_source.go here and in clone_snapshot.go there, so whichever PR lands second has to drop its copy. #216, #217 and #220 merge cleanly, and this branch plus #217 builds and passes the store, CLI and controller tests.
  4. Row 93 now says only the unit tests pin it and the stand cells have not run.
  5. #205 still runs blockstor snapshot delete my-volume before-upgrade right after the restore in cli.md and operations.md. That one is for #205.

Nit: the comment on the apiReader field (pkg/store/k8s/snapshots.go:52) still says only ListByDefinitionUncached reads through it. Update does too now.

@kvaps

Copy link
Copy Markdown
Member Author

Rebased on main after #200, head e05cc3a.

#200 brought its own RestoreSourceWithdrawn, ErrRestoreSourceWithdrawn and uncachedSnapshot in clone_snapshot.go. I kept those and dropped the copies here: the one guard now withdraws a restore on a gone snapshot (wrapping ErrNotFound), the DELETE flag, a live reaping mark or a live deleting mark, and uncachedSnapshot keeps this PR's lookup of the stored definition spelling. Both marks map to the same retryable 409 in storeErrorRc and withdrawRestoredRD, and TravellingProps drops both.

@kvaps
Andrei Kvapil (kvaps) force-pushed the fix/restore-source-and-rollback branch from 038347e to f94b272 Compare November 2, 2026 19:28
@kvaps

Copy link
Copy Markdown
Member Author

Thanks. Your approval went with the rebase onto #200, head e92b128 now.

The rebase keeps one copy of RestoreSourceWithdrawn, ErrRestoreSourceWithdrawn and uncachedSnapshot, at main's place in pkg/store/clone_snapshot.go. It withdraws on a gone snapshot, the DELETE flag, a live reaping mark and a live deleting mark, and both mark cases answer the same retryable 409. TravellingProps drops the clone marks and SnapshotDeletingProp.

Nit: the apiReader comment names ListByDefinitionUncached, Update and PatchSnapshotProps from #200.

Non-blocking 5: #205 is in main, so I fixed it here. cli.md and operations.md now say the delete is refused with 409 until the restore has an UpToDate replica.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Andrei Kvapil (@kvaps) The rebase onto #200 itself is clean. Each shared symbol is defined once now, go build ./... and go vet pass, and the five former conflicts keep both sides. RestoreSourceWithdrawn withdraws on a gone snapshot, the DELETE flag, a live reaping mark and a live deleting mark. Both mark cases answer the same retryable 409 in storeErrorRc and withdrawRestoredRD, and TravellingProps drops both marks. One thing does not hold, and it only exists now that both PRs are on the same tree.

Blocker: the deleting mark can wipe a live reaping mark. setDeletingMark and dropDeletingMark (pkg/store/restore_source.go) write through Snapshots().Update. Update re-reads the object but then replaces the whole Spec with wireToCRDSnapshotSpec(in), so the props it writes are the ones the delete read earlier. #200 moved its own mark to PatchSnapshotProps for exactly this reason: a write that comes in between makes the update conflict, and mutate runs again on the fresh object.

The race is reachable. An operator can s d an internal clone snapshot; it goes through DeleteSnapshot like any other. Suppose that delete reads the snapshot, a reap of the same snapshot writes its mark, and then the delete writes its own. The reaping mark is gone. The same happens between dropDeletingMark's read and its write. Once the delete keeps the snapshot and takes its mark off (a transient list or delete error is enough), a restore that reads back sees no mark at all and goes on, while the reap, which has already listed dependents, deletes the snapshot under it. That is the race the reaping mark exists to close. Repro, as a test in pkg/store:

type racingSnaps struct {
	SnapshotStore
	inject func()
}

func (s *racingSnaps) ListByDefinitionUncached(ctx context.Context, rd string) ([]apiv1.Snapshot, error) {
	out, err := s.SnapshotStore.ListByDefinitionUncached(ctx, rd)
	if f := s.inject; f != nil {
		s.inject = nil
		f()
	}
	return out, err
}

type racingStore struct {
	Store
	snaps *racingSnaps
}

func (s *racingStore) Snapshots() SnapshotStore { return s.snaps }

func TestDeleteKeepsAReapMarkWrittenInBetween(t *testing.T) {
	inner := NewInMemory()
	seedRestoreSource(t, inner, "src", "snap")
	seedRestoredFrom(t, inner, "dst", "src:snap") // unfinished: the delete keeps the snapshot

	reap := newReapingMark("clone1", time.Now())
	st := &racingStore{Store: inner, snaps: &racingSnaps{SnapshotStore: inner.Snapshots()}}
	st.snaps.inject = func() { _ = setReapingMark(t.Context(), inner, "src", "snap", reap) }

	_ = DeleteSnapshot(t.Context(), st, "src", "snap")

	snaps, _ := inner.Snapshots().ListByDefinitionUncached(t.Context(), "src")
	if snaps[0].Props[CloneSnapshotReapingProp] != reap {
		t.Fatalf("reap mark lost: props=%v", snaps[0].Props)
	}
}

On e92b128 it fails with reap mark lost: props=map[]. When both helpers write through PatchSnapshotProps (set the mark; take it off only while it is still this delete's), the test passes, and so do the rest of pkg/store. The apiReader read in Update can then stay or go; nothing in this PR would need it any more.

Everything else I checked holds. The head e92b128 sits on current main 536e935, so I tested it as is. pkg/store, pkg/store/k8s with envtest, internal/cli and internal/controller pass. pkg/rest fails only the known "server did not stop within 2s" tests, and those pass when run by name. golangci-lint with the repo config is clean on the changes. I broke each piece of both PRs on the head in turn, and every break turns tests red: the read-back in materializeRestoredRD, the deleting-mark and reaping-mark checks in RestoreSourceWithdrawn, #200's other-reap check in setReapingMark, the direct read in Update, the 409 for ErrRestoreSourceDeleting in storeErrorRc and in withdrawRestoredRD, SnapshotDeletingProp in TravellingProps, releaseRestoreSource, and the ErrNotFound wrap on a deleted snapshot. The apiReader comment is fixed.

Non-blocking:

  1. No test covers the stored-spelling lookup that uncachedSnapshot gained in the rebase. Drop srcRD = rd.Name and nothing goes red, because the in-memory ListByDefinitionUncached folds case and the k8s one does not. An envtest case in pkg/store/k8s with a differently cased name would pin it.
  2. The new docs sentence in cli.md and operations.md says "refused with 409" right above blockstor snapshot delete. That command calls store.DeleteSnapshot directly (internal/cli/write_more.go:612) and fails with "a restore from this snapshot is not finished". It never sees an HTTP status. The 409 is what linstor s d gets.
  3. CI: attempt 1 failed in TestGroupFRAutoPlace3WithTieBreaker, lane 1 recovery-auto-place-real-drbd, and lane 6, where the stand never became ready. The integration test failed the same way on #224's head the same evening. Neither failure touches the restore or snapshot-delete paths. Attempt 2 is still running.
  4. Sibling PRs: merging with #222 conflicts only on adjacent rows in tests/e2e/cli-matrix/README.md. #217, #224, #225 and #226 merge cleanly.

@kvaps
Andrei Kvapil (kvaps) force-pushed the fix/restore-source-and-rollback branch from e92b128 to d58b169 Compare November 2, 2026 21:55
@kvaps

Copy link
Copy Markdown
Member Author

Thanks, head d58b169, rebased on main with #222 and #224 (the cli-matrix README keeps both rows).

Blocker: setDeletingMark and dropDeletingMark write through PatchSnapshotProps now, and the drop takes the mark off only while it is still this delete's. Your repro is TestDeleteKeepsAReapMarkWrittenInBetween, with a second case for a reap mark written right before the drop. Both go red with the old Update helpers. The in-memory patch now leaves no props when it empties them, like the API server, so a restore from a snapshot that had none still falls back to the source's props (TestDroppingTheDeletingMarkLeavesNoProps).

Update reads through the cache again, as on main. Nothing in production calls it now, and TestDeletingASnapshotTheCacheHasNotSeenDeletesIt passes without the direct read since the mark goes through the patch.

Non-blocking 1: TestRestoreSourceIsFoundUnderAnotherSpellingOfItsDefinition in pkg/store/k8s, envtest, definition Pvc-Src asked as PVC-SRC. Without srcRD = rd.Name it answers "deleted".

Non-blocking 2: cli.md and operations.md say the blockstor CLI fails with a restore from this snapshot is not finished, and linstor snapshot delete gets the 409.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Andrei Kvapil (@kvaps) The reap-mark race is fixed, and the fix is pinned. With the old Update helpers put back on d58b169, both cases of TestDeleteKeepsAReapMarkWrittenInBetween fail with reap mark lost: props=map[]. With only setDeletingMark put back, the "before the mark is set" case fails. The other production writers of snapshot props are a patch (the reaping mark) or a get-modify-update that conflicts on a stale resourceVersion, and nothing outside storetest calls Snapshots().Update now. The cache-miss fix moved into PatchSnapshotProps and still holds: read through the cache there instead of apiReader, and TestDeletingASnapshotTheCacheHasNotSeenDeletesIt fails with "object not found". Removing the nil normalisation from the in-memory patch fails TestDroppingTheDeletingMarkLeavesNoProps.

The two non-blocking points are fixed too. Without srcRD = rd.Name the new envtest case fails, and the CLI error does carry the text the docs now quote.

Blocker: some commit messages describe states of this branch that are gone. Main squashes with the commit messages (#222 went in with all 24), so each one lands in main as written.

  • 1af28da says "The snapshot store's update now reads through the direct reader when it has one." 0725538 reverts that, and on the head Update is the same as on main. What is left of 1af28da is the cache-miss test and an edit to parity row 93 that its message does not mention.
  • f81d24e writes a comment that lists Update as an uncached reader. 0725538 rewrites it again.
  • 0725538 opens with "The snapshot delete set and dropped its mark through Update" and says "Update no longer needs to read past the cache for the mark, so it reads as it did". Both sentences are about an earlier version of this PR, not about main.
  • 661ad68 says the delete "is refused with 409", and d58b169 corrects that for the CLI.

Please fold each fix into the commit that introduced what it fixes: 0725538 and the cache-miss test into b674461, the row 93 edit into 8b90cc5, d58b169 into 661ad68. Then drop what remains of 1af28da and f81d24e. Same thing as d9d2c29 in the first round.

Non-blocking:

  1. No test checks that a delete takes off only its own deleting mark. I removed the props[SnapshotDeletingProp] != mark check in dropDeletingMark, and pkg/store, pkg/store/k8s, internal/cli and the snapshot and restore tests in pkg/rest still pass. The check matters when two deletes of one snapshot overlap. B writes its mark after A, then A keeps the snapshot (after a transient list error, say) and takes B's mark off. A restore that reads back in that gap sees no mark and goes on while B deletes the snapshot. A third case in TestDeleteKeepsAReapMarkWrittenInBetween that writes another delete's mark before write 2 and checks it survives would pin it.
  2. The head sits on current main e49b274, so I tested it as is. pkg/store, pkg/store/k8s with envtest, internal/cli and internal/controller pass, and go vet is clean. pkg/rest fails only the known "server did not stop within 2s" tests, which pass when rerun by name. On CI, Unit, Lint, Contract and Integration are green; the E2E lanes were still running when I posted this.
  3. This PR adds no merge conflicts with #192, #199, #217, #225 or #226. #221 already conflicts with main.

A definition restored or cloned from a snapshot keeps its restore
marker, and while the marker is there every new replica is brought up
from that snapshot and placed on its nodes. Nothing protected the
snapshot: `s d` deleted it straight away and the auto-snapshot prune
deleted by age, so the definition's next replica came up blank and
never reached UpToDate.

A snapshot delete, on the REST door and in the CLI, now first ends the
dependency: a definition whose restore is finished, by the rule an
operator's own delete of the marker passes, has the marker taken off,
and its later replicas sync from their peers. One whose restore is
still running keeps it, and the delete is refused for as long as that
lasts: a retryable 409 on the REST door, an error naming the definition
in the CLI. The prune skips such a snapshot for the pass.

The clone door already refused rewriting the marker through
override_props.

Fixes #197

Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
A restore or clone into a name with no definition but with a diskless
replica under it created the definition over that replica. The stamp
on its node then failed, and the rollback of the definition cascaded
over every replica under the name, deleting one the request had not
made.

Both restore doors, REST and CLI, now refuse such a name with 409
before they write anything, and name the replicas. A replica holding
data is still taken as placed, the controller's tie-breaker witness
is still promoted by the stamp, and a replica already being deleted is
no obstacle.

Fixes #203

Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
A clone or restore rollback marks the definition in-progress while it
runs, and every retry over that mark answers 409. An apiserver replica
OOM-killed or lost between writing the mark and taking it off left it
for good, and linstor-csi retried CreateVolume against the 409 until
an operator cleared the prop by hand.

The mark now carries the time it was written. A retry, on the REST
doors and in the CLI, takes over a mark older than two minutes and
resumes the leftover; a mark another retry already took off counts as
taken over. Every rollback that writes the mark runs on a budget well
under that: the REST door's detached one, and a one-minute budget the
CLI rollback now gets as well, which had no deadline before. The mark
only ever stands over a definition left whole, and a rollback still
alive past its budget finds the mark gone at its next step and stands
down without deleting.

Fixes #213

Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
A snapshot delete listed the definitions restored from the snapshot
once, and a restore read the snapshot once, before it created its
marked definition. A delete landing between the two found no
dependent and took the snapshot, and the restore then answered 201
over a definition whose marker named a snapshot that was gone, so its
next replica came up blank. The list also went through the informer
cache, which can miss a definition created a moment earlier.

Every operator-facing delete, on the REST door, in the CLI and in the
auto-snapshot prune, now marks the snapshot first, lists the
definitions past the cache, and only then deletes; a snapshot it keeps
gets its mark back off. A restore, on both doors, reads the snapshot
back past the cache once its marked definition stands, and withdraws
when the snapshot is gone or carries a live delete mark, taking back
a definition it created; the REST door answers 404 and a retryable
409 for the two. Whichever of the
list and the create comes second sees the other. The mark expires, so
one a dead process left stops refusing restores.

The mark goes on and comes off as a patch of the props the API server
holds, read past the cache. A clone's reaping mark written in between
survives it, a snapshot the cache has not seen yet is marked rather
than reported absent, and a delete takes off only its own mark, never
another delete's. An in-memory patch that empties the props leaves
none, as the API server does.

A delete of a snapshot that does not exist is judged before any
restore again: a definition whose marker named a missing snapshot,
with a restore that could never finish, turned the idempotent 200
into a 409 that linstor-csi retried forever.

Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
The takeover of a stale in-progress mark reads the time written next
to it, and every takeover test seeded that mark by hand. Writers that
dropped the time, or a time left behind when the mark was replaced or
removed, passed the whole suite while no real mark could ever go
stale.

The REST rollback and the store calls the CLI rollback runs on now
have tests that drive them to the in-progress mark and check its
time, and check that the step mark replacing it and a stand-down
removing it take the time with them. An operator setting the time by
hand is refused like any other server-owned prop.

Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Both snapshot examples delete the snapshot right after restoring from
it. That delete is refused until the restore has an UpToDate replica:
the blockstor CLI fails with "a restore from this snapshot is not
finished", and linstor snapshot delete gets a 409. Say so next to it.

Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
Assisted-by: LLM
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
@kvaps
Andrei Kvapil (kvaps) force-pushed the fix/restore-source-and-rollback branch from d58b169 to 1150d36 Compare November 2, 2026 22:21
@kvaps

Copy link
Copy Markdown
Member Author

Thanks, head 1150d36. Folded as you asked, the tree is d58b169 plus one test.

  • d422218 fix(snapshot): keep a restore's source until the restore is done
  • 4578438 fix(restore): refuse a target name with orphan replicas holding no data
  • c56786d fix(rollback): let a retry take over an in-progress mark nobody holds
  • 042feef fix(snapshot): hold off a restore that races a snapshot delete (now with the patch-based marks, the cache-miss test and the own-mark test)
  • d8645c4 test(rollback): pin the time on the marks the rollback writers put
  • 6b953dc docs(parity): record that a snapshot a restore still reads is kept (with the row 93 wording)
  • 9b1f47b docs(site): say a restore source cannot be deleted mid-restore (CLI error text and the 409 for linstor s d)
  • 1150d36 test(store): pin the stored spelling a restore source is read under

1af28da and f81d24e are gone. I also reread the other messages: d422218 and 042feef said the delete answers 409 on both doors, now the 409 is the REST door's and the CLI gets an error.

Non-blocking 1: TestDeleteTakesOffOnlyItsOwnDeletingMark writes another delete's mark right before the drop and checks it survives. Without the != mark check it fails with deleting mark = "", want the other delete's ....

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Andrei Kvapil (@kvaps) Approving. The history rewrite didn't touch the code: the diff from d58b169 to 1150d36 is the new test and nothing else, on the same base, and every commit builds and passes go vet on its own.

The commit messages are fine now. I read each of the eight against its own diff and the head, and none describes a state that is gone. 042feef fixes the cached list that d422218 leaves behind, which is a real step of the series. Row 93 lands after the race fix, so its sentence about the mark is true where it lands.

The own-mark check is pinned. With the != mark check disabled in dropDeletingMark, TestDeleteTakesOffOnlyItsOwnDeletingMark fails with deleting mark = "", want the other delete's .... The earlier mutations still fail too. With the old Update helpers put back, both cases of TestDeleteKeepsAReapMarkWrittenInBetween fail with reap mark lost. With PatchSnapshotProps reading through the cache, TestDeletingASnapshotTheCacheHasNotSeenDeletesIt fails with "object not found".

Non-blocking:

  1. The PR body still says the delete "answers a retryable 409" for both doors, while the commits now say the CLI gets an error. The body doesn't land in main with the squash settings, so it only matters to readers here.
  2. pkg/store, pkg/store/k8s with envtest, internal/cli and internal/controller pass. In pkg/rest only the "server did not stop within 2s" tests fail, a different set each run, and the same failure shows up on main. Unit, Lint and Contract are green on CI. Integration and E2E were still running when I posted this.
  3. No new conflicts with the other open PRs. #192 and #221 already conflict with main on the same files.

@kvaps
Andrei Kvapil (kvaps) merged commit f0c833b into main Nov 2, 2026
15 of 16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants