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 &mdash; 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 &mdash; {@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 &mdash; scheduled here and also swept by
050 *       {@link #sweepTimeouts()};
051 *    <li>{@link AsyncJob#maxOutputBytes()} of streamed output per job &mdash; enforced in {@link AsyncJob#progress};
052 *    <li>{@link AsyncJob#maxSubscribers()} concurrent subscribers per job &mdash; 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 &mdash; well beyond the &ge;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 &mdash; 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 &mdash;
244    * running or finished (see the class Javadoc for the finished-key behavior) &mdash; 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 &mdash; 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}