All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.broadinstitute.hellbender.utils.runtime.StreamingProcessController Maven / Gradle / Ivy

The newest version!
package org.broadinstitute.hellbender.utils.runtime;

import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.broadinstitute.hellbender.exceptions.GATKException;
import org.broadinstitute.hellbender.utils.Utils;

import java.io.*;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;

/**
 * Facade to Runtime.exec() and java.lang.Process. Handles running of an interactive, keep-alive process that
 * uses a FIFO to indicate remote command success or failure. The controller is agnostic about what process is
 * being started; the only requirement is that it is capable of executing, at the callers demand, a command that
 * writes an acknowledgement message to the ack FIFO file that is managed by this controller, which can be
 * detected by {@link #waitForAck}. Creates separate threads for reading stdout and stderr and provides access
 * to stdout and stderr as strings.
 */
public final class StreamingProcessController extends ProcessControllerBase {
    private static final Logger logger = LogManager.getLogger(StreamingProcessController.class);

    private final ProcessSettings settings;

    // Names for the ack and data fifo files used by this controller. Each controller instance creates a
    // unique temporary directory for these files, so the names don't have to be unique across instances.
    private static String ACK_FIFO_FILE_NAME = "gatkStreamingControllerAck.fifo";
    private static String DATA_FIFO_FILE_NAME = "gatkStreamingControllerData.fifo";

    private File fifoTempDir = null;
    private File ackFIFOFile = null;
    private File dataFIFOFile = null;

    private InputStream ackFIFOInputStream;
    private Future ackFuture;

    // keep an optional journal of all IPC; disabled/no-op by default
    private ProcessJournal processJournal = new ProcessJournal();
    private OutputStream processStdinStream; // stream to which we send remote commands

    //Timeout used when terminating the remote process
    private static final int REMOTE_PROCESS_TERMINATION_TIMEOUT_SECONDS = 30;

    /**
     * @param settings Settings to be used.
     */
    public StreamingProcessController(final ProcessSettings settings) {
        this(settings, false);
    }

    /**
     * Create a controller using the specified settings.
     *
     * @param settings                 Settings to be run.
     * @param enableJournaling         Turn on process I/O journaling. This records all inter-process communication to a file.
     *                                 Journaling incurs a performance penalty, and should be used for debugging purposes only.
     */
    public StreamingProcessController(
            final ProcessSettings settings,
            final boolean enableJournaling) {
        Utils.nonNull(settings, "Process settings are required");
        this.settings = settings;

        if (enableJournaling) {
            processJournal.enable(settings.getCommandString());
        }
    }

    /**
     * Starts the remote process running based on the setting specified in the constructor.
     *
     * @return the FIFO File to be used for ack messages. This caller should pass the name
     * to the companion process to be used for writing ack/nck messages.
     */
    public File start() {
        if (process != null) {
            throw new IllegalStateException("This controller is already running a process");
        }
        process = launchProcess(settings);
        startListeners();
        processStdinStream = getProcess().getOutputStream();

        // create the fifo temp directory, and one FIFO to use for IPC signalling
        try {
            fifoTempDir = Files.createTempDirectory("StreamingProcessController").toFile();
        }
        catch ( IOException e ) {
            throw new GATKException("Unable to create temp directory for StreamingProcessController", e);
        }
        
        ackFIFOFile = createFIFOFile(ACK_FIFO_FILE_NAME);
        return ackFIFOFile;
    }

    /**
     * Write some input to the remote process, without waiting for output.
     *
     * @param line data to be written to the remote process
     */
    public void writeProcessInput(final String line) {
        try {
            // Its possible that we already have completed futures from output from a previous command that
            // weren't consumed before the last synchronization point. If so, drop them and write any existing
            // output to the journal so it appears in the journal before the command we're about to send.
            if (stdErrFuture != null && stdErrFuture.isDone()) {
                processJournal.writeLogMessage("Dropping stale stderr output before send: \n" + stdErrFuture.get().getBufferString() + "\n");
                stdErrFuture = null;
            }
            if (stdOutFuture != null && stdOutFuture.isDone()) {
                processJournal.writeLogMessage("Dropping stale stdout output before send: \n" + stdOutFuture.get().getBufferString() + "\n");
                stdOutFuture = null;
            }
        } catch (InterruptedException e) {
            throw new GATKException(String.format("Interrupted retrieving stale future: " + line, e));
        } catch (ExecutionException e) {
            throw new GATKException(String.format("Execution exception retrieving stale future: " + line, e));
        }

        startListeners();

        try {
            // write and flush the output stream that is the process' input
            processStdinStream.write(line.getBytes());
            processStdinStream.flush();
            processJournal.writeOutbound(line);
        } catch (IOException e) {
            throw new GATKException(String.format("Error writing (%s) to stdin on command", line), e);
        }
    }

