Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.apache.flink.kubernetes.operator.api.spec.FlinkDeploymentSpec;
import org.apache.flink.kubernetes.operator.api.status.FlinkDeploymentStatus;
import org.apache.flink.kubernetes.operator.api.status.JobManagerDeploymentStatus;
import org.apache.flink.kubernetes.operator.api.status.ReconciliationState;
import org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions;
import org.apache.flink.kubernetes.operator.controller.FlinkResourceContext;
import org.apache.flink.kubernetes.operator.reconciler.ReconciliationUtils;
Expand All @@ -42,6 +43,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
Expand All @@ -64,9 +66,38 @@ public SessionReconciler(

@Override
protected boolean readyToReconcile(FlinkResourceContext<FlinkDeployment> ctx) {
var deployment = ctx.getResource();
// Only check when there is a pending spec change on the deployment itself
if (!Objects.equals(
deployment.getMetadata().getGeneration(),
deployment.getStatus().getObservedGeneration())) {
var deploymentName = deployment.getMetadata().getName();
boolean hasTransitioningSessionJob =
ctx.getJosdkContext().getSecondaryResources(FlinkSessionJob.class).stream()
.filter(job -> deploymentName.equals(job.getSpec().getDeploymentName()))
.anyMatch(SessionReconciler::isSessionJobTransitioning);
if (hasTransitioningSessionJob) {
LOG.info(
"Deferring session cluster upgrade: associated session jobs are being upgraded");
return false;
}
}
return true;
}

private static boolean isSessionJobTransitioning(FlinkSessionJob job) {
var reconStatus = job.getStatus().getReconciliationStatus();
// New session jobs state is UPGRADING by default before first deployment. Treating them as
// transitioning would deadlock: the deployment can't
// start because it waits for the job, and the job can't start because it waits for the
// cluster.
if (reconStatus.isBeforeFirstDeployment()) {
return false;
}
var state = reconStatus.getState();
return state == ReconciliationState.UPGRADING || state == ReconciliationState.ROLLING_BACK;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there any case where a session job may get stuck in upgrading/rolling_back state? This check is quite tricky as it may cause a "deadlock" situation with the session upgrade.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

That's a good point! I think there's a possibility when something goes wrong and session job can be stuck in upgrading/rolling_back state and basically meaning that until the job won't be fixed/deleted, the cluster can be stuck.

What is in your opinion is the better / safer way to fix this case?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The safer way is to never prevent session deployment updates even if a job is upgrading. We can prevent jobs while the session is upgrading but not the other way around. Simply remove the logic from the SessionReconciler

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tested this end-to-end against with the change scoped to SessionJobReconciler only, with both upgradeMode: stateless and upgradeMode: savepoint (HA + persistent checkpoint storage), and it behaves correctly, with no Connection refused / JobNotFoundException received. The savepoint falls back cleanly to last-state when needed. So keeping the support as part of SessionJobReconciler should be all good.

The asymmetry is mostly fine in practice: stateful jobs running with HA + checkpointing recover transparently via the savepoint -> last-state fallback. One case worth flagging though:

  • Stateful jobs without HA: In the rare ordering where a session-job upgrade is already in flight when the FlinkDeployment starts upgrading, last-state has nothing to fallback to and the job can end up parked in waiting for upgradeable state. Probably best treated as a documented prerequisite (HA + checkpointing for stateful session jobs) rather than something to gate the deployment side on. This case I can also address & document as part of FLINK-39571.

One small follow-up suggestion: it would help future readers if SessionReconciler#readyToReconcile will carry a short JavaDoc explaining why the FlinkDeployment side intentionally does not gate on FlinkSessionJob state as this asymmetry is deliberate (deployment updates must never be blocked by job upgrades, and the reverse coupling is the only one needed to close the race).

}

@Override
protected boolean reconcileSpecChange(
DiffType diffType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.apache.flink.kubernetes.operator.api.spec.JobState;
import org.apache.flink.kubernetes.operator.api.status.FlinkSessionJobStatus;
import org.apache.flink.kubernetes.operator.api.status.JobManagerDeploymentStatus;
import org.apache.flink.kubernetes.operator.api.status.ReconciliationState;
import org.apache.flink.kubernetes.operator.autoscaler.KubernetesJobAutoScalerContext;
import org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions;
import org.apache.flink.kubernetes.operator.controller.FlinkResourceContext;
Expand All @@ -43,6 +44,7 @@
import org.slf4j.LoggerFactory;

import java.time.Instant;
import java.util.Objects;
import java.util.Optional;

/** The reconciler for the {@link FlinkSessionJob}. */
Expand All @@ -60,8 +62,9 @@ public SessionJobReconciler(

@Override
public boolean readyToReconcile(FlinkResourceContext<FlinkSessionJob> ctx) {
return sessionClusterReady(
ctx.getJosdkContext().getSecondaryResource(FlinkDeployment.class))
var flinkDeploymentOpt = ctx.getJosdkContext().getSecondaryResource(FlinkDeployment.class);
return sessionClusterReady(flinkDeploymentOpt)
&& noDeploymentChangesPending(flinkDeploymentOpt)
&& super.readyToReconcile(ctx);
}

Expand Down Expand Up @@ -178,20 +181,59 @@ public DeleteControl cleanupInternal(FlinkResourceContext<FlinkSessionJob> ctx)
}

public static boolean sessionClusterReady(Optional<FlinkDeployment> flinkDeploymentOpt) {
if (flinkDeploymentOpt.isPresent()) {
var flinkdep = flinkDeploymentOpt.get();
var jobmanagerDeploymentStatus = flinkdep.getStatus().getJobManagerDeploymentStatus();
if (jobmanagerDeploymentStatus != JobManagerDeploymentStatus.READY) {
LOG.info(
"Session cluster deployment is in {} status, not ready for serve",
jobmanagerDeploymentStatus);
return false;
} else {
return true;
}
} else {
if (flinkDeploymentOpt.isEmpty()) {
LOG.warn("Session cluster deployment is not found");
return false;
}
var flinkdep = flinkDeploymentOpt.get();
var jobmanagerDeploymentStatus = flinkdep.getStatus().getJobManagerDeploymentStatus();
if (jobmanagerDeploymentStatus != JobManagerDeploymentStatus.READY) {
LOG.info(
"Session cluster deployment is in {} status, not ready for serve",
jobmanagerDeploymentStatus);
return false;
}

// Block while FlinkDeployment is in a transitional reconciliation state
var reconciliationState = flinkdep.getStatus().getReconciliationStatus().getState();
if (reconciliationState != ReconciliationState.DEPLOYED
&& reconciliationState != ReconciliationState.ROLLED_BACK) {
LOG.info(
"Session cluster deployment reconciliation state is {}, not ready for serve",
reconciliationState);
return false;
}

return true;
}

/**
* Checks that the FlinkDeployment has no pending spec changes
*
* <p>When they differ, the deployment spec was changed but the operator hasn't acted yet. The
* cluster may still appear healthy (JM READY, state DEPLOYED), but it is about to be updated
* (e.g. deleted and recreated). Allowing a session job to start upgrading in this window would
* risk the savepoint being destroyed during the cluster rebuild.
*
* <p>This check is only used in {@link #readyToReconcile}, not in {@link #sessionClusterReady},
* so it does not block cleanup or FlinkService creation.
*/
Comment on lines +210 to +220

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would suggest here to mention that this check is intended to cover a very early & incipient upgrade stage of the session cluster:

Suggested change
/**
* Checks that the FlinkDeployment has no pending spec changes
*
* <p>When they differ, the deployment spec was changed but the operator hasn't acted yet. The
* cluster may still appear healthy (JM READY, state DEPLOYED), but it is about to be updated
* (e.g. deleted and recreated). Allowing a session job to start upgrading in this window would
* risk the savepoint being destroyed during the cluster rebuild.
*
* <p>This check is only used in {@link #readyToReconcile}, not in {@link #sessionClusterReady},
* so it does not block cleanup or FlinkService creation.
*/
/**
* Checks that the FlinkDeployment has no pending spec changes, i.e. that {@code
* metadata.generation} equals {@code status.observedGeneration}.
*
* <p>This guard targets the earliest, most incipient stage of a session-cluster upgrade: the
* brief window between the user submitting a new FlinkDeployment spec and the
* FlinkDeploymentController observing it and starting to act on it. During that window the
* cluster still appears healthy from every other angle (JM READY, reconciliation state
* DEPLOYED), so {@link #sessionClusterReady} alone would let a session-job upgrade through.
* Allowing a savepoint upgrade to start here would risk the savepoint being destroyed during
* the cluster rebuild.
*
* <p>This check is only used in {@link #readyToReconcile}, not in {@link #sessionClusterReady},
* so it does not block cleanup of {@link
* org.apache.flink.kubernetes.operator.service.FlinkService} creation.
*/

private static boolean noDeploymentChangesPending(
Optional<FlinkDeployment> flinkDeploymentOpt) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor nit: replace Optional<FlinkDeployment> with a nullable FlinkDeployment parameter. Optional is meant for return values, not parameters (per Effective Java Item 55, and IntelliJ flags it).

if (flinkDeploymentOpt.isEmpty()) {
return false;
}
var flinkdep = flinkDeploymentOpt.get();
if (!Objects.equals(
flinkdep.getMetadata().getGeneration(),
flinkdep.getStatus().getObservedGeneration())) {
Comment on lines +228 to +229

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think it would be worth double checking the the observedGeneration is always correctly updated (even for ignore/non-upgrade changes such as labels/operator configs etc). Otherwise we could easily block session job deletion

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I checked this and for ignore/non-upgrade changes (labels/operator configs) I see that observedGeneration is updated as well.

LOG.info(
"Session cluster deployment has pending spec changes "
+ "(generation={}, observedGeneration={}), not ready for sync",
flinkdep.getMetadata().getGeneration(),
flinkdep.getStatus().getObservedGeneration());
return false;
}
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.apache.flink.kubernetes.operator.api.spec.JobReference;
import org.apache.flink.kubernetes.operator.api.spec.UpgradeMode;
import org.apache.flink.kubernetes.operator.api.status.JobManagerDeploymentStatus;
import org.apache.flink.kubernetes.operator.api.status.ReconciliationState;
import org.apache.flink.kubernetes.operator.api.utils.BaseTestUtils;
import org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions;
import org.apache.flink.kubernetes.operator.health.CanaryResourceManager;
Expand Down Expand Up @@ -226,6 +227,12 @@ public Optional<T> getSecondaryResource(Class expectedType, String eventSourceNa
session.getStatus()
.getReconciliationStatus()
.serializeAndSetLastReconciledSpec(session.getSpec(), session);
// ReconciliationStatus defaults to UPGRADING. Ready session
// cluster must be in DEPLOYED state, otherwise sessionClusterReady() will
// reject it due to the reconciliation state check.
session.getStatus()
.getReconciliationStatus()
.setState(ReconciliationState.DEPLOYED);
return (Optional<T>) Optional.of(session);
}

Expand Down Expand Up @@ -498,17 +505,28 @@ public Optional<RetryInfo> getRetryInfo() {
@Override
public <T1> Set<T1> getSecondaryResources(Class<T1> aClass) {
// TODO: improve this, even if we only support FlinkDeployment as a secondary resource
KubernetesClient client = getClient();
if (client == null) {
return Set.of();
}
if (aClass.getSimpleName().equals(FlinkDeployment.class.getSimpleName())) {
KubernetesClient client = getClient();
var hasMetadata =
new HashSet<>(
client.resources(FlinkDeployment.class)
.inAnyNamespace()
.list()
.getItems());
return (Set<T1>) hasMetadata;
} else if (aClass.getSimpleName().equals(FlinkSessionJob.class.getSimpleName())) {
var hasMetadata =
new HashSet<>(
client.resources(FlinkSessionJob.class)
.inAnyNamespace()
.list()
.getItems());
return (Set<T1>) hasMetadata;
} else {
return null;
return Set.of();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,90 @@ public void testGetNonTerminalJobs() throws Exception {
nonTerminalJobsAfterRemoval.size(),
"Should have no non-terminal jobs when only terminated jobs exist");
}

@Test
public void testUpgradeDeferredWhenSessionJobUpgrading() throws Exception {
FlinkDeployment deployment = TestUtils.buildSessionCluster();
reconciler.reconcile(deployment, flinkService.getContext());
assertEquals(
ReconciliationState.DEPLOYED,
deployment.getStatus().getReconciliationStatus().getState());

FlinkSessionJob upgradingJob = TestUtils.buildSessionJob();
upgradingJob
.getStatus()
.getReconciliationStatus()
.serializeAndSetLastReconciledSpec(upgradingJob.getSpec(), upgradingJob);
upgradingJob.getStatus().getReconciliationStatus().setState(ReconciliationState.UPGRADING);
kubernetesClient.resource(upgradingJob).createOrReplace();

deployment.getMetadata().setGeneration(2L);
deployment.getSpec().setRestartNonce(2L);

// Reconcile should be deferred because session job is upgrading
reconciler.reconcile(deployment, flinkService.getContext());

// Should still be DEPLOYED (not UPGRADING) — upgrade was deferred
assertEquals(
ReconciliationState.DEPLOYED,
deployment.getStatus().getReconciliationStatus().getState());
}

@Test
public void testUpgradeProceedsWhenSessionJobDeployed() throws Exception {
FlinkDeployment deployment = TestUtils.buildSessionCluster();

reconciler.reconcile(deployment, flinkService.getContext());
assertEquals(
ReconciliationState.DEPLOYED,
deployment.getStatus().getReconciliationStatus().getState());

FlinkSessionJob deployedJob = TestUtils.buildSessionJob();
deployedJob
.getStatus()
.getReconciliationStatus()
.serializeAndSetLastReconciledSpec(deployedJob.getSpec(), deployedJob);
deployedJob.getStatus().getReconciliationStatus().setState(ReconciliationState.DEPLOYED);
kubernetesClient.resource(deployedJob).createOrReplace();

// Simulate a pending spec change on the deployment
deployment.getMetadata().setGeneration(2L);
deployment.getSpec().setRestartNonce(456L);

// Reconcile should proceed because session job is not upgrading
reconciler.reconcile(deployment, flinkService.getContext());

// Should be DEPLOYED with the new spec (upgrade went through)
assertEquals(
ReconciliationState.DEPLOYED,
deployment.getStatus().getReconciliationStatus().getState());
assertEquals(
456L,
deployment
.getStatus()
.getReconciliationStatus()
.deserializeLastReconciledSpec()
.getRestartNonce());
}

@Test
public void testDeploymentCleanupWaitsForSessionJobs() throws Exception {
FlinkDeployment deployment = TestUtils.buildSessionCluster();
reconciler.reconcile(deployment, flinkService.getContext());

FlinkSessionJob job = TestUtils.buildSessionJob();
job.getStatus()
.getReconciliationStatus()
.serializeAndSetLastReconciledSpec(job.getSpec(), job);
job.getStatus().getReconciliationStatus().setState(ReconciliationState.DEPLOYED);
kubernetesClient.resource(job).createOrReplace();

var deleteControl = reconciler.cleanup(deployment, flinkService.getContext());
assertFalse(deleteControl.isRemoveFinalizer());

kubernetesClient.resource(job).delete();

deleteControl = reconciler.cleanup(deployment, flinkService.getContext());
assertTrue(deleteControl.isRemoveFinalizer());
}
}
Loading