org.apache.beam.runners.dataflow.DataflowPipelineJob.java Source code

Java tutorial

Introduction

Here is the source code for org.apache.beam.runners.dataflow.DataflowPipelineJob.java

Source

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.beam.runners.dataflow;

import static org.apache.beam.runners.dataflow.util.TimeUtil.fromCloudTime;
import static org.apache.beam.vendor.guava.v26_0_jre.com.google.common.base.MoreObjects.firstNonNull;

import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.util.BackOff;
import com.google.api.client.util.BackOffUtils;
import com.google.api.client.util.NanoClock;
import com.google.api.client.util.Sleeper;
import com.google.api.services.dataflow.model.Job;
import com.google.api.services.dataflow.model.JobMessage;
import com.google.api.services.dataflow.model.MetricUpdate;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.atomic.AtomicReference;
import javax.annotation.Nullable;
import org.apache.beam.runners.dataflow.options.DataflowPipelineOptions;
import org.apache.beam.runners.dataflow.util.MonitoringUtil;
import org.apache.beam.runners.dataflow.util.MonitoringUtil.JobMessagesHandler;
import org.apache.beam.sdk.PipelineResult;
import org.apache.beam.sdk.extensions.gcp.util.BackOffAdapter;
import org.apache.beam.sdk.metrics.MetricResults;
import org.apache.beam.sdk.runners.AppliedPTransform;
import org.apache.beam.sdk.util.FluentBackoff;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.annotations.VisibleForTesting;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.BiMap;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.HashBiMap;
import org.apache.beam.vendor.guava.v26_0_jre.com.google.common.collect.ImmutableMap;
import org.joda.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** A DataflowPipelineJob represents a job submitted to Dataflow using {@link DataflowRunner}. */
public class DataflowPipelineJob implements PipelineResult {

    private static final Logger LOG = LoggerFactory.getLogger(DataflowPipelineJob.class);

    /** The id for the job. */
    protected String jobId;

    /** The {@link DataflowPipelineOptions} for the job. */
    private final DataflowPipelineOptions dataflowOptions;

    /**
     * Client for the Dataflow service. This can be used to query the service for information about
     * the job.
     */
    private final DataflowClient dataflowClient;

    /**
     * MetricResults object for Dataflow Runner. It allows for querying of metrics from the Dataflow
     * service.
     */
    private final DataflowMetrics dataflowMetrics;

    /** The state the job terminated in or {@code null} if the job has not terminated. */
    @Nullable
    private State terminalState = null;

    /** The job that replaced this one or {@code null} if the job has not been replaced. */
    @Nullable
    private DataflowPipelineJob replacedByJob = null;

    protected BiMap<AppliedPTransform<?, ?, ?>, String> transformStepNames;

    /** The Metric Updates retrieved after the job was in a terminal state. */
    private List<MetricUpdate> terminalMetricUpdates;

    /** The latest timestamp up to which job messages have been retrieved. */
    private long lastTimestamp = Long.MIN_VALUE;

    /** The polling interval for job status and messages information. */
    static final Duration MESSAGES_POLLING_INTERVAL = Duration.standardSeconds(2);

    static final Duration STATUS_POLLING_INTERVAL = Duration.standardSeconds(2);

    static final Duration DEFAULT_MAX_BACKOFF = Duration.standardMinutes(2);

    static final double DEFAULT_BACKOFF_EXPONENT = 1.5;

    /** The amount of polling retries for job status and messages information. */
    static final int MESSAGES_POLLING_RETRIES = 11;

    static final int STATUS_POLLING_RETRIES = 4;

    private static final FluentBackoff MESSAGES_BACKOFF_FACTORY = FluentBackoff.DEFAULT
            .withInitialBackoff(MESSAGES_POLLING_INTERVAL).withMaxRetries(MESSAGES_POLLING_RETRIES)
            .withExponent(DEFAULT_BACKOFF_EXPONENT).withMaxBackoff(DEFAULT_MAX_BACKOFF);

