Skip to content

Add execution-aware future completion executor for async clients - #7478

Open
scrocquesel wants to merge 2 commits into
aws:masterfrom
scrocquesel:gh-7470
Open

scrocquesel wants to merge 2 commits into
aws:masterfrom
scrocquesel:gh-7470

Conversation

@scrocquesel

Copy link
Copy Markdown
Contributor

Motivation and Context

Closes #7470.

Async clients complete the futures they return on SDK threads (the FUTURE_COMPLETION_EXECUTOR), so any context bound to the caller's thread (logging MDC, tracing context, thread context class loader, Vert.x duplicated context...) is lost in the continuation. FUTURE_COMPLETION_EXECUTOR only receives a bare Runnable and cannot know which execution it is completing, so there is currently no SDK extension point to restore that context (see also #5242, #2142, #1613).

This issue is one of the most requested feature in the Quarkus extension for the past two years and we lack of a proper way to handle it.

This PR adds an additive, non-breaking option: a future completion executor that also receives the execution's ExecutionAttributes. Combined with ExecutionInterceptor.beforeExecution, which runs on the caller's thread, context can be captured when the call starts and restored when its future completes:

static final ExecutionAttribute<Map<String, String>> MDC_CONTEXT = new ExecutionAttribute<>("MdcContext");

// interceptor: runs on the caller's thread
public void beforeExecution(Context.BeforeExecution context, ExecutionAttributes attributes) {
    attributes.putAttribute(MDC_CONTEXT, MDC.getCopyOfContextMap());
}

// completion executor: restores what the interceptor captured
ExecutionAwareExecutor executor = (command, attributes) -> pool.execute(() -> {
    Map<String, String> mdc = attributes.getAttribute(MDC_CONTEXT);
    if (mdc != null) {
        MDC.setContextMap(mdc);
    }
    try {
        command.run();
    } finally {
        MDC.clear();
    }
});

S3AsyncClient.builder()
             .overrideConfiguration(o -> o.addExecutionInterceptor(interceptor))
             .asyncConfiguration(c -> c.advancedOption(EXECUTION_AWARE_FUTURE_COMPLETION_EXECUTOR, executor))
             .build();

This has been validated end to end in the Quarkus extension for the AWS SDK to propagate the Vert.x duplicated context.

One thing worth noting: overrideConfiguration(...) and asyncConfiguration(...) are not additive, each call replaces the whole configuration object. asyncConfiguration has no getter, and overrideConfiguration() is only exposed on the concrete service builders, not on SdkAsyncClientBuilder/AwsAsyncClientBuilder.
This makes it impractical to configure a client in multiple steps (e.g. a framework configuring a client that the application then customizes). With this feature it matters more, because the executor and its capturing interceptor live in two separate configurations, and replacing either one silently disables the context propagation.

It also prevents chaining executors. In the Quarkus extension, we restore Vert.x context and the user may want to restore MDC. It would have to reimplement the Vert.x logic. But this is well beyond this PR.

Modifications

The PR has two commits; the second one can be split into its own PR if preferred.

