PasswordResetExecutorConfiguration.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.configuration;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

@Slf4j
@Configuration
public class PasswordResetExecutorConfiguration {

	private static final int PASSWORD_RESET_POOL_SIZE = 2;
	private static final int PASSWORD_RESET_QUEUE_CAPACITY = 100;
	private static final int PASSWORD_RESET_DRAIN_SECONDS = 30;

	/**
	 * Executor for the asynchronous part of the password-reset flow (token generation, bcrypt, SMTP
	 * send). {@code AuthService.pwReset} moves that work off the request thread so the nonexistent /
	 * disabled / active branches all return without the bcrypt+SMTP timing gap, closing a
	 * timing-based account-enumeration side channel.
	 * <p>
	 * Pool size is intentionally small: pwReset throughput is bounded by
	 * {@code PasswordResetRateLimiter}, so two threads are enough to absorb concurrent dispatches
	 * without blocking the request path. Threads are daemons so a shutdown that misses the container
	 * callback still does not block JVM exit.
	 * <p>
	 * The queue is bounded to cap heap growth under distributed credential-stuffing or an SMTP
	 * outage: at queue saturation {@code execute} throws {@code TaskRejectedException} (a
	 * {@link java.util.concurrent.RejectedExecutionException}), which
	 * {@code AuthService.submitPasswordResetDispatch} catches and treats as a client-silent drop —
	 * the response stays at the generic 200 so the timing-equivalence guarantee is preserved, but
	 * the rejection is logged at ERROR and increments the {@code auth.pwreset.rejected} Micrometer
	 * counter so operators can alert on sustained drops via monitoring rather than log scraping. A
	 * {@code CallerRunsPolicy} would have run the rejected task on the request thread, which
	 * re-opens the timing oracle this whole executor exists to close.
	 * <p>
	 * Declared as a bean so the container owns its lifecycle: {@code afterPropertiesSet()} calls
	 * {@code initialize()} on startup, and on context close the executor is shut down as described
	 * below, discarding the queued backlog and awaiting the already-running tasks for
	 * {@value #PASSWORD_RESET_DRAIN_SECONDS} seconds. The await is best-effort: {@code shutdownNow()}
	 * has already interrupted the workers at that point — an in-flight SMTP send usually still
	 * completes only because a blocking socket write does not respond to interruption.
	 * <p>
	 * Discarding rather than draining is deliberate, and takes both flags below to actually happen.
	 * {@code waitForTasksToCompleteOnShutdown} stays off so {@code shutdown()} takes the
	 * {@code shutdownNow()} branch. {@code acceptTasksAfterContextClose} is on because without it the
	 * two workers keep draining the queue through the coordinated {@code SmartLifecycle} stop phase,
	 * blocking context close on the lifecycle latch for up to {@code timeoutPerShutdownPhase} (30s)
	 * before {@code destroy()} finally discards the rest: {@code ThreadPoolTaskExecutor}'s
	 * {@code initiateEarlyShutdown()} is a no-op unless {@code strictEarlyShutdown} is set, and
	 * {@code markShutdown()} defeats the {@code beforeExecute} pause guard, so nothing actually stops
	 * the workers during that phase. With the flag set the executor skips the stop phase entirely and
	 * {@code destroy()} → {@code shutdownNow()} is the first and only shutdown step. Its side effect —
	 * dispatches submitted during the close window are still accepted, then dropped — is harmless
	 * here: Boot's graceful shutdown stops the connector before bean destruction, and a client-silent
	 * drop is the contract anyway.
	 * <p>
	 * Draining the queue instead would not terminate — two workers against {@code Email}'s 5s SMTP
	 * timeouts clear roughly a dozen sends in {@value #PASSWORD_RESET_DRAIN_SECONDS} seconds, so a
	 * queue anywhere near {@value #PASSWORD_RESET_QUEUE_CAPACITY} cannot finish, and on timeout Spring
	 * only logs a warning without following up with {@code shutdownNow()} the way the hand-rolled
	 * {@code @PreDestroy} this replaced did. Those tasks would then keep running past context close,
	 * storing tokens in a cache the restart is about to discard and mailing links that are already
	 * dead. Dropping a queued dispatch is the outcome {@code AuthService.submitPasswordResetDispatch}
	 * already accepts on rejection; {@code cancelRemainingTask} logs each one so the drop is not
	 * silent.
	 * <p>
	 * {@code newThread} is overridden to install an {@code UncaughtExceptionHandler}:
	 * {@code AuthService.dispatchPasswordResetEmail} deliberately runs bcrypt and {@code putToken}
	 * outside its own try/catch (nothing needs cleaning up before the token is stored), and relies on
	 * this handler to make a {@code Throwable} from that stretch reach the logs instead of being
	 * swallowed by {@code ThreadPoolExecutor}'s default {@code Worker.run} path. That depends on the
	 * task being handed to {@code execute()}, as {@code submitPasswordResetDispatch} does — a
	 * {@code submit()} would capture the same {@code Throwable} into the returned {@code Future},
	 * where the handler never fires.
	 */
	@Bean
	ThreadPoolTaskExecutor passwordResetTaskExecutor() {
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor() {
			@Override
			public Thread newThread(Runnable runnable) {
				Thread thread = super.newThread(runnable);
				thread.setUncaughtExceptionHandler((t, ex) ->
						log.error("Uncaught error in password-reset worker {}", t.getName(), ex));
				return thread;
			}

			@Override
			protected void cancelRemainingTask(Runnable task) {
				log.warn("Dropping queued password-reset dispatch on shutdown; the user must request a new reset");
				super.cancelRemainingTask(task);
			}
		};
		executor.setCorePoolSize(PASSWORD_RESET_POOL_SIZE);
		executor.setMaxPoolSize(PASSWORD_RESET_POOL_SIZE);
		executor.setQueueCapacity(PASSWORD_RESET_QUEUE_CAPACITY);
		executor.setThreadNamePrefix("password-reset-async-");
		executor.setDaemon(true);
		executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
		executor.setAcceptTasksAfterContextClose(true);
		executor.setAwaitTerminationSeconds(PASSWORD_RESET_DRAIN_SECONDS);
		return executor;
	}
}