    protected static final FluentBackoff STATUS_BACKOFF_FACTORY = FluentBackoff.DEFAULT
            .withInitialBackoff(STATUS_POLLING_INTERVAL).withMaxRetries(STATUS_POLLING_RETRIES)
            .withExponent(DEFAULT_BACKOFF_EXPONENT);

    /**
     * Constructs the job.
     *
     * @param jobId the job id
     * @param dataflowOptions used to configure the client for the Dataflow Service
     * @param transformStepNames a mapping from AppliedPTransforms to Step Names
     */
    public DataflowPipelineJob(DataflowClient dataflowClient, String jobId, DataflowPipelineOptions dataflowOptions,
            Map<AppliedPTransform<?, ?, ?>, String> transformStepNames) {
        this.dataflowClient = dataflowClient;
        this.jobId = jobId;
        this.dataflowOptions = dataflowOptions;
        this.transformStepNames = HashBiMap.create(firstNonNull(transformStepNames, ImmutableMap.of()));
        this.dataflowMetrics = new DataflowMetrics(this, this.dataflowClient);
    }

    /** Get the id of this job. */
    public String getJobId() {
        return jobId;
    }

    /** Get the project this job exists in. */
    public String getProjectId() {
        return dataflowOptions.getProject();
    }

    public DataflowPipelineOptions getDataflowOptions() {
        return dataflowOptions;
    }

    /** Get the region this job exists in. */
    public String getRegion() {
        return dataflowOptions.getRegion();
    }

    /**
     * Returns a new {@link DataflowPipelineJob} for the job that replaced this one, if applicable.
     *
     * @throws IllegalStateException if called before the job has terminated or if the job terminated
     *     but was not updated
     */
    public DataflowPipelineJob getReplacedByJob() {
        if (terminalState == null) {
            throw new IllegalStateException("getReplacedByJob() called before job terminated");
        }
        if (replacedByJob == null) {
            throw new IllegalStateException("getReplacedByJob() called for job that was not replaced");
        }
        return replacedByJob;
    }

    @Override
    @Nullable
    public State waitUntilFinish() {
        return waitUntilFinish(Duration.millis(-1));
    }

    @Override
    @Nullable
    public State waitUntilFinish(Duration duration) {
        try {
            return waitUntilFinish(duration, new MonitoringUtil.LoggingHandler());
        } catch (Exception e) {
            if (e instanceof InterruptedException) {
                Thread.currentThread().interrupt();
            }
            if (e instanceof RuntimeException) {
                throw (RuntimeException) e;
            }
            throw new RuntimeException(e);
        }
    }

    /**
     * Waits until the pipeline finishes and returns the final status.
     *
     * @param duration The time to wait for the job to finish. Provide a value less than 1 ms for an
     *     infinite wait.
     * @param messageHandler If non null this handler will be invoked for each batch of messages
     *     received.
     * @return The final state of the job or null on timeout or if the thread is interrupted.
     * @throws IOException If there is a persistent problem getting job information.
     */
    @Nullable
    @VisibleForTesting
    public State waitUntilFinish(Duration duration, MonitoringUtil.JobMessagesHandler messageHandler)
            throws IOException, InterruptedException {
        // We ignore the potential race condition here (Ctrl-C after job submission but before the
        // shutdown hook is registered). Even if we tried to do something smarter (eg., SettableFuture)
        // the run method (which produces the job) could fail or be Ctrl-C'd before it had returned a
        // job. The display of the command to cancel the job is best-effort anyways -- RPC's could fail,
        // etc. If the user wants to verify the job was cancelled they should look at the job status.
        Thread shutdownHook = new Thread(() -> LOG.warn(
                "Job is already running in Google Cloud Platform, Ctrl-C will not cancel it.\n"
                        + "To cancel the job in the cloud, run:\n> {}",
                MonitoringUtil.getGcloudCancelCommand(dataflowOptions, getJobId())));

        try {
            Runtime.getRuntime().addShutdownHook(shutdownHook);
            return waitUntilFinish(duration, messageHandler, Sleeper.DEFAULT, NanoClock.SYSTEM,
                    new MonitoringUtil(dataflowClient));
        } finally {
            Runtime.getRuntime().removeShutdownHook(shutdownHook);
        }
    }

