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.terminal.bus;
018
019import static org.apache.juneau.terminal.report.TerminalViewChecks.*;
020
021import java.util.*;
022import java.util.concurrent.*;
023import java.util.function.*;
024import java.util.logging.*;
025
026import org.apache.juneau.marshall.collections.*;
027import org.apache.juneau.console.bus.*;
028import org.apache.juneau.terminal.region.*;
029
030/**
031 * Tells browser terminal cards that a terminal has new bytes, so they fetch immediately instead of waiting for the
032 * next poll.
033 *
034 * <p>
035 * This is a {@link TerminalNudgePublisher}: hand it to {@link MemoryTerminalSource#nudges(String, TerminalNudgePublisher)} or
036 * {@link TerminalProcess#nudges(String, TerminalNudgePublisher)}, or call {@link #nudge(String, long, boolean)} after a
037 * write of your own.  Each call is coalesced to at most one frame per terminal per 250 ms, carrying the latest offset,
038 * and is published on {@code term.nudge:<id>} as <c>{"end": &lt;offset&gt;}</c>, plus <c>"done": true</c> once no more
039 * bytes will arrive.  A nudge is only a hint: the card still reads the bytes through the terminal's bytes endpoint,
040 * and keeps polling as a fallback.
041 *
042 * <p>
043 * Wiring.  The bus is built from the policy, so it is bound afterwards, once:
044 * <p class='bjava'>
045 *    TerminalNudges <jv>nudges</jv> = TerminalNudges.<jsm>create</jsm>(<jv>access</jv>);
046 *    ServerBus <jv>bus</jv> = ServerBus.<jsm>create</jsm>(BusPolicy.<jsm>create</jsm>()
047 *       .downstream(<js>"term.nudge:*"</js>, <jk>false</jk>)
048 *       .build());
049 *    <jv>nudges</jv>.bind(<jv>bus</jv>);
050 * </p>
051 *
052 * <p>
053 * Access.  {@link TerminalAccess#canWatch(TerminalCaller, String)} is called for every delivery, which is up to four
054 * times per second per terminal, for each connected session that holds the grant (up to 256 by default).  It must be
055 * cheap.  Only sessions holding the exact {@code term.nudge:<id>} grant receive a nudge; a session granted only
056 * {@code term.nudge:*} receives none.  A revocation that races a delivery can let that one frame through; the next is
057 * denied.
058 *
059 * <p>
060 * Delivery runs on one daemon thread named {@code juneau-terminal-nudges}.  {@link #nudge(String, long, boolean)} only
061 * records state and schedules, so it never blocks the writer.
062 */
063public final class TerminalNudges implements TerminalNudgePublisher, AutoCloseable {
064
065   /** The topic family; a terminal's topic is {@code term.nudge:<id>}. */
066   public static final String FAMILY = "term.nudge";
067
068   private static final Logger LOG = Logger.getLogger(TerminalNudges.class.getName());
069   private static final long WINDOW_MS = 250;
070   private static final long CLEANUP_MS = 5000;
071   private static final long WARN_MS = 60_000;
072
073   /** Runs delayed tasks; replaced in tests. */
074   interface Scheduler {
075      /** Starts the scheduler's thread, if it has one. */
076      default void start() {}
077
078      /** Runs a task after a delay. */
079      void schedule(Runnable r, long delayMs);
080
081      /** Stops the scheduler. */
082      void close();
083   }
084
085   private static final class State {
086      long end;
087      long lastSentAt = Long.MIN_VALUE;
088      long lastTouched;
089      boolean done;
090      boolean scheduled;
091      boolean cleanupPending;
092   }
093
094   private final TerminalAccess access;
095   private final Scheduler scheduler;
096   private final LongSupplier clock;
097   private final Map<String,State> states = new HashMap<>();
098   private final Map<String,Long> lastWarned = new HashMap<>();
099   private ServerBus bus;
100   private boolean closed;
101
102   TerminalNudges(TerminalAccess access, Scheduler scheduler, LongSupplier clock) {
103      this.access = access;
104      this.scheduler = scheduler;
105      this.clock = clock;
106   }
107
108   /**
109    * Creates a publisher.
110    *
111    * @param access Who may watch.  Must not be <jk>null</jk>.
112    * @return A new, unbound publisher.
113    * @throws IllegalArgumentException If {@code access} is <jk>null</jk>.
114    */
115   public static TerminalNudges create(TerminalAccess access) {
116      if (access == null)
117         throw new IllegalArgumentException("TerminalNudges.create: access must not be null");
118      return new TerminalNudges(access, new ThreadScheduler(), () -> System.nanoTime() / 1_000_000);
119   }
120
121   /**
122    * The nudge topic of a terminal.
123    *
124    * @param terminalId The terminal id.
125    * @return {@code term.nudge:<id>}.
126    * @throws IllegalArgumentException If the id is not a valid run id.
127    */
128   public static String topic(String terminalId) {
129      if (terminalId == null || ! isRunId(terminalId))
130         throw new IllegalArgumentException("TerminalNudges: '" + terminalId + "' is not a valid terminal id");
131      return FAMILY + ":" + terminalId;
132   }
133
134   /**
135    * Binds the bus the nudges are published on, and starts the scheduler.  May be called once.
136    *
137    * @param bus The bus.  Its policy must allow {@code term.nudge:*} downstream without retention.
138    * @return This object.
139    * @throws IllegalArgumentException If {@code bus} is <jk>null</jk>.
140    * @throws IllegalStateException If a bus is already bound, or the policy does not allow {@code term.nudge:*}
141    *    downstream unretained.
142    */
143   public TerminalNudges bind(ServerBus bus) {
144      if (bus == null)
145         throw new IllegalArgumentException("TerminalNudges.bind: bus must not be null");
146      synchronized (this) {
147         if (this.bus != null)
148            throw new IllegalStateException("TerminalNudges.bind: already bound");
149         var retain = bus.policy().downstreamRetain(FAMILY + ":*");
150         if (retain.orElse(true))
151            throw new IllegalStateException("TerminalNudges.bind: the bus policy must allow '" + FAMILY + ":*' downstream without retention");
152         // Started before the bus is visible: a nudge that sees the bus must find a running scheduler.
153         scheduler.start();
154         this.bus = bus;
155      }
156      return this;
157   }
158
159   /**
160    * Records that a terminal's bytes now end at an offset; delivery is coalesced and happens on the scheduler thread.
161    *
162    * <p>
163    * Dropped silently before {@link #bind(ServerBus)} and after {@link #close()}.
164    *
165    * @param terminalId The terminal id.
166    * @param end The offset one past the last byte.
167    * @param done <jk>true</jk> if no more bytes will be appended.
168    * @throws IllegalArgumentException If the id is not a valid run id.
169    */
170   @Override /* TerminalNudgePublisher */
171   public void nudge(String terminalId, long end, boolean done) {
172      topic(terminalId);
173      synchronized (this) {
174         if (bus == null || closed)
175            return;
176         var now = clock.getAsLong();
177         var st = states.computeIfAbsent(terminalId, k -> new State());
178         st.end = Math.max(st.end, end);
179         st.done |= done;
180         st.lastTouched = now;
181         if (! st.scheduled) {
182            st.scheduled = true;
183            var delay = st.lastSentAt == Long.MIN_VALUE ? 0 : Math.max(0, st.lastSentAt + WINDOW_MS - now);
184            scheduler.schedule(guarded(() -> flush(terminalId)), delay);
185         }
186      }
187   }
188
189   /**
190    * Stops the scheduler.  Flushes already queued, and later nudges, deliver nothing.  Idempotent.
191    */
192   @Override /* AutoCloseable */
193   public void close() {
194      synchronized (this) {
195         if (closed)
196            return;
197         closed = true;
198         states.clear();
199         lastWarned.clear();
200      }
201      scheduler.close();
202   }
203
204   /** The number of terminals with state held; for tests. */
205   synchronized int pendingTerminals() {
206      return states.size();
207   }
208
209   /** The number of terminals with a rate-limited warning held; for tests. */
210   synchronized int warnedTerminals() {
211      return lastWarned.size();
212   }
213
214   private Runnable guarded(Runnable r) {
215      return () -> {
216         try {
217            r.run();
218         } catch (RuntimeException e) {
219            LOG.log(Level.WARNING, e, () -> "Terminal nudge task failed.");
220         }
221      };
222   }
223
224   private void flush(String id) {
225      ServerBus b;
226      long end;
227      boolean done;
228      synchronized (this) {
229         if (closed)
230            return;
231         var st = states.get(id);
232         if (st == null)
233            return;
234         // Reset first, so a failure below cannot leave the terminal marked as scheduled forever.
235         st.scheduled = false;
236         st.lastSentAt = clock.getAsLong();
237         b = bus;
238         end = st.end;
239         done = st.done;
240         st.done = false;
241      }
242      var delivered = false;
243      try {
244         deliver(b, id, end, done);
245         delivered = true;
246      } finally {
247         synchronized (this) {
248            var st = states.get(id);
249            if (st != null && ! closed) {
250               if (! delivered) {
251                  if (st.scheduled) {
252                     // A later nudge is already queued: it carries the done report.
253                     st.done |= done;
254                  } else {
255                     // No follow-up: drop the state; polling finishes the card.
256                     states.remove(id);
257                     lastWarned.remove(id);
258                  }
259               } else if (done) {
260                  // A nudge right after a done delivery starts a fresh state and skips the 250 ms window (allowed).
261                  if (! st.scheduled) {
262                     states.remove(id);
263                     lastWarned.remove(id);
264                  }
265               } else if (! st.cleanupPending) {
266                  st.cleanupPending = true;
267                  scheduler.schedule(guarded(() -> cleanup(id)), CLEANUP_MS);
268               }
269            }
270         }
271      }
272   }
273
274   private void cleanup(String id) {
275      synchronized (this) {
276         var st = states.get(id);
277         if (st == null || closed)
278            return;
279         st.cleanupPending = false;
280         var age = clock.getAsLong() - st.lastTouched;
281         if (st.scheduled)
282            return;
283         if (age >= CLEANUP_MS) {
284            states.remove(id);
285            lastWarned.remove(id);
286         } else {
287            st.cleanupPending = true;
288            scheduler.schedule(guarded(() -> cleanup(id)), CLEANUP_MS - age);
289         }
290      }
291   }
292
293   private void deliver(ServerBus b, String id, long end, boolean done) {
294      var topic = topic(id);
295      var allowed = Collections.newSetFromMap(new IdentityHashMap<BusSession,Boolean>());
296      for (var s : b.sessions()) {
297         if (! s.isConnected() || ! s.downstream().contains(topic))
298            continue;
299         try {
300            if (access.canWatch(new SessionCaller(s), id))
301               allowed.add(s);
302         } catch (RuntimeException e) {
303            warnDenied(id, e);
304         }
305      }
306      if (allowed.isEmpty())
307         return;
308      var payload = done ? JsonMap.of("end", end, "done", true) : JsonMap.of("end", end);
309      try {
310         b.publishTo(allowed::contains, topic, payload);
311      } catch (IllegalArgumentException e) {
312         LOG.log(Level.WARNING, e, () -> "Terminal nudge for '" + id + "' was refused by the bus.");
313      }
314   }
315
316   private void warnDenied(String id, RuntimeException e) {
317      var now = clock.getAsLong();
318      synchronized (this) {
319         var last = lastWarned.get(id);
320         if (last != null && now - last < WARN_MS)
321            return;
322         lastWarned.put(id, now);
323      }
324      LOG.log(Level.WARNING, e, () -> "TerminalAccess.canWatch failed for '" + id + "'; the session gets no nudge.");
325   }
326
327   /** The default scheduler: one daemon thread. */
328   private static final class ThreadScheduler implements Scheduler {
329      private ScheduledExecutorService executor;
330
331      @Override public synchronized void start() {
332         if (executor != null)
333            return;
334         var ex = new ScheduledThreadPoolExecutor(1, r -> {
335            var t = new Thread(r, "juneau-terminal-nudges");
336            t.setDaemon(true);
337            return t;
338         });
339         ex.setRemoveOnCancelPolicy(true);
340         ex.prestartAllCoreThreads();
341         executor = ex;
342      }
343
344      @Override public synchronized void schedule(Runnable r, long delayMs) {
345         if (executor == null)
346            return;
347         try {
348            executor.schedule(r, delayMs, TimeUnit.MILLISECONDS);
349         } catch (RejectedExecutionException e) {
350            // Closed concurrently: the nudge is dropped.
351         }
352      }
353
354      @Override public synchronized void close() {
355         if (executor != null)
356            executor.shutdownNow();
357      }
358   }
359}