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         * If final reconciliation uses interrupt-sensitive work such as Pyranid transaction entry, clear and remember
101         * that flag with {@link Thread#interrupted()}, perform only bounded reconciliation, and restore the flag in a
102         * {@code finally} block.
103         *
104         * @param maxWait maximum best-effort elapsed time to wait, which must not be negative
105         * @return an immutable notification batch, empty only when the budget expires without an observed notification
106         * @throws NullPointerException if {@code maxWait} is null
107         * @throws IllegalArgumentException if {@code maxWait} is negative
108         * @throws IllegalStateException if the session is expired, failed, used from another thread, used reentrantly, or
109         *                               used while any Pyranid transaction is active on this thread
110         * @throws InterruptedException if interruption is observed before protocol work or after an empty receive
111         * @throws DatabaseException if notification transport fails
112         * @since 4.6.0
113         */
114        @NonNull
115        public List<@NonNull Notification> awaitNotifications(@NonNull Duration maxWait)
116                        throws InterruptedException {
117                enterReceive();
118
119                try {
120                        requireNonNull(maxWait);
121
122                        if (maxWait.isNegative())
123                                throw new IllegalArgumentException("maxWait must not be negative");
124
125                        rejectAmbientTransaction();
126
127                        if (maxWait.isZero())
128                                return drainActiveTransport();
129
130                        if (Thread.interrupted())
131                                throw new InterruptedException();
132
133                        long waitNanos = durationToNanosSaturated(maxWait);
134                        long startTime = this.nanoTimeSupplier.getAsLong();
135                        boolean firstSlice = true;
136
137                        for (;;) {
138                                long elapsedNanos = this.nanoTimeSupplier.getAsLong() - startTime;
139                                long remainingNanos = waitNanos - Math.max(0L, elapsedNanos);
140
141                                if (remainingNanos <= 0L
142                                                && !(firstSlice && waitNanos < MIN_RECEIVE_SLICE_NANOS)) {
143                                        if (Thread.interrupted())
144                                                throw new InterruptedException();
145
146                                        return List.of();
147                                }
148
149                                long waitSliceNanos = Math.min(
150                                                Math.max(remainingNanos, MIN_RECEIVE_SLICE_NANOS),
151                                                MAX_RECEIVE_SLICE_NANOS);
152                                Duration waitSlice = Duration.ofNanos(waitSliceNanos);
153                                List<Notification> notifications = receiveFromTransport(waitSlice);
154                                firstSlice = false;
155
156                                if (!notifications.isEmpty())
157                                        return delivered(notifications);
158
159                                if (Thread.interrupted())
160                                        throw new InterruptedException();
161                        }
162                } finally {
163                        exitReceive();
164                }
165        }
166
167        /**
168         * Polls the existing listener connection once using the adapter's driver-specific non-waiting mode.
169         * <p>
170         * The method performs no acquisition, registration, sleep, reconnect, or reconciliation. A nonempty batch wins
171         * over an interrupt that races after the driver returns; the batch is returned and the interrupt flag remains set.
172         * If final reconciliation uses interrupt-sensitive work such as Pyranid transaction entry, clear and remember that
173         * flag with {@link Thread#interrupted()}, perform only bounded reconciliation, and restore the flag in a
174         * {@code finally} block.
175         *
176         * @return an immutable notification batch, possibly empty
177         * @throws IllegalStateException if the session is expired, failed, used from another thread, used reentrantly, or
178         *                               used while any Pyranid transaction is active on this thread
179         * @throws InterruptedException if interruption is observed before protocol work or after an empty receive
180         * @throws DatabaseException if notification transport fails
181         * @since 4.6.0
182         */
183        @NonNull
184        public List<@NonNull Notification> drainNotifications()
185                        throws InterruptedException {
186                enterReceive();
187
188                try {
189                        rejectAmbientTransaction();
190                        return drainActiveTransport();
191                } finally {
192                        exitReceive();
193                }
194        }
195
196        private void enterReceive() {
197                if (Thread.currentThread() != this.ownerThread)
198                        throw new IllegalStateException("Notification session may only be used from its callback thread");
199
200                if (this.state == State.EXPIRED)
201                        throw new IllegalStateException("Notification session has expired");
202
203                if (this.state == State.FAILED)
204                        throw new IllegalStateException("Notification session has failed");
205
206                if (this.receiving)
207                        throw new IllegalStateException("Notification session receive methods are not reentrant");
208
209                this.receiving = true;
210        }
211
212        private void exitReceive() {
213                this.receiving = false;
214        }
215
216        private void rejectAmbientTransaction() {
217                if (Database.hasAmbientTransaction())
218                        throw new IllegalStateException("Notification receive is not permitted inside a Pyranid transaction");
219        }
220
221        @NonNull
222        private List<@NonNull Notification> drainActiveTransport()
223                        throws InterruptedException {
224                if (Thread.interrupted())
225                        throw new InterruptedException();
226
227                List<Notification> notifications = drainTransport();
228
229                if (!notifications.isEmpty())
230                        return delivered(notifications);
231
232                if (Thread.interrupted())
233                        throw new InterruptedException();
234
235                return List.of();
236        }
237
238        @NonNull
239        private List<@NonNull Notification> receiveFromTransport(@NonNull Duration waitSlice) {
240                requireNonNull(waitSlice);
241
242                try {
243                        return immutableNotifications(this.transport.receive(waitSlice));
244                } catch (SQLException | RuntimeException exception) {
245                        throw fail(exception);
246                } catch (Error error) {
247                        throw failIfConnectionUncertain(error);
248                }
249        }
250
251        @NonNull
252        private List<@NonNull Notification> drainTransport() {
253                try {
254                        return immutableNotifications(this.transport.drain());
255                } catch (SQLException | RuntimeException exception) {
256                        throw fail(exception);
257                } catch (Error error) {
258                        throw failIfConnectionUncertain(error);
259                }
260        }
261
262        @NonNull
263        private List<@NonNull Notification> immutableNotifications(@NonNull List<@NonNull Notification> notifications) {
264                return List.copyOf(requireNonNull(notifications));
265        }
266
267        @NonNull
268        private List<@NonNull Notification> delivered(@NonNull List<@NonNull Notification> notifications) {
269                requireNonNull(notifications);
270
271                if (this.notificationSessionId != null)
272                        this.database.getMetricsCollectorDispatcher().didDeliverNotificationBatch(
273                                        this.databaseType, this.notificationSessionId, (long) notifications.size());
274
275                return notifications;
276        }
277
278        @NonNull
279        private DatabaseException fail(@NonNull Throwable cause) {
280                requireNonNull(cause);
281
282                DatabaseException databaseException = cause instanceof DatabaseException
283                                ? (DatabaseException) cause
284                                : new DatabaseException(
285                                                "Unable to receive database notifications", cause, this.databaseType.dialect());
286
287                latchFailure(databaseException);
288                return databaseException;
289        }
290
291        @NonNull
292        private Error failIfConnectionUncertain(@NonNull Error error) {
293                requireNonNull(error);
294
295                if (this.transport.isConnectionUncertain())
296                        latchFailure(error);
297
298                return error;
299        }
300
301        private void latchFailure(@NonNull Throwable failure) {
302                requireNonNull(failure);
303
304                this.state = State.FAILED;
305                this.terminalFailure = failure;
306
307                if (!this.connectionLossReported && this.notificationSessionId != null) {
308                        this.connectionLossReported = true;
309                        this.database.getMetricsCollectorDispatcher().didLoseNotificationConnection(
310                                        this.databaseType, this.notificationSessionId, failure);
311                }
312        }
313
314        @NonNull
315        private static Long durationToNanosSaturated(@NonNull Duration duration) {
316                requireNonNull(duration);
317
318                try {
319                        return duration.toNanos();
320                } catch (ArithmeticException ignored) {
321                        return Long.MAX_VALUE;
322                }
323        }
324
325        void expire() {
326                this.state = State.EXPIRED;
327        }
328
329        @Nullable
330        Throwable terminalFailure() {
331                return this.terminalFailure;
332        }
333
334        boolean isConnectionUncertain() {
335                return this.terminalFailure != null || this.transport.isConnectionUncertain();
336        }
337
338        private enum State {
339                ACTIVE,
340                FAILED,
341                EXPIRED
342        }
343}