    @Nullable
    @VisibleForTesting
    State waitUntilFinish(Duration duration, @Nullable MonitoringUtil.JobMessagesHandler messageHandler,
            Sleeper sleeper, NanoClock nanoClock) throws IOException, InterruptedException {
        return waitUntilFinish(duration, messageHandler, sleeper, nanoClock, new MonitoringUtil(dataflowClient));
    }

    private static BackOff getMessagesBackoff(Duration duration) {
        FluentBackoff factory = MESSAGES_BACKOFF_FACTORY;

        if (!duration.isShorterThan(Duration.ZERO)) {
            factory = factory.withMaxCumulativeBackoff(duration);
        }

        return BackOffAdapter.toGcpBackOff(factory.backoff());
    }

    /**
     * Waits until the pipeline finishes and returns the final status.
     *
     * @param duration The time to wait for the job to finish. Provide a value less than 1 ms for an
     *     infinite wait.
     * @param messageHandler If non null this handler will be invoked for each batch of messages
     *     received.
     * @param sleeper A sleeper to use to sleep between attempts.
     * @param nanoClock A nanoClock used to time the total time taken.
     * @return The final state of the job or null on timeout.
     * @throws IOException If there is a persistent problem getting job information.
     * @throws InterruptedException if the thread is interrupted.
     */
    @Nullable
    @VisibleForTesting
    State waitUntilFinish(Duration duration, @Nullable MonitoringUtil.JobMessagesHandler messageHandler,
            Sleeper sleeper, NanoClock nanoClock, MonitoringUtil monitor) throws IOException, InterruptedException {

        BackOff backoff = getMessagesBackoff(duration);

        // This function tracks the cumulative time from the *first request* to enforce the wall-clock
        // limit. Any backoff instance could, at best, track the the time since the first attempt at a
        // given request. Thus, we need to track the cumulative time ourselves.
        long startNanos = nanoClock.nanoTime();

        State state = State.UNKNOWN;
        Exception exception;
        do {
            exception = null;
            try {
                // Get the state of the job before listing messages. This ensures we always fetch job
                // messages after the job finishes to ensure we have all them.
                state = getStateWithRetries(
                        BackOffAdapter.toGcpBackOff(STATUS_BACKOFF_FACTORY.withMaxRetries(0).backoff()), sleeper);
            } catch (IOException e) {
                exception = e;
                LOG.warn("Failed to get job state: {}", e.getMessage());
                LOG.debug("Failed to get job state: {}", e);
                continue;
            }

            exception = processJobMessages(messageHandler, monitor);

            if (exception != null) {
                continue;
            }

            // We can stop if the job is done.
            if (state.isTerminal()) {
                logTerminalState(state);
                return state;
            }

            // Reset attempts count and update cumulative wait time.
            backoff = resetBackoff(duration, nanoClock, startNanos);
        } while (BackOffUtils.next(sleeper, backoff));

        // At this point Backoff decided that we retried enough times.
        // This can be either due to exceeding allowed timeout for job to complete, or receiving
        // error multiple times in a row.

        if (exception == null) {
            LOG.warn("No terminal state was returned within allotted timeout. State value {}", state);
        } else {
            LOG.error("Failed to fetch job metadata with error: {}", exception);
        }

        return null;
    }

