Skip to content

Commit 1858513

Browse files
fix(store): apply repairable cloud-upgrade mutations even when a blocker is queued (#446) (#452)
Previously RepairCloudUpgrade returned Applied:false and skipped all repairs when BlockedCount > 0, even if RepairableCount was also > 0. The message also always picked Findings[0] (lowest seq, often a repairable entry) instead of the first non-repairable finding, and omitted entity_key. Now the repairable subset is applied first inside the existing transaction path; the blocked report then carries Applied:true, accurate counts, and a message that scans Findings for the first non-repairable entry (with entity_key) so operators see the actual blocker.
1 parent 7fb528a commit 1858513

2 files changed

Lines changed: 193 additions & 18 deletions

File tree

internal/store/store.go

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,37 +1231,70 @@ func (s *Store) RepairCloudUpgrade(project string, apply bool) (CloudUpgradeRepa
12311231
if err != nil {
12321232
return CloudUpgradeRepairReport{}, fmt.Errorf("diagnose legacy cloud upgrade mutations: %w", err)
12331233
}
1234+
// When both repairable and blocked mutations coexist we must not hold the
1235+
// entire repair pass hostage to the non-repairable entries. Apply the
1236+
// repairable subset first, then surface the residual blockers.
1237+
appliedRepairs := false
1238+
if apply && legacyReport.RepairableCount > 0 {
1239+
if err := s.applyCloudUpgradeLegacyMutationRepairs(project); err != nil {
1240+
return CloudUpgradeRepairReport{}, fmt.Errorf("apply cloud upgrade legacy mutation repairs: %w", err)
1241+
}
1242+
appliedRepairs = true
1243+
}
1244+
12341245
if legacyReport.BlockedCount > 0 {
1235-
first := legacyReport.Findings[0]
1246+
// Scan for the first non-repairable finding so the operator sees the
1247+
// actual blocker. Findings[0] is ordered by seq and may be a repairable
1248+
// entry, which previously produced a misleading error message (#446).
1249+
var blocked CloudUpgradeLegacyMutationFinding
1250+
for _, f := range legacyReport.Findings {
1251+
if !f.Repairable {
1252+
blocked = f
1253+
break
1254+
}
1255+
}
1256+
var msg string
1257+
switch {
1258+
case appliedRepairs:
1259+
msg = fmt.Sprintf("applied %d repairable payload(s); %d remain blocked: manual-action-required: %s (seq=%d entity=%s entity_key=%q op=%s)",
1260+
legacyReport.RepairableCount, legacyReport.BlockedCount, blocked.Message, blocked.Seq, blocked.Entity, blocked.EntityKey, blocked.Op)
1261+
case legacyReport.RepairableCount > 0:
1262+
msg = fmt.Sprintf("%d repairable payload(s) would apply; %d would remain blocked: manual-action-required: %s (seq=%d entity=%s entity_key=%q op=%s)",
1263+
legacyReport.RepairableCount, legacyReport.BlockedCount, blocked.Message, blocked.Seq, blocked.Entity, blocked.EntityKey, blocked.Op)
1264+
default:
1265+
msg = fmt.Sprintf("manual-action-required: %s (seq=%d entity=%s entity_key=%q op=%s)",
1266+
blocked.Message, blocked.Seq, blocked.Entity, blocked.EntityKey, blocked.Op)
1267+
}
12361268
return CloudUpgradeRepairReport{
12371269
Class: UpgradeRepairClassBlocked,
12381270
ReasonCode: UpgradeReasonBlockedLegacyMutationManual,
1239-
Message: fmt.Sprintf("manual-action-required: %s (seq=%d entity=%s op=%s)", first.Message, first.Seq, first.Entity, first.Op),
1240-
Applied: false,
1271+
Message: msg,
1272+
Applied: appliedRepairs,
12411273
}, nil
12421274
}
1275+
12431276
if legacyReport.RepairableCount > 0 {
1244-
report := CloudUpgradeRepairReport{
1245-
Class: UpgradeRepairClassRepairable,
1246-
ReasonCode: UpgradeReasonRepairableLegacyMutationPayload,
1247-
Message: fmt.Sprintf("project %q has %d repairable legacy mutation payload issue(s)", project, legacyReport.RepairableCount),
1248-
PlannedAction: "repair_legacy_mutation_payloads",
1249-
Applied: false,
1250-
}
1251-
if !apply {
1252-
return report, nil
1253-
}
1254-
if err := s.applyCloudUpgradeLegacyMutationRepairs(project); err != nil {
1255-
return CloudUpgradeRepairReport{}, fmt.Errorf("apply cloud upgrade legacy mutation repairs: %w", err)
1277+
if !appliedRepairs {
1278+
return CloudUpgradeRepairReport{
1279+
Class: UpgradeRepairClassRepairable,
1280+
ReasonCode: UpgradeReasonRepairableLegacyMutationPayload,
1281+
Message: fmt.Sprintf("project %q has %d repairable legacy mutation payload issue(s)", project, legacyReport.RepairableCount),
1282+
PlannedAction: "repair_legacy_mutation_payloads",
1283+
Applied: false,
1284+
}, nil
12561285
}
1257-
report.Applied = true
1258-
report.Message = fmt.Sprintf("applied deterministic legacy mutation payload repairs for project %q", project)
12591286
_ = s.SaveCloudUpgradeState(CloudUpgradeState{
12601287
Project: project,
12611288
Stage: UpgradeStageRepairApplied,
12621289
RepairClass: UpgradeRepairClassRepairable,
12631290
})
1264-
return report, nil
1291+
return CloudUpgradeRepairReport{
1292+
Class: UpgradeRepairClassRepairable,
1293+
ReasonCode: UpgradeReasonRepairableLegacyMutationPayload,
1294+
Message: fmt.Sprintf("applied deterministic legacy mutation payload repairs for project %q", project),
1295+
PlannedAction: "repair_legacy_mutation_payloads",
1296+
Applied: true,
1297+
}, nil
12651298
}
12661299

12671300
requiresBackfill, err := s.projectSyncBackfillRequired(project)

internal/store/store_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2118,6 +2118,148 @@ func TestUpgradeRepairDryRunAndApply(t *testing.T) {
21182118
t.Fatalf("expected missing provenance to remain blocked, got %+v", diagnosis)
21192119
}
21202120
})
2121+
2122+
// Regression test for GitHub issue #446: when both repairable and blocked
2123+
// mutations coexist, RepairCloudUpgrade(apply=true) must apply the
2124+
// repairable subset and return Applied:true with Class=Blocked (and the
2125+
// message must reference the actual blocker, not the low-seq repairable
2126+
// entry that happens to be first in Findings order).
2127+
t.Run("partial apply: repairable mutations applied even when a blocker is queued", func(t *testing.T) {
2128+
s := newTestStore(t)
2129+
if err := s.CreateSession("partial-repair-s1", "partial-repair-proj", "/tmp/partial-repair"); err != nil {
2130+
t.Fatalf("create session: %v", err)
2131+
}
2132+
2133+
// Create an observation so we have authoritative local state for the
2134+
// repairable mutation.
2135+
obsID, err := s.AddObservation(AddObservationParams{
2136+
SessionID: "partial-repair-s1",
2137+
Type: "decision",
2138+
Title: "Authoritative title",
2139+
Content: "Authoritative content",
2140+
Project: "partial-repair-proj",
2141+
Scope: "project",
2142+
})
2143+
if err != nil {
2144+
t.Fatalf("add observation: %v", err)
2145+
}
2146+
if err := s.EnrollProject("partial-repair-proj"); err != nil {
2147+
t.Fatalf("enroll project: %v", err)
2148+
}
2149+
2150+
// Look up the observation's sync_id for payload construction.
2151+
var obsSyncID string
2152+
if err := s.db.QueryRow(`SELECT sync_id FROM observations WHERE id = ?`, obsID).Scan(&obsSyncID); err != nil {
2153+
t.Fatalf("lookup observation sync_id: %v", err)
2154+
}
2155+
2156+
// Insert a REPAIRABLE observation mutation (missing title — low seq,
2157+
// will naturally come before the blocker we insert next).
2158+
repairablePayload := `{"sync_id":"` + obsSyncID + `","session_id":"partial-repair-s1","type":"decision","content":"legacy payload missing title","scope":"project"}`
2159+
if _, err := s.execHook(s.db,
2160+
`INSERT INTO sync_mutations (target_key, entity, entity_key, op, payload, source, project) VALUES (?, ?, ?, ?, ?, ?, ?)`,
2161+
DefaultSyncTargetKey,
2162+
SyncEntityObservation,
2163+
obsSyncID,
2164+
SyncOpUpsert,
2165+
repairablePayload,
2166+
SyncSourceLocal,
2167+
"partial-repair-proj",
2168+
); err != nil {
2169+
t.Fatalf("insert repairable observation mutation: %v", err)
2170+
}
2171+
2172+
// Create two observations for source/target of the relation we use as
2173+
// the blocker mutation.
2174+
srcID, err := s.AddObservation(AddObservationParams{SessionID: "partial-repair-s1", Type: "decision", Title: "Src", Content: "src", Project: "partial-repair-proj", Scope: "project"})
2175+
if err != nil {
2176+
t.Fatalf("add source observation: %v", err)
2177+
}
2178+
dstID, err := s.AddObservation(AddObservationParams{SessionID: "partial-repair-s1", Type: "decision", Title: "Dst", Content: "dst", Project: "partial-repair-proj", Scope: "project"})
2179+
if err != nil {
2180+
t.Fatalf("add dest observation: %v", err)
2181+
}
2182+
var srcSyncID, dstSyncID string
2183+
if err := s.db.QueryRow(`SELECT sync_id FROM observations WHERE id = ?`, srcID).Scan(&srcSyncID); err != nil {
2184+
t.Fatalf("lookup src sync_id: %v", err)
2185+
}
2186+
if err := s.db.QueryRow(`SELECT sync_id FROM observations WHERE id = ?`, dstID).Scan(&dstSyncID); err != nil {
2187+
t.Fatalf("lookup dst sync_id: %v", err)
2188+
}
2189+
2190+
// Insert a BLOCKED relation mutation (missing provenance — high seq,
2191+
// comes after the repairable entry so Findings[0] would point to the
2192+
// repairable entry without the fix).
2193+
const blockerEntityKey = "rel-partial-blocked-446"
2194+
blockerPayload := `{"sync_id":"` + blockerEntityKey + `","source_id":"` + srcSyncID + `","target_id":"` + dstSyncID + `","relation":"compatible","project":"partial-repair-proj"}`
2195+
if _, err := s.execHook(s.db,
2196+
`INSERT INTO sync_mutations (target_key, entity, entity_key, op, payload, source, project) VALUES (?, ?, ?, ?, ?, ?, ?)`,
2197+
DefaultSyncTargetKey,
2198+
SyncEntityRelation,
2199+
blockerEntityKey,
2200+
SyncOpUpsert,
2201+
blockerPayload,
2202+
SyncSourceLocal,
2203+
"partial-repair-proj",
2204+
); err != nil {
2205+
t.Fatalf("insert blocked relation mutation: %v", err)
2206+
}
2207+
2208+
// Confirm pre-conditions: diagnosis must see exactly one repairable
2209+
// and one blocked entry.
2210+
diag, err := s.DiagnoseCloudUpgradeLegacyMutations("partial-repair-proj")
2211+
if err != nil {
2212+
t.Fatalf("pre-condition diagnose: %v", err)
2213+
}
2214+
if diag.RepairableCount != 1 || diag.BlockedCount != 1 {
2215+
t.Fatalf("expected 1 repairable + 1 blocked pre-condition, got %+v", diag)
2216+
}
2217+
2218+
// Find the seq of the blocked finding so we can assert the message
2219+
// references the right entry.
2220+
var blockerSeq int64
2221+
for _, f := range diag.Findings {
2222+
if !f.Repairable {
2223+
blockerSeq = f.Seq
2224+
break
2225+
}
2226+
}
2227+
if blockerSeq == 0 {
2228+
t.Fatal("pre-condition: could not locate blocked finding seq")
2229+
}
2230+
// Also verify that Findings[0] is NOT the blocker (i.e. lowest-seq is
2231+
// the repairable one); this is the exact condition that triggered the
2232+
// wrong-message bug.
2233+
if diag.Findings[0].Seq == blockerSeq {
2234+
t.Fatalf("pre-condition: expected Findings[0] to be repairable (lowest seq), but got blocker seq=%d", blockerSeq)
2235+
}
2236+
2237+
// === THE ACTUAL ASSERTION ===
2238+
// With apply=true, repairable mutations must be applied and the
2239+
// report must still surface the blocker clearly.
2240+
report, err := s.RepairCloudUpgrade("partial-repair-proj", true)
2241+
if err != nil {
2242+
t.Fatalf("repair with mixed repairable+blocker: %v", err)
2243+
}
2244+
2245+
// Applied MUST be true: at least the repairable mutation was applied.
2246+
if !report.Applied {
2247+
t.Fatalf("expected Applied=true (repairable subset was processed), got %+v", report)
2248+
}
2249+
// Class MUST be Blocked because the non-repairable mutation is still present.
2250+
if report.Class != UpgradeRepairClassBlocked {
2251+
t.Fatalf("expected Class=%q, got %q (full report: %+v)", UpgradeRepairClassBlocked, report.Class, report)
2252+
}
2253+
// The message must name the BLOCKER seq, not the repairable entry.
2254+
blockerSeqStr := fmt.Sprintf("seq=%d", blockerSeq)
2255+
if !strings.Contains(report.Message, blockerSeqStr) {
2256+
t.Fatalf("expected message to reference blocker seq (%s), got %q", blockerSeqStr, report.Message)
2257+
}
2258+
// The message must also include entity_key for debuggability.
2259+
if !strings.Contains(report.Message, blockerEntityKey) {
2260+
t.Fatalf("expected message to include entity_key=%q, got %q", blockerEntityKey, report.Message)
2261+
}
2262+
})
21212263
}
21222264

21232265
func TestRollbackCloudUpgradeSafetyBoundary(t *testing.T) {

0 commit comments

Comments
 (0)