1
0
Files
spring-security-demo/docs/03-structured-concurrency.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

3.2 KiB

3. StructuredTaskScope and SecurityContext

← Prev: Async + virtual threads | Next: Executor/ExecutorService wrapping →

Demo3StructuredConcurrency.java asks the Chapter 2 question again, but for StructuredTaskScope (JEP 505, fifth preview in JDK 25 -- still preview through the JDK 26 sixth preview per JEP 525, so every example here needs --enable-preview). A fork() call starts a brand new virtual thread for the subtask, same as the executors in Chapter 2, so the Chapter 1 finding applies here too. Full output in docs/output/demo3.txt.

What the JEP actually promises

JEP 525's text is explicit about one kind of context and silent about another:

Subtasks forked in a scope inherit ScopedValue bindings.

That is a real, specified guarantee -- and it says nothing about ThreadLocal. Spring Security's SecurityContextHolder is a ThreadLocal/InheritableThreadLocal, not a ScopedValue. Nothing in the structured concurrency API changes that, and scenario A below proves it: a plain scope.fork(...) with the default MODE_THREADLOCAL strategy loses the Authentication exactly like the unwrapped executor in Chapter 2 did.

Four scenarios

  • A) Plain fork, MODE_THREADLOCAL -- lost. The default SecurityContextHolder strategy isn't inherited by anything, structured concurrency included.

  • B) Plain fork, MODE_INHERITABLETHREADLOCAL -- propagates. Same reasoning as Chapter 2, scenario B: fork()'s subtask thread is a fresh virtual thread, so inheritance at construction time works and there is no pooled-thread staleness risk.

  • C) Manual capture-and-restore around the forked Callable -- propagates, and does not depend on the global strategy mode at all:

    SecurityContext captured = SecurityContextHolder.getContext();
    Callable<String> task = () -> {
        SecurityContextHolder.setContext(captured);
        try { return doWork(); }
        finally { SecurityContextHolder.clearContext(); }
    };
    scope.fork(task);
    

    This is the safest pattern for a StructuredTaskScope used inside library code, the same way DelegatingSecurityContextExecutor is the safest pattern for an Executor: it works regardless of what the surrounding application has set SecurityContextHolder's strategy to.

  • D) ContextSnapshot.wrap(...) around the forked Callable -- the Chapter 2 mechanism applied to fork() instead of execute(). Because SecurityContextHolderThreadLocalAccessor is already registered with Micrometer's ContextRegistry, ContextSnapshotFactory.builder() .build().captureAll() picks up the current SecurityContext (and MDC, and tracing context) in one call, and .wrap(callable) restores all of them inside the subtask. This is the version worth reaching for once you have more than the SecurityContext to carry across the scope boundary.

The practical takeaway

StructuredTaskScope does not give SecurityContextHolder anything for free. If your fork()ed subtasks need to call secured services, wrap them explicitly -- option C if you want zero new dependencies, option D if context-propagation is already on the classpath and you have other thread-locals to carry along too.