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 java.sql.SQLException;
024import java.time.Duration;
025import java.util.List;
026import java.util.UUID;
027import java.util.function.LongSupplier;
028
029import static java.util.Objects.requireNonNull;
030
031/**
032 * A callback-scoped session for receiving transient database notifications.
033 * <p>
034 * Instances are supplied only to {@link NotificationSessionOperation} by
035 * {@link Database#withNotificationSession(String, NotificationSessionOperation)} or
036 * {@link Database#withNotificationSession(java.util.Set, NotificationSessionOperation)}. A session is confined to
037 * the callback thread, expires when that callback exits, and never reconnects. It exposes no lifecycle snapshot:
038 * an apparently quiet session is not proof that its physical connection remains healthy.
039 * <p>
040 * Notification delivery is lossy and non-durable. Treat each returned batch as a hint to reconcile authoritative
041 * state rather than as an event count or work queue.
042 *
043 * @author <a href="https://www.revetkn.com">Mark Allen</a>
044 * @since 4.6.0
045 */
046@NotThreadSafe
047public final class NotificationSession {
048        private static final long MIN_RECEIVE_SLICE_NANOS = Duration.ofMillis(1).toNanos();
049        private static final long MAX_RECEIVE_SLICE_NANOS = Duration.ofMillis(250).toNanos();
050
051        @NonNull
052        private final Database database;
053        @NonNull
054        private final NotificationTransport transport;
055        @NonNull
056        private final DatabaseType databaseType;
057        @NonNull
058        private final Thread ownerThread;
059        @NonNull
060        private final LongSupplier nanoTimeSupplier;
061        @Nullable
062        private final UUID notificationSessionId;
063        @NonNull
064        private State state;
065        @Nullable
066        private Throwable terminalFailure;
067        private boolean receiving;
068        private boolean connectionLossReported;
069
070        NotificationSession(@NonNull Database database,
071                                                                                        @NonNull NotificationTransport transport,
072                                                                                        @NonNull DatabaseType databaseType,
073                                                                                        @Nullable UUID notificationSessionId) {
074                this(database, transport, databaseType, notificationSessionId, System::nanoTime);
075        }
076
077        NotificationSession(@NonNull Database database,
078                                                                                        @NonNull NotificationTransport transport,
079                                                                                        @NonNull DatabaseType databaseType,
080                                                                                        @Nullable UUID notificationSessionId,
081                                                                                        @NonNull LongSupplier nanoTimeSupplier) {
082                this.database = requireNonNull(database);
083                this.transport = requireNonNull(transport);
084                this.databaseType = requireNonNull(databaseType);
085                this.ownerThread = Thread.currentThread();
086                this.nanoTimeSupplier = requireNonNull(nanoTimeSupplier);
087                this.notificationSessionId = notificationSessionId;
088                this.state = State.ACTIVE;
089        }
090
091        /**
092         * Waits for a nonempty batch of notifications, until the best-effort elapsed-time budget expires.
093         * <p>
094         * Pyranid divides positive waits into driver calls of at most 250 milliseconds so interruption can normally be
095         * observed between calls. The budget is not a hard completion deadline: a JDBC driver call already in progress
096         * may overrun it. A zero duration has exactly the polling semantics of {@link #drainNotifications()}.
097         * <p>
098         * A nonempty batch wins over an interrupt that races after the driver returns; the batch is returned and the
099         * interrupt flag remains set for application code or the next receive to observe.
100         *
101         * @param maxWait maximum best-effort elapsed time to wait, which must not be negative
102         * @return an immutable notification batch, empty only when the budget expires without an observed notification
103         * @throws NullPointerException if {@code maxWait} is null
104         * @throws IllegalArgumentException if {@code maxWait} is negative
105         * @throws IllegalStateException if the session is expired, failed, used from another thread, used reentrantly, or
106         *                               used while any Pyranid transaction is active on this thread
107         * @throws InterruptedException if interruption is observed before protocol work or after an empty receive
108         * @throws DatabaseException if notification transport fails
109         * @since 4.6.0
110         */
111        @NonNull
112        public List<@NonNull Notification> awaitNotifications(@NonNull Duration maxWait)
113                        throws InterruptedException {
114                enterReceive();
115
116                try {
117                        requireNonNull(maxWait);
118
119                        if (maxWait.isNegative())
120                                throw new IllegalArgumentException("maxWait must not be negative");
121
122                        rejectAmbientTransaction();
123
124                        if (maxWait.isZero())
125                                return drainActiveTransport();
126
127                        if (Thread.interrupted())
128                                throw new InterruptedException();
129
130                        long waitNanos = durationToNanosSaturated(maxWait);
131                        long startTime = this.nanoTimeSupplier.getAsLong();
132                        boolean firstSlice = true;
133
134                        for (;;) {
135                                long elapsedNanos = this.nanoTimeSupplier.getAsLong() - startTime;
136                                long remainingNanos = waitNanos - Math.max(0L, elapsedNanos);
137
138                                if (remainingNanos <= 0L
139                                                && !(firstSlice && waitNanos < MIN_RECEIVE_SLICE_NANOS)) {
140                                        if (Thread.interrupted())
141                                                throw new InterruptedException();
142
143                                        return List.of();
144                                }
145
146                                long waitSliceNanos = Math.min(
147                                                Math.max(remainingNanos, MIN_RECEIVE_SLICE_NANOS),
148                                                MAX_RECEIVE_SLICE_NANOS);
149                                Duration waitSlice = Duration.ofNanos(waitSliceNanos);
150                                List<Notification> notifications = receiveFromTransport(waitSlice);
151                                firstSlice = false;
152
153                                if (!notifications.isEmpty())
154                                        return delivered(notifications);
155
156                                if (Thread.interrupted())
157                                        throw new InterruptedException();
158                        }
159                } finally {
160                        exitReceive();
161                }
162        }
163
164        /**
165         * Polls the existing listener connection once using the adapter's driver-specific non-waiting mode.
166         * <p>
167         * The method performs no acquisition, registration, sleep, reconnect, or reconciliation. A nonempty batch wins
168         * over an interrupt that races after the driver returns.
169         *
170         * @return an immutable notification batch, possibly empty
171         * @throws IllegalStateException if the session is expired, failed, used from another thread, used reentrantly, or
172         *                               used while any Pyranid transaction is active on this thread
173         * @throws InterruptedException if interruption is observed before protocol work or after an empty receive
174         * @throws DatabaseException if notification transport fails
175         * @since 4.6.0
176         */
177        @NonNull
178        public List<@NonNull Notification> drainNotifications()
179                        throws InterruptedException {
180                enterReceive();
181
182                try {
183                        rejectAmbientTransaction();
184                        return drainActiveTransport();
185                } finally {
186                        exitReceive();
187                }
188        }
189
190        private void enterReceive() {
191                if (Thread.currentThread() != this.ownerThread)
192                        throw new IllegalStateException("Notification session may only be used from its callback thread");
193
194                if (this.state == State.EXPIRED)
195                        throw new IllegalStateException("Notification session has expired");
196
197                if (this.state == State.FAILED)
198                        throw new IllegalStateException("Notification session has failed");
199
200                if (this.receiving)
201                        throw new IllegalStateException("Notification session receive methods are not reentrant");
202
203                this.receiving = true;
204        }
205
206        private void exitReceive() {
207                this.receiving = false;
208        }
209
210        private void rejectAmbientTransaction() {
211                if (Database.hasAmbientTransaction())
212                        throw new IllegalStateException("Notification receive is not permitted inside a Pyranid transaction");
213        }
214
215        @NonNull
216        private List<@NonNull Notification> drainActiveTransport()
217                        throws InterruptedException {
218                if (Thread.interrupted())
219                        throw new InterruptedException();
220
221                List<Notification> notifications = drainTransport();
222
223                if (!notifications.isEmpty())
224                        return delivered(notifications);
225
226                if (Thread.interrupted())
227                        throw new InterruptedException();
228
229                return List.of();
230        }
231
232        @NonNull
233        private List<@NonNull Notification> receiveFromTransport(@NonNull Duration waitSlice) {
234                requireNonNull(waitSlice);
235
236                try {
237                        return immutableNotifications(this.transport.receive(waitSlice));
238                } catch (SQLException | RuntimeException exception) {
239                        throw fail(exception);
240                } catch (Error error) {
241                        throw failIfConnectionUncertain(error);
242                }
243        }
244
245        @NonNull
246        private List<@NonNull Notification> drainTransport() {
247                try {
248                        return immutableNotifications(this.transport.drain());
249                } catch (SQLException | RuntimeException exception) {
250                        throw fail(exception);
251                } catch (Error error) {
252                        throw failIfConnectionUncertain(error);
253                }
254        }
255
256        @NonNull
257        private List<@NonNull Notification> immutableNotifications(@NonNull List<@NonNull Notification> notifications) {
258                return List.copyOf(requireNonNull(notifications));
259        }
260
261        @NonNull
262        private List<@NonNull Notification> delivered(@NonNull List<@NonNull Notification> notifications) {
263                requireNonNull(notifications);
264
265                if (this.notificationSessionId != null)
266                        this.database.getMetricsCollectorDispatcher().didDeliverNotificationBatch(
267                                        this.databaseType, this.notificationSessionId, (long) notifications.size());
268
269                return notifications;
270        }
271
272        @NonNull
273        private DatabaseException fail(@NonNull Throwable cause) {
274                requireNonNull(cause);
275
276                DatabaseException databaseException = cause instanceof DatabaseException
277                                ? (DatabaseException) cause
278                                : new DatabaseException(
279                                                "Unable to receive database notifications", cause, this.databaseType.dialect());
280
281                latchFailure(databaseException);
282                return databaseException;
283        }
284
285        @NonNull
286        private Error failIfConnectionUncertain(@NonNull Error error) {
287                requireNonNull(error);
288
289                if (this.transport.isConnectionUncertain())
290                        latchFailure(error);
291
292                return error;
293        }
294
295        private void latchFailure(@NonNull Throwable failure) {
296                requireNonNull(failure);
297
298                this.state = State.FAILED;
299                this.terminalFailure = failure;
300
301                if (!this.connectionLossReported && this.notificationSessionId != null) {
302                        this.connectionLossReported = true;
303                        this.database.getMetricsCollectorDispatcher().didLoseNotificationConnection(
304                                        this.databaseType, this.notificationSessionId, failure);
305                }
306        }
307
308        @NonNull
309        private static Long durationToNanosSaturated(@NonNull Duration duration) {
310                requireNonNull(duration);
311
312                try {
313                        return duration.toNanos();
314                } catch (ArithmeticException ignored) {
315                        return Long.MAX_VALUE;
316                }
317        }
318
319        void expire() {
320                this.state = State.EXPIRED;
321        }
322
323        @Nullable
324        Throwable terminalFailure() {
325                return this.terminalFailure;
326        }
327
328        boolean isConnectionUncertain() {
329                return this.terminalFailure != null || this.transport.isConnectionUncertain();
330        }
331
332        private enum State {
333                ACTIVE,
334                FAILED,
335                EXPIRED
336        }
337}