001/* 002 * Copyright 2015-2022 Transmogrify LLC, 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.pyranid; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.sql.SQLFeatureNotSupportedException; 025import java.time.Duration; 026import java.time.Instant; 027import java.util.ArrayList; 028import java.util.List; 029import java.util.Optional; 030import java.util.Set; 031import java.util.concurrent.CancellationException; 032import java.util.concurrent.CompletableFuture; 033import java.util.concurrent.CompletionStage; 034import java.util.concurrent.CountDownLatch; 035import java.util.concurrent.ExecutionException; 036import java.util.concurrent.Executor; 037import java.util.concurrent.RejectedExecutionException; 038import java.util.concurrent.TimeUnit; 039import java.util.concurrent.ThreadFactory; 040import java.util.concurrent.atomic.AtomicBoolean; 041import java.util.concurrent.atomic.AtomicReference; 042import java.util.concurrent.atomic.AtomicLong; 043import java.util.concurrent.locks.ReentrantLock; 044import java.util.function.LongSupplier; 045import java.util.function.Consumer; 046import java.util.function.Function; 047import java.util.function.Predicate; 048 049import static java.util.Objects.requireNonNull; 050 051/** 052 * An explicitly started, single-use supervisor for notification-driven durable reconciliation. 053 * <p> 054 * The supervisor registers its channels, reconciles authoritative state, then reconciles once per nonempty 055 * notification batch. A selected retry opens a new session and repeats initial reconciliation. Notifications 056 * remain lossy hints: enable {@link Builder#pollEvery(Duration)} for independent durable-state polling. 057 * Managed reconciliation is serialized and coalesces requests, and must be bounded and repeatable. 058 * <p> 059 * {@link #start()} creates managed workers using named daemon threads by default; customize their creation with 060 * {@link Builder#threadFactory(Function)}. Call {@link #requestShutdown()} and await shutdown for orderly cleanup. 061 * The advanced {@link #start(Executor)} mode uses an application-owned asynchronous executor with synchronous 062 * reconciliation and does not support polling or managed thread factories. The data source is always caller-owned. 063 * A listener needs a session-affine source and holds one connection; ordinary reconciliation queries acquire their 064 * own connections. See {@link Database#withNotificationSession}. 065 * <p> 066 * Completion-stage views cannot be used to cancel or complete this supervisor. Use {@link #requestShutdown()} to 067 * stop it. Stage continuations may run on the completing thread: keep them short or use asynchronous continuations. 068 * Neither a receive timeout nor an {@link State#ACTIVE} snapshot proves that the physical connection is healthy. 069 * 070 * @author <a href="https://www.revetkn.com">Mark Allen</a> 071 * @since 4.8.0 072 */ 073@ThreadSafe 074public final class NotificationSupervisor { 075 private static final Duration RECEIVE_INTERVAL = Duration.ofSeconds(30); 076 private static final AtomicLong SUPERVISOR_IDS = new AtomicLong(); 077 private final ReentrantLock lifecycleLock = new ReentrantLock(); 078 private final SessionAttempt sessionAttempt; 079 private final Runnable reconciler; 080 private final @Nullable Predicate<? super RetryFailure> retryCondition; 081 private final RetryPolicy.@Nullable Backoff backoff; 082 private final long stabilityWindowNanos; 083 private final LongSupplier nanoTime; 084 private final Sleeper sleeper; 085 private final @Nullable Duration pollInterval; 086 private final Function<? super ThreadRole, ? extends ThreadFactory> threadFactories; 087 private final boolean customThreadFactories; 088 private final boolean customReconciliationFailureObserver; 089 private final Consumer<? super Throwable> reconciliationFailureObserver; 090 private final CompletableFuture<Void> ready = new CompletableFuture<>(); 091 private final CompletableFuture<ShutdownResult> terminated = new CompletableFuture<>(); 092 private final CompletionStage<Void> readyView = this.ready.minimalCompletionStage(); 093 private final CompletionStage<ShutdownResult> terminatedView = this.terminated.minimalCompletionStage(); 094 // All mutable lifecycle fields are guarded by lifecycleLock. 095 private State state = State.NEW; 096 private @Nullable Thread worker; 097 private boolean stopRequested; 098 private boolean readinessDecided; 099 private boolean everReady; 100 private long attemptNumber; 101 private @Nullable Throwable lastFailure; 102 private @Nullable Managed managed; 103 private @Nullable Instant lastSuccessfulReconciliation; 104 private @Nullable Throwable lastReconciliationFailure; 105 private boolean reconciliationRunning; 106 private @Nullable Throwable shutdownInterruptFailure; 107 private boolean legacyCompleting; 108 private @Nullable Thread legacyCompletingThread; 109 110 private NotificationSupervisor(Builder builder, SessionAttempt sessionAttempt, 111 LongSupplier nanoTime, Sleeper sleeper) { 112 this.sessionAttempt = requireNonNull(sessionAttempt); 113 this.reconciler = requireNonNull(builder.reconciler); 114 this.retryCondition = builder.retryCondition; 115 this.backoff = builder.backoff; 116 this.stabilityWindowNanos = saturatedNanos(builder.stabilityWindow); 117 this.nanoTime = requireNonNull(nanoTime); 118 this.sleeper = requireNonNull(sleeper); 119 this.pollInterval = builder.pollInterval; 120 this.customThreadFactories = builder.threadFactories != null; 121 long supervisorId = SUPERVISOR_IDS.incrementAndGet(); 122 this.threadFactories = builder.threadFactories != null ? builder.threadFactories : role -> task -> { 123 Thread thread = new Thread(task, "pyranid-notifications-" + supervisorId + "-" 124 + role.name().toLowerCase(java.util.Locale.ROOT)); 125 thread.setDaemon(true); 126 return thread; 127 }; 128 this.customReconciliationFailureObserver = builder.reconciliationFailureObserver != null; 129 this.reconciliationFailureObserver = builder.reconciliationFailureObserver != null 130 ? builder.reconciliationFailureObserver : failure -> {}; 131 } 132 133 /** 134 * Starts this single-use supervisor using its own listener and reconciliation workers. 135 * Factories are resolved and threads validated before any work is allowed to begin. The default workers 136 * are named daemon threads; explicit shutdown is still required for orderly resource cleanup. 137 * @throws IllegalStateException if already started/stopped or a factory returns an invalid thread 138 */ 139 public void start() { 140 Managed runtime; 141 this.lifecycleLock.lock(); 142 try { 143 if (this.state != State.NEW) 144 throw new IllegalStateException("Notification supervisor has already been started or stopped"); 145 this.state = State.STARTING; 146 runtime = new Managed(); 147 this.managed = runtime; 148 } finally { 149 this.lifecycleLock.unlock(); 150 } 151 runtime.start(); 152 } 153 154 /** 155 * Creates a builder without opening a connection or starting work. 156 * 157 * @param listenerDatabase database whose source preserves listener-session affinity 158 * @return a builder 159 */ 160 @NonNull 161 public static Builder withDatabase(@NonNull Database listenerDatabase) { 162 return new Builder(requireNonNull(listenerDatabase)); 163 } 164 165 /** 166 * Submits this supervisor exactly once to an application-owned asynchronous executor. 167 * <p> 168 * Inline execution, including a caller-runs rejection policy, is rejected before any JDBC or reconciliation work. 169 * A later queued invocation on the submitting thread is allowed. The executor must eventually run an accepted 170 * task; silently discarded tasks cannot be detected. Calling {@link #requestShutdown()} still terminates a queued task. 171 * 172 * @param executor asynchronous executor with capacity for a long-lived listener 173 * @throws IllegalStateException if already started/stopped, or managed-worker options are configured 174 * @throws RejectedExecutionException if the executor rejects submission or executes inline 175 */ 176 public void start(@NonNull Executor executor) { 177 requireNonNull(executor); 178 if (this.pollInterval != null || this.customThreadFactories || this.customReconciliationFailureObserver) 179 throw new IllegalStateException("Polling, thread factories and failure observers require managed start()"); 180 this.lifecycleLock.lock(); 181 try { 182 if (this.state != State.NEW) 183 throw new IllegalStateException("Notification supervisor has already been started or stopped"); 184 this.state = State.STARTING; 185 } finally { 186 this.lifecycleLock.unlock(); 187 } 188 189 Thread submittingThread = Thread.currentThread(); 190 AtomicBoolean submitting = new AtomicBoolean(true); 191 AtomicReference<RejectedExecutionException> inlineFailure = new AtomicReference<>(); 192 try { 193 executor.execute(() -> { 194 if (Thread.currentThread() == submittingThread && submitting.get()) { 195 RejectedExecutionException failure = new RejectedExecutionException( 196 "Notification supervisor requires an asynchronous executor"); 197 inlineFailure.set(failure); 198 finish(failure); 199 return; 200 } 201 runWorker(); 202 }); 203 if (inlineFailure.get() != null) 204 throw inlineFailure.get(); 205 } catch (RuntimeException | Error failure) { 206 finish(failure); 207 throw failure; 208 } finally { 209 submitting.set(false); 210 } 211 } 212 213 /** 214 * Requests cooperative stop without waiting for listener I/O or cleanup. Repeated requests are harmless. 215 * <p> 216 * A shutdown before worker entry prevents listener JDBC work. A running listener is interrupted. Polling is 217 * cancelled, but a decoded nonempty batch still receives one final bounded reconciliation, with an already-observed 218 * interrupt temporarily cleared and restored. Managed reconciliation is drained without interrupting user work. 219 * If listener interruption is rejected, polling cancellation and shutdown publication still proceed; the 220 * interruption failure is retained in the shutdown result while listener cleanup awaits cooperative progress. 221 * Arbitrary JDBC I/O cannot be forcibly bounded: wait on {@link #awaitShutdownAsync()} with an application deadline. 222 */ 223 public void requestShutdown() { 224 CancellationException cancellation = null; 225 ShutdownResult result = null; 226 Managed runtime; 227 this.lifecycleLock.lock(); 228 try { 229 if (this.state == State.TERMINATED || this.stopRequested) 230 return; 231 this.stopRequested = true; 232 runtime = this.managed; 233 if (!this.readinessDecided) { 234 this.readinessDecided = true; 235 cancellation = cancelledBeforeReadiness(); 236 } 237 if (this.worker == null && runtime == null && !this.legacyCompleting) { 238 this.state = State.TERMINATED; 239 result = new ShutdownResult(null, true); 240 } else { 241 this.state = State.STOPPING; 242 // Keep interrupt delivery inside the unregister handshake: the thread must not be reused first. 243 if (this.worker != null) { 244 try { 245 this.worker.interrupt(); 246 } catch (RuntimeException | Error failure) { 247 if (runtime != null) 248 rememberManagedFailure(runtime, failure); 249 else { 250 this.shutdownInterruptFailure = failure; 251 this.lastFailure = failure; 252 } 253 } 254 } 255 } 256 } finally { 257 this.lifecycleLock.unlock(); 258 } 259 if (runtime != null) 260 runtime.reconciliation.stopPolling(); 261 if (cancellation != null) 262 this.ready.completeExceptionally(cancellation); 263 if (runtime != null) { 264 completeManagedIfFinished(runtime); 265 } 266 if (result != null) 267 this.terminated.complete(result); 268 } 269 270 /** 271 * Gets a read-only signal for the first successful registration and initial reconciliation. 272 * <p> 273 * Retryable startup failures leave this pending. Terminal failure or stop before readiness completes it 274 * exceptionally. Later outages do not undo successful readiness; inspect {@link #snapshot()} and 275 * {@link #awaitShutdownAsync()} separately. Timing out while waiting does not request stop. 276 * 277 * @return first-readiness stage 278 */ 279 @NonNull 280 public CompletionStage<Void> firstReady() { 281 return this.readyView; 282 } 283 284 /** 285 * Gets a read-only stage that completes normally with the terminal result, including any terminal failure. 286 * <p> 287 * Completion means session cleanup and managed reconciliation have finished, including partial-start cleanup, 288 * and no more supervisor JDBC or reconciliation work can occur. It does not mean that a caller's synchronous 289 * stage continuation has returned, that a worker's uncaught-exception handler has returned, or that an 290 * application-owned executor has terminated. 291 * 292 * @return terminal-result stage 293 */ 294 @NonNull 295 public CompletionStage<ShutdownResult> awaitShutdownAsync() { 296 return this.terminatedView; 297 } 298 299 /** 300 * Waits interruptibly for listener cleanup and all managed reconciliation work to finish. 301 * Interruption cancels only this wait; it does not request shutdown. Do not call from a supervisor callback. 302 * @return the shutdown result, including any terminal failure 303 * @throws InterruptedException if this waiting thread is interrupted 304 * @throws IllegalStateException if called by a supervisor worker, which cannot wait for itself 305 */ 306 @NonNull 307 public ShutdownResult awaitShutdown() throws InterruptedException { 308 this.lifecycleLock.lock(); 309 try { 310 Thread current = Thread.currentThread(); 311 if (current == this.worker || current == this.legacyCompletingThread || (this.managed != null 312 && (current == this.managed.listener || current == this.managed.reconciliationThread 313 || current == this.managed.completingThread 314 || (this.managed.initializing && current == this.managed.initializingThread)))) 315 throw new IllegalStateException("A supervisor worker cannot await its own shutdown"); 316 } finally { 317 this.lifecycleLock.unlock(); 318 } 319 try { 320 return this.terminated.get(); 321 } catch (ExecutionException impossible) { 322 throw new IllegalStateException("Shutdown result completed exceptionally", impossible.getCause()); 323 } 324 } 325 326 /** 327 * Gets an immutable snapshot of observed lifecycle state, not a physical-connection health check. 328 * 329 * @return current lifecycle snapshot 330 */ 331 @NonNull 332 public Snapshot snapshot() { 333 this.lifecycleLock.lock(); 334 try { 335 NotificationReconciler.Snapshot reconciliation = this.managed == null 336 ? null : this.managed.reconciliation.snapshot(); 337 return new Snapshot(this.state, this.attemptNumber, this.everReady, this.lastFailure, 338 reconciliation == null ? this.lastSuccessfulReconciliation : reconciliation.lastSuccessTime(), 339 reconciliation == null ? this.lastReconciliationFailure : reconciliation.lastFailure(), 340 reconciliation == null ? this.reconciliationRunning : reconciliation.running(), 341 reconciliation != null && reconciliation.pending()); 342 } finally { 343 this.lifecycleLock.unlock(); 344 } 345 } 346 347 private void runWorker() { 348 this.lifecycleLock.lock(); 349 try { 350 if (this.state == State.TERMINATED || this.stopRequested || this.worker != null 351 || (this.managed != null && this.managed.failure != null)) 352 return; 353 this.worker = Thread.currentThread(); 354 } finally { 355 this.lifecycleLock.unlock(); 356 } 357 Throwable terminalFailure = null; 358 try { 359 terminalFailure = runAttempts(); 360 } catch (RuntimeException | Error failure) { 361 terminalFailure = failure; 362 } finally { 363 finish(terminalFailure); 364 } 365 if (terminalFailure instanceof Error error) 366 throw error; 367 } 368 369 @Nullable 370 private Throwable runAttempts() { 371 int consecutiveFailures = 0; 372 DatabaseException previousFailure = null; 373 while (true) { 374 this.lifecycleLock.lock(); 375 try { 376 if (this.stopRequested || (this.managed != null && this.managed.failure != null) 377 || Thread.currentThread().isInterrupted()) 378 return previousFailure; 379 this.state = State.STARTING; 380 if (this.attemptNumber < Long.MAX_VALUE) 381 ++this.attemptNumber; 382 } finally { 383 this.lifecycleLock.unlock(); 384 } 385 Attempt attempt = new Attempt(); 386 try { 387 this.sessionAttempt.open(session -> consume(session, attempt)); 388 throw new IllegalStateException("Notification session returned unexpectedly"); 389 } catch (InterruptedException interrupted) { 390 Thread.currentThread().interrupt(); 391 return legacyFailureOnCancellation(interrupted); 392 } catch (DatabaseException failure) { 393 noteFailure(attempt, failure); 394 // Cleanup has finished. Classification must retain accessor failures before any scan 395 // can consume one-shot fatal/cancellation evidence and make a later retry look safe. 396 boolean terminalFailure = inspectTerminalFailure(failure); 397 if (stopping() || Thread.currentThread().isInterrupted() 398 || attempt.unexpectedCallbackFailure || this.retryCondition == null 399 || terminalFailure) 400 return failure; 401 if (attempt.readySince != null 402 && attempt.failedAt - attempt.readySince >= this.stabilityWindowNanos) 403 consecutiveFailures = 0; 404 if (consecutiveFailures < Integer.MAX_VALUE) 405 ++consecutiveFailures; 406 boolean previouslyReady; 407 this.lifecycleLock.lock(); 408 try { 409 previouslyReady = this.everReady; 410 } finally { 411 this.lifecycleLock.unlock(); 412 } 413 Duration delay; 414 try { 415 if (!this.retryCondition.test(new RetryFailure( 416 failure, attempt.phase, consecutiveFailures, previouslyReady))) 417 return failure; 418 if (stopping() || Thread.currentThread().isInterrupted()) 419 return failure; 420 delay = requireNonNull(requireNonNull(this.backoff) 421 .delayAfterFailedAttempt(consecutiveFailures, failure)); 422 if (delay.isNegative()) 423 throw new IllegalArgumentException("Supervisor backoff must not be negative"); 424 } catch (RuntimeException | Error policyFailure) { 425 if (policyFailure != failure) 426 policyFailure.addSuppressed(failure); 427 throw policyFailure; 428 } 429 if (stopping() || Thread.currentThread().isInterrupted()) 430 return failure; 431 try { 432 this.sleeper.sleep(delay); 433 } catch (InterruptedException interrupted) { 434 Thread.currentThread().interrupt(); 435 return failure; 436 } 437 previousFailure = failure; 438 } 439 } 440 } 441 442 @Nullable 443 private Throwable legacyFailureOnCancellation(InterruptedException interrupted) { 444 Throwable unrecovered; 445 this.lifecycleLock.lock(); 446 try { 447 unrecovered = this.managed == null ? this.lastReconciliationFailure : null; 448 } finally { 449 this.lifecycleLock.unlock(); 450 } 451 try { 452 // Successful passes clear the field. Cancellation after one must not resurrect an older 453 // recovered failure, but an interrupted retry still needs its unrecovered failure retained. 454 return unrecovered == null ? null 455 : NotificationReconciler.retainUnrecoveredFailure(interrupted, unrecovered); 456 } finally { 457 // Foreign diagnostic accessors may consume interruption during aggregation. 458 if (!Thread.currentThread().isInterrupted()) 459 Thread.currentThread().interrupt(); 460 } 461 } 462 463 /** Retains retry-decision diagnostics and restores interruption without changing ordinary pure graph scans. */ 464 private static boolean inspectTerminalFailure(Throwable failure) { 465 final int interrupted = 1; 466 final int terminal = 2; 467 final int unreadable = 4; 468 int evidence = Thread.currentThread().isInterrupted() ? interrupted : 0; 469 // Fatal errors and cancellation count even inside connection cleanup failures. 470 evidence |= ThrowableGraph.inspectRetainingFailures(failure, 471 current -> (current instanceof InterruptedException || Thread.currentThread().isInterrupted() ? interrupted : 0) 472 | (current instanceof Error ? terminal : 0), 473 unreadable, current -> true, interrupted); 474 // Preserve status observed between scans before another foreign accessor can consume it. 475 if (Thread.currentThread().isInterrupted()) 476 evidence |= interrupted; 477 // Unsupported optional abort/close does not mean notification receive is unavailable. Keep 478 // this scan retaining as well: an accessor may fail only on this later inspection. 479 evidence |= ThrowableGraph.inspectRetainingFailures(failure, 480 current -> (current instanceof InterruptedException || Thread.currentThread().isInterrupted() ? interrupted : 0) 481 | (current instanceof NotificationReceiveUnsupportedException || current instanceof UnsupportedOperationException 482 || current instanceof SQLFeatureNotSupportedException ? terminal : 0), 483 unreadable, current -> !(current instanceof NotificationConnectionCleanupException), interrupted); 484 if (Thread.currentThread().isInterrupted()) 485 evidence |= interrupted; 486 if ((evidence & unreadable) != 0) { 487 // Retained diagnostics are appended after each traversal. Inspect their cancellation 488 // causes too, but keep the unreadable veto even if the graph has since become readable. 489 evidence |= ThrowableGraph.inspectRetainingFailures(failure, 490 current -> (current instanceof InterruptedException || Thread.currentThread().isInterrupted() ? interrupted : 0) 491 | (current instanceof Error ? terminal : 0), 492 unreadable, current -> true, interrupted); 493 if (Thread.currentThread().isInterrupted()) 494 evidence |= interrupted; 495 } 496 if ((evidence & interrupted) != 0) 497 Thread.currentThread().interrupt(); 498 return (evidence & (terminal | unreadable)) != 0; 499 } 500 501 private void consume(HintSession session, Attempt attempt) throws InterruptedException { 502 try { 503 throwIfStopping(); 504 reconcile(attempt); 505 throwIfStopping(); 506 boolean publishReadiness; 507 this.lifecycleLock.lock(); 508 try { 509 if (this.stopRequested || (this.managed != null && this.managed.failure != null) 510 || Thread.currentThread().isInterrupted()) 511 throw new InterruptedException(); 512 attempt.readySince = this.nanoTime.getAsLong(); 513 this.state = State.ACTIVE; 514 this.everReady = true; 515 publishReadiness = !this.readinessDecided; 516 this.readinessDecided = true; 517 } finally { 518 this.lifecycleLock.unlock(); 519 } 520 if (publishReadiness) 521 this.ready.complete(null); 522 while (true) { 523 throwIfStopping(); 524 attempt.phase = FailurePhase.RECEIVE; 525 List<Notification> hints = session.await(RECEIVE_INTERVAL); 526 boolean interrupted = Thread.interrupted(); 527 boolean stop = stopping(); 528 if (stop) 529 interrupted |= Thread.interrupted(); 530 try { 531 if (!hints.isEmpty()) 532 reconcile(attempt); 533 } finally { 534 if (interrupted) 535 Thread.currentThread().interrupt(); 536 } 537 if (stop || interrupted) 538 throw new InterruptedException(); 539 } 540 } catch (RuntimeException | Error failure) { 541 // The core session can wrap callback runtime exceptions in DatabaseException. Do not turn 542 // an unsupported capability or programming error into a retryable database failure. 543 attempt.unexpectedCallbackFailure |= !(failure instanceof DatabaseException); 544 noteFailure(attempt, failure); 545 throw failure; 546 } finally { 547 if (!attempt.failureObserved) 548 attempt.phase = FailurePhase.CLEANUP; 549 } 550 } 551 552 private void reconcile(Attempt attempt) throws InterruptedException { 553 attempt.phase = FailurePhase.RECONCILE; 554 Managed runtime; 555 this.lifecycleLock.lock(); 556 try { 557 runtime = this.managed; 558 if (runtime == null) 559 this.reconciliationRunning = true; 560 } finally { 561 this.lifecycleLock.unlock(); 562 } 563 try { 564 if (runtime != null) 565 runtime.reconciliation.reconcileAndAwait(); 566 else { 567 this.reconciler.run(); 568 // Match managed reconciliation: restoring cancellation and returning is not a successful pass. 569 if (Thread.currentThread().isInterrupted()) 570 throw new InterruptedException(); 571 this.lifecycleLock.lock(); 572 try { 573 this.lastSuccessfulReconciliation = Instant.now(); 574 this.lastReconciliationFailure = null; 575 } finally { 576 this.lifecycleLock.unlock(); 577 } 578 } 579 } catch (RuntimeException | Error failure) { 580 attempt.unexpectedCallbackFailure = !(failure instanceof DatabaseException); 581 if (runtime == null) { 582 this.lifecycleLock.lock(); 583 try { 584 this.lastReconciliationFailure = failure; 585 } finally { 586 this.lifecycleLock.unlock(); 587 } 588 } 589 throw failure; 590 } finally { 591 if (runtime == null) { 592 this.lifecycleLock.lock(); 593 try { 594 this.reconciliationRunning = false; 595 } finally { 596 this.lifecycleLock.unlock(); 597 } 598 } 599 } 600 } 601 602 private void noteFailure(Attempt attempt, Throwable failure) { 603 if (!attempt.failureObserved) { 604 attempt.failureObserved = true; 605 attempt.failedAt = this.nanoTime.getAsLong(); 606 } 607 this.lifecycleLock.lock(); 608 try { 609 this.lastFailure = failure; 610 if (!this.stopRequested) 611 this.state = State.BACKING_OFF; 612 } finally { 613 this.lifecycleLock.unlock(); 614 } 615 } 616 617 private boolean stopping() { 618 this.lifecycleLock.lock(); 619 try { 620 return this.stopRequested || (this.managed != null && this.managed.failure != null); 621 } finally { 622 this.lifecycleLock.unlock(); 623 } 624 } 625 626 private void throwIfStopping() throws InterruptedException { 627 if (Thread.interrupted() || stopping()) 628 throw new InterruptedException(); 629 } 630 631 private void finish(@Nullable Throwable failure) { 632 Managed runtime; 633 Throwable unrecovered; 634 this.lifecycleLock.lock(); 635 try { 636 runtime = this.managed; 637 unrecovered = runtime == null ? this.lastReconciliationFailure : null; 638 if (runtime != null) { 639 this.worker = null; 640 rememberManagedFailure(runtime, failure); 641 this.state = State.STOPPING; 642 } 643 } finally { 644 this.lifecycleLock.unlock(); 645 } 646 if (runtime != null) { 647 runtime.reconciliation.stop(); 648 return; 649 } 650 if (failure != null && unrecovered != null && Thread.currentThread().isInterrupted()) { 651 try { 652 // Session cleanup may promote cancellation to a database exception or error. Keep 653 // the unresolved reconciliation failure regardless of that terminal exception shape. 654 failure = NotificationReconciler.retainUnrecoveredFailure(failure, unrecovered); 655 } finally { 656 if (!Thread.currentThread().isInterrupted()) 657 Thread.currentThread().interrupt(); 658 } 659 } 660 List<Throwable> failures = new ArrayList<>(); 661 this.lifecycleLock.lock(); 662 try { 663 if (this.state == State.TERMINATED || this.legacyCompleting) 664 return; 665 this.worker = null; 666 this.legacyCompleting = true; 667 this.legacyCompletingThread = Thread.currentThread(); 668 this.state = State.STOPPING; 669 if (failure != null) 670 failures.add(failure); 671 if (this.shutdownInterruptFailure != null) 672 failures.add(this.shutdownInterruptFailure); 673 } finally { 674 this.lifecycleLock.unlock(); 675 } 676 // Exception accessors are application/driver code and must not run under the lifecycle lock. 677 Throwable terminalFailure = aggregateFailures(failures); 678 Throwable readinessFailure = null; 679 ShutdownResult result; 680 this.lifecycleLock.lock(); 681 try { 682 this.state = State.TERMINATED; 683 this.legacyCompleting = false; 684 this.legacyCompletingThread = null; 685 if (terminalFailure != null) 686 this.lastFailure = terminalFailure; 687 if (!this.readinessDecided) { 688 this.readinessDecided = true; 689 readinessFailure = terminalFailure == null ? cancelledBeforeReadiness() : terminalFailure; 690 } 691 result = new ShutdownResult(terminalFailure, this.stopRequested); 692 } finally { 693 this.lifecycleLock.unlock(); 694 } 695 if (readinessFailure != null) 696 this.ready.completeExceptionally(readinessFailure); 697 this.terminated.complete(result); 698 } 699 700 private static CancellationException cancelledBeforeReadiness() { 701 return new CancellationException("Notification supervisor stopped before readiness"); 702 } 703 704 private static long saturatedNanos(Duration duration) { 705 try { 706 return duration.toNanos(); 707 } catch (ArithmeticException overflow) { 708 return Long.MAX_VALUE; 709 } 710 } 711 712 // Called only with lifecycleLock held. Record identities without invoking exception accessors. 713 private void rememberManagedFailure(Managed runtime, @Nullable Throwable failure) { 714 if (failure == null) 715 return; 716 for (Throwable existing : runtime.failures) 717 if (existing == failure) 718 return; 719 runtime.failures.add(failure); 720 if (runtime.failure == null) 721 runtime.failure = failure; 722 this.lastFailure = runtime.failure; 723 } 724 725 @Nullable 726 private static Throwable aggregateFailures(List<Throwable> failures) { 727 Throwable primary = null; 728 for (Throwable failure : failures) { 729 if (primary == null) { 730 primary = failure; 731 } else if (primary != failure) { 732 Throwable previous = primary; 733 // Replace a primary only when the new graph demonstrably retains it. Unreadable diagnostics 734 // must not masquerade as reachability evidence and erase the original failure. 735 if (ThrowableGraph.inspect(failure, current -> current == previous ? 1 : 0, 0) != 0) 736 primary = failure; 737 else if (ThrowableGraph.inspect(previous, current -> current == failure ? 1 : 0, 0) == 0) 738 previous.addSuppressed(failure); 739 } 740 } 741 return primary; 742 } 743 744 private void managedFailure(Managed runtime, Throwable failure) { 745 this.lifecycleLock.lock(); 746 try { 747 rememberManagedFailure(runtime, failure); 748 if (this.state != State.TERMINATED) 749 this.state = State.STOPPING; 750 if (this.worker != null) { 751 try { 752 this.worker.interrupt(); 753 } catch (RuntimeException | Error interruptFailure) { 754 rememberManagedFailure(runtime, interruptFailure); 755 } 756 } 757 } finally { 758 this.lifecycleLock.unlock(); 759 } 760 runtime.reconciliation.stopPolling(); 761 } 762 763 private void completeManagedIfFinished(Managed runtime) { 764 List<Throwable> failures; 765 this.lifecycleLock.lock(); 766 try { 767 if (runtime.initializing || !runtime.listenerDone || !runtime.reconciliationDone 768 || runtime.completing || this.state == State.TERMINATED) 769 return; 770 this.worker = null; 771 runtime.completing = true; 772 runtime.completingThread = Thread.currentThread(); 773 failures = List.copyOf(runtime.failures); 774 } finally { 775 this.lifecycleLock.unlock(); 776 } 777 // Both workers have finished. A diagnostic accessor may call lifecycle methods, or block, 778 // without holding up shutdown requests/status reads or allowing premature completion. 779 Throwable terminalFailure = aggregateFailures(failures); 780 Throwable readinessFailure = null; 781 ShutdownResult result; 782 this.lifecycleLock.lock(); 783 try { 784 runtime.failure = terminalFailure; 785 runtime.completingThread = null; 786 this.state = State.TERMINATED; 787 if (terminalFailure != null) 788 this.lastFailure = terminalFailure; 789 if (!this.readinessDecided) { 790 this.readinessDecided = true; 791 readinessFailure = terminalFailure == null ? cancelledBeforeReadiness() : terminalFailure; 792 } 793 result = new ShutdownResult(terminalFailure, this.stopRequested); 794 } finally { 795 this.lifecycleLock.unlock(); 796 } 797 if (readinessFailure != null) 798 this.ready.completeExceptionally(readinessFailure); 799 this.terminated.complete(result); 800 } 801 802 /** All mutable fields except the startup latch are guarded by lifecycleLock. */ 803 private final class Managed { 804 private final Thread initializingThread = Thread.currentThread(); 805 private final CountDownLatch launch = new CountDownLatch(1); 806 private final NotificationReconciler reconciliation = new NotificationReconciler( 807 NotificationSupervisor.this.reconciler, NotificationSupervisor.this.pollInterval, 808 NotificationSupervisor.this.nanoTime, failure -> { 809 boolean terminalFailure = inspectTerminalFailure(failure); 810 boolean recoverable = failure instanceof DatabaseException && !Thread.currentThread().isInterrupted() 811 && !terminalFailure; 812 if (!recoverable) { 813 // Publish the terminal decision before the engine wakes its failed waiter. A later 814 // readable scan on the listener must not undo an earlier unreadable-graph veto. 815 NotificationSupervisor.this.lifecycleLock.lock(); 816 try { 817 rememberManagedFailure(this, failure); 818 if (NotificationSupervisor.this.state != State.TERMINATED) 819 NotificationSupervisor.this.state = State.STOPPING; 820 } finally { 821 NotificationSupervisor.this.lifecycleLock.unlock(); 822 } 823 } 824 return recoverable; 825 }, failure -> { 826 NotificationSupervisor.this.lifecycleLock.lock(); 827 try { 828 NotificationSupervisor.this.lastFailure = failure; 829 } finally { 830 NotificationSupervisor.this.lifecycleLock.unlock(); 831 } 832 NotificationSupervisor.this.reconciliationFailureObserver.accept(failure); 833 }, failure -> managedFailure(this, failure)); 834 private @Nullable Thread listener; 835 private @Nullable Thread reconciliationThread; 836 private boolean initializing = true; 837 private boolean launchAllowed; 838 private boolean listenerDone = true; 839 private boolean reconciliationDone = true; 840 private @Nullable Throwable failure; 841 private final List<Throwable> failures = new ArrayList<>(); 842 private boolean completing; 843 private @Nullable Thread completingThread; 844 845 private void start() { 846 Throwable startupFailure = null; 847 try { 848 // Resolve both factories first: a bad mapping cannot leave partially created workers. 849 ThreadFactory listenerFactory = requireNonNull(threadFactories.apply(ThreadRole.LISTENER), 850 "Listener thread factory"); 851 ThreadFactory reconciliationFactory = requireNonNull(threadFactories.apply(ThreadRole.RECONCILIATION), 852 "Reconciliation thread factory"); 853 if (!stopping()) { 854 Thread listenerThread = requireNonNull(listenerFactory.newThread(() -> enter(true)), "Listener thread"); 855 lifecycleLock.lock(); 856 try { 857 this.listener = listenerThread; 858 this.listenerDone = listenerThread.getState() == Thread.State.NEW 859 || listenerThread.getState() == Thread.State.TERMINATED; 860 } finally { 861 lifecycleLock.unlock(); 862 } 863 Thread reconcilerThread = requireNonNull(reconciliationFactory.newThread(() -> enter(false)), 864 "Reconciliation thread"); 865 if (listenerThread == reconcilerThread) 866 throw new IllegalStateException("Thread factories must return distinct, unstarted threads"); 867 lifecycleLock.lock(); 868 try { 869 this.reconciliationThread = reconcilerThread; 870 this.reconciliationDone = reconcilerThread.getState() == Thread.State.NEW 871 || reconcilerThread.getState() == Thread.State.TERMINATED; 872 } finally { 873 lifecycleLock.unlock(); 874 } 875 if (listenerThread.getState() != Thread.State.NEW 876 || reconcilerThread.getState() != Thread.State.NEW) 877 throw new IllegalStateException("Thread factories must return distinct, unstarted threads"); 878 lifecycleLock.lock(); 879 try { 880 this.listener = listenerThread; 881 this.reconciliationThread = reconcilerThread; 882 this.listenerDone = false; 883 this.reconciliationDone = false; 884 } finally { 885 lifecycleLock.unlock(); 886 } 887 if (!stopping()) { 888 reconcilerThread.start(); 889 if (reconcilerThread.getState() == Thread.State.NEW) 890 throw new IllegalStateException("Reconciliation thread did not start"); 891 listenerThread.start(); 892 if (listenerThread.getState() == Thread.State.NEW) 893 throw new IllegalStateException("Listener thread did not start"); 894 } 895 } 896 } catch (RuntimeException | Error observed) { 897 startupFailure = observed; 898 managedFailure(this, observed); 899 } finally { 900 lifecycleLock.lock(); 901 try { 902 this.initializing = false; 903 this.launchAllowed = startupFailure == null && !stopRequested; 904 this.listenerDone |= this.listener == null || this.listener.getState() == Thread.State.NEW; 905 this.reconciliationDone |= this.reconciliationThread == null 906 || this.reconciliationThread.getState() == Thread.State.NEW; 907 } finally { 908 lifecycleLock.unlock(); 909 } 910 if (!this.launchAllowed) 911 this.reconciliation.stop(); 912 this.launch.countDown(); 913 completeManagedIfFinished(this); 914 } 915 if (startupFailure instanceof RuntimeException runtime) 916 throw runtime; 917 if (startupFailure instanceof Error error) 918 throw error; 919 } 920 921 private void enter(boolean listenerWorker) { 922 if (Thread.currentThread() == this.initializingThread) 923 throw new IllegalStateException("A thread factory must not execute its task inline"); 924 boolean interrupted = false; 925 try { 926 while (true) { 927 try { 928 this.launch.await(); 929 break; 930 } catch (InterruptedException ignored) { 931 interrupted = true; 932 } 933 } 934 if (interrupted) 935 Thread.currentThread().interrupt(); 936 if (this.launchAllowed) { 937 if (listenerWorker) 938 runWorker(); 939 else 940 this.reconciliation.run(); 941 } 942 } finally { 943 if (listenerWorker) 944 this.reconciliation.stop(); 945 lifecycleLock.lock(); 946 try { 947 if (listenerWorker) 948 this.listenerDone = true; 949 else 950 this.reconciliationDone = true; 951 } finally { 952 lifecycleLock.unlock(); 953 } 954 completeManagedIfFinished(this); 955 } 956 } 957 } 958 959 private static final class Attempt { 960 private FailurePhase phase = FailurePhase.OPEN; 961 private @Nullable Long readySince; 962 private boolean failureObserved; 963 private long failedAt; 964 private boolean unexpectedCallbackFailure; 965 } 966 967 @FunctionalInterface 968 interface SessionAttempt { 969 void open(SessionOperation operation) throws InterruptedException; 970 } 971 972 @FunctionalInterface 973 interface SessionOperation { 974 void perform(HintSession session) throws InterruptedException; 975 } 976 977 @FunctionalInterface 978 interface HintSession { 979 List<Notification> await(Duration maxWait) throws InterruptedException; 980 } 981 982 @FunctionalInterface 983 interface Sleeper { 984 void sleep(Duration delay) throws InterruptedException; 985 } 986 987 /** Builder for a single-use supervisor. No work starts until {@link NotificationSupervisor#start}. */ 988 @NotThreadSafe 989 public static final class Builder { 990 private final Database database; 991 private @Nullable Set<String> channels; 992 private @Nullable Runnable reconciler; 993 private @Nullable Predicate<? super RetryFailure> retryCondition; 994 private RetryPolicy.@Nullable Backoff backoff; 995 private Duration stabilityWindow = Duration.ofMinutes(1); 996 private @Nullable Duration pollInterval; 997 private @Nullable Function<? super ThreadRole, ? extends ThreadFactory> threadFactories; 998 private @Nullable Consumer<? super Throwable> reconciliationFailureObserver; 999 1000 private Builder(Database database) { 1001 this.database = database; 1002 } 1003 1004 /** 1005 * Sets the nonempty, fixed channel set, copying it immediately. 1006 * @param channels channels to register 1007 * @return this builder 1008 */ 1009 @NonNull 1010 public Builder channels(@NonNull Set<@NonNull String> channels) { 1011 requireNonNull(channels); 1012 if (channels.isEmpty()) 1013 throw new IllegalArgumentException("channels must not be empty"); 1014 for (String channel : channels) 1015 Notification.validateChannel(channel); 1016 this.channels = Set.copyOf(channels); 1017 return this; 1018 } 1019 1020 /** 1021 * Sets bounded, repeatable reconciliation, requested after registration and each nonempty batch. 1022 * Managed workers also serve optional polling; requests share a successful pass begun after each request. 1023 * @param reconciler application operation that reads or claims authoritative durable state 1024 * @return this builder 1025 */ 1026 @NonNull 1027 public Builder reconcile(@NonNull Runnable reconciler) { 1028 this.reconciler = requireNonNull(reconciler); 1029 return this; 1030 } 1031 1032 /** 1033 * Enables an immediate poll and subsequent fixed-delay reconciliation independent of listener I/O/backoff. 1034 * Recoverable database failures are retried by later polls without reconnecting the listener. 1035 * @param interval positive delay after a pass before the next periodic pass 1036 * @return this builder 1037 */ 1038 @NonNull 1039 public Builder pollEvery(@NonNull Duration interval) { 1040 requireNonNull(interval); 1041 if (interval.isZero() || interval.isNegative()) 1042 throw new IllegalArgumentException("Poll interval must be positive"); 1043 this.pollInterval = interval; 1044 return this; 1045 } 1046 1047 /** 1048 * Customizes managed workers by logical role. The function is resolved at start, not per task, and must 1049 * return non-null factories producing distinct new, unstarted threads. The supervisor owns those workers. 1050 * @param factoryForRole role-to-factory mapping; omit this setting to use named daemon workers 1051 * @return this builder 1052 */ 1053 @NonNull 1054 public Builder threadFactory(@NonNull Function<? super ThreadRole, ? extends ThreadFactory> factoryForRole) { 1055 this.threadFactories = requireNonNull(factoryForRole); 1056 return this; 1057 } 1058 1059 /** 1060 * Observes recoverable managed reconciliation failures. Runs on the reconciliation worker, outside locks; 1061 * it must be bounded and must not await shutdown. An observer failure terminates the supervisor. 1062 * @param observer failure observer 1063 * @return this builder 1064 */ 1065 @NonNull 1066 public Builder onReconciliationFailure(@NonNull Consumer<? super Throwable> observer) { 1067 this.reconciliationFailureObserver = requireNonNull(observer); 1068 return this; 1069 } 1070 1071 /** 1072 * Enables explicit retry classification and backoff, including during startup. 1073 * <p> 1074 * A retryable startup failure leaves first readiness pending. Reconciliation failures are offered to the policy 1075 * only when they are {@link DatabaseException}s. Unsupported capabilities, other application exceptions, errors, 1076 * and failures in the policy terminate. Stop or interruption prevents retries and preserves observed failures. 1077 * With managed polling enabled, recoverable reconciliation failures await the next poll instead of reconnecting; 1078 * listener open/receive/cleanup failures still use this policy. 1079 * @param condition decides whether a database failure is retryable 1080 * @param backoff chooses a nonnegative delay after each selected failure 1081 * @param stabilityWindow positive established-session duration needed to reset consecutive failure escalation 1082 * @return this builder 1083 */ 1084 @NonNull 1085 public Builder retry(@NonNull Predicate<? super RetryFailure> condition, 1086 RetryPolicy.@NonNull Backoff backoff, @NonNull Duration stabilityWindow) { 1087 requireNonNull(stabilityWindow); 1088 if (stabilityWindow.isZero() || stabilityWindow.isNegative()) 1089 throw new IllegalArgumentException("stabilityWindow must be positive"); 1090 this.retryCondition = requireNonNull(condition); 1091 this.backoff = requireNonNull(backoff); 1092 this.stabilityWindow = stabilityWindow; 1093 return this; 1094 } 1095 1096 /** 1097 * Builds a supervisor without acquiring resources. Channels and reconciliation are required. 1098 * @return a new, unstarted supervisor 1099 * @throws IllegalStateException if channels or reconciliation have not been configured 1100 */ 1101 @NonNull 1102 public NotificationSupervisor build() { 1103 validate(); 1104 Set<String> fixedChannels = requireNonNull(this.channels); 1105 return build(operation -> this.database.withNotificationSession(fixedChannels, 1106 session -> operation.perform(session::awaitNotifications)), 1107 System::nanoTime, delay -> TimeUnit.NANOSECONDS.sleep(saturatedNanos(delay))); 1108 } 1109 1110 NotificationSupervisor build(SessionAttempt sessionAttempt, LongSupplier nanoTime, Sleeper sleeper) { 1111 validate(); 1112 return new NotificationSupervisor(this, sessionAttempt, nanoTime, sleeper); 1113 } 1114 1115 private void validate() { 1116 if (this.channels == null || this.reconciler == null) 1117 throw new IllegalStateException("Notification supervisor requires channels and reconciliation"); 1118 } 1119 } 1120 1121 /** Logical managed-worker responsibility, without a guaranteed thread count or per-task classification. */ 1122 public enum ThreadRole { 1123 /** Blocking notification registration, receive and reconnect. */ LISTENER, 1124 /** Serialized durable-state reconciliation and its periodic polling. */ RECONCILIATION 1125 } 1126 1127 /** Locally observed lifecycle state; no state certifies physical-connection health. */ 1128 public enum State { 1129 /** Built, with no work submitted. */ NEW, 1130 /** Queued or establishing a new session and performing initial reconciliation. */ STARTING, 1131 /** The current session completed registration and initial reconciliation. */ ACTIVE, 1132 /** A failed attempt is being cleaned up, classified, or delayed before retry. */ BACKING_OFF, 1133 /** Stop requested while a worker still owns lifecycle work. */ STOPPING, 1134 /** Cleanup complete; no future supervisor JDBC or reconciliation work. */ TERMINATED 1135 } 1136 1137 /** Origin of the primary database failure; a suppressed cleanup failure does not replace it. */ 1138 public enum FailurePhase { 1139 /** Connection acquisition and listener setup. */ OPEN, 1140 /** Receiving notification hints. */ RECEIVE, 1141 /** Application reconciliation. */ RECONCILE, 1142 /** Session cleanup without an earlier primary failure. */ CLEANUP 1143 } 1144 1145 /** Immutable input to an application retry condition. */ 1146 @ThreadSafe 1147 public static final class RetryFailure { 1148 private final DatabaseException exception; 1149 private final FailurePhase phase; 1150 private final int consecutiveFailures; 1151 private final boolean firstReadyReached; 1152 1153 private RetryFailure(DatabaseException exception, FailurePhase phase, 1154 int consecutiveFailures, boolean firstReadyReached) { 1155 this.exception = exception; 1156 this.phase = phase; 1157 this.consecutiveFailures = consecutiveFailures; 1158 this.firstReadyReached = firstReadyReached; 1159 } 1160 1161 /** @return primary database failure */ 1162 @NonNull public DatabaseException getException() { return this.exception; } 1163 /** @return phase where the primary failure occurred */ 1164 @NonNull public FailurePhase getPhase() { return this.phase; } 1165 /** @return one-based failure number, reset only after a stable session */ 1166 @NonNull public Integer getConsecutiveFailures() { return this.consecutiveFailures; } 1167 /** @return whether any attempt has successfully registered and reconciled */ 1168 @NonNull public Boolean isFirstReadyReached() { return this.firstReadyReached; } 1169 } 1170 1171 /** Immutable lifecycle snapshot. The last failure remains available after a successful reconnect. */ 1172 @ThreadSafe 1173 public static final class Snapshot { 1174 private final State state; 1175 private final long attemptNumber; 1176 private final boolean everReady; 1177 private final @Nullable Throwable lastFailure; 1178 private final @Nullable Instant lastSuccessfulReconciliation; 1179 private final @Nullable Throwable lastReconciliationFailure; 1180 private final boolean reconciliationRunning; 1181 private final boolean reconciliationPending; 1182 1183 private Snapshot(State state, long attemptNumber, boolean everReady, @Nullable Throwable lastFailure, 1184 @Nullable Instant lastSuccessfulReconciliation, @Nullable Throwable lastReconciliationFailure, 1185 boolean reconciliationRunning, boolean reconciliationPending) { 1186 this.state = state; 1187 this.attemptNumber = attemptNumber; 1188 this.everReady = everReady; 1189 this.lastFailure = lastFailure; 1190 this.lastSuccessfulReconciliation = lastSuccessfulReconciliation; 1191 this.lastReconciliationFailure = lastReconciliationFailure; 1192 this.reconciliationRunning = reconciliationRunning; 1193 this.reconciliationPending = reconciliationPending; 1194 } 1195 1196 /** @return observed lifecycle state */ 1197 @NonNull public State getState() { return this.state; } 1198 /** @return number of session attempts started, initially zero */ 1199 @NonNull public Long getAttemptNumber() { return this.attemptNumber; } 1200 /** @return whether first readiness was reached */ 1201 @NonNull public Boolean isEverReady() { return this.everReady; } 1202 /** @return most recently observed failure, if any */ 1203 @NonNull public Optional<Throwable> getLastFailure() { return Optional.ofNullable(this.lastFailure); } 1204 /** @return last successful pass time, including independent polls */ 1205 @NonNull public Optional<Instant> getLastSuccessfulReconciliation() { 1206 return Optional.ofNullable(this.lastSuccessfulReconciliation); 1207 } 1208 /** @return current reconciliation failure, cleared by a successful pass */ 1209 @NonNull public Optional<Throwable> getLastReconciliationFailure() { 1210 return Optional.ofNullable(this.lastReconciliationFailure); 1211 } 1212 /** @return whether a reconciliation pass is executing */ 1213 @NonNull public Boolean isReconciliationRunning() { return this.reconciliationRunning; } 1214 /** @return whether reconciliation has pending requests or is awaiting a retry */ 1215 @NonNull public Boolean isReconciliationPending() { return this.reconciliationPending; } 1216 } 1217 1218 /** Terminal lifecycle result. A stop-correlated failure is retained rather than treated as success. */ 1219 @ThreadSafe 1220 public static final class ShutdownResult { 1221 private final @Nullable Throwable failure; 1222 private final boolean stopRequested; 1223 1224 private ShutdownResult(@Nullable Throwable failure, boolean stopRequested) { 1225 this.failure = failure; 1226 this.stopRequested = stopRequested; 1227 } 1228 1229 /** @return terminal failure, empty for clean cooperative stop */ 1230 @NonNull public Optional<Throwable> getFailure() { return Optional.ofNullable(this.failure); } 1231 /** @return whether stop was requested before termination, without attributing a failure to that request */ 1232 @NonNull public Boolean isShutdownRequested() { return this.stopRequested; } 1233 } 1234}