Skip to content

Commit f8ecc4d

Browse files
authored
Merge pull request quarkusio#55647 from TSFenwick/deterministic-app-model-serialization
Serialize application model set-valued fields in a deterministic order
2 parents 8052a80 + 9c311ce commit f8ecc4d

2 files changed

Lines changed: 115 additions & 4 deletions

File tree

independent-projects/bootstrap/app-model/src/main/java/io/quarkus/bootstrap/model/ApplicationModel.java

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,15 +193,23 @@ default Map<String, Object> asMap(MappableCollectionFactory factory) {
193193
map.put(BootstrapConstants.MAPPABLE_CAPABILITIES, Mappable.asMaps(getExtensionCapabilities(), factory));
194194
}
195195
if (!getReloadableWorkspaceDependencies().isEmpty()) {
196+
// Sort so the serialized form is deterministic across JVMs: Set.copyOf iteration
197+
// order is salted per JVM, which busts the Gradle build cache (see #55619).
196198
map.put(BootstrapConstants.MAPPABLE_LOCAL_PROJECTS,
197-
Mappable.toStringCollection(getReloadableWorkspaceDependencies(), factory));
199+
Mappable.toStringCollection(
200+
getReloadableWorkspaceDependencies().stream().sorted().toList(), factory));
198201
}
199202
if (!getRemovedResources().isEmpty()) {
200203
final Map<ArtifactKey, Set<String>> removedResources = getRemovedResources();
201204
final Map<String, Object> mappedExcludedResources = factory.newMap(removedResources.size());
202-
for (Map.Entry<ArtifactKey, Set<String>> entry : removedResources.entrySet()) {
203-
mappedExcludedResources.put(entry.getKey().toString(), Mappable.toStringCollection(entry.getValue(), factory));
204-
}
205+
// getRemovedResources() is a per-JVM-salted Map.copyOf, so sort by key: this fixes the
206+
// insertion order into the (HashMap-backed) JSON object, keeping its output stable across
207+
// JVMs even when keys collide in a bucket. Sort each value collection for the same reason
208+
// as local-projects above (see #55619).
209+
removedResources.entrySet().stream()
210+
.sorted(Map.Entry.comparingByKey())
211+
.forEach(entry -> mappedExcludedResources.put(entry.getKey().toString(),
212+
Mappable.toStringCollection(entry.getValue().stream().sorted().toList(), factory)));
205213
map.put(BootstrapConstants.MAPPABLE_EXCLUDED_RESOURCES, mappedExcludedResources);
206214
}
207215
if (!getExtensionDevModeConfig().isEmpty()) {

independent-projects/bootstrap/core/src/test/java/io/quarkus/bootstrap/app/ApplicationModelSerializerTest.java

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import java.io.IOException;
66
import java.nio.file.Files;
77
import java.nio.file.Path;
8+
import java.util.ArrayList;
89
import java.util.Collection;
910
import java.util.Collections;
11+
import java.util.LinkedHashMap;
1012
import java.util.List;
13+
import java.util.Map;
1114
import java.util.Properties;
1215

1316
import org.junit.jupiter.api.Test;
@@ -18,6 +21,7 @@
1821
import io.quarkus.bootstrap.model.ApplicationModelBuilder;
1922
import io.quarkus.bootstrap.model.ExtensionDevModeConfig;
2023
import io.quarkus.bootstrap.model.JvmOption;
24+
import io.quarkus.bootstrap.model.MappableCollectionFactory;
2125
import io.quarkus.bootstrap.model.PlatformImports;
2226
import io.quarkus.maven.dependency.ArtifactKey;
2327
import io.quarkus.maven.dependency.Dependency;
@@ -171,6 +175,105 @@ void testSerializationWithDirectDependencies() throws IOException {
171175
});
172176
}
173177