    private void logTerminalState(State state) {
        switch (state) {
        case DONE:
        case CANCELLED:
            LOG.info("Job {} finished with status {}.", getJobId(), state);
            break;
        case UPDATED:
            LOG.info("Job {} has been updated and is running as the new job with id {}. "
                    + "To access the updated job on the Dataflow monitoring console, " + "please navigate to {}",
                    getJobId(), getReplacedByJob().getJobId(), MonitoringUtil.getJobMonitoringPageURL(
                            getReplacedByJob().getProjectId(), getRegion(), getReplacedByJob().getJobId()));
            break;
        default:
            LOG.info("Job {} failed with status {}.", getJobId(), state);
        }
    }

    /**
     * Reset backoff. If duration is limited, calculate time remaining, otherwise just reset retry
     * count.
     *
     * <p>If a total duration for all backoff has been set, update the new cumulative sleep time to be
     * the remaining total backoff duration, stopping if we have already exceeded the allotted time.
     */
    private static BackOff resetBackoff(Duration duration, NanoClock nanoClock, long startNanos) {
        BackOff backoff;
        if (duration.isLongerThan(Duration.ZERO)) {
            long nanosConsumed = nanoClock.nanoTime() - startNanos;
            Duration consumed = Duration.millis((nanosConsumed + 999999) / 1000000);
            Duration remaining = duration.minus(consumed);
            if (remaining.isLongerThan(Duration.ZERO)) {
                backoff = getMessagesBackoff(remaining);
            } else {
                backoff = BackOff.STOP_BACKOFF;
            }
        } else {
            backoff = getMessagesBackoff(duration);
        }
        return backoff;
    }

    /**
     * Process all the job messages that have accumulated so far.
     *
     * @return Exception that caused failure to process messages or null.
     */
    private Exception processJobMessages(@Nullable JobMessagesHandler messageHandler, MonitoringUtil monitor)
            throws IOException {
        if (messageHandler != null) {
            try {
                List<JobMessage> allMessages = monitor.getJobMessages(getJobId(), lastTimestamp);

                if (!allMessages.isEmpty()) {
                    lastTimestamp = fromCloudTime(allMessages.get(allMessages.size() - 1).getTime()).getMillis();
                    messageHandler.process(allMessages);
                }
            } catch (GoogleJsonResponseException | SocketTimeoutException e) {
                LOG.warn("Failed to get job messages: {}", e.getMessage());
                LOG.debug("Failed to get job messages: {}", e);
                return e;
            }
        }
        return null;
    }

    private AtomicReference<FutureTask<State>> cancelState = new AtomicReference<>();

    @Override
    public State cancel() throws IOException {
        // Enforce that a cancel() call on the job is done at most once - as
        // a workaround for Dataflow service's current bugs with multiple
        // cancellation, where it may sometimes return an error when cancelling
        // a job that was already cancelled, but still report the job state as
        // RUNNING.
        // To partially work around these issues, we absorb duplicate cancel()
        // calls. This, of course, doesn't address the case when the job terminates
        // externally almost concurrently to calling cancel(), but at least it
        // makes it possible to safely call cancel() multiple times and from
        // multiple threads in one program.
        FutureTask<State> tentativeCancelTask = new FutureTask<>(() -> {
            Job content = new Job();
            content.setProjectId(getProjectId());
            String currentJobId = getJobId();
            content.setId(currentJobId);
            content.setRequestedState("JOB_STATE_CANCELLED");
            try {
                Job job = dataflowClient.updateJob(currentJobId, content);
                return MonitoringUtil.toState(job.getCurrentState());
            } catch (IOException e) {
                State state = getState();
                if (state.isTerminal()) {
                    LOG.warn("Cancel failed because job is already terminated. State is {}", state);
                    return state;
                } else if (e.getMessage().contains("has terminated")) {
                    // This handles the case where the getState() call above returns RUNNING but the
                    // cancel was rejected because the job is in fact done. Hopefully, someday we can
                    // delete this code if there is better consistency between the State and whether
                    // Cancel succeeds.
                    //
                    // Example message:
                    //    Workflow modification failed. Causes: (7603adc9e9bff51e): Cannot perform
                    //    operation 'cancel' on Job: 2017-04-01_22_50_59-9269855660514862348. Job has
                    //    terminated in state SUCCESS: Workflow job:
                    //    2017-04-01_22_50_59-9269855660514862348 succeeded.
                    LOG.warn("Cancel failed because job is already terminated.", e);
                    return state;
                } else {
                    String errorMsg = String.format(
                            "Failed to cancel job in state %s, "
                                    + "please go to the Developers Console to cancel it manually: %s",
                            state, MonitoringUtil.getJobMonitoringPageURL(getProjectId(), getRegion(), getJobId()));
                    LOG.warn(errorMsg);
                    throw new IOException(errorMsg, e);
                }
            }
        });
        if (cancelState.compareAndSet(null, tentativeCancelTask)) {
            // This thread should perform cancellation, while others will
            // only wait for the result.
            cancelState.get().run();
        }
        try {
            return cancelState.get().get();
        } catch (InterruptedException | ExecutionException e) {
            throw new IOException(e);
        }
    }

