Skip to content

Commit cba33a0

Browse files
committed
Instrument JDK executors to propagate request context
Weave ThreadPoolExecutor, ForkJoinPool, AbstractExecutorService, ScheduledThreadPoolExecutor and the Executors$Delegated* services so a task submitted from a request thread runs under that request's Context on the pool worker. Helper-backed wrappers share a cached reflection bridge (ExecutorContextPropagation); the Scheduled/Delegated wrappers load agent_api per call through a URLClassLoader that is closed once wrapping is done. Integration tests cover preservation, nested submits, concurrent isolation and pooled-worker reuse.
1 parent 75593a3 commit cba33a0

8 files changed

Lines changed: 552 additions & 0 deletions

File tree

agent/src/main/java/dev/aikido/agent/Wrappers.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package dev.aikido.agent;
22

33
import dev.aikido.agent.wrappers.*;
4+
import dev.aikido.agent.wrappers.executor.AbstractExecutorServiceWrapper;
5+
import dev.aikido.agent.wrappers.executor.DelegatedExecutorServiceWrapper;
6+
import dev.aikido.agent.wrappers.executor.ForkJoinPoolWrapper;
7+
import dev.aikido.agent.wrappers.executor.ScheduledThreadPoolExecutorWrapper;
8+
import dev.aikido.agent.wrappers.executor.ThreadPoolExecutorWrapper;
49
import dev.aikido.agent.wrappers.file.FileConstructorMultiArgumentWrapper;
510
import dev.aikido.agent.wrappers.file.FileConstructorSingleArgumentWrapper;
611
import dev.aikido.agent.wrappers.javalin.*;
@@ -17,6 +22,13 @@ public final class Wrappers {
1722
private Wrappers() {}
1823
public static final List<Wrapper> WRAPPERS = Arrays.asList(
1924
new PostgresWrapper(),
25+
26+
new DelegatedExecutorServiceWrapper(),
27+
new ThreadPoolExecutorWrapper(),
28+
new AbstractExecutorServiceWrapper(),
29+
new ForkJoinPoolWrapper(),
30+
new ScheduledThreadPoolExecutorWrapper(),
31+
2032
new SpringMVCJakartaWrapper(),
2133
new SpringMVCJavaxWrapper(),
2234
new SpringWebfluxWrapper(),
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package dev.aikido.agent.wrappers.executor;
2+
3+
import dev.aikido.agent.wrappers.Wrapper;
4+
import net.bytebuddy.asm.Advice;
5+
import net.bytebuddy.description.method.MethodDescription;
6+
import net.bytebuddy.description.type.TypeDescription;
7+
import net.bytebuddy.matcher.ElementMatcher;
8+
9+
import java.util.concurrent.AbstractExecutorService;
10+
import java.util.concurrent.Callable;
11+
12+
import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC;
13+
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
14+
import static net.bytebuddy.matcher.ElementMatchers.isSubTypeOf;
15+
import static net.bytebuddy.matcher.ElementMatchers.named;
16+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
17+
18+
public class AbstractExecutorServiceWrapper implements Wrapper {
19+
@Override
20+
public String getName() {
21+
return SubmitAdvice.class.getName();
22+
}
23+
24+
@Override
25+
public ElementMatcher getMatcher() {
26+
return isMethod()
27+
.and(named("submit"))
28+
.and(
29+
takesArguments(Runnable.class)
30+
.or(takesArguments(Callable.class))
31+
.or(takesArguments(Runnable.class, Object.class))
32+
);
33+
}
34+
35+
@Override
36+
public ElementMatcher getTypeMatcher() {
37+
return isSubTypeOf(AbstractExecutorService.class);
38+
}
39+
40+
public static class SubmitAdvice {
41+
@Advice.OnMethodEnter(suppress = Throwable.class)
42+
public static void before(
43+
@Advice.Argument(value = 0, readOnly = false, typing = DYNAMIC) Object task
44+
) {
45+
if (task instanceof Runnable) {
46+
task = ExecutorContextPropagation.wrap((Runnable) task);
47+
} else if (task instanceof Callable) {
48+
task = ExecutorContextPropagation.wrap((Callable) task);
49+
}
50+
}
51+
}
52+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package dev.aikido.agent.wrappers.executor;
2+
3+
import dev.aikido.agent.wrappers.Wrapper;
4+
import net.bytebuddy.asm.Advice;
5+
import net.bytebuddy.description.method.MethodDescription;
6+
import net.bytebuddy.description.type.TypeDescription;
7+
import net.bytebuddy.matcher.ElementMatcher;
8+
9+
import java.lang.reflect.Method;
10+
import java.net.URL;
11+
import java.net.URLClassLoader;
12+
import java.util.concurrent.Callable;
13+
14+
import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC;
15+
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
16+
import static net.bytebuddy.matcher.ElementMatchers.nameStartsWith;
17+
import static net.bytebuddy.matcher.ElementMatchers.named;
18+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
19+
20+
public class DelegatedExecutorServiceWrapper implements Wrapper {
21+
@Override
22+
public String getName() {
23+
return DelegatedExecutorAdvice.class.getName();
24+
}
25+
26+
@Override
27+
public ElementMatcher getMatcher() {
28+
return isMethod()
29+
.and(named("execute").or(named("submit")))
30+
.and(
31+
takesArguments(Runnable.class)
32+
.or(takesArguments(Callable.class))
33+
.or(takesArguments(Runnable.class, Object.class))
34+
);
35+
}
36+
37+
@Override
38+
public ElementMatcher getTypeMatcher() {
39+
return nameStartsWith("java.util.concurrent.Executors$");
40+
}
41+
42+
public static class DelegatedExecutorAdvice {
43+
@Advice.OnMethodEnter(suppress = Throwable.class)
44+
public static void before(
45+
@Advice.Argument(value = 0, readOnly = false, typing = DYNAMIC) Object task
46+
) throws Exception {
47+
if (task == null) {
48+
return;
49+
}
50+
51+
// This advice is applied to JDK classes loaded by the bootstrap classloader.
52+
// Load agent_api reflectively because bootstrap classes cannot directly reference agent classes.
53+
String jarFilePath = System.getProperty("AIK_agent_api_jar");
54+
if (jarFilePath == null || jarFilePath.isBlank()) {
55+
return;
56+
}
57+
58+
// The wrapper returned by wrap() resolves through the parent (system) classloader,
59+
// so this per-call loader can be closed once wrapping is done.
60+
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });
61+
try {
62+
Class<?> contextPropagationClass = classLoader.loadClass(
63+
"dev.aikido.agent_api.context.ContextPropagation"
64+
);
65+
66+
if (task instanceof Runnable) {
67+
Method wrapRunnable = contextPropagationClass.getMethod("wrap", Runnable.class);
68+
task = wrapRunnable.invoke(null, task);
69+
} else if (task instanceof Callable) {
70+
Method wrapCallable = contextPropagationClass.getMethod("wrap", Callable.class);
71+
task = wrapCallable.invoke(null, task);
72+
}
73+
} finally {
74+
classLoader.close();
75+
}
76+
}
77+
}
78+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package dev.aikido.agent.wrappers.executor;
2+
3+
import java.lang.reflect.Method;
4+
import java.net.URL;
5+
import java.net.URLClassLoader;
6+
import java.util.concurrent.Callable;
7+
8+
// Bridges the executor advice (woven into java.util.concurrent classes) to ContextPropagation in
9+
// agent_api, which lives on a different classloader, and caches the reflected methods so the lookup
10+
// happens once. A missing AIK_agent_api_jar is treated as "not ready yet" (early startup) and
11+
// retried on the next call, rather than disabling propagation for the whole JVM; only a genuine
12+
// load failure once the path is set disables it.
13+
public final class ExecutorContextPropagation {
14+
private static volatile Method wrapRunnableMethod;
15+
private static volatile Method wrapCallableMethod;
16+
private static volatile boolean disabled;
17+
18+
private ExecutorContextPropagation() {}
19+
20+
public static Runnable wrap(Runnable task) {
21+
if (task == null) {
22+
return task;
23+
}
24+
Method wrap = wrapRunnableMethod;
25+
if (wrap == null) {
26+
init();
27+
wrap = wrapRunnableMethod;
28+
}
29+
if (wrap == null) {
30+
return task;
31+
}
32+
try {
33+
return (Runnable) wrap.invoke(null, task);
34+
} catch (Throwable ignored) {
35+
return task;
36+
}
37+
}
38+
39+
@SuppressWarnings("unchecked")
40+
public static <T> Callable<T> wrap(Callable<T> task) {
41+
if (task == null) {
42+
return task;
43+
}
44+
Method wrap = wrapCallableMethod;
45+
if (wrap == null) {
46+
init();
47+
wrap = wrapCallableMethod;
48+
}
49+
if (wrap == null) {
50+
return task;
51+
}
52+
try {
53+
return (Callable<T>) wrap.invoke(null, task);
54+
} catch (Throwable ignored) {
55+
return task;
56+
}
57+
}
58+
59+
private static synchronized void init() {
60+
if (disabled || wrapRunnableMethod != null) {
61+
return;
62+
}
63+
String jarFilePath = System.getProperty("AIK_agent_api_jar");
64+
if (jarFilePath == null || jarFilePath.isBlank()) {
65+
return; // not set yet during early startup - retry on a later call
66+
}
67+
try {
68+
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });
69+
Class<?> clazz = classLoader.loadClass("dev.aikido.agent_api.context.ContextPropagation");
70+
wrapCallableMethod = clazz.getMethod("wrap", Callable.class);
71+
wrapRunnableMethod = clazz.getMethod("wrap", Runnable.class);
72+
} catch (Throwable ignored) {
73+
disabled = true;
74+
}
75+
}
76+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package dev.aikido.agent.wrappers.executor;
2+
3+
import dev.aikido.agent.wrappers.Wrapper;
4+
import net.bytebuddy.asm.Advice;
5+
import net.bytebuddy.description.method.MethodDescription;
6+
import net.bytebuddy.description.type.TypeDescription;
7+
import net.bytebuddy.matcher.ElementMatcher;
8+
9+
import java.util.concurrent.Callable;
10+
import java.util.concurrent.ForkJoinPool;
11+
12+
import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC;
13+
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
14+
import static net.bytebuddy.matcher.ElementMatchers.isSubTypeOf;
15+
import static net.bytebuddy.matcher.ElementMatchers.named;
16+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
17+
18+
public class ForkJoinPoolWrapper implements Wrapper {
19+
@Override
20+
public String getName() {
21+
return ForkJoinAdvice.class.getName();
22+
}
23+
24+
@Override
25+
public ElementMatcher getMatcher() {
26+
return isMethod()
27+
.and(named("execute").or(named("submit")))
28+
.and(
29+
takesArguments(Runnable.class)
30+
.or(takesArguments(Callable.class))
31+
.or(takesArguments(Runnable.class, Object.class))
32+
);
33+
}
34+
35+
@Override
36+
public ElementMatcher getTypeMatcher() {
37+
return isSubTypeOf(ForkJoinPool.class);
38+
}
39+
40+
public static class ForkJoinAdvice {
41+
@Advice.OnMethodEnter(suppress = Throwable.class)
42+
public static void before(
43+
@Advice.Argument(value = 0, readOnly = false, typing = DYNAMIC) Object task
44+
) {
45+
if (task instanceof Runnable) {
46+
task = ExecutorContextPropagation.wrap((Runnable) task);
47+
} else if (task instanceof Callable) {
48+
task = ExecutorContextPropagation.wrap((Callable) task);
49+
}
50+
}
51+
}
52+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package dev.aikido.agent.wrappers.executor;
2+
3+
import dev.aikido.agent.wrappers.Wrapper;
4+
import net.bytebuddy.asm.Advice;
5+
import net.bytebuddy.description.method.MethodDescription;
6+
import net.bytebuddy.description.type.TypeDescription;
7+
import net.bytebuddy.matcher.ElementMatcher;
8+
9+
import java.lang.reflect.Method;
10+
import java.net.URL;
11+
import java.net.URLClassLoader;
12+
import java.util.concurrent.Callable;
13+
import java.util.concurrent.ScheduledThreadPoolExecutor;
14+
import java.util.concurrent.TimeUnit;
15+
16+
import static net.bytebuddy.implementation.bytecode.assign.Assigner.Typing.DYNAMIC;
17+
import static net.bytebuddy.matcher.ElementMatchers.isMethod;
18+
import static net.bytebuddy.matcher.ElementMatchers.isSubTypeOf;
19+
import static net.bytebuddy.matcher.ElementMatchers.named;
20+
import static net.bytebuddy.matcher.ElementMatchers.takesArguments;
21+
22+
public class ScheduledThreadPoolExecutorWrapper implements Wrapper {
23+
@Override
24+
public String getName() {
25+
return ScheduleAdvice.class.getName();
26+
}
27+
28+
@Override
29+
public ElementMatcher getMatcher() {
30+
return isMethod()
31+
.and(named("schedule"))
32+
.and(
33+
takesArguments(Runnable.class, long.class, TimeUnit.class)
34+
.or(takesArguments(Callable.class, long.class, TimeUnit.class))
35+
);
36+
}
37+
38+
@Override
39+
public ElementMatcher getTypeMatcher() {
40+
return isSubTypeOf(ScheduledThreadPoolExecutor.class);
41+
}
42+
43+
public static class ScheduleAdvice {
44+
@Advice.OnMethodEnter(suppress = Throwable.class)
45+
public static void before(
46+
@Advice.Argument(value = 0, readOnly = false, typing = DYNAMIC) Object task
47+
) throws Exception {
48+
if (task == null) {
49+
return;
50+
}
51+
52+
// This advice is applied to JDK classes loaded by the bootstrap classloader.
53+
// Load agent_api reflectively because bootstrap classes cannot directly reference agent classes.
54+
String jarFilePath = System.getProperty("AIK_agent_api_jar");
55+
if (jarFilePath == null || jarFilePath.isBlank()) {
56+
return;
57+
}
58+
59+
// The wrapper returned by wrap() resolves through the parent (system) classloader,
60+
// so this per-call loader can be closed once wrapping is done.
61+
URLClassLoader classLoader = new URLClassLoader(new URL[] { new URL(jarFilePath) });
62+
try {
63+
Class<?> contextPropagationClass = classLoader.loadClass(
64+
"dev.aikido.agent_api.context.ContextPropagation"
65+
);
66+
67+
if (task instanceof Runnable) {
68+
Method wrapRunnable = contextPropagationClass.getMethod("wrap", Runnable.class);
69+
task = wrapRunnable.invoke(null, task);
70+
} else if (task instanceof Callable) {
71+
Method wrapCallable = contextPropagationClass.getMethod("wrap", Callable.class);
72+
task = wrapCallable.invoke(null, task);
73+
}
74+
} finally {
75+
classLoader.close();
76+
}
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)