Skip to content

Commit bc7b9c7

Browse files
committed
fix(recovery): match write-set locks to repository plans
1 parent f55794e commit bc7b9c7

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

‎core/app/recovery_operations.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ func decideRecovery(
556556
if lock.Scope != "repository" {
557557
decision.Blockers = append(decision.Blockers, "unexpected-lock-scope")
558558
}
559-
if _, allowed := allowedRepositories[lock.ScopeID]; !allowed {
559+
if !repositoryLockBelongsToPlan(lock.ScopeID, allowedRepositories) {
560560
decision.Blockers = append(decision.Blockers, "lock-scope-outside-plan")
561561
}
562562
if lock.DeviceID != deviceID {
@@ -655,6 +655,24 @@ func decideRecovery(
655655
return decision
656656
}
657657

658+
// repositoryLockBelongsToPlan accepts both the legacy bare repository scope
659+
// and the operation engine's write-set scope (`<repository-id>:<target>`).
660+
// Locks are keyed by the latter so independent write targets in one repository
661+
// do not collide, while recovery is scoped by the repository id in the plan.
662+
// Comparing only exact strings made every interrupted write-set operation look
663+
// foreign and left its expired lock unrecoverable.
664+
func repositoryLockBelongsToPlan(scopeID string, allowedRepositories map[string]struct{}) bool {
665+
if _, allowed := allowedRepositories[scopeID]; allowed {
666+
return true
667+
}
668+
for repositoryID := range allowedRepositories {
669+
if strings.HasPrefix(scopeID, repositoryID+":") {
670+
return true
671+
}
672+
}
673+
return false
674+
}
675+
658676
func processAlive(pid int) (bool, error) {
659677
if pid <= 0 {
660678
return false, fmt.Errorf("invalid process id %d", pid)

‎core/app/recovery_operations_test.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ func TestRecoveryDecisionReportsCompensationWithoutApplyingIt(t *testing.T) {
7373
}
7474
}
7575

76+
func TestRecoveryDecisionAcceptsWriteSetRepositoryLockScope(t *testing.T) {
77+
now := time.Now().UTC()
78+
snapshot := recoveryDecisionFixture(now)
79+
snapshot.Locks[0].ScopeID = "repo_fixture:repository"
80+
plan := operations.Plan{
81+
Steps: []operations.Step{{
82+
StepID: "step-1", Compensation: operations.Compensation{Mode: "manual"},
83+
}},
84+
Scope: operations.Scope{Repositories: []string{"repo_fixture"}},
85+
}
86+
decision := decideRecovery(snapshot, plan, now, "device:test")
87+
if !decision.Automatable || decision.Classification != "safe-abort" {
88+
t.Fatalf("decision=%#v", decision)
89+
}
90+
}
91+
7692
func containsString(values []string, expected string) bool {
7793
for _, value := range values {
7894
if value == expected {

0 commit comments

Comments
 (0)