001/* 002 * Copyright 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.soklet; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.time.Duration; 025 026import static java.util.Objects.requireNonNull; 027 028/** 029 * Immutable startup and shutdown deadline policy shared by every configured 030 * lifecycle component. 031 * <p> 032 * Every timeout accepts {@link Duration#ZERO} as an immediate deadline, not 033 * an unlimited wait or a disabled phase. Zero leaves no waiting allowance for 034 * startup, startup cancelation, graceful drain or forced termination proof. 035 * Use positive budgets for normal operation; shutdown completion depends on 036 * physical termination evidence, not merely requesting interruption. 037 * <p> 038 * Each timeout and the sum of startup cancelation, graceful shutdown, and 039 * forced shutdown timeouts must fit in signed 64-bit nanoseconds. These three 040 * phases share one shutdown-intent origin; startup has its own independent 041 * budget. The combined bound keeps monotonic deadline comparisons valid even 042 * when the clock wraps. 043 */ 044@ThreadSafe 045public final class LifecyclePolicy { 046 @NonNull 047 private static final Duration DEFAULT_STARTUP_TIMEOUT = Duration.ofSeconds(30); 048 @NonNull 049 private static final Duration DEFAULT_STARTUP_CANCELATION_TIMEOUT = 050 Duration.ofSeconds(2); 051 @NonNull 052 private static final Duration DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT = 053 Duration.ofSeconds(15); 054 @NonNull 055 private static final Duration DEFAULT_FORCED_SHUTDOWN_TIMEOUT = 056 Duration.ofSeconds(3); 057 @NonNull 058 private static final LifecyclePolicy DEFAULT_INSTANCE = builder().build(); 059 060 @NonNull 061 private final Duration startupTimeout; 062 @NonNull 063 private final Duration startupCancelationTimeout; 064 @NonNull 065 private final Duration gracefulShutdownTimeout; 066 @NonNull 067 private final Duration forcedShutdownTimeout; 068 069 private LifecyclePolicy(@NonNull Builder builder) { 070 Builder exactBuilder = requireNonNull(builder); 071 this.startupTimeout = validate(exactBuilder.startupTimeout, 072 "startupTimeout"); 073 this.startupCancelationTimeout = validate( 074 exactBuilder.startupCancelationTimeout, 075 "startupCancelationTimeout"); 076 this.gracefulShutdownTimeout = validate( 077 exactBuilder.gracefulShutdownTimeout, 078 "gracefulShutdownTimeout"); 079 this.forcedShutdownTimeout = validate( 080 exactBuilder.forcedShutdownTimeout, 081 "forcedShutdownTimeout"); 082 LifecycleDeadlines.validateShutdownHorizon(this.startupCancelationTimeout, 083 this.gracefulShutdownTimeout, this.forcedShutdownTimeout); 084 } 085 086 /** @return the shared default lifecycle policy */ 087 @NonNull 088 public static LifecyclePolicy defaultInstance() { 089 return DEFAULT_INSTANCE; 090 } 091 092 /** @return a mutable builder initialized with the default policy */ 093 @NonNull 094 public static Builder builder() { 095 return new Builder(); 096 } 097 098 /** @return startup timeout */ 099 @NonNull 100 public Duration getStartupTimeout() { 101 return this.startupTimeout; 102 } 103 104 /** @return budget for canceling an in-flight startup call */ 105 @NonNull 106 public Duration getStartupCancelationTimeout() { 107 return this.startupCancelationTimeout; 108 } 109 110 /** @return graceful shutdown timeout */ 111 @NonNull 112 public Duration getGracefulShutdownTimeout() { 113 return this.gracefulShutdownTimeout; 114 } 115 116 /** @return forced shutdown timeout */ 117 @NonNull 118 public Duration getForcedShutdownTimeout() { 119 return this.forcedShutdownTimeout; 120 } 121 122 /** @return whether this policy is structurally equal to the other policy */ 123 @Override 124 public boolean equals(@Nullable Object other) { 125 return this == other 126 || other instanceof LifecyclePolicy policy 127 && this.startupTimeout.equals(policy.startupTimeout) 128 && this.startupCancelationTimeout.equals( 129 policy.startupCancelationTimeout) 130 && this.gracefulShutdownTimeout.equals( 131 policy.gracefulShutdownTimeout) 132 && this.forcedShutdownTimeout.equals( 133 policy.forcedShutdownTimeout); 134 } 135 136 /** @return structural lifecycle-policy hash code */ 137 @Override 138 public int hashCode() { 139 int result = this.startupTimeout.hashCode(); 140 result = 31 * result + this.startupCancelationTimeout.hashCode(); 141 result = 31 * result + this.gracefulShutdownTimeout.hashCode(); 142 result = 31 * result + this.forcedShutdownTimeout.hashCode(); 143 return result; 144 } 145 146 @NonNull 147 InternalLifecyclePolicy toInternal() { 148 return new InternalLifecyclePolicy(getStartupTimeout(), 149 getStartupCancelationTimeout(), getGracefulShutdownTimeout(), 150 getForcedShutdownTimeout()); 151 } 152 153 @NonNull 154 static LifecyclePolicy fromInternal( 155 @NonNull InternalLifecyclePolicy lifecyclePolicy) { 156 requireNonNull(lifecyclePolicy); 157 return builder() 158 .startupTimeout(lifecyclePolicy.startupTimeout()) 159 .startupCancelationTimeout( 160 lifecyclePolicy.startupCancelationTimeout()) 161 .gracefulShutdownTimeout( 162 lifecyclePolicy.gracefulShutdownTimeout()) 163 .forcedShutdownTimeout( 164 lifecyclePolicy.forcedShutdownTimeout()) 165 .build(); 166 } 167 168 @NonNull 169 private static Duration validate(@NonNull Duration duration, 170 @NonNull String name) { 171 requireNonNull(duration, requireNonNull(name)); 172 if (duration.isNegative()) 173 throw new IllegalArgumentException(name + " must be >= 0"); 174 try { 175 long validatedNanoseconds = duration.toNanos(); 176 if (validatedNanoseconds < 0L) 177 throw new IllegalArgumentException(name + " must be >= 0"); 178 } catch (ArithmeticException exception) { 179 throw new IllegalArgumentException( 180 name + " exceeds signed nanoseconds", exception); 181 } 182 return duration; 183 } 184 185 /** Mutable builder for an immutable {@link LifecyclePolicy}. */ 186 @NotThreadSafe 187 public static final class Builder { 188 @NonNull 189 private Duration startupTimeout; 190 @NonNull 191 private Duration startupCancelationTimeout; 192 @NonNull 193 private Duration gracefulShutdownTimeout; 194 @NonNull 195 private Duration forcedShutdownTimeout; 196 197 private Builder() { 198 this.startupTimeout = DEFAULT_STARTUP_TIMEOUT; 199 this.startupCancelationTimeout = 200 DEFAULT_STARTUP_CANCELATION_TIMEOUT; 201 this.gracefulShutdownTimeout = DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT; 202 this.forcedShutdownTimeout = DEFAULT_FORCED_SHUTDOWN_TIMEOUT; 203 } 204 205 /** 206 * Sets the startup timeout. Zero means an immediate boundary. 207 * A zero budget leaves no waiting allowance for framework setup or 208 * transport startup and can immediately produce 209 * {@link StartupDisposition#TIMED_OUT}; it does not disable the timeout. 210 * 211 * Passing {@code null} restores the built-in default of 30 seconds. 212 * 213 * @param startupTimeout startup timeout, or {@code null} to use the default 214 * @return this builder 215 * @throws IllegalArgumentException if negative or not representable as 216 * signed nanoseconds 217 */ 218 @NonNull 219 public Builder startupTimeout(@Nullable Duration startupTimeout) { 220 this.startupTimeout = startupTimeout == null 221 ? DEFAULT_STARTUP_TIMEOUT 222 : validate(startupTimeout, "startupTimeout"); 223 return this; 224 } 225 226 /** 227 * Sets the startup-cancelation budget. Zero means an immediate boundary. 228 * A zero budget leaves no waiting allowance for an active startup call 229 * to return after shutdown intent. Remaining startup work stays tracked 230 * through the graceful and forced shutdown phases. 231 * 232 * Passing {@code null} restores the built-in default of 2 seconds. 233 * 234 * @param startupCancelationTimeout cancelation budget, or {@code null} 235 * to use the default 236 * @return this builder 237 * @throws IllegalArgumentException if negative or not representable as 238 * signed nanoseconds 239 */ 240 @NonNull 241 public Builder startupCancelationTimeout( 242 @Nullable Duration startupCancelationTimeout) { 243 this.startupCancelationTimeout = startupCancelationTimeout == null 244 ? DEFAULT_STARTUP_CANCELATION_TIMEOUT 245 : validate(startupCancelationTimeout, 246 "startupCancelationTimeout"); 247 return this; 248 } 249 250 /** 251 * Sets the graceful shutdown timeout. Zero means an immediate boundary. 252 * Passing {@code null} restores the built-in default of 15 seconds. 253 * A zero budget leaves no waiting allowance for graceful drain; 254 * participants without termination proof proceed to forced shutdown. 255 * <p> 256 * This budget is independent of the server request-handler timeout (60 seconds by default). 257 * To let all permitted in-flight requests finish, allow their maximum remaining work and 258 * response transmission time. A shorter drain intentionally permits interruption and 259 * connection closure at the forced boundary. Interruption cannot guarantee that 260 * noncooperative application code stops. Long-lived streams need an explicit termination 261 * policy; the surrounding process/container budget must also include forced shutdown 262 * and operational margin. 263 * <p> 264 * After a closing response, the standard HTTP server closes promptly when the 265 * request was fully consumed and no surplus input was observed, including one 266 * nonblocking check for input already queued on the socket. If unread input 267 * may remain, its one-second lingering-input window consumes this budget even 268 * after the response has been written. Short graceful budgets can therefore 269 * proceed to forced shutdown while such a client connection still lingers. 270 * 271 * @param gracefulShutdownTimeout graceful shutdown timeout, or 272 * {@code null} to use the default 273 * @return this builder 274 * @throws IllegalArgumentException if negative or not representable as 275 * signed nanoseconds 276 */ 277 @NonNull 278 public Builder gracefulShutdownTimeout( 279 @Nullable Duration gracefulShutdownTimeout) { 280 this.gracefulShutdownTimeout = gracefulShutdownTimeout == null 281 ? DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT 282 : validate(gracefulShutdownTimeout, 283 "gracefulShutdownTimeout"); 284 return this; 285 } 286 287 /** 288 * Sets the forced shutdown timeout. Zero means an immediate boundary. 289 * Passing {@code null} restores the built-in default of 3 seconds. 290 * A zero budget leaves no waiting allowance for forced termination 291 * proof. Any still-unproven or residual work produces an incomplete 292 * result. In particular, zero graceful and forced budgets can make 293 * even an idle running listener's shutdown incomplete because its stop 294 * calls and termination proof are asynchronous. 295 * 296 * @param forcedShutdownTimeout forced shutdown timeout, or {@code null} 297 * to use the default 298 * @return this builder 299 * @throws IllegalArgumentException if negative or not representable as 300 * signed nanoseconds 301 */ 302 @NonNull 303 public Builder forcedShutdownTimeout( 304 @Nullable Duration forcedShutdownTimeout) { 305 this.forcedShutdownTimeout = forcedShutdownTimeout == null 306 ? DEFAULT_FORCED_SHUTDOWN_TIMEOUT 307 : validate(forcedShutdownTimeout, "forcedShutdownTimeout"); 308 return this; 309 } 310 311 /** 312 * @return immutable lifecycle policy 313 * @throws IllegalArgumentException if the combined startup-cancelation, 314 * graceful-shutdown, and forced-shutdown budget exceeds signed nanoseconds 315 */ 316 @NonNull 317 public LifecyclePolicy build() { 318 return new LifecyclePolicy(this); 319 } 320 } 321}