From ee8ac8e2dc2c64ebb8d0ef2584de6b84fce97cb5 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 18:32:18 +0800 Subject: [PATCH 01/17] fix(scheduler): isolate control timers from user jobs --- .../java/io/threadforge/DelayScheduler.java | 11 +- src/main/java/io/threadforge/ThreadScope.java | 129 ++++++++++++++- .../threadforge/SchedulerIsolationTest.java | 149 ++++++++++++++++++ 3 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 src/test/java/io/threadforge/SchedulerIsolationTest.java diff --git a/src/main/java/io/threadforge/DelayScheduler.java b/src/main/java/io/threadforge/DelayScheduler.java index 17d7851..0b5e66e 100644 --- a/src/main/java/io/threadforge/DelayScheduler.java +++ b/src/main/java/io/threadforge/DelayScheduler.java @@ -19,7 +19,8 @@ */ public final class DelayScheduler { - private static final DelayScheduler SHARED = new DelayScheduler(createSharedExecutor(), false); + private static final DelayScheduler SHARED = new DelayScheduler(createSharedExecutor("threadforge-delay"), false); + private static final DelayScheduler CONTROL = new DelayScheduler(createSharedExecutor("threadforge-control"), false); private final ScheduledExecutorService executor; private final boolean ownsExecutor; @@ -50,6 +51,10 @@ public static DelayScheduler shared() { return SHARED; } + static DelayScheduler control() { + return CONTROL; + } + /** * 基于外部 {@link ScheduledExecutorService} 构造包装。 * @@ -152,10 +157,10 @@ void shutdownIfOwned() { /** * 创建框架默认共享的单线程调度执行器。 */ - private static ScheduledExecutorService createSharedExecutor() { + private static ScheduledExecutorService createSharedExecutor(String threadName) { ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor( 1, - new NamedThreadFactory("threadforge-delay") + new NamedThreadFactory(threadName) ); executor.setRemoveOnCancelPolicy(true); executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); diff --git a/src/main/java/io/threadforge/ThreadScope.java b/src/main/java/io/threadforge/ThreadScope.java index 91742ad..0de1a00 100644 --- a/src/main/java/io/threadforge/ThreadScope.java +++ b/src/main/java/io/threadforge/ThreadScope.java @@ -18,6 +18,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.FutureTask; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; @@ -67,6 +69,7 @@ public final class ThreadScope implements AutoCloseable { private final Deque deferred; private final DefaultCancellationToken token; private final DelayScheduler delayScheduler; + private final DelayScheduler controlDelayScheduler; private final ScopeMetrics metrics; private volatile Scheduler scheduler; @@ -103,6 +106,7 @@ private ThreadScope() { this.deadline = DEFAULT_DEADLINE; this.hook = NOOP_HOOK; this.delayScheduler = DelayScheduler.shared(); + this.controlDelayScheduler = DelayScheduler.control(); this.metrics = new ScopeMetrics(); this.token = new DefaultCancellationToken(new Runnable() { @Override @@ -558,7 +562,18 @@ public ScheduledTask schedule(Duration delay, final Callable callable) { compactFinishedScheduledTasks(); final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - ScheduledTask task = delayScheduler.schedule(delay, executionContext.wrapCallable(callable, token)); + ScheduledTask task = scheduleDispatched(delay, new Runnable() { + @Override + public void run() { + try { + executionContext.wrapCallable(callable, token).call(); + } catch (RuntimeException runtimeException) { + throw runtimeException; + } catch (Exception exception) { + throw new RuntimeException(exception); + } + } + }); scheduledTasks.add(task); return task; } @@ -570,7 +585,7 @@ public ScheduledTask schedule(Duration delay, final Runnable runnable) { ensureOpen(); compactFinishedScheduledTasks(); final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - ScheduledTask task = delayScheduler.schedule(delay, executionContext.wrapRunnable(runnable, token)); + ScheduledTask task = scheduleDispatched(delay, executionContext.wrapRunnable(runnable, token)); scheduledTasks.add(task); return task; } @@ -583,7 +598,10 @@ public ScheduledTask scheduleAtFixedRate(Duration initial, Duration period, fina ensureOpen(); compactFinishedScheduledTasks(); final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - ScheduledTask task = delayScheduler.scheduleAtFixedRate(initial, period, executionContext.wrapRunnable(runnable, token)); + final DispatchingScheduledTask task = new DispatchingScheduledTask( + scheduler.executor(), executionContext.wrapRunnable(runnable, token) + ); + task.bind(delayScheduler.scheduleAtFixedRate(initial, period, task)); scheduledTasks.add(task); return task; } @@ -596,7 +614,10 @@ public ScheduledTask scheduleWithFixedDelay(Duration initial, Duration delay, fi ensureOpen(); compactFinishedScheduledTasks(); final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - ScheduledTask task = delayScheduler.scheduleWithFixedDelay(initial, delay, executionContext.wrapRunnable(runnable, token)); + final DispatchingScheduledTask task = new DispatchingScheduledTask( + scheduler.executor(), executionContext.wrapRunnable(runnable, token) + ); + task.bind(delayScheduler.scheduleWithFixedDelay(initial, delay, task)); scheduledTasks.add(task); return task; } @@ -802,14 +823,14 @@ private ScheduledTask scheduleTaskTimeout(final Task task, final TaskInfo inf if (timeout == null) { return null; } - return delayScheduler.schedule(timeout, new Runnable() { + return controlDelayScheduler.schedule(timeout, new Runnable() { @Override public void run() { TaskTimeoutException timeoutException = taskTimeoutException(info, timeout); if (task.toCompletableFuture().completeExceptionally(timeoutException)) { task.markFailed(); task.interruptRunner(); - safeHookFailure(info, timeoutException, timeout.toNanos()); + dispatchHookFailure(info, timeoutException, timeout.toNanos()); } } }); @@ -875,7 +896,7 @@ private void rescheduleDeadlineMonitor() { if (deadlineTriggerTask != null) { deadlineTriggerTask.cancel(); } - deadlineTriggerTask = delayScheduler.schedule(deadline, new Runnable() { + deadlineTriggerTask = controlDelayScheduler.schedule(deadline, new Runnable() { @Override public void run() { triggerDeadline(); @@ -1019,4 +1040,98 @@ private void safeHookCancel(TaskInfo info, long durationNanos) { } catch (Throwable ignored) { } } + + private ScheduledTask scheduleDispatched(Duration delay, Runnable runnable) { + final DispatchingScheduledTask task = new DispatchingScheduledTask(scheduler.executor(), runnable); + task.bind(delayScheduler.schedule(delay, task)); + return task; + } + + private void dispatchHookFailure(final TaskInfo info, final Throwable throwable, final long durationNanos) { + try { + scheduler.executor().execute(new Runnable() { + @Override + public void run() { + safeHookFailure(info, throwable, durationNanos); + } + }); + } catch (RejectedExecutionException ignored) { + } + } + + private static final class DispatchingScheduledTask implements ScheduledTask, Runnable { + private final ExecutorService executor; + private final Runnable command; + private final AtomicBoolean cancelled; + private final Queue> dispatched; + private volatile ScheduledTask timer; + + private DispatchingScheduledTask(ExecutorService executor, Runnable command) { + this.executor = executor; + this.command = command; + this.cancelled = new AtomicBoolean(); + this.dispatched = new ConcurrentLinkedQueue>(); + } + + private void bind(ScheduledTask timer) { + this.timer = timer; + if (cancelled.get()) { + timer.cancel(); + } + } + + @Override + public void run() { + if (cancelled.get()) { + return; + } + FutureTask work = new DispatchedWork(command); + dispatched.add(work); + if (cancelled.get()) { + work.cancel(true); + return; + } + try { + executor.execute(work); + } catch (RejectedExecutionException rejected) { + work.cancel(false); + throw rejected; + } + } + + @Override + public boolean cancel() { + boolean changed = cancelled.compareAndSet(false, true); + ScheduledTask currentTimer = timer; + if (currentTimer != null) { + changed |= currentTimer.cancel(); + } + for (FutureTask work : dispatched) { + changed |= work.cancel(true); + } + return changed; + } + + @Override + public boolean isCancelled() { + return cancelled.get(); + } + + @Override + public boolean isDone() { + ScheduledTask currentTimer = timer; + return cancelled.get() || (currentTimer != null && currentTimer.isDone() && dispatched.isEmpty()); + } + + private final class DispatchedWork extends FutureTask { + private DispatchedWork(Runnable runnable) { + super(runnable, null); + } + + @Override + protected void done() { + dispatched.remove(this); + } + } + } } diff --git a/src/test/java/io/threadforge/SchedulerIsolationTest.java b/src/test/java/io/threadforge/SchedulerIsolationTest.java new file mode 100644 index 0000000..740f9eb --- /dev/null +++ b/src/test/java/io/threadforge/SchedulerIsolationTest.java @@ -0,0 +1,149 @@ +package io.threadforge; + +import org.junit.jupiter.api.RepeatedTest; + +import java.time.Duration; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +class SchedulerIsolationTest { + + @RepeatedTest(50) + void blockingScheduledUserJobDoesNotDelayTimeoutsInOtherScopes() throws Exception { + final CountDownLatch userJobStarted = new CountDownLatch(1); + final CountDownLatch releaseUserJob = new CountDownLatch(1); + + try (ThreadScope blockingScope = ThreadScope.open(); + ThreadScope firstTimedScope = ThreadScope.open(); + ThreadScope secondTimedScope = ThreadScope.open()) { + blockingScope.schedule(Duration.ZERO, new Runnable() { + @Override + public void run() { + userJobStarted.countDown(); + await(releaseUserJob); + } + }); + assertTrue(userJobStarted.await(1L, TimeUnit.SECONDS)); + + Task first = timedBlockingTask(firstTimedScope); + Task second = timedBlockingTask(secondTimedScope); + + assertTimeoutPreemptively(Duration.ofMillis(500), new org.junit.jupiter.api.function.Executable() { + @Override + public void execute() { + assertThrows(TaskTimeoutException.class, first::await); + assertThrows(TaskTimeoutException.class, second::await); + } + }); + } finally { + releaseUserJob.countDown(); + } + } + + @RepeatedTest(50) + void cancellingPeriodicTaskStopsDispatchingWork() throws Exception { + RecordingExecutor executor = new RecordingExecutor(); + try (ThreadScope scope = ThreadScope.open().withScheduler(Scheduler.from(executor))) { + ScheduledTask periodic = scope.scheduleAtFixedRate( + Duration.ZERO, + Duration.ofMillis(5), + new Runnable() { + @Override + public void run() { + } + } + ); + + assertTrue(executor.firstSubmission.await(1L, TimeUnit.SECONDS)); + periodic.cancel(); + int submissionsAtCancel = executor.submissions.get(); + + CountDownLatch observationComplete = new CountDownLatch(1); + DelayScheduler.shared().schedule(Duration.ofMillis(40), new Runnable() { + @Override + public void run() { + observationComplete.countDown(); + } + }); + assertTrue(observationComplete.await(1L, TimeUnit.SECONDS)); + assertEquals(submissionsAtCancel, executor.submissions.get()); + } finally { + executor.shutdownNow(); + } + } + + private static Task timedBlockingTask(ThreadScope scope) { + return scope.submit(new Callable() { + @Override + public Void call() throws Exception { + new CountDownLatch(1).await(); + return null; + } + }, Duration.ofMillis(40)); + } + + private static void await(CountDownLatch latch) { + boolean interrupted = false; + try { + while (true) { + try { + latch.await(); + return; + } catch (InterruptedException ignored) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + private static final class RecordingExecutor extends AbstractExecutorService { + private final AtomicBoolean shutdown = new AtomicBoolean(); + private final AtomicInteger submissions = new AtomicInteger(); + private final CountDownLatch firstSubmission = new CountDownLatch(1); + + @Override + public void shutdown() { + shutdown.set(true); + } + + @Override + public java.util.List shutdownNow() { + shutdown.set(true); + return java.util.Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return shutdown.get(); + } + + @Override + public boolean isTerminated() { + return shutdown.get(); + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return shutdown.get(); + } + + @Override + public void execute(Runnable command) { + submissions.incrementAndGet(); + firstSubmission.countDown(); + } + } +} From 8dc99b7a0655d5ccc8053fb47af733a093ce1f45 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 18:42:59 +0800 Subject: [PATCH 02/17] fix(task): make task lifecycle transitions atomic --- src/main/java/io/threadforge/Task.java | 332 ++++++++++++------ src/main/java/io/threadforge/ThreadScope.java | 107 +++--- .../io/threadforge/TaskLifecycleTest.java | 248 +++++++++++++ 3 files changed, 538 insertions(+), 149 deletions(-) create mode 100644 src/test/java/io/threadforge/TaskLifecycleTest.java diff --git a/src/main/java/io/threadforge/Task.java b/src/main/java/io/threadforge/Task.java index e72f306..21992e0 100644 --- a/src/main/java/io/threadforge/Task.java +++ b/src/main/java/io/threadforge/Task.java @@ -4,9 +4,9 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; /** @@ -23,9 +23,7 @@ */ public final class Task { - /** - * 任务生命周期状态。 - */ + /** 任务生命周期状态。 */ public enum State { /** 已创建但尚未运行。 */ PENDING, @@ -42,122 +40,121 @@ public enum State { private final long id; private final String name; private final CompletableFuture future; - private final AtomicReference state; - private final AtomicReference runnerThread; + private final Object lifecycleLock; + private final CompletableFuture executionFinished; + private State state; + private Thread runnerThread; + private boolean executionEntered; + private Future execution; + private Runnable executionFinishedCallback; - /** - * 包级构造函数,仅供 {@link ThreadScope} 创建任务句柄。 - */ + /** 包级构造函数,仅供 {@link ThreadScope} 创建任务句柄。 */ Task(long id, String name, CompletableFuture future) { this.id = id; this.name = name; this.future = future; - this.state = new AtomicReference(State.PENDING); - this.runnerThread = new AtomicReference(); + this.lifecycleLock = new Object(); + this.executionFinished = new CompletableFuture(); + this.state = State.PENDING; } - /** - * 任务 ID(在同一个 scope 内单调递增)。 - */ + /** 任务 ID(在同一个 scope 内单调递增)。 */ public long id() { return id; } - /** - * 任务名称。 - */ + /** 任务名称。 */ public String name() { return name; } - /** - * 获取当前任务状态快照。 - */ + /** 获取当前任务状态快照。 */ public State state() { - return state.get(); + synchronized (lifecycleLock) { + return state; + } } - /** - * 任务是否已经结束(成功/失败/取消任一状态)。 - */ + /** 任务是否已经逻辑结束。 */ public boolean isDone() { return future.isDone(); } - /** - * 任务是否处于取消状态。 - */ + /** 任务是否处于取消状态。 */ public boolean isCancelled() { - return state.get() == State.CANCELLED || future.isCancelled(); + synchronized (lifecycleLock) { + return state == State.CANCELLED || future.isCancelled(); + } } - /** - * 任务是否处于失败状态。 - */ + /** 任务是否处于失败状态。 */ public boolean isFailed() { - return state.get() == State.FAILED; + return state() == State.FAILED; } /** - * 取消任务,并尝试中断执行线程。 - * - *

返回值与 {@link CompletableFuture#cancel(boolean)} 语义一致。 + * 取消任务。只有成功赢得终态竞争时才会修改状态或中断执行线程。 */ public boolean cancel() { - state.set(State.CANCELLED); - Thread runner = runnerThread.get(); + Thread runner; + Future executionToCancel; + Runnable callback = null; + synchronized (lifecycleLock) { + if (isTerminal(state) || future.isDone()) { + return false; + } + State previous = state; + state = State.CANCELLED; + if (!future.cancel(true)) { + state = previous; + return false; + } + runner = runnerThread; + executionToCancel = execution; + if (!executionEntered) { + callback = markExecutionFinishedLocked(); + } + } + if (executionToCancel != null) { + executionToCancel.cancel(true); + } if (runner != null) { runner.interrupt(); } - return future.cancel(true); + runCallback(callback); + return true; } /** * 等待任务完成并返回结果。 * - *

异常语义: - * 若被取消抛 {@link CancelledException}; - * 若任务抛运行时异常/错误则原样传播; - * 若任务抛 checked exception 则包装为 {@link TaskExecutionException}。 + *

若被取消抛 {@link CancelledException};运行时异常/错误原样传播; + * checked exception 包装为 {@link TaskExecutionException}。 */ public T await() { try { - T value = future.get(); - state.compareAndSet(State.RUNNING, State.SUCCESS); - state.compareAndSet(State.PENDING, State.SUCCESS); - return value; + return future.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new CancelledException("Task interrupted", e); } catch (CancellationException e) { - state.set(State.CANCELLED); throw new CancelledException("Task cancelled", e); } catch (ExecutionException e) { - state.set(State.FAILED); rethrow(e.getCause()); return null; } } - /** - * 在指定超时时间内等待任务完成(包级方法,供 scope 内部使用)。 - * - *

超时会抛出 {@link ScopeTimeoutException}。 - */ + /** 在指定超时时间内等待任务完成(包级方法,供 scope 内部使用)。 */ T await(Duration timeout) { try { - T value = future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); - state.compareAndSet(State.RUNNING, State.SUCCESS); - state.compareAndSet(State.PENDING, State.SUCCESS); - return value; + return future.get(timeout.toNanos(), TimeUnit.NANOSECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new CancelledException("Task interrupted", e); } catch (CancellationException e) { - state.set(State.CANCELLED); throw new CancelledException("Task cancelled", e); } catch (ExecutionException e) { - state.set(State.FAILED); rethrow(e.getCause()); return null; } catch (TimeoutException e) { @@ -172,80 +169,209 @@ public CompletableFuture toCompletableFuture() { return future; } - /** - * 任务成功后做同步映射。 - * - *

示例: - *

{@code
-     * Integer result = task.thenApply(v -> v + 1).join();
-     * }
- */ + /** 任务成功后做同步映射。 */ public CompletableFuture thenApply(Function function) { return future.thenApply(function); } - /** - * 任务成功后做异步映射。 - */ + /** 任务成功后做异步映射。 */ public CompletableFuture thenCompose(Function> function) { return future.thenCompose(function); } - /** - * 任务异常完成时提供兜底值映射。 - */ + /** 任务异常完成时提供兜底值映射。 */ public CompletableFuture exceptionally(Function function) { return future.exceptionally(function); } - /** - * 标记任务进入运行状态,并记录执行线程。 - */ - boolean markRunning(Thread runner) { - boolean marked = state.compareAndSet(State.PENDING, State.RUNNING); - if (marked) { - runnerThread.set(runner); + void attachExecution(Future execution) { + synchronized (lifecycleLock) { + this.execution = execution; } - return marked; } - /** - * 标记任务成功完成。 - */ + void whenExecutionFinished(Runnable callback) { + boolean runNow; + synchronized (lifecycleLock) { + if (executionFinished.isDone()) { + runNow = true; + } else { + executionFinishedCallback = callback; + runNow = false; + } + } + if (runNow) { + callback.run(); + } + } + + boolean beginExecution(Thread runner) { + synchronized (lifecycleLock) { + executionEntered = true; + if (state != State.PENDING) { + return false; + } + state = State.RUNNING; + runnerThread = runner; + return true; + } + } + + void markExecutionFinished(Thread runner) { + Runnable callback; + synchronized (lifecycleLock) { + if (runnerThread == runner) { + runnerThread = null; + } + callback = markExecutionFinishedLocked(); + } + runCallback(callback); + } + + boolean completeSuccess(T value) { + synchronized (lifecycleLock) { + if (isTerminal(state)) { + return false; + } + State previous = state; + state = State.SUCCESS; + if (!future.complete(value)) { + state = previous; + return false; + } + return true; + } + } + + boolean completeFailure(Throwable failure, boolean interrupt) { + Thread runner; + Future executionToCancel; + Runnable callback = null; + synchronized (lifecycleLock) { + if (isTerminal(state)) { + return false; + } + State previous = state; + state = State.FAILED; + if (!future.completeExceptionally(failure)) { + state = previous; + return false; + } + runner = runnerThread; + executionToCancel = execution; + if (!executionEntered) { + callback = markExecutionFinishedLocked(); + } + } + if (interrupt) { + if (executionToCancel != null) { + executionToCancel.cancel(true); + } + if (runner != null) { + runner.interrupt(); + } + } + runCallback(callback); + return true; + } + + boolean completeCancelled(CancelledException cancellation) { + synchronized (lifecycleLock) { + if (isTerminal(state)) { + return false; + } + State previous = state; + state = State.CANCELLED; + if (!future.completeExceptionally(cancellation)) { + state = previous; + return false; + } + return true; + } + } + + /** Compatibility hooks used by package-level coverage tests. */ + void markRunning(Thread runner) { + beginExecution(runner); + } + void markSuccess() { - runnerThread.set(null); - state.set(State.SUCCESS); + synchronized (lifecycleLock) { + if (!isTerminal(state)) { + state = State.SUCCESS; + } + } } - /** - * 标记任务失败完成。 - */ void markFailed() { - runnerThread.set(null); - state.set(State.FAILED); + synchronized (lifecycleLock) { + if (!isTerminal(state)) { + state = State.FAILED; + } + } } - /** - * 标记任务取消完成。 - */ void markCancelled() { - runnerThread.set(null); - state.set(State.CANCELLED); + synchronized (lifecycleLock) { + if (!isTerminal(state)) { + state = State.CANCELLED; + } + } } - /** - * 中断当前运行线程(若存在)。 - */ void interruptRunner() { - Thread runner = runnerThread.get(); + Thread runner; + synchronized (lifecycleLock) { + runner = runnerThread; + } if (runner != null) { runner.interrupt(); } } - /** - * 统一异常转换并重新抛出。 - */ + boolean isExecutionFinished() { + return executionFinished.isDone(); + } + + void awaitExecutionFinished(Duration timeout) { + try { + executionFinished.get(timeout.toNanos(), TimeUnit.NANOSECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancelledException("Interrupted while waiting for task execution to finish", interrupted); + } catch (ExecutionException impossible) { + throw new IllegalStateException(impossible); + } catch (TimeoutException timeoutException) { + throw new ScopeTimeoutException("Task execution did not finish in time"); + } + } + + boolean hasRunnerThread() { + synchronized (lifecycleLock) { + return runnerThread != null; + } + } + + private Runnable markExecutionFinishedLocked() { + if (executionFinished.isDone()) { + return null; + } + executionFinished.complete(null); + Runnable callback = executionFinishedCallback; + executionFinishedCallback = null; + return callback; + } + + private static boolean isTerminal(State state) { + return state == State.SUCCESS || state == State.FAILED || state == State.CANCELLED; + } + + private static void runCallback(Runnable callback) { + if (callback != null) { + callback.run(); + } + } + private void rethrow(Throwable cause) { if (cause instanceof CancelledException) { throw (CancelledException) cause; diff --git a/src/main/java/io/threadforge/ThreadScope.java b/src/main/java/io/threadforge/ThreadScope.java index 0de1a00..d0fce0c 100644 --- a/src/main/java/io/threadforge/ThreadScope.java +++ b/src/main/java/io/threadforge/ThreadScope.java @@ -26,7 +26,6 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.function.BiConsumer; /** * ThreadForge 的结构化并发作用域。 @@ -701,36 +700,43 @@ private Task submit( final Task task = new Task(id, name, future); final TaskInfo info = new TaskInfo(scopeId, id, name, Instant.now(), scheduler.name()); final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - final ScheduledTask timeoutTask = scheduleTaskTimeout(task, info, taskTimeout); + final TaskExecution execution = new TaskExecution(task, executionContext.wrapRunnable(new Runnable() { + @Override + public void run() { + runTask(task, info, callable, taskRetryPolicy); + } + })); + task.attachExecution(execution); tasks.add(task); - future.whenComplete(new BiConsumer() { + task.whenExecutionFinished(new Runnable() { @Override - public void accept(T value, Throwable throwable) { + public void run() { tasks.remove(task); - if (timeoutTask != null) { - timeoutTask.cancel(); + if (permitAcquired && semaphore != null) { + semaphore.release(); } } }); + final ScheduledTask timeoutTask = scheduleTaskTimeout(task, info, taskTimeout); + if (timeoutTask != null) { + future.whenComplete(new java.util.function.BiConsumer() { + @Override + public void accept(T value, Throwable throwable) { + timeoutTask.cancel(); + } + }); + } try { scheduler.executor().execute(Scheduler.prioritized( - executionContext.wrapRunnable(new Runnable() { - @Override - public void run() { - runTask(task, info, callable, taskRetryPolicy, permitAcquired ? semaphore : null); - } - }), + execution, taskPriority, id )); } catch (RejectedExecutionException rejectedExecutionException) { - if (permitAcquired && semaphore != null) { - semaphore.release(); + if (task.completeFailure(rejectedExecutionException, true)) { + safeHookFailure(info, rejectedExecutionException, 0L); } - task.markFailed(); - future.completeExceptionally(rejectedExecutionException); - safeHookFailure(info, rejectedExecutionException, 0L); } return task; @@ -751,19 +757,13 @@ private void runTask( Task task, TaskInfo info, Callable callable, - RetryPolicy retryPolicy, - Semaphore acquiredSemaphore + RetryPolicy retryPolicy ) { long started = System.nanoTime(); - CompletableFuture future = task.toCompletableFuture(); try { - if (task.isCancelled() || token.isCancelled()) { - completeTaskCancelled(task, future, new CancelledException("Task cancelled before start"), info, started); - return; - } - - if (!task.markRunning(Thread.currentThread())) { + if (task.state() != Task.State.RUNNING || token.isCancelled()) { + completeTaskCancelled(task, new CancelledException("Task cancelled before start"), info, started); return; } @@ -771,50 +771,40 @@ private void runTask( token.throwIfCancelled(); T value = RetryExecutor.execute(callable, retryPolicy, token); - if (future.complete(value)) { - task.markSuccess(); + if (task.completeSuccess(value)) { safeHookSuccess(info, elapsedNanos(started)); } } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); - completeTaskCancelled(task, future, new CancelledException("Task interrupted", interruptedException), info, started); + completeTaskCancelled(task, new CancelledException("Task interrupted", interruptedException), info, started); } catch (CancelledException cancelledException) { - completeTaskCancelled(task, future, cancelledException, info, started); + completeTaskCancelled(task, cancelledException, info, started); } catch (Throwable throwable) { - completeTaskFailure(task, future, throwable, info, started); - } finally { - if (acquiredSemaphore != null) { - acquiredSemaphore.release(); - } + completeTaskFailure(task, throwable, info, started); } } private void completeTaskCancelled( Task task, - CompletableFuture future, CancelledException cancelledException, TaskInfo info, long started ) { - if (future.completeExceptionally(cancelledException)) { - task.markCancelled(); + if (task.completeCancelled(cancelledException)) { safeHookCancel(info, elapsedNanos(started)); return; } - if (future.isCancelled() || task.state() == Task.State.CANCELLED) { - task.markCancelled(); + if (task.state() == Task.State.CANCELLED) { safeHookCancel(info, elapsedNanos(started)); } } private void completeTaskFailure( Task task, - CompletableFuture future, Throwable throwable, TaskInfo info, long started ) { - if (future.completeExceptionally(throwable)) { - task.markFailed(); + if (task.completeFailure(throwable, false)) { safeHookFailure(info, throwable, elapsedNanos(started)); } } @@ -827,9 +817,7 @@ private ScheduledTask scheduleTaskTimeout(final Task task, final TaskInfo inf @Override public void run() { TaskTimeoutException timeoutException = taskTimeoutException(info, timeout); - if (task.toCompletableFuture().completeExceptionally(timeoutException)) { - task.markFailed(); - task.interruptRunner(); + if (task.completeFailure(timeoutException, true)) { dispatchHookFailure(info, timeoutException, timeout.toNanos()); } } @@ -951,6 +939,10 @@ private void ensureOpen() { } } + int trackedTaskCount() { + return tasks.size(); + } + private void ensureConfigurable() { ensureOpen(); if (configLocked.get()) { @@ -1059,6 +1051,29 @@ public void run() { } } + private static final class TaskExecution extends FutureTask { + private final Task task; + + private TaskExecution(Task task, Runnable runnable) { + super(runnable, null); + this.task = task; + } + + @Override + public void run() { + Thread runner = Thread.currentThread(); + if (!task.beginExecution(runner)) { + task.markExecutionFinished(runner); + return; + } + try { + super.run(); + } finally { + task.markExecutionFinished(runner); + } + } + } + private static final class DispatchingScheduledTask implements ScheduledTask, Runnable { private final ExecutorService executor; private final Runnable command; diff --git a/src/test/java/io/threadforge/TaskLifecycleTest.java b/src/test/java/io/threadforge/TaskLifecycleTest.java new file mode 100644 index 0000000..ba0ace1 --- /dev/null +++ b/src/test/java/io/threadforge/TaskLifecycleTest.java @@ -0,0 +1,248 @@ +package io.threadforge; + +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TaskLifecycleTest { + + @Test + void runningTaskTimeoutInterruptsRunner() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Void call() throws Exception { + started.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException expected) { + interrupted.countDown(); + throw expected; + } + return null; + } + }, Duration.ofMillis(40)); + + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertThrows(TaskTimeoutException.class, task::await); + assertTrue(interrupted.await(1L, TimeUnit.SECONDS)); + assertEquals(Task.State.FAILED, task.state()); + } + } + + @Test + void queuedTaskTimeoutPreventsCallableExecution() throws Exception { + CountDownLatch blockerStarted = new CountDownLatch(1); + CountDownLatch releaseBlocker = new CountDownLatch(1); + AtomicBoolean called = new AtomicBoolean(); + try (ThreadScope scope = ThreadScope.open().withScheduler(Scheduler.fixed(1))) { + Task blocker = scope.submit(new Callable() { + @Override + public Void call() throws Exception { + blockerStarted.countDown(); + releaseBlocker.await(); + return null; + } + }); + assertTrue(blockerStarted.await(1L, TimeUnit.SECONDS)); + + Task queued = scope.submit(new Callable() { + @Override + public Void call() { + called.set(true); + return null; + } + }, Duration.ofMillis(40)); + + assertThrows(TaskTimeoutException.class, queued::await); + releaseBlocker.countDown(); + blocker.await(); + queued.awaitExecutionFinished(Duration.ofSeconds(1)); + assertFalse(called.get()); + assertEquals(Task.State.FAILED, queued.state()); + } finally { + releaseBlocker.countDown(); + } + } + + @Test + void successfulCancelInterruptsRunner() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Void call() throws Exception { + started.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException expected) { + interrupted.countDown(); + throw expected; + } + return null; + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertTrue(task.cancel()); + assertTrue(interrupted.await(1L, TimeUnit.SECONDS)); + assertEquals(Task.State.CANCELLED, task.state()); + } + } + + @Test + void cancelCannotOverwriteSuccessfulOrFailedTerminalState() { + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.SUPERVISOR)) { + Task success = scope.submit(new Callable() { + @Override + public Integer call() { + return 1; + } + }); + Task failure = scope.submit(new Callable() { + @Override + public Integer call() { + throw new IllegalStateException("boom"); + } + }); + + scope.await(Arrays.>asList(success, failure)); + assertFalse(success.cancel()); + assertEquals(Task.State.SUCCESS, success.state()); + assertFalse(failure.cancel()); + assertEquals(Task.State.FAILED, failure.state()); + } + } + + @Test + void cancelledStateCannotBeOverwritten() { + Task task = new Task(1L, "cancelled", new CompletableFuture()); + assertTrue(task.cancel()); + task.markSuccess(); + task.markFailed(); + assertEquals(Task.State.CANCELLED, task.state()); + } + + @Test + void callableCancelledExceptionProducesStableCancelledState() { + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Void call() { + throw new CancelledException("self-cancelled"); + } + }); + assertThrows(CancelledException.class, task::await); + assertEquals(Task.State.CANCELLED, task.state()); + task.markSuccess(); + task.markFailed(); + assertEquals(Task.State.CANCELLED, task.state()); + } + } + + @RepeatedTest(50) + void timeoutCompletionAndCancelRaceHasExactlyOneTerminalWinner() throws Exception { + Task task = new Task(1L, "race", new CompletableFuture()); + Thread runner = new Thread(); + assertTrue(task.beginExecution(runner)); + ExecutorService executor = Executors.newFixedThreadPool(3); + CyclicBarrier barrier = new CyclicBarrier(3); + try { + Future success = executor.submit(() -> { + barrier.await(); + return task.completeSuccess(1); + }); + Future failure = executor.submit(() -> { + barrier.await(); + return task.completeFailure(new TaskTimeoutException("timeout"), true); + }); + Future cancel = executor.submit(() -> { + barrier.await(); + return task.cancel(); + }); + + int winners = (success.get(1L, TimeUnit.SECONDS) ? 1 : 0) + + (failure.get(1L, TimeUnit.SECONDS) ? 1 : 0) + + (cancel.get(1L, TimeUnit.SECONDS) ? 1 : 0); + assertEquals(1, winners); + assertTrue(task.isDone()); + assertTrue(task.state() == Task.State.SUCCESS + || task.state() == Task.State.FAILED + || task.state() == Task.State.CANCELLED); + } finally { + task.markExecutionFinished(runner); + executor.shutdownNow(); + } + } + + @Test + void runnerThreadIsClearedOnlyAfterExecutionExits() { + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Integer call() { + return 1; + } + }); + assertEquals(Integer.valueOf(1), task.await()); + task.awaitExecutionFinished(Duration.ofSeconds(1)); + assertTrue(task.isExecutionFinished()); + assertFalse(task.hasRunnerThread()); + } + } + + @Test + void scopeTracksTimedOutTaskUntilIgnoringRunnerActuallyExits() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch timeoutInterruptObserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Void call() { + started.countDown(); + while (true) { + try { + release.await(); + return null; + } catch (InterruptedException ignored) { + timeoutInterruptObserved.countDown(); + } + } + } + }, Duration.ofMillis(40)); + + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertThrows(TaskTimeoutException.class, task::await); + assertTrue(timeoutInterruptObserved.await(1L, TimeUnit.SECONDS)); + assertFalse(task.isExecutionFinished()); + assertEquals(1, scope.trackedTaskCount()); + + release.countDown(); + task.awaitExecutionFinished(Duration.ofSeconds(1)); + assertEquals(0, scope.trackedTaskCount()); + assertFalse(task.hasRunnerThread()); + } finally { + release.countDown(); + } + } +} From 78ed41ad909b194f31dc6b7cb2719cdc168cae60 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 19:26:25 +0800 Subject: [PATCH 03/17] fix(task): expose read-only result futures --- README.md | 2 + docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 2 + docs/ai/threadforge.mdc | 1 + docs/api/core/Task.md | 5 +- src/main/java/io/threadforge/ScopeJoiner.java | 2 +- src/main/java/io/threadforge/Task.java | 69 ++++++++- .../threadforge/TaskFutureIsolationTest.java | 133 ++++++++++++++++++ 8 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/test/java/io/threadforge/TaskFutureIsolationTest.java diff --git a/README.md b/README.md index 32bf7ad..6d821f3 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,8 @@ CompletableFuture toCompletableFuture() CompletableFuture exceptionally(Function fn) ``` +`toCompletableFuture()` 返回只读结果镜像;组合操作保持兼容,但外部完成或取消镜像不会修改底层 `Task`。 + ### FailurePolicy - `FAIL_FAST`:首个失败直接抛出,并取消其他任务 diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index a874b14..0422946 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -18,6 +18,7 @@ try (ThreadScope scope = ThreadScope.open()) { - `ThreadScope.open().withFailurePolicy().withDeadline().withScheduler().withRetryPolicy().withConcurrencyLimit()` - `scope.submit(name, callable)` — submit a value-returning task - `scope.submit(name, runnable)` — submit a basic no-result task and receive `Task` +- `task.toCompletableFuture()` — observe/compose results only; use `task.cancel()` for cancellation - `scope.await(tasks)` / `scope.awaitAll(tasks)` — wait for completion - `scope.joiner().firstSuccess(...)` — return first successful result, cancel unfinished siblings - `scope.joiner().quorum(n, ...)` — return once `n` tasks succeed diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index 8fa035a..1646f5d 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -441,6 +441,8 @@ try (ThreadScope scope = ThreadScope.open() | `ExecutorService.submit()` | `scope.submit()` | | `CompletableFuture.get()` | `task.await()` | | `CompletableFuture.allOf()` | `scope.await(tasks)` / `scope.awaitAll(tasks)` | + +`task.toCompletableFuture()` is an observation-only mirror. Do not use it to complete or cancel the underlying task; call `task.cancel()` for framework-aware cancellation. | `ExecutorService.shutdownNow()` | `scope.close()` (try-with-resources) | | `ThreadLocal` manual propagation | `Context.put/get` (auto-propagated) | | Custom retry loops | `RetryPolicy` + `scope.withRetryPolicy()` | diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index f197502..a6ccb8d 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -126,6 +126,7 @@ String id = Context.get("traceId"); // "req-1001" - `ExecutorService.submit()` → `scope.submit()` - `CompletableFuture.get()` → `task.await()` - `CompletableFuture.allOf()` → `scope.awaitAll(tasks)` +- `task.toCompletableFuture()` is observation-only; use `task.cancel()` to cancel the task - `shutdownNow()` → `scope.close()` (try-with-resources) - Manual retry loops → `RetryPolicy` - Manual timeout → `withDeadline()` or per-task `Duration` diff --git a/docs/api/core/Task.md b/docs/api/core/Task.md index bc442f8..49740b8 100644 --- a/docs/api/core/Task.md +++ b/docs/api/core/Task.md @@ -54,7 +54,8 @@ ### `CompletableFuture toCompletableFuture()` -暴露底层 `CompletableFuture`,便于与生态 API 互操作。 +返回只读的结果镜像,便于与生态 API 互操作。任务的成功、失败和取消会传播到镜像; +对镜像调用 `complete`、`completeExceptionally` 或 `cancel` 不会修改底层任务。 ## 取消 @@ -62,7 +63,7 @@ 请求取消任务。 -- 会将状态设为 `CANCELLED` +- 仅在赢得终态竞争时将状态设为 `CANCELLED` - 若任务正在运行,会中断运行线程 - 返回值语义与 `CompletableFuture.cancel(true)` 一致 diff --git a/src/main/java/io/threadforge/ScopeJoiner.java b/src/main/java/io/threadforge/ScopeJoiner.java index 1c6ad82..b43f0ff 100644 --- a/src/main/java/io/threadforge/ScopeJoiner.java +++ b/src/main/java/io/threadforge/ScopeJoiner.java @@ -136,7 +136,7 @@ private void launch(Callable callable) { synchronized (monitor) { tasks.add(task); } - task.toCompletableFuture().whenComplete(new java.util.function.BiConsumer() { + task.internalFuture().whenComplete(new java.util.function.BiConsumer() { @Override public void accept(T value, Throwable throwable) { handleCompletion(task, value, throwable); diff --git a/src/main/java/io/threadforge/Task.java b/src/main/java/io/threadforge/Task.java index 21992e0..33d7b9a 100644 --- a/src/main/java/io/threadforge/Task.java +++ b/src/main/java/io/threadforge/Task.java @@ -7,6 +7,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; import java.util.function.Function; /** @@ -40,6 +41,7 @@ public enum State { private final long id; private final String name; private final CompletableFuture future; + private final ReadOnlyCompletableFuture observer; private final Object lifecycleLock; private final CompletableFuture executionFinished; private State state; @@ -53,9 +55,22 @@ public enum State { this.id = id; this.name = name; this.future = future; + this.observer = new ReadOnlyCompletableFuture(); this.lifecycleLock = new Object(); this.executionFinished = new CompletableFuture(); this.state = State.PENDING; + future.whenComplete(new BiConsumer() { + @Override + public void accept(T value, Throwable failure) { + if (failure == null) { + observer.completeFromTask(value); + } else if (Task.this.future.isCancelled()) { + observer.cancelFromTask(); + } else { + observer.failFromTask(failure); + } + } + }); } /** 任务 ID(在同一个 scope 内单调递增)。 */ @@ -163,25 +178,30 @@ T await(Duration timeout) { } /** - * 暴露底层 {@link CompletableFuture},用于与外部 API 互操作。 + * 返回只读的结果镜像,用于与外部 {@link CompletableFuture} API 互操作。 + * 外部完成或取消镜像不会修改底层任务。 */ public CompletableFuture toCompletableFuture() { - return future; + return observer; } /** 任务成功后做同步映射。 */ public CompletableFuture thenApply(Function function) { - return future.thenApply(function); + return observer.thenApply(function); } /** 任务成功后做异步映射。 */ public CompletableFuture thenCompose(Function> function) { - return future.thenCompose(function); + return observer.thenCompose(function); } /** 任务异常完成时提供兜底值映射。 */ public CompletableFuture exceptionally(Function function) { - return future.exceptionally(function); + return observer.exceptionally(function); + } + + CompletableFuture internalFuture() { + return future; } void attachExecution(Future execution) { @@ -384,4 +404,43 @@ private void rethrow(Throwable cause) { } throw new TaskExecutionException("Task execution failed", cause); } + + private static final class ReadOnlyCompletableFuture extends CompletableFuture { + private boolean completeFromTask(V value) { + return super.complete(value); + } + + private boolean failFromTask(Throwable failure) { + return super.completeExceptionally(failure); + } + + private boolean cancelFromTask() { + return super.cancel(false); + } + + @Override + public boolean complete(V value) { + return false; + } + + @Override + public boolean completeExceptionally(Throwable failure) { + return false; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + + @Override + public void obtrudeValue(V value) { + throw new UnsupportedOperationException("Task result Future is read-only"); + } + + @Override + public void obtrudeException(Throwable failure) { + throw new UnsupportedOperationException("Task result Future is read-only"); + } + } } diff --git a/src/test/java/io/threadforge/TaskFutureIsolationTest.java b/src/test/java/io/threadforge/TaskFutureIsolationTest.java new file mode 100644 index 0000000..1696949 --- /dev/null +++ b/src/test/java/io/threadforge/TaskFutureIsolationTest.java @@ -0,0 +1,133 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TaskFutureIsolationTest { + + @Test + void externalCompletionCannotMutateUnderlyingTask() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Integer call() throws Exception { + started.countDown(); + release.await(); + return 7; + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + + CompletableFuture observer = task.toCompletableFuture(); + assertFalse(observer.complete(99)); + assertFalse(observer.completeExceptionally(new IllegalStateException("fake"))); + assertFalse(task.isDone()); + + release.countDown(); + assertEquals(Integer.valueOf(7), task.await()); + assertEquals(Integer.valueOf(7), observer.join()); + } finally { + release.countDown(); + } + } + + @Test + void externalCancellationDoesNotInterruptOrCancelUnderlyingTask() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Integer call() throws Exception { + started.countDown(); + try { + release.await(); + } catch (InterruptedException failure) { + interrupted.set(true); + throw failure; + } + return 5; + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + + assertFalse(task.toCompletableFuture().cancel(true)); + assertFalse(task.isCancelled()); + assertFalse(interrupted.get()); + + release.countDown(); + assertEquals(Integer.valueOf(5), task.await()); + } finally { + release.countDown(); + } + } + + @Test + void underlyingTerminalResultsPropagateToObserver() { + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.SUPERVISOR)) { + Task success = scope.submit(() -> 3); + Task failure = scope.submit(new Callable() { + @Override + public Integer call() { + throw new IllegalArgumentException("boom"); + } + }); + CountDownLatch started = new CountDownLatch(1); + Task cancelled = scope.submit(new Callable() { + @Override + public Integer call() throws Exception { + started.countDown(); + new CountDownLatch(1).await(); + return 1; + } + }); + try { + assertTrue(started.await(1L, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + + CompletableFuture successObserver = success.toCompletableFuture(); + CompletableFuture failureObserver = failure.toCompletableFuture(); + CompletableFuture cancelledObserver = cancelled.toCompletableFuture(); + assertTrue(cancelled.cancel()); + + assertEquals(Integer.valueOf(3), successObserver.join()); + assertThrows(java.util.concurrent.CompletionException.class, failureObserver::join); + assertThrows(java.util.concurrent.CancellationException.class, cancelledObserver::join); + assertTrue(cancelledObserver.isCancelled()); + } + } + + @Test + void compositionMethodsRetainCompletableFutureBehavior() { + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(() -> 2); + assertEquals(Integer.valueOf(3), task.thenApply(value -> value + 1).join()); + assertEquals(Integer.valueOf(4), task.thenCompose(value -> + CompletableFuture.completedFuture(value * 2)).join()); + + Task failure = scope.submit(new Callable() { + @Override + public Integer call() { + throw new IllegalStateException("boom"); + } + }); + assertEquals(Integer.valueOf(9), failure.exceptionally(ignored -> 9).join()); + } + } +} From db18fb16f81cdc02ca395cae6e5085f5c41c6bdb Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 19:53:56 +0800 Subject: [PATCH 04/17] fix(channel): make channel operations interruptible --- docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 2 + docs/ai/threadforge.mdc | 1 + docs/api/dataflow/Channel.md | 2 + src/main/java/io/threadforge/Channel.java | 36 +++--- .../threadforge/ChannelInterruptionTest.java | 104 ++++++++++++++++++ 6 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 src/test/java/io/threadforge/ChannelInterruptionTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index 0422946..e1fc3e4 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -19,6 +19,7 @@ try (ThreadScope scope = ThreadScope.open()) { - `scope.submit(name, callable)` — submit a value-returning task - `scope.submit(name, runnable)` — submit a basic no-result task and receive `Task` - `task.toCompletableFuture()` — observe/compose results only; use `task.cancel()` for cancellation +- `Channel.send/receive` — blocking waits are interruptible and throw `CancelledException` - `scope.await(tasks)` / `scope.awaitAll(tasks)` — wait for completion - `scope.joiner().firstSuccess(...)` — return first successful result, cancel unfinished siblings - `scope.joiner().quorum(n, ...)` — return once `n` tasks succeed diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index 1646f5d..ee57610 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -236,6 +236,8 @@ channel.close(); // Signal no more sends // Iterable: for (T v : channel) { ... } stops after closed and drained ``` +Blocked `send`/`receive` calls are interruptible and surface cancellation as `CancelledException` while preserving the interrupt flag. + ### Scheduling ```java diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index a6ccb8d..3fb1588 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -127,6 +127,7 @@ String id = Context.get("traceId"); // "req-1001" - `CompletableFuture.get()` → `task.await()` - `CompletableFuture.allOf()` → `scope.awaitAll(tasks)` - `task.toCompletableFuture()` is observation-only; use `task.cancel()` to cancel the task +- Blocking `Channel.send/receive` calls are interruptible and throw `CancelledException` - `shutdownNow()` → `scope.close()` (try-with-resources) - Manual retry loops → `RetryPolicy` - Manual timeout → `withDeadline()` or per-task `Duration` diff --git a/docs/api/dataflow/Channel.md b/docs/api/dataflow/Channel.md index c923212..bc5f25d 100644 --- a/docs/api/dataflow/Channel.md +++ b/docs/api/dataflow/Channel.md @@ -26,6 +26,7 @@ 发送一个元素。 - 缓冲满时阻塞 +- 等待线程被中断时保留中断标记并抛 `CancelledException` - 通道关闭后抛 `ChannelClosedException` ### `T receive()` @@ -33,6 +34,7 @@ 接收一个元素。 - 缓冲空且未关闭时阻塞 +- 等待线程被中断时保留中断标记并抛 `CancelledException` - 通道已关闭且已耗尽时抛 `ChannelClosedException` ### `void close()` diff --git a/src/main/java/io/threadforge/Channel.java b/src/main/java/io/threadforge/Channel.java index f9104ff..0f5b94c 100644 --- a/src/main/java/io/threadforge/Channel.java +++ b/src/main/java/io/threadforge/Channel.java @@ -40,10 +40,10 @@ public static Channel bounded(int capacity) { * Throws {@link ChannelClosedException} if the channel has been closed. */ public void send(T value) { - lock.lock(); + lockInterruptibly(); try { while (queue.size() >= capacity && !closed) { - awaitUninterruptibly(notFull); + await(notFull); } if (closed) { throw new ChannelClosedException("Channel is closed"); @@ -60,10 +60,10 @@ public void send(T value) { * When channel is closed and drained, throws {@link ChannelClosedException}. */ public T receive() { - lock.lock(); + lockInterruptibly(); try { while (queue.isEmpty() && !closed) { - awaitUninterruptibly(notEmpty); + await(notEmpty); } if (queue.isEmpty() && closed) { throw new ChannelClosedException("Channel is closed and drained"); @@ -127,21 +127,21 @@ public T next() { }; } - private static void awaitUninterruptibly(Condition condition) { - boolean interrupted = false; + private void lockInterruptibly() { try { - while (true) { - try { - condition.await(); - return; - } catch (InterruptedException e) { - interrupted = true; - } - } - } finally { - if (interrupted) { - Thread.currentThread().interrupt(); - } + lock.lockInterruptibly(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancelledException("Interrupted while waiting for channel lock", interrupted); + } + } + + private static void await(Condition condition) { + try { + condition.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancelledException("Interrupted while waiting on channel", interrupted); } } } diff --git a/src/test/java/io/threadforge/ChannelInterruptionTest.java b/src/test/java/io/threadforge/ChannelInterruptionTest.java new file mode 100644 index 0000000..5dc33c7 --- /dev/null +++ b/src/test/java/io/threadforge/ChannelInterruptionTest.java @@ -0,0 +1,104 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChannelInterruptionTest { + + @Test + void cancellingBlockedReceiverInterruptsAndExitsRunner() throws Exception { + Channel channel = Channel.bounded(1); + CountDownLatch started = new CountDownLatch(1); + AtomicBoolean interruptPreserved = new AtomicBoolean(); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Integer call() { + started.countDown(); + try { + return channel.receive(); + } catch (CancelledException expected) { + interruptPreserved.set(Thread.currentThread().isInterrupted()); + throw expected; + } + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertTrue(task.cancel()); + task.awaitExecutionFinished(Duration.ofMillis(500)); + assertTrue(interruptPreserved.get()); + } finally { + channel.close(); + } + } + + @Test + void cancellingBlockedSenderInterruptsAndExitsRunner() throws Exception { + Channel channel = Channel.bounded(1); + channel.send(1); + CountDownLatch started = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(new Callable() { + @Override + public Void call() { + started.countDown(); + channel.send(2); + return null; + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertTrue(task.cancel()); + task.awaitExecutionFinished(Duration.ofMillis(500)); + } finally { + channel.close(); + } + } + + @Test + void scopeDeadlineTerminatesChannelWait() throws Exception { + Channel channel = Channel.bounded(1); + CountDownLatch started = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open().withDeadline(Duration.ofMillis(50))) { + Task task = scope.submit(new Callable() { + @Override + public Integer call() { + started.countDown(); + return channel.receive(); + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertThrows(ScopeTimeoutException.class, () -> scope.await(task)); + task.awaitExecutionFinished(Duration.ofMillis(500)); + } finally { + channel.close(); + } + } + + @Test + void closedChannelDrainsBufferBeforeIteratorEnds() { + Channel channel = Channel.bounded(3); + channel.send(1); + channel.send(2); + channel.close(); + + assertEquals(Integer.valueOf(1), channel.receive()); + List remaining = new ArrayList(); + for (Integer value : channel) { + remaining.add(value); + } + assertEquals(Arrays.asList(2), remaining); + assertThrows(ChannelClosedException.class, channel::receive); + } +} From 92463b54f55758559a4116fdc32597e50238f4f0 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 20:00:02 +0800 Subject: [PATCH 05/17] fix(scope): implement completion-order fail-fast --- docs/ai/threadforge-agents.md | 2 +- docs/ai/threadforge.SKILL.md | 2 +- docs/ai/threadforge.mdc | 2 +- docs/api/control/FailurePolicy.md | 2 +- src/main/java/io/threadforge/ThreadScope.java | 55 ++++++ .../FailurePolicyCompletionOrderTest.java | 157 ++++++++++++++++++ 6 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/threadforge/FailurePolicyCompletionOrderTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index e1fc3e4..67e999f 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -32,7 +32,7 @@ try (ThreadScope scope = ThreadScope.open()) { ## FailurePolicy -- `FAIL_FAST` (default) — first failure cancels all, throws +- `FAIL_FAST` (default) — first completed failure cancels all, throws - `SUPERVISOR` — no auto-cancel, check `Outcome.hasFailures()` - `COLLECT_ALL` — wait all, throw `AggregateException` - `CANCEL_OTHERS` — cancel siblings, don't throw diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index ee57610..0c90c48 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -171,7 +171,7 @@ Joiner rules: | Policy | Behavior | Use Case | |---|---|---| -| `FAIL_FAST` | First failure throws, cancel remaining | Default; fail-fast pipelines | +| `FAIL_FAST` | First completed failure throws, cancel remaining | Default; fail-fast pipelines | | `COLLECT_ALL` | Wait all, throw `AggregateException` if any failed | Batch jobs needing all results | | `SUPERVISOR` | Never auto-cancel; failures in `Outcome` | Independent tasks, no cascading | | `CANCEL_OTHERS` | Cancel siblings on failure, don't throw | Fan-out with graceful degradation | diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index 3fb1588..03cdfbc 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -39,7 +39,7 @@ try (ThreadScope scope = ThreadScope.open() | Policy | Behavior | |---|---| -| `FAIL_FAST` | First failure throws, cancel remaining | +| `FAIL_FAST` | First completed failure throws, cancel remaining | | `COLLECT_ALL` | Wait all, throw `AggregateException` if any failed | | `SUPERVISOR` | Never auto-cancel; failures in `Outcome` | | `CANCEL_OTHERS` | Cancel siblings on failure, don't throw | diff --git a/docs/api/control/FailurePolicy.md b/docs/api/control/FailurePolicy.md index 2361006..f748d40 100644 --- a/docs/api/control/FailurePolicy.md +++ b/docs/api/control/FailurePolicy.md @@ -13,7 +13,7 @@ ### `FAIL_FAST` -- 行为:首个失败立即抛出,取消其余任务 +- 行为:按实际完成顺序观察任务;首个失败立即抛出并取消其余任务 - 适用:强一致聚合、任意子任务失败即整体失败 ### `COLLECT_ALL` diff --git a/src/main/java/io/threadforge/ThreadScope.java b/src/main/java/io/threadforge/ThreadScope.java index d0fce0c..536839e 100644 --- a/src/main/java/io/threadforge/ThreadScope.java +++ b/src/main/java/io/threadforge/ThreadScope.java @@ -15,11 +15,13 @@ import java.util.Objects; import java.util.Queue; import java.util.concurrent.Callable; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; @@ -468,6 +470,9 @@ public Outcome await(Collection> awaitedTasks) { if (taskList.isEmpty()) { return new Outcome(0, 0, 0, Collections.emptyList()); } + if (failurePolicy == FailurePolicy.FAIL_FAST) { + return awaitFailFast(taskList); + } int succeeded = 0; int cancelled = 0; @@ -516,6 +521,56 @@ public Outcome await(Collection> awaitedTasks) { return new Outcome(taskList.size(), succeeded, cancelled, failures); } + private Outcome awaitFailFast(final List> taskList) { + final BlockingQueue> completions = new LinkedBlockingQueue>(); + for (final Task task : taskList) { + task.internalFuture().whenComplete(new java.util.function.BiConsumer() { + @Override + public void accept(Object value, Throwable failure) { + completions.offer(task); + } + }); + } + + int succeeded = 0; + int cancelled = 0; + for (int completed = 0; completed < taskList.size(); completed++) { + Task task = takeCompletedTask(completions); + try { + task.await(); + succeeded++; + } catch (CancelledException cancellation) { + cancelled++; + } catch (RuntimeException failure) { + cancelOthers(taskList, task); + throw failure; + } catch (Error failure) { + cancelOthers(taskList, task); + throw failure; + } + } + + if (deadlineTriggered) { + throw new ScopeTimeoutException("ThreadScope deadline exceeded"); + } + return new Outcome(taskList.size(), succeeded, cancelled, Collections.emptyList()); + } + + private Task takeCompletedTask(BlockingQueue> completions) { + try { + Duration remaining = remainingDeadline(); + Task completed = completions.poll(remaining.toNanos(), TimeUnit.NANOSECONDS); + if (completed == null) { + triggerDeadline(); + throw new ScopeTimeoutException("ThreadScope deadline exceeded"); + } + return completed; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new CancelledException("Interrupted while waiting for scope tasks", interrupted); + } + } + public Outcome await(Task first, Task... rest) { Objects.requireNonNull(first, "first"); Objects.requireNonNull(rest, "rest"); diff --git a/src/test/java/io/threadforge/FailurePolicyCompletionOrderTest.java b/src/test/java/io/threadforge/FailurePolicyCompletionOrderTest.java new file mode 100644 index 0000000..293eb6d --- /dev/null +++ b/src/test/java/io/threadforge/FailurePolicyCompletionOrderTest.java @@ -0,0 +1,157 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FailurePolicyCompletionOrderTest { + + @Test + void failFastObservesFailureBeforeEarlierSlowTask() throws Exception { + assertFailFastCompletionOrder(false); + } + + @Test + void failFastIsIndependentOfCollectionOrder() throws Exception { + assertFailFastCompletionOrder(true); + } + + @Test + void collectAllWaitsAndAggregatesFailures() throws Exception { + CountDownLatch release = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.COLLECT_ALL)) { + Task slow = scope.submit(() -> { + release.await(); + return 1; + }); + Task failed = scope.submit(new Callable() { + @Override + public Integer call() { + throw new IllegalStateException("boom"); + } + }); + release.countDown(); + AggregateException aggregate = assertThrows(AggregateException.class, + () -> scope.await(slow, failed)); + assertEquals(1, aggregate.failures().size()); + assertEquals(Task.State.SUCCESS, slow.state()); + } finally { + release.countDown(); + } + } + + @Test + void supervisorCollectsRealStatesWithoutCancellingSiblings() throws Exception { + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.SUPERVISOR)) { + Task success = scope.submit(() -> 1); + Task failed = scope.submit(new Callable() { + @Override + public Integer call() { + throw new IllegalArgumentException("bad"); + } + }); + Outcome outcome = scope.await(success, failed); + assertEquals(2, outcome.total()); + assertEquals(1, outcome.succeeded()); + assertEquals(1, outcome.failed()); + assertEquals(0, outcome.cancelled()); + } + } + + @Test + void cancelOthersCancelsStillRunningSiblingAndReturnsFailure() throws Exception { + CountDownLatch slowStarted = new CountDownLatch(1); + CountDownLatch slowInterrupted = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.CANCEL_OTHERS)) { + Task slow = scope.submit(blockingTask(slowStarted, slowInterrupted)); + assertTrue(slowStarted.await(1L, TimeUnit.SECONDS)); + Task failed = scope.submit(new Callable() { + @Override + public Integer call() { + throw new IllegalStateException("boom"); + } + }); + + Outcome outcome = scope.await(failed, slow); + assertEquals(1, outcome.failed()); + assertEquals(1, outcome.cancelled()); + assertTrue(slowInterrupted.await(1L, TimeUnit.SECONDS)); + } + } + + @Test + void ignoreAllReturnsNoFailuresWithoutCancellingTasks() { + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.IGNORE_ALL)) { + Task success = scope.submit(() -> 1); + Task failed = scope.submit(new Callable() { + @Override + public Integer call() { + throw new IllegalStateException("ignored"); + } + }); + Outcome outcome = scope.await(success, failed); + assertEquals(1, outcome.succeeded()); + assertFalse(outcome.hasFailures()); + assertEquals(Task.State.FAILED, failed.state()); + } + } + + private static void assertFailFastCompletionOrder(boolean failedFirstInCollection) throws Exception { + CountDownLatch slowStarted = new CountDownLatch(1); + CountDownLatch slowInterrupted = new CountDownLatch(1); + CountDownLatch failureThrown = new CountDownLatch(1); + ExecutorService waiter = Executors.newSingleThreadExecutor(); + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.FAIL_FAST)) { + Task slow = scope.submit(blockingTask(slowStarted, slowInterrupted)); + assertTrue(slowStarted.await(1L, TimeUnit.SECONDS)); + Task failed = scope.submit(new Callable() { + @Override + public Integer call() { + failureThrown.countDown(); + throw new IllegalStateException("fast-failure"); + } + }); + assertTrue(failureThrown.await(1L, TimeUnit.SECONDS)); + + Future awaiting = waiter.submit(() -> failedFirstInCollection + ? scope.await(Arrays.>asList(failed, slow)) + : scope.await(Arrays.>asList(slow, failed))); + ExecutionException wrapper = assertThrows(ExecutionException.class, + () -> awaiting.get(500L, TimeUnit.MILLISECONDS)); + assertTrue(wrapper.getCause() instanceof IllegalStateException); + assertEquals("fast-failure", wrapper.getCause().getMessage()); + assertTrue(slowInterrupted.await(1L, TimeUnit.SECONDS)); + assertEquals(Task.State.CANCELLED, slow.state()); + } finally { + waiter.shutdownNow(); + } + } + + private static Callable blockingTask(CountDownLatch started, CountDownLatch interrupted) { + return new Callable() { + @Override + public Integer call() throws Exception { + started.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException expected) { + interrupted.countDown(); + throw expected; + } + return 1; + } + }; + } +} From 314e1c2f8c65a0b1c5549d0cc41281dfa70b7577 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 20:04:06 +0800 Subject: [PATCH 06/17] fix(scope): propagate await interruption correctly --- docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 4 +- docs/ai/threadforge.mdc | 1 + docs/api/core/ThreadScope.md | 1 + src/main/java/io/threadforge/ThreadScope.java | 3 + .../io/threadforge/AwaitInterruptionTest.java | 102 ++++++++++++++++++ 6 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/test/java/io/threadforge/AwaitInterruptionTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index 67e999f..66ce2a5 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -20,6 +20,7 @@ try (ThreadScope scope = ThreadScope.open()) { - `scope.submit(name, runnable)` — submit a basic no-result task and receive `Task` - `task.toCompletableFuture()` — observe/compose results only; use `task.cancel()` for cancellation - `Channel.send/receive` — blocking waits are interruptible and throw `CancelledException` +- `task.await()` / `scope.await(...)` — caller interruption propagates without changing target task states - `scope.await(tasks)` / `scope.awaitAll(tasks)` — wait for completion - `scope.joiner().firstSuccess(...)` — return first successful result, cancel unfinished siblings - `scope.joiner().quorum(n, ...)` — return once `n` tasks succeed diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index 0c90c48..22804d5 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -444,8 +444,10 @@ try (ThreadScope scope = ThreadScope.open() | `CompletableFuture.get()` | `task.await()` | | `CompletableFuture.allOf()` | `scope.await(tasks)` / `scope.awaitAll(tasks)` | -`task.toCompletableFuture()` is an observation-only mirror. Do not use it to complete or cancel the underlying task; call `task.cancel()` for framework-aware cancellation. | `ExecutorService.shutdownNow()` | `scope.close()` (try-with-resources) | | `ThreadLocal` manual propagation | `Context.put/get` (auto-propagated) | | Custom retry loops | `RetryPolicy` + `scope.withRetryPolicy()` | | Manual timeout management | `scope.withDeadline()` or per-task `Duration` timeout | + +`task.toCompletableFuture()` is an observation-only mirror. Do not use it to complete or cancel the underlying task; call `task.cancel()` for framework-aware cancellation. +Interrupting a thread blocked in `task.await()` or `scope.await(...)` throws `CancelledException` without changing target task states. diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index 03cdfbc..4c69b15 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -128,6 +128,7 @@ String id = Context.get("traceId"); // "req-1001" - `CompletableFuture.allOf()` → `scope.awaitAll(tasks)` - `task.toCompletableFuture()` is observation-only; use `task.cancel()` to cancel the task - Blocking `Channel.send/receive` calls are interruptible and throw `CancelledException` +- Interrupting an `await` caller does not mark target tasks cancelled - `shutdownNow()` → `scope.close()` (try-with-resources) - Manual retry loops → `RetryPolicy` - Manual timeout → `withDeadline()` or per-task `Duration` diff --git a/docs/api/core/ThreadScope.md b/docs/api/core/ThreadScope.md index ab71779..81a43d9 100644 --- a/docs/api/core/ThreadScope.md +++ b/docs/api/core/ThreadScope.md @@ -257,6 +257,7 @@ try (ThreadScope scope = ThreadScope.open() - `TaskTimeoutException`:任务级超时 - `RuntimeException`:`FAIL_FAST` 下的首个失败 - `AggregateException`:`COLLECT_ALL` 下有失败 +- 等待线程被中断时保留中断标记并立即抛 `CancelledException`,不会修改目标任务状态 ### `Outcome await(Task first, Task... rest)` diff --git a/src/main/java/io/threadforge/ThreadScope.java b/src/main/java/io/threadforge/ThreadScope.java index 536839e..12f8bb4 100644 --- a/src/main/java/io/threadforge/ThreadScope.java +++ b/src/main/java/io/threadforge/ThreadScope.java @@ -491,6 +491,9 @@ public Outcome await(Collection> awaitedTasks) { triggerDeadline(); throw new ScopeTimeoutException("ThreadScope deadline exceeded"); } catch (CancelledException cancelledException) { + if (Thread.currentThread().isInterrupted()) { + throw cancelledException; + } cancelled++; } catch (RuntimeException failure) { if (failurePolicy == FailurePolicy.FAIL_FAST) { diff --git a/src/test/java/io/threadforge/AwaitInterruptionTest.java b/src/test/java/io/threadforge/AwaitInterruptionTest.java new file mode 100644 index 0000000..d87a420 --- /dev/null +++ b/src/test/java/io/threadforge/AwaitInterruptionTest.java @@ -0,0 +1,102 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AwaitInterruptionTest { + + @Test + void taskAwaitPropagatesWaiterInterruptionWithoutChangingTask() throws Exception { + CountDownLatch taskStarted = new CountDownLatch(1); + CountDownLatch releaseTask = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open()) { + Task task = scope.submit(blockingTask(taskStarted, releaseTask)); + assertTrue(taskStarted.await(1L, TimeUnit.SECONDS)); + + WaitResult result = interruptWaiter(new Runnable() { + @Override + public void run() { + task.await(); + } + }); + + assertTrue(result.failure.get() instanceof CancelledException); + assertTrue(result.interruptPreserved.get()); + assertEquals(Task.State.RUNNING, task.state()); + } finally { + releaseTask.countDown(); + } + } + + @Test + void scopeAwaitPropagatesWaiterInterruptionWithoutFalseOutcome() throws Exception { + CountDownLatch tasksStarted = new CountDownLatch(2); + CountDownLatch releaseTasks = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.SUPERVISOR)) { + Task first = scope.submit(blockingTask(tasksStarted, releaseTasks)); + Task second = scope.submit(blockingTask(tasksStarted, releaseTasks)); + assertTrue(tasksStarted.await(1L, TimeUnit.SECONDS)); + + WaitResult result = interruptWaiter(new Runnable() { + @Override + public void run() { + scope.await(first, second); + } + }); + + assertTrue(result.failure.get() instanceof CancelledException); + assertTrue(result.interruptPreserved.get()); + assertEquals(Task.State.RUNNING, first.state()); + assertEquals(Task.State.RUNNING, second.state()); + } finally { + releaseTasks.countDown(); + } + } + + private static Callable blockingTask(CountDownLatch started, CountDownLatch release) { + return new Callable() { + @Override + public Void call() throws Exception { + started.countDown(); + release.await(); + return null; + } + }; + } + + private static WaitResult interruptWaiter(Runnable await) throws Exception { + WaitResult result = new WaitResult(); + CountDownLatch waiterReady = new CountDownLatch(1); + Thread waiter = new Thread(new Runnable() { + @Override + public void run() { + waiterReady.countDown(); + try { + await.run(); + } catch (Throwable failure) { + result.failure.set(failure); + result.interruptPreserved.set(Thread.currentThread().isInterrupted()); + } + } + }); + waiter.start(); + assertTrue(waiterReady.await(1L, TimeUnit.SECONDS)); + waiter.interrupt(); + waiter.join(1000L); + assertTrue(!waiter.isAlive()); + return result; + } + + private static final class WaitResult { + private final AtomicReference failure = new AtomicReference(); + private final AtomicBoolean interruptPreserved = new AtomicBoolean(); + } +} From 6f412682be18ddfc6008822eb204ad1f6ba2fc5e Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 20:14:40 +0800 Subject: [PATCH 07/17] fix(scope): wait for physical task termination on close --- README.md | 2 + docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 1 + docs/ai/threadforge.mdc | 1 + docs/api/core/ThreadScope.md | 8 +- src/main/java/io/threadforge/Task.java | 26 ++- src/main/java/io/threadforge/ThreadScope.java | 71 ++++++- .../threadforge/SchedulerIsolationTest.java | 10 +- .../java/io/threadforge/ScopeCloseTest.java | 193 ++++++++++++++++++ 9 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 src/test/java/io/threadforge/ScopeCloseTest.java diff --git a/README.md b/README.md index 6d821f3..5cd27c1 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,8 @@ Scheduler.priority(int size) scope.defer(() -> resource.close()); ``` +`close()` 会等待已启动工作真正退出后再执行 deferred cleanup。忽略中断的用户代码会让关闭继续等待,直到代码自行结束。 + ### Task ```java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index 66ce2a5..d78304e 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -21,6 +21,7 @@ try (ThreadScope scope = ThreadScope.open()) { - `task.toCompletableFuture()` — observe/compose results only; use `task.cancel()` for cancellation - `Channel.send/receive` — blocking waits are interruptible and throw `CancelledException` - `task.await()` / `scope.await(...)` — caller interruption propagates without changing target task states +- `scope.close()` — waits for started work to exit before deferred cleanup; interruption-ignoring code keeps it blocked - `scope.await(tasks)` / `scope.awaitAll(tasks)` — wait for completion - `scope.joiner().firstSuccess(...)` — return first successful result, cancel unfinished siblings - `scope.joiner().quorum(n, ...)` — return once `n` tasks succeed diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index 22804d5..003dfc1 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -451,3 +451,4 @@ try (ThreadScope scope = ThreadScope.open() `task.toCompletableFuture()` is an observation-only mirror. Do not use it to complete or cancel the underlying task; call `task.cancel()` for framework-aware cancellation. Interrupting a thread blocked in `task.await()` or `scope.await(...)` throws `CancelledException` without changing target task states. +`scope.close()` waits for started work to physically exit before deferred cleanup; code that ignores interruption keeps close blocked. diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index 4c69b15..b03bb80 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -129,6 +129,7 @@ String id = Context.get("traceId"); // "req-1001" - `task.toCompletableFuture()` is observation-only; use `task.cancel()` to cancel the task - Blocking `Channel.send/receive` calls are interruptible and throw `CancelledException` - Interrupting an `await` caller does not mark target tasks cancelled +- `scope.close()` waits for started work to exit before running deferred cleanup - `shutdownNow()` → `scope.close()` (try-with-resources) - Manual retry loops → `RetryPolicy` - Manual timeout → `withDeadline()` or per-task `Duration` diff --git a/docs/api/core/ThreadScope.md b/docs/api/core/ThreadScope.md index 81a43d9..37e8dcc 100644 --- a/docs/api/core/ThreadScope.md +++ b/docs/api/core/ThreadScope.md @@ -307,13 +307,15 @@ try (ThreadScope scope = ThreadScope.open() 1. 触发 token 取消 2. 取消全部计划任务 3. 取消全部未完成任务 -4. 依次执行 `defer` 清理(LIFO) -5. 取消截止时间监控任务 -6. 关闭由 scope 持有的执行器 +4. 等待已启动的任务和计划工作真正退出 +5. 依次执行 `defer` 清理(LIFO) +6. 取消截止时间监控任务 +7. 关闭由 scope 持有的执行器 异常语义: - `close()` 本身是幂等的 +- 用户代码若忽略中断,`close()` 会继续等待,直到该代码自行退出;框架不会把逻辑取消伪装成物理结束 - 清理过程中的异常会聚合(通过 `addSuppressed`)后抛出 - 如果主流程已有异常(比如 try-with-resources 体内抛错),清理异常会以 suppressed 形式附加 diff --git a/src/main/java/io/threadforge/Task.java b/src/main/java/io/threadforge/Task.java index 33d7b9a..ec9808f 100644 --- a/src/main/java/io/threadforge/Task.java +++ b/src/main/java/io/threadforge/Task.java @@ -132,8 +132,7 @@ public boolean cancel() { } if (executionToCancel != null) { executionToCancel.cancel(true); - } - if (runner != null) { + } else if (runner != null) { runner.interrupt(); } runCallback(callback); @@ -286,8 +285,7 @@ boolean completeFailure(Throwable failure, boolean interrupt) { if (interrupt) { if (executionToCancel != null) { executionToCancel.cancel(true); - } - if (runner != null) { + } else if (runner != null) { runner.interrupt(); } } @@ -366,6 +364,26 @@ void awaitExecutionFinished(Duration timeout) { } } + void awaitExecutionFinished() { + boolean interrupted = false; + try { + while (true) { + try { + executionFinished.get(); + return; + } catch (InterruptedException ignored) { + interrupted = true; + } catch (ExecutionException impossible) { + throw new IllegalStateException(impossible); + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + boolean hasRunnerThread() { synchronized (lifecycleLock) { return runnerThread != null; diff --git a/src/main/java/io/threadforge/ThreadScope.java b/src/main/java/io/threadforge/ThreadScope.java index 12f8bb4..92ba5c2 100644 --- a/src/main/java/io/threadforge/ThreadScope.java +++ b/src/main/java/io/threadforge/ThreadScope.java @@ -686,6 +686,7 @@ public void close() { } Throwable primary = null; + List> closingTasks = new ArrayList>(tasks); token.cancel(); @@ -697,7 +698,7 @@ public void close() { } } - for (Task task : tasks) { + for (Task task : closingTasks) { try { if (!task.isDone()) { task.cancel(); @@ -707,6 +708,16 @@ public void close() { } } + for (ScheduledTask scheduledTask : scheduledTasks) { + if (scheduledTask instanceof DispatchingScheduledTask) { + ((DispatchingScheduledTask) scheduledTask).awaitDispatchedWork(); + } + } + + for (Task task : closingTasks) { + task.awaitExecutionFinished(); + } + for (Runnable cleanup : deferred) { try { cleanup.run(); @@ -1137,6 +1148,7 @@ private static final class DispatchingScheduledTask implements ScheduledTask, Ru private final Runnable command; private final AtomicBoolean cancelled; private final Queue> dispatched; + private final Object dispatchMonitor; private volatile ScheduledTask timer; private DispatchingScheduledTask(ExecutorService executor, Runnable command) { @@ -1144,6 +1156,7 @@ private DispatchingScheduledTask(ExecutorService executor, Runnable command) { this.command = command; this.cancelled = new AtomicBoolean(); this.dispatched = new ConcurrentLinkedQueue>(); + this.dispatchMonitor = new Object(); } private void bind(ScheduledTask timer) { @@ -1155,11 +1168,14 @@ private void bind(ScheduledTask timer) { @Override public void run() { - if (cancelled.get()) { - return; + FutureTask work; + synchronized (dispatchMonitor) { + if (cancelled.get()) { + return; + } + work = new DispatchedWork(command); + dispatched.add(work); } - FutureTask work = new DispatchedWork(command); - dispatched.add(work); if (cancelled.get()) { work.cancel(true); return; @@ -1174,7 +1190,10 @@ public void run() { @Override public boolean cancel() { - boolean changed = cancelled.compareAndSet(false, true); + boolean changed; + synchronized (dispatchMonitor) { + changed = cancelled.compareAndSet(false, true); + } ScheduledTask currentTimer = timer; if (currentTimer != null) { changed |= currentTimer.cancel(); @@ -1185,6 +1204,22 @@ public boolean cancel() { return changed; } + private void awaitDispatchedWork() { + boolean interrupted = false; + synchronized (dispatchMonitor) { + while (!dispatched.isEmpty()) { + try { + dispatchMonitor.wait(); + } catch (InterruptedException ignored) { + interrupted = true; + } + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + @Override public boolean isCancelled() { return cancelled.get(); @@ -1197,13 +1232,35 @@ public boolean isDone() { } private final class DispatchedWork extends FutureTask { + private final AtomicBoolean started; + private DispatchedWork(Runnable runnable) { super(runnable, null); + this.started = new AtomicBoolean(); + } + + @Override + public void run() { + started.set(true); + try { + super.run(); + } finally { + removeDispatchedWork(this); + } } @Override protected void done() { - dispatched.remove(this); + if (!started.get()) { + removeDispatchedWork(this); + } + } + } + + private void removeDispatchedWork(FutureTask work) { + synchronized (dispatchMonitor) { + dispatched.remove(work); + dispatchMonitor.notifyAll(); } } } diff --git a/src/test/java/io/threadforge/SchedulerIsolationTest.java b/src/test/java/io/threadforge/SchedulerIsolationTest.java index 740f9eb..74a37a5 100644 --- a/src/test/java/io/threadforge/SchedulerIsolationTest.java +++ b/src/test/java/io/threadforge/SchedulerIsolationTest.java @@ -22,9 +22,10 @@ void blockingScheduledUserJobDoesNotDelayTimeoutsInOtherScopes() throws Exceptio final CountDownLatch userJobStarted = new CountDownLatch(1); final CountDownLatch releaseUserJob = new CountDownLatch(1); - try (ThreadScope blockingScope = ThreadScope.open(); - ThreadScope firstTimedScope = ThreadScope.open(); - ThreadScope secondTimedScope = ThreadScope.open()) { + ThreadScope blockingScope = ThreadScope.open(); + ThreadScope firstTimedScope = ThreadScope.open(); + ThreadScope secondTimedScope = ThreadScope.open(); + try { blockingScope.schedule(Duration.ZERO, new Runnable() { @Override public void run() { @@ -46,6 +47,9 @@ public void execute() { }); } finally { releaseUserJob.countDown(); + secondTimedScope.close(); + firstTimedScope.close(); + blockingScope.close(); } } diff --git a/src/test/java/io/threadforge/ScopeCloseTest.java b/src/test/java/io/threadforge/ScopeCloseTest.java new file mode 100644 index 0000000..32791d4 --- /dev/null +++ b/src/test/java/io/threadforge/ScopeCloseTest.java @@ -0,0 +1,193 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScopeCloseTest { + + @Test + void closeWaitsForTaskFinallyBeforeReturningAndRunningCleanup() throws Exception { + ThreadScope scope = ThreadScope.open(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finallyEntered = new CountDownLatch(1); + CountDownLatch allowFinallyExit = new CountDownLatch(1); + AtomicBoolean taskExited = new AtomicBoolean(); + AtomicBoolean cleanupRan = new AtomicBoolean(); + AtomicBoolean cleanupSawExit = new AtomicBoolean(); + AtomicBoolean closeReturned = new AtomicBoolean(); + try { + scope.submit(new Callable() { + @Override + public Void call() throws Exception { + started.countDown(); + try { + new CountDownLatch(1).await(); + } finally { + finallyEntered.countDown(); + allowFinallyExit.await(); + taskExited.set(true); + } + return null; + } + }); + scope.defer(() -> { + cleanupRan.set(true); + cleanupSawExit.set(taskExited.get()); + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + + Thread closer = closeInNewThread(scope, closeReturned); + assertTrue(finallyEntered.await(1L, TimeUnit.SECONDS)); + assertFalse(closeReturned.get()); + assertFalse(cleanupSawExit.get()); + + allowFinallyExit.countDown(); + closer.join(1000L); + assertFalse(closer.isAlive()); + assertTrue(closeReturned.get()); + assertTrue(taskExited.get()); + assertTrue(cleanupRan.get()); + assertTrue(cleanupSawExit.get()); + } finally { + allowFinallyExit.countDown(); + scope.close(); + } + } + + @Test + void queuedTaskNeverRunsWhenScopeCloses() throws Exception { + ThreadScope scope = ThreadScope.open().withScheduler(Scheduler.fixed(1)); + CountDownLatch blockerStarted = new CountDownLatch(1); + CountDownLatch blockerFinally = new CountDownLatch(1); + CountDownLatch releaseBlockerFinally = new CountDownLatch(1); + AtomicBoolean queuedRan = new AtomicBoolean(); + AtomicBoolean closeReturned = new AtomicBoolean(); + try { + scope.submit(new Callable() { + @Override + public Void call() throws Exception { + blockerStarted.countDown(); + try { + new CountDownLatch(1).await(); + } finally { + blockerFinally.countDown(); + releaseBlockerFinally.await(); + } + return null; + } + }); + scope.submit(() -> { + queuedRan.set(true); + return null; + }); + assertTrue(blockerStarted.await(1L, TimeUnit.SECONDS)); + + Thread closer = closeInNewThread(scope, closeReturned); + assertTrue(blockerFinally.await(1L, TimeUnit.SECONDS)); + assertFalse(closeReturned.get()); + releaseBlockerFinally.countDown(); + closer.join(1000L); + + assertFalse(closer.isAlive()); + assertFalse(queuedRan.get()); + } finally { + releaseBlockerFinally.countDown(); + scope.close(); + } + } + + @Test + void interruptIgnoringTaskKeepsCloseBlockedUntilItActuallyEnds() throws Exception { + ThreadScope scope = ThreadScope.open(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interruptObserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean closeReturned = new AtomicBoolean(); + try { + scope.submit(new Callable() { + @Override + public Void call() { + started.countDown(); + while (true) { + try { + release.await(); + return null; + } catch (InterruptedException ignored) { + interruptObserved.countDown(); + } + } + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + + Thread closer = closeInNewThread(scope, closeReturned); + assertTrue(interruptObserved.await(1L, TimeUnit.SECONDS)); + assertFalse(closeReturned.get()); + + release.countDown(); + closer.join(1000L); + assertFalse(closer.isAlive()); + assertTrue(closeReturned.get()); + } finally { + release.countDown(); + scope.close(); + } + } + + @Test + void closeAlsoWaitsForRunningScheduledWork() throws Exception { + ThreadScope scope = ThreadScope.open(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interruptObserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean closeReturned = new AtomicBoolean(); + try { + scope.schedule(java.time.Duration.ZERO, new Runnable() { + @Override + public void run() { + started.countDown(); + while (true) { + try { + release.await(); + return; + } catch (InterruptedException ignored) { + interruptObserved.countDown(); + } + } + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + + Thread closer = closeInNewThread(scope, closeReturned); + assertTrue(interruptObserved.await(1L, TimeUnit.SECONDS)); + assertFalse(closeReturned.get()); + release.countDown(); + closer.join(1000L); + + assertFalse(closer.isAlive()); + assertTrue(closeReturned.get()); + } finally { + release.countDown(); + scope.close(); + } + } + + private static Thread closeInNewThread(ThreadScope scope, AtomicBoolean returned) { + Thread closer = new Thread(new Runnable() { + @Override + public void run() { + scope.close(); + returned.set(true); + } + }); + closer.start(); + return closer; + } +} From 26c0461129a0c08fe7a0548d5e52aadf452eaa7b Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 20:23:14 +0800 Subject: [PATCH 08/17] fix(scope): make close and registration atomic --- docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 1 + docs/ai/threadforge.mdc | 1 + docs/api/core/ThreadScope.md | 1 + src/main/java/io/threadforge/ThreadScope.java | 179 ++++++++++-------- .../ScopeRegistrationRaceTest.java | 105 ++++++++++ 6 files changed, 211 insertions(+), 77 deletions(-) create mode 100644 src/test/java/io/threadforge/ScopeRegistrationRaceTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index d78304e..906c562 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -22,6 +22,7 @@ try (ThreadScope scope = ThreadScope.open()) { - `Channel.send/receive` — blocking waits are interruptible and throw `CancelledException` - `task.await()` / `scope.await(...)` — caller interruption propagates without changing target task states - `scope.close()` — waits for started work to exit before deferred cleanup; interruption-ignoring code keeps it blocked +- Registrations racing with `close()` either succeed and are cleaned up or fail with `IllegalStateException` - `scope.await(tasks)` / `scope.awaitAll(tasks)` — wait for completion - `scope.joiner().firstSuccess(...)` — return first successful result, cancel unfinished siblings - `scope.joiner().quorum(n, ...)` — return once `n` tasks succeed diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index 003dfc1..b1e8bc2 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -452,3 +452,4 @@ try (ThreadScope scope = ThreadScope.open() `task.toCompletableFuture()` is an observation-only mirror. Do not use it to complete or cancel the underlying task; call `task.cancel()` for framework-aware cancellation. Interrupting a thread blocked in `task.await()` or `scope.await(...)` throws `CancelledException` without changing target task states. `scope.close()` waits for started work to physically exit before deferred cleanup; code that ignores interruption keeps close blocked. +Registrations racing with close either succeed and are cleaned up, or fail with `IllegalStateException`; no resource is accepted after close. diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index b03bb80..4d85dfc 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -130,6 +130,7 @@ String id = Context.get("traceId"); // "req-1001" - Blocking `Channel.send/receive` calls are interruptible and throw `CancelledException` - Interrupting an `await` caller does not mark target tasks cancelled - `scope.close()` waits for started work to exit before running deferred cleanup +- `submit`, `schedule*`, and `defer` racing with close either register fully or fail clearly - `shutdownNow()` → `scope.close()` (try-with-resources) - Manual retry loops → `RetryPolicy` - Manual timeout → `withDeadline()` or per-task `Duration` diff --git a/docs/api/core/ThreadScope.md b/docs/api/core/ThreadScope.md index 37e8dcc..dfc46cc 100644 --- a/docs/api/core/ThreadScope.md +++ b/docs/api/core/ThreadScope.md @@ -46,6 +46,7 @@ try (ThreadScope scope = ThreadScope.open() - `deadline = Duration.ofSeconds(30)` - 自动传播 `Context`(提交/调度时捕获,执行时恢复) - 作用域关闭时自动取消未完成任务和计划任务 +- `submit`、`schedule*`、`defer` 与 `close` 原子竞争:注册成功必由关闭流程处理,关闭先发生则注册抛 `IllegalStateException` ## API 清单 diff --git a/src/main/java/io/threadforge/ThreadScope.java b/src/main/java/io/threadforge/ThreadScope.java index 92ba5c2..74c1e1b 100644 --- a/src/main/java/io/threadforge/ThreadScope.java +++ b/src/main/java/io/threadforge/ThreadScope.java @@ -64,6 +64,7 @@ public final class ThreadScope implements AutoCloseable { private final long scopeId; private final AtomicLong taskIdGen; private final AtomicBoolean closed; + private final Object lifecycleLock; private final AtomicBoolean configLocked; private final Queue> tasks; private final Queue scheduledTasks; @@ -96,6 +97,7 @@ private ThreadScope() { this.scopeId = SCOPE_IDS.getAndIncrement(); this.taskIdGen = new AtomicLong(1L); this.closed = new AtomicBoolean(false); + this.lifecycleLock = new Object(); this.configLocked = new AtomicBoolean(false); this.tasks = new ConcurrentLinkedQueue>(); this.scheduledTasks = new ConcurrentLinkedQueue(); @@ -360,8 +362,10 @@ public final R join(JoinStrategy strategy, Callable first, Calla */ public void defer(Runnable cleanup) { Objects.requireNonNull(cleanup, "cleanup"); - ensureOpen(); - deferred.addFirst(cleanup); + synchronized (lifecycleLock) { + ensureOpen(); + deferred.addFirst(cleanup); + } } private long nextTaskId() { @@ -615,36 +619,39 @@ public ScheduledTask schedule(Duration delay, final Callable callable) { Objects.requireNonNull(delay, "delay"); Objects.requireNonNull(callable, "callable"); lockConfiguration(); - ensureOpen(); - compactFinishedScheduledTasks(); - final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - - ScheduledTask task = scheduleDispatched(delay, new Runnable() { - @Override - public void run() { - try { - executionContext.wrapCallable(callable, token).call(); - } catch (RuntimeException runtimeException) { - throw runtimeException; - } catch (Exception exception) { - throw new RuntimeException(exception); + synchronized (lifecycleLock) { + ensureOpen(); + compactFinishedScheduledTasks(); + final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); + ScheduledTask task = scheduleDispatched(delay, new Runnable() { + @Override + public void run() { + try { + executionContext.wrapCallable(callable, token).call(); + } catch (RuntimeException runtimeException) { + throw runtimeException; + } catch (Exception exception) { + throw new RuntimeException(exception); + } } - } - }); - scheduledTasks.add(task); - return task; + }); + scheduledTasks.add(task); + return task; + } } public ScheduledTask schedule(Duration delay, final Runnable runnable) { Objects.requireNonNull(delay, "delay"); Objects.requireNonNull(runnable, "runnable"); lockConfiguration(); - ensureOpen(); - compactFinishedScheduledTasks(); - final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - ScheduledTask task = scheduleDispatched(delay, executionContext.wrapRunnable(runnable, token)); - scheduledTasks.add(task); - return task; + synchronized (lifecycleLock) { + ensureOpen(); + compactFinishedScheduledTasks(); + final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); + ScheduledTask task = scheduleDispatched(delay, executionContext.wrapRunnable(runnable, token)); + scheduledTasks.add(task); + return task; + } } public ScheduledTask scheduleAtFixedRate(Duration initial, Duration period, final Runnable runnable) { @@ -652,15 +659,17 @@ public ScheduledTask scheduleAtFixedRate(Duration initial, Duration period, fina Objects.requireNonNull(period, "period"); Objects.requireNonNull(runnable, "runnable"); lockConfiguration(); - ensureOpen(); - compactFinishedScheduledTasks(); - final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - final DispatchingScheduledTask task = new DispatchingScheduledTask( - scheduler.executor(), executionContext.wrapRunnable(runnable, token) - ); - task.bind(delayScheduler.scheduleAtFixedRate(initial, period, task)); - scheduledTasks.add(task); - return task; + synchronized (lifecycleLock) { + ensureOpen(); + compactFinishedScheduledTasks(); + final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); + final DispatchingScheduledTask task = new DispatchingScheduledTask( + scheduler.executor(), executionContext.wrapRunnable(runnable, token) + ); + task.bind(delayScheduler.scheduleAtFixedRate(initial, period, task)); + scheduledTasks.add(task); + return task; + } } public ScheduledTask scheduleWithFixedDelay(Duration initial, Duration delay, final Runnable runnable) { @@ -668,29 +677,38 @@ public ScheduledTask scheduleWithFixedDelay(Duration initial, Duration delay, fi Objects.requireNonNull(delay, "delay"); Objects.requireNonNull(runnable, "runnable"); lockConfiguration(); - ensureOpen(); - compactFinishedScheduledTasks(); - final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - final DispatchingScheduledTask task = new DispatchingScheduledTask( - scheduler.executor(), executionContext.wrapRunnable(runnable, token) - ); - task.bind(delayScheduler.scheduleWithFixedDelay(initial, delay, task)); - scheduledTasks.add(task); - return task; + synchronized (lifecycleLock) { + ensureOpen(); + compactFinishedScheduledTasks(); + final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); + final DispatchingScheduledTask task = new DispatchingScheduledTask( + scheduler.executor(), executionContext.wrapRunnable(runnable, token) + ); + task.bind(delayScheduler.scheduleWithFixedDelay(initial, delay, task)); + scheduledTasks.add(task); + return task; + } } @Override public void close() { - if (!closed.compareAndSet(false, true)) { - return; + List> closingTasks; + List closingScheduledTasks; + List closingDeferred; + synchronized (lifecycleLock) { + if (!closed.compareAndSet(false, true)) { + return; + } + closingTasks = new ArrayList>(tasks); + closingScheduledTasks = new ArrayList(scheduledTasks); + closingDeferred = new ArrayList(deferred); } Throwable primary = null; - List> closingTasks = new ArrayList>(tasks); token.cancel(); - for (ScheduledTask scheduledTask : scheduledTasks) { + for (ScheduledTask scheduledTask : closingScheduledTasks) { try { scheduledTask.cancel(); } catch (Throwable t) { @@ -708,7 +726,7 @@ public void close() { } } - for (ScheduledTask scheduledTask : scheduledTasks) { + for (ScheduledTask scheduledTask : closingScheduledTasks) { if (scheduledTask instanceof DispatchingScheduledTask) { ((DispatchingScheduledTask) scheduledTask).awaitDispatchedWork(); } @@ -718,7 +736,7 @@ public void close() { task.awaitExecutionFinished(); } - for (Runnable cleanup : deferred) { + for (Runnable cleanup : closingDeferred) { try { cleanup.run(); } catch (Throwable t) { @@ -764,50 +782,57 @@ private Task submit( final boolean permitAcquired = acquireSubmissionPermit(semaphore); final RetryPolicy taskRetryPolicy = retryPolicy; final Duration taskTimeout = timeout; + final Task task; + final TaskInfo info; + final TaskExecution execution; - final CompletableFuture future = new CompletableFuture(); - final Task task = new Task(id, name, future); - final TaskInfo info = new TaskInfo(scopeId, id, name, Instant.now(), scheduler.name()); - final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); - final TaskExecution execution = new TaskExecution(task, executionContext.wrapRunnable(new Runnable() { - @Override - public void run() { - runTask(task, info, callable, taskRetryPolicy); - } - })); - task.attachExecution(execution); - tasks.add(task); - task.whenExecutionFinished(new Runnable() { - @Override - public void run() { - tasks.remove(task); + synchronized (lifecycleLock) { + if (closed.get()) { if (permitAcquired && semaphore != null) { semaphore.release(); } + ensureOpen(); } - }); - final ScheduledTask timeoutTask = scheduleTaskTimeout(task, info, taskTimeout); - if (timeoutTask != null) { - future.whenComplete(new java.util.function.BiConsumer() { + final CompletableFuture future = new CompletableFuture(); + task = new Task(id, name, future); + info = new TaskInfo(scopeId, id, name, Instant.now(), scheduler.name()); + final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); + execution = new TaskExecution(task, executionContext.wrapRunnable(new Runnable() { + @Override + public void run() { + runTask(task, info, callable, taskRetryPolicy); + } + })); + task.attachExecution(execution); + tasks.add(task); + task.whenExecutionFinished(new Runnable() { @Override - public void accept(T value, Throwable throwable) { - timeoutTask.cancel(); + public void run() { + tasks.remove(task); + if (permitAcquired && semaphore != null) { + semaphore.release(); + } } }); + final ScheduledTask timeoutTask = scheduleTaskTimeout(task, info, taskTimeout); + if (timeoutTask != null) { + future.whenComplete(new java.util.function.BiConsumer() { + @Override + public void accept(T value, Throwable throwable) { + timeoutTask.cancel(); + } + }); + } + } try { - scheduler.executor().execute(Scheduler.prioritized( - execution, - taskPriority, - id - )); + scheduler.executor().execute(Scheduler.prioritized(execution, taskPriority, id)); } catch (RejectedExecutionException rejectedExecutionException) { if (task.completeFailure(rejectedExecutionException, true)) { safeHookFailure(info, rejectedExecutionException, 0L); } } - return task; } diff --git a/src/test/java/io/threadforge/ScopeRegistrationRaceTest.java b/src/test/java/io/threadforge/ScopeRegistrationRaceTest.java new file mode 100644 index 0000000..c730b85 --- /dev/null +++ b/src/test/java/io/threadforge/ScopeRegistrationRaceTest.java @@ -0,0 +1,105 @@ +package io.threadforge; + +import org.junit.jupiter.api.RepeatedTest; + +import java.time.Duration; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScopeRegistrationRaceTest { + + @RepeatedTest(100) + void deferEitherRegistersAndRunsOrFailsClearly() throws Exception { + ThreadScope scope = ThreadScope.open(); + AtomicBoolean cleanupRan = new AtomicBoolean(); + RaceResult result = raceClose(scope, () -> { + scope.defer(() -> cleanupRan.set(true)); + return null; + }); + assertTrue(result.failedClearly() || cleanupRan.get()); + } + + @RepeatedTest(100) + void submitEitherRegistersAndIsClosedOrFailsClearly() throws Exception { + ThreadScope scope = ThreadScope.open(); + RaceResult> result = raceClose(scope, () -> scope.submit(() -> 1)); + if (result.failedClearly()) { + return; + } + Task task = result.value; + assertTrue(task.isDone()); + assertTrue(task.isExecutionFinished()); + } + + @RepeatedTest(100) + void scheduleEitherRegistersAndIsCancelledOrFailsClearly() throws Exception { + ThreadScope scope = ThreadScope.open(); + RaceResult result = raceClose(scope, + () -> scope.schedule(Duration.ofDays(1), new Runnable() { + @Override + public void run() { + } + })); + if (result.failedClearly()) { + return; + } + assertTrue(result.value.isCancelled() || result.value.isDone()); + } + + private static RaceResult raceClose(ThreadScope scope, java.util.concurrent.Callable registration) + throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2); + try { + Future register = executor.submit(() -> { + barrier.await(); + return registration.call(); + }); + Future close = executor.submit(() -> { + barrier.await(); + scope.close(); + return null; + }); + close.get(1L, TimeUnit.SECONDS); + try { + return RaceResult.success(register.get(1L, TimeUnit.SECONDS)); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + assertTrue(cause instanceof IllegalStateException || cause instanceof CancelledException); + return RaceResult.failure(); + } + } finally { + scope.close(); + executor.shutdownNow(); + } + } + + private static final class RaceResult { + private final T value; + private final boolean failed; + + private RaceResult(T value, boolean failed) { + this.value = value; + this.failed = failed; + } + + private static RaceResult success(T value) { + return new RaceResult(value, false); + } + + private static RaceResult failure() { + return new RaceResult(null, true); + } + + private boolean failedClearly() { + return failed; + } + } +} From a9203c721d0688c011275aaf56342412a4ce4879 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 20:43:24 +0800 Subject: [PATCH 09/17] fix(observability): restore task context on runner thread --- docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 2 + docs/ai/threadforge.mdc | 1 + docs/api/observability/OpenTelemetryHook.md | 2 + integrations/threadforge-slf4j/README.md | 5 +- integrations/threadforge-slf4j/pom.xml | 20 ++ .../io/threadforge/slf4j/MdcThreadHook.java | 25 ++- .../threadforge/slf4j/MdcThreadHookTest.java | 183 ++++++++++++++++++ pom.xml | 7 + .../io/threadforge/OpenTelemetryHook.java | 18 +- src/main/java/io/threadforge/Task.java | 13 ++ src/main/java/io/threadforge/ThreadScope.java | 143 ++++++++++---- .../internal/otel/OpenTelemetryBridge.java | 24 ++- .../io/threadforge/HookThreadContextTest.java | 141 ++++++++++++++ .../OpenTelemetryThreadContextTest.java | 91 +++++++++ 15 files changed, 616 insertions(+), 60 deletions(-) create mode 100644 integrations/threadforge-slf4j/src/test/java/io/threadforge/slf4j/MdcThreadHookTest.java create mode 100644 src/test/java/io/threadforge/HookThreadContextTest.java create mode 100644 src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index 906c562..3a8aa74 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -28,6 +28,7 @@ try (ThreadScope scope = ThreadScope.open()) { - `scope.joiner().quorum(n, ...)` — return once `n` tasks succeed - `scope.joiner().hedged(delay, primary, backup...)` — start one task now and release backup tasks after the hedge delay - `SlowTaskHook.create(threshold, consumer)` — emit events for tasks slower than a threshold +- ThreadLocal hooks install and restore context on the same runner thread, including timeout/cancel paths - `hookA.andThen(hookB)` — compose multiple hooks - `task.await()` — get single task result - `scope.schedule(duration, callable)` — delayed execution diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index b1e8bc2..2e1c594 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -422,6 +422,8 @@ try (ThreadScope scope = ThreadScope.open() // Requires OpenTelemetry API on classpath ``` +ThreadLocal-style hooks are installed and restored on the same runner thread. A shared hook must distinguish tasks by both scope ID and task ID; timeout signals do not move context cleanup to the timer thread. + ## Common Mistakes | Mistake | Fix | diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index 4d85dfc..be5967a 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -91,6 +91,7 @@ ThreadHook combined = SlowTaskHook.create(Duration.ofMillis(200), event -> { - `ThreadHook.andThen(...)` composes multiple hooks - `SlowTaskHook` emits events only when duration crosses the configured threshold - optional Micrometer and SLF4J / MDC integrations live under `integrations/` +- ThreadLocal hook setup and cleanup run on the same task runner; hook state is scoped by scope ID plus task ID ## Context Propagation diff --git a/docs/api/observability/OpenTelemetryHook.md b/docs/api/observability/OpenTelemetryHook.md index 9414de9..9f36e0c 100644 --- a/docs/api/observability/OpenTelemetryHook.md +++ b/docs/api/observability/OpenTelemetryHook.md @@ -50,4 +50,6 @@ try (ThreadScope scope = ThreadScope.open() - ThreadForge 在 `submit/schedule` 时会捕获当前 OpenTelemetry 上下文 - 任务执行前恢复父上下文,再创建任务 span +- Span Scope 在创建它的 runner 线程中关闭并恢复 ThreadLocal,上下文恢复后再结束 Span +- timeout 等终态信号不会把 Scope 的关闭转移到控制调度线程 - 该机制同时适用于平台线程和虚拟线程 diff --git a/integrations/threadforge-slf4j/README.md b/integrations/threadforge-slf4j/README.md index b85299b..83dbc0c 100644 --- a/integrations/threadforge-slf4j/README.md +++ b/integrations/threadforge-slf4j/README.md @@ -3,7 +3,7 @@ Build first: ```bash -mvn -B -ntp -DskipTests install +mvn -B -ntp clean install mvn -B -ntp -f integrations/threadforge-slf4j/pom.xml compile ``` @@ -28,4 +28,7 @@ Behavior: - reads values from `Context` - installs them into MDC when the task starts - restores the previous MDC map when the task completes +- restores on the same runner thread that installed MDC, including timeout and cancellation paths +- keys hook state by both scope ID and task ID, so one hook can be shared across scopes +- terminal events for tasks that never started do not clear the current thread's MDC - ignores non-string `Context` values diff --git a/integrations/threadforge-slf4j/pom.xml b/integrations/threadforge-slf4j/pom.xml index dfc18fa..e14f490 100644 --- a/integrations/threadforge-slf4j/pom.xml +++ b/integrations/threadforge-slf4j/pom.xml @@ -14,6 +14,9 @@ 1.2.1 2.0.16 3.14.1 + 5.11.4 + 3.5.4 + 1.3.14 @@ -27,6 +30,18 @@ slf4j-api ${slf4j.version} + + ch.qos.logback + logback-classic + ${logback.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + @@ -39,6 +54,11 @@ ${maven.compiler.release} + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.version} + diff --git a/integrations/threadforge-slf4j/src/main/java/io/threadforge/slf4j/MdcThreadHook.java b/integrations/threadforge-slf4j/src/main/java/io/threadforge/slf4j/MdcThreadHook.java index cbf8409..01398e0 100644 --- a/integrations/threadforge-slf4j/src/main/java/io/threadforge/slf4j/MdcThreadHook.java +++ b/integrations/threadforge-slf4j/src/main/java/io/threadforge/slf4j/MdcThreadHook.java @@ -17,12 +17,12 @@ public final class MdcThreadHook implements ThreadHook { private final String[] keys; private final boolean captureAll; - private final ConcurrentMap> previousStates; + private final ConcurrentMap> previousStates; private MdcThreadHook(String[] keys, boolean captureAll) { this.keys = keys; this.captureAll = captureAll; - this.previousStates = new ConcurrentHashMap>(); + this.previousStates = new ConcurrentHashMap>(); } public static MdcThreadHook captureAll() { @@ -41,7 +41,7 @@ public static MdcThreadHook captureKeys(String... keys) { @Override public void onStart(TaskInfo info) { Map previous = MDC.getCopyOfContextMap(); - previousStates.put(info.taskId(), previous == null ? Collections.emptyMap() : previous); + previousStates.put(key(info), previous == null ? Collections.emptyMap() : previous); Map next = captureAll ? captureAllStringValues() : captureSelectedKeys(); if (next.isEmpty()) { @@ -53,17 +53,17 @@ public void onStart(TaskInfo info) { @Override public void onSuccess(TaskInfo info, Duration duration) { - restore(info.taskId()); + restore(info); } @Override public void onFailure(TaskInfo info, Throwable error, Duration duration) { - restore(info.taskId()); + restore(info); } @Override public void onCancel(TaskInfo info, Duration duration) { - restore(info.taskId()); + restore(info); } private Map captureAllStringValues() { @@ -88,12 +88,19 @@ private Map captureSelectedKeys() { return values; } - private void restore(long taskId) { - Map previous = previousStates.remove(taskId); - if (previous == null || previous.isEmpty()) { + private void restore(TaskInfo info) { + Map previous = previousStates.remove(key(info)); + if (previous == null) { + return; + } + if (previous.isEmpty()) { MDC.clear(); return; } MDC.setContextMap(previous); } + + private String key(TaskInfo info) { + return info.scopeId() + ":" + info.taskId(); + } } diff --git a/integrations/threadforge-slf4j/src/test/java/io/threadforge/slf4j/MdcThreadHookTest.java b/integrations/threadforge-slf4j/src/test/java/io/threadforge/slf4j/MdcThreadHookTest.java new file mode 100644 index 0000000..bcd5e63 --- /dev/null +++ b/integrations/threadforge-slf4j/src/test/java/io/threadforge/slf4j/MdcThreadHookTest.java @@ -0,0 +1,183 @@ +package io.threadforge.slf4j; + +import io.threadforge.CancelledException; +import io.threadforge.Context; +import io.threadforge.FailurePolicy; +import io.threadforge.Scheduler; +import io.threadforge.Task; +import io.threadforge.TaskInfo; +import io.threadforge.TaskTimeoutException; +import io.threadforge.ThreadScope; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MdcThreadHookTest { + + @AfterEach + void clearContext() { + MDC.clear(); + Context.clear(); + } + + @Test + void successFailureAndCancelRestoreReusedWorkerMdc() throws Exception { + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + setWorkerMdc(worker, "base"); + MdcThreadHook hook = MdcThreadHook.captureAll(); + + Context.put("traceId", "success"); + try (ThreadScope scope = ThreadScope.open() + .withScheduler(Scheduler.from(worker)).withHook(hook)) { + assertEquals("success", scope.submit(() -> MDC.get("traceId")).await()); + } + assertWorkerMdc(worker, "base"); + + Context.put("traceId", "failure"); + try (ThreadScope scope = ThreadScope.open().withFailurePolicy(FailurePolicy.SUPERVISOR) + .withScheduler(Scheduler.from(worker)).withHook(hook)) { + Task failed = scope.submit(new Callable() { + @Override + public Void call() { + assertEquals("failure", MDC.get("traceId")); + throw new IllegalStateException("boom"); + } + }); + assertThrows(IllegalStateException.class, failed::await); + } + assertWorkerMdc(worker, "base"); + + Context.put("traceId", "cancel"); + CountDownLatch started = new CountDownLatch(1); + try (ThreadScope scope = ThreadScope.open() + .withScheduler(Scheduler.from(worker)).withHook(hook)) { + Task cancelled = scope.submit(new Callable() { + @Override + public Void call() throws Exception { + assertEquals("cancel", MDC.get("traceId")); + started.countDown(); + new CountDownLatch(1).await(); + return null; + } + }); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertTrue(cancelled.cancel()); + assertThrows(CancelledException.class, cancelled::await); + } + assertWorkerMdc(worker, "base"); + } finally { + worker.shutdownNow(); + } + } + + @Test + void runningTimeoutRestoresMdcOnRunnerThread() throws Exception { + ExecutorService worker = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interruptObserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ThreadScope scope = ThreadScope.open().withScheduler(Scheduler.from(worker)) + .withHook(MdcThreadHook.captureAll()); + try { + setWorkerMdc(worker, "base"); + Context.put("traceId", "timeout"); + Task task = scope.submit(new Callable() { + @Override + public Void call() { + assertEquals("timeout", MDC.get("traceId")); + started.countDown(); + while (true) { + try { + release.await(); + return null; + } catch (InterruptedException ignored) { + interruptObserved.countDown(); + } + } + } + }, Duration.ofMillis(40)); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertThrows(TaskTimeoutException.class, task::await); + assertTrue(interruptObserved.await(1L, TimeUnit.SECONDS)); + release.countDown(); + assertWorkerMdc(worker, "base"); + } finally { + release.countDown(); + scope.close(); + worker.shutdownNow(); + } + } + + @Test + void terminalWithoutStartDoesNotClearCurrentMdc() { + MDC.put("timer", "keep"); + MdcThreadHook hook = MdcThreadHook.captureAll(); + hook.onFailure(info(1L, 1L), new TaskTimeoutException("timeout"), Duration.ofMillis(1)); + assertEquals("keep", MDC.get("timer")); + } + + @Test + void sameHookSupportsSameTaskIdFromDifferentScopes() throws Exception { + MdcThreadHook hook = MdcThreadHook.captureAll(); + ExecutorService threads = Executors.newFixedThreadPool(2); + CyclicBarrier started = new CyclicBarrier(2); + CyclicBarrier finish = new CyclicBarrier(2); + try { + Future first = threads.submit(() -> exerciseHook(hook, info(1L, 1L), "first", started, finish)); + Future second = threads.submit(() -> exerciseHook(hook, info(2L, 1L), "second", started, finish)); + assertEquals("first", first.get(1L, TimeUnit.SECONDS)); + assertEquals("second", second.get(1L, TimeUnit.SECONDS)); + } finally { + threads.shutdownNow(); + } + } + + private static String exerciseHook( + MdcThreadHook hook, + TaskInfo info, + String workerValue, + CyclicBarrier started, + CyclicBarrier finish + ) throws Exception { + MDC.put("worker", workerValue); + Context.put("traceId", "task-" + workerValue); + hook.onStart(info); + assertEquals("task-" + workerValue, MDC.get("traceId")); + started.await(); + finish.await(); + hook.onSuccess(info, Duration.ZERO); + assertNull(MDC.get("traceId")); + return MDC.get("worker"); + } + + private static TaskInfo info(long scopeId, long taskId) { + return new TaskInfo(scopeId, taskId, "task", Instant.now(), "test"); + } + + private static void setWorkerMdc(ExecutorService worker, String value) throws Exception { + worker.submit(() -> MDC.put("worker", value)).get(1L, TimeUnit.SECONDS); + } + + private static void assertWorkerMdc(ExecutorService worker, String value) throws Exception { + worker.submit(() -> { + assertEquals(value, MDC.get("worker")); + assertNull(MDC.get("traceId")); + }).get(1L, TimeUnit.SECONDS); + } +} diff --git a/pom.xml b/pom.xml index 85adb82..d54c354 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,7 @@ 3.12.0 3.2.8 0.9.0 + 1.44.1 @@ -55,6 +56,12 @@ ${junit.jupiter.version} test + + io.opentelemetry + opentelemetry-sdk + ${opentelemetry.version} + test + diff --git a/src/main/java/io/threadforge/OpenTelemetryHook.java b/src/main/java/io/threadforge/OpenTelemetryHook.java index 8f50463..d315f41 100644 --- a/src/main/java/io/threadforge/OpenTelemetryHook.java +++ b/src/main/java/io/threadforge/OpenTelemetryHook.java @@ -16,12 +16,12 @@ public final class OpenTelemetryHook implements ThreadHook { private final String instrumentationName; private final Object tracer; - private final ConcurrentMap spans; + private final ConcurrentMap spans; private OpenTelemetryHook(String instrumentationName, Object tracer) { this.instrumentationName = instrumentationName; this.tracer = tracer; - this.spans = new ConcurrentHashMap(); + this.spans = new ConcurrentHashMap(); } /** @@ -58,7 +58,7 @@ public void onStart(TaskInfo info) { return; } Object scope = OpenTelemetryBridge.spanMakeCurrent(span); - SpanState previous = spans.put(info.taskId(), new SpanState(span, scope)); + SpanState previous = spans.put(key(info), new SpanState(span, scope)); if (previous != null) { finish(previous, null, null, false); } @@ -66,19 +66,19 @@ public void onStart(TaskInfo info) { @Override public void onSuccess(TaskInfo info, Duration duration) { - SpanState spanState = spans.remove(info.taskId()); + SpanState spanState = spans.remove(key(info)); finish(spanState, duration, null, false); } @Override public void onFailure(TaskInfo info, Throwable error, Duration duration) { - SpanState spanState = spans.remove(info.taskId()); + SpanState spanState = spans.remove(key(info)); finish(spanState, duration, error, false); } @Override public void onCancel(TaskInfo info, Duration duration) { - SpanState spanState = spans.remove(info.taskId()); + SpanState spanState = spans.remove(key(info)); finish(spanState, duration, null, true); } @@ -96,12 +96,16 @@ private void finish(SpanState spanState, Duration duration, Throwable error, boo if (error != null) { OpenTelemetryBridge.spanRecordFailure(spanState.span, error); } - OpenTelemetryBridge.spanEnd(spanState.span); } finally { OpenTelemetryBridge.closeScope(spanState.scope); + OpenTelemetryBridge.spanEnd(spanState.span); } } + private String key(TaskInfo info) { + return info.scopeId() + ":" + info.taskId(); + } + private String spanName(TaskInfo info) { return "threadforge.task " + info.name(); } diff --git a/src/main/java/io/threadforge/Task.java b/src/main/java/io/threadforge/Task.java index ec9808f..d52397e 100644 --- a/src/main/java/io/threadforge/Task.java +++ b/src/main/java/io/threadforge/Task.java @@ -49,6 +49,7 @@ public enum State { private boolean executionEntered; private Future execution; private Runnable executionFinishedCallback; + private Throwable terminalFailure; /** 包级构造函数,仅供 {@link ThreadScope} 创建任务句柄。 */ Task(long id, String name, CompletableFuture future) { @@ -120,8 +121,10 @@ public boolean cancel() { } State previous = state; state = State.CANCELLED; + terminalFailure = new CancelledException("Task cancelled"); if (!future.cancel(true)) { state = previous; + terminalFailure = null; return false; } runner = runnerThread; @@ -272,8 +275,10 @@ boolean completeFailure(Throwable failure, boolean interrupt) { } State previous = state; state = State.FAILED; + terminalFailure = failure; if (!future.completeExceptionally(failure)) { state = previous; + terminalFailure = null; return false; } runner = runnerThread; @@ -300,8 +305,10 @@ boolean completeCancelled(CancelledException cancellation) { } State previous = state; state = State.CANCELLED; + terminalFailure = cancellation; if (!future.completeExceptionally(cancellation)) { state = previous; + terminalFailure = null; return false; } return true; @@ -390,6 +397,12 @@ boolean hasRunnerThread() { } } + Throwable terminalFailure() { + synchronized (lifecycleLock) { + return terminalFailure; + } + } + private Runnable markExecutionFinishedLocked() { if (executionFinished.isDone()) { return null; diff --git a/src/main/java/io/threadforge/ThreadScope.java b/src/main/java/io/threadforge/ThreadScope.java index 74c1e1b..dafa082 100644 --- a/src/main/java/io/threadforge/ThreadScope.java +++ b/src/main/java/io/threadforge/ThreadScope.java @@ -785,6 +785,7 @@ private Task submit( final Task task; final TaskInfo info; final TaskExecution execution; + final TaskHookState hookState; synchronized (lifecycleLock) { if (closed.get()) { @@ -796,11 +797,12 @@ private Task submit( final CompletableFuture future = new CompletableFuture(); task = new Task(id, name, future); info = new TaskInfo(scopeId, id, name, Instant.now(), scheduler.name()); + hookState = new TaskHookState(info, System.nanoTime()); final ExecutionContextCarrier executionContext = ExecutionContextCarrier.capture(); execution = new TaskExecution(task, executionContext.wrapRunnable(new Runnable() { @Override public void run() { - runTask(task, info, callable, taskRetryPolicy); + runTask(task, callable, taskRetryPolicy, hookState); } })); task.attachExecution(execution); @@ -808,13 +810,14 @@ public void run() { task.whenExecutionFinished(new Runnable() { @Override public void run() { + hookState.finishAfterExecution(task); tasks.remove(task); if (permitAcquired && semaphore != null) { semaphore.release(); } } }); - final ScheduledTask timeoutTask = scheduleTaskTimeout(task, info, taskTimeout); + final ScheduledTask timeoutTask = scheduleTaskTimeout(task, info, taskTimeout, hookState); if (timeoutTask != null) { future.whenComplete(new java.util.function.BiConsumer() { @Override @@ -830,7 +833,7 @@ public void accept(T value, Throwable throwable) { scheduler.executor().execute(Scheduler.prioritized(execution, taskPriority, id)); } catch (RejectedExecutionException rejectedExecutionException) { if (task.completeFailure(rejectedExecutionException, true)) { - safeHookFailure(info, rejectedExecutionException, 0L); + hookState.finishUnstarted(task); } } return task; @@ -849,61 +852,43 @@ public Void call() { private void runTask( Task task, - TaskInfo info, Callable callable, - RetryPolicy retryPolicy + RetryPolicy retryPolicy, + TaskHookState hookState ) { - long started = System.nanoTime(); - try { if (task.state() != Task.State.RUNNING || token.isCancelled()) { - completeTaskCancelled(task, new CancelledException("Task cancelled before start"), info, started); + task.completeCancelled(new CancelledException("Task cancelled before start")); + return; + } + if (!hookState.start(task)) { + return; + } + if (task.state() != Task.State.RUNNING) { return; } - - safeHookStart(info); token.throwIfCancelled(); T value = RetryExecutor.execute(callable, retryPolicy, token); - if (task.completeSuccess(value)) { - safeHookSuccess(info, elapsedNanos(started)); - } + task.completeSuccess(value); } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); - completeTaskCancelled(task, new CancelledException("Task interrupted", interruptedException), info, started); + task.completeCancelled(new CancelledException("Task interrupted", interruptedException)); } catch (CancelledException cancelledException) { - completeTaskCancelled(task, cancelledException, info, started); + task.completeCancelled(cancelledException); } catch (Throwable throwable) { - completeTaskFailure(task, throwable, info, started); - } - } - private void completeTaskCancelled( - Task task, - CancelledException cancelledException, - TaskInfo info, - long started - ) { - if (task.completeCancelled(cancelledException)) { - safeHookCancel(info, elapsedNanos(started)); - return; - } - if (task.state() == Task.State.CANCELLED) { - safeHookCancel(info, elapsedNanos(started)); + task.completeFailure(throwable, false); + } finally { + hookState.finishStarted(task); } } - private void completeTaskFailure( - Task task, - Throwable throwable, - TaskInfo info, - long started + private ScheduledTask scheduleTaskTimeout( + final Task task, + final TaskInfo info, + final Duration timeout, + final TaskHookState hookState ) { - if (task.completeFailure(throwable, false)) { - safeHookFailure(info, throwable, elapsedNanos(started)); - } - } - - private ScheduledTask scheduleTaskTimeout(final Task task, final TaskInfo info, final Duration timeout) { if (timeout == null) { return null; } @@ -912,7 +897,7 @@ private ScheduledTask scheduleTaskTimeout(final Task task, final TaskInfo inf public void run() { TaskTimeoutException timeoutException = taskTimeoutException(info, timeout); if (task.completeFailure(timeoutException, true)) { - dispatchHookFailure(info, timeoutException, timeout.toNanos()); + hookState.finishTimeout(task, timeoutException, timeout.toNanos()); } } }); @@ -1145,6 +1130,80 @@ public void run() { } } + private final class TaskHookState { + private final TaskInfo info; + private final long createdAtNanos; + private boolean started; + private boolean terminal; + private long startedAtNanos; + + private TaskHookState(TaskInfo info, long createdAtNanos) { + this.info = info; + this.createdAtNanos = createdAtNanos; + } + + private boolean start(Task task) { + synchronized (this) { + if (terminal || task.state() != Task.State.RUNNING) { + return false; + } + started = true; + startedAtNanos = System.nanoTime(); + } + safeHookStart(info); + return true; + } + + private void finishStarted(Task task) { + long duration; + synchronized (this) { + if (!started || terminal) { + return; + } + terminal = true; + duration = elapsedNanos(startedAtNanos); + } + emitTerminal(task, duration); + } + + private void finishTimeout(Task task, Throwable failure, long durationNanos) { + synchronized (this) { + if (started || terminal) { + return; + } + terminal = true; + } + dispatchHookFailure(info, failure, durationNanos); + } + + private void finishUnstarted(Task task) { + synchronized (this) { + if (started || terminal) { + return; + } + terminal = true; + } + emitTerminal(task, elapsedNanos(createdAtNanos)); + } + + private void finishAfterExecution(Task task) { + if (task.state() == Task.State.CANCELLED) { + finishUnstarted(task); + } + } + + private void emitTerminal(Task task, long durationNanos) { + Task.State terminalState = task.state(); + if (terminalState == Task.State.SUCCESS) { + safeHookSuccess(info, durationNanos); + } else if (terminalState == Task.State.FAILED) { + safeHookFailure(info, task.terminalFailure(), durationNanos); + } else if (terminalState == Task.State.CANCELLED) { + safeHookCancel(info, durationNanos); + } + } + } + private static final class TaskExecution extends FutureTask { private final Task task; diff --git a/src/main/java/io/threadforge/internal/otel/OpenTelemetryBridge.java b/src/main/java/io/threadforge/internal/otel/OpenTelemetryBridge.java index a654ec0..d201820 100644 --- a/src/main/java/io/threadforge/internal/otel/OpenTelemetryBridge.java +++ b/src/main/java/io/threadforge/internal/otel/OpenTelemetryBridge.java @@ -2,6 +2,9 @@ import io.threadforge.TaskInfo; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + /** * Reflection bridge for OpenTelemetry API. * @@ -136,9 +139,28 @@ private static Object invoke(Object target, String methodName) { private static Object invoke(Object target, String methodName, Class[] argTypes, Object[] args) { try { - return target.getClass().getMethod(methodName, argTypes).invoke(target, args); + Method method = accessibleMethod(target.getClass(), methodName, argTypes); + return method == null ? null : method.invoke(target, args); } catch (Throwable ignored) { return null; } } + + private static Method accessibleMethod(Class type, String methodName, Class[] argTypes) { + try { + Method method = type.getMethod(methodName, argTypes); + if (Modifier.isPublic(method.getDeclaringClass().getModifiers())) { + return method; + } + } catch (NoSuchMethodException ignored) { + } + for (Class current : type.getInterfaces()) { + Method method = accessibleMethod(current, methodName, argTypes); + if (method != null) { + return method; + } + } + Class parent = type.getSuperclass(); + return parent == null ? null : accessibleMethod(parent, methodName, argTypes); + } } diff --git a/src/test/java/io/threadforge/HookThreadContextTest.java b/src/test/java/io/threadforge/HookThreadContextTest.java new file mode 100644 index 0000000..fa4a282 --- /dev/null +++ b/src/test/java/io/threadforge/HookThreadContextTest.java @@ -0,0 +1,141 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HookThreadContextTest { + + @Test + void runningTimeoutFinishesHookOnRunnerAfterUserCodeExits() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interruptObserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch failureHook = new CountDownLatch(1); + AtomicReference startThread = new AtomicReference(); + AtomicReference failureThread = new AtomicReference(); + AtomicInteger failures = new AtomicInteger(); + ThreadHook hook = new ThreadHook() { + @Override + public void onStart(TaskInfo info) { + startThread.set(Thread.currentThread()); + } + + @Override + public void onFailure(TaskInfo info, Throwable error, Duration duration) { + failureThread.set(Thread.currentThread()); + failures.incrementAndGet(); + failureHook.countDown(); + } + }; + + ThreadScope scope = ThreadScope.open().withHook(hook); + try { + Task task = scope.submit(new Callable() { + @Override + public Void call() { + started.countDown(); + while (true) { + try { + release.await(); + return null; + } catch (InterruptedException ignored) { + interruptObserved.countDown(); + } + } + } + }, Duration.ofMillis(40)); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertThrows(TaskTimeoutException.class, task::await); + assertTrue(interruptObserved.await(1L, TimeUnit.SECONDS)); + assertFalse(failureHook.await(50L, TimeUnit.MILLISECONDS)); + + release.countDown(); + task.awaitExecutionFinished(Duration.ofSeconds(1)); + assertTrue(failureHook.await(1L, TimeUnit.SECONDS)); + assertSame(startThread.get(), failureThread.get()); + assertEquals(1, failures.get()); + } finally { + release.countDown(); + scope.close(); + } + } + + @Test + void queuedTimeoutDoesNotCallStartButReportsFailureOnce() throws Exception { + CountDownLatch blockerStarted = new CountDownLatch(1); + CountDownLatch releaseBlocker = new CountDownLatch(1); + CountDownLatch queuedFailure = new CountDownLatch(1); + AtomicInteger queuedStarts = new AtomicInteger(); + AtomicInteger queuedFailures = new AtomicInteger(); + ThreadHook hook = new ThreadHook() { + @Override + public void onStart(TaskInfo info) { + if ("queued".equals(info.name())) { + queuedStarts.incrementAndGet(); + } + } + + @Override + public void onFailure(TaskInfo info, Throwable error, Duration duration) { + if ("queued".equals(info.name())) { + queuedFailures.incrementAndGet(); + queuedFailure.countDown(); + } + } + }; + + try (ThreadScope scope = ThreadScope.open() + .withScheduler(Scheduler.fixed(1)) + .withHook(hook)) { + Task blocker = scope.submit("blocker", new Callable() { + @Override + public Void call() throws Exception { + blockerStarted.countDown(); + releaseBlocker.await(); + return null; + } + }); + assertTrue(blockerStarted.await(1L, TimeUnit.SECONDS)); + Task queued = scope.submit("queued", () -> null, Duration.ofMillis(40)); + assertThrows(TaskTimeoutException.class, queued::await); + releaseBlocker.countDown(); + blocker.await(); + queued.awaitExecutionFinished(Duration.ofSeconds(1)); + assertTrue(queuedFailure.await(1L, TimeUnit.SECONDS)); + assertEquals(0, queuedStarts.get()); + assertEquals(1, queuedFailures.get()); + } finally { + releaseBlocker.countDown(); + } + } + + @Test + void hookExceptionsNeverChangeTaskResult() { + ThreadHook throwing = new ThreadHook() { + @Override + public void onStart(TaskInfo info) { + throw new IllegalStateException("start-hook"); + } + + @Override + public void onSuccess(TaskInfo info, Duration duration) { + throw new IllegalStateException("success-hook"); + } + }; + try (ThreadScope scope = ThreadScope.open().withHook(throwing)) { + assertEquals(Integer.valueOf(7), scope.submit(() -> 7).await()); + } + } +} diff --git a/src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java b/src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java new file mode 100644 index 0000000..67da112 --- /dev/null +++ b/src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java @@ -0,0 +1,91 @@ +package io.threadforge; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OpenTelemetryThreadContextTest { + + private SdkTracerProvider tracerProvider; + + @BeforeEach + void installOpenTelemetry() { + GlobalOpenTelemetry.resetForTest(); + tracerProvider = SdkTracerProvider.builder().build(); + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).buildAndRegisterGlobal(); + } + + @AfterEach + void resetOpenTelemetry() { + tracerProvider.close(); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void successRestoresWorkerOpenTelemetryContext() throws Exception { + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + try (ThreadScope scope = ThreadScope.open() + .withScheduler(Scheduler.from(worker)).withOpenTelemetry("test")) { + assertTrue(scope.submit(() -> Span.current().getSpanContext().isValid()).await()); + } + assertWorkerHasNoSpan(worker); + } finally { + worker.shutdownNow(); + } + } + + @Test + void runningTimeoutClosesOpenTelemetryScopeOnRunner() throws Exception { + ExecutorService worker = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interruptObserved = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ThreadScope scope = ThreadScope.open().withScheduler(Scheduler.from(worker)).withOpenTelemetry("test-timeout"); + try { + Task task = scope.submit(new Callable() { + @Override + public Void call() { + assertTrue(Span.current().getSpanContext().isValid()); + started.countDown(); + while (true) { + try { + release.await(); + return null; + } catch (InterruptedException ignored) { + interruptObserved.countDown(); + } + } + } + }, Duration.ofMillis(40)); + assertTrue(started.await(1L, TimeUnit.SECONDS)); + assertThrows(TaskTimeoutException.class, task::await); + assertTrue(interruptObserved.await(1L, TimeUnit.SECONDS)); + release.countDown(); + assertWorkerHasNoSpan(worker); + } finally { + release.countDown(); + scope.close(); + worker.shutdownNow(); + } + } + + private static void assertWorkerHasNoSpan(ExecutorService worker) throws Exception { + assertFalse(worker.submit(() -> Span.current().getSpanContext().isValid()).get(1L, TimeUnit.SECONDS)); + } +} From 48fb597ff509003ff4b5b0ff40df32f8db71ebf5 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 20:49:14 +0800 Subject: [PATCH 10/17] fix(scheduler): reject submissions after shutdown --- docs/ai/threadforge-agents.md | 2 + docs/ai/threadforge.SKILL.md | 2 + docs/ai/threadforge.mdc | 2 + docs/api/runtime/Scheduler.md | 9 +- src/main/java/io/threadforge/Scheduler.java | 17 ++- .../io/threadforge/SchedulerShutdownTest.java | 115 ++++++++++++++++++ 6 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 src/test/java/io/threadforge/SchedulerShutdownTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index 3a8aa74..c8a0e75 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -58,6 +58,8 @@ try (ThreadScope scope = ThreadScope.open()) { - `Scheduler.detect()` — auto-selects virtual threads (JDK 21+) or common pool - `Scheduler.fixed(n)` — fixed thread pool - `Scheduler.priority(n)` — priority-based pool (use with `TaskPriority`) +- Owned schedulers (`fixed`/`priority`) belong to one scope and close with it; use `Scheduler.from(...)` for a caller-managed executor shared across scopes +- Submissions to a shut-down scheduler are rejected instead of remaining pending ## Exceptions diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index 2e1c594..a00fbed 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -204,6 +204,8 @@ Scheduler.virtualThreads() // Explicit virtual threads, scope does NOT own Scheduler.from(executorService) // Wrap external executor, scope does NOT own ``` +An owned scheduler (`fixed`/`priority`) belongs to one scope and is closed with it. Use `from(...)` with a caller-managed executor when multiple scopes must share an executor. Submissions after shutdown are rejected. + ### Context Propagation ThreadForge automatically captures `Context` at submit time and restores it in the task thread. After task completes, original thread context is restored. diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index be5967a..8e180b2 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -55,6 +55,8 @@ Scheduler.virtualThreads() // Explicit virtual threads Scheduler.from(executorService) // Wrap external executor ``` +An owned scheduler (`fixed`/`priority`) belongs to one scope and is closed with it. Use `from(...)` with a caller-managed executor when multiple scopes must share an executor. Submissions after shutdown are rejected. + ## RetryPolicy ```java diff --git a/docs/api/runtime/Scheduler.md b/docs/api/runtime/Scheduler.md index 7f6b3e4..aa81b11 100644 --- a/docs/api/runtime/Scheduler.md +++ b/docs/api/runtime/Scheduler.md @@ -24,7 +24,8 @@ - 线程池特性: - 核心线程数 = 最大线程数 = `size` - 队列容量 = `max(256, size * 100)` - - 拒绝策略 = `CallerRunsPolicy` + - 队列满且执行器仍运行时,由提交线程执行 + - 执行器已关闭时,明确抛出 `RejectedExecutionException` - 允许核心线程超时回收 ### `static Scheduler priority(int size)` @@ -35,7 +36,7 @@ - 线程池特性: - 核心线程数 = 最大线程数 = `size` - 队列类型 = `PriorityBlockingQueue` - - 拒绝策略 = `CallerRunsPolicy` + - 执行器已关闭时,明确抛出 `RejectedExecutionException` - 说明: - 需配合 `TaskPriority` 使用 - 同优先级按提交顺序执行 @@ -75,6 +76,10 @@ 是否虚拟线程模式。 +## 生命周期 + +`fixed(...)` 和 `priority(...)` 返回 owned scheduler,应只绑定一个 `ThreadScope`;scope 关闭时会关闭其执行器,之后再次提交会被明确拒绝。需要跨 scope 复用时,请用 `from(executor)` 包装由调用方管理的外部执行器。 + ## 推荐用法 ```java diff --git a/src/main/java/io/threadforge/Scheduler.java b/src/main/java/io/threadforge/Scheduler.java index c688ee6..6209346 100644 --- a/src/main/java/io/threadforge/Scheduler.java +++ b/src/main/java/io/threadforge/Scheduler.java @@ -8,6 +8,8 @@ import java.util.concurrent.ForkJoinPool; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -24,6 +26,15 @@ public final class Scheduler { private static final Scheduler COMMON_POOL = new Scheduler(ForkJoinPool.commonPool(), false, "commonPool", false); + private static final RejectedExecutionHandler CALLER_RUNS_WHILE_ACTIVE = new RejectedExecutionHandler() { + @Override + public void rejectedExecution(Runnable runnable, ThreadPoolExecutor executor) { + if (executor.isShutdown()) { + throw new RejectedExecutionException("Scheduler executor is shut down"); + } + runnable.run(); + } + }; private static volatile Scheduler SHARED_VIRTUAL_THREADS; private final ExecutorService executor; @@ -53,7 +64,7 @@ public static Scheduler commonPool() { /** * 创建固定大小线程池调度器。 * - *

返回的调度器拥有执行器所有权,scope 关闭时会一并关闭。 + *

返回的调度器拥有执行器所有权,scope 关闭时会一并关闭,因此应只绑定一个 scope。 * *

示例: *

{@code
@@ -73,7 +84,7 @@ public static Scheduler fixed(int size) {
             TimeUnit.SECONDS,
             new LinkedBlockingQueue(queueCapacity),
             new NamedThreadFactory("threadforge-fixed"),
-            new ThreadPoolExecutor.CallerRunsPolicy()
+            CALLER_RUNS_WHILE_ACTIVE
         );
         executor.allowCoreThreadTimeOut(true);
         return new Scheduler(executor, true, "fixed(" + size + ")", false);
@@ -95,7 +106,7 @@ public static Scheduler priority(int size) {
             TimeUnit.SECONDS,
             new PriorityBlockingQueue(),
             new NamedThreadFactory("threadforge-priority"),
-            new ThreadPoolExecutor.CallerRunsPolicy()
+            CALLER_RUNS_WHILE_ACTIVE
         );
         executor.allowCoreThreadTimeOut(true);
         return new Scheduler(executor, true, "priority(" + size + ")", false);
diff --git a/src/test/java/io/threadforge/SchedulerShutdownTest.java b/src/test/java/io/threadforge/SchedulerShutdownTest.java
new file mode 100644
index 0000000..008a676
--- /dev/null
+++ b/src/test/java/io/threadforge/SchedulerShutdownTest.java
@@ -0,0 +1,115 @@
+package io.threadforge;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SchedulerShutdownTest {
+
+    @Test
+    void fixedSchedulerRunsOnCallerWhenQueueIsFull() throws Exception {
+        Scheduler scheduler = Scheduler.fixed(1);
+        ThreadPoolExecutor executor = (ThreadPoolExecutor) scheduler.executor();
+        CountDownLatch workerStarted = new CountDownLatch(1);
+        CountDownLatch releaseWorker = new CountDownLatch(1);
+        try {
+            executor.execute(new Runnable() {
+                @Override
+                public void run() {
+                    workerStarted.countDown();
+                    try {
+                        releaseWorker.await();
+                    } catch (InterruptedException interrupted) {
+                        Thread.currentThread().interrupt();
+                    }
+                }
+            });
+            assertTrue(workerStarted.await(1L, TimeUnit.SECONDS));
+
+            int queueCapacity = executor.getQueue().remainingCapacity();
+            for (int i = 0; i < queueCapacity; i++) {
+                executor.execute(new Runnable() {
+                    @Override
+                    public void run() {
+                    }
+                });
+            }
+
+            final AtomicReference executionThread = new AtomicReference();
+            executor.execute(new Runnable() {
+                @Override
+                public void run() {
+                    executionThread.set(Thread.currentThread());
+                }
+            });
+
+            assertSame(Thread.currentThread(), executionThread.get());
+        } finally {
+            releaseWorker.countDown();
+            scheduler.shutdownIfOwned();
+        }
+    }
+
+    @Test
+    void submissionAfterOwnedSchedulerShutdownFailsTask() throws Exception {
+        Scheduler scheduler = Scheduler.fixed(1);
+        scheduler.shutdownIfOwned();
+
+        try (ThreadScope scope = ThreadScope.open().withScheduler(scheduler)) {
+            Task task = scope.submit(new java.util.concurrent.Callable() {
+                @Override
+                public Integer call() {
+                    return 1;
+                }
+            });
+
+            ExecutionException failure = assertThrows(ExecutionException.class, () ->
+                task.internalFuture().get(1L, TimeUnit.SECONDS));
+            assertTrue(failure.getCause() instanceof RejectedExecutionException);
+            assertEquals(Task.State.FAILED, task.state());
+        }
+    }
+
+    @Test
+    void closingFirstScopeMakesSharedOwnedSchedulerRejectSecondScope() throws Exception {
+        Scheduler scheduler = Scheduler.fixed(1);
+        ThreadScope first = ThreadScope.open().withScheduler(scheduler);
+        assertEquals(Integer.valueOf(1), first.submit(() -> 1).await());
+        first.close();
+
+        try (ThreadScope second = ThreadScope.open().withScheduler(scheduler)) {
+            Task rejected = second.submit(() -> 2);
+            ExecutionException failure = assertThrows(ExecutionException.class, () ->
+                rejected.internalFuture().get(1L, TimeUnit.SECONDS));
+            assertTrue(failure.getCause() instanceof RejectedExecutionException);
+            assertEquals(Task.State.FAILED, rejected.state());
+        }
+    }
+
+    @Test
+    void closingScopeDoesNotShutdownExternalScheduler() {
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try {
+            ThreadScope scope = ThreadScope.open().withScheduler(Scheduler.from(executor));
+            assertEquals(Integer.valueOf(1), scope.submit(() -> 1).await());
+            scope.close();
+
+            assertFalse(executor.isShutdown());
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+}

From 38843a924a1b8844c1fd3cee464bb6ec2e090739 Mon Sep 17 00:00:00 2001
From: JaveysWuu 
Date: Mon, 3 Aug 2026 20:53:46 +0800
Subject: [PATCH 11/17] fix(delay-scheduler): add explicit lifecycle management

---
 README.md                                     |  2 +
 docs/ai/threadforge-agents.md                 |  1 +
 docs/ai/threadforge.SKILL.md                  |  2 +
 docs/ai/threadforge.mdc                       |  2 +
 docs/api/runtime/DelayScheduler.md            | 19 +++-
 .../java/io/threadforge/DelayScheduler.java   | 17 +++-
 .../DelaySchedulerLifecycleTest.java          | 87 +++++++++++++++++++
 7 files changed, 125 insertions(+), 5 deletions(-)
 create mode 100644 src/test/java/io/threadforge/DelaySchedulerLifecycleTest.java

diff --git a/README.md b/README.md
index 5cd27c1..6b81952 100644
--- a/README.md
+++ b/README.md
@@ -245,6 +245,8 @@ ScheduledTask scheduleAtFixedRate(Duration initial, Duration period, Runnable ru
 ScheduledTask scheduleWithFixedDelay(Duration initial, Duration delay, Runnable runnable)
 ```
 
+`DelayScheduler.singleThread()` 拥有底层线程并实现 `AutoCloseable`,应使用 try-with-resources;`shared()` 和 `from(executor)` 的 `close()` 不会关闭共享或外部执行器。
+
 优先级调度器:
 
 ```java
diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md
index c8a0e75..8a32ae8 100644
--- a/docs/ai/threadforge-agents.md
+++ b/docs/ai/threadforge-agents.md
@@ -60,6 +60,7 @@ try (ThreadScope scope = ThreadScope.open()) {
 - `Scheduler.priority(n)` — priority-based pool (use with `TaskPriority`)
 - Owned schedulers (`fixed`/`priority`) belong to one scope and close with it; use `Scheduler.from(...)` for a caller-managed executor shared across scopes
 - Submissions to a shut-down scheduler are rejected instead of remaining pending
+- `DelayScheduler.singleThread()` is `AutoCloseable` and owned; closing `shared()` or `from(executor)` does not close shared/external executors
 
 ## Exceptions
 
diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md
index a00fbed..faf386f 100644
--- a/docs/ai/threadforge.SKILL.md
+++ b/docs/ai/threadforge.SKILL.md
@@ -258,6 +258,8 @@ ScheduledTask poll = scope.scheduleWithFixedDelay(
 t.cancel();
 ```
 
+For direct scheduling, `DelayScheduler.singleThread()` is owned and must be closed (prefer try-with-resources). Closing `DelayScheduler.shared()` or `DelayScheduler.from(executor)` does not close the shared or external executor.
+
 ### Task Composition
 
 ```java
diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc
index 8e180b2..041fa31 100644
--- a/docs/ai/threadforge.mdc
+++ b/docs/ai/threadforge.mdc
@@ -57,6 +57,8 @@ Scheduler.from(executorService) // Wrap external executor
 
 An owned scheduler (`fixed`/`priority`) belongs to one scope and is closed with it. Use `from(...)` with a caller-managed executor when multiple scopes must share an executor. Submissions after shutdown are rejected.
 
+`DelayScheduler.singleThread()` is `AutoCloseable` and owns its executor. Closing `DelayScheduler.shared()` or `DelayScheduler.from(executor)` does not close the shared or external executor.
+
 ## RetryPolicy
 
 ```java
diff --git a/docs/api/runtime/DelayScheduler.md b/docs/api/runtime/DelayScheduler.md
index d8b46cb..ba44077 100644
--- a/docs/api/runtime/DelayScheduler.md
+++ b/docs/api/runtime/DelayScheduler.md
@@ -7,14 +7,14 @@
 
 `DelayScheduler` 提供延迟和周期任务调度能力。
 
-- 类型:`public final class DelayScheduler`
+- 类型:`public final class DelayScheduler implements AutoCloseable`
 - 线程安全:可在多个 scope 间共享
 
 ## 工厂方法
 
 ### `static DelayScheduler singleThread()`
 
-创建单线程延迟调度器。
+创建拥有底层执行器的单线程延迟调度器。使用后应调用 `close()`,推荐 try-with-resources。
 
 ### `static DelayScheduler shared()`
 
@@ -27,6 +27,21 @@
 - 参数:`executor != null`
 - 生命周期:外部执行器由调用方负责关闭
 
+## 生命周期
+
+### `void close()`
+
+- `singleThread()`:关闭 owned executor,终止其线程;关闭后提交新任务会被拒绝
+- `shared()`:不关闭框架全局共享 executor
+- `from(executor)`:不关闭外部 executor
+- 重复调用是幂等的
+
+```java
+try (DelayScheduler scheduler = DelayScheduler.singleThread()) {
+    scheduler.schedule(Duration.ofMillis(10), () -> runOnce());
+}
+```
+
 ## 调度方法
 
 ### ` ScheduledTask schedule(Duration delay, Callable callable)`
diff --git a/src/main/java/io/threadforge/DelayScheduler.java b/src/main/java/io/threadforge/DelayScheduler.java
index 0b5e66e..42512f2 100644
--- a/src/main/java/io/threadforge/DelayScheduler.java
+++ b/src/main/java/io/threadforge/DelayScheduler.java
@@ -17,7 +17,7 @@
  * 

用于承载 once/fixed-rate/fixed-delay 三类定时任务。 * 在多个 scope 之间共享时是线程安全的。 */ -public final class DelayScheduler { +public final class DelayScheduler implements AutoCloseable { private static final DelayScheduler SHARED = new DelayScheduler(createSharedExecutor("threadforge-delay"), false); private static final DelayScheduler CONTROL = new DelayScheduler(createSharedExecutor("threadforge-control"), false); @@ -146,14 +146,25 @@ public ScheduledTask scheduleWithFixedDelay(Duration initial, Duration delay, Ru } /** - * 当当前调度器拥有执行器所有权时,关闭执行器。 + * 关闭此调度器拥有的执行器。 + * + *

{@link #shared()} 与 {@link #from(ScheduledExecutorService)} 不拥有执行器, + * 因此调用此方法不会关闭共享或外部执行器。重复关闭是安全的。 */ - void shutdownIfOwned() { + @Override + public void close() { if (ownsExecutor) { executor.shutdownNow(); } } + /** + * 当当前调度器拥有执行器所有权时,关闭执行器。 + */ + void shutdownIfOwned() { + close(); + } + /** * 创建框架默认共享的单线程调度执行器。 */ diff --git a/src/test/java/io/threadforge/DelaySchedulerLifecycleTest.java b/src/test/java/io/threadforge/DelaySchedulerLifecycleTest.java new file mode 100644 index 0000000..3ff578e --- /dev/null +++ b/src/test/java/io/threadforge/DelaySchedulerLifecycleTest.java @@ -0,0 +1,87 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DelaySchedulerLifecycleTest { + + @Test + void singleThreadSchedulerCanBeClosedAndRejectsNewTasks() throws Exception { + DelayScheduler scheduler = DelayScheduler.singleThread(); + CountDownLatch ran = new CountDownLatch(1); + AtomicReference schedulerThread = new AtomicReference(); + scheduler.schedule(Duration.ZERO, new Runnable() { + @Override + public void run() { + schedulerThread.set(Thread.currentThread()); + ran.countDown(); + } + }); + assertTrue(ran.await(1L, TimeUnit.SECONDS)); + + scheduler.close(); + schedulerThread.get().join(1000L); + + assertFalse(schedulerThread.get().isAlive()); + assertThrows(RejectedExecutionException.class, () -> + scheduler.schedule(Duration.ZERO, new Runnable() { + @Override + public void run() { + } + })); + } + + @Test + void closingOwnedSchedulerTwiceIsIdempotent() { + DelayScheduler scheduler = DelayScheduler.singleThread(); + scheduler.close(); + scheduler.close(); + } + + @Test + void closingSharedSchedulerDoesNotBreakIt() throws Exception { + DelayScheduler scheduler = DelayScheduler.shared(); + scheduler.close(); + + CountDownLatch ran = new CountDownLatch(1); + scheduler.schedule(Duration.ZERO, new Runnable() { + @Override + public void run() { + ran.countDown(); + } + }); + assertTrue(ran.await(1L, TimeUnit.SECONDS)); + } + + @Test + void closingExternalWrapperDoesNotCloseExternalExecutor() throws Exception { + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + try { + DelayScheduler scheduler = DelayScheduler.from(executor); + scheduler.close(); + + assertFalse(executor.isShutdown()); + CountDownLatch ran = new CountDownLatch(1); + scheduler.schedule(Duration.ZERO, new Runnable() { + @Override + public void run() { + ran.countDown(); + } + }); + assertTrue(ran.await(1L, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } +} From 71ac85efbb883762225b9d86a90dcb886018201f Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 20:57:09 +0800 Subject: [PATCH 12/17] fix(delay-scheduler): validate scheduling durations --- docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 1 + docs/ai/threadforge.mdc | 1 + docs/api/runtime/DelayScheduler.md | 3 + .../java/io/threadforge/DelayScheduler.java | 33 ++- .../DelaySchedulerDurationValidationTest.java | 230 ++++++++++++++++++ 6 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 src/test/java/io/threadforge/DelaySchedulerDurationValidationTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index 8a32ae8..c0c53b0 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -61,6 +61,7 @@ try (ThreadScope scope = ThreadScope.open()) { - Owned schedulers (`fixed`/`priority`) belong to one scope and close with it; use `Scheduler.from(...)` for a caller-managed executor shared across scopes - Submissions to a shut-down scheduler are rejected instead of remaining pending - `DelayScheduler.singleThread()` is `AutoCloseable` and owned; closing `shared()` or `from(executor)` does not close shared/external executors +- One-shot/initial delays may be zero but not negative; fixed-rate periods and fixed delays must be positive and retain nanosecond precision ## Exceptions diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index faf386f..4de3daa 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -259,6 +259,7 @@ t.cancel(); ``` For direct scheduling, `DelayScheduler.singleThread()` is owned and must be closed (prefer try-with-resources). Closing `DelayScheduler.shared()` or `DelayScheduler.from(executor)` does not close the shared or external executor. +One-shot delays and periodic initial delays may be zero but not negative; fixed-rate periods and fixed delays must be positive. Scheduling preserves nanosecond precision. ### Task Composition diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index 041fa31..c892782 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -58,6 +58,7 @@ Scheduler.from(executorService) // Wrap external executor An owned scheduler (`fixed`/`priority`) belongs to one scope and is closed with it. Use `from(...)` with a caller-managed executor when multiple scopes must share an executor. Submissions after shutdown are rejected. `DelayScheduler.singleThread()` is `AutoCloseable` and owns its executor. Closing `DelayScheduler.shared()` or `DelayScheduler.from(executor)` does not close the shared or external executor. +One-shot delays and periodic initial delays may be zero but not negative; fixed-rate periods and fixed delays must be positive. Scheduling preserves nanosecond precision. ## RetryPolicy diff --git a/docs/api/runtime/DelayScheduler.md b/docs/api/runtime/DelayScheduler.md index ba44077..8220ea8 100644 --- a/docs/api/runtime/DelayScheduler.md +++ b/docs/api/runtime/DelayScheduler.md @@ -63,6 +63,9 @@ try (DelayScheduler scheduler = DelayScheduler.singleThread()) { 通用约束: - 参数不可为 `null` +- 一次性 `delay` 和周期任务 `initial` 必须大于等于 0 +- fixed-rate `period` 和 fixed-delay `delay` 必须大于 0 +- 使用纳秒精度;超出 `long` 纳秒范围的正 Duration 按 `Long.MAX_VALUE` 纳秒处理 - 返回值均为 `ScheduledTask`,可取消 ## 与 ThreadScope 的关系 diff --git a/src/main/java/io/threadforge/DelayScheduler.java b/src/main/java/io/threadforge/DelayScheduler.java index 42512f2..4f89a30 100644 --- a/src/main/java/io/threadforge/DelayScheduler.java +++ b/src/main/java/io/threadforge/DelayScheduler.java @@ -79,6 +79,7 @@ public static DelayScheduler from(ScheduledExecutorService executor) { public ScheduledTask schedule(Duration delay, final Callable callable) { Objects.requireNonNull(delay, "delay"); Objects.requireNonNull(callable, "callable"); + long delayNanos = toNanos(delay, "delay", true); ScheduledFuture future = executor.schedule(new Runnable() { @Override public void run() { @@ -88,7 +89,7 @@ public void run() { throw new RuntimeException(e); } } - }, delay.toMillis(), TimeUnit.MILLISECONDS); + }, delayNanos, TimeUnit.NANOSECONDS); return new DefaultScheduledTask(future); } @@ -98,7 +99,8 @@ public void run() { public ScheduledTask schedule(Duration delay, final Runnable runnable) { Objects.requireNonNull(delay, "delay"); Objects.requireNonNull(runnable, "runnable"); - ScheduledFuture future = executor.schedule(runnable, delay.toMillis(), TimeUnit.MILLISECONDS); + long delayNanos = toNanos(delay, "delay", true); + ScheduledFuture future = executor.schedule(runnable, delayNanos, TimeUnit.NANOSECONDS); return new DefaultScheduledTask(future); } @@ -118,11 +120,13 @@ public ScheduledTask scheduleAtFixedRate(Duration initial, Duration period, Runn Objects.requireNonNull(initial, "initial"); Objects.requireNonNull(period, "period"); Objects.requireNonNull(runnable, "runnable"); + long initialNanos = toNanos(initial, "initial delay", true); + long periodNanos = toNanos(period, "period", false); ScheduledFuture future = executor.scheduleAtFixedRate( runnable, - initial.toMillis(), - period.toMillis(), - TimeUnit.MILLISECONDS + initialNanos, + periodNanos, + TimeUnit.NANOSECONDS ); return new DefaultScheduledTask(future); } @@ -136,11 +140,13 @@ public ScheduledTask scheduleWithFixedDelay(Duration initial, Duration delay, Ru Objects.requireNonNull(initial, "initial"); Objects.requireNonNull(delay, "delay"); Objects.requireNonNull(runnable, "runnable"); + long initialNanos = toNanos(initial, "initial delay", true); + long delayNanos = toNanos(delay, "delay", false); ScheduledFuture future = executor.scheduleWithFixedDelay( runnable, - initial.toMillis(), - delay.toMillis(), - TimeUnit.MILLISECONDS + initialNanos, + delayNanos, + TimeUnit.NANOSECONDS ); return new DefaultScheduledTask(future); } @@ -165,6 +171,17 @@ void shutdownIfOwned() { close(); } + private static long toNanos(Duration duration, String name, boolean allowZero) { + if (duration.isNegative() || (!allowZero && duration.isZero())) { + throw new IllegalArgumentException(name + (allowZero ? " must be >= 0" : " must be > 0")); + } + try { + return duration.toNanos(); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; + } + } + /** * 创建框架默认共享的单线程调度执行器。 */ diff --git a/src/test/java/io/threadforge/DelaySchedulerDurationValidationTest.java b/src/test/java/io/threadforge/DelaySchedulerDurationValidationTest.java new file mode 100644 index 0000000..5d952dc --- /dev/null +++ b/src/test/java/io/threadforge/DelaySchedulerDurationValidationTest.java @@ -0,0 +1,230 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.Delayed; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DelaySchedulerDurationValidationTest { + + @Test + void oneShotDelayRejectsNegativeAndAllowsZero() { + RecordingExecutor executor = new RecordingExecutor(); + try { + DelayScheduler scheduler = DelayScheduler.from(executor); + + assertThrows(IllegalArgumentException.class, () -> + scheduler.schedule(Duration.ofNanos(-1L), new Runnable() { + @Override + public void run() { + } + })); + assertThrows(IllegalArgumentException.class, () -> + scheduler.schedule(Duration.ofNanos(-1L), new Callable() { + @Override + public Integer call() { + return 1; + } + })); + assertEquals(0, executor.calls); + + scheduler.schedule(Duration.ZERO, new Runnable() { + @Override + public void run() { + } + }); + assertEquals(0L, executor.delay); + assertEquals(TimeUnit.NANOSECONDS, executor.unit); + } finally { + executor.shutdownNow(); + } + } + + @Test + void oneShotDelayPreservesNanosecondsAndSaturatesHugeDuration() { + RecordingExecutor executor = new RecordingExecutor(); + try { + DelayScheduler scheduler = DelayScheduler.from(executor); + + scheduler.schedule(Duration.ofNanos(1L), new Runnable() { + @Override + public void run() { + } + }); + assertEquals(1L, executor.delay); + assertEquals(TimeUnit.NANOSECONDS, executor.unit); + + scheduler.schedule(Duration.ofSeconds(Long.MAX_VALUE), new Runnable() { + @Override + public void run() { + } + }); + assertEquals(Long.MAX_VALUE, executor.delay); + assertEquals(TimeUnit.NANOSECONDS, executor.unit); + } finally { + executor.shutdownNow(); + } + } + + @Test + void fixedRateValidatesInitialDelayAndPositivePeriod() { + RecordingExecutor executor = new RecordingExecutor(); + try { + DelayScheduler scheduler = DelayScheduler.from(executor); + Runnable runnable = new Runnable() { + @Override + public void run() { + } + }; + + assertThrows(IllegalArgumentException.class, () -> + scheduler.scheduleAtFixedRate(Duration.ofNanos(-1L), Duration.ofNanos(1L), runnable)); + assertThrows(IllegalArgumentException.class, () -> + scheduler.scheduleAtFixedRate(Duration.ZERO, Duration.ZERO, runnable)); + assertThrows(IllegalArgumentException.class, () -> + scheduler.scheduleAtFixedRate(Duration.ZERO, Duration.ofNanos(-1L), runnable)); + assertEquals(0, executor.calls); + + scheduler.scheduleAtFixedRate(Duration.ZERO, Duration.ofNanos(1L), runnable); + assertEquals(0L, executor.initialDelay); + assertEquals(1L, executor.interval); + assertEquals(TimeUnit.NANOSECONDS, executor.unit); + } finally { + executor.shutdownNow(); + } + } + + @Test + void fixedDelayValidatesInitialDelayAndPositiveDelay() { + RecordingExecutor executor = new RecordingExecutor(); + try { + DelayScheduler scheduler = DelayScheduler.from(executor); + Runnable runnable = new Runnable() { + @Override + public void run() { + } + }; + + assertThrows(IllegalArgumentException.class, () -> + scheduler.scheduleWithFixedDelay(Duration.ofNanos(-1L), Duration.ofNanos(1L), runnable)); + assertThrows(IllegalArgumentException.class, () -> + scheduler.scheduleWithFixedDelay(Duration.ZERO, Duration.ZERO, runnable)); + assertThrows(IllegalArgumentException.class, () -> + scheduler.scheduleWithFixedDelay(Duration.ZERO, Duration.ofNanos(-1L), runnable)); + assertEquals(0, executor.calls); + + scheduler.scheduleWithFixedDelay(Duration.ZERO, Duration.ofNanos(1L), runnable); + assertEquals(0L, executor.initialDelay); + assertEquals(1L, executor.interval); + assertEquals(TimeUnit.NANOSECONDS, executor.unit); + } finally { + executor.shutdownNow(); + } + } + + private static final class RecordingExecutor extends ScheduledThreadPoolExecutor { + private int calls; + private long delay; + private long initialDelay; + private long interval; + private TimeUnit unit; + + private RecordingExecutor() { + super(1); + } + + @Override + public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + calls++; + this.delay = delay; + this.unit = unit; + return new NeverScheduledFuture(); + } + + @Override + public ScheduledFuture schedule(Callable callable, long delay, TimeUnit unit) { + calls++; + this.delay = delay; + this.unit = unit; + return new NeverScheduledFuture(); + } + + @Override + public ScheduledFuture scheduleAtFixedRate( + Runnable command, + long initialDelay, + long period, + TimeUnit unit + ) { + calls++; + this.initialDelay = initialDelay; + this.interval = period; + this.unit = unit; + return new NeverScheduledFuture(); + } + + @Override + public ScheduledFuture scheduleWithFixedDelay( + Runnable command, + long initialDelay, + long delay, + TimeUnit unit + ) { + calls++; + this.initialDelay = initialDelay; + this.interval = delay; + this.unit = unit; + return new NeverScheduledFuture(); + } + } + + private static final class NeverScheduledFuture implements ScheduledFuture { + private boolean cancelled; + + @Override + public long getDelay(TimeUnit unit) { + return Long.MAX_VALUE; + } + + @Override + public int compareTo(Delayed other) { + return 0; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + cancelled = true; + return true; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public boolean isDone() { + return cancelled; + } + + @Override + public V get() throws InterruptedException, ExecutionException { + throw new UnsupportedOperationException(); + } + + @Override + public V get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + throw new TimeoutException(); + } + } +} From 2265f8ec3e1d11d06ec9a80d2483e7564c1a0769 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 21:01:36 +0800 Subject: [PATCH 13/17] fix(retry): validate exponential backoff multiplier --- docs/ai/threadforge-agents.md | 1 + docs/ai/threadforge.SKILL.md | 1 + docs/ai/threadforge.mdc | 1 + docs/api/control/RetryPolicy.md | 2 + src/main/java/io/threadforge/RetryPolicy.java | 56 ++++++++++++--- .../RetryPolicyValidationTest.java | 72 +++++++++++++++++++ 6 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 src/test/java/io/threadforge/RetryPolicyValidationTest.java diff --git a/docs/ai/threadforge-agents.md b/docs/ai/threadforge-agents.md index c0c53b0..1ee1c97 100644 --- a/docs/ai/threadforge-agents.md +++ b/docs/ai/threadforge-agents.md @@ -48,6 +48,7 @@ try (ThreadScope scope = ThreadScope.open()) { - Always use try-with-resources for `ThreadScope` - Default deadline is 30 seconds — override with `.withDeadline()` - `RetryPolicy.maxAttempts` includes the first attempt (3 = 1 initial + 2 retries) +- Exponential-backoff multipliers must be finite and at least `1.0`; delays clamp safely to `maxDelay` - `Context` auto-propagates from submit thread to task thread - `ScopeJoiner` launches tasks inside the same `ThreadScope`; deadline, cancellation, retry, and hooks still apply - Basic `Runnable` submissions use scope defaults; use `Callable` overloads for per-task priority, retry, or timeout overrides diff --git a/docs/ai/threadforge.SKILL.md b/docs/ai/threadforge.SKILL.md index 4de3daa..480a17f 100644 --- a/docs/ai/threadforge.SKILL.md +++ b/docs/ai/threadforge.SKILL.md @@ -192,6 +192,7 @@ RetryPolicy.builder() // Full customiza ``` Note: `maxAttempts` includes the first attempt. CancelledException and Error are never retried by default. +Exponential-backoff multipliers must be finite and at least `1.0`; computed delays are clamped to `maxDelay` without numeric overflow. ### Scheduler diff --git a/docs/ai/threadforge.mdc b/docs/ai/threadforge.mdc index c892782..c1e9641 100644 --- a/docs/ai/threadforge.mdc +++ b/docs/ai/threadforge.mdc @@ -69,6 +69,7 @@ RetryPolicy.exponentialBackoff(3, initial, 2.0, maxDelay) // Exponential backof ``` Note: `maxAttempts` includes the first attempt (3 = 1 initial + 2 retries). +Exponential-backoff multipliers must be finite and at least `1.0`; computed delays are clamped to `maxDelay` without numeric overflow. ## ScopeJoiner diff --git a/docs/api/control/RetryPolicy.md b/docs/api/control/RetryPolicy.md index 0108f23..0e38833 100644 --- a/docs/api/control/RetryPolicy.md +++ b/docs/api/control/RetryPolicy.md @@ -28,7 +28,9 @@ ### `RetryPolicy.exponentialBackoff(int maxAttempts, Duration initialDelay, double multiplier, Duration maxDelay)` - 指数退避重试,延迟计算为 `initialDelay * multiplier^(attempt-1)` +- `multiplier` 必须是有限数且大于等于 `1.0` - 延迟不会超过 `maxDelay` +- 超大 Duration 或计算溢出时仍稳定截断到 `maxDelay`,不会产生负延迟 ## 在 ThreadScope 中使用 diff --git a/src/main/java/io/threadforge/RetryPolicy.java b/src/main/java/io/threadforge/RetryPolicy.java index 861cd65..72a30d6 100644 --- a/src/main/java/io/threadforge/RetryPolicy.java +++ b/src/main/java/io/threadforge/RetryPolicy.java @@ -1,5 +1,7 @@ package io.threadforge; +import java.math.BigDecimal; +import java.math.BigInteger; import java.time.Duration; import java.util.Objects; @@ -10,6 +12,8 @@ */ public final class RetryPolicy { + private static final BigInteger NANOS_PER_SECOND = BigInteger.valueOf(1_000_000_000L); + /** * Decide whether to retry after a failed attempt. */ @@ -192,22 +196,14 @@ public Builder exponentialBackoff( if (maxDelay.isNegative() || maxDelay.isZero()) { throw new IllegalArgumentException("maxDelay must be > 0"); } - if (multiplier < 1.0d) { - throw new IllegalArgumentException("multiplier must be >= 1.0"); + if (Double.isNaN(multiplier) || Double.isInfinite(multiplier) || multiplier < 1.0d) { + throw new IllegalArgumentException("multiplier must be finite and >= 1.0"); } this.backoffStrategy = new BackoffStrategy() { @Override public Duration nextDelay(int attempt, Throwable failure) { int exponent = Math.max(0, attempt - 1); - double computed = initialDelay.toNanos() * Math.pow(multiplier, exponent); - if (Double.isInfinite(computed) || computed >= Long.MAX_VALUE) { - return maxDelay; - } - long delayNanos = Math.min((long) computed, maxDelay.toNanos()); - if (delayNanos <= 0L) { - return Duration.ZERO; - } - return Duration.ofNanos(delayNanos); + return exponentialDelay(initialDelay, multiplier, exponent, maxDelay); } }; return this; @@ -217,4 +213,42 @@ public RetryPolicy build() { return new RetryPolicy(maxAttempts, retryCondition, backoffStrategy); } } + + private static Duration exponentialDelay( + Duration initialDelay, + double multiplier, + int exponent, + Duration maxDelay + ) { + if (initialDelay.isZero()) { + return Duration.ZERO; + } + if (initialDelay.compareTo(maxDelay) >= 0) { + return maxDelay; + } + if (exponent == 0 || multiplier == 1.0d) { + return initialDelay; + } + + double factor = Math.pow(multiplier, exponent); + if (Double.isInfinite(factor)) { + return maxDelay; + } + + BigDecimal computedNanos = new BigDecimal(toNanos(initialDelay)) + .multiply(BigDecimal.valueOf(factor)); + BigInteger maxNanos = toNanos(maxDelay); + if (computedNanos.compareTo(new BigDecimal(maxNanos)) >= 0) { + return maxDelay; + } + + BigInteger[] secondsAndNanos = computedNanos.toBigInteger().divideAndRemainder(NANOS_PER_SECOND); + return Duration.ofSeconds(secondsAndNanos[0].longValueExact(), secondsAndNanos[1].longValue()); + } + + private static BigInteger toNanos(Duration duration) { + return BigInteger.valueOf(duration.getSeconds()) + .multiply(NANOS_PER_SECOND) + .add(BigInteger.valueOf(duration.getNano())); + } } diff --git a/src/test/java/io/threadforge/RetryPolicyValidationTest.java b/src/test/java/io/threadforge/RetryPolicyValidationTest.java new file mode 100644 index 0000000..5126a22 --- /dev/null +++ b/src/test/java/io/threadforge/RetryPolicyValidationTest.java @@ -0,0 +1,72 @@ +package io.threadforge; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RetryPolicyValidationTest { + + @Test + void exponentialBackoffRejectsNonFiniteAndTooSmallMultipliers() { + assertInvalidMultiplier(Double.NaN); + assertInvalidMultiplier(Double.POSITIVE_INFINITY); + assertInvalidMultiplier(Double.NEGATIVE_INFINITY); + assertInvalidMultiplier(0.999d); + } + + @Test + void exponentialBackoffAcceptsOneAndNormalMultipliers() { + RetryPolicy constant = RetryPolicy.exponentialBackoff( + 3, Duration.ofMillis(10), 1.0d, Duration.ofSeconds(1)); + assertEquals(Duration.ofMillis(10), constant.nextDelay(1, new RuntimeException("x"))); + assertEquals(Duration.ofMillis(10), constant.nextDelay(2, new RuntimeException("x"))); + + RetryPolicy doubling = RetryPolicy.exponentialBackoff( + 4, Duration.ofMillis(10), 2.0d, Duration.ofSeconds(1)); + assertEquals(Duration.ofMillis(10), doubling.nextDelay(1, new RuntimeException("x"))); + assertEquals(Duration.ofMillis(20), doubling.nextDelay(2, new RuntimeException("x"))); + assertEquals(Duration.ofMillis(40), doubling.nextDelay(3, new RuntimeException("x"))); + } + + @Test + void exponentialBackoffClampsCalculationOverflowToMaxDelay() { + Duration initial = Duration.ofSeconds(Long.MAX_VALUE / 4L); + Duration max = Duration.ofSeconds(Long.MAX_VALUE); + RetryPolicy policy = RetryPolicy.exponentialBackoff(3, initial, 8.0d, max); + + Duration delay = policy.nextDelay(2, new RuntimeException("x")); + + assertEquals(max, delay); + assertFalse(delay.isNegative()); + } + + @Test + void exponentialBackoffPreservesHugeDelayWhenMultiplierIsOne() { + Duration initial = Duration.ofSeconds(Long.MAX_VALUE / 4L); + Duration max = Duration.ofSeconds(Long.MAX_VALUE); + RetryPolicy policy = RetryPolicy.exponentialBackoff(3, initial, 1.0d, max); + + assertEquals(initial, policy.nextDelay(2, new RuntimeException("x"))); + } + + @Test + void exponentialBackoffAlwaysClampsAtMaxDelay() { + Duration max = Duration.ofMillis(25); + RetryPolicy policy = RetryPolicy.exponentialBackoff( + 5, Duration.ofMillis(10), 3.0d, max); + + assertEquals(Duration.ofMillis(10), policy.nextDelay(1, new RuntimeException("x"))); + assertEquals(max, policy.nextDelay(2, new RuntimeException("x"))); + assertEquals(max, policy.nextDelay(4, new RuntimeException("x"))); + } + + private static void assertInvalidMultiplier(final double multiplier) { + assertThrows(IllegalArgumentException.class, () -> + RetryPolicy.exponentialBackoff( + 3, Duration.ofMillis(1), multiplier, Duration.ofSeconds(1))); + } +} From 8cac90619d3174c6c1a9cc3ecb1737c0d2278a49 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 21:04:56 +0800 Subject: [PATCH 14/17] test(ci): verify integrations and auxiliary modules --- .github/workflows/ci.yml | 29 ++++++++++++ integrations/threadforge-micrometer/pom.xml | 13 ++++++ .../micrometer/MicrometerThreadHookTest.java | 44 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 integrations/threadforge-micrometer/src/test/java/io/threadforge/micrometer/MicrometerThreadHookTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c77523..bd43a28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,32 @@ jobs: target/*.jar target/site/jacoco/** if-no-files-found: error + + auxiliary-modules: + name: Verify integrations, examples, and benchmarks + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Temurin JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Install current core + run: mvn -B -ntp clean install + + - name: Test SLF4J integration + run: mvn -B -ntp -f integrations/threadforge-slf4j/pom.xml clean verify + + - name: Test Micrometer integration + run: mvn -B -ntp -f integrations/threadforge-micrometer/pom.xml clean verify + + - name: Compile examples + run: mvn -B -ntp -f examples/pom.xml clean package + + - name: Compile benchmarks + run: mvn -B -ntp -f benchmarks/pom.xml clean package -DskipTests diff --git a/integrations/threadforge-micrometer/pom.xml b/integrations/threadforge-micrometer/pom.xml index 53a520a..af1bba6 100644 --- a/integrations/threadforge-micrometer/pom.xml +++ b/integrations/threadforge-micrometer/pom.xml @@ -14,6 +14,8 @@ 1.2.1 1.13.6 3.14.1 + 5.11.4 + 3.5.4 @@ -27,6 +29,12 @@ micrometer-core ${micrometer.version} + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + @@ -39,6 +47,11 @@ ${maven.compiler.release} + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.surefire.version} + diff --git a/integrations/threadforge-micrometer/src/test/java/io/threadforge/micrometer/MicrometerThreadHookTest.java b/integrations/threadforge-micrometer/src/test/java/io/threadforge/micrometer/MicrometerThreadHookTest.java new file mode 100644 index 0000000..6f387aa --- /dev/null +++ b/integrations/threadforge-micrometer/src/test/java/io/threadforge/micrometer/MicrometerThreadHookTest.java @@ -0,0 +1,44 @@ +package io.threadforge.micrometer; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.threadforge.TaskInfo; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MicrometerThreadHookTest { + + @Test + void recordsStartAndEveryTerminalState() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + MicrometerThreadHook hook = MicrometerThreadHook.create(registry, "test.task"); + TaskInfo info = new TaskInfo(1L, 1L, "task", Instant.now(), "fixed"); + + hook.onStart(info); + hook.onSuccess(info, Duration.ofMillis(10)); + hook.onFailure(info, new IllegalStateException("boom"), Duration.ofMillis(20)); + hook.onCancel(info, Duration.ofMillis(30)); + + assertEquals(1.0d, registry.get("test.task.started") + .tags("scheduler", "fixed", "state", "started").counter().count()); + assertEquals(1.0d, completed(registry, "success")); + assertEquals(1.0d, completed(registry, "failed")); + assertEquals(1.0d, completed(registry, "cancelled")); + assertEquals(1L, durationCount(registry, "success")); + assertEquals(1L, durationCount(registry, "failed")); + assertEquals(1L, durationCount(registry, "cancelled")); + } + + private static double completed(SimpleMeterRegistry registry, String state) { + return registry.get("test.task.completed") + .tags("scheduler", "fixed", "state", state).counter().count(); + } + + private static long durationCount(SimpleMeterRegistry registry, String state) { + return registry.get("test.task.duration") + .tags("scheduler", "fixed", "state", state).timer().count(); + } +} From 4e525c840743ba9aa7ce42a97e39113bdddc0b92 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 3 Aug 2026 21:12:31 +0800 Subject: [PATCH 15/17] fix(task): complete physical termination after cleanup --- src/main/java/io/threadforge/Task.java | 35 ++++++++++++------- .../io/threadforge/TaskLifecycleTest.java | 2 +- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/threadforge/Task.java b/src/main/java/io/threadforge/Task.java index d52397e..6e2288f 100644 --- a/src/main/java/io/threadforge/Task.java +++ b/src/main/java/io/threadforge/Task.java @@ -24,6 +24,12 @@ */ public final class Task { + private static final Runnable NOOP_CALLBACK = new Runnable() { + @Override + public void run() { + } + }; + /** 任务生命周期状态。 */ public enum State { /** 已创建但尚未运行。 */ @@ -47,6 +53,7 @@ public enum State { private State state; private Thread runnerThread; private boolean executionEntered; + private boolean executionFinishing; private Future execution; private Runnable executionFinishedCallback; private Throwable terminalFailure; @@ -130,7 +137,7 @@ public boolean cancel() { runner = runnerThread; executionToCancel = execution; if (!executionEntered) { - callback = markExecutionFinishedLocked(); + callback = beginExecutionFinishedLocked(); } } if (executionToCancel != null) { @@ -138,7 +145,7 @@ public boolean cancel() { } else if (runner != null) { runner.interrupt(); } - runCallback(callback); + finishExecution(callback); return true; } @@ -245,9 +252,9 @@ void markExecutionFinished(Thread runner) { if (runnerThread == runner) { runnerThread = null; } - callback = markExecutionFinishedLocked(); + callback = beginExecutionFinishedLocked(); } - runCallback(callback); + finishExecution(callback); } boolean completeSuccess(T value) { @@ -284,7 +291,7 @@ boolean completeFailure(Throwable failure, boolean interrupt) { runner = runnerThread; executionToCancel = execution; if (!executionEntered) { - callback = markExecutionFinishedLocked(); + callback = beginExecutionFinishedLocked(); } } if (interrupt) { @@ -294,7 +301,7 @@ boolean completeFailure(Throwable failure, boolean interrupt) { runner.interrupt(); } } - runCallback(callback); + finishExecution(callback); return true; } @@ -403,23 +410,27 @@ Throwable terminalFailure() { } } - private Runnable markExecutionFinishedLocked() { - if (executionFinished.isDone()) { + private Runnable beginExecutionFinishedLocked() { + if (executionFinishing || executionFinished.isDone()) { return null; } - executionFinished.complete(null); + executionFinishing = true; Runnable callback = executionFinishedCallback; executionFinishedCallback = null; - return callback; + return callback == null ? NOOP_CALLBACK : callback; } private static boolean isTerminal(State state) { return state == State.SUCCESS || state == State.FAILED || state == State.CANCELLED; } - private static void runCallback(Runnable callback) { + private void finishExecution(Runnable callback) { if (callback != null) { - callback.run(); + try { + callback.run(); + } finally { + executionFinished.complete(null); + } } } diff --git a/src/test/java/io/threadforge/TaskLifecycleTest.java b/src/test/java/io/threadforge/TaskLifecycleTest.java index ba0ace1..30cf2fe 100644 --- a/src/test/java/io/threadforge/TaskLifecycleTest.java +++ b/src/test/java/io/threadforge/TaskLifecycleTest.java @@ -210,7 +210,7 @@ public Integer call() { } } - @Test + @RepeatedTest(50) void scopeTracksTimedOutTaskUntilIgnoringRunnerActuallyExits() throws Exception { CountDownLatch started = new CountDownLatch(1); CountDownLatch timeoutInterruptObserved = new CountDownLatch(1); From 7652720d7bd6ff1fde24f96a4946f82e5dc4941f Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 10 Aug 2026 10:15:51 +0800 Subject: [PATCH 16/17] test(ci): stabilize runner-dependent concurrency tests --- .../io/threadforge/AwaitInterruptionTest.java | 30 +++++++++++-------- .../OpenTelemetryThreadContextTest.java | 3 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/test/java/io/threadforge/AwaitInterruptionTest.java b/src/test/java/io/threadforge/AwaitInterruptionTest.java index d87a420..52f2a9d 100644 --- a/src/test/java/io/threadforge/AwaitInterruptionTest.java +++ b/src/test/java/io/threadforge/AwaitInterruptionTest.java @@ -4,6 +4,8 @@ import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; @@ -17,22 +19,26 @@ class AwaitInterruptionTest { void taskAwaitPropagatesWaiterInterruptionWithoutChangingTask() throws Exception { CountDownLatch taskStarted = new CountDownLatch(1); CountDownLatch releaseTask = new CountDownLatch(1); - try (ThreadScope scope = ThreadScope.open()) { - Task task = scope.submit(blockingTask(taskStarted, releaseTask)); - assertTrue(taskStarted.await(1L, TimeUnit.SECONDS)); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + try (ThreadScope scope = ThreadScope.open().withScheduler(Scheduler.from(worker))) { + Task task = scope.submit(blockingTask(taskStarted, releaseTask)); + assertTrue(taskStarted.await(1L, TimeUnit.SECONDS)); - WaitResult result = interruptWaiter(new Runnable() { - @Override - public void run() { - task.await(); - } - }); + WaitResult result = interruptWaiter(new Runnable() { + @Override + public void run() { + task.await(); + } + }); - assertTrue(result.failure.get() instanceof CancelledException); - assertTrue(result.interruptPreserved.get()); - assertEquals(Task.State.RUNNING, task.state()); + assertTrue(result.failure.get() instanceof CancelledException); + assertTrue(result.interruptPreserved.get()); + assertEquals(Task.State.RUNNING, task.state()); + } } finally { releaseTask.countDown(); + worker.shutdownNow(); } } diff --git a/src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java b/src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java index 67da112..ba78a26 100644 --- a/src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java +++ b/src/test/java/io/threadforge/OpenTelemetryThreadContextTest.java @@ -53,6 +53,7 @@ void successRestoresWorkerOpenTelemetryContext() throws Exception { @Test void runningTimeoutClosesOpenTelemetryScopeOnRunner() throws Exception { ExecutorService worker = Executors.newSingleThreadExecutor(); + worker.submit(() -> null).get(1L, TimeUnit.SECONDS); CountDownLatch started = new CountDownLatch(1); CountDownLatch interruptObserved = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -72,7 +73,7 @@ public Void call() { } } } - }, Duration.ofMillis(40)); + }, Duration.ofMillis(500)); assertTrue(started.await(1L, TimeUnit.SECONDS)); assertThrows(TaskTimeoutException.class, task::await); assertTrue(interruptObserved.await(1L, TimeUnit.SECONDS)); From a0d5ffad72ad18c65a0ccd2fda71439a423bcd43 Mon Sep 17 00:00:00 2001 From: JaveysWuu Date: Mon, 10 Aug 2026 10:19:14 +0800 Subject: [PATCH 17/17] test(ci): make first-success cancellation deterministic --- src/test/java/io/threadforge/ScopeJoinerTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/io/threadforge/ScopeJoinerTest.java b/src/test/java/io/threadforge/ScopeJoinerTest.java index 712f5b4..7e2935e 100644 --- a/src/test/java/io/threadforge/ScopeJoinerTest.java +++ b/src/test/java/io/threadforge/ScopeJoinerTest.java @@ -30,7 +30,7 @@ void firstSuccessReturnsFastestSuccessfulResultAndCancelsLosers() throws Excepti public String call() throws Exception { slowStarted.countDown(); try { - Thread.sleep(1000L); + new CountDownLatch(1).await(); } catch (InterruptedException interruptedException) { slowCancelled.countDown(); throw interruptedException; @@ -40,7 +40,8 @@ public String call() throws Exception { }, new Callable() { @Override - public String call() { + public String call() throws Exception { + assertTrue(slowStarted.await(1L, TimeUnit.SECONDS)); return "fast"; } }