Skip to content

Commit 1d4b5e5

Browse files
Fix FBGD stale-state bugs on aborted transitions (#3)
## TL;DR Fixes three bugs in `FlinkBlueGreenDeployment` that compound to leave a transition silently restoring from a stale savepoint: | # | Bug | Fix | |---|-----|-----| | 1 | `savepointTriggerId` not cleared on abort → next deploy stuck FAILING+IGNORE | Clear it in `abortDeployment` (mirrors upstream [a00bf27](apache@a00bf27)) | | 2 | `setLastReconciledSpec` runs before `startTransition` → failed spec marked reconciled, retries no-op | Stamp only on success (mirrors upstream [apache#1072](apache#1072)) | | 3 | Suspended child carries stale `status.upgradeSavepointPath` → JM restores from old savepoint on retry | Delete the failed child in `abortDeployment` so the next transition fresh-creates it | Verified in unit test and end-to-end on `data-stg` — see [Validation](#Staging-validation-on-data-stg) below. ## Issue 1 — `savepointTriggerId` not cleared on abort `abortDeployment` left `status.savepointTriggerId` in place. The next reconcile tried to re-fetch the trigger from the active side's JM, which had TTL-evicted it (~300s), threw `Could not fetch savepoint with triggerId: X`, and left FBGD stuck in FAILING + IGNORE. Required a `restartNonce` bump to escape. ## Issue 2 — `setLastReconciledSpec` stamped before `startTransition` returns In `checkAndInitiateDeployment`, `setLastReconciledSpec` ran *before* `startTransition`. If `startTransition` threw (e.g., from Issue 1), the failed spec was already marked reconciled, so the next reconcile saw `specDiff = IGNORE` and silently did nothing until another spec change came in. ## Issue 3 — Stale `status.upgradeSavepointPath` shadows fresh `spec.initialSavepointPath` `AbstractJobReconciler.restoreJob()` reads `status.jobStatus.upgradeSavepointPath`, not `spec.initialSavepointPath`, in the `terminal && !isHaMetadataAvailable` upgrade branch. A suspended child retained its stale `upgradeSavepointPath` from the previous failed deploy. On retry, FBGD wrote a fresh `spec.initialSavepointPath`, but `createOrReplace` does not touch the status subresource — so the stale status value won and the JM restored from an arbitrarily old savepoint while the transition appeared successful. Upstream [apache#1073](apache#1073) does NOT fix this: its `!isHaMetadataAvailable || isJmAccessible` gate is satisfied with empty HA, so the operator still routes through the stale-status path. **Fix**: `abortDeployment` now deletes the failed child instead of suspending it. The next transition fresh-creates the child via `createOrReplace`'s POST path, with no status to be stale. Falls back to `suspendFlinkDeployment` if the delete call fails, to avoid an inconsistent state. ## Tests - `verifyFailedChildDeletedOnAbort` (new): forces a failed transition, asserts the failed child is gone, then asserts the retry fresh-creates GREEN with the fresh savepoint in spec and no carry-over status. - `verifyFailureDuringTransition` / `verifyFailureBeforeFirstDeployment`: updated to assert the failed child is absent (not SUSPENDED) after abort. - `verifySavepointFetchFailureRecovery`: asserts the failed spec is NOT present in `lastReconciledSpec` (Issue 2). ## Test plan - [x] `FlinkBlueGreenDeploymentControllerTest` → 60/60 pass - [x] `*ControllerTest` → 148/148 pass (no regressions in FlinkDeployment / SessionJob / StateSnapshot) - [x] Deployed to `data-stg`; forced abort + retry on `beamperfk8s1`; verified all three fixes ([details below](#Staging-validation-on-data-stg)) ## Staging validation on data-stg Forced a failed transition on `beamperfk8s1` with a bad image tag, then retried with a good image. | Time (UTC) | Event | |------------|-------| | 15:08:40 | First transition: triggerId `5d75a09efb99...`, savepoint `6966a02076a5` → new GREEN | | 15:09:42 | `Aborting deployment 'beamperfk8s1-green', rolling B/G deployment back to ACTIVE_BLUE` | | 15:09:44 | GREEN audit: `Status[Job] \| Error \| DELETED \| ErrImagePull` | | 15:11:40 | Retry: NEW triggerId `5f1d597953fe...` (different from first) | | 15:11:55 | Fresh savepoint `b0d173acab2e` → fresh GREEN | | 15:12:56 | GREEN JM log: `Starting job ... from savepoint .../savepoint-4d93e8-b0d173acab2e` | | 15:13:23 | Old BLUE deleted (normal post-transition cleanup) | - **1**: retry used a new triggerId, not the TTL-evicted first one - **2**: retry transition actually ran (would have been IGNORE'd if the failed spec had been stamped) - **3**: failed GREEN was DELETED (not suspended); fresh GREEN's JM restored from the fresh `b0d173acab2e`, not the prior attempt's `6966a02076a5` ## Production incident this resolves May 7 `demandingestion`: a transient deploy failure left stale `savepointTriggerId` and `status.upgradeSavepointPath` on a suspended GREEN; the failed spec was stamped as reconciled, freezing retries; when the trigger TTL evicted and a deploy went through, the JM silently restored from a 9-day-old savepoint. Full RCA: [Deploy Failure Investigation](https://docs.google.com/document/d/1rwN2TABe-3VtkHWBttLbLlW_P6Q-TMs0cuAs6fd-cQA/edit). --------- Co-authored-by: jennifer-xiong25 <jennifer.xiong@shopify.com>
1 parent eb99c5a commit 1d4b5e5

2 files changed

Lines changed: 131 additions & 16 deletions

File tree

flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/controller/bluegreen/BlueGreenDeploymentService.java

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,18 @@ public UpdateControl<FlinkBlueGreenDeployment> checkAndInitiateDeployment(
134134
.rescheduleAfter(getReconciliationReschedInterval(context));
135135
}
136136

137-
setLastReconciledSpec(context);
138137
try {
139-
return startTransition(
140-
context, currentBlueGreenDeploymentType, currentFlinkDeployment);
138+
var result =
139+
startTransition(
140+
context,
141+
currentBlueGreenDeploymentType,
142+
currentFlinkDeployment);
143+
// Only stamp lastReconciledSpec after the transition
144+
// succeeds. If stamped before and the transition
145+
// fails/aborts, lastReconciledSpec drifts from the
146+
// active child's actual spec
147+
setLastReconciledSpec(context);
148+
return result;
141149
} catch (Exception e) {
142150
var error = "Could not start Transition. Details: " + e.getMessage();
143151
context.getDeploymentStatus().setSavepointTriggerId(null);
@@ -551,11 +559,24 @@ private UpdateControl<FlinkBlueGreenDeployment> abortDeployment(
551559
FlinkBlueGreenDeploymentState nextState,
552560
String deploymentName) {
553561

554-
suspendFlinkDeployment(context, nextDeployment);
562+
// Delete the failed child rather than suspending it. Keeping a suspended FD around leaves
563+
// its status subresource (notably status.jobStatus.upgradeSavepointPath) on the cluster.
564+
// createOrReplace on the next transition writes a fresh spec but does not touch status,
565+
// so AbstractJobReconciler.restoreJob() would read the stale savepoint path and restore
566+
// the job from it. Deleting forces the next transition to use the first-deployment code
567+
// path, which reads spec.initialSavepointPath correctly.
568+
boolean deleted = deleteFlinkDeployment(nextDeployment, context);
569+
if (!deleted) {
570+
LOG.warn(
571+
"Failed to delete child '{}' during abort; falling back to suspend",
572+
deploymentName);
573+
suspendFlinkDeployment(context, nextDeployment);
574+
}
555575

556576
FlinkBlueGreenDeploymentState previousState =
557577
getPreviousState(nextState, context.getDeployments());
558578
context.getDeploymentStatus().setBlueGreenState(previousState);
579+
context.getDeploymentStatus().setSavepointTriggerId(null);
559580

560581
var error =
561582
String.format(

flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/controller/FlinkBlueGreenDeploymentControllerTest.java

Lines changed: 106 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -297,25 +297,24 @@ public void verifyFailureDuringTransition(FlinkVersion flinkVersion) throws Exce
297297

298298
assertTrue(rs.updateControl.isPatchStatus());
299299

300-
// The first job should be RUNNING, the second should be SUSPENDED
301300
assertFailingJobStatus(rs);
302301
// No longer TRANSITIONING_TO_GREEN and rolled back to ACTIVE_BLUE
303302
assertEquals(
304303
FlinkBlueGreenDeploymentState.ACTIVE_BLUE, rs.reconciledStatus.getBlueGreenState());
304+
// The failed child is deleted on abort (instead of suspended), so only the active
305+
// BLUE deployment should remain. Deletion prevents stale status.upgradeSavepointPath
306+
// on the failed child from being read by the next transition's restoreJob().
305307
var flinkDeployments = getFlinkDeployments();
306-
assertEquals(2, flinkDeployments.size());
308+
assertEquals(1, flinkDeployments.size());
307309
assertEquals(
308310
JobStatus.RUNNING, flinkDeployments.get(0).getStatus().getJobStatus().getState());
309311
assertEquals(
310312
ReconciliationState.DEPLOYED,
311313
flinkDeployments.get(0).getStatus().getReconciliationStatus().getState());
312-
// The B/G controller changes the State = SUSPENDED, the actual suspension is done by the
313-
// FlinkDeploymentController
314-
assertEquals(JobState.SUSPENDED, flinkDeployments.get(1).getSpec().getJob().getState());
315-
assertEquals(
316-
ReconciliationState.UPGRADING,
317-
flinkDeployments.get(1).getStatus().getReconciliationStatus().getState());
318314
assertTrue(instantStrToMillis(rs.reconciledStatus.getAbortTimestamp()) > 0);
315+
// savepointTriggerId must be cleared on abort so the next transition
316+
// triggers a fresh savepoint instead of reusing a stale triggerId
317+
assertNull(rs.reconciledStatus.getSavepointTriggerId());
319318

320319
// Simulate another change in the spec to trigger a redeployment
321320
customValue = UUID.randomUUID().toString();
@@ -325,6 +324,96 @@ public void verifyFailureDuringTransition(FlinkVersion flinkVersion) throws Exce
325324
testTransitionToGreen(rs, customValue, null);
326325
}
327326

327+
/**
328+
* abortDeployment deletes the failed child FD instead of suspending it. Keeping a suspended
329+
* child around leaves its status subresource (notably status.jobStatus.upgradeSavepointPath)
330+
* intact, which AbstractJobReconciler.restoreJob() then reads on the next transition and uses
331+
* to restore the job — silently shadowing the fresh spec.initialSavepointPath that FBGD writes.
332+
*
333+
* <p>This test asserts that after an aborted transition the failed child is gone, and the next
334+
* transition fresh-creates it with the new savepoint.
335+
*/
336+
@ParameterizedTest
337+
@MethodSource("org.apache.flink.kubernetes.operator.TestUtils#flinkVersions")
338+
public void verifyFailedChildDeletedOnAbort(FlinkVersion flinkVersion) throws Exception {
339+
var blueGreenDeployment =
340+
buildSessionCluster(
341+
TEST_DEPLOYMENT_NAME,
342+
TEST_NAMESPACE,
343+
flinkVersion,
344+
null,
345+
UpgradeMode.SAVEPOINT);
346+
347+
var abortGracePeriodMs = 1200;
348+
var reschedulingIntervalMs = 3000;
349+
blueGreenDeployment
350+
.getSpec()
351+
.getConfiguration()
352+
.put(ABORT_GRACE_PERIOD.key(), String.valueOf(abortGracePeriodMs));
353+
blueGreenDeployment
354+
.getSpec()
355+
.getConfiguration()
356+
.put(
357+
RECONCILIATION_RESCHEDULING_INTERVAL.key(),
358+
String.valueOf(reschedulingIntervalMs));
359+
360+
// 1. Initial deploy → ACTIVE_BLUE
361+
var rs = executeBasicDeployment(flinkVersion, blueGreenDeployment, false, null);
362+
String blueName = getFlinkDeployments().get(0).getMetadata().getName();
363+
364+
// 2. Spec change → savepoint → start transition to GREEN
365+
simulateChangeInSpec(rs.deployment, UUID.randomUUID().toString(), 0, null);
366+
rs = handleSavepoint(rs);
367+
rs = reconcile(rs.deployment);
368+
assertEquals(
369+
FlinkBlueGreenDeploymentState.TRANSITIONING_TO_GREEN,
370+
rs.reconciledStatus.getBlueGreenState());
371+
assertEquals(2, getFlinkDeployments().size());
372+
373+
// 3. GREEN never becomes ready → abort
374+
Long reschedDelayMs = 0L;
375+
for (int i = 0; i < 2; i++) {
376+
rs = reconcile(rs.deployment);
377+
reschedDelayMs = rs.updateControl.getScheduleDelay().get();
378+
}
379+
Thread.sleep(reschedDelayMs);
380+
rs = reconcile(rs.deployment);
381+
assertFailingJobStatus(rs);
382+
assertEquals(
383+
FlinkBlueGreenDeploymentState.ACTIVE_BLUE, rs.reconciledStatus.getBlueGreenState());
384+
385+
// 4. The failed child must be deleted (not suspended). Only BLUE remains.
386+
var remaining = getFlinkDeployments();
387+
assertEquals(1, remaining.size(), "failed child should be deleted on abort");
388+
assertEquals(blueName, remaining.get(0).getMetadata().getName());
389+
390+
// 5. Second transition: spec change → fresh savepoint → GREEN is fresh-created.
391+
simulateChangeInSpec(rs.deployment, UUID.randomUUID().toString(), 0, null);
392+
rs = handleSavepoint(rs);
393+
rs = reconcile(rs.deployment);
394+
assertEquals(
395+
FlinkBlueGreenDeploymentState.TRANSITIONING_TO_GREEN,
396+
rs.reconciledStatus.getBlueGreenState());
397+
398+
var greenAfterRetry =
399+
getFlinkDeployments().stream()
400+
.filter(d -> !d.getMetadata().getName().equals(blueName))
401+
.findFirst()
402+
.orElseThrow(
403+
() -> new AssertionError("GREEN should be fresh-created on retry"));
404+
// Freshly-created FD must start with no status, so restoreJob() falls into the first-
405+
// deployment branch and uses spec.initialSavepointPath rather than status.
406+
assertTrue(
407+
greenAfterRetry.getStatus() == null
408+
|| greenAfterRetry.getStatus().getJobStatus() == null
409+
|| greenAfterRetry.getStatus().getJobStatus().getUpgradeSavepointPath()
410+
== null,
411+
"freshly created GREEN must not carry status.upgradeSavepointPath");
412+
assertNotNull(
413+
greenAfterRetry.getSpec().getJob().getInitialSavepointPath(),
414+
"GREEN must have a fresh initialSavepointPath in spec");
415+
}
416+
328417
private static String getFlinkConfigurationValue(
329418
FlinkDeploymentSpec flinkDeploymentSpec, String propertyName) {
330419
return flinkDeploymentSpec.getFlinkConfiguration().get(propertyName).asText();
@@ -389,11 +478,9 @@ public void verifyFailureBeforeFirstDeployment(FlinkVersion flinkVersion) throws
389478
assertEquals(
390479
FlinkBlueGreenDeploymentState.INITIALIZING_BLUE,
391480
rs.reconciledStatus.getBlueGreenState());
481+
// The failed child is deleted on abort.
392482
var flinkDeployments = getFlinkDeployments();
393-
assertEquals(1, flinkDeployments.size());
394-
// The B/G controller changes the State = SUSPENDED, the actual suspension is done by the
395-
// FlinkDeploymentController
396-
assertEquals(JobState.SUSPENDED, flinkDeployments.get(0).getSpec().getJob().getState());
483+
assertEquals(0, flinkDeployments.size());
397484

398485
// No-op if the spec remains the same
399486
rs = reconcile(rs.deployment);
@@ -537,6 +624,13 @@ public void verifySavepointFetchFailureRecovery(FlinkVersion flinkVersion) throw
537624
rs = reconcile(rs.deployment);
538625
assertFailingWithError(rs, "Could not start Transition", error);
539626

627+
// lastReconciledSpec must NOT contain the failed spec change — otherwise
628+
// subsequent deploys see no diff and fall into PATCH_CHILD (in-place
629+
// upgrade) instead of a proper B/G transition
630+
assertFalse(
631+
rs.reconciledStatus.getLastReconciledSpec().contains(customValue),
632+
"lastReconciledSpec should not be stamped when transition fails");
633+
540634
// Recovery: Clear the fetch error and try again with new spec change
541635
flinkService.clearSavepointFetchError();
542636
customValue = UUID.randomUUID().toString() + "_recovery";

0 commit comments

Comments
 (0)