From c60b08fac17e52efd2684f6497f762fa3c2c9857 Mon Sep 17 00:00:00 2001 From: Daniel Mohedano Date: Thu, 16 Jul 2026 11:02:00 +0200 Subject: [PATCH] Memoize per-test class analysis in a module-wide cache Jacoco's Analyzer re-parsed each covered class once per test, dominating line-coverage report cost. Cache the covered lines per (class id, probe set), shared across tests, so a class covered identically by many tests is analyzed only once. Recording path is unchanged, so Jacoco's aggregate coverage is preserved by its native probe writes (no probe-array swap). Co-Authored-By: Claude Opus 4.8 --- .../coverage/line/ExecutionDataAdapter.java | 8 ++ .../coverage/line/LineCoverageStore.java | 108 ++++++++++++++---- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/ExecutionDataAdapter.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/ExecutionDataAdapter.java index 6441eb8ded3..34682106006 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/ExecutionDataAdapter.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/ExecutionDataAdapter.java @@ -18,6 +18,14 @@ public String getClassName() { return className; } + long getClassId() { + return classId; + } + + boolean[] getProbeActivations() { + return probeActivations; + } + void record(int probeId) { probeActivations[probeId] = true; } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/LineCoverageStore.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/LineCoverageStore.java index 647f28ca181..b1b187c781e 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/LineCoverageStore.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/LineCoverageStore.java @@ -14,6 +14,7 @@ import datadog.trace.civisibility.source.Utils; import java.io.InputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.BitSet; import java.util.Collection; import java.util.HashMap; @@ -37,16 +38,27 @@ public class LineCoverageStore extends ConcurrentCoverageStore { private static final Logger log = LoggerFactory.getLogger(LineCoverageStore.class); + /** + * Upper bound on the number of cached class analyses. Coverage stays correct beyond it (analysis + * just isn't cached), this only guards memory for pathologically large suites. + */ + private static final int MAX_ANALYSIS_CACHE_ENTRIES = 50_000; + private final CiVisibilityMetricCollector metrics; private final SourcePathResolver sourcePathResolver; + // Module-wide cache: (class id + probe set) -> covered lines, shared across tests so a class + // covered identically by many tests is parsed by Jacoco's Analyzer only once. + private final Map analysisCache; private LineCoverageStore( Function probesFactory, CiVisibilityMetricCollector metrics, - SourcePathResolver sourcePathResolver) { + SourcePathResolver sourcePathResolver, + Map analysisCache) { super(probesFactory); this.metrics = metrics; this.sourcePathResolver = sourcePathResolver; + this.analysisCache = analysisCache; } @Nullable @@ -83,24 +95,9 @@ protected TestReport report( } String sourcePath = sourcePaths.iterator().next(); - try (InputStream is = Utils.getClassStream(clazz)) { - BitSet coveredLines = - coveredLinesBySourcePath.computeIfAbsent(sourcePath, key -> new BitSet()); - ExecutionDataStore store = new ExecutionDataStore(); - store.put(executionDataAdapter.toExecutionData()); - - // TODO optimize this part to avoid parsing - // the same class multiple times for different test cases - Analyzer analyzer = new Analyzer(store, new SourceAnalyzer(coveredLines)); - analyzer.analyzeClass(is, null); - - } catch (Exception exception) { - log.debug( - "Skipping coverage reporting for {} ({}) because of error", - className, - sourcePath, - exception); - metrics.add(CiVisibilityCountMetric.CODE_COVERAGE_ERRORS, 1); + BitSet coveredLines = analyzeClass(clazz, executionDataAdapter); + if (coveredLines != null) { + coveredLinesBySourcePath.computeIfAbsent(sourcePath, key -> new BitSet()).or(coveredLines); } } @@ -132,9 +129,80 @@ protected TestReport report( return report; } + /** + * Resolves the covered lines for a class given a test's probe activations. Parsing the class with + * Jacoco's {@link Analyzer} is the dominant cost of reporting, and the result depends only on the + * class bytecode and the probe set, so it is memoized: the same class covered identically by + * different tests is parsed once. + * + * @return the covered lines, or {@code null} if the class could not be analyzed + */ + @Nullable + private BitSet analyzeClass(Class clazz, ExecutionDataAdapter executionDataAdapter) { + AnalysisCacheKey key = + new AnalysisCacheKey( + executionDataAdapter.getClassId(), executionDataAdapter.getProbeActivations()); + BitSet cached = analysisCache.get(key); + if (cached != null) { + return cached; + } + + try (InputStream is = Utils.getClassStream(clazz)) { + BitSet coveredLines = new BitSet(); + ExecutionDataStore store = new ExecutionDataStore(); + store.put(executionDataAdapter.toExecutionData()); + Analyzer analyzer = new Analyzer(store, new SourceAnalyzer(coveredLines)); + analyzer.analyzeClass(is, null); + + if (analysisCache.size() < MAX_ANALYSIS_CACHE_ENTRIES) { + analysisCache.putIfAbsent(key, coveredLines); + } + return coveredLines; + + } catch (Exception exception) { + log.debug( + "Skipping coverage reporting for {} because of error", + executionDataAdapter.getClassName(), + exception); + metrics.add(CiVisibilityCountMetric.CODE_COVERAGE_ERRORS, 1); + return null; + } + } + + /** Cache key identifying a class (by Jacoco class id) covered by a specific set of probes. */ + static final class AnalysisCacheKey { + private final long classId; + private final boolean[] probes; + private final int hash; + + AnalysisCacheKey(long classId, boolean[] probes) { + this.classId = classId; + this.probes = probes; + this.hash = 31 * Long.hashCode(classId) + Arrays.hashCode(probes); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AnalysisCacheKey)) { + return false; + } + AnalysisCacheKey other = (AnalysisCacheKey) o; + return classId == other.classId && hash == other.hash && Arrays.equals(probes, other.probes); + } + + @Override + public int hashCode() { + return hash; + } + } + public static final class Factory implements CoverageStore.Factory { private final Map probeCounts = new ConcurrentHashMap<>(); + private final Map analysisCache = new ConcurrentHashMap<>(); private final CiVisibilityMetricCollector metrics; private final SourcePathResolver sourcePathResolver; @@ -146,7 +214,7 @@ public Factory(CiVisibilityMetricCollector metrics, SourcePathResolver sourcePat @Override public CoverageStore create(@Nullable TestIdentifier testIdentifier) { - return new LineCoverageStore(this::createProbes, metrics, sourcePathResolver); + return new LineCoverageStore(this::createProbes, metrics, sourcePathResolver, analysisCache); } private LineProbes createProbes(boolean isTestThread) {