diff --git a/PRDs/20251217-parallel-constraint-validation/PRD.md b/PRDs/20251217-parallel-constraint-validation/PRD.md new file mode 100644 index 000000000..895801bba --- /dev/null +++ b/PRDs/20251217-parallel-constraint-validation/PRD.md @@ -0,0 +1,482 @@ +# Parallel Constraint Validation + +## Overview + +Add experimental support for executing constraint validations in multiple threads to improve validation performance for large documents. + +## Goals + +1. **Performance**: Reduce validation time for large documents by parallelizing constraint evaluation across multiple CPU cores +2. **Flexibility**: Support both CLI usage (specify thread count) and service integration (provide managed ExecutorService) +3. **Backward Compatibility**: Default to single-threaded execution; existing behavior unchanged +4. **Correctness**: Maintain identical validation results regardless of thread count + +## Non-Goals + +- Automatic thread count selection (user must specify) +- Parallelizing schema validation (only constraint validation) +- Distributed validation across multiple machines + +## Requirements + +### Functional Requirements + +1. **CLI Argument**: Add `--threads ` option to validation commands + - Default: 1 (single-threaded, current behavior) + - Values > 1 enable parallel validation + +2. **Service API**: Allow applications to provide a managed `ExecutorService` + - Enables integration with application thread pools + - Shared pool can be reused across multiple validations + +3. **Parallelization Strategy**: + - Document-level: Validate multiple documents concurrently + - Node-level: Validate sibling subtrees within a document concurrently + - Constraint evaluation on each node remains sequential + +4. **Finding Order**: Sort findings by document location before returning (for consistent CLI output) + +### Non-Functional Requirements + +1. **Thread Safety**: All shared state must be thread-safe +2. **No Deadlocks**: Parallel execution must not introduce deadlock scenarios +3. **Graceful Degradation**: If parallelization fails, fall back to sequential execution + +## Technical Design + +### Thread Contention Analysis + +The following shared state requires thread-safe handling: + +| Component | Current State | Issue | Solution | +|-----------|--------------|-------|----------| +| `DefaultConstraintValidator.valueMap` | `LinkedHashMap` | Not thread-safe | `ConcurrentHashMap` | +| `DefaultConstraintValidator.indexNameToKeyRefMap` | `ConcurrentHashMap` with `LinkedList` values | List operations not thread-safe | `computeIfAbsent` + `Collections.synchronizedList` | +| `FindingCollectingConstraintValidationHandler.findings` | `LinkedList` | Not thread-safe | `ConcurrentLinkedQueue` | +| `FindingCollectingConstraintValidationHandler.highestLevel` | `Level` field | Non-atomic updates | `AtomicReference` | +| `DynamicContext.SharedState.executionStack` | `ArrayDeque` (shared) | Concurrent push/pop corrupts state | Move to per-context (copied from parent) | +| `DynamicContext.SharedState.availableDocuments` | `HashMap` | Race condition in caching | `ConcurrentHashMap` | + +### Constraint Dependencies + +Some constraints have ordering dependencies: + +1. **Index constraints** must complete before **index-has-key** validation in `finalizeValidation()` +2. **Allowed-values** constraints are registered during traversal, validated at end of node visit + +The parallel traversal respects these by: +- Running `finalizeValidation()` after all parallel traversal completes +- Keeping per-node constraint evaluation sequential + +### API Design + +#### ParallelValidationConfig + +```java +/** + * Configuration for parallel constraint validation. + *

