Skip to content

Commit cb61bd7

Browse files
authored
refactor: independent stage cloud (#911)
1 parent 506dfba commit cb61bd7

11 files changed

Lines changed: 414 additions & 34 deletions

File tree

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ plugins {
1919

2020
allprojects {
2121
group = "io.flamingock"
22-
val declaredVersion = "1.3.0-SNAPSHOT"
22+
val declaredVersion = "1.4.0-SNAPSHOT"
2323
version = VersionManager.resolveVersion(declaredVersion, project.hasProperty("release"))
2424

2525
extra["generalUtilVersion"] = "1.5.3"

cloud/flamingock-cloud-api/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ dependencies {
44
description = "Cloud Edition public API definitions"
55

66
val coreApiVersion: String by extra
7+
val jacksonVersion = "2.14.1"
78
dependencies {
89
api("io.flamingock:flamingock-core-api:${coreApiVersion}")
10+
testImplementation("com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}")
911
}
1012

1113
java {

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,34 +17,43 @@
1717

1818
import java.util.List;
1919

20+
/**
21+
* Payload submitted by the client describing the pipeline state for an execution-plan request.
22+
* Stages are grouped into {@link StageBlockRequest}s where the block list order conveys the
23+
* dependency order — {@code blocks.get(0)} must complete before {@code blocks.get(1)} may run.
24+
*
25+
* <p>Block membership is owned by the client's {@code PipelineRun.getStageBlocks()}; the server
26+
* consumes the list as-is, with no {@code StageType}-based regrouping.
27+
*/
2028
public class ClientSubmissionRequest {
21-
private List<StageRequest> stages;
29+
30+
private List<StageBlockRequest> blocks;
2231

2332
public ClientSubmissionRequest() {
2433
}
2534

26-
public ClientSubmissionRequest(List<StageRequest> stages) {
27-
this.stages = stages;
35+
public ClientSubmissionRequest(List<StageBlockRequest> blocks) {
36+
this.blocks = blocks;
2837
}
2938

30-
public List<StageRequest> getStages() {
31-
return stages;
39+
public List<StageBlockRequest> getBlocks() {
40+
return blocks;
3241
}
3342

34-
public void setStages(List<StageRequest> stages) {
35-
this.stages = stages;
43+
public void setBlocks(List<StageBlockRequest> blocks) {
44+
this.blocks = blocks;
3645
}
3746

3847
@Override
3948
public boolean equals(Object o) {
4049
if (this == o) return true;
4150
if (o == null || getClass() != o.getClass()) return false;
4251
ClientSubmissionRequest that = (ClientSubmissionRequest) o;
43-
return java.util.Objects.equals(stages, that.stages);
52+
return java.util.Objects.equals(blocks, that.blocks);
4453
}
4554

4655
@Override
4756
public int hashCode() {
48-
return java.util.Objects.hash(stages);
57+
return java.util.Objects.hash(blocks);
4958
}
50-
}
59+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ public class ExecutionPlanRequest {
2525
public ExecutionPlanRequest() {
2626
}
2727

28-
public ExecutionPlanRequest(long lockAcquiredForMillis, List<StageRequest> stages) {
28+
public ExecutionPlanRequest(long lockAcquiredForMillis, List<StageBlockRequest> blocks) {
2929
this.lockAcquiredForMillis = lockAcquiredForMillis;
30-
this.clientSubmission = new ClientSubmissionRequest(stages);
30+
this.clientSubmission = new ClientSubmissionRequest(blocks);
3131
}
3232

3333
public void setClientSubmission(ClientSubmissionRequest clientSubmission) {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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.request;
17+
18+
import io.flamingock.api.StageType;
19+
20+
import java.util.List;
21+
import java.util.Objects;
22+
23+
/**
24+
* Structural unit of a cloud execution-plan submission: a block of stages that must complete
25+
* before the next block in the {@code ClientSubmissionRequest.blocks} list may run. Block
26+
* membership is owned by the client's {@code PipelineRun}; the server consumes the block list
27+
* as-is and does NOT regroup stages by {@link #type}.
28+
*
29+
* <p>The {@code type} field is metadata (diagnostics, future use). Two blocks may share the
30+
* same {@link StageType} — the server iterates the block list in order and never collapses by
31+
* type.
32+
*/
33+
public class StageBlockRequest {
34+
35+
private StageType type;
36+
private List<StageRequest> stages;
37+
38+
public StageBlockRequest() {
39+
}
40+
41+
public StageBlockRequest(StageType type, List<StageRequest> stages) {
42+
this.type = type;
43+
this.stages = stages;
44+
}
45+
46+
public StageType getType() {
47+
return type;
48+
}
49+
50+
public void setType(StageType type) {
51+
this.type = type;
52+
}
53+
54+
public List<StageRequest> getStages() {
55+
return stages;
56+
}
57+
58+
public void setStages(List<StageRequest> stages) {
59+
this.stages = stages;
60+
}
61+
62+
@Override
63+
public boolean equals(Object o) {
64+
if (this == o) return true;
65+
if (o == null || getClass() != o.getClass()) return false;
66+
StageBlockRequest that = (StageBlockRequest) o;
67+
return type == that.type && Objects.equals(stages, that.stages);
68+
}
69+
70+
@Override
71+
public int hashCode() {
72+
return Objects.hash(type, stages);
73+
}
74+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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.request;
17+
18+
import com.fasterxml.jackson.databind.DeserializationFeature;
19+
import com.fasterxml.jackson.databind.JsonNode;
20+
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import io.flamingock.api.StageType;
22+
import io.flamingock.cloud.api.vo.CloudStageStatus;
23+
import io.flamingock.cloud.api.vo.CloudTargetSystemAuditMarkType;
24+
import org.junit.jupiter.api.DisplayName;
25+
import org.junit.jupiter.api.Test;
26+
27+
import java.util.Arrays;
28+
import java.util.Collections;
29+
import java.util.List;
30+
31+
import static org.junit.jupiter.api.Assertions.assertEquals;
32+
import static org.junit.jupiter.api.Assertions.assertNotNull;
33+
import static org.junit.jupiter.api.Assertions.assertNull;
34+
import static org.junit.jupiter.api.Assertions.assertTrue;
35+
36+
/**
37+
* Wire-contract serialization tests for {@link ClientSubmissionRequest}. Pins the JSON shape
38+
* consumed by the cloud server. A divergence here is the canonical "wire mismatch" symptom.
39+
*/
40+
class ClientSubmissionRequestSerializationTest {
41+
42+
// Match production mapper configuration (see JsonObjectMapper.DEFAULT_INSTANCE in
43+
// flamingock-java-general-util): unknown properties are silently ignored on the wire.
44+
private static final ObjectMapper MAPPER = new ObjectMapper()
45+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
46+
47+
@Test
48+
@DisplayName("Serializes blocks in input order with type + stages")
49+
void serializesBlocksWithTypeAndStages() throws Exception {
50+
StageRequest sysStage = new StageRequest(
51+
"system-stage", 0, CloudStageStatus.NOT_STARTED,
52+
Collections.singletonList(new ChangeRequest("sys-c1",
53+
CloudTargetSystemAuditMarkType.NONE, false)));
54+
StageRequest userStage = new StageRequest(
55+
"user-stage", 1, CloudStageStatus.NOT_STARTED,
56+
Collections.singletonList(new ChangeRequest("user-c1",
57+
CloudTargetSystemAuditMarkType.NONE, false)));
58+
59+
ClientSubmissionRequest request = new ClientSubmissionRequest(Arrays.asList(
60+
new StageBlockRequest(StageType.SYSTEM, Collections.singletonList(sysStage)),
61+
new StageBlockRequest(StageType.DEFAULT, Collections.singletonList(userStage))));
62+
63+
JsonNode json = MAPPER.valueToTree(request);
64+
65+
assertNotNull(json.get("blocks"));
66+
assertEquals(2, json.get("blocks").size());
67+
68+
JsonNode block0 = json.get("blocks").get(0);
69+
assertEquals("SYSTEM", block0.get("type").asText());
70+
assertEquals(1, block0.get("stages").size());
71+
assertEquals("system-stage", block0.get("stages").get(0).get("name").asText());
72+
73+
JsonNode block1 = json.get("blocks").get(1);
74+
assertEquals("DEFAULT", block1.get("type").asText());
75+
assertEquals(1, block1.get("stages").size());
76+
assertEquals("user-stage", block1.get("stages").get(0).get("name").asText());
77+
}
78+
79+
@Test
80+
@DisplayName("Round-trips through Jackson preserving block order and contents")
81+
void roundTripsPreservingBlockOrderAndContents() throws Exception {
82+
ClientSubmissionRequest original = new ClientSubmissionRequest(Arrays.asList(
83+
new StageBlockRequest(StageType.LEGACY, Collections.singletonList(
84+
new StageRequest("legacy", 0, CloudStageStatus.COMPLETED,
85+
Collections.singletonList(new ChangeRequest("legacy-c1",
86+
CloudTargetSystemAuditMarkType.APPLIED, true))))),
87+
new StageBlockRequest(StageType.DEFAULT, Arrays.asList(
88+
new StageRequest("user-a", 1, CloudStageStatus.STARTED,
89+
Collections.singletonList(new ChangeRequest("user-a-c1",
90+
CloudTargetSystemAuditMarkType.NONE, false))),
91+
new StageRequest("user-b", 2, CloudStageStatus.NOT_STARTED,
92+
Collections.singletonList(new ChangeRequest("user-b-c1",
93+
CloudTargetSystemAuditMarkType.NONE, false)))))));
94+
95+
String json = MAPPER.writeValueAsString(original);
96+
ClientSubmissionRequest deserialized = MAPPER.readValue(json, ClientSubmissionRequest.class);
97+
98+
assertEquals(original, deserialized);
99+
// Block order is significant and preserved verbatim.
100+
assertEquals(2, deserialized.getBlocks().size());
101+
assertEquals(StageType.LEGACY, deserialized.getBlocks().get(0).getType());
102+
assertEquals(StageType.DEFAULT, deserialized.getBlocks().get(1).getType());
103+
assertEquals(2, deserialized.getBlocks().get(1).getStages().size());
104+
assertEquals("user-a", deserialized.getBlocks().get(1).getStages().get(0).getName());
105+
assertEquals("user-b", deserialized.getBlocks().get(1).getStages().get(1).getName());
106+
}
107+
108+
@Test
109+
@DisplayName("Same StageType repeated across multiple blocks is preserved on the wire (multi-block-same-type lock-in)")
110+
void sameStageTypeAcrossMultipleBlocksIsPreserved() throws Exception {
111+
StageRequest a = new StageRequest("user-a", 0, CloudStageStatus.NOT_STARTED,
112+
Collections.singletonList(new ChangeRequest("a-c1",
113+
CloudTargetSystemAuditMarkType.NONE, false)));
114+
StageRequest b = new StageRequest("user-b", 1, CloudStageStatus.NOT_STARTED,
115+
Collections.singletonList(new ChangeRequest("b-c1",
116+
CloudTargetSystemAuditMarkType.NONE, false)));
117+
118+
ClientSubmissionRequest request = new ClientSubmissionRequest(Arrays.asList(
119+
new StageBlockRequest(StageType.DEFAULT, Collections.singletonList(a)),
120+
new StageBlockRequest(StageType.DEFAULT, Collections.singletonList(b))));
121+
122+
String json = MAPPER.writeValueAsString(request);
123+
ClientSubmissionRequest deserialized = MAPPER.readValue(json, ClientSubmissionRequest.class);
124+
125+
// Two distinct blocks of the same StageType, NOT collapsed into one.
126+
assertEquals(2, deserialized.getBlocks().size());
127+
assertEquals(StageType.DEFAULT, deserialized.getBlocks().get(0).getType());
128+
assertEquals(StageType.DEFAULT, deserialized.getBlocks().get(1).getType());
129+
assertEquals("user-a", deserialized.getBlocks().get(0).getStages().get(0).getName());
130+
assertEquals("user-b", deserialized.getBlocks().get(1).getStages().get(0).getName());
131+
}
132+
133+
@Test
134+
@DisplayName("Empty blocks list serializes and deserializes cleanly")
135+
void emptyBlocksListRoundTrips() throws Exception {
136+
ClientSubmissionRequest original = new ClientSubmissionRequest(Collections.<StageBlockRequest>emptyList());
137+
138+
String json = MAPPER.writeValueAsString(original);
139+
ClientSubmissionRequest deserialized = MAPPER.readValue(json, ClientSubmissionRequest.class);
140+
141+
assertNotNull(deserialized.getBlocks());
142+
assertTrue(deserialized.getBlocks().isEmpty());
143+
}
144+
145+
@Test
146+
@DisplayName("Old flat 'stages' field at top level is silently ignored (clean cut — no fallback)")
147+
void oldFlatStagesFieldIsIgnored() throws Exception {
148+
// Wire format from a hypothetical old client: top-level `stages` instead of `blocks`.
149+
// The new server-side DTO has no `stages` getter/setter, so the field is dropped.
150+
String oldFormat = "{\"stages\":[{\"name\":\"legacy-flat\",\"order\":0}]}";
151+
ClientSubmissionRequest deserialized = MAPPER.readValue(oldFormat, ClientSubmissionRequest.class);
152+
153+
// No fallback — blocks is null (or empty) because the old field was ignored.
154+
assertNull(deserialized.getBlocks());
155+
}
156+
}

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import io.flamingock.internal.util.TimeService;
2020
import io.flamingock.cloud.api.request.ExecutionPlanRequest;
2121
import io.flamingock.cloud.api.response.ExecutionPlanResponse;
22+
import io.flamingock.cloud.api.request.StageBlockRequest;
2223
import io.flamingock.cloud.api.request.StageRequest;
2324
import io.flamingock.cloud.api.request.ChangeRequest;
2425
import io.flamingock.cloud.api.response.StageResponse;
@@ -29,6 +30,7 @@
2930
import io.flamingock.cloud.CloudApiMapper;
3031
import io.flamingock.internal.core.pipeline.run.PipelineRun;
3132
import io.flamingock.internal.core.pipeline.run.StageRun;
33+
import io.flamingock.internal.core.pipeline.run.StageRunBlock;
3234
import io.flamingock.internal.common.core.targets.TargetSystemAuditMarkType;
3335
import io.flamingock.cloud.lock.CloudLockService;
3436
import io.flamingock.internal.core.configuration.core.CoreConfigurable;
@@ -58,21 +60,30 @@ public static ExecutionPlanRequest toRequest(PipelineRun pipelineRun,
5860
long lockAcquiredForMillis,
5961
Map<String, TargetSystemAuditMarkType> ongoingStatusesMap) {
6062

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();
66-
List<ChangeRequest> stageChanges = currentStage
67-
.getChanges()
68-
.stream()
69-
.map(descriptor -> CloudExecutionPlanMapper.mapToChangeRequest(descriptor, ongoingStatusesMap))
70-
.collect(Collectors.toList());
71-
CloudStageStatus status = CloudApiMapper.toCloud(stageRun.getState());
72-
requestStages.add(new StageRequest(currentStage.getName(), i, status, stageChanges));
63+
// Walk the PipelineRun's block list verbatim — block grouping is owned by PipelineRun,
64+
// not derived from StageType. Two blocks of the same StageType are preserved as two
65+
// separate StageBlockRequests in input order.
66+
List<StageRunBlock> blocks = pipelineRun.getStageBlocks();
67+
List<StageBlockRequest> requestBlocks = new ArrayList<>(blocks.size());
68+
// Stage order index is global across the request — preserves the same ordering used
69+
// before block-awareness (each stage's index in the flat run list).
70+
int stageOrder = 0;
71+
for (StageRunBlock block : blocks) {
72+
List<StageRequest> blockStages = new ArrayList<>(block.getStageRuns().size());
73+
for (StageRun stageRun : block.getStageRuns()) {
74+
AbstractLoadedStage currentStage = stageRun.getLoadedStage();
75+
List<ChangeRequest> stageChanges = currentStage
76+
.getChanges()
77+
.stream()
78+
.map(descriptor -> CloudExecutionPlanMapper.mapToChangeRequest(descriptor, ongoingStatusesMap))
79+
.collect(Collectors.toList());
80+
CloudStageStatus status = CloudApiMapper.toCloud(stageRun.getState());
81+
blockStages.add(new StageRequest(currentStage.getName(), stageOrder++, status, stageChanges));
82+
}
83+
requestBlocks.add(new StageBlockRequest(block.getType(), blockStages));
7384
}
7485

75-
return new ExecutionPlanRequest(lockAcquiredForMillis, requestStages);
86+
return new ExecutionPlanRequest(lockAcquiredForMillis, requestBlocks);
7687
}
7788

7889
private static ChangeRequest mapToChangeRequest(AbstractLoadedChange descriptor,

0 commit comments

Comments
 (0)