| // Copyright (C) 2012 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.server.git; |
| |
| import static com.google.gerrit.server.DeadlineChecker.getTimeoutFormatter; |
| import static java.util.concurrent.TimeUnit.MILLISECONDS; |
| import static java.util.concurrent.TimeUnit.NANOSECONDS; |
| |
| import com.google.common.base.Strings; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.common.util.concurrent.UncheckedExecutionException; |
| import com.google.gerrit.server.CancellationMetrics; |
| import com.google.gerrit.server.cancellation.RequestStateProvider; |
| import com.google.inject.assistedinject.Assisted; |
| import com.google.inject.assistedinject.AssistedInject; |
| import java.io.IOException; |
| import java.io.OutputStream; |
| import java.util.List; |
| import java.util.Optional; |
| import java.util.concurrent.CancellationException; |
| import java.util.concurrent.CopyOnWriteArrayList; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.Future; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.TimeoutException; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.concurrent.atomic.AtomicInteger; |
| import org.eclipse.jgit.lib.Constants; |
| import org.eclipse.jgit.lib.ProgressMonitor; |
| |
| /** |
| * Progress reporting interface that multiplexes multiple sub-tasks. |
| * |
| * <p>Output is of the format: |
| * |
| * <pre> |
| * Task: subA: 1, subB: 75% (3/4) (-)\r |
| * Task: subA: 2, subB: 75% (3/4), subC: 1 (\)\r |
| * Task: subA: 2, subB: 100% (4/4), subC: 1 (|)\r |
| * Task: subA: 4, subB: 100% (4/4), subC: 4, done \n |
| * </pre> |
| * |
| * <p>Callers should try to keep task and sub-task descriptions short, since the output should fit |
| * on one terminal line. (Note that git clients do not accept terminal control characters, so true |
| * multi-line progress messages would be impossible.) |
| * |
| * <p>Whether the client is disconnected or the deadline is exceeded can be checked by {@link |
| * #checkIfCancelled(RequestStateProvider.OnCancelled)}. This allows the worker thread to react to |
| * cancellations and abort its execution and finish gracefully. After a cancellation has been |
| * signaled the worker thread has 10 * {@link #maxIntervalNanos} to react to the cancellation and |
| * finish gracefully. If the worker thread doesn't finish gracefully in time after the cancellation |
| * has been signaled, the future executing the task is forcefully cancelled which means that the |
| * worker thread gets interrupted and an internal error is returned to the client. To react to |
| * cancellations it is recommended that the task opens a {@link |
| * com.google.gerrit.server.cancellation.RequestStateContext} in a try-with-resources block to |
| * register the {@link MultiProgressMonitor} as a {@link RequestStateProvider}. This way the worker |
| * thread gets aborted by a {@link com.google.gerrit.server.cancellation.RequestCancelledException} |
| * when the request is cancelled which allows the worker thread to handle the cancellation |
| * gracefully by catching this exception (e.g. to return a proper error message). {@link |
| * com.google.gerrit.server.cancellation.RequestCancelledException} is only thrown when the worker |
| * thread checks for cancellation via {@link |
| * com.google.gerrit.server.cancellation.RequestStateContext#abortIfCancelled()}. E.g. this is done |
| * whenever {@link com.google.gerrit.server.logging.TraceContext.TraceTimer} is opened/closed. |
| */ |
| public class MultiProgressMonitor implements RequestStateProvider { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| /** Constant indicating the total work units cannot be predicted. */ |
| public static final int UNKNOWN = 0; |
| |
| private static final char[] SPINNER_STATES = new char[] {'-', '\\', '|', '/'}; |
| private static final char NO_SPINNER = ' '; |
| |
| public enum TaskKind { |
| INDEXING, |
| RECEIVE_COMMITS; |
| } |
| |
| /** Handle for a sub-task. */ |
| public class Task implements ProgressMonitor { |
| private final String name; |
| private final int total; |
| private int count; |
| private int lastPercent; |
| |
| Task(String subTaskName, int totalWork) { |
| this.name = subTaskName; |
| this.total = totalWork; |
| } |
| |
| /** |
| * Indicate that work has been completed on this sub-task. |
| * |
| * <p>Must be called from a worker thread. |
| * |
| * @param completed number of work units completed. |
| */ |
| @Override |
| public void update(int completed) { |
| boolean w = false; |
| synchronized (MultiProgressMonitor.this) { |
| count += completed; |
| if (total != UNKNOWN) { |
| int percent = count * 100 / total; |
| if (percent > lastPercent) { |
| lastPercent = percent; |
| w = true; |
| } |
| } |
| } |
| if (w) { |
| wakeUp(); |
| } |
| } |
| |
| /** |
| * Indicate that this sub-task is finished. |
| * |
| * <p>Must be called from a worker thread. |
| */ |
| public void end() { |
| if (total == UNKNOWN && getCount() > 0) { |
| wakeUp(); |
| } |
| } |
| |
| @Override |
| public void start(int totalTasks) {} |
| |
| @Override |
| public void beginTask(String title, int totalWork) {} |
| |
| @Override |
| public void endTask() {} |
| |
| @Override |
| public boolean isCancelled() { |
| return false; |
| } |
| |
| public int getCount() { |
| synchronized (MultiProgressMonitor.this) { |
| return count; |
| } |
| } |
| |
| public int getTotal() { |
| return total; |
| } |
| |
| public String getName() { |
| return name; |
| } |
| |
| public String getTotalDisplay(int total) { |
| return String.valueOf(total); |
| } |
| } |
| |
| /** Handle for a sub-task whose total work can be updated while the task is in progress. */ |
| public class VolatileTask extends Task { |
| protected AtomicInteger volatileTotal; |
| protected AtomicBoolean isTotalFinalized = new AtomicBoolean(false); |
| |
| public VolatileTask(String subTaskName) { |
| super(subTaskName, UNKNOWN); |
| volatileTotal = new AtomicInteger(UNKNOWN); |
| } |
| |
| /** |
| * Update the total work for this sub-task. |
| * |
| * <p>Intended to be called from a worker thread. |
| * |
| * @param workUnits number of work units to be added to existing total work. |
| */ |
| public void updateTotal(int workUnits) { |
| if (!isTotalFinalized.get()) { |
| volatileTotal.addAndGet(workUnits); |
| } else { |
| logger.atWarning().log( |
| "Total work has been finalized on sub-task %s and cannot be updated", getName()); |
| } |
| } |
| |
| /** |
| * Mark the total on this sub-task as unmodifiable. |
| * |
| * <p>Intended to be called from a worker thread. |
| */ |
| public void finalizeTotal() { |
| isTotalFinalized.set(true); |
| } |
| |
| @Override |
| public int getTotal() { |
| return volatileTotal.get(); |
| } |
| |
| @Override |
| public String getTotalDisplay(int total) { |
| return super.getTotalDisplay(total) + (isTotalFinalized.get() ? "" : "+"); |
| } |
| } |
| |
| public interface Factory { |
| MultiProgressMonitor create(OutputStream out, TaskKind taskKind, String taskName); |
| |
| MultiProgressMonitor create( |
| OutputStream out, |
| TaskKind taskKind, |
| String taskName, |
| long maxIntervalTime, |
| TimeUnit maxIntervalUnit); |
| } |
| |
| private final CancellationMetrics cancellationMetrics; |
| private final OutputStream out; |
| private final TaskKind taskKind; |
| private final String taskName; |
| private final List<Task> tasks = new CopyOnWriteArrayList<>(); |
| private int spinnerIndex; |
| private char spinnerState = NO_SPINNER; |
| private boolean done; |
| private boolean clientDisconnected; |
| private boolean deadlineExceeded; |
| private boolean forcefulTermination; |
| private Optional<Long> timeout = Optional.empty(); |
| |
| private final long maxIntervalNanos; |
| |
| /** |
| * Create a new progress monitor for multiple sub-tasks. |
| * |
| * @param out stream for writing progress messages. |
| * @param taskName name of the overall task. |
| */ |
| @AssistedInject |
| private MultiProgressMonitor( |
| CancellationMetrics cancellationMetrics, |
| @Assisted OutputStream out, |
| @Assisted TaskKind taskKind, |
| @Assisted String taskName) { |
| this(cancellationMetrics, out, taskKind, taskName, 500, MILLISECONDS); |
| } |
| |
| /** |
| * Create a new progress monitor for multiple sub-tasks. |
| * |
| * @param out stream for writing progress messages. |
| * @param taskName name of the overall task. |
| * @param maxIntervalTime maximum interval between progress messages. |
| * @param maxIntervalUnit time unit for progress interval. |
| */ |
| @AssistedInject |
| private MultiProgressMonitor( |
| CancellationMetrics cancellationMetrics, |
| @Assisted OutputStream out, |
| @Assisted TaskKind taskKind, |
| @Assisted String taskName, |
| @Assisted long maxIntervalTime, |
| @Assisted TimeUnit maxIntervalUnit) { |
| this.cancellationMetrics = cancellationMetrics; |
| this.out = out; |
| this.taskKind = taskKind; |
| this.taskName = taskName; |
| maxIntervalNanos = NANOSECONDS.convert(maxIntervalTime, maxIntervalUnit); |
| } |
| |
| /** |
| * Wait for a task managed by a {@link Future}, with no timeout. |
| * |
| * @see #waitFor(Future, long, TimeUnit, long, TimeUnit) |
| */ |
| public <T> T waitFor(Future<T> workerFuture) { |
| try { |
| return waitFor( |
| workerFuture, |
| /* taskTimeoutTime= */ 0, |
| /* taskTimeoutUnit= */ null, |
| /* cancellationTimeoutTime= */ 0, |
| /* cancellationTimeoutUnit= */ null); |
| } catch (TimeoutException e) { |
| throw new IllegalStateException("timout exception without setting a timeout", e); |
| } |
| } |
| |
| /** |
| * Wait for a task managed by a {@link Future}. |
| * |
| * <p>Must be called from the main thread, <em>not</em> a worker thread. Once a worker thread |
| * calls {@link #end()}, the future has an additional {@code maxInterval} to finish before it is |
| * forcefully cancelled and {@link ExecutionException} is thrown. |
| * |
| * @see #waitForNonFinalTask(Future, long, TimeUnit) |
| * @param workerFuture a future that returns when worker threads are finished. |
| * @param taskTimeoutTime overall timeout for the task; the future gets a cancellation signal |
| * after this timeout is exceeded; non-positive values indicate no timeout. |
| * @param taskTimeoutUnit unit for overall task timeout. |
| * @param cancellationTimeoutTime timeout for the task to react to the cancellation signal; if the |
| * task doesn't terminate within this time it is forcefully cancelled; non-positive values |
| * indicate no timeout. |
| * @param cancellationTimeoutUnit unit for the cancellation timeout. |
| * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was |
| * cancelled, or timed out waiting for a worker to call {@link #end()}. |
| */ |
| public <T> T waitFor( |
| Future<T> workerFuture, |
| long taskTimeoutTime, |
| TimeUnit taskTimeoutUnit, |
| long cancellationTimeoutTime, |
| TimeUnit cancellationTimeoutUnit) |
| throws TimeoutException { |
| T t = |
| waitForNonFinalTask( |
| workerFuture, |
| taskTimeoutTime, |
| taskTimeoutUnit, |
| cancellationTimeoutTime, |
| cancellationTimeoutUnit); |
| synchronized (this) { |
| if (!done) { |
| // The worker may not have called end() explicitly, which is likely a |
| // programming error. |
| logger.atWarning().log("MultiProgressMonitor worker did not call end() before returning"); |
| end(); |
| } |
| } |
| sendDone(); |
| return t; |
| } |
| |
| /** |
| * Wait for a non-final task managed by a {@link Future}, with no timeout. |
| * |
| * @see #waitForNonFinalTask(Future, long, TimeUnit) |
| */ |
| public <T> T waitForNonFinalTask(Future<T> workerFuture) { |
| try { |
| return waitForNonFinalTask(workerFuture, 0, null, 0, null); |
| } catch (TimeoutException e) { |
| throw new IllegalStateException("timout exception without setting a timeout", e); |
| } |
| } |
| |
| /** |
| * Wait for a task managed by a {@link Future}. This call does not expect the worker thread to |
| * call {@link #end()}. It is intended to be used to track a non-final task. |
| * |
| * @param workerFuture a future that returns when worker threads are finished. |
| * @param taskTimeoutTime overall timeout for the task; the future is forcefully cancelled if the |
| * task exceeds the timeout. Non-positive values indicate no timeout. |
| * @param taskTimeoutUnit unit for overall task timeout. |
| * @param cancellationTimeoutTime timeout for the task to react to the cancellation signal; if the |
| * task doesn't terminate within this time it is forcefully cancelled; non-positive values |
| * indicate no timeout. |
| * @param cancellationTimeoutUnit unit for the cancellation timeout. |
| * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was |
| * cancelled, or timed out waiting for a worker to call {@link #end()}. |
| */ |
| public <T> T waitForNonFinalTask( |
| Future<T> workerFuture, |
| long taskTimeoutTime, |
| TimeUnit taskTimeoutUnit, |
| long cancellationTimeoutTime, |
| TimeUnit cancellationTimeoutUnit) |
| throws TimeoutException { |
| long overallStart = System.nanoTime(); |
| long cancellationNanos = |
| cancellationTimeoutTime > 0 |
| ? NANOSECONDS.convert(cancellationTimeoutTime, cancellationTimeoutUnit) |
| : 0; |
| long deadline; |
| if (taskTimeoutTime > 0) { |
| timeout = Optional.of(NANOSECONDS.convert(taskTimeoutTime, taskTimeoutUnit)); |
| deadline = overallStart + timeout.get(); |
| } else { |
| deadline = 0; |
| } |
| |
| synchronized (this) { |
| long left = maxIntervalNanos; |
| while (!workerFuture.isDone() && !done) { |
| long start = System.nanoTime(); |
| try { |
| NANOSECONDS.timedWait(this, left); |
| } catch (InterruptedException e) { |
| throw new UncheckedExecutionException(e); |
| } |
| |
| // Send an update on every wakeup (manual or spurious), but only move |
| // the spinner every maxInterval. |
| long now = System.nanoTime(); |
| |
| if (deadline > 0 && now > deadline) { |
| if (!deadlineExceeded) { |
| logger.atFine().log( |
| "deadline exceeded after %sms, signaling cancellation (timeout=%sms, task=%s(%s))", |
| MILLISECONDS.convert(now - overallStart, NANOSECONDS), |
| MILLISECONDS.convert(now - deadline, NANOSECONDS), |
| taskKind, |
| taskName); |
| } |
| deadlineExceeded = true; |
| |
| // After setting deadlineExceeded = true give the cancellationNanos to react to the |
| // cancellation and return gracefully. |
| if (now > deadline + cancellationNanos) { |
| // The worker didn't react to the cancellation, cancel it forcefully by an interrupt. |
| workerFuture.cancel(true); |
| forcefulTermination = true; |
| if (workerFuture.isCancelled()) { |
| logger.atWarning().log( |
| "MultiProgressMonitor worker killed after %sms, cancelled (timeout=%sms, task=%s(%s))", |
| MILLISECONDS.convert(now - overallStart, NANOSECONDS), |
| MILLISECONDS.convert(now - deadline, NANOSECONDS), |
| taskKind, |
| taskName); |
| if (taskKind == TaskKind.RECEIVE_COMMITS) { |
| cancellationMetrics.countForcefulReceiveTimeout(); |
| } |
| } |
| break; |
| } |
| } |
| |
| left -= now - start; |
| if (left <= 0) { |
| moveSpinner(); |
| left = maxIntervalNanos; |
| } |
| sendUpdate(); |
| } |
| if (deadlineExceeded && !forcefulTermination && taskKind == TaskKind.RECEIVE_COMMITS) { |
| cancellationMetrics.countGracefulReceiveTimeout(); |
| } |
| wakeUp(); |
| } |
| |
| // The loop exits as soon as the worker calls end(), but we give it another |
| // 2 x maxIntervalNanos to finish up and return. |
| try { |
| return workerFuture.get(2 * maxIntervalNanos, NANOSECONDS); |
| } catch (InterruptedException | CancellationException e) { |
| logger.atWarning().withCause(e).log( |
| "unable to finish processing (task=%s(%s))", taskKind, taskName); |
| throw new UncheckedExecutionException(e); |
| } catch (TimeoutException e) { |
| workerFuture.cancel(true); |
| throw e; |
| } catch (ExecutionException e) { |
| throw new UncheckedExecutionException(e); |
| } |
| } |
| |
| private synchronized void wakeUp() { |
| notifyAll(); |
| } |
| |
| /** |
| * Begin a sub-task. |
| * |
| * @param subTask sub-task name. |
| * @param subTaskWork total work units in sub-task, or {@link #UNKNOWN}. |
| * @return sub-task handle. |
| */ |
| public Task beginSubTask(String subTask, int subTaskWork) { |
| Task task = new Task(subTask, subTaskWork); |
| tasks.add(task); |
| return task; |
| } |
| |
| /** |
| * Begin a sub-task whose total work can be updated. |
| * |
| * @param subTask sub-task name. |
| * @return sub-task handle. |
| */ |
| public VolatileTask beginVolatileSubTask(String subTask) { |
| VolatileTask task = new VolatileTask(subTask); |
| tasks.add(task); |
| return task; |
| } |
| |
| /** |
| * End the overall task. |
| * |
| * <p>Must be called from a worker thread. |
| */ |
| public synchronized void end() { |
| done = true; |
| wakeUp(); |
| } |
| |
| private void sendDone() { |
| spinnerState = NO_SPINNER; |
| StringBuilder s = format(); |
| boolean any = false; |
| for (Task t : tasks) { |
| if (t.count != 0) { |
| any = true; |
| break; |
| } |
| } |
| if (any) { |
| s.append(","); |
| } |
| s.append(" done \n"); |
| send(s); |
| } |
| |
| private void moveSpinner() { |
| spinnerIndex = (spinnerIndex + 1) % SPINNER_STATES.length; |
| spinnerState = SPINNER_STATES[spinnerIndex]; |
| } |
| |
| private void sendUpdate() { |
| send(format()); |
| } |
| |
| private StringBuilder format() { |
| StringBuilder s = new StringBuilder().append("\r").append(taskName).append(':'); |
| |
| if (!tasks.isEmpty()) { |
| boolean first = true; |
| for (Task t : tasks) { |
| int count = t.getCount(); |
| int total = t.getTotal(); |
| if (count == 0) { |
| continue; |
| } |
| |
| if (!first) { |
| s.append(','); |
| } else { |
| first = false; |
| } |
| |
| s.append(' '); |
| if (!Strings.isNullOrEmpty(t.name)) { |
| s.append(t.name).append(": "); |
| } |
| if (total == UNKNOWN) { |
| s.append(count); |
| } else { |
| s.append( |
| String.format("%d%% (%d/%s)", count * 100 / total, count, t.getTotalDisplay(total))); |
| } |
| } |
| } |
| |
| if (spinnerState != NO_SPINNER) { |
| // Don't output a spinner until the alarm fires for the first time. |
| s.append(" (").append(spinnerState).append(')'); |
| } |
| return s; |
| } |
| |
| private void send(StringBuilder s) { |
| if (!clientDisconnected) { |
| try { |
| out.write(Constants.encode(s.toString())); |
| out.flush(); |
| } catch (IOException e) { |
| logger.atWarning().withCause(e).log( |
| "Sending progress to client failed. Stop sending updates for task %s(%s)", |
| taskKind, taskName); |
| clientDisconnected = true; |
| } |
| } |
| } |
| |
| @Override |
| public void checkIfCancelled(OnCancelled onCancelled) { |
| if (clientDisconnected) { |
| onCancelled.onCancel(RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* message= */ null); |
| } else if (deadlineExceeded) { |
| onCancelled.onCancel( |
| RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, |
| timeout |
| .map( |
| taskKind == TaskKind.RECEIVE_COMMITS |
| ? getTimeoutFormatter("receive.timeout") |
| : getTimeoutFormatter("timeout")) |
| .orElse(null)); |
| } |
| } |
| } |