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}