    /**
     * Attempt to retrieve any output from a (possibly) completed future that is immediately available without
     * blocking or waiting for the future to complete.
     * @param streamOutputFuture Future from which to retrieve output
     * @return {@link StreamOutput} if the future is complete, or null otherwise
     */
    private StreamOutput drainOutputStream(final Future streamOutputFuture) {
        StreamOutput ret = null;

        if (streamOutputFuture != null) {
            try {
                if (streamOutputFuture.isDone()) {
                    ret = streamOutputFuture.get();
                }
            } catch (InterruptedException e) {
                throw new GATKException("InterruptedException attempting to retrieve output from remote process", e);
            } catch (ExecutionException e) {
                throw new GATKException("ExecutionException attempting to retrieve output from remote process", e);
            }
        }
        return ret;
    }

    /**
     * Wait for *any* output from the process (to stdout or stderr), by draining the stream(s) until no more
     * output is available. Uses a timeout to prevent the calling thread from hanging. Use isOutputAvailable
     * for non-blocking check.
     */
    public ProcessOutput getProcessOutput() {
        StreamOutput stdout;
        StreamOutput stderr;

        // Get whatever output is immediately available without blocking.
        stderr = drainOutputStream(stdErrFuture);
        if (stderr != null) {
            stdErrFuture = null;
        }
        stdout = drainOutputStream(stdOutFuture);
        if (stdout != null) {
            stdOutFuture = null;
        }

        // log the output, and restart the listeners for next time
        processJournal.writeInbound(stdout, stderr);
        startListeners();

        return new ProcessOutput(0, stdout, stderr);
    }

    /**
     * Open a stream on the ack FIFO for reading.
     *
     * NOTE: Before this is called, the caller must have requested execution of code in the remote process to
     * open the ackFIFOFile for write. Failure to do so would cause this call to block indefinitely.
     */
    public void openAckFIFOForRead() {
        try {
            ackFIFOInputStream = new FileInputStream(ackFIFOFile);
        } catch (FileNotFoundException e) {
            throw new GATKException("Can't open ack FIFO for read");
        }
    }

    /**
     * Wait for a previously requested acknowledgement to be received. The remote process can deliver a positive
     * ack to indicate successful command completion, or a negative ack to indicate command execution failure.
     * @return a {@link ProcessControllerAckResult} indicating the resulting ack state.
     * {@link ProcessControllerAckResult#isPositiveAck()} will return true for a positive acknowledgement (ACK),
     * false if a negative acknowledgement (NCK)
     */
    public ProcessControllerAckResult waitForAck() {
        if (ackFuture != null) {
            throw new GATKException("An ack request is already outstanding");
        }
        ackFuture = executorService.submit(
                () -> {
                    try {
                        final String ackMessage = getBytesFromStream(StreamingToolConstants.STREAMING_ACK_MESSAGE_SIZE);
                        if (ackMessage.equals(StreamingToolConstants.STREAMING_ACK_MESSAGE)) {
                            return new ProcessControllerAckResult(true);
                        } else if (ackMessage.equals(StreamingToolConstants.STREAMING_NCK_MESSAGE)) {
                            return new ProcessControllerAckResult(false);
                        } else if (ackMessage.equals(StreamingToolConstants.STREAMING_NCK_WITH_MESSAGE_MESSAGE)) {
                            return getNckWithMessageResult();
                        } else {
                            final String badAckMessage = "An unrecognized ack string message was written to ack fifo";
                            logger.error(badAckMessage);
                            return new ProcessControllerAckResult(badAckMessage);
                        }
                    } catch (IOException e) {
                        throw new GATKException("IOException reading from ack fifo", e);
                    }
                }
        );

        try {
            // blocking call to wait for the ack
            final ProcessControllerAckResult pcAck = ackFuture.get();
            processJournal.writeLogMessage(pcAck.getDisplayMessage());
            ackFuture = null;
            return pcAck;
        } catch (InterruptedException | ExecutionException e) {
            throw new GATKException("Exception waiting for ack from Python: " + e.getMessage(), e);
        }
    }

