diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java index d2ef89cc015..4c55aa0431e 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/json/JsonParser216Helper.java @@ -1,11 +1,56 @@ package com.fasterxml.jackson.core.json; +import com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer; import com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer216Helper; +import datadog.trace.util.Latch; +/** + * Reads whether a {@link UTF8StreamJsonParser} interns its field names. + * + *

This reads package-private Jackson fields ({@code _symbols}, {@code _interner}). Stock + * jackson-core 2.16+ always has them, but a classpath that mixes Jackson builds can lack them, + * which surfaces as a {@link NoSuchFieldError}. + * + *

IAST design note: when the fields are missing we cannot tell whether names are + * interned, and we answer {@code true} ("interned"). The caller then records the current field name + * but does not taint the name string. This is a deliberate trade-off: + * + *

+ * + *

Each field read has its own {@link Latch}, here for {@code _symbols} and in {@link + * ByteQuadsCanonicalizer216Helper} for {@code _interner}, so a classpath missing only one of them + * keeps using the other. The first failure of each is rethrown so the instrumentation exception + * handler still reports it. This is not an exactly-once guarantee: threads that race the first + * failure each rethrow, so a failure is reported at least once, bounded by concurrency. After that + * the failure is remembered and calls return {@code true} without throwing, so a broken classpath + * does not cost an exception per parsed field name. + * + *

The call that hits the failure is aborted by the advice's exception suppression before it + * reaches {@code setCurrentName}, so that one field name is not tracked and a value read right + * after it may be attributed to no name, or to the previous one. Later calls are not affected. + */ public final class JsonParser216Helper { private JsonParser216Helper() {} + private static final Latch + SYMBOLS_LATCH = + new Latch() { + @Override + protected ByteQuadsCanonicalizer apply(UTF8StreamJsonParser jsonParser) { + return handleNoSuchField(jsonParser, parser -> parser._symbols); + } + }; + public static boolean fetchInterner(UTF8StreamJsonParser jsonParser) { - return ByteQuadsCanonicalizer216Helper.fetchInterner(jsonParser._symbols); + ByteQuadsCanonicalizer symbols = SYMBOLS_LATCH.tryApply(jsonParser); + // no symbol table to ask: assume interned (see the class comment) + return symbols == null || ByteQuadsCanonicalizer216Helper.fetchInterner(symbols); } } diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java index 7c3a6794650..2418ef7d0ca 100644 --- a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/main/java/com/fasterxml/jackson/core/sym/ByteQuadsCanonicalizer216Helper.java @@ -1,9 +1,30 @@ package com.fasterxml.jackson.core.sym; +import datadog.trace.util.Latch; + +/** + * Reads whether a {@link ByteQuadsCanonicalizer} interns its field names, from the package-private + * {@code _interner} field. + * + *

A classpath that mixes Jackson builds can lack the field, which surfaces as a {@link + * NoSuchFieldError}. That is the same for every canonicalizer, so a single {@link Latch} covers the + * read: the first failure is rethrown, so the instrumentation exception handler still reports it + * (at least once, bounded by concurrency, since threads racing the first failure each rethrow), and + * afterwards the answer is {@code true} ("interned") without throwing. See {@code + * JsonParser216Helper} for why "interned" is the default. + */ public final class ByteQuadsCanonicalizer216Helper { private ByteQuadsCanonicalizer216Helper() {} + private static final Latch INTERNER_LATCH = + new Latch() { + @Override + protected Boolean apply(ByteQuadsCanonicalizer symbols) { + return handleNoSuchField(symbols, s -> s._interner != null); + } + }; + public static boolean fetchInterner(ByteQuadsCanonicalizer symbols) { - return symbols._interner != null; + return INTERNER_LATCH.tryApplyOrDefault(symbols, Boolean.TRUE); } } diff --git a/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java new file mode 100644 index 00000000000..3c526d3a2b6 --- /dev/null +++ b/dd-java-agent/instrumentation/jackson-core/jackson-core-2.16/src/test/java/datadog/trace/instrumentation/jackson_2_16/core/JsonParser216HelperTest.java @@ -0,0 +1,158 @@ +package datadog.trace.instrumentation.jackson_2_16.core; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.json.JsonParser216Helper; +import com.fasterxml.jackson.core.json.UTF8StreamJsonParser; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import net.bytebuddy.jar.asm.ClassReader; +import net.bytebuddy.jar.asm.ClassWriter; +import net.bytebuddy.jar.asm.commons.ClassRemapper; +import net.bytebuddy.jar.asm.commons.SimpleRemapper; +import org.junit.jupiter.api.Test; + +class JsonParser216HelperTest { + + private static final String JACKSON_CORE_PREFIX = "com.fasterxml.jackson.core."; + private static final String CANONICALIZER = + "com.fasterxml.jackson.core.sym.ByteQuadsCanonicalizer"; + private static final String UTF8_PARSER = "com.fasterxml.jackson.core.json.UTF8StreamJsonParser"; + + @Test + void reportsInternedFieldNames() throws Exception { + JsonFactory factory = new JsonFactory().enable(JsonFactory.Feature.INTERN_FIELD_NAMES); + UTF8StreamJsonParser parser = (UTF8StreamJsonParser) factory.createParser(json()); + + assertTrue(JsonParser216Helper.fetchInterner(parser)); + } + + @Test + void reportsNonInternedFieldNames() throws Exception { + JsonFactory factory = new JsonFactory().disable(JsonFactory.Feature.INTERN_FIELD_NAMES); + UTF8StreamJsonParser parser = (UTF8StreamJsonParser) factory.createParser(json()); + + assertFalse(JsonParser216Helper.fetchInterner(parser)); + } + + /** + * Simulates a classpath whose {@code ByteQuadsCanonicalizer} has no {@code _interner} field. The + * first failure is rethrown so it is reported once; later calls assume interned names. + */ + @Test + void rethrowsFirstMissingInternerThenAssumesInterned() throws Exception { + assertRethrowsOnceThenAssumesInterned(CANONICALIZER, "_interner"); + } + + /** The same for the parser's {@code _symbols} field, which has its own latch. */ + @Test + void rethrowsFirstMissingSymbolsThenAssumesInterned() throws Exception { + assertRethrowsOnceThenAssumesInterned(UTF8_PARSER, "_symbols"); + } + + /** + * The latch state is static, so it is per class loader: a second loader with the same problem + * must rethrow its own first failure, not inherit the first loader's latch. + */ + @Test + void eachClassLoaderRethrowsItsOwnFirstFailure() throws Exception { + assertRethrowsOnceThenAssumesInterned(CANONICALIZER, "_interner"); + assertRethrowsOnceThenAssumesInterned(CANONICALIZER, "_interner"); + } + + private void assertRethrowsOnceThenAssumesInterned(String className, String missingField) + throws Exception { + ClassLoader loader = new MissingFieldClassLoader(className, missingField); + Object factory = loader.loadClass(JsonFactory.class.getName()).getConstructor().newInstance(); + Object parser = + factory.getClass().getMethod("createParser", byte[].class).invoke(factory, (Object) json()); + Method fetchInterner = + loader + .loadClass(JsonParser216Helper.class.getName()) + .getMethod("fetchInterner", loader.loadClass(UTF8StreamJsonParser.class.getName())); + + InvocationTargetException first = + assertThrows(InvocationTargetException.class, () -> fetchInterner.invoke(null, parser)); + assertInstanceOf(NoSuchFieldError.class, first.getCause()); + + assertTrue((boolean) fetchInterner.invoke(null, parser)); + assertTrue((boolean) fetchInterner.invoke(null, parser)); + } + + private static byte[] json() { + return "{\"name\":\"value\"}".getBytes(UTF_8); + } + + /** + * Loads jackson-core child-first, renaming one field of one class in the bytecode. The class + * stays self-consistent, but a lookup of the original field name fails with {@link + * NoSuchFieldError}, like a mixed or repackaged Jackson on the classpath. + */ + private static final class MissingFieldClassLoader extends ClassLoader { + private final String className; + private final String field; + + MissingFieldClassLoader(String className, String field) { + super(JsonParser216HelperTest.class.getClassLoader()); + this.className = className; + this.field = field; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.startsWith(JACKSON_CORE_PREFIX)) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class clazz = findLoadedClass(name); + if (clazz == null) { + clazz = define(name); + } + if (resolve) { + resolveClass(clazz); + } + return clazz; + } + } + + private Class define(String name) throws ClassNotFoundException { + try (InputStream in = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { + if (in == null) { + throw new ClassNotFoundException(name); + } + byte[] bytes = readAll(in); + if (name.equals(className)) { + bytes = renameField(bytes); + } + return defineClass(name, bytes, 0, bytes.length); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + + private byte[] renameField(byte[] bytes) { + ClassWriter writer = new ClassWriter(0); + SimpleRemapper remapper = + new SimpleRemapper(className.replace('.', '/') + "." + field, field + "_renamed"); + new ClassReader(bytes).accept(new ClassRemapper(writer, remapper), 0); + return writer.toByteArray(); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + for (int read = in.read(buffer); read != -1; read = in.read(buffer)) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + } +} diff --git a/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java new file mode 100644 index 00000000000..cdda4ecd548 --- /dev/null +++ b/internal-api/src/jmh/java/datadog/trace/util/LatchBenchmark.java @@ -0,0 +1,377 @@ +package datadog.trace.util; + +import static java.util.Collections.singletonList; + +import java.io.File; +import java.io.IOException; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * What {@link Latch} saves when a field read fails the same way every time, and what it costs on + * the path that works. + * + *

The missing field is real: {@code Reader} is built against a {@code Holder} that has a {@code + * flag} field, and run against a {@code Holder} that does not, so its {@code getfield} raises the + * JVM's own {@link NoSuchFieldError}. Every arm reaches the read through the same {@link + * MethodHandle}, and each arm has its own method so that no arm's profile is shaped by another's. + * + *

+ * + * Each has a {@code Missing} form, already latched so that the steady state is measured, and a + * {@code Present} form, where the field exists and the read succeeds. The latches' flags are never + * set on the present path. + * + *

The cost of a throw grows with the depth of the stack it fills in, which is why {@code depth} + * is a parameter. At depth 50 an earlier benchmark's forks landed in different compiled states, so + * read the per-fork iterations and not only the mean. + * + *

Run with {@code ./gradlew :internal-api:jmh -Pjmh.includes=LatchBenchmark -Pjmh.profilers=gc}. + * + *

Results, one run: Zulu 17.0.7 (HotSpot), MacBook M1, single thread, 5 forks, on a laptop with + * normal background activity (load about 4). {@code unguarded} is the status quo, {@code + * volatileFlag} and {@code plainFlag} are hand-rolled flags, and {@code latch} is this class. JDK 8 + * and x86 are not measured. + * + *

+ * Benchmark              (depth)          ops/s     ns/op    err   B/op
+ * unguardedMissing             0        286,635    3488.8   0.4%    768
+ * volatileFlagMissing          0    409,799,340      2.44   1.2%      0
+ * plainFlagMissing             0    458,120,051      2.18   0.6%      0
+ * latchMissing                 0    458,763,738      2.18   0.8%      0
+ * unguardedPresent             0    298,248,659      3.35   0.5%      0
+ * volatileFlagPresent          0    247,533,590      4.04   0.4%      0
+ * plainFlagPresent             0    281,751,834      3.55   0.2%      0
+ * latchPresent                 0    282,939,106      3.53   0.2%      0
+ *
+ * unguardedMissing            50        196,076    5100.1   0.5%   2128
+ * volatileFlagMissing         50     33,753,591      29.6   0.5%      0
+ * plainFlagMissing            50     36,099,245      27.7   0.4%      0
+ * latchMissing                50     35,428,255      28.2   0.3%      0
+ * unguardedPresent            50     30,037,705      33.3   0.5%      0
+ * volatileFlagPresent         50     31,199,284      32.1   3.1%      0
+ * plainFlagPresent            50     25,366,102      39.4   6.2%      0
+ * latchPresent                50     31,938,175      31.3   0.9%      0
+ * 
+ * + * A latched skip costs about 2.2 ns where the status quo costs about 3.5 us at depth 0 (5.1 us at + * depth 50) and allocates 768 B (2,128 B); that is roughly 1,600 times cheaper at depth 0 and 180 + * times at depth 50. {@code Latch} is as cheap as a hand-rolled plain flag (2.18 against 2.18 ns + * skipped, 3.53 against 3.55 ns on the working path), so the abstraction costs nothing measurable. + * A volatile flag costs more: about 0.5 ns over a plain flag on the working path at depth 0 (4.04 + * against 3.55 ns) and about 0.3 ns when skipping (2.44 against 2.18 ns). + * + *

At depth 50, read only the skipped arms (28 to 30 ns, consistent across forks): the + * working-path arms span 31 to 39 ns and the plain flag, which is the same logic as {@code latch}, + * came out slowest with a 6% error and one fork at 28.9M against 24.2M to 24.8M ops/s for the + * others. That spread is JIT and recursion noise, not a difference between the designs. + */ +@Fork(3) +@Warmup(iterations = 3) +@Measurement(iterations = 4) +@Threads(1) +@State(Scope.Benchmark) +public class LatchBenchmark { + + @Param({"0", "50"}) + int depth; + + private Path dir; + private URLClassLoader missingLoader; + private URLClassLoader presentLoader; + private Object missingTarget; + private Object presentTarget; + + /** Set in {@link #setup}; every arm and latch reads through these. */ + private static MethodHandle readMissing; + + private static MethodHandle readPresent; + + private static boolean read(MethodHandle handle, Object target) { + try { + return (boolean) handle.invokeExact(target); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable e) { + throw new IllegalStateException(e); + } + } + + private static volatile boolean volatileMissingLatched; + private static volatile boolean volatilePresentLatched; + private static boolean plainMissingLatched; + private static boolean plainPresentLatched; + + private static final Latch LATCH_MISSING = + new Latch() { + @Override + protected Boolean apply(Object target) { + try { + return read(readMissing, target); + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + }; + + private static final Latch LATCH_PRESENT = + new Latch() { + @Override + protected Boolean apply(Object target) { + try { + return read(readPresent, target); + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + }; + + @Setup + public void setup() throws Throwable { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException("needs a JDK to build the classes under test"); + } + dir = Files.createTempDirectory("latch-benchmark"); + Path withField = Files.createDirectory(dir.resolve("with")); + Path withoutField = Files.createDirectory(dir.resolve("without")); + + // Reader is always built against a Holder that has the field + compile(compiler, withField, null, "Holder", "public class Holder { public boolean flag; }"); + compile( + compiler, + withField, + withField, + "Reader", + "public class Reader { public static boolean read(Holder h) { return h.flag; } }"); + // ...and the missing case runs it against a Holder that does not + compile( + compiler, withoutField, null, "Holder", "public class Holder { public boolean other; }"); + + missingLoader = + new URLClassLoader( + new URL[] {withoutField.toUri().toURL(), withField.toUri().toURL()}, + LatchBenchmark.class.getClassLoader()); + presentLoader = + new URLClassLoader( + new URL[] {withField.toUri().toURL()}, LatchBenchmark.class.getClassLoader()); + + missingTarget = missingLoader.loadClass("Holder").getDeclaredConstructor().newInstance(); + presentTarget = presentLoader.loadClass("Holder").getDeclaredConstructor().newInstance(); + readMissing = handle(missingLoader); + readPresent = handle(presentLoader); + + // reach the steady state: every missing-field guard has already met the failure once + try { + read(readMissing, missingTarget); + throw new IllegalStateException("expected a NoSuchFieldError"); + } catch (NoSuchFieldError expected) { + // the field really is missing + } + try { + LATCH_MISSING.tryApply(missingTarget); + } catch (NoSuchFieldError expected) { + // the first failure is rethrown + } + volatileMissingLatched = true; + plainMissingLatched = true; + if (!LATCH_MISSING.isLatched()) { + throw new IllegalStateException("expected the latch to latch"); + } + if (LATCH_PRESENT.isLatched()) { + throw new IllegalStateException("the present latch must not be latched"); + } + } + + private static MethodHandle handle(URLClassLoader loader) throws Throwable { + Class holder = loader.loadClass("Holder"); + return MethodHandles.publicLookup() + .findStatic( + loader.loadClass("Reader"), "read", MethodType.methodType(boolean.class, holder)) + .asType(MethodType.methodType(boolean.class, Object.class)); + } + + @TearDown + public void tearDown() throws IOException { + missingLoader.close(); + presentLoader.close(); + } + + @Benchmark + public boolean unguardedMissing() { + return unguardedMissing(depth); + } + + @Benchmark + public boolean volatileFlagMissing() { + return volatileFlagMissing(depth); + } + + @Benchmark + public boolean plainFlagMissing() { + return plainFlagMissing(depth); + } + + @Benchmark + public boolean latchMissing() { + return latchMissing(depth); + } + + @Benchmark + public boolean unguardedPresent() { + return unguardedPresent(depth); + } + + @Benchmark + public boolean volatileFlagPresent() { + return volatileFlagPresent(depth); + } + + @Benchmark + public boolean plainFlagPresent() { + return plainFlagPresent(depth); + } + + @Benchmark + public boolean latchPresent() { + return latchPresent(depth); + } + + // Each arm descends on its own so that a throw has a realistic amount of stack to fill in. + + private boolean unguardedMissing(int remaining) { + if (remaining > 0) { + return unguardedMissing(remaining - 1); + } + try { + return read(readMissing, missingTarget); + } catch (NoSuchFieldError e) { + return true; + } + } + + private boolean volatileFlagMissing(int remaining) { + if (remaining > 0) { + return volatileFlagMissing(remaining - 1); + } + if (volatileMissingLatched) { + return true; + } + try { + return read(readMissing, missingTarget); + } catch (NoSuchFieldError e) { + volatileMissingLatched = true; + throw e; + } + } + + private boolean plainFlagMissing(int remaining) { + if (remaining > 0) { + return plainFlagMissing(remaining - 1); + } + if (plainMissingLatched) { + return true; + } + try { + return read(readMissing, missingTarget); + } catch (NoSuchFieldError e) { + plainMissingLatched = true; + throw e; + } + } + + private boolean latchMissing(int remaining) { + return remaining > 0 + ? latchMissing(remaining - 1) + : LATCH_MISSING.tryApplyOrDefault(missingTarget, Boolean.TRUE); + } + + private boolean unguardedPresent(int remaining) { + return remaining > 0 ? unguardedPresent(remaining - 1) : read(readPresent, presentTarget); + } + + private boolean volatileFlagPresent(int remaining) { + if (remaining > 0) { + return volatileFlagPresent(remaining - 1); + } + if (volatilePresentLatched) { + return true; + } + try { + return read(readPresent, presentTarget); + } catch (NoSuchFieldError e) { + volatilePresentLatched = true; + throw e; + } + } + + private boolean plainFlagPresent(int remaining) { + if (remaining > 0) { + return plainFlagPresent(remaining - 1); + } + if (plainPresentLatched) { + return true; + } + try { + return read(readPresent, presentTarget); + } catch (NoSuchFieldError e) { + plainPresentLatched = true; + throw e; + } + } + + private boolean latchPresent(int remaining) { + return remaining > 0 + ? latchPresent(remaining - 1) + : LATCH_PRESENT.tryApplyOrDefault(presentTarget, Boolean.TRUE); + } + + private static void compile( + JavaCompiler compiler, Path out, Path classpath, String name, String source) + throws IOException { + Path file = out.resolve(name + ".java"); + Files.write(file, singletonList(source), StandardCharsets.UTF_8); + int result = + classpath == null + ? compiler.run(null, null, null, "-d", out.toString(), file.toString()) + : compiler.run( + null, + null, + null, + "-cp", + classpath.toString() + File.pathSeparator, + "-d", + out.toString(), + file.toString()); + if (result != 0) { + throw new IllegalStateException("compiling " + name + " failed"); + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/util/Latch.java b/internal-api/src/main/java/datadog/trace/util/Latch.java new file mode 100644 index 00000000000..9aea9ce4153 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/util/Latch.java @@ -0,0 +1,102 @@ +package datadog.trace.util; + +import java.util.function.Function; +import javax.annotation.Nullable; + +/** + * A one-way, call-site-wide latch for an operation that fails the same way for everyone once it has + * failed, such as reading a field that is missing from the classes on the classpath. A failure that + * depends on the receiver's class needs per-class state, which this does not keep. + * + *

Intended as a {@code static final} anonymous subclass, one per call site: the receiver is then + * a constant of a known exact type, so the JIT can inline {@link #apply}. Subclasses decide what + * counts as a failure in their own {@code try/catch} inside {@link #apply}, so checked exceptions + * and a tight {@code try} scope come for free, and call {@link #latch()} themselves. + * + *

{@link #fallback} is what every call yields once latched: {@code null} unless overridden. + * Override it when the call site has a known answer for the failed case, rather than passing the + * same default to {@link #tryApplyOrDefault} at every call. + * + *

This is a hint, not a lock. The flag is deliberately plain. A stale read only costs another + * failure; a thread always sees its own write, so each thread pays for at most one failure after + * its own first. Other threads' writes become visible eventually, with no bound on how long that + * takes. + * + * @param the type of the value the operation is applied to + * @param the type of the result + * @param the checked exception {@link #apply} may throw + */ +public abstract class Latch { + private boolean latched; + + /** Performs the operation. Call {@link #latch()} when it has failed in a way that will recur. */ + @Nullable + protected abstract R apply(T target) throws E; + + /** + * What every call yields once latched, instead of the operation. {@code null} unless overridden. + */ + @Nullable + protected R fallback(T target) throws E { + return null; + } + + /** + * Performs the operation unless latched, in which case returns {@link #fallback}. A {@code null} + * result means nothing is available: neither the operation nor the fallback produced a value. + */ + @Nullable + public final R tryApply(T target) throws E { + return latched ? fallback(target) : apply(target); + } + + /** + * Like {@link #tryApply}, but returns {@code defaultValue} when there is nothing available. It is + * also used when the operation or {@link #fallback} itself produced {@code null}, so a call and a + * skipped call always agree. + */ + public final R tryApplyOrDefault(T target, R defaultValue) throws E { + final R result = tryApply(target); + return result != null ? result : defaultValue; + } + + /** + * For a read of a field that some classes on the classpath may lack: latches if the call raises + * {@link NoSuchFieldError}, then rethrows it so the first failure is still reported; only later, + * skipped calls yield {@link #fallback}. A missing field is the same for every receiver, so one + * latch covers the site. Anything else propagates without latching. + * + *

{@code
+   * protected Boolean apply(ByteQuadsCanonicalizer symbols) {
+   *   return handleNoSuchField(symbols, s -> s._interner != null);
+   * }
+   * }
+ * + * A field read throws nothing checked, so the read is a plain {@link Function}. Unlike a helper + * that swallows the failure, this rethrows it, so the first failure is still reported. + */ + @Nullable + protected final R handleNoSuchField(T target, Function read) { + try { + return read.apply(target); + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + + /** Returns whether the operation is being skipped. */ + public final boolean isLatched() { + return latched; + } + + /** Skips the operation from now on. */ + protected final void latch() { + latched = true; + } + + /** Resumes performing the operation, for tests or for a policy that retries. */ + protected final void unlatch() { + latched = false; + } +} diff --git a/internal-api/src/test/java/datadog/trace/util/LatchTest.java b/internal-api/src/test/java/datadog/trace/util/LatchTest.java new file mode 100644 index 00000000000..2b6d0c3da61 --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/util/LatchTest.java @@ -0,0 +1,245 @@ +package datadog.trace.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.SQLException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +class LatchTest { + + /** The shape of a missing-field read: rethrow the first failure, then skip the read. */ + private static final class FieldLatch extends Latch { + final AtomicInteger calls = new AtomicInteger(); + boolean fieldPresent; + + @Override + protected Boolean apply(String target) { + calls.incrementAndGet(); + try { + if (!fieldPresent) { + throw new NoSuchFieldError("_interner"); + } + return false; + } catch (NoSuchFieldError e) { + latch(); + throw e; + } + } + } + + @Test + void performsTheOperationUntilLatched() { + FieldLatch latch = new FieldLatch(); + latch.fieldPresent = true; + + assertEquals(false, latch.tryApply("x")); + assertEquals(false, latch.tryApply("x")); + + assertEquals(2, latch.calls.get()); + assertFalse(latch.isLatched()); + } + + @Test + void rethrowsTheFirstFailureThenSkipsTheOperation() { + FieldLatch latch = new FieldLatch(); + + assertThrows(NoSuchFieldError.class, () -> latch.tryApply("x")); + assertTrue(latch.isLatched()); + + assertNull(latch.tryApply("x")); + assertNull(latch.tryApply("y")); + assertEquals(1, latch.calls.get(), "later calls should be skipped"); + } + + @Test + void onceLatchedEveryCallYieldsTheFallback() { + AtomicInteger calls = new AtomicInteger(); + Latch latch = + new Latch() { + @Override + protected String apply(String target) { + calls.incrementAndGet(); + return handleNoSuchField( + target, + t -> { + throw new NoSuchFieldError("f"); + }); + } + + @Override + protected String fallback(String target) { + return "fallback:" + target; + } + }; + + // the first failure is still rethrown, not replaced by the fallback + assertThrows(NoSuchFieldError.class, () -> latch.tryApply("x")); + + assertEquals("fallback:x", latch.tryApply("x")); + assertEquals("fallback:y", latch.tryApplyOrDefault("y", "default")); + assertEquals(1, calls.get(), "later calls should be skipped"); + } + + @Test + void aRealNullIsNotReplacedByTheFallback() { + Latch latch = + new Latch() { + @Override + protected String apply(String target) { + return null; + } + + @Override + protected String fallback(String target) { + return "fallback"; + } + }; + + assertNull(latch.tryApply("x")); + assertFalse(latch.isLatched()); + } + + @Test + void tryApplyOrDefaultReturnsTheResultWhenThereIsOne() { + FieldLatch latch = new FieldLatch(); + latch.fieldPresent = true; + + // a real false must not be replaced by the fallback + assertEquals(false, latch.tryApplyOrDefault("x", Boolean.TRUE)); + } + + @Test + void tryApplyOrDefaultReturnsTheFallbackOnceLatched() { + FieldLatch latch = new FieldLatch(); + assertThrows(NoSuchFieldError.class, () -> latch.tryApplyOrDefault("x", Boolean.TRUE)); + + assertEquals(true, latch.tryApplyOrDefault("x", Boolean.TRUE)); + assertEquals(true, latch.tryApplyOrDefault("y", Boolean.TRUE)); + assertEquals(1, latch.calls.get(), "later calls should be skipped"); + } + + @Test + void aCallThatYieldsNothingAndASkippedCallAgree() { + // the first call latches and returns null; the next is skipped. Both must give the fallback. + Latch latch = + new Latch() { + @Override + protected String apply(String target) { + latch(); + return null; + } + }; + + assertEquals("fallback", latch.tryApplyOrDefault("x", "fallback")); + assertEquals("fallback", latch.tryApplyOrDefault("x", "fallback")); + } + + /** A subclass may expose {@code unlatch}, for a policy that retries. */ + private static final class Resumable extends Latch { + int calls; + + @Override + protected String apply(String target) { + calls++; + latch(); + return "called"; + } + + void resume() { + unlatch(); + } + } + + @Test + void unlatchResumesTheOperation() { + Resumable latch = new Resumable(); + + assertEquals("called", latch.tryApply("x")); + assertNull(latch.tryApply("x")); + assertEquals(1, latch.calls); + + latch.resume(); + + assertFalse(latch.isLatched()); + assertEquals("called", latch.tryApply("x")); + assertEquals(2, latch.calls); + } + + @Test + void checkedExceptionsPropagate() { + Latch latch = + new Latch() { + @Override + protected String apply(String target) throws SQLException { + throw new SQLException("boom"); + } + }; + + assertThrows(SQLException.class, () -> latch.tryApply("x")); + assertFalse(latch.isLatched()); + } + + /** What a call site writes: {@code apply} delegating to {@code handleNoSuchField}. */ + private static final class Handling extends Latch { + final AtomicInteger calls = new AtomicInteger(); + Function read; + + @Override + protected String apply(String target) { + return handleNoSuchField( + target, + t -> { + calls.incrementAndGet(); + return read.apply(t); + }); + } + } + + @Test + void handleNoSuchFieldReturnsTheResultWithoutLatching() { + Handling latch = new Handling(); + latch.read = t -> "value"; + + assertEquals("value", latch.tryApply("x")); + assertFalse(latch.isLatched()); + } + + @Test + void handleNoSuchFieldLatchesAndRethrowsTheFirstFailure() { + Handling latch = new Handling(); + NoSuchFieldError failure = new NoSuchFieldError("_interner"); + latch.read = + t -> { + throw failure; + }; + + NoSuchFieldError thrown = assertThrows(NoSuchFieldError.class, () -> latch.tryApply("x")); + + assertSame(failure, thrown); + assertTrue(latch.isLatched()); + assertNull(latch.tryApply("x")); + assertEquals(1, latch.calls.get(), "later calls should be skipped"); + } + + @Test + void handleNoSuchFieldDoesNotLatchOnOtherFailures() { + Handling latch = new Handling(); + latch.read = + t -> { + throw new IllegalStateException("boom"); + }; + + assertThrows(IllegalStateException.class, () -> latch.tryApply("x")); + assertThrows(IllegalStateException.class, () -> latch.tryApply("x")); + + assertFalse(latch.isLatched()); + assertEquals(2, latch.calls.get()); + } +}