001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.views; 018 019import static java.nio.charset.StandardCharsets.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.time.*; 023import java.util.*; 024import java.util.regex.*; 025 026import org.apache.juneau.console.views.ActionResult.*; 027 028/** 029 * A single in-memory, one-shot async job — the streamed-progress + terminal-result unit of a future async write 030 * variant. 031 * 032 * <h5 class='section'>The stream URL IS the capability</h5> 033 * <p> 034 * A job is addressed by its {@link #id() id}, an unguessable {@value AsyncJobRegistry#CAPABILITY_BITS}-bit 035 * {@link java.security.SecureRandom} token minted by the {@link AsyncJobRegistry}, matching 036 * {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s bar. Because a browser {@code EventSource} 037 * constructor cannot set an {@code X-Csrf-Token} header and cookies are unsound on a loopback port, the SSE GET that 038 * streams a job's progress is Host-checked at the boundary and <b>capability-gated by the unguessability of the 039 * id</b> — there is no session cookie and no CSRF-on-GET. Consequently the id is a secret: 040 * {@link #toString()} never renders it. 041 * 042 * <h5 class='section'>Server-side hard limits (a data-egress disclosure bound)</h5> 043 * <p> 044 * A job caps its own streamed output at {@link #maxOutputBytes} bytes and terminates itself when that is exceeded, 045 * and it terminates itself once past its {@link #deadline() hard-timeout deadline}. Both are enforced <b>here</b>, 046 * server-side — a client cannot be trusted to cancel — and both are disclosure bounds, not only heap 047 * bounds: they cap how long, and how much, customer-adjacent triage content is streamed. The number of concurrent 048 * subscribers is capped at {@link #maxSubscribers} (a browser reload plus one straggler), enforced through 049 * {@link #acquireSubscriber()} / {@link #releaseSubscriber()}. 050 * 051 * <h5 class='section'>Progress buffer and resume</h5> 052 * <p> 053 * Progress events are kept, in order, for the job's whole life (bounded by the output cap) and are never trimmed, so 054 * the position of an event never changes: {@code events[i]} is streamed with SSE id {@code i+1}. A subscriber that 055 * reconnects with a {@code Last-Event-ID} resumes exactly after the last event it saw; a fresh subscriber is 056 * live-only (see {@link #resumeCursor(String)}). 057 * 058 * <h5 class='section'>Terminal outcomes reuse the frozen {@link ActionResult} contract</h5> 059 * <p> 060 * A job settles exactly once, to an {@link ActionResult}: a producer-supplied {@link #complete(ActionResult) 061 * success/failure/refusal/unknown}, a {@link #cancel() cancellation} (distinguishing {@link Outcome#CANCELLED 062 * cancelled} from {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} via 063 * {@link #markEffectStarted()}), an output-limit termination, or a timeout. The reserved async outcomes are read 064 * off the frozen {@link Outcome} enum via its public {@code outcome} field — this contract is reused, never 065 * re-versioned. 066 * 067 * <h5 class='section'>Notes:</h5><ul> 068 * <li class='note'>This class is thread-safe: a producer thread appends progress and settles the job while 069 * subscriber threads drain it through {@link #awaitUpdate(int, Duration)}. 070 * </ul> 071 * 072 * <h5 class='section'>See Also:</h5> 073 * <ul> 074 * <li class='jc'>{@link AsyncJobRegistry} 075 * <li class='jc'>{@link AsyncJobsMixin} 076 * <li class='jc'>{@link ActionResult} 077 * </ul> 078 * 079 * @since 10.0.0 080 */ 081public final class AsyncJob { 082 083 /** A resume value after trimming: 1 to 10 ASCII digits (so {@link Long#parseLong(String)} cannot overflow). */ 084 private static final Pattern RESUME_VALUE = Pattern.compile("\\d{1,10}"); 085 086 /** 087 * Bytes charged toward the output cap for every progress event, on top of its payload. Without it an empty 088 * event would cost nothing and a producer could grow the event buffer without bound; with it the event count is 089 * bounded by {@link #maxOutputBytes} (roughly one event per byte at worst). 090 */ 091 static final long EVENT_OVERHEAD_BYTES = 1; 092 093 private final String id; 094 private final Instant createdAt; 095 private final Duration timeout; 096 private final long maxOutputBytes; 097 private final int maxSubscribers; 098 099 private final Object lock = new Object(); 100 private final List<String> events = new ArrayList<>(); 101 private long outputBytes; 102 private long chargedBytes; // Payload plus per-event overhead; what the cap is enforced against. 103 private boolean effectStarted; 104 private int subscribers; 105 private ActionResult result; // Non-null iff terminal; the single settled outcome. 106 107 /** 108 * Constructor. 109 * 110 * @param id The unguessable capability id. Must not be <jk>null</jk> or blank. 111 * @param createdAt The instant the job was created (for the hard-timeout deadline). Must not be <jk>null</jk>. 112 * @param timeout The hard timeout after which the job self-terminates. Must not be <jk>null</jk>. 113 * @param maxOutputBytes The maximum number of streamed output bytes before the job self-terminates. 114 * @param maxSubscribers The maximum number of concurrent subscribers. 115 */ 116 AsyncJob(String id, Instant createdAt, Duration timeout, long maxOutputBytes, int maxSubscribers) { 117 this.id = reqnn("id", id); 118 this.createdAt = reqnn("createdAt", createdAt); 119 this.timeout = reqnn("timeout", timeout); 120 this.maxOutputBytes = maxOutputBytes; 121 this.maxSubscribers = maxSubscribers; 122 } 123 124 /** 125 * The unguessable capability id — the whole of the job's access control (see the class javadoc). 126 * 127 * @return The id. Never <jk>null</jk> or blank. 128 */ 129 public String id() { return id; } 130 131 /** @return The instant this job was created. */ 132 public Instant createdAt() { return createdAt; } 133 134 /** @return The instant past which this job self-terminates on the next timeout check. */ 135 public Instant deadline() { return createdAt.plus(timeout); } 136 137 /** @return The total number of streamed output bytes so far. */ 138 public long outputBytes() { synchronized (lock) { return outputBytes; } } 139 140 /** @return The maximum streamed output bytes before self-termination. */ 141 public long maxOutputBytes() { return maxOutputBytes; } 142 143 /** @return The maximum number of concurrent subscribers. */ 144 public int maxSubscribers() { return maxSubscribers; } 145 146 /** 147 * Appends a progress event. 148 * 149 * <p> 150 * A no-op once the job is terminal. Enforces the server-side output cap: if this event would push the total 151 * over {@link #maxOutputBytes}, the job is instead terminated with a {@link Outcome#FAILURE failure} carrying an 152 * {@code app:output-limit-exceeded} message and the event is dropped — the cap is a data-egress bound, so 153 * the excess content is never streamed. 154 * 155 * <p> 156 * Each event is charged its UTF-8 payload size plus a fixed {@value #EVENT_OVERHEAD_BYTES}-byte overhead toward 157 * the cap, so empty events cannot grow the buffer without bound. {@link #outputBytes()} still reports payload 158 * bytes only. 159 * 160 * @param text The progress text. Must not be <jk>null</jk>. 161 * @return <jk>true</jk> if the event was appended; <jk>false</jk> if the job was already terminal or the output 162 * cap was hit (which itself terminates the job). 163 */ 164 public boolean progress(String text) { 165 reqnn("text", text); 166 synchronized (lock) { 167 if (result != null) 168 return false; 169 var add = (long) text.getBytes(UTF_8).length; 170 var charge = add + EVENT_OVERHEAD_BYTES; 171 if (chargedBytes + charge > maxOutputBytes) { 172 settle(ActionResult.failure().message("app:output-limit-exceeded")); 173 return false; 174 } 175 outputBytes += add; 176 chargedBytes += charge; 177 events.add(text); 178 lock.notifyAll(); 179 return true; 180 } 181 } 182 183 /** 184 * Marks that this job's outbound effect has begun, so a subsequent {@link #cancel()} settles to 185 * {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} rather than {@link Outcome#CANCELLED cancelled} 186 * (the two are different outcomes and must not be collapsed). A no-op once terminal. 187 * 188 * <p> 189 * The mark and the terminal check share one lock acquisition with {@link #cancel()} and 190 * {@link #enforceTimeout(Instant)}, so a producer that starts its effect only when this returns <jk>true</jk> can 191 * never start it on a job that has already been cancelled or timed out. 192 * 193 * @return <jk>true</jk> if the job is still running and is now marked; <jk>false</jk> if it was already terminal, in 194 * which case the producer must not start the effect. 195 */ 196 public boolean markEffectStarted() { 197 synchronized (lock) { 198 if (result != null) 199 return false; 200 effectStarted = true; 201 return true; 202 } 203 } 204 205 /** @return Whether the outbound effect has begun (see {@link #markEffectStarted()}). */ 206 public boolean effectStarted() { synchronized (lock) { return effectStarted; } } 207 208 /** 209 * Settles the job to a producer-supplied terminal result (success/failure/refusal/unknown). 210 * 211 * @param value The terminal result. Must not be <jk>null</jk>. 212 * @return <jk>true</jk> if this call settled the job; <jk>false</jk> if it was already terminal (first settle 213 * wins). 214 */ 215 public boolean complete(ActionResult value) { 216 reqnn("value", value); 217 synchronized (lock) { 218 if (result != null) 219 return false; 220 settle(value); 221 return true; 222 } 223 } 224 225 /** 226 * Cancels the job, settling it to {@link Outcome#CANCELLED cancelled} — or 227 * {@link Outcome#CANCELLED_AFTER_EFFECT cancelled-after-effect} when {@link #markEffectStarted()} was called 228 * first. Enforced server-side; a client cannot be trusted to stop the work itself. 229 * 230 * @return <jk>true</jk> if this call settled the job; <jk>false</jk> if it was already terminal. 231 */ 232 public boolean cancel() { 233 synchronized (lock) { 234 if (result != null) 235 return false; 236 settle(cancelledResult(effectStarted)); 237 return true; 238 } 239 } 240 241 /** 242 * Terminates the job if it is past its {@link #deadline() hard-timeout deadline}, settling it to a non-optimistic 243 * {@link Outcome#UNKNOWN unknown} — the effect may or may not have completed in the time cut off. This is 244 * the server-side timeout enforcement: it bounds both heap and, as a data-egress disclosure bound, how 245 * long triage keeps streaming customer-adjacent content. 246 * 247 * @param now The current instant. Must not be <jk>null</jk>. 248 * @return <jk>true</jk> if this call timed the job out; <jk>false</jk> if it was already terminal or not yet past 249 * its deadline. 250 */ 251 public boolean enforceTimeout(Instant now) { 252 reqnn("now", now); 253 synchronized (lock) { 254 if (result != null || ! now.isAfter(deadline())) 255 return false; 256 settle(ActionResult.unknown().message("app:hard-timeout-exceeded")); 257 return true; 258 } 259 } 260 261 /** @return Whether the job has settled to a terminal result. */ 262 public boolean isTerminal() { synchronized (lock) { return result != null; } } 263 264 /** @return The terminal result, or <jk>null</jk> if the job is still running. */ 265 public ActionResult result() { synchronized (lock) { return result; } } 266 267 /** @return The number of progress events buffered so far. */ 268 public int eventCount() { synchronized (lock) { return events.size(); } } 269 270 /** 271 * Snapshots the progress events at or after {@code fromIndex}. 272 * 273 * <p> 274 * Not the stream's resume path: this answers empty for an out-of-range index, whereas a past-the-end resume value 275 * is refused (see {@link #resumeCursor(String)}). 276 * 277 * @param fromIndex The zero-based index to start from. 278 * @return A copy of the events from {@code fromIndex} onward (empty if none/out of range). 279 */ 280 public List<String> eventsFrom(int fromIndex) { 281 synchronized (lock) { 282 if (fromIndex < 0 || fromIndex >= events.size()) 283 return List.of(); 284 return List.copyOf(events.subList(fromIndex, events.size())); 285 } 286 } 287 288 /** 289 * Resolves the zero-based stream start position from a raw resume value (an SSE {@code Last-Event-ID} or the 290 * {@code lastEventId} query parameter, already chosen by the caller). 291 * 292 * <p> 293 * Progress event {@code events[i]} is always streamed with SSE id {@code i+1}, so a resume value {@code k} means 294 * "events {@code 0..k-1} were delivered" and the stream continues at {@code events[k]}. The value is trimmed of 295 * SP/HTAB, must then match {@code ^[0-9]{1,10}$} (so {@code +1}, {@code -1}, {@code 1e2}, {@code 0x2} and the 296 * like are refused before any parsing) and must not be past the end of the buffer. A <jk>null</jk> or empty value 297 * is a fresh attach: the current event count, so only progress produced from now on is streamed. 298 * 299 * <p> 300 * Runs under the job lock, so the bound cannot race {@link #progress(String)}. 301 * 302 * @param value The raw resume value, or <jk>null</jk> if none was supplied. 303 * @return The cursor to pass to {@link #awaitUpdate(int, Duration)}. 304 * @throws IllegalArgumentException If the value is malformed or past the end of the buffered events. 305 */ 306 int resumeCursor(String value) { 307 var v = value == null ? "" : trimOws(value); 308 synchronized (lock) { 309 var size = events.size(); 310 if (v.isEmpty()) 311 return size; 312 if (! RESUME_VALUE.matcher(v).matches()) 313 throw iaex("Malformed resume value '%s'.", v); 314 var k = Long.parseLong(v); 315 if (k > size) 316 throw iaex("Resume value %s is past the end of the job's %s buffered events.", v, size); 317 return (int) k; 318 } 319 } 320 321 /** Trims HTTP optional whitespace (SP and HTAB only) from both ends. */ 322 static String trimOws(String value) { 323 var b = 0; 324 var e = value.length(); 325 while (b < e && isOws(value.charAt(b))) 326 b++; 327 while (e > b && isOws(value.charAt(e - 1))) 328 e--; 329 return value.substring(b, e); 330 } 331 332 private static boolean isOws(char c) { 333 return c == ' ' || c == '\t'; 334 } 335 336 /** 337 * Blocks until there is a progress event at or after {@code fromIndex}, or the job is terminal, or {@code timeout} 338 * elapses — then returns whichever is available. 339 * 340 * <p> 341 * A terminal job returns immediately with any remaining buffered events plus its terminal result, so a single 342 * subscriber loop drains a completed (or re-attached) job in one pass. 343 * 344 * @param fromIndex The zero-based index the caller has already consumed up to. 345 * @param timeout The maximum time to wait for a new event. Must not be <jk>null</jk>. 346 * @return The new events (possibly empty) and the terminal result (<jk>null</jk> while still running). 347 */ 348 public Update awaitUpdate(int fromIndex, Duration timeout) { 349 reqnn("timeout", timeout); 350 var deadlineNanos = System.nanoTime() + Math.max(0L, timeout.toNanos()); 351 synchronized (lock) { 352 while (true) { 353 if (fromIndex < events.size() || result != null) { 354 var newEvents = fromIndex < events.size() 355 ? List.copyOf(events.subList(Math.max(0, fromIndex), events.size())) 356 : List.<String>of(); 357 return new Update(newEvents, result); 358 } 359 var remaining = deadlineNanos - System.nanoTime(); 360 if (remaining <= 0L) 361 return new Update(List.of(), null); 362 try { 363 lock.wait(remaining / 1_000_000L, (int) (remaining % 1_000_000L)); 364 } catch (InterruptedException e) { 365 Thread.currentThread().interrupt(); 366 return new Update(List.of(), result); 367 } 368 } 369 } 370 } 371 372 /** 373 * Attempts to register a subscriber, enforcing the {@link #maxSubscribers} cap. 374 * 375 * @return <jk>true</jk> if a subscriber slot was acquired; <jk>false</jk> if the cap is already reached. 376 */ 377 public boolean acquireSubscriber() { 378 synchronized (lock) { 379 if (subscribers >= maxSubscribers) 380 return false; 381 subscribers++; 382 return true; 383 } 384 } 385 386 /** Releases a subscriber slot previously acquired via {@link #acquireSubscriber()}. */ 387 public void releaseSubscriber() { 388 synchronized (lock) { 389 if (subscribers > 0) 390 subscribers--; 391 } 392 } 393 394 /** @return The current number of registered subscribers. */ 395 public int subscriberCount() { synchronized (lock) { return subscribers; } } 396 397 /** Settles the terminal result and wakes every waiting subscriber. Caller must hold {@link #lock}. */ 398 private void settle(ActionResult value) { 399 synchronized (lock) { 400 result = value; 401 lock.notifyAll(); 402 } 403 } 404 405 /** Builds a terminal {@link ActionResult} for a cancellation, reusing the frozen contract's reserved outcomes. */ 406 private static ActionResult cancelledResult(boolean afterEffect) { 407 var r = new ActionResult(); 408 r.outcome = (afterEffect ? Outcome.CANCELLED_AFTER_EFFECT : Outcome.CANCELLED).wire(); 409 return r; 410 } 411 412 /** 413 * Returns a description that does <b>not</b> include the capability id. 414 * 415 * <p> 416 * The id is the stream's whole access control (see the class javadoc); a bean-dumping logger or debug view that 417 * stringified it would write a live capability somewhere it can be read back and used to attach to the stream. 418 * 419 * @return A value-free description. 420 */ 421 @Override /* Object */ 422 public String toString() { 423 return "AsyncJob(id=<redacted>,terminal=" + isTerminal() + ")"; 424 } 425 426 /** 427 * The result of {@link AsyncJob#awaitUpdate(int, Duration)}: the newly-available progress events and the terminal 428 * result (<jk>null</jk> while the job is still running). 429 * 430 * @param events The new progress events since the caller's cursor. Never <jk>null</jk>. 431 * @param result The terminal result, or <jk>null</jk> if the job is still running. 432 */ 433 public record Update(List<String> events, ActionResult result) {} 434}