fix(recovery): retain healthy checkpoint window
- commit
- e82f1b85ffea532d11cbbeb57ce8de7211467e16
- author
- brice · · 2026-07-26 02:44 UTC
- parent
- 2a1a401
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +126 -4
M internal/ports/recovery.go +3 -0
| @@ -99,6 +99,9 @@ type ReconcileDecision struct { | |||
| 99 | 99 | Name string | |
| 100 | 100 | Candidate *domain.CheckpointRef | |
| 101 | 101 | ReasonCode string | |
| 102 | + | // RetentionResolved permits collection after a conclusive adoption or | |
| 103 | + | // not-forward decision. Its zero value conservatively pins known sessions. | |
| 104 | + | RetentionResolved bool | |
| 102 | 105 | } | |
| 103 | 106 | ||
| 104 | 107 | var ( | |
| @@ -99,6 +99,9 @@ type ReconcileDecision struct { | |||||
| 99 | Name string | 99 | Name string | ||
| 100 | Candidate *domain.CheckpointRef | 100 | Candidate *domain.CheckpointRef | ||
| 101 | ReasonCode string | 101 | ReasonCode string | ||
| 102 | + | // RetentionResolved permits collection after a conclusive adoption or | |||
| 103 | + | // not-forward decision. Its zero value conservatively pins known sessions. | |||
| 104 | + | RetentionResolved bool | |||
| 102 | } | 105 | } | ||
| 103 | 106 | ||||
| 104 | var ( | 107 | var ( | ||
M internal/usecase/daemon/maintenance.go +1 -1
| @@ -116,7 +116,7 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) { | |||
| 116 | 116 | deps.cursor = next | |
| 117 | 117 | for _, decision := range decisions { | |
| 118 | 118 | record, ok := deps.catalogue.Record(decision.Name) | |
| 119 | - | if ok && decision.Kind != ports.ReconcileAdopt { | |
| 119 | + | if ok && !decision.RetentionResolved { | |
| 120 | 120 | deps.repairUncertain[record.IncarnationID] = struct{}{} | |
| 121 | 121 | } | |
| 122 | 122 | d.log.Info("durable_reconciliation_decision", | |
| @@ -116,7 +116,7 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) { | |||||
| 116 | deps.cursor = next | 116 | deps.cursor = next | ||
| 117 | for _, decision := range decisions { | 117 | for _, decision := range decisions { | ||
| 118 | record, ok := deps.catalogue.Record(decision.Name) | 118 | record, ok := deps.catalogue.Record(decision.Name) | ||
| 119 | - | if ok && decision.Kind != ports.ReconcileAdopt { | 119 | + | if ok && !decision.RetentionResolved { |
| 120 | deps.repairUncertain[record.IncarnationID] = struct{}{} | 120 | deps.repairUncertain[record.IncarnationID] = struct{}{} | ||
| 121 | } | 121 | } | ||
| 122 | d.log.Info("durable_reconciliation_decision", | 122 | d.log.Info("durable_reconciliation_decision", | ||
M internal/usecase/daemon/maintenance_test.go +114 -2
| @@ -2,12 +2,15 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 11 | + | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 10 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 11 | 14 | ) | |
| 12 | 15 | ||
| 13 | 16 | type cursorMaintenanceReconciler struct { | |
| @@ -36,8 +39,117 @@ func TestProductionMaintenanceUsesCatalogueAndResumesCursor(t *testing.T) { | |||
| 36 | 39 | ||
| 37 | 40 | d.runDurableMaintenanceTick(context.Background()) | |
| 38 | 41 | d.runDurableMaintenanceTick(context.Background()) | |
| 39 | - | if len(reconciler.seen) != 2 || reconciler.seen[0].DirectoryCookie != 0 || reconciler.seen[1].DirectoryCookie != 7 { | |
| 40 | - | t.Fatalf("reconciliation cursors = %v, want [0 7]", reconciler.seen) | |
| 42 | + | d.runDurableMaintenanceTick(context.Background()) | |
| 43 | + | if len(reconciler.seen) != 3 || reconciler.seen[0].DirectoryCookie != 0 || reconciler.seen[1].DirectoryCookie != 7 || reconciler.seen[2].DirectoryCookie != 0 { | |
| 44 | + | t.Fatalf("reconciliation cursors = %v, want [0 7 0]", reconciler.seen) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type reconciliationMaintenanceRepository struct { | |
| 49 | + | ports.SnapshotRepository | |
| 50 | + | findings []ports.ReconcileFinding | |
| 51 | + | err error | |
| 52 | + | plans []ports.RetentionPlan | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (r *reconciliationMaintenanceRepository) Reconcile(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) { | |
| 56 | + | return ports.ReconcileCursor{}, r.findings, r.err | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (r *reconciliationMaintenanceRepository) MaintainSession(_ context.Context, plan ports.RetentionPlan, _ ports.MaintenanceBudget) (bool, error) { | |
| 60 | + | r.plans = append(r.plans, plan) | |
| 61 | + | return true, nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |
| 65 | + | committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}} | |
| 66 | + | fallback1 := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | |
| 67 | + | fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |
| 68 | + | record := domain.CatalogueRecord{ | |
| 69 | + | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | |
| 70 | + | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | |
| 71 | + | } | |
| 72 | + | wrongParent := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{9}} | |
| 73 | + | ||
| 74 | + | for _, tc := range []struct { | |
| 75 | + | name string | |
| 76 | + | finding ports.ReconcileFinding | |
| 77 | + | reconcileErr error | |
| 78 | + | wantPlan *ports.RetentionPlan | |
| 79 | + | }{ | |
| 80 | + | { | |
| 81 | + | name: "current HEAD is conclusively not forward", | |
| 82 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |
| 83 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: committed, Parent: &fallback1, | |
| 84 | + | }}, | |
| 85 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |
| 86 | + | }, | |
| 87 | + | { | |
| 88 | + | name: "known predecessor is conclusively not forward", | |
| 89 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |
| 90 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: fallback1, Parent: &fallback2, | |
| 91 | + | }}, | |
| 92 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "non-forward candidate kind does not pin", | |
| 96 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileInvalidCandidate, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |
| 97 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, | |
| 98 | + | }}, | |
| 99 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | name: "unreadable candidate remains pinned", | |
| 103 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileInvalidCandidate, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{ | |
| 104 | + | Name: record.Name, IncarnationID: record.IncarnationID, | |
| 105 | + | }}, | |
| 106 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |
| 107 | + | }, | |
| 108 | + | { | |
| 109 | + | name: "quarantined forward candidate remains pinned", | |
| 110 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{ | |
| 111 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, Parent: &wrongParent, | |
| 112 | + | }}, | |
| 113 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |
| 114 | + | }, | |
| 115 | + | { | |
| 116 | + | name: "incarnation mismatch remains pinned", | |
| 117 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |
| 118 | + | Name: record.Name, IncarnationID: domain.IncarnationID{9}, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, Parent: &committed, | |
| 119 | + | }}, | |
| 120 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |
| 121 | + | }, | |
| 122 | + | { | |
| 123 | + | name: "incomplete decision remains pinned", | |
| 124 | + | finding: ports.ReconcileFinding{Status: ports.ReconcileBudgetExhausted, Candidate: ports.ReconcileCandidate{ | |
| 125 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, | |
| 126 | + | }}, | |
| 127 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |
| 128 | + | }, | |
| 129 | + | { | |
| 130 | + | name: "reconciliation error withholds maintenance", | |
| 131 | + | reconcileErr: errors.New("scan failed"), | |
| 132 | + | }, | |
| 133 | + | } { | |
| 134 | + | t.Run(tc.name, func(t *testing.T) { | |
| 135 | + | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |
| 136 | + | repository := &reconciliationMaintenanceRepository{err: tc.reconcileErr} | |
| 137 | + | if tc.finding.Candidate.Name != "" { | |
| 138 | + | repository.findings = []ports.ReconcileFinding{tc.finding} | |
| 139 | + | } | |
| 140 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil) | |
| 141 | + | reconciler := recoveryusecase.NewReconciler(coordinator, catalogue, repository, ports.MaintenanceBudget{Entries: 8, Bytes: 1024}) | |
| 142 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 143 | + | WithDurableMaintenance(catalogue, repository, reconciler, nil)(d) | |
| 144 | + | ||
| 145 | + | d.runDurableMaintenanceTick(context.Background()) | |
| 146 | + | ||
| 147 | + | if tc.wantPlan == nil { | |
| 148 | + | require.Empty(t, repository.plans) | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | require.Equal(t, []ports.RetentionPlan{*tc.wantPlan}, repository.plans) | |
| 152 | + | }) | |
| 41 | 153 | } | |
| 42 | 154 | } | |
| 43 | 155 | ||
| @@ -2,12 +2,15 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 10 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 11 | + | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |||
| 10 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 11 | ) | 14 | ) | ||
| 12 | 15 | ||||
| 13 | type cursorMaintenanceReconciler struct { | 16 | type cursorMaintenanceReconciler struct { | ||
| @@ -36,8 +39,117 @@ func TestProductionMaintenanceUsesCatalogueAndResumesCursor(t *testing.T) { | |||||
| 36 | 39 | ||||
| 37 | d.runDurableMaintenanceTick(context.Background()) | 40 | d.runDurableMaintenanceTick(context.Background()) | ||
| 38 | d.runDurableMaintenanceTick(context.Background()) | 41 | d.runDurableMaintenanceTick(context.Background()) | ||
| 39 | - | if len(reconciler.seen) != 2 || reconciler.seen[0].DirectoryCookie != 0 || reconciler.seen[1].DirectoryCookie != 7 { | 42 | + | d.runDurableMaintenanceTick(context.Background()) |
| 40 | - | t.Fatalf("reconciliation cursors = %v, want [0 7]", reconciler.seen) | 43 | + | if len(reconciler.seen) != 3 || reconciler.seen[0].DirectoryCookie != 0 || reconciler.seen[1].DirectoryCookie != 7 || reconciler.seen[2].DirectoryCookie != 0 { |
| 44 | + | t.Fatalf("reconciliation cursors = %v, want [0 7 0]", reconciler.seen) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type reconciliationMaintenanceRepository struct { | |||
| 49 | + | ports.SnapshotRepository | |||
| 50 | + | findings []ports.ReconcileFinding | |||
| 51 | + | err error | |||
| 52 | + | plans []ports.RetentionPlan | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (r *reconciliationMaintenanceRepository) Reconcile(context.Context, []domain.CatalogueRecord, ports.ReconcileCursor, ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) { | |||
| 56 | + | return ports.ReconcileCursor{}, r.findings, r.err | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (r *reconciliationMaintenanceRepository) MaintainSession(_ context.Context, plan ports.RetentionPlan, _ ports.MaintenanceBudget) (bool, error) { | |||
| 60 | + | r.plans = append(r.plans, plan) | |||
| 61 | + | return true, nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestMaintenanceRetentionUsesReconciliationOutcome(t *testing.T) { | |||
| 65 | + | committed := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}} | |||
| 66 | + | fallback1 := domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | |||
| 67 | + | fallback2 := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |||
| 68 | + | record := domain.CatalogueRecord{ | |||
| 69 | + | Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy, | |||
| 70 | + | Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2}, | |||
| 71 | + | } | |||
| 72 | + | wrongParent := domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{9}} | |||
| 73 | + | ||||
| 74 | + | for _, tc := range []struct { | |||
| 75 | + | name string | |||
| 76 | + | finding ports.ReconcileFinding | |||
| 77 | + | reconcileErr error | |||
| 78 | + | wantPlan *ports.RetentionPlan | |||
| 79 | + | }{ | |||
| 80 | + | { | |||
| 81 | + | name: "current HEAD is conclusively not forward", | |||
| 82 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |||
| 83 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: committed, Parent: &fallback1, | |||
| 84 | + | }}, | |||
| 85 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |||
| 86 | + | }, | |||
| 87 | + | { | |||
| 88 | + | name: "known predecessor is conclusively not forward", | |||
| 89 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |||
| 90 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: fallback1, Parent: &fallback2, | |||
| 91 | + | }}, | |||
| 92 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "non-forward candidate kind does not pin", | |||
| 96 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileInvalidCandidate, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |||
| 97 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, | |||
| 98 | + | }}, | |||
| 99 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, Keep: []ports.CheckpointRef{committed, fallback1, fallback2}}, | |||
| 100 | + | }, | |||
| 101 | + | { | |||
| 102 | + | name: "unreadable candidate remains pinned", | |||
| 103 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileInvalidCandidate, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{ | |||
| 104 | + | Name: record.Name, IncarnationID: record.IncarnationID, | |||
| 105 | + | }}, | |||
| 106 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "quarantined forward candidate remains pinned", | |||
| 110 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileQuarantined, Candidate: ports.ReconcileCandidate{ | |||
| 111 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, Parent: &wrongParent, | |||
| 112 | + | }}, | |||
| 113 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |||
| 114 | + | }, | |||
| 115 | + | { | |||
| 116 | + | name: "incarnation mismatch remains pinned", | |||
| 117 | + | finding: ports.ReconcileFinding{Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: ports.ReconcileCandidate{ | |||
| 118 | + | Name: record.Name, IncarnationID: domain.IncarnationID{9}, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, Parent: &committed, | |||
| 119 | + | }}, | |||
| 120 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "incomplete decision remains pinned", | |||
| 124 | + | finding: ports.ReconcileFinding{Status: ports.ReconcileBudgetExhausted, Candidate: ports.ReconcileCandidate{ | |||
| 125 | + | Name: record.Name, IncarnationID: record.IncarnationID, Ref: domain.CheckpointRef{Generation: 4, ManifestDigest: [32]byte{4}}, | |||
| 126 | + | }}, | |||
| 127 | + | wantPlan: &ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, | |||
| 128 | + | }, | |||
| 129 | + | { | |||
| 130 | + | name: "reconciliation error withholds maintenance", | |||
| 131 | + | reconcileErr: errors.New("scan failed"), | |||
| 132 | + | }, | |||
| 133 | + | } { | |||
| 134 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 135 | + | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |||
| 136 | + | repository := &reconciliationMaintenanceRepository{err: tc.reconcileErr} | |||
| 137 | + | if tc.finding.Candidate.Name != "" { | |||
| 138 | + | repository.findings = []ports.ReconcileFinding{tc.finding} | |||
| 139 | + | } | |||
| 140 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil) | |||
| 141 | + | reconciler := recoveryusecase.NewReconciler(coordinator, catalogue, repository, ports.MaintenanceBudget{Entries: 8, Bytes: 1024}) | |||
| 142 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 143 | + | WithDurableMaintenance(catalogue, repository, reconciler, nil)(d) | |||
| 144 | + | ||||
| 145 | + | d.runDurableMaintenanceTick(context.Background()) | |||
| 146 | + | ||||
| 147 | + | if tc.wantPlan == nil { | |||
| 148 | + | require.Empty(t, repository.plans) | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | require.Equal(t, []ports.RetentionPlan{*tc.wantPlan}, repository.plans) | |||
| 152 | + | }) | |||
| 41 | } | 153 | } | ||
| 42 | } | 154 | } | ||
| 43 | 155 | ||||
M internal/usecase/recovery/reconcile.go +8 -1
| @@ -72,9 +72,15 @@ func classifyFinding(record domain.CatalogueRecord, finding ports.ReconcileFindi | |||
| 72 | 72 | decision.ReasonCode = "incarnation-mismatch" | |
| 73 | 73 | return decision | |
| 74 | 74 | } | |
| 75 | - | if finding.Kind != ports.ReconcileForwardOrphan || record.Committed == nil || finding.Candidate.Ref.Generation <= record.Committed.Generation { | |
| 75 | + | if finding.Status == ports.ReconcileValidated && record.Committed != nil && (finding.Kind != ports.ReconcileForwardOrphan || finding.Candidate.Ref.Generation <= record.Committed.Generation) { | |
| 76 | 76 | decision.Kind = ports.ReconcileKeepQuarantined | |
| 77 | 77 | decision.ReasonCode = "not-forward" | |
| 78 | + | decision.RetentionResolved = true | |
| 79 | + | return decision | |
| 80 | + | } | |
| 81 | + | if finding.Kind != ports.ReconcileForwardOrphan || record.Committed == nil { | |
| 82 | + | decision.Kind = ports.ReconcileKeepQuarantined | |
| 83 | + | decision.ReasonCode = "invalid-candidate" | |
| 78 | 84 | return decision | |
| 79 | 85 | } | |
| 80 | 86 | if finding.Status != ports.ReconcileValidated || !validForwardChain(*record.Committed, finding.Candidate, finding.AncestorChain) { | |
| @@ -83,6 +89,7 @@ func classifyFinding(record domain.CatalogueRecord, finding ports.ReconcileFindi | |||
| 83 | 89 | return decision | |
| 84 | 90 | } | |
| 85 | 91 | decision.Kind = ports.ReconcileAdopt | |
| 92 | + | decision.RetentionResolved = true | |
| 86 | 93 | return decision | |
| 87 | 94 | } | |
| 88 | 95 | ||
| @@ -72,9 +72,15 @@ func classifyFinding(record domain.CatalogueRecord, finding ports.ReconcileFindi | |||||
| 72 | decision.ReasonCode = "incarnation-mismatch" | 72 | decision.ReasonCode = "incarnation-mismatch" | ||
| 73 | return decision | 73 | return decision | ||
| 74 | } | 74 | } | ||
| 75 | - | if finding.Kind != ports.ReconcileForwardOrphan || record.Committed == nil || finding.Candidate.Ref.Generation <= record.Committed.Generation { | 75 | + | if finding.Status == ports.ReconcileValidated && record.Committed != nil && (finding.Kind != ports.ReconcileForwardOrphan || finding.Candidate.Ref.Generation <= record.Committed.Generation) { |
| 76 | decision.Kind = ports.ReconcileKeepQuarantined | 76 | decision.Kind = ports.ReconcileKeepQuarantined | ||
| 77 | decision.ReasonCode = "not-forward" | 77 | decision.ReasonCode = "not-forward" | ||
| 78 | + | decision.RetentionResolved = true | |||
| 79 | + | return decision | |||
| 80 | + | } | |||
| 81 | + | if finding.Kind != ports.ReconcileForwardOrphan || record.Committed == nil { | |||
| 82 | + | decision.Kind = ports.ReconcileKeepQuarantined | |||
| 83 | + | decision.ReasonCode = "invalid-candidate" | |||
| 78 | return decision | 84 | return decision | ||
| 79 | } | 85 | } | ||
| 80 | if finding.Status != ports.ReconcileValidated || !validForwardChain(*record.Committed, finding.Candidate, finding.AncestorChain) { | 86 | if finding.Status != ports.ReconcileValidated || !validForwardChain(*record.Committed, finding.Candidate, finding.AncestorChain) { | ||
| @@ -83,6 +89,7 @@ func classifyFinding(record domain.CatalogueRecord, finding ports.ReconcileFindi | |||||
| 83 | return decision | 89 | return decision | ||
| 84 | } | 90 | } | ||
| 85 | decision.Kind = ports.ReconcileAdopt | 91 | decision.Kind = ports.ReconcileAdopt | ||
| 92 | + | decision.RetentionResolved = true | |||
| 86 | return decision | 93 | return decision | ||
| 87 | } | 94 | } | ||
| 88 | 95 | ||||