Skip to content

Commit 4719f5a

Browse files
Memoize per-test class analysis in a module-wide cache (#11969)
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 <noreply@anthropic.com> Bound the analysis cache by memory, and snapshot probes consistently Addresses Codex review of the memoization cache: - Bit-pack the probe activations into a BitSet in the key (~8x smaller than the test's boolean[], exact equality). - Cap the cache by approximate retained bytes rather than entry count, so a class with many probes covered by many distinct probe sets can't retain unbounded memory for the module lifetime. - Reserve an entry's weight atomically before insertion (release on over-limit or lost putIfAbsent race) so concurrent inserts can't overshoot the bound. - Count fixed per-entry overhead (key + both BitSets + map node), not just the packed bits, so the byte bound is a real ceiling. - Snapshot the per-test probe array once and use that same snapshot for both the cache key and the JaCoCo analysis, so a late probe recorded by a background thread during report() can't be analyzed into coveredLines yet stored under the earlier key, poisoning the entry for later tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: daniel.mohedano <daniel.mohedano@datadoghq.com>
1 parent b85908e commit 4719f5a

3 files changed

Lines changed: 180 additions & 26 deletions

File tree

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/ExecutionDataAdapter.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package datadog.trace.civisibility.coverage.line;
22

3-
import org.jacoco.core.data.ExecutionData;
4-
53
public class ExecutionDataAdapter {
64
private final long classId;
75
private final String className;
@@ -18,6 +16,14 @@ public String getClassName() {
1816
return className;
1917
}
2018

19+
long getClassId() {
20+
return classId;
21+
}
22+
23+
boolean[] getProbeActivations() {
24+
return probeActivations;
25+
}
26+
2127
void record(int probeId) {
2228
probeActivations[probeId] = true;
2329
}
@@ -28,8 +34,4 @@ ExecutionDataAdapter merge(ExecutionDataAdapter other) {
2834
}
2935
return this;
3036
}
31-
32-
ExecutionData toExecutionData() {
33-
return new ExecutionData(classId, className, probeActivations);
34-
}
3537
}

dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/coverage/line/LineCoverageStore.java

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
import java.util.List;
2323
import java.util.Map;
2424
import java.util.concurrent.ConcurrentHashMap;
25+
import java.util.concurrent.atomic.AtomicLong;
2526
import java.util.function.Function;
2627
import javax.annotation.Nullable;
2728
import org.jacoco.core.analysis.Analyzer;
29+
import org.jacoco.core.data.ExecutionData;
2830
import org.jacoco.core.data.ExecutionDataStore;
2931
import org.slf4j.Logger;
3032
import org.slf4j.LoggerFactory;
@@ -37,16 +39,41 @@ public class LineCoverageStore extends ConcurrentCoverageStore<LineProbes> {
3739

3840
private static final Logger log = LoggerFactory.getLogger(LineCoverageStore.class);
3941

42+
/**
43+
* Upper bound on the approximate memory retained by the analysis cache. Coverage stays correct
44+
* beyond it (analysis just isn't cached), this only guards memory for pathologically large
45+
* suites. Bounding by bytes (rather than entry count) is what keeps a class with many probes,
46+
* covered by many distinct probe sets, from retaining large arrays for the whole module lifetime.
47+
*/
48+
private static final long MAX_ANALYSIS_CACHE_BYTES = 64L * 1024 * 1024;
49+
50+
/**
51+
* Approximate fixed cost of one cache entry beyond its variable bit data: the {@link
52+
* AnalysisCacheKey} and both {@link BitSet} objects (with their {@code long[]} + array headers)
53+
* plus the {@code ConcurrentHashMap} node. Deliberately generous so small entries aren't
54+
* undercounted and the byte bound stays a real ceiling.
55+
*/
56+
private static final int APPROX_ENTRY_OVERHEAD_BYTES = 160;
57+
4058
private final CiVisibilityMetricCollector metrics;
4159
private final SourcePathResolver sourcePathResolver;
60+
// Module-wide cache: (class id + probe set) -> covered lines, shared across tests so a class
61+
// covered identically by many tests is parsed by Jacoco's Analyzer only once.
62+
private final Map<AnalysisCacheKey, BitSet> analysisCache;
63+
// Approximate bytes retained by analysisCache, so the cache is bounded by size, not entry count.
64+
private final AtomicLong analysisCacheBytes;
4265

4366
private LineCoverageStore(
4467
Function<Boolean, LineProbes> probesFactory,
4568
CiVisibilityMetricCollector metrics,
46-
SourcePathResolver sourcePathResolver) {
69+
SourcePathResolver sourcePathResolver,
70+
Map<AnalysisCacheKey, BitSet> analysisCache,
71+
AtomicLong analysisCacheBytes) {
4772
super(probesFactory);
4873
this.metrics = metrics;
4974
this.sourcePathResolver = sourcePathResolver;
75+
this.analysisCache = analysisCache;
76+
this.analysisCacheBytes = analysisCacheBytes;
5077
}
5178

5279
@Nullable
@@ -83,24 +110,9 @@ protected TestReport report(
83110
}
84111
String sourcePath = sourcePaths.iterator().next();
85112

86-
try (InputStream is = Utils.getClassStream(clazz)) {
87-
BitSet coveredLines =
88-
coveredLinesBySourcePath.computeIfAbsent(sourcePath, key -> new BitSet());
89-
ExecutionDataStore store = new ExecutionDataStore();
90-
store.put(executionDataAdapter.toExecutionData());
91-
92-
// TODO optimize this part to avoid parsing
93-
// the same class multiple times for different test cases
94-
Analyzer analyzer = new Analyzer(store, new SourceAnalyzer(coveredLines));
95-
analyzer.analyzeClass(is, null);
96-
97-
} catch (Exception exception) {
98-
log.debug(
99-
"Skipping coverage reporting for {} ({}) because of error",
100-
className,
101-
sourcePath,
102-
exception);
103-
metrics.add(CiVisibilityCountMetric.CODE_COVERAGE_ERRORS, 1);
113+
BitSet coveredLines = analyzeClass(clazz, executionDataAdapter);
114+
if (coveredLines != null) {
115+
coveredLinesBySourcePath.computeIfAbsent(sourcePath, key -> new BitSet()).or(coveredLines);
104116
}
105117
}
106118

@@ -132,9 +144,110 @@ protected TestReport report(
132144
return report;
133145
}
134146

147+
/**
148+
* Resolves the covered lines for a class given a test's probe activations. Parsing the class with
149+
* Jacoco's {@link Analyzer} is the dominant cost of reporting, and the result depends only on the
150+
* class bytecode and the probe set, so it is memoized: the same class covered identically by
151+
* different tests is parsed once.
152+
*
153+
* @return the covered lines, or {@code null} if the class could not be analyzed
154+
*/
155+
@Nullable
156+
private BitSet analyzeClass(Class<?> clazz, ExecutionDataAdapter executionDataAdapter) {
157+
long classId = executionDataAdapter.getClassId();
158+
// Snapshot the activations once and use the same snapshot for both the cache key and the
159+
// analysis. The per-test array is mutable and a propagated/background thread may record a late
160+
// probe while report() runs; sharing one snapshot ensures the cached covered lines always match
161+
// the key's probe set, so a late activation can't poison the entry for later tests.
162+
boolean[] probes = executionDataAdapter.getProbeActivations().clone();
163+
AnalysisCacheKey key = new AnalysisCacheKey(classId, probes);
164+
BitSet cached = analysisCache.get(key);
165+
if (cached != null) {
166+
return cached;
167+
}
168+
169+
try (InputStream is = Utils.getClassStream(clazz)) {
170+
BitSet coveredLines = new BitSet();
171+
ExecutionDataStore store = new ExecutionDataStore();
172+
store.put(new ExecutionData(classId, executionDataAdapter.getClassName(), probes));
173+
Analyzer analyzer = new Analyzer(store, new SourceAnalyzer(coveredLines));
174+
analyzer.analyzeClass(is, null);
175+
176+
// Reserve the entry's weight before inserting so concurrent inserts near the limit can't
177+
// collectively overshoot the bound; release the reservation if we exceed it or another thread
178+
// cached the class first.
179+
long entryBytes =
180+
APPROX_ENTRY_OVERHEAD_BYTES + key.packedBytes() + (coveredLines.size() >>> 3);
181+
if (analysisCacheBytes.addAndGet(entryBytes) <= MAX_ANALYSIS_CACHE_BYTES) {
182+
if (analysisCache.putIfAbsent(key, coveredLines) != null) {
183+
analysisCacheBytes.addAndGet(-entryBytes);
184+
}
185+
} else {
186+
analysisCacheBytes.addAndGet(-entryBytes);
187+
}
188+
return coveredLines;
189+
190+
} catch (Exception exception) {
191+
log.debug(
192+
"Skipping coverage reporting for {} because of error",
193+
executionDataAdapter.getClassName(),
194+
exception);
195+
metrics.add(CiVisibilityCountMetric.CODE_COVERAGE_ERRORS, 1);
196+
return null;
197+
}
198+
}
199+
200+
/**
201+
* Cache key identifying a class (by Jacoco class id) covered by a specific set of probes. The
202+
* probe activations are bit-packed into a {@link BitSet} rather than retaining the test's full
203+
* {@code boolean[]} (1 byte/element), so a cached key uses ~8x less memory. Equality is exact:
204+
* two keys match iff the same class was covered by the same set of probe ids.
205+
*/
206+
static final class AnalysisCacheKey {
207+
private final long classId;
208+
private final BitSet probes;
209+
private final int hash;
210+
211+
AnalysisCacheKey(long classId, boolean[] probeActivations) {
212+
this.classId = classId;
213+
BitSet bits = new BitSet(probeActivations.length);
214+
for (int i = 0; i < probeActivations.length; i++) {
215+
if (probeActivations[i]) {
216+
bits.set(i);
217+
}
218+
}
219+
this.probes = bits;
220+
this.hash = 31 * Long.hashCode(classId) + bits.hashCode();
221+
}
222+
223+
/** Bytes of the packed probe bits (the variable part of the retained key). */
224+
int packedBytes() {
225+
return probes.size() >>> 3;
226+
}
227+
228+
@Override
229+
public boolean equals(Object o) {
230+
if (this == o) {
231+
return true;
232+
}
233+
if (!(o instanceof AnalysisCacheKey)) {
234+
return false;
235+
}
236+
AnalysisCacheKey other = (AnalysisCacheKey) o;
237+
return classId == other.classId && hash == other.hash && probes.equals(other.probes);
238+
}
239+
240+
@Override
241+
public int hashCode() {
242+
return hash;
243+
}
244+
}
245+
135246
public static final class Factory implements CoverageStore.Factory {
136247

137248
private final Map<String, Integer> probeCounts = new ConcurrentHashMap<>();
249+
private final Map<AnalysisCacheKey, BitSet> analysisCache = new ConcurrentHashMap<>();
250+
private final AtomicLong analysisCacheBytes = new AtomicLong();
138251

139252
private final CiVisibilityMetricCollector metrics;
140253
private final SourcePathResolver sourcePathResolver;
@@ -146,7 +259,8 @@ public Factory(CiVisibilityMetricCollector metrics, SourcePathResolver sourcePat
146259

147260
@Override
148261
public CoverageStore create(@Nullable TestIdentifier testIdentifier) {
149-
return new LineCoverageStore(this::createProbes, metrics, sourcePathResolver);
262+
return new LineCoverageStore(
263+
this::createProbes, metrics, sourcePathResolver, analysisCache, analysisCacheBytes);
150264
}
151265

152266
private LineProbes createProbes(boolean isTestThread) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package datadog.trace.civisibility.coverage.line;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
5+
6+
import datadog.trace.civisibility.coverage.line.LineCoverageStore.AnalysisCacheKey;
7+
import org.junit.jupiter.api.Test;
8+
9+
class LineCoverageStoreTest {
10+
11+
@Test
12+
void cacheKeyReusesAnalysisForSameClassAndProbes() {
13+
AnalysisCacheKey key = new AnalysisCacheKey(1L, new boolean[] {true, false, true, false});
14+
AnalysisCacheKey same = new AnalysisCacheKey(1L, new boolean[] {true, false, true, false});
15+
// identical class id + probe set must collide so the analysis is reused
16+
assertEquals(key, same);
17+
assertEquals(key.hashCode(), same.hashCode());
18+
}
19+
20+
@Test
21+
void cacheKeyDistinguishesClassesAndProbeSets() {
22+
AnalysisCacheKey key = new AnalysisCacheKey(1L, new boolean[] {true, false, true});
23+
// a different class or a different probe set must NOT hit the same cache entry
24+
assertNotEquals(key, new AnalysisCacheKey(2L, new boolean[] {true, false, true}));
25+
assertNotEquals(key, new AnalysisCacheKey(1L, new boolean[] {true, true, true}));
26+
}
27+
28+
@Test
29+
void cacheKeyIgnoresTrailingUnsetProbes() {
30+
// The key bit-packs the activated probe set; trailing probes that never fire don't change
31+
// coverage, so padding differences must not create distinct entries.
32+
AnalysisCacheKey shortKey = new AnalysisCacheKey(1L, new boolean[] {true, false, true});
33+
AnalysisCacheKey padded =
34+
new AnalysisCacheKey(1L, new boolean[] {true, false, true, false, false});
35+
assertEquals(shortKey, padded);
36+
assertEquals(shortKey.hashCode(), padded.hashCode());
37+
}
38+
}

0 commit comments

Comments
 (0)