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
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ plugins {

allprojects {
group = "io.flamingock"
val declaredVersion = "1.3.0-SNAPSHOT"
val declaredVersion = "1.4.0-SNAPSHOT"
version = VersionManager.resolveVersion(declaredVersion, project.hasProperty("release"))

extra["generalUtilVersion"] = "1.5.3"
Expand Down
2 changes: 2 additions & 0 deletions cloud/flamingock-cloud-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ dependencies {
description = "Cloud Edition public API definitions"

val coreApiVersion: String by extra
val jacksonVersion = "2.14.1"
dependencies {
api("io.flamingock:flamingock-core-api:${coreApiVersion}")
testImplementation("com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}")
}

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,43 @@

import java.util.List;

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

private List<StageBlockRequest> blocks;

public ClientSubmissionRequest() {
}

public ClientSubmissionRequest(List<StageRequest> stages) {
this.stages = stages;
public ClientSubmissionRequest(List<StageBlockRequest> blocks) {
this.blocks = blocks;
}

public List<StageRequest> getStages() {
return stages;
public List<StageBlockRequest> getBlocks() {
return blocks;
}

public void setStages(List<StageRequest> stages) {
this.stages = stages;
public void setBlocks(List<StageBlockRequest> blocks) {
this.blocks = blocks;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ClientSubmissionRequest that = (ClientSubmissionRequest) o;
return java.util.Objects.equals(stages, that.stages);
return java.util.Objects.equals(blocks, that.blocks);
}

@Override
public int hashCode() {
return java.util.Objects.hash(stages);
return java.util.Objects.hash(blocks);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ public class ExecutionPlanRequest {
public ExecutionPlanRequest() {
}

public ExecutionPlanRequest(long lockAcquiredForMillis, List<StageRequest> stages) {
public ExecutionPlanRequest(long lockAcquiredForMillis, List<StageBlockRequest> blocks) {
this.lockAcquiredForMillis = lockAcquiredForMillis;
this.clientSubmission = new ClientSubmissionRequest(stages);
this.clientSubmission = new ClientSubmissionRequest(blocks);
}

public void setClientSubmission(ClientSubmissionRequest clientSubmission) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2026 Flamingock (https://www.flamingock.io)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.flamingock.cloud.api.request;

import io.flamingock.api.StageType;

import java.util.List;
import java.util.Objects;

/**
* Structural unit of a cloud execution-plan submission: a block of stages that must complete
* before the next block in the {@code ClientSubmissionRequest.blocks} list may run. Block
* membership is owned by the client's {@code PipelineRun}; the server consumes the block list
* as-is and does NOT regroup stages by {@link #type}.
*
* <p>The {@code type} field is metadata (diagnostics, future use). Two blocks may share the
* same {@link StageType} — the server iterates the block list in order and never collapses by
* type.
*/
public class StageBlockRequest {

private StageType type;
private List<StageRequest> stages;

public StageBlockRequest() {
}

public StageBlockRequest(StageType type, List<StageRequest> stages) {
this.type = type;
this.stages = stages;
}

public StageType getType() {
return type;
}

public void setType(StageType type) {
this.type = type;
}

public List<StageRequest> getStages() {
return stages;
}

public void setStages(List<StageRequest> stages) {
this.stages = stages;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StageBlockRequest that = (StageBlockRequest) o;
return type == that.type && Objects.equals(stages, that.stages);
}

@Override
public int hashCode() {
return Objects.hash(type, stages);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright 2026 Flamingock (https://www.flamingock.io)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.flamingock.cloud.api.request;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.flamingock.api.StageType;
import io.flamingock.cloud.api.vo.CloudStageStatus;
import io.flamingock.cloud.api.vo.CloudTargetSystemAuditMarkType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Wire-contract serialization tests for {@link ClientSubmissionRequest}. Pins the JSON shape
* consumed by the cloud server. A divergence here is the canonical "wire mismatch" symptom.
*/
class ClientSubmissionRequestSerializationTest {

// Match production mapper configuration (see JsonObjectMapper.DEFAULT_INSTANCE in
// flamingock-java-general-util): unknown properties are silently ignored on the wire.
private static final ObjectMapper MAPPER = new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

@Test
@DisplayName("Serializes blocks in input order with type + stages")
void serializesBlocksWithTypeAndStages() throws Exception {
StageRequest sysStage = new StageRequest(
"system-stage", 0, CloudStageStatus.NOT_STARTED,
Collections.singletonList(new ChangeRequest("sys-c1",
CloudTargetSystemAuditMarkType.NONE, false)));
StageRequest userStage = new StageRequest(
"user-stage", 1, CloudStageStatus.NOT_STARTED,
Collections.singletonList(new ChangeRequest("user-c1",
CloudTargetSystemAuditMarkType.NONE, false)));

ClientSubmissionRequest request = new ClientSubmissionRequest(Arrays.asList(
new StageBlockRequest(StageType.SYSTEM, Collections.singletonList(sysStage)),
new StageBlockRequest(StageType.DEFAULT, Collections.singletonList(userStage))));

JsonNode json = MAPPER.valueToTree(request);

assertNotNull(json.get("blocks"));
assertEquals(2, json.get("blocks").size());

JsonNode block0 = json.get("blocks").get(0);
assertEquals("SYSTEM", block0.get("type").asText());
assertEquals(1, block0.get("stages").size());
assertEquals("system-stage", block0.get("stages").get(0).get("name").asText());

JsonNode block1 = json.get("blocks").get(1);
assertEquals("DEFAULT", block1.get("type").asText());
assertEquals(1, block1.get("stages").size());
assertEquals("user-stage", block1.get("stages").get(0).get("name").asText());
}

@Test
@DisplayName("Round-trips through Jackson preserving block order and contents")
void roundTripsPreservingBlockOrderAndContents() throws Exception {
ClientSubmissionRequest original = new ClientSubmissionRequest(Arrays.asList(
new StageBlockRequest(StageType.LEGACY, Collections.singletonList(
new StageRequest("legacy", 0, CloudStageStatus.COMPLETED,
Collections.singletonList(new ChangeRequest("legacy-c1",
CloudTargetSystemAuditMarkType.APPLIED, true))))),
new StageBlockRequest(StageType.DEFAULT, Arrays.asList(
new StageRequest("user-a", 1, CloudStageStatus.STARTED,
Collections.singletonList(new ChangeRequest("user-a-c1",
CloudTargetSystemAuditMarkType.NONE, false))),
new StageRequest("user-b", 2, CloudStageStatus.NOT_STARTED,
Collections.singletonList(new ChangeRequest("user-b-c1",
CloudTargetSystemAuditMarkType.NONE, false)))))));

String json = MAPPER.writeValueAsString(original);
ClientSubmissionRequest deserialized = MAPPER.readValue(json, ClientSubmissionRequest.class);

assertEquals(original, deserialized);
// Block order is significant and preserved verbatim.
assertEquals(2, deserialized.getBlocks().size());
assertEquals(StageType.LEGACY, deserialized.getBlocks().get(0).getType());
assertEquals(StageType.DEFAULT, deserialized.getBlocks().get(1).getType());
assertEquals(2, deserialized.getBlocks().get(1).getStages().size());
assertEquals("user-a", deserialized.getBlocks().get(1).getStages().get(0).getName());
assertEquals("user-b", deserialized.getBlocks().get(1).getStages().get(1).getName());
}

@Test
@DisplayName("Same StageType repeated across multiple blocks is preserved on the wire (multi-block-same-type lock-in)")
void sameStageTypeAcrossMultipleBlocksIsPreserved() throws Exception {
StageRequest a = new StageRequest("user-a", 0, CloudStageStatus.NOT_STARTED,
Collections.singletonList(new ChangeRequest("a-c1",
CloudTargetSystemAuditMarkType.NONE, false)));
StageRequest b = new StageRequest("user-b", 1, CloudStageStatus.NOT_STARTED,
Collections.singletonList(new ChangeRequest("b-c1",
CloudTargetSystemAuditMarkType.NONE, false)));

ClientSubmissionRequest request = new ClientSubmissionRequest(Arrays.asList(
new StageBlockRequest(StageType.DEFAULT, Collections.singletonList(a)),
new StageBlockRequest(StageType.DEFAULT, Collections.singletonList(b))));

String json = MAPPER.writeValueAsString(request);
ClientSubmissionRequest deserialized = MAPPER.readValue(json, ClientSubmissionRequest.class);

// Two distinct blocks of the same StageType, NOT collapsed into one.
assertEquals(2, deserialized.getBlocks().size());
assertEquals(StageType.DEFAULT, deserialized.getBlocks().get(0).getType());
assertEquals(StageType.DEFAULT, deserialized.getBlocks().get(1).getType());
assertEquals("user-a", deserialized.getBlocks().get(0).getStages().get(0).getName());
assertEquals("user-b", deserialized.getBlocks().get(1).getStages().get(0).getName());
}

@Test
@DisplayName("Empty blocks list serializes and deserializes cleanly")
void emptyBlocksListRoundTrips() throws Exception {
ClientSubmissionRequest original = new ClientSubmissionRequest(Collections.<StageBlockRequest>emptyList());

String json = MAPPER.writeValueAsString(original);
ClientSubmissionRequest deserialized = MAPPER.readValue(json, ClientSubmissionRequest.class);

assertNotNull(deserialized.getBlocks());
assertTrue(deserialized.getBlocks().isEmpty());
}

@Test
@DisplayName("Old flat 'stages' field at top level is silently ignored (clean cut — no fallback)")
void oldFlatStagesFieldIsIgnored() throws Exception {
// Wire format from a hypothetical old client: top-level `stages` instead of `blocks`.
// The new server-side DTO has no `stages` getter/setter, so the field is dropped.
String oldFormat = "{\"stages\":[{\"name\":\"legacy-flat\",\"order\":0}]}";
ClientSubmissionRequest deserialized = MAPPER.readValue(oldFormat, ClientSubmissionRequest.class);

// No fallback — blocks is null (or empty) because the old field was ignored.
assertNull(deserialized.getBlocks());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import io.flamingock.internal.util.TimeService;
import io.flamingock.cloud.api.request.ExecutionPlanRequest;
import io.flamingock.cloud.api.response.ExecutionPlanResponse;
import io.flamingock.cloud.api.request.StageBlockRequest;
import io.flamingock.cloud.api.request.StageRequest;
import io.flamingock.cloud.api.request.ChangeRequest;
import io.flamingock.cloud.api.response.StageResponse;
Expand All @@ -29,6 +30,7 @@
import io.flamingock.cloud.CloudApiMapper;
import io.flamingock.internal.core.pipeline.run.PipelineRun;
import io.flamingock.internal.core.pipeline.run.StageRun;
import io.flamingock.internal.core.pipeline.run.StageRunBlock;
import io.flamingock.internal.common.core.targets.TargetSystemAuditMarkType;
import io.flamingock.cloud.lock.CloudLockService;
import io.flamingock.internal.core.configuration.core.CoreConfigurable;
Expand Down Expand Up @@ -58,21 +60,30 @@ public static ExecutionPlanRequest toRequest(PipelineRun pipelineRun,
long lockAcquiredForMillis,
Map<String, TargetSystemAuditMarkType> ongoingStatusesMap) {

List<StageRun> stageRuns = pipelineRun.getStageRuns();
List<StageRequest> requestStages = new ArrayList<>(stageRuns.size());
for (int i = 0; i < stageRuns.size(); i++) {
StageRun stageRun = stageRuns.get(i);
AbstractLoadedStage currentStage = stageRun.getLoadedStage();
List<ChangeRequest> stageChanges = currentStage
.getChanges()
.stream()
.map(descriptor -> CloudExecutionPlanMapper.mapToChangeRequest(descriptor, ongoingStatusesMap))
.collect(Collectors.toList());
CloudStageStatus status = CloudApiMapper.toCloud(stageRun.getState());
requestStages.add(new StageRequest(currentStage.getName(), i, status, stageChanges));
// Walk the PipelineRun's block list verbatim — block grouping is owned by PipelineRun,
// not derived from StageType. Two blocks of the same StageType are preserved as two
// separate StageBlockRequests in input order.
List<StageRunBlock> blocks = pipelineRun.getStageBlocks();
List<StageBlockRequest> requestBlocks = new ArrayList<>(blocks.size());
// Stage order index is global across the request — preserves the same ordering used
// before block-awareness (each stage's index in the flat run list).
int stageOrder = 0;
for (StageRunBlock block : blocks) {
List<StageRequest> blockStages = new ArrayList<>(block.getStageRuns().size());
for (StageRun stageRun : block.getStageRuns()) {
AbstractLoadedStage currentStage = stageRun.getLoadedStage();
List<ChangeRequest> stageChanges = currentStage
.getChanges()
.stream()
.map(descriptor -> CloudExecutionPlanMapper.mapToChangeRequest(descriptor, ongoingStatusesMap))
.collect(Collectors.toList());
CloudStageStatus status = CloudApiMapper.toCloud(stageRun.getState());
blockStages.add(new StageRequest(currentStage.getName(), stageOrder++, status, stageChanges));
}
requestBlocks.add(new StageBlockRequest(block.getType(), blockStages));
}

return new ExecutionPlanRequest(lockAcquiredForMillis, requestStages);
return new ExecutionPlanRequest(lockAcquiredForMillis, requestBlocks);
}

private static ChangeRequest mapToChangeRequest(AbstractLoadedChange descriptor,
Expand Down
Loading
Loading