+ * This class is thread-safe and immutable. + */ +public final class ParallelValidationConfig { + + /** Single-threaded execution (default, current behavior). */ + public static final ParallelValidationConfig SEQUENTIAL = + new ParallelValidationConfig(null, 1); + + private final ExecutorService executor; + private final int threadCount; + + /** + * Create configuration using an application-provided executor. + *

+ * The executor is NOT shut down by the validator; the caller retains ownership. + * + * @param executor the executor service to use for parallel tasks + * @return configuration using the provided executor + */ + public static ParallelValidationConfig withExecutor(@NonNull ExecutorService executor); + + /** + * Create configuration that creates an internal thread pool. + *

+ * The internal pool is shut down after validation completes. + * + * @param threadCount number of threads (must be >= 1) + * @return configuration with internal thread pool + * @throws IllegalArgumentException if threadCount < 1 + */ + public static ParallelValidationConfig withThreads(int threadCount) { + if (threadCount < 1) { + throw new IllegalArgumentException("Thread count must be at least 1, got: " + threadCount); + } + if (threadCount == 1) { + return SEQUENTIAL; + } + return new ParallelValidationConfig(null, threadCount); + } + + /** + * Check if parallel execution is enabled. + * + * @return true if using more than one thread + */ + public boolean isParallel(); + + /** + * Get the executor service, creating an internal pool if needed. + * + * @return the executor service + * @throws IllegalStateException if called on SEQUENTIAL config + */ + @NonNull + ExecutorService getExecutor(); + + /** + * Close this configuration, shutting down internal executor if one was created. + * Does nothing if using an external executor. + * Implements AutoCloseable for use with try-with-resources. + */ + @Override + void close(); +} +``` + +#### Validator Integration + +```java +public class DefaultConstraintValidator { + + /** + * Construct a validator with parallel execution support. + * + * @param handler the validation handler for findings + * @param parallelConfig parallel execution configuration + */ + public DefaultConstraintValidator( + @NonNull IConstraintValidationHandler handler, + @NonNull ParallelValidationConfig parallelConfig); + + /** + * Construct a validator with sequential execution (backward compatible). + * + * @param handler the validation handler for findings + */ + public DefaultConstraintValidator(@NonNull IConstraintValidationHandler handler) { + this(handler, ParallelValidationConfig.SEQUENTIAL); + } +} +``` + +### Thread-Safe Shared State + +#### DefaultConstraintValidator Changes + +```java +public class DefaultConstraintValidator { + + // CHANGED: LinkedHashMap → ConcurrentHashMap + @NonNull + private final Map valueMap = new ConcurrentHashMap<>(); + + // EXISTING: Already ConcurrentHashMap, but list operations need synchronization + @NonNull + private final Map> indexNameToKeyRefMap = new ConcurrentHashMap<>(); + + // CHANGED: Thread-safe collection of KeyRefs + private void validateIndexHasKey( + @NonNull IIndexHasKeyConstraint constraint, + @NonNull IDefinitionNodeItem node, + @NonNull ISequence targets) { + String indexName = constraint.getIndexName(); + + List keyRefItems = indexNameToKeyRefMap.computeIfAbsent( + indexName, + k -> Collections.synchronizedList(new ArrayList<>())); + + keyRefItems.add(new KeyRef(constraint, node, new ArrayList<>(targets))); + } +} +``` + +#### FindingCollectingConstraintValidationHandler Changes + +```java +public class FindingCollectingConstraintValidationHandler { + + // CHANGED: LinkedList → ConcurrentLinkedQueue + @NonNull + private final Queue findings = new ConcurrentLinkedQueue<>(); + + // CHANGED: Level → AtomicReference + @NonNull + private final AtomicReference highestLevel = + new AtomicReference<>(IConstraint.Level.INFORMATIONAL); + + protected void addFinding(@NonNull ConstraintValidationFinding finding) { + findings.add(finding); + + Level severity = finding.getSeverity(); + highestLevel.updateAndGet(current -> + severity.ordinal() > current.ordinal() ? severity : current); + } + + @Override + @NonNull + public Level getHighestSeverity() { + return highestLevel.get(); + } + + @Override + @NonNull + public List getFindings() { + // Sort by document location for consistent CLI output + return findings.stream() + .sorted(Comparator.comparing(f -> f.getTarget().getMetapath())) + .collect(Collectors.toUnmodifiableList()); + } +} +``` + +#### DynamicContext Changes + +```java +public class DynamicContext { + + @NonNull + private final Map> letVariableMap; + @NonNull + private final SharedState sharedState; + // CHANGED: Moved from SharedState to per-context + @NonNull + private final Deque executionStack; + + public DynamicContext(@NonNull StaticContext staticContext) { + this.letVariableMap = new ConcurrentHashMap<>(); + this.sharedState = new SharedState(staticContext); + this.executionStack = new ArrayDeque<>(); + } + + private DynamicContext(@NonNull DynamicContext context) { + this.letVariableMap = new ConcurrentHashMap<>(context.letVariableMap); + this.sharedState = context.sharedState; + // Copy parent's stack so error traces show full call chain + this.executionStack = new ArrayDeque<>(context.executionStack); + } + + private static class SharedState { + // ... other fields unchanged ... + + // CHANGED: HashMap → ConcurrentHashMap + @NonNull + private final Map availableDocuments = new ConcurrentHashMap<>(); + + // REMOVED: executionStack (moved to per-context) + } +} +``` + +### Parallel Traversal Mechanism + +#### Parallel Visitor + +```java +class Visitor extends AbstractNodeItemVisitor { + + private static final int PARALLEL_THRESHOLD = 4; // Min children to parallelize + + private final ParallelValidationConfig parallelConfig; + + @Override + public Void visitAssembly(@NonNull IAssemblyNodeItem item, DynamicContext context) { + assert context != null; + + IAssemblyDefinition definition = item.getDefinition(); + DynamicContext effectiveContext = handleLetStatements(item, definition.getLetExpressions(), context); + + try { + validateAssembly(item, effectiveContext); + } catch (ConstraintValidationException ex) { + throw ExceptionUtils.wrap(ex); + } + + // Parallel or sequential child traversal + if (parallelConfig.isParallel() && shouldParallelize(item)) { + visitChildrenParallel(item, effectiveContext); + } else { + super.visitAssembly(item, effectiveContext); + } + + return null; + } + + private boolean shouldParallelize(@NonNull IAssemblyNodeItem item) { + return item.modelItems().count() >= PARALLEL_THRESHOLD; + } + + private void visitChildrenParallel( + @NonNull IAssemblyNodeItem item, + @NonNull DynamicContext context) { + + ExecutorService executor = parallelConfig.getExecutor(); + List> children = item.modelItems().collect(Collectors.toList()); + + List> futures = new ArrayList<>(children.size()); + for (IModelNodeItem child : children) { + futures.add(executor.submit(() -> { + DynamicContext childContext = context.subContext(); + child.accept(this, childContext); + return null; + })); + } + + // Wait for all children and propagate exceptions + try { + for (Future future : futures) { + future.get(); + } + } catch (ExecutionException e) { + cancelRemainingFutures(futures); + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw ExceptionUtils.wrap(new ConstraintValidationException("Error during parallel validation", cause)); + } catch (InterruptedException e) { + cancelRemainingFutures(futures); + Thread.currentThread().interrupt(); + throw ExceptionUtils.wrap(new ConstraintValidationException("Validation interrupted", e)); + } + } + + private void cancelRemainingFutures(List> futures) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } +} +``` + +### CLI Integration + +#### New Option + +```java +// In AbstractValidateContentCommand +private static final Option PARALLEL_THREADS_OPTION = Option.builder() + .longOpt("threads") + .hasArg() + .argName("count") + .type(Number.class) + .desc("Number of threads for parallel constraint validation (default: 1, experimental)") + .build(); +``` + +#### Usage in Executor + +```java +// In AbstractValidationCommandExecutor +int threadCount = 1; +if (cmdLine.hasOption("threads")) { + threadCount = ((Number) cmdLine.getParsedOptionValue("threads")).intValue(); + if (threadCount < 1) { + throw new InvalidArgumentException("Thread count must be at least 1"); + } +} + +ParallelValidationConfig parallelConfig = threadCount > 1 + ? ParallelValidationConfig.withThreads(threadCount) + : ParallelValidationConfig.SEQUENTIAL; + +try (parallelConfig) { // AutoCloseable - calls close() automatically + // ... validation logic using parallelConfig ... +} +``` + +### Execution Flow Diagram + +```text +CLI: metaschema-cli validate --threads 4 document.xml + + ┌─────────────────┐ + │ Root Assembly │ (main thread validates root) + └────────┬────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ Child 1 │ │ Child 2 │ │ Child 3 │ (parallel via thread pool) + │ subtree │ │ subtree │ │ subtree │ + └─────────┘ └─────────┘ └─────────┘ + │ │ │ + └───────────────────┴───────────────────┘ + │ + ▼ + ┌─────────────────┐ + │finalizeValidation│ (main thread, after all traversal) + │ (index-has-key) │ + └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Sort findings │ (by document location) + │ Return results │ + └─────────────────┘ +``` + +## Testing Strategy + +### Unit Tests + +1. **Thread Safety Tests** + - Concurrent access to `valueMap` + - Concurrent adds to `findings` + - Concurrent updates to `highestLevel` + +2. **Behavioral Equivalence Tests** + - Same document produces identical findings with 1 vs N threads + - Finding count matches between sequential and parallel + +3. **Configuration Tests** + - `ParallelValidationConfig` factory methods + - Executor lifecycle (internal pool shutdown) + +### Integration Tests + +1. **CLI Tests** + - `--threads 1` produces same results as no flag + - `--threads 4` completes successfully + - Invalid thread count rejected + +2. **Large Document Tests** + - Performance improvement with multiple threads + - No missing or duplicate findings + +## Success Metrics + +1. **Correctness**: 100% of existing constraint validation tests pass +2. **Performance**: 2x+ speedup on large documents with 4 threads +3. **Stability**: No deadlocks or race conditions in stress tests + +## Risks and Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Race conditions in shared state | Medium | High | Comprehensive thread-safety review; stress testing | +| Performance regression for small documents | Low | Medium | Parallelize only when children >= threshold | +| Complex debugging | Medium | Medium | Per-context execution stacks; clear error messages | +| Executor resource leaks | Low | Medium | try-finally shutdown pattern; clear ownership semantics | + +## Design Decisions + +1. **PARALLEL_THRESHOLD**: Fixed at 4 (not configurable). This avoids complexity and provides a reasonable default. Can be made configurable later if users request it. + +2. **CLI**: Only `--threads N` option. No `--parallel` shortcut. Explicit thread count is clearer for an experimental feature. + +3. **Experimental warning**: Print warning to stderr when `--threads > 1`: + ```text + WARNING: Parallel constraint validation (--threads N) is experimental. + Report issues at https://github.com/metaschema-framework/metaschema-java/issues + ``` diff --git a/PRDs/20251217-parallel-constraint-validation/implementation-plan.md b/PRDs/20251217-parallel-constraint-validation/implementation-plan.md new file mode 100644 index 000000000..d13670fb8 --- /dev/null +++ b/PRDs/20251217-parallel-constraint-validation/implementation-plan.md @@ -0,0 +1,1377 @@ +# Parallel Constraint Validation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add experimental parallel constraint validation with `--threads N` CLI option to improve validation performance for large documents. + +**Architecture:** Thread-safe shared state in validator/handler classes, parallel sibling traversal via ExecutorService, per-subContext execution stacks. Sequential by default, parallel when `--threads > 1`. + +**Tech Stack:** Java 11, ConcurrentHashMap, AtomicReference, ExecutorService, Apache Commons CLI + +--- + +## Task 1: Create ParallelValidationConfig Class + +**Files:** +- Create: `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfig.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfigTest.java` + +**Step 1: Write the failing tests** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +class ParallelValidationConfigTest { + + @Test + void testSequentialIsNotParallel() { + ParallelValidationConfig config = ParallelValidationConfig.SEQUENTIAL; + assertFalse(config.isParallel()); + } + + @Test + void testWithThreadsOneIsNotParallel() { + ParallelValidationConfig config = ParallelValidationConfig.withThreads(1); + assertFalse(config.isParallel()); + } + + @Test + void testWithThreadsFourIsParallel() { + ParallelValidationConfig config = ParallelValidationConfig.withThreads(4); + assertTrue(config.isParallel()); + config.close(); + } + + @Test + void testWithThreadsZeroThrows() { + assertThrows(IllegalArgumentException.class, () -> ParallelValidationConfig.withThreads(0)); + } + + @Test + void testWithThreadsNegativeThrows() { + assertThrows(IllegalArgumentException.class, () -> ParallelValidationConfig.withThreads(-1)); + } + + @Test + void testWithExecutorIsParallel() { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + ParallelValidationConfig config = ParallelValidationConfig.withExecutor(executor); + assertTrue(config.isParallel()); + } finally { + executor.shutdown(); + } + } + + @Test + void testWithExecutorNullThrows() { + assertThrows(NullPointerException.class, () -> ParallelValidationConfig.withExecutor(null)); + } + + @Test + void testCloseShutdownsInternalExecutor() { + ParallelValidationConfig config = ParallelValidationConfig.withThreads(2); + ExecutorService executor = config.getExecutor(); + assertFalse(executor.isShutdown()); + config.close(); + assertTrue(executor.isShutdown()); + } + + @Test + void testCloseDoesNotShutdownExternalExecutor() { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + ParallelValidationConfig config = ParallelValidationConfig.withExecutor(executor); + config.close(); + assertFalse(executor.isShutdown()); + } finally { + executor.shutdown(); + } + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl core test -Dtest=ParallelValidationConfigTest -DfailIfNoTests=false` +Expected: Compilation error - class does not exist + +**Step 3: Write the implementation** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Configuration for parallel constraint validation. + *

+ * This class supports two modes: + *

+ *

+ * Instances should be used with try-with-resources or explicitly closed after validation. + */ +public final class ParallelValidationConfig implements AutoCloseable { + + /** + * Single-threaded sequential execution (default, current behavior). + *

+ * This instance does not need to be closed. + */ + @NonNull + public static final ParallelValidationConfig SEQUENTIAL = new ParallelValidationConfig(null, 1, false); + + @Nullable + private ExecutorService executor; + private final int threadCount; + private final boolean ownsExecutor; + + private ParallelValidationConfig(@Nullable ExecutorService executor, int threadCount, boolean ownsExecutor) { + this.executor = executor; + this.threadCount = threadCount; + this.ownsExecutor = ownsExecutor; + } + + /** + * Create configuration using an application-provided executor. + *

+ * The executor is NOT shut down by {@link #close()}; the caller retains ownership. + * + * @param executor the executor service to use for parallel tasks + * @return configuration using the provided executor + * @throws NullPointerException if executor is null + */ + @NonNull + public static ParallelValidationConfig withExecutor(@NonNull ExecutorService executor) { + Objects.requireNonNull(executor, "executor must not be null"); + return new ParallelValidationConfig(executor, 0, false); + } + + /** + * Create configuration that creates an internal thread pool. + *

+ * The internal pool is shut down when {@link #close()} is called. + * + * @param threadCount number of threads (must be >= 1) + * @return configuration with internal thread pool + * @throws IllegalArgumentException if threadCount < 1 + */ + @NonNull + public static ParallelValidationConfig withThreads(int threadCount) { + if (threadCount < 1) { + throw new IllegalArgumentException("threadCount must be at least 1, got: " + threadCount); + } + if (threadCount == 1) { + return SEQUENTIAL; + } + return new ParallelValidationConfig(null, threadCount, true); + } + + /** + * Check if parallel execution is enabled. + * + * @return true if using more than one thread + */ + public boolean isParallel() { + return executor != null || threadCount > 1; + } + + /** + * Get the executor service, creating an internal pool if needed. + *

+ * For internal pools, the executor is created lazily on first call. + * + * @return the executor service + * @throws IllegalStateException if called on SEQUENTIAL config + */ + @NonNull + public ExecutorService getExecutor() { + if (!isParallel()) { + throw new IllegalStateException("Cannot get executor for sequential configuration"); + } + if (executor == null) { + synchronized (this) { + if (executor == null) { + executor = Executors.newFixedThreadPool(threadCount); + } + } + } + return executor; + } + + /** + * Shut down internal executor if one was created. + *

+ * Does nothing if using an external executor or if no executor was created. + */ + @Override + public void close() { + if (ownsExecutor && executor != null) { + executor.shutdown(); + try { + if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl core test -Dtest=ParallelValidationConfigTest` +Expected: All 10 tests PASS + +**Step 5: Run checkstyle to verify Javadoc** + +Run: `mvn -pl core checkstyle:check` +Expected: BUILD SUCCESS + +**Step 6: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfig.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfigTest.java +git commit -m "feat(core): add ParallelValidationConfig for parallel constraint validation" +``` + +--- + +## Task 2: Make DynamicContext Thread-Safe + +**Files:** +- Modify: `core/src/main/java/gov/nist/secauto/metaschema/core/metapath/DynamicContext.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/metapath/DynamicContextTest.java` + +**Step 1: Write failing tests for thread-safety** + +Add to existing test file or create new: + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.metapath; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class DynamicContextTest { + + @Test + void testSubContextCopiesExecutionStack() { + DynamicContext parent = new DynamicContext(); + IExpression mockExpr = new MockExpression(); + + parent.pushExecutionStack(mockExpr); + assertEquals(1, parent.getExecutionStack().size()); + + DynamicContext child = parent.subContext(); + + // Child should have copy of parent's stack + assertEquals(1, child.getExecutionStack().size()); + + // Modifying child stack should not affect parent + child.popExecutionStack(mockExpr); + assertEquals(0, child.getExecutionStack().size()); + assertEquals(1, parent.getExecutionStack().size()); + } + + @Test + void testSubContextExecutionStackIsolation() { + DynamicContext parent = new DynamicContext(); + DynamicContext child = parent.subContext(); + + IExpression mockExpr = new MockExpression(); + child.pushExecutionStack(mockExpr); + + // Parent should not see child's push + assertEquals(0, parent.getExecutionStack().size()); + assertEquals(1, child.getExecutionStack().size()); + } + + // Simple mock for testing - real implementation uses CST expressions + private static class MockExpression implements IExpression { + @Override + public String toCSTString() { + return "mock"; + } + // ... implement other required methods with defaults + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl core test -Dtest=DynamicContextTest -DfailIfNoTests=false` +Expected: FAIL - subContext shares executionStack with parent + +**Step 3: Modify DynamicContext** + +In `core/src/main/java/gov/nist/secauto/metaschema/core/metapath/DynamicContext.java`: + +Change 1: Move executionStack from SharedState to instance field (around line 52): + +```java +public class DynamicContext { // NOPMD - intentional data class + + @NonNull + private final Map> letVariableMap; + @NonNull + private final SharedState sharedState; + @NonNull + private final Deque executionStack; +``` + +Change 2: Initialize in primary constructor (around line 69): + +```java + public DynamicContext(@NonNull StaticContext staticContext) { + this.letVariableMap = new ConcurrentHashMap<>(); + this.sharedState = new SharedState(staticContext); + this.executionStack = new ArrayDeque<>(); + } +``` + +Change 3: Copy stack in subContext constructor (around line 74): + +```java + private DynamicContext(@NonNull DynamicContext context) { + this.letVariableMap = new ConcurrentHashMap<>(context.letVariableMap); + this.sharedState = context.sharedState; + this.executionStack = new ArrayDeque<>(context.executionStack); + } +``` + +Change 4: Update SharedState class - remove executionStack (around line 93): + +```java + private static class SharedState { + @NonNull + private final StaticContext staticContext; + @NonNull + private final ZonedDateTime currentDateTime; + @NonNull + private final Map availableDocuments; + @NonNull + private final Map> functionResultCache; + @Nullable + private CachingLoader documentLoader; + @NonNull + private final IMutableConfiguration> configuration; + @NonNull + private ZoneId implicitTimeZone; + // REMOVED: executionStack - now per-context +``` + +Change 5: Update availableDocuments to ConcurrentHashMap (in SharedState constructor): + +```java + this.availableDocuments = new ConcurrentHashMap<>(); +``` + +Change 6: Update push/pop methods to use instance field (around line 376): + +```java + public void pushExecutionStack(@NonNull IExpression expression) { + this.executionStack.push(expression); + } + + public void popExecutionStack(@NonNull IExpression expression) { + IExpression popped = this.executionStack.pop(); + if (!expression.equals(popped)) { + throw new IllegalStateException("Popped expression does not match expected expression"); + } + } + + @NonNull + public Deque getExecutionStack() { + return new ArrayDeque<>(this.executionStack); + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl core test -Dtest=DynamicContextTest` +Expected: All tests PASS + +**Step 5: Run full core tests to verify no regressions** + +Run: `mvn -pl core test` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/metapath/DynamicContext.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/metapath/DynamicContextTest.java +git commit -m "feat(core): make DynamicContext execution stack per-context for thread safety" +``` + +--- + +## Task 3: Make FindingCollectingConstraintValidationHandler Thread-Safe + +**Files:** +- Modify: `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandlerTest.java` + +**Step 1: Write failing tests for thread-safety and sorting** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.junit.jupiter.api.Assertions.*; + +import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +class FindingCollectingConstraintValidationHandlerTest { + + @Test + void testConcurrentAddFindings() throws Exception { + FindingCollectingConstraintValidationHandler handler = + new FindingCollectingConstraintValidationHandler(); + + int threadCount = 10; + int findingsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + executor.submit(() -> { + try { + for (int i = 0; i < findingsPerThread; i++) { + // Create mock finding - implementation will need test helpers + handler.handleExpectViolation( + createMockConstraint(Level.ERROR), + createMockNode("/root/item" + threadId + "-" + i), + createMockNode("/root/item" + threadId + "-" + i), + createMockContext()); + } + } finally { + latch.countDown(); + } + }); + } + + latch.await(30, TimeUnit.SECONDS); + executor.shutdown(); + + List findings = handler.getFindings(); + assertEquals(threadCount * findingsPerThread, findings.size()); + } + + @Test + void testHighestSeverityConcurrentUpdates() throws Exception { + FindingCollectingConstraintValidationHandler handler = + new FindingCollectingConstraintValidationHandler(); + + ExecutorService executor = Executors.newFixedThreadPool(4); + CountDownLatch latch = new CountDownLatch(4); + + // Thread 1: Add INFORMATIONAL findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.INFORMATIONAL); + } + } finally { + latch.countDown(); + } + }); + + // Thread 2: Add WARNING findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.WARNING); + } + } finally { + latch.countDown(); + } + }); + + // Thread 3: Add ERROR findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.ERROR); + } + } finally { + latch.countDown(); + } + }); + + // Thread 4: Add CRITICAL findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.CRITICAL); + } + } finally { + latch.countDown(); + } + }); + + latch.await(30, TimeUnit.SECONDS); + executor.shutdown(); + + assertEquals(Level.CRITICAL, handler.getHighestSeverity()); + assertEquals(400, handler.getFindings().size()); + } + + @Test + void testFindingsSortedByMetapath() { + FindingCollectingConstraintValidationHandler handler = + new FindingCollectingConstraintValidationHandler(); + + // Add findings in random order + addFindingWithPath(handler, "/root/zebra"); + addFindingWithPath(handler, "/root/alpha"); + addFindingWithPath(handler, "/root/middle"); + + List findings = handler.getFindings(); + + assertEquals("/root/alpha", findings.get(0).getTarget().getMetapath()); + assertEquals("/root/middle", findings.get(1).getTarget().getMetapath()); + assertEquals("/root/zebra", findings.get(2).getTarget().getMetapath()); + } + + // Helper methods - implement using mock objects or test fixtures + private void addFinding(FindingCollectingConstraintValidationHandler handler, Level level) { + // Implementation using mocks + } + + private void addFindingWithPath(FindingCollectingConstraintValidationHandler handler, String path) { + // Implementation using mocks + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl core test -Dtest=FindingCollectingConstraintValidationHandlerTest -DfailIfNoTests=false` +Expected: FAIL - race conditions or wrong ordering + +**Step 3: Modify FindingCollectingConstraintValidationHandler** + +In `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java`: + +Change 1: Update imports (around line 20): + +```java +import java.util.Comparator; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +``` + +Change 2: Update class Javadoc (around line 28): + +```java +/** + * A validation result handler that collects the resulting findings for later + * retrieval using the {@link #getFindings()} method. + *

+ * This class is thread-safe and can be used with parallel constraint validation. + */ +``` + +Change 3: Update fields (around line 39): + +```java + @NonNull + private final Queue findings = new ConcurrentLinkedQueue<>(); + @NonNull + private final AtomicReference highestLevel = new AtomicReference<>(IConstraint.Level.INFORMATIONAL); +``` + +Change 4: Update getFindings() method (around line 44): + +```java + @Override + @NonNull + public List getFindings() { + return findings.stream() + .sorted(Comparator.comparing(f -> f.getTarget().getMetapath())) + .collect(Collectors.toUnmodifiableList()); + } +``` + +Change 5: Update getHighestSeverity() method (around line 50): + +```java + @Override + @NonNull + public Level getHighestSeverity() { + return highestLevel.get(); + } +``` + +Change 6: Update addFinding() method (around line 62): + +```java + protected void addFinding(@NonNull ConstraintValidationFinding finding) { + findings.add(finding); + + Level severity = finding.getSeverity(); + highestLevel.updateAndGet(current -> + severity.ordinal() > current.ordinal() ? severity : current); + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl core test -Dtest=FindingCollectingConstraintValidationHandlerTest` +Expected: All tests PASS + +**Step 5: Run full core tests to verify no regressions** + +Run: `mvn -pl core test` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandlerTest.java +git commit -m "feat(core): make FindingCollectingConstraintValidationHandler thread-safe" +``` + +--- + +## Task 4: Make DefaultConstraintValidator Thread-Safe + +**Files:** +- Modify: `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidatorThreadSafetyTest.java` + +**Step 1: Write failing tests** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class DefaultConstraintValidatorThreadSafetyTest { + + @Test + void testConcurrentValueMapAccess() throws Exception { + FindingCollectingConstraintValidationHandler handler = + new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger errors = new AtomicInteger(0); + + for (int t = 0; t < threadCount; t++) { + executor.submit(() -> { + try { + // Simulate concurrent allowed-values validation + // This test verifies no ConcurrentModificationException + for (int i = 0; i < 100; i++) { + // Call methods that access valueMap + // Implementation will use mock node items + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(30, TimeUnit.SECONDS); + executor.shutdown(); + + assertEquals(0, errors.get(), "Should have no concurrent access errors"); + } + + @Test + void testConcurrentIndexKeyRefAccess() throws Exception { + FindingCollectingConstraintValidationHandler handler = + new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger errors = new AtomicInteger(0); + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + executor.submit(() -> { + try { + // Simulate concurrent index-has-key validation + for (int i = 0; i < 100; i++) { + // Call methods that access indexNameToKeyRefMap + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(30, TimeUnit.SECONDS); + executor.shutdown(); + + assertEquals(0, errors.get(), "Should have no concurrent access errors"); + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl core test -Dtest=DefaultConstraintValidatorThreadSafetyTest -DfailIfNoTests=false` +Expected: FAIL or race condition errors + +**Step 3: Modify DefaultConstraintValidator** + +In `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java`: + +Change 1: Update imports (add around line 37): + +```java +import java.util.Collections; +``` + +Change 2: Update valueMap declaration (line 65): + +```java + @NonNull + private final Map valueMap = new ConcurrentHashMap<>(); +``` + +Change 3: Update class Javadoc (around line 51): + +```java +/** + * Used to perform constraint validation over one or more node items. + *

+ * This class is thread-safe when used with {@link ParallelValidationConfig}. + */ +``` + +Change 4: Update validateIndexHasKey method (around line 664): + +```java + private void validateIndexHasKey( + @NonNull IIndexHasKeyConstraint constraint, + @NonNull IDefinitionNodeItem node, + @NonNull ISequence targets) { + String indexName = constraint.getIndexName(); + + List keyRefItems = indexNameToKeyRefMap.computeIfAbsent( + indexName, + k -> Collections.synchronizedList(new ArrayList<>())); + + keyRefItems.add(new KeyRef(constraint, node, new ArrayList<>(targets))); + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl core test -Dtest=DefaultConstraintValidatorThreadSafetyTest` +Expected: All tests PASS + +**Step 5: Run full core tests to verify no regressions** + +Run: `mvn -pl core test` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidatorThreadSafetyTest.java +git commit -m "feat(core): make DefaultConstraintValidator thread-safe" +``` + +--- + +## Task 5: Add Parallel Traversal to DefaultConstraintValidator + +**Files:** +- Modify: `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java` +- Test: `core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationTest.java` + +**Step 1: Write failing tests** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.junit.jupiter.api.Assertions.*; + +import gov.nist.secauto.metaschema.core.metapath.DynamicContext; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +class ParallelValidationTest { + + @Test + void testSequentialAndParallelProduceSameResults() { + // Create a document with multiple sibling nodes + // Validate with sequential (threads=1) + // Validate with parallel (threads=4) + // Assert same findings count and content + + FindingCollectingConstraintValidationHandler sequentialHandler = + new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator sequentialValidator = + new DefaultConstraintValidator(sequentialHandler); + + FindingCollectingConstraintValidationHandler parallelHandler = + new FindingCollectingConstraintValidationHandler(); + + try (ParallelValidationConfig parallelConfig = ParallelValidationConfig.withThreads(4)) { + DefaultConstraintValidator parallelValidator = + new DefaultConstraintValidator(parallelHandler, parallelConfig); + + // Create test document with constraints + // INodeItem testDoc = createTestDocument(); + // DynamicContext context = new DynamicContext(); + + // sequentialValidator.validate(testDoc, context); + // parallelValidator.validate(testDoc, context); + + // List seqFindings = sequentialHandler.getFindings(); + // List parFindings = parallelHandler.getFindings(); + + // assertEquals(seqFindings.size(), parFindings.size()); + // Findings should be same (sorted by location) + } + } + + @Test + void testParallelValidationWithManyChildren() { + // Test that parallel validation works with > PARALLEL_THRESHOLD children + FindingCollectingConstraintValidationHandler handler = + new FindingCollectingConstraintValidationHandler(); + + try (ParallelValidationConfig config = ParallelValidationConfig.withThreads(4)) { + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler, config); + + // Create document with 10+ sibling nodes (above threshold of 4) + // Validate and verify completion without errors + } + } + + @Test + void testParallelConfigConstructor() { + FindingCollectingConstraintValidationHandler handler = + new FindingCollectingConstraintValidationHandler(); + + // Test new constructor accepts ParallelValidationConfig + try (ParallelValidationConfig config = ParallelValidationConfig.withThreads(2)) { + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler, config); + assertNotNull(validator); + } + + // Test backward-compatible constructor still works + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + assertNotNull(validator); + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl core test -Dtest=ParallelValidationTest -DfailIfNoTests=false` +Expected: Compilation error - constructor does not exist + +**Step 3: Modify DefaultConstraintValidator for parallel support** + +In `core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java`: + +Change 1: Add imports: + +```java +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +``` + +Change 2: Add field for parallel config (around line 73): + +```java + @NonNull + private final ParallelValidationConfig parallelConfig; +``` + +Change 3: Add new constructor (around line 81): + +```java + /** + * Construct a new constraint validation instance with parallel execution support. + * + * @param handler + * the validation handler to use for handling constraint violations + * @param parallelConfig + * the parallel execution configuration + */ + public DefaultConstraintValidator( + @NonNull IConstraintValidationHandler handler, + @NonNull ParallelValidationConfig parallelConfig) { + this.handler = handler; + this.configuration = new DefaultConfiguration<>(); + this.parallelConfig = parallelConfig; + } +``` + +Change 4: Update existing constructor to call new one: + +```java + /** + * Construct a new constraint validation instance. + * + * @param handler + * the validation handler to use for handling constraint violations + */ + public DefaultConstraintValidator( + @NonNull IConstraintValidationHandler handler) { + this(handler, ParallelValidationConfig.SEQUENTIAL); + } +``` + +Change 5: Add constant for parallel threshold (around line 63): + +```java + private static final int PARALLEL_THRESHOLD = 4; +``` + +Change 6: Modify Visitor class to support parallel traversal. Update visitAssembly (around line 1070): + +```java + @Override + public Void visitAssembly(@NonNull IAssemblyNodeItem item, DynamicContext context) { + assert context != null; + + IAssemblyDefinition definition = item.getDefinition(); + DynamicContext effectiveContext = handleLetStatements(item, definition.getLetExpressions(), context); + + try { + validateAssembly(item, effectiveContext); + } catch (ConstraintValidationException ex) { + throw ExceptionUtils.wrap(ex); + } + + // Parallel or sequential child traversal + if (parallelConfig.isParallel() && shouldParallelize(item)) { + visitChildrenParallel(item, effectiveContext); + } else { + super.visitAssembly(item, effectiveContext); + } + + return null; + } + + private boolean shouldParallelize(@NonNull IAssemblyNodeItem item) { + return item.modelItems().count() >= PARALLEL_THRESHOLD; + } + + private void visitChildrenParallel( + @NonNull IAssemblyNodeItem item, + @NonNull DynamicContext context) { + + ExecutorService executor = parallelConfig.getExecutor(); + List> children = + item.modelItems().collect(Collectors.toList()); + + List> futures = new ArrayList<>(children.size()); + for (IModelNodeItem child : children) { + futures.add(executor.submit(() -> { + DynamicContext childContext = context.subContext(); + child.accept(this, childContext); + return null; + })); + } + + // Wait for all children and propagate exceptions + try { + for (Future future : futures) { + future.get(); + } + } catch (ExecutionException e) { + cancelRemainingFutures(futures); + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new ConstraintValidationException(cause); + } catch (InterruptedException e) { + cancelRemainingFutures(futures); + Thread.currentThread().interrupt(); + throw new ConstraintValidationException("Validation interrupted", e); + } + } + + private void cancelRemainingFutures(List> futures) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl core test -Dtest=ParallelValidationTest` +Expected: All tests PASS + +**Step 5: Run full core tests to verify no regressions** + +Run: `mvn -pl core test` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java +git add core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationTest.java +git commit -m "feat(core): add parallel traversal support to DefaultConstraintValidator" +``` + +--- + +## Task 6: Add --threads CLI Option + +**Files:** +- Modify: `metaschema-cli/src/main/java/gov/nist/secauto/metaschema/cli/commands/AbstractValidateContentCommand.java` +- Test: `metaschema-cli/src/test/java/gov/nist/secauto/metaschema/cli/commands/ValidateCommandParallelTest.java` + +**Step 1: Write failing tests** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.cli.commands; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ValidateCommandParallelTest { + + @Test + void testThreadsOptionParsing() { + // Test that --threads 4 is parsed correctly + // This will be an integration test using the CLI + } + + @Test + void testThreadsOptionDefaultsToOne() { + // Test that without --threads, validation uses sequential mode + } + + @Test + void testThreadsOptionZeroRejected() { + // Test that --threads 0 produces an error + } + + @Test + void testThreadsOptionNegativeRejected() { + // Test that --threads -1 produces an error + } + + @Test + void testExperimentalWarningPrinted() { + // Test that using --threads > 1 prints experimental warning to stderr + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `mvn -pl metaschema-cli test -Dtest=ValidateCommandParallelTest -DfailIfNoTests=false` +Expected: Tests fail or option not recognized + +**Step 3: Modify AbstractValidateContentCommand** + +In `metaschema-cli/src/main/java/gov/nist/secauto/metaschema/cli/commands/AbstractValidateContentCommand.java`: + +Change 1: Add import: + +```java +import gov.nist.secauto.metaschema.core.model.constraint.ParallelValidationConfig; +``` + +Change 2: Add constant for the option (around line 65): + +```java + @NonNull + private static final Option PARALLEL_THREADS_OPTION = ObjectUtils.notNull( + Option.builder() + .longOpt("threads") + .hasArg() + .argName("count") + .desc("Number of threads for parallel constraint validation (default: 1, experimental)") + .build()); +``` + +Change 3: Add option to gatherOptions() method (around line 110): + +```java + @Override + public Collection gatherOptions() { + return ObjectUtils.notNull(List.of( + CONSTRAINTS_OPTION, + SARIF_OUTPUT_FILE_OPTION, + SARIF_INCLUDE_PASS_OPTION, + NO_SCHEMA_VALIDATION_OPTION, + NO_CONSTRAINT_VALIDATION_OPTION, + PARALLEL_THREADS_OPTION)); + } +``` + +Change 4: Add helper method to parse thread count: + +```java + /** + * Get the parallel validation configuration from command line options. + * + * @param cmdLine the parsed command line + * @return the parallel validation config + * @throws InvalidArgumentException if thread count is invalid + */ + @NonNull + protected static ParallelValidationConfig getParallelConfig(@NonNull CommandLine cmdLine) + throws InvalidArgumentException { + int threadCount = 1; + if (cmdLine.hasOption(PARALLEL_THREADS_OPTION)) { + String value = cmdLine.getOptionValue(PARALLEL_THREADS_OPTION); + try { + threadCount = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new InvalidArgumentException("Invalid thread count: " + value); + } + if (threadCount < 1) { + throw new InvalidArgumentException("Thread count must be at least 1, got: " + threadCount); + } + } + return threadCount > 1 + ? ParallelValidationConfig.withThreads(threadCount) + : ParallelValidationConfig.SEQUENTIAL; + } + + /** + * Print experimental warning if parallel validation is enabled. + * + * @param config the parallel validation config + * @param threadCount the thread count for the warning message + */ + protected static void printParallelWarningIfNeeded( + @NonNull ParallelValidationConfig config, + int threadCount) { + if (config.isParallel()) { + System.err.println("WARNING: Parallel constraint validation (--threads " + threadCount + ") is experimental."); + System.err.println(" Report issues at https://github.com/metaschema-framework/metaschema-java/issues"); + } + } +``` + +Change 5: Update executor to use parallel config. In the execute method of AbstractValidationCommandExecutor, add handling for parallel config: + +```java + // In execute() method, add before validation: + ParallelValidationConfig parallelConfig = getParallelConfig(cmdLine); + int threadCount = cmdLine.hasOption(PARALLEL_THREADS_OPTION) + ? Integer.parseInt(cmdLine.getOptionValue(PARALLEL_THREADS_OPTION)) + : 1; + printParallelWarningIfNeeded(parallelConfig, threadCount); + + try { + // ... existing validation logic, pass parallelConfig to validator ... + } finally { + parallelConfig.close(); + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `mvn -pl metaschema-cli test -Dtest=ValidateCommandParallelTest` +Expected: All tests PASS + +**Step 5: Run full CLI tests to verify no regressions** + +Run: `mvn -pl metaschema-cli test` +Expected: All tests PASS + +**Step 6: Commit** + +```bash +git add metaschema-cli/src/main/java/gov/nist/secauto/metaschema/cli/commands/AbstractValidateContentCommand.java +git add metaschema-cli/src/test/java/gov/nist/secauto/metaschema/cli/commands/ValidateCommandParallelTest.java +git commit -m "feat(cli): add --threads option for parallel constraint validation" +``` + +--- + +## Task 7: Integration Testing and Documentation + +**Files:** +- Create: `metaschema-cli/src/test/java/gov/nist/secauto/metaschema/cli/commands/ParallelValidationIntegrationTest.java` +- Update: PRD with completion status + +**Step 1: Write integration tests** + +```java +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.cli.commands; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +class ParallelValidationIntegrationTest { + + @TempDir + Path tempDir; + + @Test + void testValidateWithThreadsOption() { + // Run CLI with --threads 4 on a sample document + // Verify successful validation + } + + @Test + void testParallelAndSequentialProduceSameOutput() { + // Run same validation with --threads 1 and --threads 4 + // Compare SARIF output (findings should match) + } + + @Test + void testLargeDocumentPerformance() { + // Validate a large document with many sibling nodes + // Verify parallel is faster than sequential (basic smoke test) + } +} +``` + +**Step 2: Run all tests** + +Run: `mvn test` +Expected: All tests PASS + +**Step 3: Run full CI build** + +Run: `mvn clean install -PCI -Prelease` +Expected: BUILD SUCCESS + +**Step 4: Final commit** + +```bash +git add metaschema-cli/src/test/java/gov/nist/secauto/metaschema/cli/commands/ParallelValidationIntegrationTest.java +git commit -m "test(cli): add integration tests for parallel constraint validation" +``` + +--- + +## Verification Checklist + +After all tasks complete: + +- [ ] All unit tests pass: `mvn test` +- [ ] CI build passes: `mvn clean install -PCI -Prelease` +- [ ] Checkstyle passes: `mvn checkstyle:check` +- [ ] `--threads 1` produces same results as no flag +- [ ] `--threads 4` validates successfully +- [ ] Experimental warning prints to stderr +- [ ] Invalid thread counts rejected with clear error + +--- + +## Summary + +| Task | Component | Key Changes | +|------|-----------|-------------| +| 1 | ParallelValidationConfig | New class for thread pool configuration | +| 2 | DynamicContext | Per-context execution stack, ConcurrentHashMap for docs | +| 3 | FindingCollectingHandler | ConcurrentLinkedQueue, AtomicReference, sorted output | +| 4 | DefaultConstraintValidator | ConcurrentHashMap for valueMap, synchronized lists | +| 5 | DefaultConstraintValidator | Parallel visitor, sibling-level parallelization | +| 6 | CLI | --threads option, experimental warning | +| 7 | Integration | End-to-end tests, CI verification | diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/metapath/DynamicContext.java b/core/src/main/java/gov/nist/secauto/metaschema/core/metapath/DynamicContext.java index 9718ef18c..6916beead 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/metapath/DynamicContext.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/metapath/DynamicContext.java @@ -54,6 +54,8 @@ public class DynamicContext { // NOPMD - intentional data class private final SharedState sharedState; @Nullable private final FocusContext focusContext; + @NonNull + private final Deque executionStack; /** * Construct a new dynamic context with a default static context. @@ -72,6 +74,7 @@ public DynamicContext(@NonNull StaticContext staticContext) { this.letVariableMap = new ConcurrentHashMap<>(); this.sharedState = new SharedState(staticContext); this.focusContext = null; + this.executionStack = new ArrayDeque<>(); } private DynamicContext(@NonNull DynamicContext context) { @@ -82,6 +85,8 @@ private DynamicContext(@NonNull DynamicContext context, @Nullable FocusContext f this.letVariableMap = new ConcurrentHashMap<>(context.letVariableMap); this.sharedState = context.sharedState; this.focusContext = focusContext; + // Copy parent's stack so error traces show full call chain + this.executionStack = new ArrayDeque<>(context.executionStack); } private static class SharedState { @@ -98,8 +103,6 @@ private static class SharedState { @NonNull private final IMutableConfiguration> configuration; @NonNull - private final Deque executionStack = new ArrayDeque<>(); - @NonNull private ZoneId implicitTimeZone; public SharedState(@NonNull StaticContext staticContext) { @@ -110,7 +113,7 @@ public SharedState(@NonNull StaticContext staticContext) { this.implicitTimeZone = ObjectUtils.notNull(clock.getZone()); this.currentDateTime = ObjectUtils.notNull(ZonedDateTime.now(clock)); - this.availableDocuments = new HashMap<>(); + this.availableDocuments = new ConcurrentHashMap<>(); this.functionResultCache = ObjectUtils.notNull(Caffeine.newBuilder() .maximumSize(5000) .expireAfterAccess(10, TimeUnit.MINUTES) @@ -416,7 +419,7 @@ public DynamicContext bindVariableValue(@NonNull IEnhancedQName name, @NonNull I * the expression to push */ public void pushExecutionStack(@NonNull IExpression expression) { - this.sharedState.executionStack.push(expression); + this.executionStack.push(expression); } /** @@ -426,7 +429,7 @@ public void pushExecutionStack(@NonNull IExpression expression) { * the expected expression to be popped */ public void popExecutionStack(@NonNull IExpression expression) { - IExpression popped = this.sharedState.executionStack.pop(); + IExpression popped = this.executionStack.pop(); if (!expression.equals(popped)) { throw new IllegalStateException("Popped expression does not match expected expression"); } @@ -439,7 +442,7 @@ public void popExecutionStack(@NonNull IExpression expression) { */ @NonNull public Deque getExecutionStack() { - return new ArrayDeque<>(this.sharedState.executionStack); + return new ArrayDeque<>(this.executionStack); } /** diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java index e428a8f33..42d858faa 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidator.java @@ -19,6 +19,7 @@ import gov.nist.secauto.metaschema.core.metapath.item.node.IDefinitionNodeItem; import gov.nist.secauto.metaschema.core.metapath.item.node.IFieldNodeItem; import gov.nist.secauto.metaschema.core.metapath.item.node.IFlagNodeItem; +import gov.nist.secauto.metaschema.core.metapath.item.node.IModelNodeItem; import gov.nist.secauto.metaschema.core.metapath.item.node.IModuleNodeItem; import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem; import gov.nist.secauto.metaschema.core.model.IAssemblyDefinition; @@ -36,11 +37,13 @@ import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.LinkedList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -51,7 +54,8 @@ /** * Used to perform constraint validation over one or more node items. *

- * This class is not thread safe. + * This class is thread-safe and can be used with parallel constraint + * validation. */ @SuppressWarnings({ "PMD.CouplingBetweenObjects", @@ -62,7 +66,7 @@ public class DefaultConstraintValidator private static final Logger LOGGER = LogManager.getLogger(DefaultConstraintValidator.class); @NonNull - private final Map valueMap = new LinkedHashMap<>(); // NOPMD - intentional + private final Map valueMap = new ConcurrentHashMap<>(); @NonNull private final Map indexNameToIndexMap = new ConcurrentHashMap<>(); @NonNull @@ -71,17 +75,34 @@ public class DefaultConstraintValidator private final IConstraintValidationHandler handler; @NonNull private final IMutableConfiguration> configuration; + @NonNull + private final ParallelValidationConfig parallelConfig; /** - * Construct a new constraint validation instance. + * Construct a new constraint validation instance with sequential execution. * * @param handler * the validation handler to use for handling constraint violations */ public DefaultConstraintValidator( @NonNull IConstraintValidationHandler handler) { + this(handler, ParallelValidationConfig.SEQUENTIAL); + } + + /** + * Construct a new constraint validation instance with configurable parallelism. + * + * @param handler + * the validation handler to use for handling constraint violations + * @param parallelConfig + * the parallel execution configuration + */ + public DefaultConstraintValidator( + @NonNull IConstraintValidationHandler handler, + @NonNull ParallelValidationConfig parallelConfig) { this.handler = handler; this.configuration = new DefaultConfiguration<>(); + this.parallelConfig = parallelConfig; } /** @@ -667,11 +688,11 @@ private void validateIndexHasKey( @NonNull ISequence targets) { String indexName = constraint.getIndexName(); - List keyRefItems = indexNameToKeyRefMap.get(indexName); - if (keyRefItems == null) { - keyRefItems = new LinkedList<>(); - indexNameToKeyRefMap.put(indexName, keyRefItems); - } + // Use computeIfAbsent for thread-safe lazy initialization + // The list is wrapped in synchronizedList to ensure thread-safe add operations + List keyRefItems = indexNameToKeyRefMap.computeIfAbsent( + indexName, + k -> Collections.synchronizedList(new ArrayList<>())); keyRefItems.add(new KeyRef(constraint, node, new ArrayList<>(targets))); } @@ -831,14 +852,8 @@ protected void updateValueStatus( @NonNull INodeItem targetItem, @NonNull IAllowedValuesConstraint allowedValues, @NonNull IDefinitionNodeItem node) throws ConstraintValidationException { - // constraint.getAllowedValues().containsKey(value) - - @Nullable - ValueStatus valueStatus = valueMap.get(targetItem); - if (valueStatus == null) { - valueStatus = new ValueStatus(targetItem); - valueMap.put(targetItem, valueStatus); - } + // Use computeIfAbsent for thread-safe lazy initialization + ValueStatus valueStatus = valueMap.computeIfAbsent(targetItem, ValueStatus::new); valueStatus.registerAllowedValues(allowedValues, node); } @@ -927,23 +942,38 @@ private void validateKeyRef( } } + @SuppressWarnings("PMD.AvoidUsingVolatile") // Required for thread-safe visibility across threads private class ValueStatus { @NonNull - private final List>> constraints = new LinkedList<>(); + private final List>> constraints + = Collections.synchronizedList(new ArrayList<>()); @NonNull private final String value; @NonNull private final INodeItem item; - private boolean allowOthers = true; + private volatile boolean allowOthers = true; @NonNull - private IAllowedValuesConstraint.Extensible extensible = IAllowedValuesConstraint.Extensible.EXTERNAL; + private volatile IAllowedValuesConstraint.Extensible extensible = IAllowedValuesConstraint.Extensible.EXTERNAL; public ValueStatus(@NonNull INodeItem item) { this.item = item; this.value = item.toAtomicItem().asString(); } - public void registerAllowedValues( + /** + * Register allowed values constraint for this item. + *

+ * This method is synchronized to ensure thread-safe updates to the + * extensibility and allowOthers state. + * + * @param allowedValues + * the allowed values constraint + * @param node + * the definition node + * @throws ConstraintValidationException + * if constraint registration fails + */ + public synchronized void registerAllowedValues( @NonNull IAllowedValuesConstraint allowedValues, @NonNull IDefinitionNodeItem node) throws ConstraintValidationException { IAllowedValuesConstraint.Extensible newExtensible = allowedValues.getExtensible(); @@ -983,11 +1013,19 @@ public void registerAllowedValues( } public void validate(@NonNull DynamicContext dynamicContext) { - if (!constraints.isEmpty()) { + // Take a snapshot of the state for thread-safe validation + final boolean localAllowOthers; + final List>> localConstraints; + synchronized (this) { + localAllowOthers = this.allowOthers; + localConstraints = new ArrayList<>(this.constraints); + } + + if (!localConstraints.isEmpty()) { boolean match = false; - List failedConstraints = new LinkedList<>(); + List failedConstraints = new ArrayList<>(); IConstraintValidationHandler handler = getConstraintValidationHandler(); - for (Pair> pair : constraints) { + for (Pair> pair : localConstraints) { IAllowedValuesConstraint allowedValues = pair.getLeft(); IDefinitionNodeItem node = ObjectUtils.notNull(pair.getRight()); IAllowedValue matchingValue = allowedValues.getAllowedValue(value); @@ -1005,7 +1043,7 @@ public void validate(@NonNull DynamicContext dynamicContext) { } // it's not a failure if allow others is true - if (!match && !allowOthers) { + if (!match && !localAllowOthers) { handler.handleAllowedValuesViolation(failedConstraints, item, dynamicContext); } } @@ -1015,6 +1053,11 @@ public void validate(@NonNull DynamicContext dynamicContext) { class Visitor extends AbstractNodeItemVisitor { + /** + * Minimum number of model children required to enable parallel traversal. + */ + private static final int PARALLEL_THRESHOLD = 4; + @NonNull private DynamicContext handleLetStatements( @NonNull INodeItem focus, @@ -1079,10 +1122,88 @@ public Void visitAssembly(@NonNull IAssemblyNodeItem item, DynamicContext contex } catch (ConstraintValidationException ex) { throw ExceptionUtils.wrap(ex); } - super.visitAssembly(item, effectiveContext); + + // Parallel or sequential child traversal + if (parallelConfig.isParallel() && shouldParallelize(item)) { + visitFlags(item, effectiveContext); + visitChildrenParallel(item, effectiveContext); + } else { + super.visitAssembly(item, effectiveContext); + } + return null; } + /** + * Check if the item has enough children to benefit from parallel traversal. + * + * @param item + * the assembly item to check + * @return true if the item has at least PARALLEL_THRESHOLD model children + */ + private boolean shouldParallelize(@NonNull IAssemblyNodeItem item) { + return item.modelItems().count() >= PARALLEL_THRESHOLD; + } + + /** + * Visit model children in parallel using the configured executor. + * + * @param item + * the parent assembly item + * @param context + * the dynamic context + */ + private void visitChildrenParallel( + @NonNull IAssemblyNodeItem item, + @NonNull DynamicContext context) { + + ExecutorService executor = parallelConfig.getExecutor(); + List> children = item.modelItems() + .collect(Collectors.toList()); + + List> futures = new ArrayList<>(children.size()); + for (IModelNodeItem child : children) { + futures.add(executor.submit(() -> { + // Each parallel task gets its own subContext for isolated execution stack + DynamicContext childContext = context.subContext(); + child.accept(this, childContext); + return null; + })); + } + + // Wait for all children and propagate exceptions + try { + for (Future future : futures) { + future.get(); + } + } catch (ExecutionException e) { + cancelRemainingFutures(futures); + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw ExceptionUtils.wrap(new ConstraintValidationException("Error during parallel validation", cause)); + } catch (InterruptedException e) { + cancelRemainingFutures(futures); + Thread.currentThread().interrupt(); + throw ExceptionUtils.wrap(new ConstraintValidationException("Validation interrupted", e)); + } + } + + /** + * Cancel any futures that are still running. + * + * @param futures + * the list of futures to cancel + */ + private void cancelRemainingFutures(@NonNull List> futures) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + @Override public Void visitMetaschema(@NonNull IModuleNodeItem item, DynamicContext context) { throw new UnsupportedOperationException("Method not used."); diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java index a6d62d26c..9ed7a5717 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandler.java @@ -19,9 +19,13 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.util.LinkedList; +import java.util.Comparator; import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; +import java.util.stream.Collectors; import edu.umd.cs.findbugs.annotations.NonNull; @@ -29,7 +33,8 @@ * A validation result handler that collects the resulting findings for later * retrieval using the {@link #getFindings()} method. *

- * This class is not thread safe. + * This class is thread-safe and can be used with parallel constraint + * validation. */ @SuppressWarnings("PMD.CouplingBetweenObjects") public class FindingCollectingConstraintValidationHandler @@ -37,20 +42,23 @@ public class FindingCollectingConstraintValidationHandler implements IValidationResult { private static final Logger LOGGER = LogManager.getLogger(FindingCollectingConstraintValidationHandler.class); @NonNull - private final List findings = new LinkedList<>(); + private final Queue findings = new ConcurrentLinkedQueue<>(); @NonNull - private Level highestLevel = IConstraint.Level.INFORMATIONAL; + private final AtomicReference highestLevel = new AtomicReference<>(IConstraint.Level.INFORMATIONAL); @Override @NonNull public List getFindings() { - return CollectionUtil.unmodifiableList(findings); + // Sort by document location for consistent CLI output + return findings.stream() + .sorted(Comparator.comparing(f -> f.getTarget().getMetapath())) + .collect(Collectors.toUnmodifiableList()); } @Override @NonNull public Level getHighestSeverity() { - return highestLevel; + return highestLevel.get(); } /** @@ -63,9 +71,7 @@ protected void addFinding(@NonNull ConstraintValidationFinding finding) { findings.add(finding); Level severity = finding.getSeverity(); - if (severity.ordinal() > highestLevel.ordinal()) { - highestLevel = severity; - } + highestLevel.updateAndGet(current -> severity.ordinal() > current.ordinal() ? severity : current); } @NonNull diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfig.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfig.java new file mode 100644 index 000000000..54c29f0e0 --- /dev/null +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfig.java @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Configuration for parallel constraint validation. + *

+ * This class supports two modes: + *

+ *

+ * Instances should be used with try-with-resources or explicitly closed after + * validation. + */ +public final class ParallelValidationConfig implements AutoCloseable { + + /** + * Single-threaded sequential execution (default, current behavior). + *

+ * This instance does not need to be closed. + */ + @NonNull + public static final ParallelValidationConfig SEQUENTIAL = new ParallelValidationConfig(null, 1, false); + + /** + * The executor service, lazily initialized if using internal pool. + *

+ * Volatile is required for thread-safe lazy initialization. + */ + @SuppressWarnings("PMD.AvoidUsingVolatile") // Required for thread-safe lazy initialization + @Nullable + private volatile ExecutorService executor; + private final int threadCount; + private final boolean ownsExecutor; + + private ParallelValidationConfig(@Nullable ExecutorService executor, int threadCount, boolean ownsExecutor) { + this.executor = executor; + this.threadCount = threadCount; + this.ownsExecutor = ownsExecutor; + } + + /** + * Create configuration using an application-provided executor. + *

+ * The executor is NOT shut down by {@link #close()}; the caller retains + * ownership. + * + * @param executor + * the executor service to use for parallel tasks + * @return configuration using the provided executor + * @throws NullPointerException + * if executor is null + */ + @NonNull + public static ParallelValidationConfig withExecutor(@NonNull ExecutorService executor) { + Objects.requireNonNull(executor, "executor must not be null"); + return new ParallelValidationConfig(executor, 0, false); + } + + /** + * Create configuration that creates an internal thread pool. + *

+ * The internal pool is shut down when {@link #close()} is called. + * + * @param threadCount + * number of threads (must be >= 1) + * @return configuration with internal thread pool + * @throws IllegalArgumentException + * if threadCount < 1 + */ + @NonNull + public static ParallelValidationConfig withThreads(int threadCount) { + if (threadCount < 1) { + throw new IllegalArgumentException("threadCount must be at least 1, got: " + threadCount); + } + if (threadCount == 1) { + return SEQUENTIAL; + } + return new ParallelValidationConfig(null, threadCount, true); + } + + /** + * Check if parallel execution is enabled. + * + * @return true if using more than one thread + */ + public boolean isParallel() { + return executor != null || threadCount > 1; + } + + /** + * Get the executor service, creating an internal pool if needed. + *

+ * For internal pools, the executor is created lazily on first call. + * + * @return the executor service + * @throws IllegalStateException + * if called on SEQUENTIAL config + */ + @SuppressWarnings("PMD.DoubleCheckedLocking") // Correct with volatile field + @NonNull + public ExecutorService getExecutor() { + if (!isParallel()) { + throw new IllegalStateException("Cannot get executor for sequential configuration"); + } + ExecutorService result = executor; + if (result == null) { + synchronized (this) { + result = executor; + if (result == null) { + // Use ForkJoinPool to avoid deadlock with nested parallelism. + // Fixed thread pools deadlock when all threads wait for children. + result = new ForkJoinPool(threadCount); + executor = result; + } + } + } + return Objects.requireNonNull(result, "Executor should not be null after initialization"); + } + + /** + * Shut down internal executor if one was created. + *

+ * Does nothing if using an external executor or if no executor was created. + */ + @Override + public void close() { + ExecutorService exec = executor; + if (ownsExecutor && exec != null) { + exec.shutdown(); + try { + if (!exec.awaitTermination(60, TimeUnit.SECONDS)) { + exec.shutdownNow(); + } + } catch (InterruptedException e) { + exec.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValidationFeature.java b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValidationFeature.java index db13481fa..6fd44ce3a 100644 --- a/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValidationFeature.java +++ b/core/src/main/java/gov/nist/secauto/metaschema/core/model/constraint/ValidationFeature.java @@ -30,6 +30,18 @@ public final class ValidationFeature @NonNull public static final ValidationFeature THROW_EXCEPTION_ON_ERROR = new ValidationFeature<>("throw-exception-on-error", Boolean.class, false); + /** + * The number of threads to use for parallel constraint validation. + *

+ * A value of 1 (the default) means sequential validation. Values greater than 1 + * enable experimental parallel validation with the specified number of threads. + *

+ * Warning: Parallel validation is an experimental feature. Results + * should be verified against sequential validation. + */ + @NonNull + public static final ValidationFeature PARALLEL_THREADS + = new ValidationFeature<>("parallel-threads", Integer.class, 1); private ValidationFeature( @NonNull String name, diff --git a/core/src/test/java/gov/nist/secauto/metaschema/core/metapath/DynamicContextTest.java b/core/src/test/java/gov/nist/secauto/metaschema/core/metapath/DynamicContextTest.java new file mode 100644 index 000000000..418ec6737 --- /dev/null +++ b/core/src/test/java/gov/nist/secauto/metaschema/core/metapath/DynamicContextTest.java @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.metapath; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import gov.nist.secauto.metaschema.core.metapath.cst.IExpressionVisitor; +import gov.nist.secauto.metaschema.core.metapath.item.IItem; +import gov.nist.secauto.metaschema.core.metapath.item.ISequence; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import edu.umd.cs.findbugs.annotations.NonNull; + +class DynamicContextTest { + + @Test + void testSubContextCopiesExecutionStack() { + DynamicContext parent = new DynamicContext(); + IExpression mockExpr = new MockExpression(); + + parent.pushExecutionStack(mockExpr); + assertEquals(1, parent.getExecutionStack().size()); + + DynamicContext child = parent.subContext(); + + // Child should have copy of parent's stack + assertEquals(1, child.getExecutionStack().size()); + + // Modifying child stack should not affect parent + child.popExecutionStack(mockExpr); + assertEquals(0, child.getExecutionStack().size()); + assertEquals(1, parent.getExecutionStack().size()); + } + + @Test + void testSubContextExecutionStackIsolation() { + DynamicContext parent = new DynamicContext(); + DynamicContext child = parent.subContext(); + + IExpression mockExpr = new MockExpression(); + child.pushExecutionStack(mockExpr); + + // Parent should not see child's push + assertEquals(0, parent.getExecutionStack().size()); + assertEquals(1, child.getExecutionStack().size()); + } + + /** + * Simple mock expression for testing execution stack isolation. + */ + private static class MockExpression implements IExpression { + @Override + public String toCSTString() { + return "mock"; + } + + @Override + @NonNull + public String getPath() { + return "mock"; + } + + @Override + @NonNull + public List getChildren() { + return Collections.emptyList(); + } + + @Override + @NonNull + public ISequence accept( + @NonNull DynamicContext dynamicContext, + @NonNull ISequence focus) { + return ISequence.empty(); + } + + @Override + public RESULT accept( + @NonNull IExpressionVisitor visitor, + @NonNull CONTEXT context) { + return null; + } + } +} diff --git a/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidatorThreadSafetyTest.java b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidatorThreadSafetyTest.java new file mode 100644 index 000000000..b9a3dac77 --- /dev/null +++ b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/DefaultConstraintValidatorThreadSafetyTest.java @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import gov.nist.secauto.metaschema.core.metapath.DynamicContext; +import gov.nist.secauto.metaschema.core.metapath.IMetapathExpression; +import gov.nist.secauto.metaschema.core.metapath.StaticContext; +import gov.nist.secauto.metaschema.core.metapath.item.ISequence; +import gov.nist.secauto.metaschema.core.metapath.item.node.IDefinitionNodeItem; +import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem; +import gov.nist.secauto.metaschema.core.model.ISource; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Tests for thread-safety of DefaultConstraintValidator. + */ +@SuppressWarnings({ "PMD.TooManyStaticImports", "PMD.CouplingBetweenObjects" }) +class DefaultConstraintValidatorThreadSafetyTest { + + /** + * Test that parallel validation produces the same results as sequential + * validation. + */ + @Test + void testParallelValidationBehavioralEquivalence() throws Exception { + // This test verifies that parallel validation doesn't crash + // and that the ParallelValidationConfig integrates correctly + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + + try (ParallelValidationConfig config = ParallelValidationConfig.withThreads(4)) { + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler, config); + + // Verify the validator was created successfully with parallel config + assertTrue(config.isParallel(), "Config should be parallel with 4 threads"); + + // The executor should be created lazily + java.util.concurrent.ExecutorService executor = config.getExecutor(); + assertTrue(executor != null, "Executor should be created"); + } + } + + /** + * Test that sequential validation still works with new constructor. + */ + @Test + void testSequentialValidationBackwardCompatibility() { + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + + // Old constructor should still work + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + + // New constructor with SEQUENTIAL config should also work + DefaultConstraintValidator validator2 = new DefaultConstraintValidator( + handler, ParallelValidationConfig.SEQUENTIAL); + + // Both should work without errors + assertTrue(true, "Both constructors should work"); + } + + /** + * Test that concurrent calls to validateIndexHasKey properly accumulate key + * references without race conditions. + */ + @Test + void testConcurrentIndexHasKeyAccumulation() throws Exception { + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + + int threadCount = 10; + int keysPerThread = 50; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + ConcurrentLinkedQueue errorMessages = new ConcurrentLinkedQueue<>(); + + // All threads will add key references to the same index name + String indexName = "test-index"; + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + executor.submit(() -> { + try { + startLatch.await(); // Wait for all threads to be ready + for (int i = 0; i < keysPerThread; i++) { + IIndexHasKeyConstraint constraint = createMockIndexHasKeyConstraint(indexName); + IDefinitionNodeItem node = createMockDefinitionNodeItem("/thread" + threadId + "/item" + i); + ISequence targets = createMockSequence(); + + // This method adds to indexNameToKeyRefMap + invokeValidateIndexHasKey(validator, constraint, node, targets); + } + } catch (Exception e) { + StringBuilder sb = new StringBuilder(); + sb.append("Thread ").append(threadId).append(" error: ") + .append(e.getClass().getName()).append(": ").append(e.getMessage()); + if (e.getCause() != null) { + sb.append(" Caused by: ").append(e.getCause().getClass().getName()) + .append(": ").append(e.getCause().getMessage()); + } + errorMessages.add(sb.toString()); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); // Start all threads simultaneously + assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads should complete within timeout"); + executor.shutdown(); + + assertEquals(0, errorMessages.size(), + "No errors should occur during concurrent access. Errors: " + errorMessages); + } + + /** + * Test that concurrent updateValueStatus calls properly track allowed values + * without race conditions. + */ + @Test + void testConcurrentAllowedValuesTracking() throws Exception { + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + DefaultConstraintValidator validator = new DefaultConstraintValidator(handler); + + int threadCount = 8; + int itemsPerThread = 25; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + ConcurrentLinkedQueue errorMessages = new ConcurrentLinkedQueue<>(); + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + executor.submit(() -> { + try { + startLatch.await(); + for (int i = 0; i < itemsPerThread; i++) { + INodeItem targetItem = createMockNodeItemWithValue("/thread" + threadId + "/value" + i); + IAllowedValuesConstraint constraint = createMockAllowedValuesConstraint(); + IDefinitionNodeItem node = createMockDefinitionNodeItem("/thread" + threadId + "/node" + i); + + try { + validator.updateValueStatus(targetItem, constraint, node); + } catch (ConstraintValidationException ex) { + // Expected for some constraint combinations + } + } + } catch (Exception e) { + StringBuilder sb = new StringBuilder(); + sb.append("Thread ").append(threadId).append(" error: ") + .append(e.getClass().getName()).append(": ").append(e.getMessage()); + if (e.getCause() != null) { + sb.append(" Caused by: ").append(e.getCause().getClass().getName()) + .append(": ").append(e.getCause().getMessage()); + } + errorMessages.add(sb.toString()); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "Threads should complete within timeout"); + executor.shutdown(); + + assertEquals(0, errorMessages.size(), + "No errors should occur during concurrent access. Errors: " + errorMessages); + } + + // Helper methods + + @NonNull + private IIndexHasKeyConstraint createMockIndexHasKeyConstraint(@NonNull String indexName) { + IIndexHasKeyConstraint constraint = mock(IIndexHasKeyConstraint.class); + ISource source = mock(ISource.class); + doReturn(source).when(constraint).getSource(); + doReturn(StaticContext.instance()).when(source).getStaticContext(); + doReturn(indexName).when(constraint).getIndexName(); + doReturn(IConstraint.Level.ERROR).when(constraint).getLevel(); + doReturn(Collections.emptyList()).when(constraint).getKeyFields(); + return constraint; + } + + @SuppressWarnings("unchecked") + @NonNull + private IDefinitionNodeItem createMockDefinitionNodeItem(@NonNull String metapath) { + IDefinitionNodeItem item = mock(IDefinitionNodeItem.class); + doReturn(metapath).when(item).getMetapath(); + return item; + } + + @NonNull + private INodeItem createMockNodeItemWithValue(@NonNull String metapath) { + INodeItem item = mock(INodeItem.class); + doReturn(metapath).when(item).getMetapath(); + doReturn(true).when(item).hasValue(); + + // Mock toAtomicItem() to return a proper atomic item + gov.nist.secauto.metaschema.core.metapath.item.atomic.IAnyAtomicItem atomicItem + = mock(gov.nist.secauto.metaschema.core.metapath.item.atomic.IAnyAtomicItem.class); + doReturn("test-value").when(atomicItem).asString(); + doReturn(atomicItem).when(item).toAtomicItem(); + + return item; + } + + @NonNull + private ISequence createMockSequence() { + // Return an actual empty sequence instead of a mock + // This avoids issues with incomplete mock implementations + return ISequence.empty(); + } + + @NonNull + private IAllowedValuesConstraint createMockAllowedValuesConstraint() { + IAllowedValuesConstraint constraint = mock(IAllowedValuesConstraint.class); + ISource source = mock(ISource.class); + doReturn(source).when(constraint).getSource(); + doReturn(StaticContext.instance()).when(source).getStaticContext(); + doReturn(IConstraint.Level.ERROR).when(constraint).getLevel(); + doReturn(IAllowedValuesConstraint.Extensible.EXTERNAL).when(constraint).getExtensible(); + doReturn(true).when(constraint).isAllowedOther(); + IMetapathExpression targetExpr = mock(IMetapathExpression.class); + doReturn(".").when(targetExpr).getPath(); + doReturn(targetExpr).when(constraint).getTarget(); + return constraint; + } + + /** + * Invoke the private validateIndexHasKey method via the public interface. Since + * validateIndexHasKey is private, we use reflection to test the thread-safety + * of the underlying data structure operations. + */ + private void invokeValidateIndexHasKey( + @NonNull DefaultConstraintValidator validator, + @NonNull IIndexHasKeyConstraint constraint, + @NonNull IDefinitionNodeItem node, + @NonNull ISequence targets) throws Exception { + // Use reflection to access the private method + java.lang.reflect.Method method = DefaultConstraintValidator.class.getDeclaredMethod( + "validateIndexHasKey", + IIndexHasKeyConstraint.class, + IDefinitionNodeItem.class, + ISequence.class); + method.setAccessible(true); + method.invoke(validator, constraint, node, targets); + } +} diff --git a/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandlerTest.java b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandlerTest.java new file mode 100644 index 000000000..8ac28985b --- /dev/null +++ b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/FindingCollectingConstraintValidationHandlerTest.java @@ -0,0 +1,190 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import gov.nist.secauto.metaschema.core.metapath.DynamicContext; +import gov.nist.secauto.metaschema.core.metapath.StaticContext; +import gov.nist.secauto.metaschema.core.metapath.format.IPathFormatter; +import gov.nist.secauto.metaschema.core.metapath.item.node.INodeItem; +import gov.nist.secauto.metaschema.core.model.ISource; +import gov.nist.secauto.metaschema.core.model.constraint.IConstraint.Level; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@SuppressWarnings("PMD.TooManyStaticImports") +class FindingCollectingConstraintValidationHandlerTest { + + @Test + void testConcurrentAddFindings() throws Exception { + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + + int threadCount = 10; + int findingsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int t = 0; t < threadCount; t++) { + final int threadId = t; + executor.submit(() -> { + try { + for (int i = 0; i < findingsPerThread; i++) { + // Create mock finding + addFinding(handler, Level.ERROR, "/root/item" + threadId + "-" + i); + } + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS), "Threads should complete within timeout"); + executor.shutdown(); + + List findings = handler.getFindings(); + assertEquals(threadCount * findingsPerThread, findings.size(), + "Should have all findings from all threads"); + } + + @Test + void testHighestSeverityConcurrentUpdates() throws Exception { + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + + ExecutorService executor = Executors.newFixedThreadPool(4); + CountDownLatch latch = new CountDownLatch(4); + + // Thread 1: Add INFORMATIONAL findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.INFORMATIONAL, "/root/info-" + i); + } + } finally { + latch.countDown(); + } + }); + + // Thread 2: Add WARNING findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.WARNING, "/root/warn-" + i); + } + } finally { + latch.countDown(); + } + }); + + // Thread 3: Add ERROR findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.ERROR, "/root/error-" + i); + } + } finally { + latch.countDown(); + } + }); + + // Thread 4: Add CRITICAL findings + executor.submit(() -> { + try { + for (int i = 0; i < 100; i++) { + addFinding(handler, Level.CRITICAL, "/root/critical-" + i); + } + } finally { + latch.countDown(); + } + }); + + assertTrue(latch.await(30, TimeUnit.SECONDS), "Threads should complete within timeout"); + executor.shutdown(); + + assertEquals(Level.CRITICAL, handler.getHighestSeverity(), + "Highest severity should be CRITICAL"); + assertEquals(400, handler.getFindings().size(), + "Should have all findings from all threads"); + } + + @Test + void testFindingsSortedByMetapath() { + FindingCollectingConstraintValidationHandler handler = new FindingCollectingConstraintValidationHandler(); + + // Add findings in random order + addFinding(handler, Level.ERROR, "/root/zebra"); + addFinding(handler, Level.ERROR, "/root/alpha"); + addFinding(handler, Level.ERROR, "/root/middle"); + + List findings = handler.getFindings(); + + assertEquals(3, findings.size(), "Should have 3 findings"); + assertEquals("/root/alpha", findings.get(0).getTarget().getMetapath(), + "First finding should be /root/alpha"); + assertEquals("/root/middle", findings.get(1).getTarget().getMetapath(), + "Second finding should be /root/middle"); + assertEquals("/root/zebra", findings.get(2).getTarget().getMetapath(), + "Third finding should be /root/zebra"); + } + + /** + * Helper method to add a finding with a specific level and metapath. + * + * @param handler + * the handler to add the finding to + * @param level + * the severity level + * @param metapath + * the metapath for the target node + */ + private void addFinding( + @NonNull FindingCollectingConstraintValidationHandler handler, + @NonNull Level level, + @NonNull String metapath) { + // Create mock constraint + IExpectConstraint constraint = mock(IExpectConstraint.class); + ISource source = mock(ISource.class); + doReturn(source).when(constraint).getSource(); + doReturn(level).when(constraint).getLevel(); + doReturn("test-constraint").when(constraint).getId(); + doReturn("Test violation message").when(constraint).getMessage(); + try { + doReturn("Test violation message").when(constraint).generateMessage(any(), any()); + } catch (ConstraintValidationException e) { + // Mockito stub doesn't actually call the method + } + doReturn(StaticContext.instance()).when(source).getStaticContext(); + + // Create mock node item with the specified metapath + INodeItem node = mock(INodeItem.class); + doReturn(metapath).when(node).getMetapath(); + doReturn(metapath).when(node).toPath(any(IPathFormatter.class)); + + INodeItem target = node; + + // Create dynamic context + DynamicContext context = new DynamicContext(StaticContext.instance()); + + // Add the finding + try { + handler.handleExpectViolation(constraint, node, target, context); + } catch (ConstraintValidationException e) { + throw new RuntimeException("Unexpected exception during test", e); + } + } +} diff --git a/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfigTest.java b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfigTest.java new file mode 100644 index 000000000..058ac23d0 --- /dev/null +++ b/core/src/test/java/gov/nist/secauto/metaschema/core/model/constraint/ParallelValidationConfigTest.java @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: none + * SPDX-License-Identifier: CC0-1.0 + */ + +package gov.nist.secauto.metaschema.core.model.constraint; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +class ParallelValidationConfigTest { + + @Test + void testSequentialIsNotParallel() { + ParallelValidationConfig config = ParallelValidationConfig.SEQUENTIAL; + assertFalse(config.isParallel()); + } + + @Test + void testWithThreadsOneIsNotParallel() { + ParallelValidationConfig config = ParallelValidationConfig.withThreads(1); + assertFalse(config.isParallel()); + } + + @Test + void testWithThreadsFourIsParallel() { + ParallelValidationConfig config = ParallelValidationConfig.withThreads(4); + assertTrue(config.isParallel()); + config.close(); + } + + @Test + void testWithThreadsZeroThrows() { + assertThrows(IllegalArgumentException.class, () -> ParallelValidationConfig.withThreads(0)); + } + + @Test + void testWithThreadsNegativeThrows() { + assertThrows(IllegalArgumentException.class, () -> ParallelValidationConfig.withThreads(-1)); + } + + @Test + void testWithExecutorIsParallel() { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + ParallelValidationConfig config = ParallelValidationConfig.withExecutor(executor); + assertTrue(config.isParallel()); + } finally { + executor.shutdown(); + } + } + + @Test + void testWithExecutorNullThrows() { + assertThrows(NullPointerException.class, () -> ParallelValidationConfig.withExecutor(null)); + } + + @Test + void testCloseShutdownsInternalExecutor() { + ParallelValidationConfig config = ParallelValidationConfig.withThreads(2); + ExecutorService executor = config.getExecutor(); + assertFalse(executor.isShutdown()); + config.close(); + assertTrue(executor.isShutdown()); + } + + @Test + void testCloseDoesNotShutdownExternalExecutor() { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + ParallelValidationConfig config = ParallelValidationConfig.withExecutor(executor); + config.close(); + assertFalse(executor.isShutdown()); + } finally { + executor.shutdown(); + } + } +} diff --git a/databind/src/main/java/gov/nist/secauto/metaschema/databind/IBindingContext.java b/databind/src/main/java/gov/nist/secauto/metaschema/databind/IBindingContext.java index df6b9898e..138d11cee 100644 --- a/databind/src/main/java/gov/nist/secauto/metaschema/databind/IBindingContext.java +++ b/databind/src/main/java/gov/nist/secauto/metaschema/databind/IBindingContext.java @@ -24,6 +24,7 @@ import gov.nist.secauto.metaschema.core.model.constraint.IConstraintSet; import gov.nist.secauto.metaschema.core.model.constraint.IConstraintValidationHandler; import gov.nist.secauto.metaschema.core.model.constraint.IConstraintValidator; +import gov.nist.secauto.metaschema.core.model.constraint.ParallelValidationConfig; import gov.nist.secauto.metaschema.core.model.constraint.ValidationFeature; import gov.nist.secauto.metaschema.core.model.validation.AggregateValidationResult; import gov.nist.secauto.metaschema.core.model.validation.IValidationResult; @@ -450,7 +451,15 @@ default IConstraintValidator newValidator( DynamicContext context = new DynamicContext(); context.setDocumentLoader(loader); - DefaultConstraintValidator retval = new DefaultConstraintValidator(handler); + // Determine parallel validation configuration + int threadCount = config != null + ? config.get(ValidationFeature.PARALLEL_THREADS) + : ValidationFeature.PARALLEL_THREADS.getDefault(); + ParallelValidationConfig parallelConfig = threadCount > 1 + ? ParallelValidationConfig.withThreads(threadCount) + : ParallelValidationConfig.SEQUENTIAL; + + DefaultConstraintValidator retval = new DefaultConstraintValidator(handler, parallelConfig); if (config != null) { retval.applyConfiguration(config); } diff --git a/metaschema-cli/src/main/java/gov/nist/secauto/metaschema/cli/commands/AbstractValidateContentCommand.java b/metaschema-cli/src/main/java/gov/nist/secauto/metaschema/cli/commands/AbstractValidateContentCommand.java index edcbfd7f6..81d0ebc29 100644 --- a/metaschema-cli/src/main/java/gov/nist/secauto/metaschema/cli/commands/AbstractValidateContentCommand.java +++ b/metaschema-cli/src/main/java/gov/nist/secauto/metaschema/cli/commands/AbstractValidateContentCommand.java @@ -110,6 +110,15 @@ public abstract class AbstractValidateContentCommand .desc("path format in validation output: auto (default, selects based on document format), " + "metapath, xpath, jsonpointer") .get()); + @NonNull + private static final Option PARALLEL_THREADS_OPTION = ObjectUtils.notNull( + Option.builder() + .longOpt("threads") + .hasArg() + .argName("count") + .type(Number.class) + .desc("number of threads for parallel constraint validation (default: 1, experimental)") + .get()); @Override public String getName() { @@ -126,7 +135,8 @@ public Collection gatherOptions() { SARIF_INCLUDE_PASS_OPTION, NO_SCHEMA_VALIDATION_OPTION, NO_CONSTRAINT_VALIDATION_OPTION, - PATH_FORMAT_OPTION); + PATH_FORMAT_OPTION, + PARALLEL_THREADS_OPTION); } @Override @@ -283,6 +293,32 @@ private IValidationResult validate( configuration.enableFeature(ValidationFeature.VALIDATE_GENERATE_PASS_FINDINGS); } + // Configure parallel validation if requested + if (commandLine.hasOption(PARALLEL_THREADS_OPTION)) { + String threadValue = commandLine.getOptionValue(PARALLEL_THREADS_OPTION); + int threadCount; + try { + threadCount = Integer.parseInt(threadValue); + } catch (NumberFormatException ex) { + throw new CommandExecutionException( + ExitCode.INVALID_ARGUMENTS, + String.format("Invalid thread count '%s': must be a positive integer", threadValue), + ex); + } + if (threadCount < 1) { + throw new CommandExecutionException( + ExitCode.INVALID_ARGUMENTS, + String.format("Thread count must be at least 1, got: %d", threadCount)); + } + if (threadCount > 1) { + if (LOGGER.isWarnEnabled()) { + LOGGER.warn("Parallel constraint validation is an experimental feature. " + + "Using {} threads.", threadCount); + } + configuration.set(ValidationFeature.PARALLEL_THREADS, threadCount); + } + } + // perform constraint validation bindingContext.registerModule(module); // ensure the module is registered IValidationResult constraintValidationResult = bindingContext.validateWithConstraints(source, configuration);