1. Execution-aware future completion executor

  • New @SdkPublicApi functional interface ExecutionAwareExecutor (void execute(Runnable command, ExecutionAttributes executionAttributes)).
  • New SdkAdvancedAsyncClientOption.EXECUTION_AWARE_FUTURE_COMPLETION_EXECUTOR, annotated @SdkAdvancedApi like FUTURE_COMPLETION_EXECUTOR.
  • FUTURE_COMPLETION_EXECUTOR and EXECUTION_AWARE_FUTURE_COMPLETION_EXECUTOR are mutually exclusive: ClientAsyncConfiguration rejects a configuration that sets both (Validate.mutuallyExclusive, as in WaiterOverrideConfiguration).
  • SdkDefaultClientBuilder always resolves an ExecutionAwareExecutor: when only FUTURE_COMPLETION_EXECUTOR is configured, or neither (SDK-managed default pool), it is adapted as (command, attributes) -> executor.execute(command), so existing behavior is unchanged. SdkClientOptionValidation now requires the execution-aware executor, which is the one used at runtime.
  • MakeAsyncHttpRequestStage only uses the resolved execution-aware executor and passes it context.executionAttributes(). The existing fallback to synchronous completion when the executor rejects the task is unchanged.
  • A user-provided ExecutionAwareExecutor is wrapped so the SDK never closes it, even if it is AutoCloseable (same intent as ExecutorUtils.unmanagedExecutor for FUTURE_COMPLETION_EXECUTOR).
  • S3CrtAsyncClientBuilder.executionAwareFutureCompletionExecutor(ExecutionAwareExecutor), forwarded to the wrapped S3AsyncClient. It is a distinct method rather than an overload of futureCompletionExecutor(Executor), so existing futureCompletionExecutor(null) calls don't become ambiguous.
  • The javadoc documents that the executor is invoked once per HTTP attempt (with the same ExecutionAttributes for every attempt), and which SDK processing runs on the thread it dispatches to (retry bookkeeping, AsyncResponseTransformer#exceptionOccurred, post-response interceptor hooks, metric publishing). This is the same as FUTURE_COMPLETION_EXECUTOR today, but matters when dispatching to threads that must not block, such as an event loop.
  • No codegen change: generated event-stream operations pass the executor to EventStreamAsyncResponseTransformer.Builder.executor(...), which is deprecated and ignored.
  • Test configurations that build SdkClientConfiguration by hand (bypassing the client builder) now set EXECUTION_AWARE_FUTURE_COMPLETION_EXECUTOR.

2. Execution interceptors on the S3 CRT client builder

  • S3CrtAsyncClientBuilder had no public way to add an ExecutionInterceptor (only a package-private, test-only method on its implementation), although interceptors were already forwarded to the wrapped S3AsyncClient. Without it, executionAwareFutureCompletionExecutor cannot be paired with a context-capturing interceptor on the CRT client.
  • Adds executionInterceptors(List<ExecutionInterceptor>) and addExecutionInterceptor(ExecutionInterceptor), named like their ClientOverrideConfiguration.Builder counterparts, as default methods throwing UnsupportedOperationException on the public interface.

Testing

  • ClientAsyncConfigurationTest (new): both options set via advancedOption or advancedOptions(Map) throw; the execution-aware executor is delegated to; null stays unset.
  • DefaultClientBuilderTest: the resolved execution-aware executor delegates to a configured FUTURE_COMPLETION_EXECUTOR or to the default sdk-async-response pool; a configured execution-aware executor is used as is; an AutoCloseable one is not closed with the client.
  • MakeAsyncHttpRequestStageTest: the executor receives the execution's ExecutionAttributes.
  • AsyncResponseThreadingTest (protocol tests): end to end, a thread-local captured by an interceptor in beforeExecution is visible in the continuation of the returned future on success, connection error and server error; configuring both options on a client builder fails.
  • S3CrtClientWiremockTest: the CRT builder's execution-aware executor is invoked with the execution's attributes; an interceptor added with addExecutionInterceptor captures a value on the caller's thread that the executor reads on completion; executionInterceptors(List) replaces previously added interceptors; configuring both executors fails.
  • Ran locally: sdk-core, aws-core and protocol-tests full unit test suites, plus the S3 CRT / presigned URL tests, with checkstyle, spotbugs and japicmp.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the CONTRIBUTING document
  • Local run of mvn install succeeds
  • My code follows the code style of this project
  • My change requires a change to the Javadoc documentation
  • I have updated the Javadoc documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed
  • I have added a changelog entry. Adding a new entry must be accomplished by running the scripts/new-change script and following the instructions. Commit the new file created by the script in .changes/next-release with your changes.
  • My change is to implement 1.11 parity feature and I have updated LaunchChangelog

License

  • I confirm that this pull request can be released under the Apache 2 license

Async clients complete the futures they return on SDK threads (the
FUTURE_COMPLETION_EXECUTOR), so any context bound to the caller's thread
(logging MDC, tracing context, thread context class loader, Vert.x
context...) is lost in the continuation. FUTURE_COMPLETION_EXECUTOR only
receives a bare Runnable and cannot know which execution it is completing,
so it cannot restore that context.

This adds SdkAdvancedAsyncClientOption.EXECUTION_AWARE_FUTURE_COMPLETION_EXECUTOR
and the ExecutionAwareExecutor interface, whose execute method also
receives the ExecutionAttributes of the execution (API call) being
completed. Combined with ExecutionInterceptor.beforeExecution, which runs
on the caller's thread, context can be captured into an ExecutionAttribute
when the call starts and restored when its future completes.

- The client builder always resolves an ExecutionAwareExecutor: when only
  FUTURE_COMPLETION_EXECUTOR is configured, or neither (SDK-managed
  default), it is adapted, so existing behavior is unchanged.
  MakeAsyncHttpRequestStage only uses the resolved execution-aware
  executor, once per attempt, with the execution's attributes.
- FUTURE_COMPLETION_EXECUTOR and EXECUTION_AWARE_FUTURE_COMPLETION_EXECUTOR
  are mutually exclusive; ClientAsyncConfiguration rejects both being set.
- A user-provided ExecutionAwareExecutor is never closed by the SDK.
- S3CrtAsyncClientBuilder gets executionAwareFutureCompletionExecutor(...).

Generated event-stream operations are unchanged: the executor they pass to
EventStreamAsyncResponseTransformer is deprecated and ignored.

Closes aws#7470
S3CrtAsyncClientBuilder had no public way to add an ExecutionInterceptor
(only a package-private, test-only method on its implementation), although
the interceptors are already forwarded to the wrapped S3AsyncClient. This
prevents, for instance, pairing executionAwareFutureCompletionExecutor with
an interceptor capturing the caller's context in beforeExecution, as is
possible with the regular async client builder through
overrideConfiguration.

Add executionInterceptors(List) and addExecutionInterceptor(...), named
like their ClientOverrideConfiguration.Builder counterparts. They are
default methods throwing UnsupportedOperationException on the public
interface, like the other recently added builder methods.

See aws#7470
@scrocquesel
scrocquesel requested a review from a team as a code owner October 22, 2026 22:48

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request-aware future completion executor to propagate caller context (MDC, tracing, Vert.x) on async clients

1 participant