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 org.apache.juneau.commons.utils.Shorts.*; 020 021import java.security.*; 022import java.time.*; 023import java.util.*; 024import java.util.concurrent.*; 025 026/** 027 * An in-memory, per-process registry of {@link AsyncJob async jobs} — the single-process job store a future 028 * async write variant runs on. 029 * 030 * <h5 class='section'>Single-process, by decision</h5> 031 * <p> 032 * Job state lives only in this object's heap, which is enough for a single-user loopback console and a documented 033 * limitation for anything else: a process restart drops every in-flight job, exactly as a restart invalidates 034 * every {@link org.apache.juneau.rest.server.filter.SynchronizerToken}. There is deliberately no store abstraction. 035 * 036 * <h5 class='section'>The id is the capability; the caps are load-bearing</h5> 037 * <p> 038 * Each job is keyed by an unguessable {@value #CAPABILITY_BITS}-bit {@link SecureRandom} id 039 * ({@value #CAPABILITY_BYTES} bytes, hex-encoded), matching 040 * {@link org.apache.juneau.rest.server.filter.SynchronizerToken}'s width — the id <b>is</b> the stream's 041 * access control. {@link #get(String)} of an unknown id returns empty, so a miss is indistinguishable from 042 * any other and the id space cannot be enumerated. 043 * <p> 044 * Because jobs keep running after the page navigates away and stream customer-adjacent content, the following 045 * caps are enforced server-side and are security controls, not tuning knobs (a data-egress disclosure bound): 046 * <ul class='spaced-list'> 047 * <li>{@link #MAX_CONCURRENT_JOBS} concurrent (non-terminal) jobs — {@link #tryCreate()} refuses beyond it, and the 048 * check-and-insert is atomic, so concurrent callers cannot overshoot it; 049 * <li>{@link AsyncJobRegistry#HARD_TIMEOUT} hard per-job timeout — scheduled here and also swept by 050 * {@link #sweepTimeouts()}; 051 * <li>{@link AsyncJob#maxOutputBytes()} of streamed output per job — enforced in {@link AsyncJob#progress}; 052 * <li>{@link AsyncJob#maxSubscribers()} concurrent subscribers per job — enforced in 053 * {@link AsyncJob#acquireSubscriber()}. 054 * </ul> 055 * 056 * <h5 class='section'>De-duplication by {@link IdempotencyKey}</h5> 057 * <p> 058 * {@link #tryCreate(IdempotencyKey)} / {@link #create(IdempotencyKey)} register a job under an idempotency key's 059 * {@link IdempotencyKey#value() value}; submitting again with a key that is already registered returns the 060 * <b>existing</b> job instead of creating a second one, so a double-click, an impatient re-submit or a browser retry 061 * collapses to one job. The lookup-or-create is atomic: concurrent submits with one key yield exactly one job. 062 * 063 * <p> 064 * <b>A key whose job has finished replays that finished job</b> (the same replay-on-duplicate contract as 065 * {@link IdempotencyKey}): the duplicate gets the terminal job and its {@link AsyncJob#result() result} back rather 066 * than triggering a second run. This holds for the job's retention window; once the terminal job is reaped from the 067 * heap (see {@link #sweepTimeouts()}), its key is forgotten too, and the same key starts a fresh job. A duplicate 068 * never counts against {@link #MAX_CONCURRENT_JOBS}, since it creates nothing. 069 * 070 * <h5 class='section'>Example:</h5> 071 * <p class='bjava'> 072 * AsyncJobRegistry <jv>registry</jv> = <jk>new</jk> AsyncJobRegistry(); 073 * IdempotencyKey <jv>key</jv> = IdempotencyKey.<jsm>mintSelfTargeted</jsm>(<js>"create"</js>); 074 * 075 * AsyncJob <jv>first</jv> = <jv>registry</jv>.create(<jv>key</jv>); 076 * AsyncJob <jv>again</jv> = <jv>registry</jv>.create(<jv>key</jv>); <jc>// Double-click: same job, nothing new started.</jc> 077 * <jsm>assert</jsm> <jv>first</jv> == <jv>again</jv>; 078 * </p> 079 * 080 * <h5 class='section'>See Also:</h5> 081 * <ul> 082 * <li class='jc'>{@link AsyncJob} 083 * <li class='jc'>{@link AsyncJobsMixin} 084 * <li class='jc'>{@link IdempotencyKey} 085 * <li class='jc'>{@link JobKeyLedger} 086 * </ul> 087 * 088 * @since 10.0.0 089 */ 090public final class AsyncJobRegistry implements AutoCloseable { 091 092 /** Number of random bytes behind a capability id. {@value #CAPABILITY_BITS} bits, matching {@code SynchronizerToken}. */ 093 public static final int CAPABILITY_BYTES = 32; 094 095 /** Width of a capability id, in bits — well beyond the ≥128-bit bar. */ 096 public static final int CAPABILITY_BITS = CAPABILITY_BYTES * 8; 097 098 /** Maximum number of concurrent (non-terminal) jobs; {@link #tryCreate()} refuses beyond this. */ 099 public static final int MAX_CONCURRENT_JOBS = 8; 100 101 /** Maximum number of concurrent subscribers per job (a reload plus one straggler). */ 102 public static final int MAX_SUBSCRIBERS_PER_JOB = 2; 103 104 /** Maximum streamed output per job, in bytes (1 MiB); a data-egress bound, not only a heap bound. */ 105 public static final long MAX_OUTPUT_BYTES = 1024L * 1024L; 106 107 /** Hard per-job timeout; caps how long triage keeps streaming customer-adjacent content. */ 108 public static final Duration HARD_TIMEOUT = Duration.ofSeconds(120); 109 110 /** How long a terminal job is retained (for a page-reload re-attach) before it is reaped from the heap. */ 111 static final Duration RETENTION = Duration.ofSeconds(60); 112 113 private final Map<String,AsyncJob> jobs = new ConcurrentHashMap<>(); 114 private final Map<String,AsyncJob> byKey = new ConcurrentHashMap<>(); 115 private final Object admission = new Object(); 116 private final SecureRandom random = new SecureRandom(); 117 private final Clock clock; 118 private final Duration timeout; 119 private final long maxOutputBytes; 120 private final int maxSubscribers; 121 private final ScheduledExecutorService scheduler; 122 private final boolean ownsScheduler; 123 124 /** 125 * Creates a registry with the production defaults: a system clock, the {@link #HARD_TIMEOUT} (120s) timeout, 126 * {@link #MAX_OUTPUT_BYTES} / {@link #MAX_SUBSCRIBERS_PER_JOB} caps, and a private daemon scheduler that enforces 127 * each job's hard timeout. 128 * 129 * <p> 130 * The 120s default is a disclosure bound for jobs that are not long-running agent dispatches. Callers that need 131 * a longer bound (for example a Claude-length create) use {@link #AsyncJobRegistry(Duration)} rather than raising 132 * this global default. 133 * </p> 134 */ 135 public AsyncJobRegistry() { 136 this(HARD_TIMEOUT); 137 } 138 139 /** 140 * Creates a registry with a caller-chosen hard per-job timeout and the production caps / scheduler. 141 * 142 * <p> 143 * Use this when a job may run longer than {@link #HARD_TIMEOUT} (120s). The no-arg constructor keeps that 144 * default; this overload does not change it. 145 * </p> 146 * 147 * @param timeout The hard per-job timeout. Must be a positive duration. 148 * @throws IllegalArgumentException If {@code timeout} is <jk>null</jk>, zero, or negative. 149 */ 150 public AsyncJobRegistry(Duration timeout) { 151 this(Clock.systemUTC(), requirePositiveTimeout(timeout), MAX_OUTPUT_BYTES, MAX_SUBSCRIBERS_PER_JOB, defaultScheduler(), true); 152 } 153 154 /** 155 * Test/advanced constructor allowing an injected clock, timeout, caps and scheduler. 156 * 157 * @param clock The clock supplying job-creation and timeout-check instants. Must not be <jk>null</jk>. 158 * @param timeout The hard per-job timeout. Must be a positive duration. 159 * @param maxOutputBytes The per-job streamed-output cap, in bytes. 160 * @param maxSubscribers The per-job concurrent-subscriber cap. 161 * @param scheduler The scheduler that enforces each job's hard timeout, or <jk>null</jk> to rely solely on 162 * {@link #sweepTimeouts()} (used by deterministic tests). 163 */ 164 AsyncJobRegistry(Clock clock, Duration timeout, long maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler) { 165 this(clock, timeout, maxOutputBytes, maxSubscribers, scheduler, false); 166 } 167 168 private AsyncJobRegistry(Clock clock, Duration timeout, long maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler, boolean ownsScheduler) { 169 this.clock = reqnn("clock", clock); 170 this.timeout = requirePositiveTimeout(timeout); 171 this.maxOutputBytes = maxOutputBytes; 172 this.maxSubscribers = maxSubscribers; 173 this.scheduler = scheduler; 174 this.ownsScheduler = ownsScheduler; 175 } 176 177 private static Duration requirePositiveTimeout(Duration timeout) { 178 reqnn("timeout", timeout); 179 if (timeout.isZero() || timeout.isNegative()) 180 throw iaex("AsyncJobRegistry timeout must be a positive duration, not %s.", timeout); 181 return timeout; 182 } 183 184 private static ScheduledExecutorService defaultScheduler() { 185 return Executors.newSingleThreadScheduledExecutor(r -> { 186 var t = new Thread(r, "juneau-async-jobs"); 187 t.setDaemon(true); 188 return t; 189 }); 190 } 191 192 /** 193 * Attempts to create and register a new job with a freshly-minted capability id. 194 * 195 * <p> 196 * Sweeps timeouts first (so a slot held only by an expired job is freed), then refuses when the running-job cap 197 * is already reached — an unbounded set of keep-running jobs would be unbounded egress of customer-adjacent 198 * content, so this refusal is load-bearing. 199 * 200 * @return The new job, or empty if {@link #MAX_CONCURRENT_JOBS} concurrent jobs are already running. 201 */ 202 public Optional<AsyncJob> tryCreate() { 203 sweepTimeouts(); 204 return Optional.ofNullable(newJobIfRoom()); 205 } 206 207 /** 208 * Atomically checks the running-job cap and registers a new job, or returns <jk>null</jk> when the cap is reached. 209 * 210 * <p> 211 * The check and the insert share {@link #admission}, so concurrent callers (unkeyed, or keyed with different keys) 212 * cannot all observe a free slot and overshoot {@link #MAX_CONCURRENT_JOBS}. A slot is simply a non-terminal 213 * entry in {@link #jobs}, so there is no separate counter to release when a job settles, times out or is reaped. 214 * Lock order is key bucket ({@code byKey.compute}) then {@code admission}, never the reverse. 215 */ 216 private AsyncJob newJobIfRoom() { 217 AsyncJob job; 218 synchronized (admission) { 219 if (runningCount() >= MAX_CONCURRENT_JOBS) 220 return null; 221 job = new AsyncJob(mintId(), clock.instant(), timeout, maxOutputBytes, maxSubscribers); 222 jobs.put(job.id(), job); 223 } 224 scheduleTimeout(job); 225 return job; 226 } 227 228 /** 229 * Creates and registers a new job, throwing when the concurrency cap is reached. 230 * 231 * @return The new job. 232 * @throws IllegalStateException If {@link #MAX_CONCURRENT_JOBS} concurrent jobs are already running. 233 */ 234 public AsyncJob create() { 235 return tryCreate().orElseThrow(() -> new IllegalStateException( 236 "Refusing to create an async job: the " + MAX_CONCURRENT_JOBS + "-concurrent-job cap is reached.")); 237 } 238 239 /** 240 * Returns the job registered under the specified idempotency key, or atomically creates and registers one. 241 * 242 * <p> 243 * If the key's {@link IdempotencyKey#value() value} is already registered, the existing job is returned — 244 * running or finished (see the class Javadoc for the finished-key behavior) — and nothing new is created, so 245 * a duplicate is never refused by the concurrency cap. Otherwise behaves like {@link #tryCreate()}. Concurrent 246 * callers with one key observe exactly one job. 247 * 248 * @param key The idempotency key. Must not be <jk>null</jk>. 249 * @return The existing or new job, or empty if the key is unregistered and {@link #MAX_CONCURRENT_JOBS} concurrent 250 * jobs are already running. 251 */ 252 public Optional<AsyncJob> tryCreate(IdempotencyKey key) { 253 reqnn("key", key); 254 sweepTimeouts(); 255 return Optional.ofNullable(byKey.compute(key.value(), (k, existing) -> { 256 if (existing != null) 257 return existing; 258 return newJobIfRoom(); 259 })); 260 } 261 262 /** 263 * Returns the job registered under the specified idempotency key, or creates and registers one, throwing when the 264 * concurrency cap is reached. 265 * 266 * @param key The idempotency key. Must not be <jk>null</jk>. 267 * @return The existing or new job. 268 * @throws IllegalStateException If the key is unregistered and {@link #MAX_CONCURRENT_JOBS} concurrent jobs are already running. 269 * @see #tryCreate(IdempotencyKey) 270 */ 271 public AsyncJob create(IdempotencyKey key) { 272 return tryCreate(key).orElseThrow(() -> new IllegalStateException( 273 "Refusing to create an async job: the " + MAX_CONCURRENT_JOBS + "-concurrent-job cap is reached.")); 274 } 275 276 /** 277 * Looks up a job by its capability id. 278 * 279 * <p> 280 * An unknown id returns empty — a miss is indistinguishable from any other, so the unguessable id space 281 * cannot be enumerated. 282 * 283 * @param id The capability id. Can be <jk>null</jk>/blank (returns empty). 284 * @return The job, or empty if no such id is registered. 285 */ 286 public Optional<AsyncJob> get(String id) { 287 if (ib(id)) 288 return Optional.empty(); 289 return Optional.ofNullable(jobs.get(id)); 290 } 291 292 /** 293 * Cancels the job with the given id (see {@link AsyncJob#cancel()}). 294 * 295 * @param id The capability id. Can be <jk>null</jk>/blank. 296 * @return The (now terminal) job, or empty if no such id is registered. 297 */ 298 public Optional<AsyncJob> cancel(String id) { 299 var job = get(id); 300 job.ifPresent(AsyncJob::cancel); 301 return job; 302 } 303 304 /** @return The number of concurrent (non-terminal) jobs currently registered. */ 305 public int runningCount() { 306 var n = 0; 307 for (var job : jobs.values()) 308 if (! job.isTerminal()) 309 n++; 310 return n; 311 } 312 313 /** @return The total number of jobs currently registered (running plus retained-terminal). */ 314 public int size() { return jobs.size(); } 315 316 /** 317 * Enforces the hard timeout on every job at the current clock instant and reaps terminal jobs past their 318 * retention window. Called on every {@link #tryCreate()}, scheduled per-job by the private scheduler, and callable 319 * directly by deterministic tests (with a mutable clock) instead of waiting on wall time. 320 */ 321 public void sweepTimeouts() { 322 var now = clock.instant(); 323 var reapBefore = now.minus(RETENTION); 324 var it = jobs.values().iterator(); 325 while (it.hasNext()) { 326 var job = it.next(); 327 job.enforceTimeout(now); 328 if (job.isTerminal() && job.createdAt().isBefore(reapBefore)) { 329 it.remove(); 330 byKey.values().remove(job); 331 } 332 } 333 } 334 335 private void scheduleTimeout(AsyncJob job) { 336 if (scheduler == null) 337 return; 338 // Round up to the next millisecond past the deadline. The scheduler may still fire on or a tick before it (timer 339 // and clock granularity), and enforceTimeout only settles a job strictly past its deadline, so a task that fires 340 // early re-arms itself rather than letting the job run past its hard timeout. 341 var nanos = Duration.between(clock.instant(), job.deadline()).toNanos(); 342 var delay = Math.max(1L, nanos / 1_000_000L + 1L); 343 try { 344 scheduler.schedule(() -> { 345 if (! job.enforceTimeout(clock.instant()) && ! job.isTerminal()) 346 scheduleTimeout(job); 347 }, delay, TimeUnit.MILLISECONDS); 348 } catch (RejectedExecutionException e) { 349 // Scheduler shut down (registry closed); the sweep on the next tryCreate() still enforces the timeout. 350 } 351 } 352 353 private String mintId() { 354 var bytes = new byte[CAPABILITY_BYTES]; 355 random.nextBytes(bytes); 356 return HexFormat.of().formatHex(bytes); 357 } 358 359 /** Shuts down the private scheduler (if this registry created one). Registered jobs are left as-is. */ 360 @Override /* AutoCloseable */ 361 public void close() { 362 if (ownsScheduler && scheduler != null) 363 scheduler.shutdownNow(); 364 } 365}