178+
@Test
179+
void testReloadableWorkspaceModulesSerializedInDeterministicOrder() throws IOException {
180+
// Add the reloadable workspace modules in a deliberately non-sorted order
181+
ApplicationModel model = createBasicApplicationModelBuilder()
182+
.addReloadableWorkspaceModule(ArtifactKey.of("org.example", "z-module"))
183+
.addReloadableWorkspaceModule(ArtifactKey.of("org.example", "a-module"))
184+
.addReloadableWorkspaceModule(ArtifactKey.of("org.example", "m-module"))
185+
.addReloadableWorkspaceModule(ArtifactKey.of("org.example", "1-module"))
186+
.build();
187+
188+
Path serializedFile = tempDir.resolve("app-model-local-projects.json");
189+
ApplicationModelSerializer.serialize(model, serializedFile);
190+
191+
// local-projects must be serialized in natural (sorted) order regardless of insertion
192+
// order so the output is byte-stable across JVMs and doesn't bust the Gradle build cache
193+
String json = Files.readString(serializedFile);
194+
assertThat(json.indexOf("1-module")).isLessThan(json.indexOf("a-module"));
195+
assertThat(json.indexOf("a-module")).isLessThan(json.indexOf("m-module"));
196+
assertThat(json.indexOf("m-module")).isLessThan(json.indexOf("z-module"));
197+
}
198+
199+
@Test
200+
void testRemovedResourcesSerializedInDeterministicOrder() throws IOException {
201+
// Populate removed resources through the extension-descriptor path, which stores each
202+
// artifact's resources as a per-JVM-salted Set.of(value.split(",")) -- the actual source of
203+
// nondeterminism the value sort defends against (see ApplicationModelBuilder#addRemovedResources).
204+
ApplicationModelBuilder builder = createBasicApplicationModelBuilder();
205+
Properties props = new Properties();
206+
props.setProperty(ApplicationModelBuilder.REMOVED_RESOURCES_DOT + "org.example:lib-a",
207+
"c-one.txt,a-one.txt,b-one.txt");
208+
props.setProperty(ApplicationModelBuilder.REMOVED_RESOURCES_DOT + "org.example:lib-b",
209+
"z-two.txt,x-two.txt,y-two.txt");
210+
builder.handleExtensionProperties(props, ArtifactKey.ga("io.quarkus", "some-extension"));
211+
ApplicationModel model = builder.build();
212+
213+
Path serializedFile = tempDir.resolve("app-model-removed-resources.json");
214+
ApplicationModelSerializer.serialize(model, serializedFile);
215+
216+
// The resources of each artifact must be serialized in natural (sorted) order regardless of the
217+
// salted Set.of iteration order, so the output is byte-stable across JVMs and does not bust the
218+
// Gradle build cache. (The map keys serialize in HashMap order, which is already JVM-stable.)
219+
String json = Files.readString(serializedFile);
220+
assertThat(json.indexOf("a-one.txt")).isLessThan(json.indexOf("b-one.txt"));
221+
assertThat(json.indexOf("b-one.txt")).isLessThan(json.indexOf("c-one.txt"));
222+
assertThat(json.indexOf("x-two.txt")).isLessThan(json.indexOf("y-two.txt"));
223+
assertThat(json.indexOf("y-two.txt")).isLessThan(json.indexOf("z-two.txt"));
224+
}
225+
226+
@Test
227+
void testRemovedResourceKeysMappedInSortedOrder() {
228+
// Add removed resources for several artifacts in a deliberately non-sorted key order
229+
List<ArtifactKey> keys = List.of(
230+
ArtifactKey.of("org.example", "lib-c"),
231+
ArtifactKey.of("org.example", "lib-a"),
232+
ArtifactKey.of("org.example", "lib-d"),
233+
ArtifactKey.of("org.example", "lib-b"));
234+
ApplicationModelBuilder builder = createBasicApplicationModelBuilder();
235+
keys.forEach(key -> builder.addRemovedResources(key, List.of("r.txt")));
236+
ApplicationModel model = builder.build();
237+
238+
// The production JSON writer is HashMap-backed, so the serialized excluded-resources keys come
239+
// out in (JVM-stable) hash order, which we cannot assert as sorted. But asMap() is factory-
240+
// parameterized: mapping with an insertion-order-preserving factory exposes the order in which
241+
// asMap() emits the keys. That insertion order is what Map.Entry.comparingByKey() pins, and what
242+
// keeps the serialized output stable across JVMs (getRemovedResources() is a salted Map.copyOf).
243+
MappableCollectionFactory orderedFactory = new MappableCollectionFactory() {
244+
@Override
245+
public Map<String, Object> newMap() {
246+
return new LinkedHashMap<>();
247+
}
248+
249+
@Override
250+
public Map<String, Object> newMap(int initialCapacity) {
251+
return new LinkedHashMap<>(initialCapacity);
252+
}
253+
254+
@Override
255+
public Collection<Object> newCollection() {
256+
return new ArrayList<>();
257+
}
258+
259+
@Override
260+
public Collection<Object> newCollection(int initialCapacity) {
261+
return new ArrayList<>(initialCapacity);
262+
}
263+
};
264+
265+
Map<String, Object> mapped = model.asMap(orderedFactory);
266+
@SuppressWarnings("unchecked")
267+
Map<String, Object> excludedResources = (Map<String, Object>) mapped
268+
.get(BootstrapConstants.MAPPABLE_EXCLUDED_RESOURCES);
269+
270+
// asMap() must emit the keys in ArtifactKey natural order (what Map.Entry.comparingByKey uses),
271+
// rendered with the serializer's own toString -- compare against the keys' sorted order rather
272+
// than hardcoding the GACT string format.
273+
List<String> expectedKeyOrder = keys.stream().sorted().map(ArtifactKey::toString).toList();
274+
assertThat(excludedResources.keySet()).containsExactlyElementsOf(expectedKeyOrder);
275+
}
276+
174277
@Test
175278
void testSerializationWithExtensionDevModeConfig() throws IOException {
176279
ApplicationModelBuilder builder = createBasicApplicationModelBuilder();

0 commit comments

Comments
 (0)