1
0
Files
spring-security-demo/docs/04-executor-wrapping.md
Ankur 9f950bffa9 Add every example from the post, plus edge cases, to the companion repo
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.
2026-08-24 16:49:30 +00:00

4.1 KiB
Raw Blame History

4. Executor, ExecutorService, and AsyncTaskExecutor wrapping

← Prev: Structured concurrency | Next: Reactive context →

Chapters 13 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 demonstrated for InheritableThreadLocal. Full output in docs/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 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 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.