    // Retrieve a nkm message from the input stream and package it up as a ProcessControllerAckResult
    private ProcessControllerAckResult getNckWithMessageResult() throws IOException {
        // look for a 4 byte long string with a 4 byte decimal integer with a value <= 9999
        final String messageLengthString = getBytesFromStream(StreamingToolConstants.STREAMING_NCK_WITH_MESSAGE_MESSAGE_LEN_SIZE);
        final int messageLength = Integer.valueOf(messageLengthString);
        if (messageLength < 0) {
            throw new GATKException("Negative ack message length  must be > 0");
        }

        // now get the corresponding message of that length messageLength
        final String nckMessage = getBytesFromStream(messageLength);
        return new ProcessControllerAckResult(nckMessage);
    }

    // Retrieve a given number of bytes from the stream and return the value as a String.
    private String getBytesFromStream(final int expectedMessageLength) throws IOException {
        int nBytesReceived = 0;
        int nBytesRemaining = expectedMessageLength;
        final StringBuilder sb = new StringBuilder();
        while (nBytesReceived < expectedMessageLength) {
            final byte[] nckMessage = new byte[nBytesRemaining];
            int readLen = ackFIFOInputStream.read(nckMessage, 0, nBytesRemaining);
            if (readLen <= 0) {
                throw new GATKException(
                        String.format("Expected message of length %d but only found %d bytes", expectedMessageLength, nBytesReceived));
            }

            sb.append(new String(nckMessage, 0, readLen));
            nBytesRemaining -= readLen;
            nBytesReceived += readLen;
        }
        if (nBytesReceived != expectedMessageLength) {
            throw new GATKException(
                    String.format("Expected message of length %d but found %d", expectedMessageLength, nBytesReceived));
        }
        return sb.toString();
    }

    /**
     * Create a temporary FIFO suitable for sending output to the remote process. The FIFO is only valid for the
     * lifetime of the controller; the FIFO is destroyed when the controller is destroyed.
     *
     * @return a FIFO File
     */
    public File createDataFIFO() {
        if (dataFIFOFile != null) {
            throw new IllegalArgumentException("Only one data FIFO per controller is supported");
        }
        dataFIFOFile = createFIFOFile(DATA_FIFO_FILE_NAME);
        return dataFIFOFile;
    }

    private File createFIFOFile(final String fifoName) {
        final String fifoTempFileName = String.format("%s/%s", fifoTempDir.getAbsolutePath(), fifoName);
        return org.broadinstitute.hellbender.utils.io.IOUtils.createFifoFile(org.broadinstitute.hellbender.utils.io.IOUtils.getPath(fifoTempFileName), true);
    }

    /**
     * Return a {@link AsynchronousStreamWriter} to be used to write to a stream on a background thread.
     * @param outputStream stream to which items should be written.
     * @param itemSerializer
     * @param  Type of items to be written to the stream.
     * @return {@link AsynchronousStreamWriter}
     */
    public  AsynchronousStreamWriter getAsynchronousStreamWriter(
            final OutputStream outputStream,
            final Function itemSerializer) {
        Utils.nonNull(outputStream);
        Utils.nonNull(itemSerializer);
        return new AsynchronousStreamWriter<>(executorService, outputStream, itemSerializer);
    }

    /**
     * Close the FIFO; called on controller termination
     */
    private void closeFIFOs() {
        if (dataFIFOFile != null) {
            dataFIFOFile.delete();
        }
        if (ackFIFOFile != null) {
            ackFIFOFile.delete();
        }
        fifoTempDir.delete();
    }

    /**
     * Submit a task to start listening for output
     */
    private void startListeners() {
        // Submit runnable task to start capturing.
        if (stdOutFuture == null) {
            stdOutFuture = executorService.submit(
                    new OutputCapture(
                            new CapturedStreamOutputSnapshot(settings.getStdoutSettings(), process.getInputStream(), System.out),
                            ProcessStream.STDOUT,
                            this.getClass().getSimpleName(),
                            controllerId));
        }
        if (!settings.isRedirectErrorStream() && stdErrFuture == null) {
            // don't waste a callable on stderr if its just going to be redirected to stdout
            stdErrFuture = executorService.submit(
                    new OutputCapture(
                            new CapturedStreamOutputSnapshot(settings.getStderrSettings(), process.getErrorStream(), System.err),
                            ProcessStream.STDERR,
                            this.getClass().getSimpleName(),
                            controllerId));
        }
    }

    /**
     * Stops the process from running and tries to ensure the process is cleaned up properly.
     * NOTE: sub-processes started by process may be zombied with their parents set to pid 1.
     * NOTE: capture threads may block on read.
     */
    @Override
    @SuppressWarnings("deprecation")
    protected void tryCleanShutdown() {
        if (stdErrFuture != null && !stdErrFuture.isDone()) {
            boolean isCancelled = stdErrFuture.cancel(true);
            if (!isCancelled) {
                logger.error("Failure cancelling stderr task");
            }
        }
        if (stdOutFuture != null && !stdOutFuture.isDone()) {
            boolean isCancelled = stdOutFuture.cancel(true);
            if (!isCancelled) {
                logger.error("Failure cancelling stdout task");
            }
        }
        if (process != null) {
            // terminate the app by closing the process' INPUT stream
            IOUtils.closeQuietly(process.getOutputStream());
        }
    }

