Full companion repo for the ankurm.com post "Spring Security Context Propagation: The Complete Guide" -- every code example the post discusses now has a corresponding runnable, verified demo (JDK 25, Spring Security 7.1.1, Spring Boot 4.1.1 dependency versions), not just the virtual-thread/structured-concurrency sections: - Demo1PlainThreadLocal: InheritableThreadLocal across thread models (no Spring) - Demo2AsyncVirtualThreads: @Async on a virtual-thread SimpleAsyncTaskExecutor (DelegatingSecurityContextExecutor vs ContextPropagatingTaskDecorator) - Demo3StructuredConcurrency: StructuredTaskScope.fork() propagation - Demo4ExecutorWrapping: DelegatingSecurityContextExecutorService/Executor/ AsyncTaskExecutor on a classic pooled platform-thread executor -- the post's "Using @Async" / "Using ExecutorService" / "Using CompletableFuture" sections - Demo5ReactiveContext: ReactiveSecurityContextHolder vs. ThreadLocal across a Reactor scheduler hop -- the post's WebFlux/getProfile() section - Demo6ScheduledSystemIdentity: DelegatingSecurityContextTaskScheduler's actual per-call capture semantics (confirmed via bytecode before writing the demo) and the createSystemContext() pattern -- the post's scheduled-tasks section - Demo7ServletFilterPersistence: SecurityContextHolderFilter (load-only) vs. SecurityContextPersistenceFilter (load+auto-save), against real filter instances and a real HttpSession -- the post's servlet-environment section - SecurityContextPropagationContractTest: 10 JUnit tests pinning the above as assertions instead of printed lines, including a TestSecurityContextHolder-based test reproducing the post's own "Testing Security Context Propagation" section Thirteen edge cases discovered along the way are indexed in docs/08 with links into the chapter that reproduces each one -- a reused pool worker NOT leaking under the Delegating* wrappers (unlike Demo1's InheritableThreadLocal), the common ForkJoinPool trap, why there's no DelegatingSecurityContextStructuredTaskScope and never will be, a real NullPointerException from Reactor's map() hit while writing the reactive test, per-call (not per-construction) context capture in DelegatingSecurityContextTaskScheduler, and the precise load-vs-save split between the two servlet filters, among others. docs/01-08 are numbered, cross-linked chapters with prev/next navigation; README indexes all demos, chapters, captured output, and the edge-case list. scripts/run-all.sh regenerates every docs/output/*.txt and the test suite output from one command.
66 lines
4.1 KiB
Markdown
66 lines
4.1 KiB
Markdown
# 4. Executor, ExecutorService, and AsyncTaskExecutor wrapping
|
|
|
|
[← Prev: Structured concurrency](03-structured-concurrency.md) | [Next: Reactive context →](05-reactive-context.md)
|
|
|
|
Chapters 1–3 are all about virtual threads and structured concurrency, which came later.
|
|
`Demo4ExecutorWrapping.java` goes back to the baseline the post's "Using @Async", "Using
|
|
ExecutorService", and "Using CompletableFuture" sections describe: a **fixed platform-thread
|
|
pool** (`ThreadPoolExecutor`, `ThreadPoolTaskExecutor`), the shape almost every Spring app used
|
|
before `spring.threads.virtual.enabled` existed, and the exact pooled-thread danger
|
|
[Chapter 1](01-inheritable-threadlocal.md) demonstrated for `InheritableThreadLocal`. Full
|
|
output in [`docs/output/demo4.txt`](output/demo4.txt).
|
|
|
|
## The three wrapper classes the post names
|
|
|
|
- **`DelegatingSecurityContextExecutorService`** wraps an entire `ExecutorService` --
|
|
`execute()`, `submit()`, `invokeAll()`, `invokeAny()` all go through the wrapper. This is
|
|
the fix for the post's `TaskExecutionService` ("Using ExecutorService") example.
|
|
- **`DelegatingSecurityContextExecutor`** wraps a plain `Executor` and is what you hand to
|
|
`CompletableFuture.supplyAsync(supplier, executor)` -- the fix for
|
|
`CompletableFutureService` ("Using CompletableFuture"). The common `ForkJoinPool` that
|
|
`CompletableFuture.supplyAsync(supplier)` uses when you don't supply an executor never
|
|
propagates context; scenario C2 in the output shows that directly.
|
|
- **`DelegatingSecurityContextAsyncTaskExecutor`** wraps Spring's own
|
|
`AsyncTaskExecutor`/`TaskExecutor` abstraction -- the type `AsyncConfigurer#getAsyncExecutor()`
|
|
actually returns, and the object Spring's `@Async` infrastructure calls `execute()`/`submit()`
|
|
on. This is the fix for the post's `AsyncConfig` example.
|
|
|
|
All three extend the same mechanism [Chapter 2](02-async-virtual-threads.md) already
|
|
described: wrap the submitted `Runnable`/`Callable`, capture `SecurityContextHolder.getContext()`
|
|
once (at wrap time, not at thread-construction time), and push/pop it around the delegate's
|
|
execution on whatever thread that turns out to be.
|
|
|
|
## The edge case Chapter 1 sets up and this chapter resolves
|
|
|
|
Chapter 1's whole point was that a **reused pool worker** keeps whatever
|
|
`InheritableThreadLocal` value existed when the pool created it, not what the submitting
|
|
thread had at submission time -- that's why `MODE_INHERITABLETHREADLOCAL` is dangerous with a
|
|
fixed thread pool. The `EDGE` scenario in this demo asks the same question of the
|
|
`Delegating*` wrappers, and the answer is the opposite:
|
|
|
|
```
|
|
EDGE) task 1 on possibly-reused worker: authenticated as carol-task1
|
|
EDGE) task 2, same pool, different caller context: authenticated as dave-task2 <-- correct, NOT stale
|
|
```
|
|
|
|
Two tasks submitted through `DelegatingSecurityContextExecutorService` to the *same* two-worker
|
|
pool, with the caller's `SecurityContextHolder` context changed in between, each see their
|
|
**own** context -- never the other task's. That's because the wrapper captures context per
|
|
submission (inside `wrap()`, called synchronously from `execute()`/`submit()`), not once per
|
|
worker thread the way thread-local inheritance does. This is the actual reason the
|
|
`Delegating*` family predates virtual threads by years and is still correct on a fixed pool:
|
|
it never depended on thread identity in the first place.
|
|
|
|
[Chapter 8](08-testing-contract.md) pins this exact contrast as a JUnit assertion
|
|
(`delegatingSecurityContextExecutorServicePropagatesAndCapturesIndependentlyPerTask`), not just
|
|
a printed line.
|
|
|
|
## Practical note: which one do you actually need?
|
|
|
|
If you already have an `AsyncConfigurer` returning a `ThreadPoolTaskExecutor`, wrap it with
|
|
`DelegatingSecurityContextAsyncTaskExecutor` and nothing else changes -- `@Async` keeps working
|
|
as written. If you're calling `CompletableFuture.supplyAsync(...)` without an explicit executor
|
|
anywhere in the codebase, that's the common-`ForkJoinPool` trap in scenario C2; the fix is
|
|
always to supply a `DelegatingSecurityContextExecutor`-wrapped executor, never to reach for
|
|
`MODE_INHERITABLETHREADLOCAL` as a global patch for one call site.
|