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> &mdash; 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 &mdash; a client cannot be trusted to cancel &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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} &mdash; 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} &mdash; 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 &mdash; 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}