    /**
     * Close the input stream, close the FIFO, and wait for the remote process to terminate
     * destroying it if necessary.
     */
    public void terminate() {
        closeFIFOs();
        tryCleanShutdown();
        boolean exited = false;
        try {
            exited = process.waitFor(REMOTE_PROCESS_TERMINATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            processJournal.close();
            if (!exited) {
                // we timed out waiting for the process to exit; it may be in a blocking call, so just force
                // it to shutdown
                process.destroy();
                process.waitFor(REMOTE_PROCESS_TERMINATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
            }
        } catch (InterruptedException e) {
            logger.error(String.format("Interrupt exception waiting for process (%s) to terminate", settings.getCommandString()));
        }
        if (process.isAlive()) {
            throw new GATKException("Failure terminating remote process");
        }
    }

    /**
     * Keep a journal of all inter-process communication. Can incur significant runtime overhead, and should
     * be used for debugging only.
     */
    private class ProcessJournal {
        private File journalingFile = null;
        private FileWriter journalingFileWriter;
        private boolean cleanUpJournal = false;

        public void enable(final String commandString) {
            enable(commandString, false);
        }

        public void enable(final String commandString, final boolean cleanUpJournal) {
            final String journalingFileName = String.format("gatkStreamingProcessJournal-%d.txt", new Random().nextInt());
            journalingFile = new File(journalingFileName);
            this.cleanUpJournal = cleanUpJournal;
            try {
                journalingFileWriter = new FileWriter(journalingFile);
                journalingFileWriter.write("Initial process command line: ");
                journalingFileWriter.write(settings.getCommandString() + "\n\n");
            } catch (IOException e) {
                throw new GATKException(String.format("Error creating streaming process journaling file %s for command \"%s\"",
                        commandString,
                        journalingFile.getAbsolutePath()), e);
            }
            logger.info(String.format("Enabling streaming process journaling file %s", journalingFileName));
        }

        /**
         * Record outbound data being written to a remote process.
         * @param line line being written
         */
        public void writeOutbound(final String line) {
            try {
                if (journalingFileWriter != null) {
                    journalingFileWriter.write("Sending: \n[");
                    journalingFileWriter.write(line);
                    journalingFileWriter.write("]\n\n");
                    journalingFileWriter.flush();
                }
            } catch (IOException e) {
                throw new GATKException("Error writing to output to process journal", e);
            }
        }

        /**
         * Record inbound data being received from a remote process.
         * @param stdout Data received from stdout.
         * @param stderr Data received from stderr.
         */
        public void writeInbound(final StreamOutput stdout, final StreamOutput stderr) {

            if (journalingFileWriter != null) {
                try {
                    if (stdout != null) {
                        journalingFileWriter.write("Received from stdout: [");
                        journalingFileWriter.write(stdout.getBufferString());
                        journalingFileWriter.write("]\n");
                    }
                    if (stderr != null) {
                        journalingFileWriter.write("Received from stderr: [");
                        journalingFileWriter.write(stderr.getBufferString());
                        journalingFileWriter.write("]\n");
                    }
                    journalingFileWriter.write("\n");
                    journalingFileWriter.flush();
                } catch (IOException e) {
                    throw new GATKException(String.format("Error writing to journaling file %s", journalingFile.getAbsolutePath()), e);
                }
            }
        }

        /**
         * Write an advisory log message to the journal.
         * @param message Message to be written to the journal.
         */
        public void writeLogMessage(final String message) {
            if (journalingFileWriter != null) {
                try {
                    journalingFileWriter.write(message);
                    journalingFileWriter.flush();
                } catch (IOException e) {
                    throw new GATKException(String.format("Error writing to journaling file %s", journalingFile.getAbsolutePath()), e);
                }
            }
        }

        /**
         * Close the journal file writer.
         */
        public void close() {
            try {
                if (journalingFileWriter != null) {
                    writeLogMessage("Shutting down journal normally");
                    journalingFileWriter.flush();
                    journalingFileWriter.close();
                    if (cleanUpJournal) {
                        journalingFile.delete();
                    }
                }
            } catch (IOException e) {
                throw new GATKException(String.format("Error closing streaming process journaling file %s", journalingFile.getAbsolutePath()), e);
            }
        }
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy