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}