Skip to content

Commit 8673961

Browse files
authored
feat: make stage independent from each other (#908)
1 parent a67ed68 commit 8673961

40 files changed

Lines changed: 1857 additions & 610 deletions

File tree

CLAUDE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,28 @@ This is a multi-module Gradle project using Kotlin DSL.
204204
- Test resources in `src/test/resources/flamingock/pipeline.yaml`
205205
- Each module has isolated test suite
206206

207+
### Validation Policy (when changing code)
208+
209+
Two tiers — use them in order:
210+
211+
**Tier 1 — Incremental validation (during iteration):**
212+
- Run targeted module tests for fast feedback: `./gradlew :core:flamingock-core:test`, `./gradlew :legacy:mongock-importer-mongodb:test`, etc.
213+
- Use `--tests "fully.qualified.ClassName"` to narrow further when iterating on a specific test.
214+
- Cheap, quick, good for confirming a focused change compiles and the obvious cases pass.
215+
216+
**Tier 2 — Final validation (before declaring a task done):**
217+
- Run `./gradlew clean build` from the repo root. This is the **authoritative, definitive check**.
218+
- It surfaces failures that targeted/incremental runs miss:
219+
- Cross-module integration regressions.
220+
- Stale Gradle / annotation-processor caches masking compile errors.
221+
- License-header drift (`spotlessCheck` runs as part of `build`).
222+
- Test-resource generation issues (annotation processors regenerating pipeline metadata).
223+
- Module-dependency ordering bugs.
224+
- A passing per-module test run is **not sufficient** to claim a task complete. Per-module runs use cached artifacts and may not exercise the full graph.
225+
- Do not skip this step on the grounds that "the affected module passed in isolation." If you reach the end of a task without a clean full build, say so explicitly rather than implying success.
226+
227+
If `clean build` is too slow to run on every minor iteration, that's expected — Tier 1 is for iteration. But the **final hand-off** must include a clean build.
228+
207229
### Java Version
208230
- Target Java 8 compatibility
209231
- Kotlin stdlib used in build scripts only

cloud/flamingock-cloud-api/src/main/java/io/flamingock/cloud/api/request/StageRequest.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,34 @@
1515
*/
1616
package io.flamingock.cloud.api.request;
1717

18+
import io.flamingock.cloud.api.vo.CloudStageStatus;
19+
1820
import java.util.List;
1921

2022
public class StageRequest {
2123
private String name;
2224

2325
private int order;
2426

27+
/**
28+
* Per-stage status reported by the client. Nullable for back-compat with older clients;
29+
* servers must treat {@code null} as {@link CloudStageStatus#NOT_STARTED}.
30+
*/
31+
private CloudStageStatus status;
32+
2533
private List<ChangeRequest> changes;
2634

2735
public StageRequest() {
2836
}
2937

3038
public StageRequest(String name, int order, List<ChangeRequest> changes) {
39+
this(name, order, null, changes);
40+
}
41+
42+
public StageRequest(String name, int order, CloudStageStatus status, List<ChangeRequest> changes) {
3143
this.name = name;
3244
this.order = order;
45+
this.status = status;
3346
this.changes = changes;
3447
}
3548

@@ -41,6 +54,10 @@ public int getOrder() {
4154
return order;
4255
}
4356

57+
public CloudStageStatus getStatus() {
58+
return status;
59+
}
60+
4461
public List<ChangeRequest> getChanges() {
4562
return changes;
4663
}
@@ -53,6 +70,10 @@ public void setOrder(int order) {
5370
this.order = order;
5471
}
5572

73+
public void setStatus(CloudStageStatus status) {
74+
this.status = status;
75+
}
76+
5677
public void setChanges(List<ChangeRequest> changes) {
5778
this.changes = changes;
5879
}
@@ -64,11 +85,12 @@ public boolean equals(Object o) {
6485
StageRequest that = (StageRequest) o;
6586
return order == that.order
6687
&& java.util.Objects.equals(name, that.name)
88+
&& status == that.status
6789
&& java.util.Objects.equals(changes, that.changes);
6890
}
6991

7092
@Override
7193
public int hashCode() {
72-
return java.util.Objects.hash(name, order, changes);
94+
return java.util.Objects.hash(name, order, status, changes);
7395
}
7496
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.cloud.api.vo;
17+
18+
/**
19+
* Wire-level per-stage status sent from the client to the cloud planner.
20+
*
21+
* <p>Mirrors the internal {@code StageState} hierarchy in shape; the cloud server uses this to
22+
* decide what to do with each stage on the next iteration (e.g., skip stages already failed
23+
* or blocked, route MI cases). Client-side mapping lives in {@code CloudApiMapper.toCloud(StageState)}.
24+
*/
25+
public enum CloudStageStatus {
26+
NOT_STARTED,
27+
STARTED,
28+
COMPLETED,
29+
FAILED,
30+
BLOCKED_MANUAL_INTERVENTION
31+
}

cloud/flamingock-cloud/src/main/java/io/flamingock/cloud/CloudApiMapper.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717

1818
import io.flamingock.cloud.api.vo.CloudAuditStatus;
1919
import io.flamingock.cloud.api.vo.CloudChangeType;
20+
import io.flamingock.cloud.api.vo.CloudStageStatus;
2021
import io.flamingock.cloud.api.vo.CloudTargetSystemAuditMarkType;
2122
import io.flamingock.cloud.api.vo.CloudTxStrategy;
2223
import io.flamingock.internal.common.core.audit.AuditEntry;
2324
import io.flamingock.internal.common.core.audit.AuditTxType;
25+
import io.flamingock.internal.common.core.response.data.StageState;
2426
import io.flamingock.internal.common.core.targets.TargetSystemAuditMarkType;
2527

2628
public final class CloudApiMapper {
@@ -44,4 +46,23 @@ public static CloudChangeType toCloud(AuditEntry.ChangeType changeType) {
4446
return CloudChangeType.valueOf(changeType.name());
4547
}
4648

49+
/**
50+
* Maps the internal {@link StageState} hierarchy to the wire enum {@link CloudStageStatus}.
51+
*
52+
* <p>Returns {@code null} for {@code NOT_STARTED} (or a null state) — the canonical wire
53+
* shape for "not started" is field absence/null, matching back-compat semantics with older
54+
* clients that don't populate the field. The server treats {@code null} as {@code NOT_STARTED}.
55+
*
56+
* <p>Order is important: {@code BlockedForMI} extends {@code Failed}, so the
57+
* blocked-for-MI check must come before the generic failed check.
58+
*/
59+
public static CloudStageStatus toCloud(StageState state) {
60+
if (state == null || state.isNotStarted()) return null;
61+
if (state.isBlockedForManualIntervention()) return CloudStageStatus.BLOCKED_MANUAL_INTERVENTION;
62+
if (state.isFailed()) return CloudStageStatus.FAILED;
63+
if (state.isCompleted()) return CloudStageStatus.COMPLETED;
64+
if (state.isStarted()) return CloudStageStatus.STARTED;
65+
throw new IllegalStateException("Unknown StageState: " + state);
66+
}
67+
4768
}

cloud/flamingock-cloud/src/main/java/io/flamingock/cloud/planner/CloudExecutionPlanMapper.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@
2424
import io.flamingock.cloud.api.response.StageResponse;
2525
import io.flamingock.cloud.api.response.ChangeResponse;
2626
import io.flamingock.cloud.api.vo.CloudChangeAction;
27+
import io.flamingock.cloud.api.vo.CloudStageStatus;
2728
import io.flamingock.cloud.api.vo.CloudTargetSystemAuditMarkType;
2829
import io.flamingock.cloud.CloudApiMapper;
30+
import io.flamingock.internal.core.pipeline.run.PipelineRun;
31+
import io.flamingock.internal.core.pipeline.run.StageRun;
2932
import io.flamingock.internal.common.core.targets.TargetSystemAuditMarkType;
3033
import io.flamingock.cloud.lock.CloudLockService;
3134
import io.flamingock.internal.core.configuration.core.CoreConfigurable;
@@ -51,19 +54,22 @@
5154

5255
public final class CloudExecutionPlanMapper {
5356

54-
public static ExecutionPlanRequest toRequest(List<AbstractLoadedStage> loadedStages,
57+
public static ExecutionPlanRequest toRequest(PipelineRun pipelineRun,
5558
long lockAcquiredForMillis,
5659
Map<String, TargetSystemAuditMarkType> ongoingStatusesMap) {
5760

58-
List<StageRequest> requestStages = new ArrayList<>(loadedStages.size());
59-
for (int i = 0; i < loadedStages.size(); i++) {
60-
AbstractLoadedStage currentStage = loadedStages.get(i);
61+
List<StageRun> stageRuns = pipelineRun.getStageRuns();
62+
List<StageRequest> requestStages = new ArrayList<>(stageRuns.size());
63+
for (int i = 0; i < stageRuns.size(); i++) {
64+
StageRun stageRun = stageRuns.get(i);
65+
AbstractLoadedStage currentStage = stageRun.getLoadedStage();
6166
List<ChangeRequest> stageChanges = currentStage
6267
.getChanges()
6368
.stream()
6469
.map(descriptor -> CloudExecutionPlanMapper.mapToChangeRequest(descriptor, ongoingStatusesMap))
6570
.collect(Collectors.toList());
66-
requestStages.add(new StageRequest(currentStage.getName(), i, stageChanges));
71+
CloudStageStatus status = CloudApiMapper.toCloud(stageRun.getState());
72+
requestStages.add(new StageRequest(currentStage.getName(), i, status, stageChanges));
6773
}
6874

6975
return new ExecutionPlanRequest(lockAcquiredForMillis, requestStages);

cloud/flamingock-cloud/src/main/java/io/flamingock/cloud/planner/CloudExecutionPlanner.java

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
import io.flamingock.internal.core.plan.ExecutionPlan;
3434
import io.flamingock.internal.core.plan.ExecutionPlanner;
3535
import io.flamingock.internal.core.external.store.lock.LockException;
36-
import io.flamingock.internal.core.pipeline.execution.ExecutableStage;
3736
import io.flamingock.internal.core.pipeline.loaded.stage.AbstractLoadedStage;
3837
import io.flamingock.internal.core.pipeline.run.PipelineRun;
3938
import io.flamingock.internal.util.log.FlamingockLoggerFactory;
@@ -94,7 +93,7 @@ public ExecutionPlan getNextExecution(PipelineRun pipelineRun) throws LockExcept
9493
do {
9594
try {
9695
logger.info("Requesting cloud execution plan - elapsed[{}ms]", counterPerGuid.getElapsed());
97-
ExecutionPlanResponse response = createExecution(loadedStages, snapshot.getMarks(), lastOwnerGuid, counterPerGuid.getElapsed());
96+
ExecutionPlanResponse response = createExecution(pipelineRun, snapshot.getMarks(), lastOwnerGuid, counterPerGuid.getElapsed());
9897
logger.info("Obtained cloud execution plan: {}", response.getAction());
9998

10099
//TODO should check if it has the lock?
@@ -103,8 +102,7 @@ public ExecutionPlan getNextExecution(PipelineRun pipelineRun) throws LockExcept
103102
}
104103

105104
if (response.isContinue()) {
106-
List<ExecutableStage> executableStages = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages);
107-
return ExecutionPlan.CONTINUE(executableStages);
105+
return ExecutionPlan.CONTINUE();
108106

109107
} else if (response.isExecute()) {
110108
Lock lock = CloudLock.initialiseLocal(response.getLock(), coreConfiguration, runnerId, lockService, timeService);
@@ -132,8 +130,7 @@ public ExecutionPlan getNextExecution(PipelineRun pipelineRun) throws LockExcept
132130
);
133131

134132
} else if (response.isAbort()) {
135-
List<ExecutableStage> stages = CloudExecutionPlanMapper.getExecutableStages(response, loadedStages);
136-
return ExecutionPlan.ABORT(stages);
133+
return ExecutionPlan.ABORT();
137134

138135
} else {
139136
throw new RuntimeException("Unrecognized action from response. Not within(CONTINUE, EXECUTE, AWAIT, ABORT)");
@@ -148,7 +145,7 @@ public ExecutionPlan getNextExecution(PipelineRun pipelineRun) throws LockExcept
148145
} while (true);
149146
}
150147

151-
private ExecutionPlanResponse createExecution(List<AbstractLoadedStage> loadedStages,
148+
private ExecutionPlanResponse createExecution(PipelineRun pipelineRun,
152149
Collection<TargetSystemAuditMark> auditMarks,
153150
String lastAcquisitionId,
154151
long elapsedMillis) {
@@ -158,7 +155,7 @@ private ExecutionPlanResponse createExecution(List<AbstractLoadedStage> loadedSt
158155
.collect(Collectors.toMap(TargetSystemAuditMark::getChangeId, TargetSystemAuditMark::getOperation));
159156

160157
ExecutionPlanRequest requestBody = CloudExecutionPlanMapper.toRequest(
161-
loadedStages,
158+
pipelineRun,
162159
coreConfiguration.getLockAcquiredForMillis(),
163160
auditMarksMap);
164161

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.cloud;
17+
18+
import io.flamingock.cloud.api.vo.CloudStageStatus;
19+
import io.flamingock.internal.common.core.recovery.RecoveryIssue;
20+
import io.flamingock.internal.common.core.response.data.ErrorInfo;
21+
import io.flamingock.internal.common.core.response.data.StageState;
22+
import org.junit.jupiter.api.DisplayName;
23+
import org.junit.jupiter.api.Test;
24+
25+
import java.util.Collections;
26+
27+
import static org.junit.jupiter.api.Assertions.assertEquals;
28+
import static org.junit.jupiter.api.Assertions.assertNull;
29+
30+
class CloudApiMapperTest {
31+
32+
@Test
33+
@DisplayName("toCloud(null) returns null (wire shape for NOT_STARTED is field absent)")
34+
void nullReturnsNull() {
35+
assertNull(CloudApiMapper.toCloud((StageState) null));
36+
}
37+
38+
@Test
39+
@DisplayName("toCloud(NOT_STARTED) returns null (wire shape for NOT_STARTED is field absent)")
40+
void notStartedReturnsNull() {
41+
assertNull(CloudApiMapper.toCloud(StageState.NOT_STARTED));
42+
}
43+
44+
@Test
45+
@DisplayName("toCloud(STARTED) maps to STARTED")
46+
void startedMaps() {
47+
assertEquals(CloudStageStatus.STARTED, CloudApiMapper.toCloud(StageState.STARTED));
48+
}
49+
50+
@Test
51+
@DisplayName("toCloud(COMPLETED) maps to COMPLETED")
52+
void completedMaps() {
53+
assertEquals(CloudStageStatus.COMPLETED, CloudApiMapper.toCloud(StageState.COMPLETED));
54+
}
55+
56+
@Test
57+
@DisplayName("toCloud(Failed) maps to FAILED")
58+
void failedMaps() {
59+
StageState failed = StageState.failed(new ErrorInfo("RuntimeException", "boom", Collections.emptyList(), "stage-1"));
60+
assertEquals(CloudStageStatus.FAILED, CloudApiMapper.toCloud(failed));
61+
}
62+
63+
@Test
64+
@DisplayName("toCloud(BlockedForMI) maps to BLOCKED_MANUAL_INTERVENTION — not FAILED — even though BlockedForMI extends Failed")
65+
void blockedForMIMapsBeforeFailed() {
66+
StageState blocked = StageState.blockedManualIntervention(
67+
"stage-1", Collections.singletonList(new RecoveryIssue("change-1")));
68+
assertEquals(CloudStageStatus.BLOCKED_MANUAL_INTERVENTION, CloudApiMapper.toCloud(blocked));
69+
}
70+
}

0 commit comments

Comments
 (0)