problems
}
}
} else {
- if (!Utility.isDir(sourceUri.toString()) && problems.size() - ignoreFromTotalCounts > 0) {
+ if (!Utility.isDir(sourceUri.toString())) {
if (!this.integrityCheckFlag) {
this.numPassedProds++;
} else {
diff --git a/src/test/java/gov/nasa/pds/tools/label/LabelValidatorConcurrencyTest.java b/src/test/java/gov/nasa/pds/tools/label/LabelValidatorConcurrencyTest.java
new file mode 100644
index 000000000..81545a249
--- /dev/null
+++ b/src/test/java/gov/nasa/pds/tools/label/LabelValidatorConcurrencyTest.java
@@ -0,0 +1,291 @@
+package gov.nasa.pds.tools.label;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.w3c.dom.Document;
+
+import gov.nasa.pds.tools.validate.ProblemContainer;
+
+/**
+ * Concurrent integration test for {@link LabelValidator}.
+ *
+ * Validates multiple labels from a thread pool concurrently to verify:
+ *
+ * - No {@code ConcurrentModificationException} from shared state
+ * - Each thread receives a correct, non-null {@link Document}
+ * - Problem handlers are not cross-contaminated between threads
+ *
+ *
+ * Uses local schemas from the github71 test resources to avoid network I/O.
+ */
+class LabelValidatorConcurrencyTest {
+
+ /** Test data directory containing github71 labels and local schemas. */
+ private static final String GITHUB71_DIR = "src/test/resources/github71";
+
+ private LabelValidator validator;
+
+ @TempDir
+ File tempDir;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ String testPath = new File(System.getProperty("user.dir"), GITHUB71_DIR).getAbsolutePath();
+
+ // Build a catalog file that rewrites PDS4 schema URLs to local files
+ File catFile = new File(tempDir, "catalog.xml");
+ String catText = "\n"
+ + "\n"
+ + " \n"
+ + " \n"
+ + "";
+ try (BufferedWriter w = new BufferedWriter(new FileWriter(catFile))) {
+ w.write(catText);
+ }
+
+ validator = new LabelValidator();
+ validator.setCatalogs(new String[] {catFile.getAbsolutePath()});
+ validator.setSchemaCheck(true);
+ validator.setSchematronCheck(false);
+ validator.setSkipProductValidation(true);
+ }
+
+ /**
+ * Validates the same label from N threads concurrently and asserts that every
+ * thread receives a non-null Document and that no exceptions are thrown.
+ */
+ @Test
+ void concurrentParseAndValidate_sameLabel() throws Exception {
+ int threadCount = 4;
+ int iterationsPerThread = 3;
+
+ URL labelUrl = findTestLabel();
+ if (labelUrl == null) {
+ System.err.println("Skipping concurrentParseAndValidate_sameLabel: no test label found");
+ return;
+ }
+
+ ExecutorService pool = Executors.newFixedThreadPool(threadCount);
+ CyclicBarrier barrier = new CyclicBarrier(threadCount);
+ List errors = Collections.synchronizedList(new ArrayList<>());
+ List> futures = new ArrayList<>();
+
+ for (int t = 0; t < threadCount; t++) {
+ futures.add(pool.submit(() -> {
+ try {
+ barrier.await(30, TimeUnit.SECONDS);
+ for (int i = 0; i < iterationsPerThread; i++) {
+ ProblemContainer handler = new ProblemContainer();
+ Document doc = validator.parseAndValidate(handler, labelUrl);
+ assertNotNull(doc, "Document should not be null");
+ }
+ } catch (Throwable ex) {
+ errors.add(ex);
+ }
+ }));
+ }
+
+ for (Future> f : futures) {
+ f.get(120, TimeUnit.SECONDS);
+ }
+ pool.shutdown();
+ assertTrue(pool.awaitTermination(60, TimeUnit.SECONDS), "Pool should terminate");
+
+ if (!errors.isEmpty()) {
+ StringBuilder sb = new StringBuilder("Concurrent validation produced errors:\n");
+ for (Throwable err : errors) {
+ sb.append(" ").append(err.getClass().getName())
+ .append(": ").append(err.getMessage()).append("\n");
+ err.printStackTrace();
+ }
+ fail(sb.toString());
+ }
+
+ long expectedFiles = (long) threadCount * iterationsPerThread;
+ assertEquals(expectedFiles, validator.getFilesProcessed(),
+ "filesProcessed should equal threadCount * iterationsPerThread");
+ assertTrue(validator.getTotalTimeElapsed() > 0, "totalTimeElapsed should be positive");
+ }
+
+ /**
+ * Verifies that {@link LabelValidator#clear()} properly invalidates all
+ * threads' cached ParserState via the generation counter.
+ */
+ @Test
+ void clearInvalidatesAllThreads() throws Exception {
+ URL labelUrl = findTestLabel();
+ if (labelUrl == null) {
+ System.err.println("Skipping clearInvalidatesAllThreads: no test label found");
+ return;
+ }
+
+ int threadCount = 4;
+ ExecutorService pool = Executors.newFixedThreadPool(threadCount);
+ CyclicBarrier barrier = new CyclicBarrier(threadCount);
+ List errors = Collections.synchronizedList(new ArrayList<>());
+
+ // Phase 1: warm up ParserState on all threads
+ List> futures = new ArrayList<>();
+ for (int t = 0; t < threadCount; t++) {
+ futures.add(pool.submit(() -> {
+ try {
+ barrier.await(30, TimeUnit.SECONDS);
+ ProblemContainer handler = new ProblemContainer();
+ validator.parseAndValidate(handler, labelUrl);
+ } catch (Throwable ex) {
+ errors.add(ex);
+ }
+ }));
+ }
+ for (Future> f : futures) {
+ f.get(120, TimeUnit.SECONDS);
+ }
+
+ // Phase 2: clear() from the main thread, then reconfigure
+ String testPath = new File(System.getProperty("user.dir"), GITHUB71_DIR).getAbsolutePath();
+ File catFile = new File(tempDir, "catalog2.xml");
+ String catText = "\n"
+ + "\n"
+ + " \n"
+ + " \n"
+ + "";
+ try (BufferedWriter w = new BufferedWriter(new FileWriter(catFile))) {
+ w.write(catText);
+ }
+
+ validator.clear();
+ validator.setCatalogs(new String[] {catFile.getAbsolutePath()});
+ validator.setSchemaCheck(true);
+ validator.setSchematronCheck(false);
+ validator.setSkipProductValidation(true);
+
+ // Phase 3: validate again - should pick up fresh state via generation counter
+ CyclicBarrier barrier2 = new CyclicBarrier(threadCount);
+ futures.clear();
+ for (int t = 0; t < threadCount; t++) {
+ futures.add(pool.submit(() -> {
+ try {
+ barrier2.await(30, TimeUnit.SECONDS);
+ ProblemContainer handler = new ProblemContainer();
+ Document doc = validator.parseAndValidate(handler, labelUrl);
+ assertNotNull(doc, "Document should not be null after clear()");
+ } catch (Throwable ex) {
+ errors.add(ex);
+ }
+ }));
+ }
+ for (Future> f : futures) {
+ f.get(120, TimeUnit.SECONDS);
+ }
+
+ pool.shutdown();
+ assertTrue(pool.awaitTermination(60, TimeUnit.SECONDS), "Pool should terminate");
+
+ if (!errors.isEmpty()) {
+ StringBuilder sb = new StringBuilder("Post-clear concurrent validation produced errors:\n");
+ for (Throwable err : errors) {
+ sb.append(" ").append(err.getClass().getName())
+ .append(": ").append(err.getMessage()).append("\n");
+ err.printStackTrace();
+ }
+ fail(sb.toString());
+ }
+ }
+
+ /**
+ * Validates with schematron checking enabled from multiple threads concurrently.
+ * This exercises the {@code loadLabelSchematrons} / {@code cachedLabelSchematrons}
+ * {@code ConcurrentHashMap} path and the shared {@code CachedLSResourceResolver}
+ * under concurrent access.
+ */
+ @Test
+ void concurrentParseAndValidate_withSchematron() throws Exception {
+ // Enable label-schematron validation so loadLabelSchematrons is exercised
+ validator.setSchematronCheck(true, true);
+
+ int threadCount = 4;
+ int iterationsPerThread = 2;
+
+ URL labelUrl = findTestLabel();
+ if (labelUrl == null) {
+ System.err.println(
+ "Skipping concurrentParseAndValidate_withSchematron: no test label found");
+ return;
+ }
+
+ ExecutorService pool = Executors.newFixedThreadPool(threadCount);
+ CyclicBarrier barrier = new CyclicBarrier(threadCount);
+ List errors = Collections.synchronizedList(new ArrayList<>());
+ List> futures = new ArrayList<>();
+
+ for (int t = 0; t < threadCount; t++) {
+ futures.add(pool.submit(() -> {
+ try {
+ barrier.await(30, TimeUnit.SECONDS);
+ for (int i = 0; i < iterationsPerThread; i++) {
+ ProblemContainer handler = new ProblemContainer();
+ Document doc = validator.parseAndValidate(handler, labelUrl);
+ assertNotNull(doc, "Document should not be null with schematron enabled");
+ }
+ } catch (Throwable ex) {
+ errors.add(ex);
+ }
+ }));
+ }
+
+ for (Future> f : futures) {
+ f.get(120, TimeUnit.SECONDS);
+ }
+ pool.shutdown();
+ assertTrue(pool.awaitTermination(60, TimeUnit.SECONDS), "Pool should terminate");
+
+ if (!errors.isEmpty()) {
+ StringBuilder sb = new StringBuilder(
+ "Concurrent schematron validation produced errors:\n");
+ for (Throwable err : errors) {
+ sb.append(" ").append(err.getClass().getName())
+ .append(": ").append(err.getMessage()).append("\n");
+ err.printStackTrace();
+ }
+ fail(sb.toString());
+ }
+ }
+
+ /**
+ * Locates a PDS4 test label from the github71 test resources.
+ */
+ private URL findTestLabel() {
+ File f = new File(System.getProperty("user.dir"), GITHUB71_DIR + "/ELE_MOM.xml");
+ if (f.exists()) {
+ try {
+ return f.toURI().toURL();
+ } catch (Exception e) {
+ // fall through
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/test/resources/features/4.1.x.feature b/src/test/resources/features/4.1.x.feature
index 459dc4d27..7b8aaff56 100644
--- a/src/test/resources/features/4.1.x.feature
+++ b/src/test/resources/features/4.1.x.feature
@@ -9,3 +9,4 @@ Feature: 4.1.x
| 956 | 1 | "github956" | "--skip-context-validation -t {datasrc}/" | |
| 1458 | 1 | "github1458" | " -t {datasrc}/" | "summary:productValidation:passed=2,summary:productValidation:total=2,summary:totalWarnings=6,messageTypes:warning.label.context_ref_mismatch=5,messageTypes:warning.label.schematron=1" |
| 1481 | 1 | "github1481" | "--skip-context-validation --skip-product-validation -R pds4.bundle -t {datasrc}/bundle_test.xml" | "summary:totalWarnings=1,summary:messageTypes:warning.integrity.reference_not_found=1" |
+| 1548 | 1 | "github1548" | "--skip-context-validation -t {datasrc}/nonexistent.xml" | "summary:totalErrors=1,summary:productValidation:failed=1,summary:messageTypes:error.label.missing_file=1" |
diff --git a/src/test/resources/github1548/.gitkeep b/src/test/resources/github1548/.gitkeep
new file mode 100644
index 000000000..e69de29bb