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}