From ab488b4a2cd028019c44477f3863753075c34216 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 14:48:57 +0000 Subject: [PATCH 01/21] feat: add optional parallel task execution in MessageCodeGenerator Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/3cc2ab86-f818-4d6f-8294-e0d1805a7497 Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../MavenMessageCodeGenerator.java | 6 +- .../codegenerator/MessageCodeGenerator.java | 83 +++++++++++++++++-- .../ParallelExecutionOptionTest.java | 73 ++++++++++++++++ 3 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MavenMessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MavenMessageCodeGenerator.java index 8dea32fcba..ec6408c9c0 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MavenMessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MavenMessageCodeGenerator.java @@ -34,14 +34,14 @@ public void setLog(Log log) { } protected void logInfo(String msg) { - log.info(msg); + log.info(formatLogMessage(msg)); } protected void logDebug(String msg) { - log.debug(msg); + log.debug(formatLogMessage(msg)); } protected void logError(String msg, Throwable e) { - log.error(msg, e); + log.error(formatLogMessage(msg), e); } } diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index 4603ef3d1d..9a66c87062 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -45,6 +45,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -62,6 +67,7 @@ public class MessageCodeGenerator { private static final String ORDERED_FIELDS_OPTION = "generator.orderedFields"; private static final String OVERWRITE_OPTION = "generator.overwrite"; private static final String UTC_TIMESTAMP_PRECISION_OPTION = "generator.utcTimestampPrecision"; + private static final String PARALLEL_TASK_EXECUTION_OPTION = "generator.parallelExecution"; // An arbitrary serial UID which will have to be changed when messages and fields won't be compatible with next versions in terms // of java serialization. @@ -76,8 +82,18 @@ public class MessageCodeGenerator { private static final Set UTC_TIMESTAMP_PRECISION_ALLOWED_VALUES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("SECONDS", "MILLIS", "MICROS", "NANOS"))); + private final ThreadLocal logPrefix = new ThreadLocal<>(); + + protected String formatLogMessage(String msg) { + String prefix = logPrefix.get(); + if (prefix == null) { + return msg; + } + return "[" + prefix + "] " + msg; + } + protected void logInfo(String msg) { - System.out.println(msg); + System.out.println(formatLogMessage(msg)); } protected void logDebug(String msg) { @@ -85,14 +101,14 @@ protected void logDebug(String msg) { } protected void logError(String msg, Throwable e) { - System.err.println(msg); + System.err.println(formatLogMessage(msg)); e.printStackTrace(); } private void generateMessageBaseClass(Task task) throws ParserConfigurationException, SAXException, IOException, TransformerFactoryConfigurationError, TransformerException { - logInfo(task.getName() + ": generating message base class"); + logInfo("generating message base class"); Map parameters = new HashMap<>(); parameters.put(XSLPARAM_SERIAL_UID, SERIAL_UID_STR); generateClassCode(task, "Message", parameters); @@ -114,7 +130,7 @@ private void generateClassCode(Task task, String className, Map throws ParserConfigurationException, SAXException, IOException, TransformerFactoryConfigurationError, TransformerException { - logDebug("generating " + className + " for " + task.getName()); + logDebug("generating " + className); if (parameters == null) { parameters = new HashMap<>(); } @@ -130,7 +146,7 @@ private void generateFieldClasses(Task task) throws ParserConfigurationException IOException { String outputDirectory = task.getOutputBaseDirectory() + "/" + task.getFieldDirectory() + "/"; - logInfo(task.getName() + ": generating field classes in " + outputDirectory); + logInfo("generating field classes in " + outputDirectory); writePackageDocumentation(outputDirectory, "FIX field definitions for " + task.getName()); Document document = getSpecification(task); List fieldNames = getNames(document.getDocumentElement(), "fields/field"); @@ -172,7 +188,7 @@ private void generateFieldClasses(Task task) throws ParserConfigurationException private void generateMessageSubclasses(Task task) throws ParserConfigurationException, SAXException, IOException, TransformerFactoryConfigurationError, TransformerException { - logInfo(task.getName() + ": generating message subclasses"); + logInfo("generating message subclasses"); String outputDirectory = task.getOutputBaseDirectory() + "/" + task.getMessageDirectory() + "/"; writePackageDocumentation(outputDirectory, "Message classes"); @@ -195,7 +211,7 @@ private void generateMessageSubclasses(Task task) throws ParserConfigurationExce private void generateComponentClasses(Task task) throws ParserConfigurationException, SAXException, IOException, TransformerFactoryConfigurationError, TransformerException { - logInfo(task.getName() + ": generating component classes"); + logInfo("generating component classes"); String outputDirectory = task.getOutputBaseDirectory() + "/" + task.getMessageDirectory() + "/component/"; Document document = getSpecification(task); @@ -234,7 +250,7 @@ private Transformer createTransformer(Task task, String xsltFile) return transformerFactory.newTransformer(styleSource); } - private final Map specificationCache = new HashMap<>(); + private final Map specificationCache = new ConcurrentHashMap<>(); private Document getSpecification(Task task) throws ParserConfigurationException, SAXException, IOException { @@ -326,6 +342,8 @@ private void generateCodeFile(Task task, Document document, Map * Generate the Message and Field related source code. */ public void generate(Task task) { + String previousLogPrefix = logPrefix.get(); + logPrefix.set(task.getName()); try { generateFieldClasses(task); generateMessageBaseClass(task); @@ -337,6 +355,51 @@ public void generate(Task task) { throw e; } catch (Exception e) { throw new CodeGenerationException(e); + } finally { + if (previousLogPrefix == null) { + logPrefix.remove(); + } else { + logPrefix.set(previousLogPrefix); + } + } + } + + /* + * Generate the Message and Field related source code for multiple tasks. + */ + public void generate(List tasks) { + if (tasks == null || tasks.isEmpty()) { + return; + } + if (!getOption(PARALLEL_TASK_EXECUTION_OPTION, false) || tasks.size() == 1) { + for (Task task : tasks) { + generate(task); + } + return; + } + int parallelism = Math.min(tasks.size(), Math.max(2, Runtime.getRuntime().availableProcessors())); + ExecutorService executor = Executors.newFixedThreadPool(parallelism); + try { + List> futures = new ArrayList<>(); + for (Task task : tasks) { + futures.add(executor.submit(() -> generate(task))); + } + for (Future future : futures) { + try { + future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CodeGenerationException(e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new CodeGenerationException(cause); + } + } + } finally { + executor.shutdownNow(); } } @@ -464,6 +527,7 @@ public static void main(String[] args) { long start = System.currentTimeMillis(); final String[] versions = { "FIXT 1.1", "FIX 5.0", "FIX 4.4", "FIX 4.3", "FIX 4.2", "FIX 4.1", "FIX 4.0" }; + List tasks = new ArrayList<>(); for (String ver : versions) { Task task = new Task(); task.setName(ver); @@ -477,8 +541,9 @@ public static void main(String[] args) { task.setOverwrite(overwrite); task.setOrderedFields(orderedFields); task.setDecimalGenerated(useDecimal); - codeGenerator.generate(task); + tasks.add(task); } + codeGenerator.generate(tasks); double duration = System.currentTimeMillis() - start; DecimalFormat durationFormat = new DecimalFormat("#.###"); codeGenerator.logInfo("Time for generation: " diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java new file mode 100644 index 0000000000..81efe6ee26 --- /dev/null +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java @@ -0,0 +1,73 @@ +package org.quickfixj.codegenerator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.After; +import org.junit.Test; + +public class ParallelExecutionOptionTest { + + private static final String PARALLEL_OPTION = "generator.parallelExecution"; + + @After + public void clearParallelOption() { + System.clearProperty(PARALLEL_OPTION); + } + + @Test + public void testSequentialExecutionWhenParallelOptionIsDisabled() { + System.setProperty(PARALLEL_OPTION, "false"); + + TrackingMessageCodeGenerator generator = new TrackingMessageCodeGenerator(); + generator.generate(createTasks(4)); + + assertEquals(1, generator.getMaxConcurrentTasks()); + } + + @Test + public void testParallelExecutionWhenParallelOptionIsEnabled() { + System.setProperty(PARALLEL_OPTION, "true"); + + TrackingMessageCodeGenerator generator = new TrackingMessageCodeGenerator(); + generator.generate(createTasks(4)); + + assertTrue(generator.getMaxConcurrentTasks() > 1); + } + + private static List createTasks(int count) { + List tasks = new ArrayList<>(); + for (int i = 0; i < count; i++) { + MessageCodeGenerator.Task task = new MessageCodeGenerator.Task(); + task.setName("task-" + i); + tasks.add(task); + } + return tasks; + } + + private static class TrackingMessageCodeGenerator extends MessageCodeGenerator { + private final AtomicInteger currentConcurrentTasks = new AtomicInteger(); + private final AtomicInteger maxConcurrentTasks = new AtomicInteger(); + + @Override + public void generate(Task task) { + int concurrentTaskCount = currentConcurrentTasks.incrementAndGet(); + maxConcurrentTasks.accumulateAndGet(concurrentTaskCount, Math::max); + try { + Thread.sleep(100L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + currentConcurrentTasks.decrementAndGet(); + } + } + + int getMaxConcurrentTasks() { + return maxConcurrentTasks.get(); + } + } +} From fde52cea4b93898090f57e296123b277374bb1a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 18:24:02 +0000 Subject: [PATCH 02/21] feat: log parallel worker count for generator tasks Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/35104349-ab05-422d-861f-1bd35f967d72 Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/MessageCodeGenerator.java | 2 ++ .../ParallelExecutionOptionTest.java | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index 9a66c87062..fc0adcf1ce 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -378,6 +378,8 @@ public void generate(List tasks) { return; } int parallelism = Math.min(tasks.size(), Math.max(2, Runtime.getRuntime().availableProcessors())); + logInfo("parallel task execution enabled with " + parallelism + " worker(s) for " + + tasks.size() + " task(s)"); ExecutorService executor = Executors.newFixedThreadPool(parallelism); try { List> futures = new ArrayList<>(); diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java index 81efe6ee26..04b8e8c16a 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java @@ -37,6 +37,8 @@ public void testParallelExecutionWhenParallelOptionIsEnabled() { generator.generate(createTasks(4)); assertTrue(generator.getMaxConcurrentTasks() > 1); + assertTrue(generator.containsInfoLog("parallel task execution enabled with")); + assertTrue(generator.containsInfoLog("for 4 task(s)")); } private static List createTasks(int count) { @@ -52,6 +54,7 @@ private static List createTasks(int count) { private static class TrackingMessageCodeGenerator extends MessageCodeGenerator { private final AtomicInteger currentConcurrentTasks = new AtomicInteger(); private final AtomicInteger maxConcurrentTasks = new AtomicInteger(); + private final List infoMessages = new ArrayList<>(); @Override public void generate(Task task) { @@ -69,5 +72,19 @@ public void generate(Task task) { int getMaxConcurrentTasks() { return maxConcurrentTasks.get(); } + + @Override + protected void logInfo(String msg) { + infoMessages.add(msg); + } + + boolean containsInfoLog(String token) { + for (String infoMessage : infoMessages) { + if (infoMessage.contains(token)) { + return true; + } + } + return false; + } } } From d9804f4bd2786db95e59355adcad4454ed77141f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 01:54:03 +0000 Subject: [PATCH 03/21] feat: enable parallel generation by default Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/3e8c2bde-9960-418c-9e64-f8f4d450726f Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/MessageCodeGenerator.java | 2 +- .../codegenerator/ParallelExecutionOptionTest.java | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index fc0adcf1ce..8aaca4880e 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -371,7 +371,7 @@ public void generate(List tasks) { if (tasks == null || tasks.isEmpty()) { return; } - if (!getOption(PARALLEL_TASK_EXECUTION_OPTION, false) || tasks.size() == 1) { + if (!getOption(PARALLEL_TASK_EXECUTION_OPTION, true) || tasks.size() == 1) { for (Task task : tasks) { generate(task); } diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java index 04b8e8c16a..d5b1d65ed2 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java @@ -41,6 +41,18 @@ public void testParallelExecutionWhenParallelOptionIsEnabled() { assertTrue(generator.containsInfoLog("for 4 task(s)")); } + @Test + public void testParallelExecutionIsEnabledByDefault() { + System.clearProperty(PARALLEL_OPTION); + + TrackingMessageCodeGenerator generator = new TrackingMessageCodeGenerator(); + generator.generate(createTasks(4)); + + assertTrue(generator.getMaxConcurrentTasks() > 1); + assertTrue(generator.containsInfoLog("parallel task execution enabled with")); + assertTrue(generator.containsInfoLog("for 4 task(s)")); + } + private static List createTasks(int count) { List tasks = new ArrayList<>(); for (int i = 0; i < count; i++) { From 3703f5f2492dbed338c7082f5309345dc925dc32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 08:50:34 +0000 Subject: [PATCH 04/21] feat: enable aggregated mojo generation tasks Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/ae17ca4b-feff-443e-a2d8-ade9e02e1fc9 Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../quickfixj/codegenerator/GenerateMojo.java | 164 +++++++++++++++--- .../quickfixj-messages-all/pom.xml | 140 ++++++--------- 2 files changed, 191 insertions(+), 113 deletions(-) diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/GenerateMojo.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/GenerateMojo.java index 781c7f0579..5e540335e3 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/GenerateMojo.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/GenerateMojo.java @@ -28,6 +28,8 @@ import org.apache.maven.plugins.annotations.LifecyclePhase; import java.io.File; +import java.util.ArrayList; +import java.util.List; /** * A mojo that uses the quickfix code generator to generate @@ -45,6 +47,12 @@ public class GenerateMojo extends AbstractMojo { @Parameter(defaultValue="${basedir}/src/main/quickfixj/dictionary/FIX44.xml") private File dictFile; + /** + * Optional list of dictionaries/tasks to generate in a single execution. + */ + @Parameter + private List tasks; + /** * The source directory containing *.xsd files. */ @@ -72,7 +80,7 @@ public class GenerateMojo extends AbstractMojo { /** * The package for the generated source. */ - @Parameter(required = true) + @Parameter private String packaging; /** @@ -122,29 +130,8 @@ public void execute() throws MojoExecutionException { } generator.setLog(getLog()); - MessageCodeGenerator.Task task = new MessageCodeGenerator.Task(); - if (getLog().isInfoEnabled()) { - getLog().info("Initialising code generator task"); - } - - if (dictFile != null && dictFile.exists()) { - task.setSpecification(dictFile); - } else { - getLog().error("Cannot find file " + dictFile); - throw new MojoExecutionException("File could not be found or was NULL!"); - } - - log("Processing " + dictFile); - task.setName(dictFile.getName()); - task.setTransformDirectory(schemaDirectory); - task.setMessagePackage(packaging); - task.setOutputBaseDirectory(outputDirectory); - task.setFieldPackage(fieldPackage); - task.setUtcTimestampPrecision(utcTimestampPrecision); - task.setOverwrite(overwrite); - task.setOrderedFields(orderedFields); - task.setDecimalGenerated(decimal); - generator.generate(task); + List generationTasks = createGenerationTasks(); + generator.generate(generationTasks); } catch (Throwable t) { throw new MojoExecutionException("QuickFIX/J code generator execution failed", t); } @@ -163,6 +150,135 @@ private void log(final String msg) { getLog().info(msg); } + private List createGenerationTasks() throws MojoExecutionException { + List configuredTasks; + if (tasks == null || tasks.isEmpty()) { + configuredTasks = new ArrayList<>(); + GeneratorTask singleTask = new GeneratorTask(); + singleTask.setDictFile(dictFile); + singleTask.setPackaging(packaging); + singleTask.setFieldPackage(fieldPackage); + singleTask.setUtcTimestampPrecision(utcTimestampPrecision); + singleTask.setOverwrite(overwrite); + singleTask.setOrderedFields(orderedFields); + singleTask.setDecimal(decimal); + configuredTasks.add(singleTask); + } else { + configuredTasks = tasks; + } + + List generationTasks = new ArrayList<>(configuredTasks.size()); + for (GeneratorTask configuredTask : configuredTasks) { + MessageCodeGenerator.Task task = new MessageCodeGenerator.Task(); + if (getLog().isInfoEnabled()) { + getLog().info("Initialising code generator task"); + } + + if (configuredTask.getDictFile() != null && configuredTask.getDictFile().exists()) { + task.setSpecification(configuredTask.getDictFile()); + } else { + getLog().error("Cannot find file " + configuredTask.getDictFile()); + throw new MojoExecutionException("File could not be found or was NULL!"); + } + if (configuredTask.getPackaging() == null || configuredTask.getPackaging().isEmpty()) { + throw new MojoExecutionException("Packaging could not be found or was NULL!"); + } + + log("Processing " + configuredTask.getDictFile()); + task.setName(configuredTask.getDictFile().getName()); + task.setTransformDirectory(schemaDirectory); + task.setMessagePackage(configuredTask.getPackaging()); + task.setOutputBaseDirectory(outputDirectory); + task.setFieldPackage(configuredTask.getFieldPackage() != null ? configuredTask.getFieldPackage() : fieldPackage); + task.setUtcTimestampPrecision(configuredTask.getUtcTimestampPrecision() != null + ? configuredTask.getUtcTimestampPrecision() : utcTimestampPrecision); + task.setOverwrite(configuredTask.getOverwrite() != null ? configuredTask.getOverwrite() : overwrite); + task.setOrderedFields(configuredTask.getOrderedFields() != null ? configuredTask.getOrderedFields() : orderedFields); + task.setDecimalGenerated(configuredTask.getDecimal() != null ? configuredTask.getDecimal() : decimal); + generationTasks.add(task); + } + return generationTasks; + } + + public static class GeneratorTask { + @Parameter(required = true) + private File dictFile; + + @Parameter(required = true) + private String packaging; + + @Parameter + private String fieldPackage; + + @Parameter + private String utcTimestampPrecision; + + @Parameter + private Boolean overwrite; + + @Parameter + private Boolean orderedFields; + + @Parameter + private Boolean decimal; + + public File getDictFile() { + return dictFile; + } + + public void setDictFile(File dictFile) { + this.dictFile = dictFile; + } + + public String getPackaging() { + return packaging; + } + + public void setPackaging(String packaging) { + this.packaging = packaging; + } + + public String getFieldPackage() { + return fieldPackage; + } + + public void setFieldPackage(String fieldPackage) { + this.fieldPackage = fieldPackage; + } + + public String getUtcTimestampPrecision() { + return utcTimestampPrecision; + } + + public void setUtcTimestampPrecision(String utcTimestampPrecision) { + this.utcTimestampPrecision = utcTimestampPrecision; + } + + public Boolean getOverwrite() { + return overwrite; + } + + public void setOverwrite(Boolean overwrite) { + this.overwrite = overwrite; + } + + public Boolean getOrderedFields() { + return orderedFields; + } + + public void setOrderedFields(Boolean orderedFields) { + this.orderedFields = orderedFields; + } + + public Boolean getDecimal() { + return decimal; + } + + public void setDecimal(Boolean decimal) { + this.decimal = decimal; + } + } + /** * Returns the destination directory to used during code generation. * diff --git a/quickfixj-messages/quickfixj-messages-all/pom.xml b/quickfixj-messages/quickfixj-messages-all/pom.xml index 571d10e57c..300cc6540c 100644 --- a/quickfixj-messages/quickfixj-messages-all/pom.xml +++ b/quickfixj-messages/quickfixj-messages-all/pom.xml @@ -249,99 +249,61 @@ ${project.version} - fix40 + all-fix-versions generate - ../quickfixj-messages-fix40/src/main/resources/FIX40.xml - quickfix.fix40 - quickfix.field - ${generator.decimal} - - - - fix41 - - generate - - - ../quickfixj-messages-fix41/src/main/resources/FIX41.xml - quickfix.fix41 - quickfix.field - ${generator.decimal} - - - - fix42 - - generate - - - ../quickfixj-messages-fix42/src/main/resources/FIX42.xml - quickfix.fix42 - quickfix.field - ${generator.decimal} - - - - fix43 - - generate - - - ../quickfixj-messages-fix43/src/main/resources/FIX43.xml - quickfix.fix43 - quickfix.field - ${generator.decimal} - - - - fix44 - - generate - - - ../quickfixj-messages-fix44/src/main/resources/FIX44.modified.xml - quickfix.fix44 - quickfix.field - ${generator.decimal} - - - - fix50 - - generate - - - ../quickfixj-messages-fix50/src/main/resources/FIX50.xml - quickfix.fix50 - quickfix.field - ${generator.decimal} - - - - fix50sp1 - - generate - - - ../quickfixj-messages-fix50sp1/src/main/resources/FIX50SP1.modified.xml - quickfix.fix50sp1 - quickfix.field - ${generator.decimal} - - - - fix50sp2 - - generate - - - ../quickfixj-messages-fix50sp2/src/main/resources/FIX50SP2.modified.xml - quickfix.fix50sp2 - quickfix.field - ${generator.decimal} + + + ../quickfixj-messages-fix40/src/main/resources/FIX40.xml + quickfix.fix40 + quickfix.field + ${generator.decimal} + + + ../quickfixj-messages-fix41/src/main/resources/FIX41.xml + quickfix.fix41 + quickfix.field + ${generator.decimal} + + + ../quickfixj-messages-fix42/src/main/resources/FIX42.xml + quickfix.fix42 + quickfix.field + ${generator.decimal} + + + ../quickfixj-messages-fix43/src/main/resources/FIX43.xml + quickfix.fix43 + quickfix.field + ${generator.decimal} + + + ../quickfixj-messages-fix44/src/main/resources/FIX44.modified.xml + quickfix.fix44 + quickfix.field + ${generator.decimal} + + + ../quickfixj-messages-fix50/src/main/resources/FIX50.xml + quickfix.fix50 + quickfix.field + ${generator.decimal} + + + ../quickfixj-messages-fix50sp1/src/main/resources/FIX50SP1.modified.xml + quickfix.fix50sp1 + quickfix.field + ${generator.decimal} + + + ../quickfixj-messages-fix50sp2/src/main/resources/FIX50SP2.modified.xml + quickfix.fix50sp2 + quickfix.field + ${generator.decimal} + + From 1804050910528ec17779535ace871b35da038b42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 10:01:40 +0000 Subject: [PATCH 05/21] feat: log task start and finish progress Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/1dcb0259-f870-44c3-9304-d4eb7980b33b Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/MessageCodeGenerator.java | 30 ++++++++++++++----- .../ParallelExecutionOptionTest.java | 6 ++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index 8aaca4880e..12bdf19742 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -371,20 +371,23 @@ public void generate(List tasks) { if (tasks == null || tasks.isEmpty()) { return; } - if (!getOption(PARALLEL_TASK_EXECUTION_OPTION, true) || tasks.size() == 1) { - for (Task task : tasks) { - generate(task); + int totalTasks = tasks.size(); + if (!getOption(PARALLEL_TASK_EXECUTION_OPTION, true) || totalTasks == 1) { + for (int i = 0; i < totalTasks; i++) { + processTaskWithProgress(tasks.get(i), i + 1, totalTasks); } return; } - int parallelism = Math.min(tasks.size(), Math.max(2, Runtime.getRuntime().availableProcessors())); + int parallelism = Math.min(totalTasks, Math.max(2, Runtime.getRuntime().availableProcessors())); logInfo("parallel task execution enabled with " + parallelism + " worker(s) for " - + tasks.size() + " task(s)"); + + totalTasks + " task(s)"); ExecutorService executor = Executors.newFixedThreadPool(parallelism); try { List> futures = new ArrayList<>(); - for (Task task : tasks) { - futures.add(executor.submit(() -> generate(task))); + for (int i = 0; i < totalTasks; i++) { + Task task = tasks.get(i); + int taskIndex = i + 1; + futures.add(executor.submit(() -> processTaskWithProgress(task, taskIndex, totalTasks))); } for (Future future : futures) { try { @@ -405,6 +408,19 @@ public void generate(List tasks) { } } + private void processTaskWithProgress(Task task, int taskIndex, int totalTasks) { + String taskName = task.getName(); + if (taskName == null || taskName.isEmpty()) { + taskName = "unnamed"; + } + logInfo("Started task for " + taskName + " (" + taskIndex + " / " + totalTasks + ")"); + try { + generate(task); + } finally { + logInfo("Finished task for " + taskName + " (" + taskIndex + " / " + totalTasks + ")"); + } + } + public static class Task { private String name; private File specification; diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java index d5b1d65ed2..44bbee4950 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java @@ -27,6 +27,8 @@ public void testSequentialExecutionWhenParallelOptionIsDisabled() { generator.generate(createTasks(4)); assertEquals(1, generator.getMaxConcurrentTasks()); + assertTrue(generator.containsInfoLog("Started task for task-0 (1 / 4)")); + assertTrue(generator.containsInfoLog("Finished task for task-3 (4 / 4)")); } @Test @@ -39,6 +41,8 @@ public void testParallelExecutionWhenParallelOptionIsEnabled() { assertTrue(generator.getMaxConcurrentTasks() > 1); assertTrue(generator.containsInfoLog("parallel task execution enabled with")); assertTrue(generator.containsInfoLog("for 4 task(s)")); + assertTrue(generator.containsInfoLog("Started task for task-0 (1 / 4)")); + assertTrue(generator.containsInfoLog("Finished task for task-3 (4 / 4)")); } @Test @@ -51,6 +55,8 @@ public void testParallelExecutionIsEnabledByDefault() { assertTrue(generator.getMaxConcurrentTasks() > 1); assertTrue(generator.containsInfoLog("parallel task execution enabled with")); assertTrue(generator.containsInfoLog("for 4 task(s)")); + assertTrue(generator.containsInfoLog("Started task for task-0 (1 / 4)")); + assertTrue(generator.containsInfoLog("Finished task for task-3 (4 / 4)")); } private static List createTasks(int count) { From 8e6161646add5f9038e1c10b83a91cf096c0d3dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 16:44:54 +0000 Subject: [PATCH 06/21] test: remove order-dependent parallel finish assertion Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/58f57ec9-970d-4117-8c1f-67f4377c307c Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/ParallelExecutionOptionTest.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java index 44bbee4950..0de9d6591a 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java @@ -55,8 +55,7 @@ public void testParallelExecutionIsEnabledByDefault() { assertTrue(generator.getMaxConcurrentTasks() > 1); assertTrue(generator.containsInfoLog("parallel task execution enabled with")); assertTrue(generator.containsInfoLog("for 4 task(s)")); - assertTrue(generator.containsInfoLog("Started task for task-0 (1 / 4)")); - assertTrue(generator.containsInfoLog("Finished task for task-3 (4 / 4)")); + assertTrue(generator.containsFinishLogForAllTasks(4)); } private static List createTasks(int count) { @@ -104,5 +103,14 @@ boolean containsInfoLog(String token) { } return false; } + + boolean containsFinishLogForAllTasks(int taskCount) { + for (int i = 0; i < taskCount; i++) { + if (!containsInfoLog("Finished task for task-" + i)) { + return false; + } + } + return true; + } } } From 1b325a9627e66a82249282266248134cb9b26709 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 19:52:23 +0000 Subject: [PATCH 07/21] test: make parallel log capture thread-safe in flaky test Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/5b3b5f51-92b8-47c2-821b-05c1abc079c8 Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../quickfixj/codegenerator/ParallelExecutionOptionTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java index 0de9d6591a..a17909ddf9 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.CopyOnWriteArrayList; import org.junit.After; import org.junit.Test; @@ -71,7 +72,7 @@ private static List createTasks(int count) { private static class TrackingMessageCodeGenerator extends MessageCodeGenerator { private final AtomicInteger currentConcurrentTasks = new AtomicInteger(); private final AtomicInteger maxConcurrentTasks = new AtomicInteger(); - private final List infoMessages = new ArrayList<>(); + private final List infoMessages = new CopyOnWriteArrayList<>(); @Override public void generate(Task task) { From 99c98cab957408f32ae339e433551e678af35c44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 20:57:45 +0000 Subject: [PATCH 08/21] test: add parallel same-directory field generation race coverage Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/9820da23-de3d-4c58-a565-a1e63f9e1dc9 Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/MessageCodeGenerator.java | 42 +++--- .../ParallelFieldGenerationRaceTest.java | 136 ++++++++++++++++++ 2 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index 12bdf19742..e2eeb4869a 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -55,6 +55,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentMap; import javax.xml.XMLConstants; @@ -83,6 +84,7 @@ public class MessageCodeGenerator { Collections.unmodifiableSet(new HashSet<>(Arrays.asList("SECONDS", "MILLIS", "MICROS", "NANOS"))); private final ThreadLocal logPrefix = new ThreadLocal<>(); + private final ConcurrentMap outputFileLocks = new ConcurrentHashMap<>(); protected String formatLogMessage(String msg) { String prefix = logPrefix.get(); @@ -273,12 +275,14 @@ private void writePackageDocumentation(String outputDirectory, String descriptio if (!parentDirectory.exists()) { parentDirectory.mkdirs(); } - PrintStream out = new PrintStream(new FileOutputStream(packageDescription)); - out.println(""); - out.println("</head>"); - out.println("<body>" + description + "</body>"); - out.println("</html>"); - out.close(); + synchronized (lockForFile(packageDescription)) { + try (PrintStream out = new PrintStream(new FileOutputStream(packageDescription))) { + out.println("<html>"); + out.println("<head><title/></head>"); + out.println("<body>" + description + "</body>"); + out.println("</html>"); + } + } } private List<String> getNames(Element element, String path) { @@ -323,21 +327,27 @@ private void generateCodeFile(Task task, Document document, Map<String, String> } } - DOMSource source = new DOMSource(document); - FileOutputStream fos = new FileOutputStream(outputFile); - BufferedOutputStream bos = new BufferedOutputStream(fos); - try { - StreamResult result = new StreamResult(bos); - transformer.transform(source, result); - } finally { + synchronized (lockForFile(outputFile)) { + DOMSource source = new DOMSource(document); + FileOutputStream fos = new FileOutputStream(outputFile); + BufferedOutputStream bos = new BufferedOutputStream(fos); try { - bos.close(); - } catch (IOException ioe) { - logError("error closing " + outputFile, ioe); + StreamResult result = new StreamResult(bos); + transformer.transform(source, result); + } finally { + try { + bos.close(); + } catch (IOException ioe) { + logError("error closing " + outputFile, ioe); + } } } } + private Object lockForFile(File file) { + return outputFileLocks.computeIfAbsent(file.getAbsolutePath(), path -> new Object()); + } + /* * Generate the Message and Field related source code. */ diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java new file mode 100644 index 0000000000..6faf0aba9e --- /dev/null +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -0,0 +1,136 @@ +package org.quickfixj.codegenerator; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Stream; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ParallelFieldGenerationRaceTest { + + private static final String PARALLEL_OPTION = "generator.parallelExecution"; + private static final int TOTAL_FIELDS = 100; + private static final int PARALLEL_TASKS = 8; + private static final int PARALLEL_ROUNDS = 8; + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @After + public void clearParallelOption() { + System.clearProperty(PARALLEL_OPTION); + } + + @Test + public void testParallelFieldGenerationMatchesSingleThreadedGolden() throws Exception { + File dictionary = createDictionaryWith100Fields(); + File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); + + File goldenOutput = tempFolder.newFolder("golden"); + MessageCodeGenerator generator = new MessageCodeGenerator(); + generator.generate(createTask("golden", dictionary, transformDirectory, goldenOutput)); + + Map<String, String> goldenFieldSources = collectFieldSources(goldenOutput); + assertEquals(TOTAL_FIELDS, goldenFieldSources.size()); + + System.setProperty(PARALLEL_OPTION, "true"); + for (int round = 0; round < PARALLEL_ROUNDS; round++) { + File parallelOutput = tempFolder.newFolder("parallel-" + round); + generator.generate(createParallelTasks(dictionary, transformDirectory, parallelOutput)); + assertEquals("Mismatch in round " + round, goldenFieldSources, + collectFieldSources(parallelOutput)); + } + } + + private MessageCodeGenerator.Task createTask(String name, File dictionary, File transformDirectory, + File outputDirectory) { + MessageCodeGenerator.Task task = new MessageCodeGenerator.Task(); + task.setName(name); + task.setSpecification(dictionary); + task.setTransformDirectory(transformDirectory); + task.setMessagePackage("quickfix.race"); + task.setOutputBaseDirectory(outputDirectory); + task.setFieldPackage("quickfix.field"); + task.setOverwrite(true); + task.setOrderedFields(true); + task.setDecimalGenerated(true); + return task; + } + + private List<MessageCodeGenerator.Task> createParallelTasks(File dictionary, File transformDirectory, + File outputDirectory) { + List<MessageCodeGenerator.Task> tasks = new ArrayList<>(); + for (int i = 0; i < PARALLEL_TASKS; i++) { + tasks.add(createTask("race-" + i, dictionary, transformDirectory, outputDirectory)); + } + return tasks; + } + + private Map<String, String> collectFieldSources(File outputDirectory) throws Exception { + Map<String, String> sources = new TreeMap<>(); + Path fieldDir = outputDirectory.toPath().resolve("quickfix/field"); + try (Stream<Path> stream = Files.walk(fieldDir)) { + stream + .filter(path -> path.toString().endsWith(".java")) + .forEach(path -> { + try { + String relative = fieldDir.relativize(path).toString(); + String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + sources.put(relative, content); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + return sources; + } + + private File createDictionaryWith100Fields() throws Exception { + File dictionary = tempFolder.newFile("RaceCondition100Fields.xml"); + StringBuilder xml = new StringBuilder(); + xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); + xml.append("<fix major=\"4\" minor=\"4\">\n"); + xml.append(" <header>\n"); + xml.append(" <field name=\"BeginString\" required=\"Y\"/>\n"); + xml.append(" <field name=\"BodyLength\" required=\"Y\"/>\n"); + xml.append(" <field name=\"MsgType\" required=\"Y\"/>\n"); + xml.append(" <field name=\"SenderCompID\" required=\"Y\"/>\n"); + xml.append(" <field name=\"TargetCompID\" required=\"Y\"/>\n"); + xml.append(" <field name=\"MsgSeqNum\" required=\"Y\"/>\n"); + xml.append(" <field name=\"SendingTime\" required=\"Y\"/>\n"); + xml.append(" </header>\n"); + xml.append(" <trailer>\n"); + xml.append(" <field name=\"CheckSum\" required=\"Y\"/>\n"); + xml.append(" </trailer>\n"); + xml.append(" <messages>\n"); + xml.append(" </messages>\n"); + xml.append(" <fields>\n"); + xml.append(" <field number=\"8\" name=\"BeginString\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"9\" name=\"BodyLength\" type=\"LENGTH\"/>\n"); + xml.append(" <field number=\"35\" name=\"MsgType\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"49\" name=\"SenderCompID\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"56\" name=\"TargetCompID\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"34\" name=\"MsgSeqNum\" type=\"SEQNUM\"/>\n"); + xml.append(" <field number=\"52\" name=\"SendingTime\" type=\"UTCTIMESTAMP\"/>\n"); + xml.append(" <field number=\"10\" name=\"CheckSum\" type=\"STRING\"/>\n"); + for (int i = 1; i <= TOTAL_FIELDS - 8; i++) { + xml.append(" <field number=\"").append(10000 + i).append("\" name=\"RaceField") + .append(i).append("\" type=\"STRING\"/>\n"); + } + xml.append(" </fields>\n"); + xml.append("</fix>\n"); + Files.write(dictionary.toPath(), xml.toString().getBytes(StandardCharsets.UTF_8)); + return dictionary; + } +} From 3caae82627bae29644561f6c06a83d23096c3018 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 21:05:49 +0000 Subject: [PATCH 09/21] refactor: remove per-output-file write synchronization from generator Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/2359a993-e9c3-4b52-8fe8-4649ff15b8da Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/MessageCodeGenerator.java | 42 +++++++------------ 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index e2eeb4869a..12bdf19742 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -55,7 +55,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentMap; import javax.xml.XMLConstants; @@ -84,7 +83,6 @@ public class MessageCodeGenerator { Collections.unmodifiableSet(new HashSet<>(Arrays.asList("SECONDS", "MILLIS", "MICROS", "NANOS"))); private final ThreadLocal<String> logPrefix = new ThreadLocal<>(); - private final ConcurrentMap<String, Object> outputFileLocks = new ConcurrentHashMap<>(); protected String formatLogMessage(String msg) { String prefix = logPrefix.get(); @@ -275,14 +273,12 @@ private void writePackageDocumentation(String outputDirectory, String descriptio if (!parentDirectory.exists()) { parentDirectory.mkdirs(); } - synchronized (lockForFile(packageDescription)) { - try (PrintStream out = new PrintStream(new FileOutputStream(packageDescription))) { - out.println("<html>"); - out.println("<head><title/></head>"); - out.println("<body>" + description + "</body>"); - out.println("</html>"); - } - } + PrintStream out = new PrintStream(new FileOutputStream(packageDescription)); + out.println("<html>"); + out.println("<head><title/></head>"); + out.println("<body>" + description + "</body>"); + out.println("</html>"); + out.close(); } private List<String> getNames(Element element, String path) { @@ -327,27 +323,21 @@ private void generateCodeFile(Task task, Document document, Map<String, String> } } - synchronized (lockForFile(outputFile)) { - DOMSource source = new DOMSource(document); - FileOutputStream fos = new FileOutputStream(outputFile); - BufferedOutputStream bos = new BufferedOutputStream(fos); + DOMSource source = new DOMSource(document); + FileOutputStream fos = new FileOutputStream(outputFile); + BufferedOutputStream bos = new BufferedOutputStream(fos); + try { + StreamResult result = new StreamResult(bos); + transformer.transform(source, result); + } finally { try { - StreamResult result = new StreamResult(bos); - transformer.transform(source, result); - } finally { - try { - bos.close(); - } catch (IOException ioe) { - logError("error closing " + outputFile, ioe); - } + bos.close(); + } catch (IOException ioe) { + logError("error closing " + outputFile, ioe); } } } - private Object lockForFile(File file) { - return outputFileLocks.computeIfAbsent(file.getAbsolutePath(), path -> new Object()); - } - /* * Generate the Message and Field related source code. */ From 53358c4c3b90e2a25d448bf46f0d6533bf446832 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 06:41:19 +0000 Subject: [PATCH 10/21] test: increase ParallelFieldGenerationRaceTest stress level Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/2d153902-1e67-4c93-bb99-a2527772ff53 Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/ParallelFieldGenerationRaceTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index 6faf0aba9e..d63a56842e 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -20,8 +20,8 @@ public class ParallelFieldGenerationRaceTest { private static final String PARALLEL_OPTION = "generator.parallelExecution"; - private static final int TOTAL_FIELDS = 100; - private static final int PARALLEL_TASKS = 8; + private static final int TOTAL_FIELDS = 1000; + private static final int PARALLEL_TASKS = 16; private static final int PARALLEL_ROUNDS = 8; @Rule @@ -34,7 +34,7 @@ public void clearParallelOption() { @Test public void testParallelFieldGenerationMatchesSingleThreadedGolden() throws Exception { - File dictionary = createDictionaryWith100Fields(); + File dictionary = createDictionaryWith1000Fields(); File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); File goldenOutput = tempFolder.newFolder("golden"); @@ -96,8 +96,8 @@ private Map<String, String> collectFieldSources(File outputDirectory) throws Exc return sources; } - private File createDictionaryWith100Fields() throws Exception { - File dictionary = tempFolder.newFile("RaceCondition100Fields.xml"); + private File createDictionaryWith1000Fields() throws Exception { + File dictionary = tempFolder.newFile("RaceCondition1000Fields.xml"); StringBuilder xml = new StringBuilder(); xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); xml.append("<fix major=\"4\" minor=\"4\">\n"); From 9c8e038e15127f7df2e025c4396c1151c1f0a13f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 12:27:20 +0000 Subject: [PATCH 11/21] feat: add optional parallel thread count for code generator Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/d36323a4-9bbd-4af6-b90a-3a610f390115 Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- .../codegenerator/MessageCodeGenerator.java | 22 ++++++++++++++++++- .../ParallelExecutionOptionTest.java | 17 +++++++++++++- .../ParallelFieldGenerationRaceTest.java | 3 +++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index 12bdf19742..c0d6d7df28 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -68,6 +68,7 @@ public class MessageCodeGenerator { private static final String OVERWRITE_OPTION = "generator.overwrite"; private static final String UTC_TIMESTAMP_PRECISION_OPTION = "generator.utcTimestampPrecision"; private static final String PARALLEL_TASK_EXECUTION_OPTION = "generator.parallelExecution"; + private static final String PARALLEL_THREAD_COUNT_OPTION = "generator.parallelThreads"; // An arbitrary serial UID which will have to be changed when messages and fields won't be compatible with next versions in terms // of java serialization. @@ -378,7 +379,7 @@ public void generate(List<Task> tasks) { } return; } - int parallelism = Math.min(totalTasks, Math.max(2, Runtime.getRuntime().availableProcessors())); + int parallelism = getParallelism(totalTasks); logInfo("parallel task execution enabled with " + parallelism + " worker(s) for " + totalTasks + " task(s)"); ExecutorService executor = Executors.newFixedThreadPool(parallelism); @@ -421,6 +422,25 @@ private void processTaskWithProgress(Task task, int taskIndex, int totalTasks) { } } + private int getParallelism(int totalTasks) { + int defaultParallelism = Math.min(totalTasks, Math.max(2, Runtime.getRuntime().availableProcessors())); + String configuredParallelThreads = getOption(PARALLEL_THREAD_COUNT_OPTION, null); + if (configuredParallelThreads == null) { + return defaultParallelism; + } + try { + int configuredParallelism = Integer.parseInt(configuredParallelThreads.trim()); + if (configuredParallelism > 0) { + return Math.min(totalTasks, configuredParallelism); + } + } catch (NumberFormatException ignored) { + // ignored, fallback to default below + } + logInfo("ignoring invalid " + PARALLEL_THREAD_COUNT_OPTION + " value '" + configuredParallelThreads + + "', using " + defaultParallelism + " worker(s)"); + return defaultParallelism; + } + public static class Task { private String name; private File specification; diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java index a17909ddf9..84ab8482ed 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelExecutionOptionTest.java @@ -14,10 +14,12 @@ public class ParallelExecutionOptionTest { private static final String PARALLEL_OPTION = "generator.parallelExecution"; + private static final String PARALLEL_THREADS_OPTION = "generator.parallelThreads"; @After - public void clearParallelOption() { + public void clearParallelOptions() { System.clearProperty(PARALLEL_OPTION); + System.clearProperty(PARALLEL_THREADS_OPTION); } @Test @@ -46,6 +48,19 @@ public void testParallelExecutionWhenParallelOptionIsEnabled() { assertTrue(generator.containsInfoLog("Finished task for task-3 (4 / 4)")); } + @Test + public void testParallelExecutionWhenParallelThreadsAreConfigured() { + System.setProperty(PARALLEL_OPTION, "true"); + System.setProperty(PARALLEL_THREADS_OPTION, "2"); + + TrackingMessageCodeGenerator generator = new TrackingMessageCodeGenerator(); + generator.generate(createTasks(4)); + + assertEquals(2, generator.getMaxConcurrentTasks()); + assertTrue(generator.containsInfoLog("parallel task execution enabled with 2 worker(s)")); + assertTrue(generator.containsInfoLog("for 4 task(s)")); + } + @Test public void testParallelExecutionIsEnabledByDefault() { System.clearProperty(PARALLEL_OPTION); diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index d63a56842e..ba61c6868a 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -20,6 +20,7 @@ public class ParallelFieldGenerationRaceTest { private static final String PARALLEL_OPTION = "generator.parallelExecution"; + private static final String PARALLEL_THREADS_OPTION = "generator.parallelThreads"; private static final int TOTAL_FIELDS = 1000; private static final int PARALLEL_TASKS = 16; private static final int PARALLEL_ROUNDS = 8; @@ -30,6 +31,7 @@ public class ParallelFieldGenerationRaceTest { @After public void clearParallelOption() { System.clearProperty(PARALLEL_OPTION); + System.clearProperty(PARALLEL_THREADS_OPTION); } @Test @@ -45,6 +47,7 @@ public void testParallelFieldGenerationMatchesSingleThreadedGolden() throws Exce assertEquals(TOTAL_FIELDS, goldenFieldSources.size()); System.setProperty(PARALLEL_OPTION, "true"); + System.setProperty(PARALLEL_THREADS_OPTION, Integer.toString(PARALLEL_TASKS)); for (int round = 0; round < PARALLEL_ROUNDS; round++) { File parallelOutput = tempFolder.newFolder("parallel-" + round); generator.generate(createParallelTasks(dictionary, transformDirectory, parallelOutput)); From 09795a58f9ba2d73b845ebaeea6134858c3a3ca4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:13:12 +0000 Subject: [PATCH 12/21] build: enable verbose javac diagnostics Agent-Logs-Url: https://github.com/quickfix-j/quickfixj/sessions/5e22437f-c919-41cc-bd7b-3e321fd6465e Co-authored-by: chrjohn <6644028+chrjohn@users.noreply.github.com> --- pom.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pom.xml b/pom.xml index 3fad05467b..7cad66d1df 100644 --- a/pom.xml +++ b/pom.xml @@ -235,6 +235,9 @@ <forceLegacyJavacApi>true</forceLegacyJavacApi> <!-- https://bugs.openjdk.java.net/browse/JDK-8216202 --> <meminitial>2g</meminitial> <maxmem>4g</maxmem> + <compilerArgs> + <arg>-Xdiags:verbose</arg> + </compilerArgs> </configuration> </plugin> <plugin> From d08f2a69ea7bf6fa9e8c11e7ac0f8f546de12bf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 18:22:08 +0000 Subject: [PATCH 13/21] ci: print generated sources on compile errors --- .github/workflows/maven.yml | 45 +++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index b0d4ef606d..2dcc729ae2 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -26,7 +26,27 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' - name: Test with Maven - run: ./mvnw install -B -V -D"maven.javadoc.skip"="true" -P"skipBundlePlugin,minimal-fix-latest" -D"java.util.logging.config.file"="${{github.workspace}}/quickfixj-core/src/test/resources/logging.properties" -D"http.keepAlive"="false" -D"maven.wagon.http.pool"="false" -D"maven.wagon.httpconnectionManager.ttlSeconds"="120" + shell: bash + run: | + set +e + log_file="${RUNNER_TEMP}/maven-build.log" + ./mvnw install -B -V -D"maven.javadoc.skip"="true" -P"skipBundlePlugin,minimal-fix-latest" -D"java.util.logging.config.file"="${{github.workspace}}/quickfixj-core/src/test/resources/logging.properties" -D"http.keepAlive"="false" -D"maven.wagon.http.pool"="false" -D"maven.wagon.httpconnectionManager.ttlSeconds"="120" 2>&1 | tee "${log_file}" + status=${PIPESTATUS[0]} + + if [ "${status}" -ne 0 ] && grep -q "COMPILATION ERROR" "${log_file}"; then + grep -Eo '/[^[:space:]]+\.java:\[[0-9]+,[0-9]+\]' "${log_file}" \ + | sed -E 's/:\[[0-9]+,[0-9]+\]$//' \ + | sort -u \ + | while IFS= read -r file; do + if [ -f "${file}" ]; then + echo "::group::Contents of ${file}" + cat "${file}" + echo "::endgroup::" + fi + done + fi + + exit "${status}" test-windows: runs-on: ${{ matrix.os }} @@ -53,4 +73,25 @@ jobs: java-version: ${{ matrix.java }} cache: 'maven' - name: Test with Maven on Windows - run: ./mvnw.cmd install -B -V -D"maven.javadoc.skip"="true" -P"skipBundlePlugin,minimal-fix-latest" -D"java.util.logging.config.file"="${{github.workspace}}/quickfixj-core/src/test/resources/logging.properties" -D"http.keepAlive"="false" -D"maven.wagon.http.pool"="false" -D"maven.wagon.httpconnectionManager.ttlSeconds"="120" + shell: pwsh + run: | + $logFile = Join-Path $env:RUNNER_TEMP "maven-build.log" + & ./mvnw.cmd install -B -V -D"maven.javadoc.skip"="true" -P"skipBundlePlugin,minimal-fix-latest" -D"java.util.logging.config.file"="${{github.workspace}}/quickfixj-core/src/test/resources/logging.properties" -D"http.keepAlive"="false" -D"maven.wagon.http.pool"="false" -D"maven.wagon.httpconnectionManager.ttlSeconds"="120" 2>&1 | Tee-Object -FilePath $logFile + $status = $LASTEXITCODE + + if ($status -ne 0 -and (Select-String -Path $logFile -Pattern 'COMPILATION ERROR' -Quiet)) { + $files = Select-String -Path $logFile -Pattern '(?<file>(?:[A-Za-z]:)?[\\/][^:\s]+\.java):\[\d+,\d+\]' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $_.Groups['file'].Value } | + Sort-Object -Unique + + foreach ($file in $files) { + if (Test-Path -Path $file) { + Write-Host "::group::Contents of $file" + Get-Content -Path $file + Write-Host "::endgroup::" + } + } + } + + exit $status From f16e57ec1dad7f671e31d4e3c0eb0c18bf163e8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 17:53:42 +0000 Subject: [PATCH 14/21] Add deterministic concurrent field-writer race test seam --- .../codegenerator/MessageCodeGenerator.java | 12 +- .../ParallelFieldGenerationRaceTest.java | 206 ++++++++++++++++++ 2 files changed, 214 insertions(+), 4 deletions(-) diff --git a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java index c0d6d7df28..186fd6f73a 100644 --- a/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java +++ b/quickfixj-codegenerator/src/main/java/org/quickfixj/codegenerator/MessageCodeGenerator.java @@ -40,6 +40,7 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStream; import java.io.PrintStream; import java.text.DecimalFormat; import java.util.ArrayList; @@ -325,20 +326,23 @@ private void generateCodeFile(Task task, Document document, Map<String, String> } DOMSource source = new DOMSource(document); - FileOutputStream fos = new FileOutputStream(outputFile); - BufferedOutputStream bos = new BufferedOutputStream(fos); + OutputStream outputStream = createOutputStream(outputFile); try { - StreamResult result = new StreamResult(bos); + StreamResult result = new StreamResult(outputStream); transformer.transform(source, result); } finally { try { - bos.close(); + outputStream.close(); } catch (IOException ioe) { logError("error closing " + outputFile, ioe); } } } + protected OutputStream createOutputStream(File outputFile) throws FileNotFoundException { + return new BufferedOutputStream(new FileOutputStream(outputFile)); + } + /* * Generate the Message and Field related source code. */ diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index ba61c6868a..417d42a5d1 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -1,15 +1,25 @@ package org.quickfixj.codegenerator; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import org.junit.After; @@ -24,6 +34,8 @@ public class ParallelFieldGenerationRaceTest { private static final int TOTAL_FIELDS = 1000; private static final int PARALLEL_TASKS = 16; private static final int PARALLEL_ROUNDS = 8; + private static final String TARGET_FIELD_NAME = "RaceSharedField"; + private static final String TARGET_FIELD_FILE = TARGET_FIELD_NAME + ".java"; @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); @@ -34,6 +46,34 @@ public void clearParallelOption() { System.clearProperty(PARALLEL_THREADS_OPTION); } + @Test + public void testDeterministicCorruptionWithTwoConcurrentWriters() throws Exception { + File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); + File dictionaryA = createTwoWriterDictionary("9001", "ALPHA"); + File dictionaryB = createTwoWriterDictionary("9002", "OMEGA"); + + MessageCodeGenerator sequential = new MessageCodeGenerator(); + String expectedA = generateAndReadTargetField(sequential, createTask("expected-a", dictionaryA, + transformDirectory, tempFolder.newFolder("expected-a"))); + String expectedB = generateAndReadTargetField(sequential, createTask("expected-b", dictionaryB, + transformDirectory, tempFolder.newFolder("expected-b"))); + int splitPosition = calculateSplitPosition(expectedA, expectedB); + + System.setProperty(PARALLEL_OPTION, "true"); + System.setProperty(PARALLEL_THREADS_OPTION, "2"); + + File raceOutput = tempFolder.newFolder("deterministic-race"); + MessageCodeGenerator generator = new CoordinatedOutputMessageCodeGenerator(TARGET_FIELD_FILE, + splitPosition); + generator.generate(Arrays.asList( + createTask("race-a", dictionaryA, transformDirectory, raceOutput), + createTask("race-b", dictionaryB, transformDirectory, raceOutput))); + + String actual = readTargetField(raceOutput); + assertFalse("Expected a mixed/corrupt output, but got variant A", expectedA.equals(actual)); + assertFalse("Expected a mixed/corrupt output, but got variant B", expectedB.equals(actual)); + } + @Test public void testParallelFieldGenerationMatchesSingleThreadedGolden() throws Exception { File dictionary = createDictionaryWith1000Fields(); @@ -80,6 +120,17 @@ private List<MessageCodeGenerator.Task> createParallelTasks(File dictionary, Fil return tasks; } + private String generateAndReadTargetField(MessageCodeGenerator generator, MessageCodeGenerator.Task task) + throws Exception { + generator.generate(task); + return readTargetField(task.getOutputBaseDirectory()); + } + + private String readTargetField(File outputDirectory) throws Exception { + Path target = outputDirectory.toPath().resolve("quickfix/field").resolve(TARGET_FIELD_FILE); + return new String(Files.readAllBytes(target), StandardCharsets.UTF_8); + } + private Map<String, String> collectFieldSources(File outputDirectory) throws Exception { Map<String, String> sources = new TreeMap<>(); Path fieldDir = outputDirectory.toPath().resolve("quickfix/field"); @@ -136,4 +187,159 @@ private File createDictionaryWith1000Fields() throws Exception { Files.write(dictionary.toPath(), xml.toString().getBytes(StandardCharsets.UTF_8)); return dictionary; } + + private File createTwoWriterDictionary(String raceFieldNumber, String raceEnumPrefix) throws Exception { + File dictionary = tempFolder.newFile("RaceCondition-" + raceFieldNumber + ".xml"); + StringBuilder xml = new StringBuilder(); + xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); + xml.append("<fix major=\"4\" minor=\"4\">\n"); + xml.append(" <header>\n"); + xml.append(" <field name=\"BeginString\" required=\"Y\"/>\n"); + xml.append(" <field name=\"BodyLength\" required=\"Y\"/>\n"); + xml.append(" <field name=\"MsgType\" required=\"Y\"/>\n"); + xml.append(" <field name=\"SenderCompID\" required=\"Y\"/>\n"); + xml.append(" <field name=\"TargetCompID\" required=\"Y\"/>\n"); + xml.append(" <field name=\"MsgSeqNum\" required=\"Y\"/>\n"); + xml.append(" <field name=\"SendingTime\" required=\"Y\"/>\n"); + xml.append(" </header>\n"); + xml.append(" <trailer>\n"); + xml.append(" <field name=\"CheckSum\" required=\"Y\"/>\n"); + xml.append(" </trailer>\n"); + xml.append(" <messages/>\n"); + xml.append(" <fields>\n"); + xml.append(" <field number=\"8\" name=\"BeginString\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"9\" name=\"BodyLength\" type=\"LENGTH\"/>\n"); + xml.append(" <field number=\"35\" name=\"MsgType\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"49\" name=\"SenderCompID\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"56\" name=\"TargetCompID\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"34\" name=\"MsgSeqNum\" type=\"SEQNUM\"/>\n"); + xml.append(" <field number=\"52\" name=\"SendingTime\" type=\"UTCTIMESTAMP\"/>\n"); + xml.append(" <field number=\"10\" name=\"CheckSum\" type=\"STRING\"/>\n"); + xml.append(" <field number=\"").append(raceFieldNumber).append("\" name=\"") + .append(TARGET_FIELD_NAME).append("\" type=\"STRING\">\n"); + xml.append(" <value enum=\"").append(raceEnumPrefix) + .append("_ONE\" description=\"first value\"/>\n"); + xml.append(" <value enum=\"").append(raceEnumPrefix) + .append("_TWO\" description=\"second value\"/>\n"); + xml.append(" <value enum=\"").append(raceEnumPrefix) + .append("_THREE\" description=\"third value\"/>\n"); + xml.append(" </field>\n"); + xml.append(" </fields>\n"); + xml.append("</fix>\n"); + Files.write(dictionary.toPath(), xml.toString().getBytes(StandardCharsets.UTF_8)); + return dictionary; + } + + private int calculateSplitPosition(String expectedA, String expectedB) { + byte[] a = expectedA.getBytes(StandardCharsets.UTF_8); + byte[] b = expectedB.getBytes(StandardCharsets.UTF_8); + int minLength = Math.min(a.length, b.length); + int firstDiff = -1; + int lastDiff = -1; + for (int i = 0; i < minLength; i++) { + if (a[i] != b[i]) { + if (firstDiff == -1) { + firstDiff = i; + } + lastDiff = i; + } + } + if (a.length != b.length) { + if (firstDiff == -1) { + firstDiff = minLength; + } + lastDiff = Math.max(a.length, b.length) - 1; + } + if (firstDiff < 0 || lastDiff <= firstDiff) { + throw new IllegalStateException("Expected distinct variants with differences across the target file"); + } + int split = (firstDiff + lastDiff) / 2; + return Math.max(1, split); + } + + private static final class CoordinatedOutputMessageCodeGenerator extends MessageCodeGenerator { + private final String targetFileName; + private final int splitPosition; + private final AtomicInteger targetStreamCounter = new AtomicInteger(); + private final CountDownLatch openedBothStreams = new CountDownLatch(2); + private final CountDownLatch firstHalfByWriterOne = new CountDownLatch(1); + private final CountDownLatch firstHalfByWriterTwo = new CountDownLatch(1); + private final CountDownLatch secondHalfByWriterTwo = new CountDownLatch(1); + private final CountDownLatch writerOneFinished = new CountDownLatch(1); + + private CoordinatedOutputMessageCodeGenerator(String targetFileName, int splitPosition) { + this.targetFileName = targetFileName; + this.splitPosition = splitPosition; + } + + @Override + protected OutputStream createOutputStream(File outputFile) throws FileNotFoundException { + FileOutputStream delegate = new FileOutputStream(outputFile); + if (!outputFile.getName().equals(targetFileName)) { + return delegate; + } + + int writerId = targetStreamCounter.incrementAndGet(); + openedBothStreams.countDown(); + await(openedBothStreams, "opening both target streams"); + return new CoordinatedRaceOutputStream(delegate, writerId); + } + + private final class CoordinatedRaceOutputStream extends OutputStream { + private final OutputStream delegate; + private final int writerId; + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + private CoordinatedRaceOutputStream(OutputStream delegate, int writerId) { + this.delegate = delegate; + this.writerId = writerId; + } + + @Override + public void write(int b) { + buffer.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + buffer.write(b, off, len); + } + + @Override + public void close() throws IOException { + byte[] data = buffer.toByteArray(); + int split = Math.min(Math.max(1, splitPosition), Math.max(1, data.length - 1)); + try { + if (writerId == 1) { + delegate.write(data, 0, split); + firstHalfByWriterOne.countDown(); + await(firstHalfByWriterTwo, "writer 2 first-half write"); + await(secondHalfByWriterTwo, "writer 2 second-half write"); + delegate.write(data, split, data.length - split); + writerOneFinished.countDown(); + } else { + await(firstHalfByWriterOne, "writer 1 first-half write"); + delegate.write(data, 0, split); + firstHalfByWriterTwo.countDown(); + delegate.write(data, split, data.length - split); + secondHalfByWriterTwo.countDown(); + await(writerOneFinished, "writer 1 completion"); + } + } finally { + delegate.close(); + } + } + } + + private void await(CountDownLatch latch, String action) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out while " + action); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while " + action, e); + } + } + } } From 2a8eb41dca0d996472784acd2f95369cc9fffea0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 20:50:16 +0000 Subject: [PATCH 15/21] Fix race test: give each parallel task its own output directory --- .../ParallelFieldGenerationRaceTest.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index 417d42a5d1..1907c903b5 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -89,10 +89,15 @@ public void testParallelFieldGenerationMatchesSingleThreadedGolden() throws Exce System.setProperty(PARALLEL_OPTION, "true"); System.setProperty(PARALLEL_THREADS_OPTION, Integer.toString(PARALLEL_TASKS)); for (int round = 0; round < PARALLEL_ROUNDS; round++) { - File parallelOutput = tempFolder.newFolder("parallel-" + round); - generator.generate(createParallelTasks(dictionary, transformDirectory, parallelOutput)); - assertEquals("Mismatch in round " + round, goldenFieldSources, - collectFieldSources(parallelOutput)); + File parallelRoot = tempFolder.newFolder("parallel-" + round); + List<MessageCodeGenerator.Task> tasks = createParallelTasks(dictionary, transformDirectory, + parallelRoot); + generator.generate(tasks); + for (int i = 0; i < PARALLEL_TASKS; i++) { + File taskOutput = new File(parallelRoot, "task-" + i); + assertEquals("Mismatch in round " + round + ", task " + i, goldenFieldSources, + collectFieldSources(taskOutput)); + } } } @@ -112,10 +117,11 @@ private MessageCodeGenerator.Task createTask(String name, File dictionary, File } private List<MessageCodeGenerator.Task> createParallelTasks(File dictionary, File transformDirectory, - File outputDirectory) { + File parallelRoot) { List<MessageCodeGenerator.Task> tasks = new ArrayList<>(); for (int i = 0; i < PARALLEL_TASKS; i++) { - tasks.add(createTask("race-" + i, dictionary, transformDirectory, outputDirectory)); + File taskOutput = new File(parallelRoot, "task-" + i); + tasks.add(createTask("race-" + i, dictionary, transformDirectory, taskOutput)); } return tasks; } From c39dfd186d942170aed14a4c829c7085e2f40bc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 11:00:36 +0000 Subject: [PATCH 16/21] Add deterministic shared-output IDSource race regression test --- .../ParallelFieldGenerationRaceTest.java | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index 1907c903b5..5a197d9a73 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import java.io.ByteArrayOutputStream; import java.io.File; @@ -36,6 +37,11 @@ public class ParallelFieldGenerationRaceTest { private static final int PARALLEL_ROUNDS = 8; private static final String TARGET_FIELD_NAME = "RaceSharedField"; private static final String TARGET_FIELD_FILE = TARGET_FIELD_NAME + ".java"; + private static final String IDSOURCE_FILE = "IDSource.java"; + private static final File FIX42_DICTIONARY = new File( + "../quickfixj-messages/quickfixj-messages-fix42/src/main/resources/FIX42.xml"); + private static final File FIX42_IDSOURCE_GOLDEN = new File( + "./src/test/resources/golden/fix42/quickfix/field/IDSource.java"); @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); @@ -74,6 +80,42 @@ public void testDeterministicCorruptionWithTwoConcurrentWriters() throws Excepti assertFalse("Expected a mixed/corrupt output, but got variant B", expectedB.equals(actual)); } + @Test + public void testParallelSharedOutputDirectoryKeepsIdSourceGolden() throws Exception { + File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); + File mutatedFix42 = createMutatedFix42Dictionary(); + File auxiliaryDictionary = createDictionaryWith1000Fields(); + MessageCodeGenerator sequential = new MessageCodeGenerator(); + + String expectedGolden = generateAndReadField(sequential, + createTask("expected-golden", FIX42_DICTIONARY, transformDirectory, + tempFolder.newFolder("expected-golden")), + IDSOURCE_FILE); + String expectedMutated = generateAndReadField(sequential, + createTask("expected-mutated", mutatedFix42, transformDirectory, + tempFolder.newFolder("expected-mutated")), + IDSOURCE_FILE); + int splitPosition = calculateSplitPosition(expectedGolden, expectedMutated); + + List<MessageCodeGenerator.Task> tasks = new ArrayList<>(); + File sharedOutput = tempFolder.newFolder("shared-output"); + tasks.add(createTask("idsource-golden", FIX42_DICTIONARY, transformDirectory, sharedOutput)); + tasks.add(createTask("idsource-mutated", mutatedFix42, transformDirectory, sharedOutput)); + for (int i = 0; i < 6; i++) { + tasks.add(createTask("aux-" + i, auxiliaryDictionary, transformDirectory, sharedOutput)); + } + + System.setProperty(PARALLEL_OPTION, "true"); + System.setProperty(PARALLEL_THREADS_OPTION, Integer.toString(tasks.size())); + MessageCodeGenerator generator = new CoordinatedOutputMessageCodeGenerator(IDSOURCE_FILE, + splitPosition); + generator.generate(tasks); + + String goldenSource = readFile(FIX42_IDSOURCE_GOLDEN); + String actual = readField(sharedOutput, IDSOURCE_FILE); + assertEquals(goldenSource, actual); + } + @Test public void testParallelFieldGenerationMatchesSingleThreadedGolden() throws Exception { File dictionary = createDictionaryWith1000Fields(); @@ -128,15 +170,28 @@ private List<MessageCodeGenerator.Task> createParallelTasks(File dictionary, Fil private String generateAndReadTargetField(MessageCodeGenerator generator, MessageCodeGenerator.Task task) throws Exception { + return generateAndReadField(generator, task, TARGET_FIELD_FILE); + } + + private String generateAndReadField(MessageCodeGenerator generator, MessageCodeGenerator.Task task, + String fieldFileName) throws Exception { generator.generate(task); - return readTargetField(task.getOutputBaseDirectory()); + return readField(task.getOutputBaseDirectory(), fieldFileName); } private String readTargetField(File outputDirectory) throws Exception { - Path target = outputDirectory.toPath().resolve("quickfix/field").resolve(TARGET_FIELD_FILE); + return readField(outputDirectory, TARGET_FIELD_FILE); + } + + private String readField(File outputDirectory, String fieldFileName) throws Exception { + Path target = outputDirectory.toPath().resolve("quickfix/field").resolve(fieldFileName); return new String(Files.readAllBytes(target), StandardCharsets.UTF_8); } + private String readFile(File file) throws Exception { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + private Map<String, String> collectFieldSources(File outputDirectory) throws Exception { Map<String, String> sources = new TreeMap<>(); Path fieldDir = outputDirectory.toPath().resolve("quickfix/field"); @@ -236,6 +291,18 @@ private File createTwoWriterDictionary(String raceFieldNumber, String raceEnumPr return dictionary; } + private File createMutatedFix42Dictionary() throws Exception { + File mutated = tempFolder.newFile("FIX42-mutated-IDSource.xml"); + String original = readFile(FIX42_DICTIONARY); + String marker = "<value enum=\"1\" description=\"CUSIP\"/>"; + String replacement = "<value enum=\"1\" description=\"CUSIP_ALT\"/>"; + String changed = original.replace(marker, replacement); + assertTrue("Expected FIX42 dictionary to contain IDSource CUSIP enum marker", + !original.equals(changed)); + Files.write(mutated.toPath(), changed.getBytes(StandardCharsets.UTF_8)); + return mutated; + } + private int calculateSplitPosition(String expectedA, String expectedB) { byte[] a = expectedA.getBytes(StandardCharsets.UTF_8); byte[] b = expectedB.getBytes(StandardCharsets.UTF_8); From 7934b748b8a1e136ec548d93d27c80ff891f9330 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:44:02 +0000 Subject: [PATCH 17/21] Replace race test with new shared-output reproduction --- .../ParallelFieldGenerationRaceTest.java | 378 ++---------------- 1 file changed, 40 insertions(+), 338 deletions(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index 5a197d9a73..da0893d142 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -1,26 +1,15 @@ package org.quickfixj.codegenerator; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.TreeMap; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import org.junit.After; @@ -34,113 +23,40 @@ public class ParallelFieldGenerationRaceTest { private static final String PARALLEL_THREADS_OPTION = "generator.parallelThreads"; private static final int TOTAL_FIELDS = 1000; private static final int PARALLEL_TASKS = 16; - private static final int PARALLEL_ROUNDS = 8; - private static final String TARGET_FIELD_NAME = "RaceSharedField"; - private static final String TARGET_FIELD_FILE = TARGET_FIELD_NAME + ".java"; - private static final String IDSOURCE_FILE = "IDSource.java"; - private static final File FIX42_DICTIONARY = new File( - "../quickfixj-messages/quickfixj-messages-fix42/src/main/resources/FIX42.xml"); - private static final File FIX42_IDSOURCE_GOLDEN = new File( - "./src/test/resources/golden/fix42/quickfix/field/IDSource.java"); @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); @After - public void clearParallelOption() { + public void clearParallelOptions() { System.clearProperty(PARALLEL_OPTION); System.clearProperty(PARALLEL_THREADS_OPTION); } @Test - public void testDeterministicCorruptionWithTwoConcurrentWriters() throws Exception { + public void testParallelSharedOutputAgainstGoldenSource() throws Exception { File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); - File dictionaryA = createTwoWriterDictionary("9001", "ALPHA"); - File dictionaryB = createTwoWriterDictionary("9002", "OMEGA"); - - MessageCodeGenerator sequential = new MessageCodeGenerator(); - String expectedA = generateAndReadTargetField(sequential, createTask("expected-a", dictionaryA, - transformDirectory, tempFolder.newFolder("expected-a"))); - String expectedB = generateAndReadTargetField(sequential, createTask("expected-b", dictionaryB, - transformDirectory, tempFolder.newFolder("expected-b"))); - int splitPosition = calculateSplitPosition(expectedA, expectedB); - - System.setProperty(PARALLEL_OPTION, "true"); - System.setProperty(PARALLEL_THREADS_OPTION, "2"); - - File raceOutput = tempFolder.newFolder("deterministic-race"); - MessageCodeGenerator generator = new CoordinatedOutputMessageCodeGenerator(TARGET_FIELD_FILE, - splitPosition); - generator.generate(Arrays.asList( - createTask("race-a", dictionaryA, transformDirectory, raceOutput), - createTask("race-b", dictionaryB, transformDirectory, raceOutput))); - - String actual = readTargetField(raceOutput); - assertFalse("Expected a mixed/corrupt output, but got variant A", expectedA.equals(actual)); - assertFalse("Expected a mixed/corrupt output, but got variant B", expectedB.equals(actual)); - } - - @Test - public void testParallelSharedOutputDirectoryKeepsIdSourceGolden() throws Exception { - File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); - File mutatedFix42 = createMutatedFix42Dictionary(); - File auxiliaryDictionary = createDictionaryWith1000Fields(); - MessageCodeGenerator sequential = new MessageCodeGenerator(); + File dictionaryTwoEnums = createDictionary("two-enums", false); + File dictionaryThreeEnums = createDictionary("three-enums", true); + MessageCodeGenerator generator = new MessageCodeGenerator(); - String expectedGolden = generateAndReadField(sequential, - createTask("expected-golden", FIX42_DICTIONARY, transformDirectory, - tempFolder.newFolder("expected-golden")), - IDSOURCE_FILE); - String expectedMutated = generateAndReadField(sequential, - createTask("expected-mutated", mutatedFix42, transformDirectory, - tempFolder.newFolder("expected-mutated")), - IDSOURCE_FILE); - int splitPosition = calculateSplitPosition(expectedGolden, expectedMutated); + File goldenOutput = tempFolder.newFolder("golden"); + generator.generate(createTask("golden", dictionaryTwoEnums, transformDirectory, goldenOutput)); + Map<String, String> goldenFieldSources = collectFieldSources(goldenOutput); + assertEquals(TOTAL_FIELDS, goldenFieldSources.size()); - List<MessageCodeGenerator.Task> tasks = new ArrayList<>(); File sharedOutput = tempFolder.newFolder("shared-output"); - tasks.add(createTask("idsource-golden", FIX42_DICTIONARY, transformDirectory, sharedOutput)); - tasks.add(createTask("idsource-mutated", mutatedFix42, transformDirectory, sharedOutput)); - for (int i = 0; i < 6; i++) { - tasks.add(createTask("aux-" + i, auxiliaryDictionary, transformDirectory, sharedOutput)); + List<MessageCodeGenerator.Task> tasks = new ArrayList<>(); + for (int i = 0; i < PARALLEL_TASKS; i++) { + File dictionary = i % 2 == 0 ? dictionaryTwoEnums : dictionaryThreeEnums; + tasks.add(createTask("race-" + i, dictionary, transformDirectory, sharedOutput)); } System.setProperty(PARALLEL_OPTION, "true"); - System.setProperty(PARALLEL_THREADS_OPTION, Integer.toString(tasks.size())); - MessageCodeGenerator generator = new CoordinatedOutputMessageCodeGenerator(IDSOURCE_FILE, - splitPosition); + System.setProperty(PARALLEL_THREADS_OPTION, Integer.toString(PARALLEL_TASKS)); generator.generate(tasks); - String goldenSource = readFile(FIX42_IDSOURCE_GOLDEN); - String actual = readField(sharedOutput, IDSOURCE_FILE); - assertEquals(goldenSource, actual); - } - - @Test - public void testParallelFieldGenerationMatchesSingleThreadedGolden() throws Exception { - File dictionary = createDictionaryWith1000Fields(); - File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); - - File goldenOutput = tempFolder.newFolder("golden"); - MessageCodeGenerator generator = new MessageCodeGenerator(); - generator.generate(createTask("golden", dictionary, transformDirectory, goldenOutput)); - - Map<String, String> goldenFieldSources = collectFieldSources(goldenOutput); - assertEquals(TOTAL_FIELDS, goldenFieldSources.size()); - - System.setProperty(PARALLEL_OPTION, "true"); - System.setProperty(PARALLEL_THREADS_OPTION, Integer.toString(PARALLEL_TASKS)); - for (int round = 0; round < PARALLEL_ROUNDS; round++) { - File parallelRoot = tempFolder.newFolder("parallel-" + round); - List<MessageCodeGenerator.Task> tasks = createParallelTasks(dictionary, transformDirectory, - parallelRoot); - generator.generate(tasks); - for (int i = 0; i < PARALLEL_TASKS; i++) { - File taskOutput = new File(parallelRoot, "task-" + i); - assertEquals("Mismatch in round " + round + ", task " + i, goldenFieldSources, - collectFieldSources(taskOutput)); - } - } + assertEquals(goldenFieldSources, collectFieldSources(sharedOutput)); } private MessageCodeGenerator.Task createTask(String name, File dictionary, File transformDirectory, @@ -158,261 +74,47 @@ private MessageCodeGenerator.Task createTask(String name, File dictionary, File return task; } - private List<MessageCodeGenerator.Task> createParallelTasks(File dictionary, File transformDirectory, - File parallelRoot) { - List<MessageCodeGenerator.Task> tasks = new ArrayList<>(); - for (int i = 0; i < PARALLEL_TASKS; i++) { - File taskOutput = new File(parallelRoot, "task-" + i); - tasks.add(createTask("race-" + i, dictionary, transformDirectory, taskOutput)); - } - return tasks; - } - - private String generateAndReadTargetField(MessageCodeGenerator generator, MessageCodeGenerator.Task task) - throws Exception { - return generateAndReadField(generator, task, TARGET_FIELD_FILE); - } - - private String generateAndReadField(MessageCodeGenerator generator, MessageCodeGenerator.Task task, - String fieldFileName) throws Exception { - generator.generate(task); - return readField(task.getOutputBaseDirectory(), fieldFileName); - } - - private String readTargetField(File outputDirectory) throws Exception { - return readField(outputDirectory, TARGET_FIELD_FILE); - } - - private String readField(File outputDirectory, String fieldFileName) throws Exception { - Path target = outputDirectory.toPath().resolve("quickfix/field").resolve(fieldFileName); - return new String(Files.readAllBytes(target), StandardCharsets.UTF_8); - } - - private String readFile(File file) throws Exception { - return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - } - private Map<String, String> collectFieldSources(File outputDirectory) throws Exception { Map<String, String> sources = new TreeMap<>(); Path fieldDir = outputDirectory.toPath().resolve("quickfix/field"); try (Stream<Path> stream = Files.walk(fieldDir)) { - stream - .filter(path -> path.toString().endsWith(".java")) - .forEach(path -> { - try { - String relative = fieldDir.relativize(path).toString(); - String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); - sources.put(relative, content); - } catch (Exception e) { - throw new RuntimeException(e); - } - }); + stream.filter(path -> path.toString().endsWith(".java")).forEach(path -> { + try { + String relative = fieldDir.relativize(path).toString(); + String content = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + sources.put(relative, content); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); } return sources; } - private File createDictionaryWith1000Fields() throws Exception { - File dictionary = tempFolder.newFile("RaceCondition1000Fields.xml"); - StringBuilder xml = new StringBuilder(); - xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); - xml.append("<fix major=\"4\" minor=\"4\">\n"); - xml.append(" <header>\n"); - xml.append(" <field name=\"BeginString\" required=\"Y\"/>\n"); - xml.append(" <field name=\"BodyLength\" required=\"Y\"/>\n"); - xml.append(" <field name=\"MsgType\" required=\"Y\"/>\n"); - xml.append(" <field name=\"SenderCompID\" required=\"Y\"/>\n"); - xml.append(" <field name=\"TargetCompID\" required=\"Y\"/>\n"); - xml.append(" <field name=\"MsgSeqNum\" required=\"Y\"/>\n"); - xml.append(" <field name=\"SendingTime\" required=\"Y\"/>\n"); - xml.append(" </header>\n"); - xml.append(" <trailer>\n"); - xml.append(" <field name=\"CheckSum\" required=\"Y\"/>\n"); - xml.append(" </trailer>\n"); - xml.append(" <messages>\n"); - xml.append(" </messages>\n"); - xml.append(" <fields>\n"); - xml.append(" <field number=\"8\" name=\"BeginString\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"9\" name=\"BodyLength\" type=\"LENGTH\"/>\n"); - xml.append(" <field number=\"35\" name=\"MsgType\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"49\" name=\"SenderCompID\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"56\" name=\"TargetCompID\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"34\" name=\"MsgSeqNum\" type=\"SEQNUM\"/>\n"); - xml.append(" <field number=\"52\" name=\"SendingTime\" type=\"UTCTIMESTAMP\"/>\n"); - xml.append(" <field number=\"10\" name=\"CheckSum\" type=\"STRING\"/>\n"); - for (int i = 1; i <= TOTAL_FIELDS - 8; i++) { - xml.append(" <field number=\"").append(10000 + i).append("\" name=\"RaceField") - .append(i).append("\" type=\"STRING\"/>\n"); - } - xml.append(" </fields>\n"); - xml.append("</fix>\n"); - Files.write(dictionary.toPath(), xml.toString().getBytes(StandardCharsets.UTF_8)); - return dictionary; - } - - private File createTwoWriterDictionary(String raceFieldNumber, String raceEnumPrefix) throws Exception { - File dictionary = tempFolder.newFile("RaceCondition-" + raceFieldNumber + ".xml"); + private File createDictionary(String name, boolean withExtraEnum) throws Exception { + File dictionary = tempFolder.newFile("RaceCondition-" + name + ".xml"); StringBuilder xml = new StringBuilder(); xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); xml.append("<fix major=\"4\" minor=\"4\">\n"); - xml.append(" <header>\n"); - xml.append(" <field name=\"BeginString\" required=\"Y\"/>\n"); - xml.append(" <field name=\"BodyLength\" required=\"Y\"/>\n"); - xml.append(" <field name=\"MsgType\" required=\"Y\"/>\n"); - xml.append(" <field name=\"SenderCompID\" required=\"Y\"/>\n"); - xml.append(" <field name=\"TargetCompID\" required=\"Y\"/>\n"); - xml.append(" <field name=\"MsgSeqNum\" required=\"Y\"/>\n"); - xml.append(" <field name=\"SendingTime\" required=\"Y\"/>\n"); - xml.append(" </header>\n"); - xml.append(" <trailer>\n"); - xml.append(" <field name=\"CheckSum\" required=\"Y\"/>\n"); - xml.append(" </trailer>\n"); + xml.append(" <header/>\n"); + xml.append(" <trailer/>\n"); xml.append(" <messages/>\n"); xml.append(" <fields>\n"); - xml.append(" <field number=\"8\" name=\"BeginString\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"9\" name=\"BodyLength\" type=\"LENGTH\"/>\n"); - xml.append(" <field number=\"35\" name=\"MsgType\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"49\" name=\"SenderCompID\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"56\" name=\"TargetCompID\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"34\" name=\"MsgSeqNum\" type=\"SEQNUM\"/>\n"); - xml.append(" <field number=\"52\" name=\"SendingTime\" type=\"UTCTIMESTAMP\"/>\n"); - xml.append(" <field number=\"10\" name=\"CheckSum\" type=\"STRING\"/>\n"); - xml.append(" <field number=\"").append(raceFieldNumber).append("\" name=\"") - .append(TARGET_FIELD_NAME).append("\" type=\"STRING\">\n"); - xml.append(" <value enum=\"").append(raceEnumPrefix) - .append("_ONE\" description=\"first value\"/>\n"); - xml.append(" <value enum=\"").append(raceEnumPrefix) - .append("_TWO\" description=\"second value\"/>\n"); - xml.append(" <value enum=\"").append(raceEnumPrefix) - .append("_THREE\" description=\"third value\"/>\n"); - xml.append(" </field>\n"); + for (int i = 1; i <= TOTAL_FIELDS; i++) { + int tag = 10000 + i; + xml.append(" <field number=\"").append(tag).append("\" name=\"RaceField") + .append(i).append("\" type=\"STRING\">\n"); + xml.append(" <value enum=\"A").append(i).append("\" description=\"VALUE_A\"/>\n"); + xml.append(" <value enum=\"B").append(i).append("\" description=\"VALUE_B\"/>\n"); + if (withExtraEnum) { + xml.append(" <value enum=\"C").append(i) + .append("\" description=\"VALUE_C\"/>\n"); + } + xml.append(" </field>\n"); + } xml.append(" </fields>\n"); xml.append("</fix>\n"); Files.write(dictionary.toPath(), xml.toString().getBytes(StandardCharsets.UTF_8)); return dictionary; } - - private File createMutatedFix42Dictionary() throws Exception { - File mutated = tempFolder.newFile("FIX42-mutated-IDSource.xml"); - String original = readFile(FIX42_DICTIONARY); - String marker = "<value enum=\"1\" description=\"CUSIP\"/>"; - String replacement = "<value enum=\"1\" description=\"CUSIP_ALT\"/>"; - String changed = original.replace(marker, replacement); - assertTrue("Expected FIX42 dictionary to contain IDSource CUSIP enum marker", - !original.equals(changed)); - Files.write(mutated.toPath(), changed.getBytes(StandardCharsets.UTF_8)); - return mutated; - } - - private int calculateSplitPosition(String expectedA, String expectedB) { - byte[] a = expectedA.getBytes(StandardCharsets.UTF_8); - byte[] b = expectedB.getBytes(StandardCharsets.UTF_8); - int minLength = Math.min(a.length, b.length); - int firstDiff = -1; - int lastDiff = -1; - for (int i = 0; i < minLength; i++) { - if (a[i] != b[i]) { - if (firstDiff == -1) { - firstDiff = i; - } - lastDiff = i; - } - } - if (a.length != b.length) { - if (firstDiff == -1) { - firstDiff = minLength; - } - lastDiff = Math.max(a.length, b.length) - 1; - } - if (firstDiff < 0 || lastDiff <= firstDiff) { - throw new IllegalStateException("Expected distinct variants with differences across the target file"); - } - int split = (firstDiff + lastDiff) / 2; - return Math.max(1, split); - } - - private static final class CoordinatedOutputMessageCodeGenerator extends MessageCodeGenerator { - private final String targetFileName; - private final int splitPosition; - private final AtomicInteger targetStreamCounter = new AtomicInteger(); - private final CountDownLatch openedBothStreams = new CountDownLatch(2); - private final CountDownLatch firstHalfByWriterOne = new CountDownLatch(1); - private final CountDownLatch firstHalfByWriterTwo = new CountDownLatch(1); - private final CountDownLatch secondHalfByWriterTwo = new CountDownLatch(1); - private final CountDownLatch writerOneFinished = new CountDownLatch(1); - - private CoordinatedOutputMessageCodeGenerator(String targetFileName, int splitPosition) { - this.targetFileName = targetFileName; - this.splitPosition = splitPosition; - } - - @Override - protected OutputStream createOutputStream(File outputFile) throws FileNotFoundException { - FileOutputStream delegate = new FileOutputStream(outputFile); - if (!outputFile.getName().equals(targetFileName)) { - return delegate; - } - - int writerId = targetStreamCounter.incrementAndGet(); - openedBothStreams.countDown(); - await(openedBothStreams, "opening both target streams"); - return new CoordinatedRaceOutputStream(delegate, writerId); - } - - private final class CoordinatedRaceOutputStream extends OutputStream { - private final OutputStream delegate; - private final int writerId; - private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - - private CoordinatedRaceOutputStream(OutputStream delegate, int writerId) { - this.delegate = delegate; - this.writerId = writerId; - } - - @Override - public void write(int b) { - buffer.write(b); - } - - @Override - public void write(byte[] b, int off, int len) { - buffer.write(b, off, len); - } - - @Override - public void close() throws IOException { - byte[] data = buffer.toByteArray(); - int split = Math.min(Math.max(1, splitPosition), Math.max(1, data.length - 1)); - try { - if (writerId == 1) { - delegate.write(data, 0, split); - firstHalfByWriterOne.countDown(); - await(firstHalfByWriterTwo, "writer 2 first-half write"); - await(secondHalfByWriterTwo, "writer 2 second-half write"); - delegate.write(data, split, data.length - split); - writerOneFinished.countDown(); - } else { - await(firstHalfByWriterOne, "writer 1 first-half write"); - delegate.write(data, 0, split); - firstHalfByWriterTwo.countDown(); - delegate.write(data, split, data.length - split); - secondHalfByWriterTwo.countDown(); - await(writerOneFinished, "writer 1 completion"); - } - } finally { - delegate.close(); - } - } - } - - private void await(CountDownLatch latch, String action) { - try { - if (!latch.await(10, TimeUnit.SECONDS)) { - throw new IllegalStateException("Timed out while " + action); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while " + action, e); - } - } - } } From ce79588522cc9d9831f68307ba5ea67b40258448 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:05:16 +0000 Subject: [PATCH 18/21] Limit race test mismatch output to first class --- .../ParallelFieldGenerationRaceTest.java | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index da0893d142..3a65c6c206 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -1,6 +1,7 @@ package org.quickfixj.codegenerator; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; import java.io.File; import java.nio.charset.StandardCharsets; @@ -56,7 +57,7 @@ public void testParallelSharedOutputAgainstGoldenSource() throws Exception { System.setProperty(PARALLEL_THREADS_OPTION, Integer.toString(PARALLEL_TASKS)); generator.generate(tasks); - assertEquals(goldenFieldSources, collectFieldSources(sharedOutput)); + assertFirstDifferenceOnly(goldenFieldSources, collectFieldSources(sharedOutput)); } private MessageCodeGenerator.Task createTask(String name, File dictionary, File transformDirectory, @@ -91,6 +92,47 @@ private Map<String, String> collectFieldSources(File outputDirectory) throws Exc return sources; } + private void assertFirstDifferenceOnly(Map<String, String> expected, Map<String, String> actual) { + for (Map.Entry<String, String> entry : expected.entrySet()) { + String className = entry.getKey(); + if (!actual.containsKey(className)) { + fail("First failing class: " + className + "\nDifference: class missing in generated output"); + } + + String expectedSource = entry.getValue(); + String actualSource = actual.get(className); + if (!expectedSource.equals(actualSource)) { + fail(buildFirstDifferenceMessage(className, expectedSource, actualSource)); + } + } + + for (String className : actual.keySet()) { + if (!expected.containsKey(className)) { + fail("First failing class: " + className + + "\nDifference: unexpected class present in generated output"); + } + } + } + + private String buildFirstDifferenceMessage(String className, String expected, String actual) { + String[] expectedLines = expected.split("\\R", -1); + String[] actualLines = actual.split("\\R", -1); + int lineCount = Math.min(expectedLines.length, actualLines.length); + for (int i = 0; i < lineCount; i++) { + if (!expectedLines[i].equals(actualLines[i])) { + return "First failing class: " + className + + "\nDifference at line " + (i + 1) + + "\nExpected: " + expectedLines[i] + + "\nActual: " + actualLines[i]; + } + } + + return "First failing class: " + className + + "\nDifference: source length mismatch" + + "\nExpected lines: " + expectedLines.length + + "\nActual lines: " + actualLines.length; + } + private File createDictionary(String name, boolean withExtraEnum) throws Exception { File dictionary = tempFolder.newFile("RaceCondition-" + name + ".xml"); StringBuilder xml = new StringBuilder(); From 13e20b8b6c4897dacfbc8d46464f461374321f74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:53:39 +0000 Subject: [PATCH 19/21] Set race test dictionary minor versions per review --- .../codegenerator/ParallelFieldGenerationRaceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index 3a65c6c206..4d2e3c8f03 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -136,8 +136,9 @@ private String buildFirstDifferenceMessage(String className, String expected, St private File createDictionary(String name, boolean withExtraEnum) throws Exception { File dictionary = tempFolder.newFile("RaceCondition-" + name + ".xml"); StringBuilder xml = new StringBuilder(); + int minorVersion = withExtraEnum ? 4 : 2; xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); - xml.append("<fix major=\"4\" minor=\"4\">\n"); + xml.append("<fix major=\"4\" minor=\"").append(minorVersion).append("\">\n"); xml.append(" <header/>\n"); xml.append(" <trailer/>\n"); xml.append(" <messages/>\n"); From bb253a5ffbdea1483d19b693a7affcb0629b76eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:27:28 +0000 Subject: [PATCH 20/21] Address review feedback on golden source and compiler args --- pom.xml | 3 --- .../codegenerator/ParallelFieldGenerationRaceTest.java | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 7cad66d1df..3fad05467b 100644 --- a/pom.xml +++ b/pom.xml @@ -235,9 +235,6 @@ <forceLegacyJavacApi>true</forceLegacyJavacApi> <!-- https://bugs.openjdk.java.net/browse/JDK-8216202 --> <meminitial>2g</meminitial> <maxmem>4g</maxmem> - <compilerArgs> - <arg>-Xdiags:verbose</arg> - </compilerArgs> </configuration> </plugin> <plugin> diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index 4d2e3c8f03..df5417c8be 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -42,7 +42,7 @@ public void testParallelSharedOutputAgainstGoldenSource() throws Exception { MessageCodeGenerator generator = new MessageCodeGenerator(); File goldenOutput = tempFolder.newFolder("golden"); - generator.generate(createTask("golden", dictionaryTwoEnums, transformDirectory, goldenOutput)); + generator.generate(createTask("golden", dictionaryThreeEnums, transformDirectory, goldenOutput)); Map<String, String> goldenFieldSources = collectFieldSources(goldenOutput); assertEquals(TOTAL_FIELDS, goldenFieldSources.size()); From 8fb097a971a4e64e6c950b887e75befb3b1e9428 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:40:55 +0000 Subject: [PATCH 21/21] Force deterministic race in ParallelFieldGenerationRaceTest via CyclicBarrier --- .../ParallelFieldGenerationRaceTest.java | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java index df5417c8be..efa074dba8 100644 --- a/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java +++ b/quickfixj-codegenerator/src/test/java/org/quickfixj/codegenerator/ParallelFieldGenerationRaceTest.java @@ -4,6 +4,10 @@ import static org.junit.Assert.fail; import java.io.File; +import java.io.FileNotFoundException; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -11,6 +15,11 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Stream; import org.junit.After; @@ -39,13 +48,59 @@ public void testParallelSharedOutputAgainstGoldenSource() throws Exception { File transformDirectory = new File("./src/main/resources/org/quickfixj/codegenerator"); File dictionaryTwoEnums = createDictionary("two-enums", false); File dictionaryThreeEnums = createDictionary("three-enums", true); - MessageCodeGenerator generator = new MessageCodeGenerator(); + // Use a plain generator for the single-task golden baseline so that the + // barrier (which requires two parties) does not deadlock. File goldenOutput = tempFolder.newFolder("golden"); - generator.generate(createTask("golden", dictionaryThreeEnums, transformDirectory, goldenOutput)); + new MessageCodeGenerator().generate( + createTask("golden", dictionaryThreeEnums, transformDirectory, goldenOutput)); Map<String, String> goldenFieldSources = collectFieldSources(goldenOutput); assertEquals(TOTAL_FIELDS, goldenFieldSources.size()); + // For every output file path, keep a CyclicBarrier(2) so that the first + // two threads that open the same file must both reach their first write() + // call before either is allowed to proceed. This forces two writers to + // be simultaneously mid-write on every field file, turning the + // probabilistic race into a near-deterministic one. + ConcurrentHashMap<String, CyclicBarrier> barriers = new ConcurrentHashMap<>(); + MessageCodeGenerator generator = new MessageCodeGenerator() { + @Override + protected OutputStream createOutputStream(File outputFile) throws FileNotFoundException { + CyclicBarrier barrier = barriers.computeIfAbsent( + outputFile.getAbsolutePath(), k -> new CyclicBarrier(2)); + return new FilterOutputStream(super.createOutputStream(outputFile)) { + private boolean awaited = false; + + @Override + public void write(byte[] b, int off, int len) throws IOException { + awaitBarrierOnce(); + out.write(b, off, len); + } + + @Override + public void write(int b) throws IOException { + awaitBarrierOnce(); + out.write(b); + } + + private void awaitBarrierOnce() throws IOException { + if (!awaited) { + awaited = true; + try { + barrier.await(10, TimeUnit.SECONDS); + } catch (BrokenBarrierException | TimeoutException e) { + // Proceed: either the barrier timed out because an odd + // number of threads wrote this file, or it was broken + // by a previous exception. + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + }; + } + }; + File sharedOutput = tempFolder.newFolder("shared-output"); List<MessageCodeGenerator.Task> tasks = new ArrayList<>(); for (int i = 0; i < PARALLEL_TASKS; i++) {