diff --git a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java index 9ee3ccf1d..4804756a9 100644 --- a/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java +++ b/maven-resolver-api/src/main/java/org/eclipse/aether/ConfigurationProperties.java @@ -81,6 +81,13 @@ public final class ConfigurationProperties { */ public static final String PREFIX_GENERATOR = PREFIX_AETHER + "generator."; + /** + * Prefix for util related configurations. For internal use only. + * + * @since 2.0.10 + */ + public static final String PREFIX_UTIL = PREFIX_AETHER + "util."; + /** * Prefix for transport related configurations. For internal use only. * @@ -544,6 +551,25 @@ public final class ConfigurationProperties { */ public static final String REPOSITORY_SYSTEM_DEPENDENCY_VISITOR_LEVELORDER = "levelOrder"; + /** + * A flag indicating whether version scheme cache statistics should be printed on JVM shutdown. + * This is useful for analyzing cache performance and effectiveness in development and testing scenarios. + * + * @since 2.0.10 + * @configurationSource {@link RepositorySystemSession#getConfigProperties()} + * @configurationType {@link java.lang.Boolean} + * @configurationDefaultValue {@link #DEFAULT_VERSION_SCHEME_CACHE_DEBUG} + * @configurationRepoIdSuffix No + */ + public static final String VERSION_SCHEME_CACHE_DEBUG = PREFIX_UTIL + "versionScheme.cacheDebug"; + + /** + * The default value for version scheme cache debug if {@link #VERSION_SCHEME_CACHE_DEBUG} isn't set. + * + * @since 2.0.10 + */ + public static final boolean DEFAULT_VERSION_SCHEME_CACHE_DEBUG = false; + private ConfigurationProperties() { // hide constructor } diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java index 9c4a715ff..5eb1bf062 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/version/GenericVersionScheme.java @@ -18,6 +18,12 @@ */ package org.eclipse.aether.util.version; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import org.eclipse.aether.ConfigurationProperties; import org.eclipse.aether.version.InvalidVersionSpecificationException; /** @@ -46,9 +52,99 @@ *

*/ public class GenericVersionScheme extends VersionSchemeSupport { + + // Using WeakHashMap wrapped in synchronizedMap for thread safety and memory-sensitive caching + private final Map versionCache = Collections.synchronizedMap(new WeakHashMap<>()); + + // Cache statistics + private final AtomicLong cacheHits = new AtomicLong(0); + private final AtomicLong cacheMisses = new AtomicLong(0); + private final AtomicLong totalRequests = new AtomicLong(0); + + // Static statistics across all instances + private static final AtomicLong GLOBAL_CACHE_HITS = new AtomicLong(0); + private static final AtomicLong GLOBAL_CACHE_MISSES = new AtomicLong(0); + private static final AtomicLong GLOBAL_TOTAL_REQUESTS = new AtomicLong(0); + private static final AtomicLong INSTANCE_COUNT = new AtomicLong(0); + + static { + // Register shutdown hook to print statistics if enabled + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (isStatisticsEnabled()) { + printGlobalStatistics(); + } + })); + } + + public GenericVersionScheme() { + INSTANCE_COUNT.incrementAndGet(); + } + + /** + * Checks if version scheme cache statistics should be printed. + * This checks both the system property and the configuration property. + */ + private static boolean isStatisticsEnabled() { + // Check system property first (for backwards compatibility and ease of use) + String sysProp = System.getProperty(ConfigurationProperties.VERSION_SCHEME_CACHE_DEBUG); + if (sysProp != null) { + return Boolean.parseBoolean(sysProp); + } + + // Default to false if not configured + return ConfigurationProperties.DEFAULT_VERSION_SCHEME_CACHE_DEBUG; + } + @Override public GenericVersion parseVersion(final String version) throws InvalidVersionSpecificationException { - return new GenericVersion(version); + totalRequests.incrementAndGet(); + GLOBAL_TOTAL_REQUESTS.incrementAndGet(); + + GenericVersion existing = versionCache.get(version); + if (existing != null) { + cacheHits.incrementAndGet(); + GLOBAL_CACHE_HITS.incrementAndGet(); + return existing; + } else { + cacheMisses.incrementAndGet(); + GLOBAL_CACHE_MISSES.incrementAndGet(); + return versionCache.computeIfAbsent(version, GenericVersion::new); + } + } + + /** + * Get cache statistics for this instance. + */ + public String getCacheStatistics() { + long hits = cacheHits.get(); + long misses = cacheMisses.get(); + long total = totalRequests.get(); + double hitRate = total > 0 ? (double) hits / total * 100.0 : 0.0; + + return String.format( + "GenericVersionScheme Cache Stats: hits=%d, misses=%d, total=%d, hit-rate=%.2f%%, cache-size=%d", + hits, misses, total, hitRate, versionCache.size()); + } + + /** + * Print global statistics across all instances. + */ + private static void printGlobalStatistics() { + long hits = GLOBAL_CACHE_HITS.get(); + long misses = GLOBAL_CACHE_MISSES.get(); + long total = GLOBAL_TOTAL_REQUESTS.get(); + long instances = INSTANCE_COUNT.get(); + double hitRate = total > 0 ? (double) hits / total * 100.0 : 0.0; + + System.err.println("=== GenericVersionScheme Global Cache Statistics (WeakHashMap) ==="); + System.err.println(String.format("Total instances created: %d", instances)); + System.err.println(String.format("Total requests: %d", total)); + System.err.println(String.format("Cache hits: %d", hits)); + System.err.println(String.format("Cache misses: %d", misses)); + System.err.println(String.format("Hit rate: %.2f%%", hitRate)); + System.err.println( + String.format("Average requests per instance: %.2f", instances > 0 ? (double) total / instances : 0.0)); + System.err.println("=== End Cache Statistics ==="); } /** @@ -67,20 +163,25 @@ public static void main(String... args) { return; } + GenericVersionScheme scheme = new GenericVersionScheme(); GenericVersion prev = null; int i = 1; for (String version : args) { - GenericVersion c = new GenericVersion(version); + try { + GenericVersion c = scheme.parseVersion(version); - if (prev != null) { - int compare = prev.compareTo(c); - System.out.println( - " " + prev + ' ' + ((compare == 0) ? "==" : ((compare < 0) ? "<" : ">")) + ' ' + version); - } + if (prev != null) { + int compare = prev.compareTo(c); + System.out.println( + " " + prev + ' ' + ((compare == 0) ? "==" : ((compare < 0) ? "<" : ">")) + ' ' + version); + } - System.out.println((i++) + ". " + version + " -> " + c.asString() + "; tokens: " + c.asItems()); + System.out.println((i++) + ". " + version + " -> " + c.asString() + "; tokens: " + c.asItems()); - prev = c; + prev = c; + } catch (InvalidVersionSpecificationException e) { + System.err.println("Invalid version: " + version + " - " + e.getMessage()); + } } } } diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionRangeTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionRangeTest.java index a42a5ec0d..bf6cf8c8c 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionRangeTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionRangeTest.java @@ -29,7 +29,11 @@ public class GenericVersionRangeTest { private final GenericVersionScheme versionScheme = new GenericVersionScheme(); private Version newVersion(String version) { - return new GenericVersion(version); + try { + return versionScheme.parseVersion(version); + } catch (InvalidVersionSpecificationException e) { + throw new RuntimeException(e); + } } private VersionRange parseValid(String range) { diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionSchemeCachingPerformanceTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionSchemeCachingPerformanceTest.java new file mode 100644 index 000000000..465d62451 --- /dev/null +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionSchemeCachingPerformanceTest.java @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.util.version; + +import org.eclipse.aether.version.InvalidVersionSpecificationException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Performance test to demonstrate the benefits of caching in GenericVersionScheme. + * This test is not run as part of the regular test suite but can be used to verify + * that caching provides performance benefits. + */ +public class GenericVersionSchemeCachingPerformanceTest { + + @Test + void testCachingPerformance() { + GenericVersionScheme scheme = new GenericVersionScheme(); + + // Common version strings that would be parsed repeatedly in real scenarios + String[] commonVersions = { + "1.0.0", + "1.0.1", + "1.0.2", + "1.1.0", + "1.1.1", + "2.0.0", + "2.0.1", + "1.0.0-SNAPSHOT", + "1.1.0-SNAPSHOT", + "2.0.0-SNAPSHOT", + "1.0.0-alpha", + "1.0.0-beta", + "1.0.0-rc1", + "1.0.0-final", + "3.0.0", + "3.1.0", + "3.2.0", + "4.0.0", + "5.0.0" + }; + + int iterations = 10000; + + // Warm up + for (int i = 0; i < 1000; i++) { + for (String version : commonVersions) { + try { + scheme.parseVersion(version); + } catch (InvalidVersionSpecificationException e) { + fail("Unexpected exception during warmup: " + e.getMessage()); + } + } + } + + // Test with caching (repeated parsing of same versions) + long startTime = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + for (String version : commonVersions) { + try { + GenericVersion parsed = scheme.parseVersion(version); + assertNotNull(parsed); + assertEquals(version, parsed.toString()); + } catch (InvalidVersionSpecificationException e) { + fail("Unexpected exception during caching test: " + e.getMessage()); + } + } + } + long cachedTime = System.nanoTime() - startTime; + + // Test without caching (direct instantiation) + startTime = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + for (String version : commonVersions) { + GenericVersion parsed = new GenericVersion(version); + assertNotNull(parsed); + assertEquals(version, parsed.toString()); + } + } + long directTime = System.nanoTime() - startTime; + + System.out.println("Performance Test Results:"); + System.out.println("Cached parsing time: " + (cachedTime / 1_000_000) + " ms"); + System.out.println("Direct instantiation time: " + (directTime / 1_000_000) + " ms"); + System.out.println("Speedup factor: " + String.format("%.2f", (double) directTime / cachedTime)); + + // The cached version should be significantly faster for repeated parsing + // Note: This assertion might be too strict for CI environments, so we use a conservative factor + assertTrue( + cachedTime < directTime, + "Cached parsing should be faster than direct instantiation for repeated versions"); + } + + @Test + void testCachingCorrectness() { + GenericVersionScheme scheme = new GenericVersionScheme(); + + // Test that caching doesn't affect correctness + String[] versions = { + "1.0.0", "1.0.1", "1.1.0", "2.0.0", "1.0.0-SNAPSHOT", "1.0.0-alpha", "1.0.0-beta", "1.0.0-rc1" + }; + + // Parse each version multiple times and verify they're the same instance + for (String versionStr : versions) { + try { + GenericVersion first = scheme.parseVersion(versionStr); + GenericVersion second = scheme.parseVersion(versionStr); + GenericVersion third = scheme.parseVersion(versionStr); + + // Should be the same cached instance + assertSame(first, second, "Second parse should return cached instance"); + assertSame(first, third, "Third parse should return cached instance"); + + // Should have correct string representation + assertEquals(versionStr, first.toString()); + assertEquals(versionStr, second.toString()); + assertEquals(versionStr, third.toString()); + } catch (InvalidVersionSpecificationException e) { + fail("Unexpected exception for version " + versionStr + ": " + e.getMessage()); + } + } + } + + @Test + void testConcurrentCaching() throws InterruptedException { + GenericVersionScheme scheme = new GenericVersionScheme(); + String version = "1.0.0"; + int numThreads = 10; + Thread[] threads = new Thread[numThreads]; + GenericVersion[] results = new GenericVersion[numThreads]; + + // Create threads that parse the same version concurrently + for (int i = 0; i < numThreads; i++) { + final int index = i; + threads[i] = new Thread(() -> { + try { + results[index] = scheme.parseVersion(version); + } catch (InvalidVersionSpecificationException e) { + throw new RuntimeException("Unexpected exception in thread " + index, e); + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // All results should be the same cached instance + GenericVersion first = results[0]; + assertNotNull(first); + for (int i = 1; i < numThreads; i++) { + assertSame(first, results[i], "All concurrent parses should return the same cached instance"); + } + } +} diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionSchemeTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionSchemeTest.java index f3c10f9a6..e7ab9305b 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionSchemeTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/GenericVersionSchemeTest.java @@ -102,4 +102,32 @@ void testSameUpperAndLowerBound() throws InvalidVersionSpecificationException { assertEquals(c, c2); assertTrue(c.containsVersion(new GenericVersion("1.0"))); } + + @Test + void testVersionCaching() throws InvalidVersionSpecificationException { + // Test that parsing the same version string returns the same instance (cached) + GenericVersion v1 = scheme.parseVersion("1.0.0"); + GenericVersion v2 = scheme.parseVersion("1.0.0"); + + // Should return the same cached instance + assertSame(v1, v2, "Parsing the same version string should return the same cached instance"); + + // Test that different version strings create different instances + GenericVersion v3 = scheme.parseVersion("2.0.0"); + assertNotSame(v1, v3, "Different version strings should create different instances"); + + // Test that parsing the first version again still returns the cached instance + GenericVersion v4 = scheme.parseVersion("1.0.0"); + assertSame(v1, v4, "Re-parsing the first version should still return the cached instance"); + + // Test with various version formats + GenericVersion snapshot1 = scheme.parseVersion("1.0.0-SNAPSHOT"); + GenericVersion snapshot2 = scheme.parseVersion("1.0.0-SNAPSHOT"); + assertSame(snapshot1, snapshot2, "Snapshot versions should also be cached"); + + // Test that semantically equivalent but different strings are treated as different + GenericVersion v5 = scheme.parseVersion("1.0"); + GenericVersion v6 = scheme.parseVersion("1.0.0"); + assertNotSame(v5, v6, "Different string representations should not be cached together"); + } } diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/UnionVersionRangeTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/UnionVersionRangeTest.java index c9244df91..a9b8a1bb2 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/UnionVersionRangeTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/version/UnionVersionRangeTest.java @@ -28,9 +28,11 @@ public class UnionVersionRangeTest { + private final GenericVersionScheme versionScheme = new GenericVersionScheme(); + private VersionRange newRange(String range) { try { - return new GenericVersionScheme().parseVersionRange(range); + return versionScheme.parseVersionRange(range); } catch (InvalidVersionSpecificationException e) { throw new IllegalArgumentException(e); } @@ -44,7 +46,7 @@ private void assertBound(String version, boolean inclusive, VersionRange.Bound b assertNotNull(bound.getVersion()); assertEquals(inclusive, bound.isInclusive()); try { - assertEquals(new GenericVersionScheme().parseVersion(version), bound.getVersion()); + assertEquals(versionScheme.parseVersion(version), bound.getVersion()); } catch (InvalidVersionSpecificationException e) { throw new IllegalArgumentException(e); } diff --git a/src/site/markdown/configuration.md b/src/site/markdown/configuration.md index 387e0c48e..eebbcff37 100644 --- a/src/site/markdown/configuration.md +++ b/src/site/markdown/configuration.md @@ -156,6 +156,7 @@ To modify this file, edit the template and regenerate. | `"aether.trustedChecksumsSource.summaryFile.basedir"` | `String` | The basedir where checksums are. If relative, is resolved from local repository root. | `".checksums"` | 1.9.0 | No | Session Configuration | | `"aether.trustedChecksumsSource.summaryFile.originAware"` | `Boolean` | Is source origin aware? | `true` | 1.9.0 | No | Session Configuration | | `"aether.updateCheckManager.sessionState"` | `String` | Manages the session state, i.e. influences if the same download requests to artifacts/metadata will happen multiple times within the same RepositorySystemSession. If "enabled" will enable the session state. If "bypass" will enable bypassing (i.e. store all artifact ids/metadata ids which have been updates but not evaluating those). All other values lead to disabling the session state completely. | `"enabled"` | | No | Session Configuration | +| `"aether.util.versionScheme.cacheDebug"` | `Boolean` | A flag indicating whether version scheme cache statistics should be printed on JVM shutdown. This is useful for analyzing cache performance and effectiveness in development and testing scenarios. | `false` | 2.0.10 | No | Session Configuration | All properties which have `yes` in the column `Supports Repo ID Suffix` can be optionally configured specifically for a repository id. In that case the configuration property needs to be suffixed with a period followed by the repository id of the repository to configure, e.g. `aether.connector.http.headers.central` for repository with id `central`.