    @Override
    public State getState() {
        if (terminalState != null) {
            return terminalState;
        }

        return getStateWithRetriesOrUnknownOnException(
                BackOffAdapter.toGcpBackOff(STATUS_BACKOFF_FACTORY.backoff()), Sleeper.DEFAULT);
    }

    /**
     * Attempts to get the state. Uses exponential backoff on failure up to the maximum number of
     * passed in attempts.
     *
     * @param attempts The amount of attempts to make.
     * @param sleeper Object used to do the sleeps between attempts.
     * @return The state of the job or State.UNKNOWN in case of failure.
     */
    @VisibleForTesting
    State getStateWithRetriesOrUnknownOnException(BackOff attempts, Sleeper sleeper) {
        try {
            return getStateWithRetries(attempts, sleeper);
        } catch (IOException exn) {
            // The only IOException that getJobWithRetries is permitted to throw is the final IOException
            // that caused the failure of retry. Other exceptions are wrapped in an unchecked exceptions
            // and will propagate.
            return State.UNKNOWN;
        }
    }

    State getStateWithRetries(BackOff attempts, Sleeper sleeper) throws IOException {
        if (terminalState != null) {
            return terminalState;
        }
        Job job = getJobWithRetries(attempts, sleeper);
        return MonitoringUtil.toState(job.getCurrentState());
    }

    /**
     * Attempts to get the underlying {@link Job}. Uses exponential backoff on failure up to the
     * maximum number of passed in attempts.
     *
     * @param backoff the {@link BackOff} used to control retries.
     * @param sleeper Object used to do the sleeps between attempts.
     * @return The underlying {@link Job} object.
     * @throws IOException When the maximum number of retries is exhausted, the last exception is
     *     thrown.
     */
    private Job getJobWithRetries(BackOff backoff, Sleeper sleeper) throws IOException {
        // Retry loop ends in return or throw
        while (true) {
            try {
                Job job = dataflowClient.getJob(getJobId());
                State currentState = MonitoringUtil.toState(job.getCurrentState());
                if (currentState.isTerminal()) {
                    terminalState = currentState;
                    replacedByJob = new DataflowPipelineJob(dataflowClient, job.getReplacedByJobId(),
                            dataflowOptions, transformStepNames);
                }
                return job;
            } catch (IOException exn) {
                LOG.warn("There were problems getting current job status: {}.", exn.getMessage());
                LOG.debug("Exception information:", exn);

                if (!nextBackOff(sleeper, backoff)) {
                    throw exn;
                }
            }
        }
    }

    /** Identical to {@link BackOffUtils#next} but without checked exceptions. */
    private boolean nextBackOff(Sleeper sleeper, BackOff backoff) {
        try {
            return BackOffUtils.next(sleeper, backoff);
        } catch (InterruptedException | IOException e) {
            if (e instanceof InterruptedException) {
                Thread.currentThread().interrupt();
            }
            throw new RuntimeException(e);
        }
    }

    @Override
    public MetricResults metrics() {
        return dataflowMetrics;
    }
}