Merge pull request #5196 from jmchilton/oom_detect

Tool-based annotation of out of memory errors.
This commit is contained in:
Björn Grüning
2017-12-27 15:37:22 +01:00
committed by GitHub
16 changed files with 222 additions and 36 deletions
+9 -5
View File
@@ -34,7 +34,7 @@ from galaxy.util.handlers import ConfiguresHandlers
from galaxy.util.xml_macros import load
from .datasets import (DatasetPath, NullDatasetPathRewriter,
OutputsToWorkingDirectoryPathRewriter, TaskPathRewriter)
from .output_checker import check_output
from .output_checker import check_output, DETECTED_JOB_STATE
log = logging.getLogger(__name__)
@@ -1122,6 +1122,7 @@ class JobWrapper(object, HasResourceParameters):
stdout,
stderr,
tool_exit_code=None,
check_output_detected_state=None,
remote_working_directory=None,
remote_metadata_directory=None,
):
@@ -1162,8 +1163,11 @@ class JobWrapper(object, HasResourceParameters):
# The job's stdout and stderr will be set accordingly.
# We set final_job_state to use for dataset management, but *don't* set
# job.state until after dataset collection to prevent history issues
if self.check_tool_output(stdout, stderr, tool_exit_code, job) and not tool_provided_metadata.has_failed_outputs():
# job.state until after dataset discovery to prevent history issues
if check_output_detected_state is None:
check_output_detected_state = self.check_tool_output(stdout, stderr, tool_exit_code, job)
if check_output_detected_state == DETECTED_JOB_STATE.OK and not tool_provided_metadata.has_failed_outputs():
final_job_state = job.states.OK
else:
final_job_state = job.states.ERROR
@@ -1893,7 +1897,7 @@ class TaskWrapper(JobWrapper):
self.sa_session.add(task)
self.sa_session.flush()
def finish(self, stdout, stderr, tool_exit_code=None):
def finish(self, stdout, stderr, tool_exit_code=None, **kwds):
# DBTODO integrate previous finish logic.
# Simple finish for tasks. Just set the flag OK.
"""
@@ -1922,7 +1926,7 @@ class TaskWrapper(JobWrapper):
# Check what the tool returned. If the stdout or stderr matched
# regular expressions that indicate errors, then set an error.
# The same goes if the tool's exit code was in a given range.
if (self.check_tool_output(stdout, stderr, tool_exit_code, task)):
if self.check_tool_output(stdout, stderr, tool_exit_code, task) == DETECTED_JOB_STATE.OK:
task.state = task.states.OK
else:
task.state = task.states.ERROR
+3 -1
View File
@@ -8,12 +8,14 @@ class StdioErrorLevel(object):
LOG = 1
WARNING = 2
FATAL = 3
MAX = 3
FATAL_OOM = 4
MAX = 4
descs = {
NO_ERROR: 'No error',
LOG: 'Log',
WARNING: 'Warning',
FATAL: 'Fatal error',
FATAL_OOM: 'Out of memory error',
}
@staticmethod
+21 -14
View File
@@ -2,28 +2,33 @@ import re
import traceback
from logging import getLogger
from galaxy.util.bunch import Bunch
from .error_level import StdioErrorLevel
log = getLogger(__name__)
DETECTED_JOB_STATE = Bunch(
OK='ok',
OUT_OF_MEMORY_ERROR='oom_error',
GENERIC_ERROR='generic_error',
)
def check_output(tool, stdout, stderr, tool_exit_code, job):
"""
Check the output of a tool - given the stdout, stderr, and the tool's
exit code, return True if the tool exited succesfully and False
otherwise. No exceptions should be thrown. If this code encounters
an exception, it returns True so that the workflow can continue;
exit code, return DETECTED_JOB_STATE.OK if the tool exited succesfully or
error type otherwise. No exceptions should be thrown. If this code encounters
an exception, it returns OK so that the workflow can continue;
otherwise, a bug in this code could halt workflow progress.
Note that, if the tool did not define any exit code handling or
any stdio/stderr handling, then it reverts back to previous behavior:
if stderr contains anything, then False is returned.
Note that the job id is just for messages.
"""
# By default, the tool succeeded. This covers the case where the code
# has a bug but the tool was ok, and it lets a workflow continue.
success = True
state = DETECTED_JOB_STATE.OK
try:
# Check exit codes and match regular expressions against stdout and
@@ -57,7 +62,7 @@ def check_output(tool, stdout, stderr, tool_exit_code, job):
stderr = tool_msg + "\n" + stderr
max_error_level = max(max_error_level,
stdio_exit_code.error_level)
if max_error_level >= StdioErrorLevel.FATAL:
if max_error_level >= StdioErrorLevel.MAX:
break
if max_error_level < StdioErrorLevel.FATAL:
@@ -105,11 +110,13 @@ def check_output(tool, stdout, stderr, tool_exit_code, job):
# If we encountered a fatal error, then we'll need to set the
# job state accordingly. Otherwise the job is ok:
if max_error_level >= StdioErrorLevel.FATAL:
if max_error_level == StdioErrorLevel.FATAL_OOM:
state = DETECTED_JOB_STATE.OUT_OF_MEMORY_ERROR
elif max_error_level >= StdioErrorLevel.FATAL:
log.debug("Tool exit code indicates an error, failing job.")
success = False
state = DETECTED_JOB_STATE.GENERIC_ERROR
else:
success = True
state = DETECTED_JOB_STATE.OK
# When there are no regular expressions and no exit codes to check,
# default to the previous behavior: when there's anything on stderr
@@ -121,22 +128,22 @@ def check_output(tool, stdout, stderr, tool_exit_code, job):
if stderr:
peak = stderr[0:250]
log.debug("Tool produced standard error failing job - [%s]" % peak)
success = False
state = DETECTED_JOB_STATE.GENERIC_ERROR
else:
success = True
state = DETECTED_JOB_STATE.OK
# On any exception, return True.
except Exception:
tb = traceback.format_exc()
log.warning("Tool check encountered unexpected exception; " +
"assuming tool was successful: " + tb)
success = True
state = DETECTED_JOB_STATE.OK
# Store the modified stdout and stderr in the job:
if job is not None:
job.set_streams(stdout, stderr)
return success
return state
def __regex_err_msg(match, regex):
+21 -2
View File
@@ -17,6 +17,7 @@ from six.moves.queue import (
import galaxy.jobs
from galaxy import model
from galaxy.jobs.command_factory import build_command
from galaxy.jobs.output_checker import DETECTED_JOB_STATE
from galaxy.jobs.runners.util.env import env_to_statement
from galaxy.jobs.runners.util.job_script import (
job_script,
@@ -406,6 +407,23 @@ class BaseJobRunner(object):
job_state.job_wrapper.change_state(model.Job.states.QUEUED)
self.app.job_manager.job_handler.dispatcher.put(job_state.job_wrapper)
def _finish_or_resubmit_job(self, job_state, stdout, stderr, exit_code):
job = job_state.job_wrapper.get_job()
check_output_detected_state = job_state.job_wrapper.check_tool_output(stdout, stderr, exit_code, job)
# Flush with streams...
self.sa_session.add(job)
self.sa_session.flush()
if check_output_detected_state != DETECTED_JOB_STATE.OK:
job_runner_state = JobState.runner_states.TOOL_DETECT_ERROR
if check_output_detected_state == DETECTED_JOB_STATE.OUT_OF_MEMORY_ERROR:
job_runner_state = JobState.runner_states.MEMORY_LIMIT_REACHED
job_state.runner_state = job_runner_state
self._handle_runner_state('failure', job_state)
# Was resubmitted or something - I think we are done with it.
if job_state.runner_state_handled:
return
job_state.job_wrapper.finish(stdout, stderr, exit_code, check_output_detected_state=check_output_detected_state)
class JobState(object):
"""
@@ -417,7 +435,8 @@ class JobState(object):
JOB_OUTPUT_NOT_RETURNED_FROM_CLUSTER='Job output not returned from cluster',
UNKNOWN_ERROR='unknown_error',
GLOBAL_WALLTIME_REACHED='global_walltime_reached',
OUTPUT_SIZE_LIMIT='output_size_limit'
OUTPUT_SIZE_LIMIT='output_size_limit',
TOOL_DETECT_ERROR='tool_detected', # job runner interaction worked fine but the tool indicated error
)
def __init__(self, job_wrapper, job_destination):
@@ -656,7 +675,7 @@ class AsynchronousJobRunner(Monitors, BaseJobRunner):
job_state.cleanup()
try:
job_state.job_wrapper.finish(stdout, stderr, exit_code)
self._finish_or_resubmit_job(job_state, stdout, stderr, exit_code)
except Exception:
log.exception("(%s/%s) Job wrapper finish method failed" % (galaxy_id_tag, external_job_id))
job_state.job_wrapper.fail("Unable to finish job", exception=True)
+5 -1
View File
@@ -143,9 +143,13 @@ class LocalJobRunner(BaseJobRunner):
return
self._handle_metadata_if_needed(job_wrapper)
job_destination = job_wrapper.job_destination
job_state = JobState(job_wrapper, job_destination)
job_state.stop_job = False
# Finish the job!
try:
job_wrapper.finish(stdout, stderr, exit_code)
self._finish_or_resubmit_job(job_state, stdout, stderr, exit_code)
except Exception:
log.exception("Job wrapper finish method failed")
self._fail_job_local(job_wrapper, "Unable to finish job")
+31 -2
View File
@@ -2,28 +2,52 @@ from .interface import ToolStdioExitCode
from .interface import ToolStdioRegex
def error_on_exit_code():
def error_on_exit_code(out_of_memory_exit_code=None):
exit_codes = []
if out_of_memory_exit_code:
exit_code_oom = ToolStdioExitCode()
exit_code_oom.range_start = int(out_of_memory_exit_code)
exit_code_oom.range_end = int(out_of_memory_exit_code)
_set_oom(exit_code_oom)
exit_codes.append(exit_code_oom)
exit_code_lower = ToolStdioExitCode()
exit_code_lower.range_start = float("-inf")
exit_code_lower.range_end = -1
_set_fatal(exit_code_lower)
exit_codes.append(exit_code_lower)
exit_code_high = ToolStdioExitCode()
exit_code_high.range_start = 1
exit_code_high.range_end = float("inf")
_set_fatal(exit_code_high)
return [exit_code_lower, exit_code_high], []
exit_codes.append(exit_code_high)
return exit_codes, []
def aggressive_error_checks():
exit_codes, _ = error_on_exit_code()
# these regexes are processed as case insensitive by default
regexes = [
_oom_regex("MemoryError"),
_oom_regex("std::bad_alloc"),
_oom_regex("java.lang.OutOfMemoryError"),
_oom_regex("Out of memory"),
_error_regex("exception:"),
_error_regex("error:")
]
return exit_codes, regexes
def _oom_regex(match):
regex = ToolStdioRegex()
_set_oom(regex)
regex.match = match
regex.stdout_match = True
regex.stderr_match = True
return regex
def _error_regex(match):
regex = ToolStdioRegex()
_set_fatal(regex)
@@ -33,6 +57,11 @@ def _error_regex(match):
return regex
def _set_oom(obj):
from galaxy.jobs.error_level import StdioErrorLevel
obj.error_level = StdioErrorLevel.FATAL_OOM
def _set_fatal(obj):
from galaxy.jobs.error_level import StdioErrorLevel
obj.error_level = StdioErrorLevel.FATAL
+7 -1
View File
@@ -327,9 +327,15 @@ class XmlToolSource(ToolSource):
detect_errors = None
if command_el is not None:
detect_errors = command_el.get("detect_errors")
if detect_errors and detect_errors != "default":
if detect_errors == "exit_code":
return error_on_exit_code()
oom_exit_code = None
if command_el is not None:
oom_exit_code = command_el.get("oom_exit_code", None)
if oom_exit_code is not None:
int(oom_exit_code)
return error_on_exit_code(out_of_memory_exit_code=oom_exit_code)
elif detect_errors == "aggressive":
return aggressive_error_checks()
else:
+17 -3
View File
@@ -2669,8 +2669,9 @@ If present on the ``command`` tag, this attribute can be one of:
* ``default`` no-op fallback to ``stdio`` tags and erroring on standard error output (for legacy tools).
* ``exit_code`` error if tool exit code is not 0. (The @jmchilton recommendation).
* ``aggressive`` error if tool exit code is not 0 or either ``Exception:`` or ``Error:``
appears in standard error/output. (The @bgruening recommendation).
* ``aggressive`` error if tool exit code is not 0 or ``Exception:``, ``Error:``, or
various messages related to being out of memory appear in the standard error or output.
(The @bgruening recommendation).
For newer tools with ``profile>=16.04``, the default behavior is ``exit_code``.
Legacy tools default to ``default`` behavior described above (erroring if the tool
@@ -2700,6 +2701,11 @@ deprecated and using the ``$__tool_directory__`` variable is superior.
<xs:documentation></xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="oom_exit_code" type="xs:integer">
<xs:annotation>
<xs:documentation>Only used if ``detect_errors="exit_code", tells Galaxy the specified exit code indicates an out of memory error. Galaxy instances may be configured to retry such jobs on resources with more memory.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="interpreter" type="xs:string" gxdocs:deprecated="true">
<xs:annotation>
<xs:documentation xml:lang="en">This attribute defines the programming language in which the tool's executable file is written. Any language can be used (tools can be written in Python, C, Perl, Java, etc.). The executable file must be in the same directory of the XML file. If instead this attribute is not specified, the tag content should be a Bash command calling executable(s) available in the $PATH. </xs:documentation>
@@ -4838,9 +4844,11 @@ The following is an example of the <exit_code> tag:
```xml
<stdio>
<exit_code range="2" level="fatal" description="Out of Memory" />
<exit_code range="3:5" level="warning" description="Low disk space" />
<exit_code range="6:" level="fatal" description="Bad input dataset" />
<!-- Catching fatal_oom allows the job runner to potentially resubmit to a resource with more
memory if Galaxy is configured to do this. -->
<exit_code range="2" level="fatal_oom" description="Out of Memory" />
</stdio>
```
@@ -4912,6 +4920,12 @@ The following is an example of regular expressions that may be used:
source="stdout"
level="fatal"
description="Unknown error encountered" />
<!-- Catching fatal_oom allows the job runner to potentially resubmit to a resource with more
memory if Galaxy is configured to do this. -->
<regex match="out of memory"
source="stdout"
level="fatal_oom"
description="Out of memory error occurred" />
<regex match="[CG]{12}"
description="Fatal error - CG island 12 nts long found" />
<regex match="^Branch A"
+33
View File
@@ -0,0 +1,33 @@
<tool id="exit_code_oom" name="exit_code_oom">
<!-- tool errors out with identified OOM error if less than 10MB are allocated. -->
<command detect_errors="exit_code" oom_exit_code="42"><![CDATA[
echo 'Hello' > '$out_file1';
echo "\$GALAXY_MEMORY_MB";
: \${GALAXY_MEMORY_MB:=20};
echo "\$GALAXY_MEMORY_MB";
if [ "\$GALAXY_MEMORY_MB" -lt 10 ];
then
exit 42;
else
exit 0;
fi
]]></command>
<inputs>
<param name="input" type="integer" label="Dummy" value="6" />
</inputs>
<outputs>
<data name="out_file1" />
</outputs>
<help>
</help>
<tests>
<test>
<param name="input" value="5" />
<output name="out_file1">
<assert_contents>
<has_line line="Hello" />
</assert_contents>
</output>
</test>
</tests>
</tool>
@@ -54,6 +54,7 @@
<tool file="version_command_plain.xml" />
<tool file="version_command_interpreter.xml" />
<tool file="version_command_tool_dir.xml" />
<tool file="exit_code_oom.xml" />
<tool file="exit_code_from_file.xml" />
<tool file="gzipped_inputs.xml" />
<tool file="output_order.xml" />
@@ -0,0 +1,17 @@
<?xml version="1.0"?>
<job_conf>
<plugins>
<plugin id="local" type="runner" load="galaxy.jobs.runners.local:LocalJobRunner" workers="2"/>
</plugins>
<handlers>
<handler id="main"/>
</handlers>
<destinations default="local_too_small">
<destination id="local_too_small" runner="local">
<env id="GALAXY_MEMORY_MB">4</env>
</destination>
</destinations>
</job_conf>
@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<job_conf>
<plugins>
<plugin id="local" type="runner" load="galaxy.jobs.runners.local:LocalJobRunner" workers="2"/>
</plugins>
<handlers>
<handler id="main"/>
</handlers>
<destinations default="local_too_small">
<destination id="local_too_small" runner="local">
<env id="GALAXY_MEMORY_MB">4</env>
<resubmit condition="memory_limit_reached" destination="local_more_memory" />
</destination>
<destination id="local_more_memory" runner="local">
<env id="GALAXY_MEMORY_MB">40</env>
</destination>
</destinations>
</job_conf>
+26 -2
View File
@@ -8,6 +8,8 @@ SCRIPT_DIRECTORY = os.path.abspath(os.path.dirname(__file__))
JOB_RESUBMISSION_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_job_conf.xml")
JOB_RESUBMISSION_DEFAULT_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_default_job_conf.xml")
JOB_RESUBMISSION_DYNAMIC_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_dynamic_job_conf.xml")
JOB_RESUBMISSION_SMALL_MEMORY_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_small_memory_job_conf.xml")
JOB_RESUBMISSION_SMALL_MEMORY_RESUBMISSION_TO_LARGE_JOB_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_small_memory_resubmission_to_large_job_conf.xml")
JOB_RESUBMISSION_JOB_RESOURCES_CONFIG_FILE = os.path.join(SCRIPT_DIRECTORY, "resubmission_job_resource_parameters_conf.xml")
@@ -15,12 +17,12 @@ class _BaseResubmissionIntegerationTestCase(integration_util.IntegrationTestCase
framework_tool_and_types = True
def _assert_job_passes(self, resource_parameters={}):
self._run_tool_test("simple_constructs", resource_parameters=resource_parameters)
self._run_tool_test("exit_code_oom", resource_parameters=resource_parameters)
def _assert_job_fails(self, resource_parameters={}):
exception_thrown = False
try:
self._run_tool_test("simple_constructs", resource_parameters=resource_parameters)
self._run_tool_test("exit_code_oom", resource_parameters=resource_parameters)
except Exception:
exception_thrown = True
@@ -128,3 +130,25 @@ class JobResubmissionDynamicIntegrationTestCase(_BaseResubmissionIntegerationTes
def test_dynamic_resubmission(self):
self._assert_job_passes()
# Verify the test tool fails if only a small amount of memory is allocated.
class JobResubmissionSmallMemoryIntegrationTestCase(_BaseResubmissionIntegerationTestCase):
@classmethod
def handle_galaxy_config_kwds(cls, config):
config["job_config_file"] = JOB_RESUBMISSION_SMALL_MEMORY_JOB_CONFIG_FILE
def test_dynamic_resubmission(self):
self._assert_job_fails()
# Verify the test tool fails if only a small amount of memory is allocated.
class JobResubmissionSmallMemoryResubmitsToLargeIntegrationTestCase(_BaseResubmissionIntegerationTestCase):
@classmethod
def handle_galaxy_config_kwds(cls, config):
config["job_config_file"] = JOB_RESUBMISSION_SMALL_MEMORY_RESUBMISSION_TO_LARGE_JOB_CONFIG_FILE
def test_dynamic_resubmission(self):
self._assert_job_passes()
+3 -3
View File
@@ -1,7 +1,7 @@
from unittest import TestCase
from galaxy.jobs.error_level import StdioErrorLevel
from galaxy.jobs.output_checker import check_output
from galaxy.jobs.output_checker import check_output, DETECTED_JOB_STATE
from galaxy.model import Job
from galaxy.tools.parser.interface import ToolStdioRegex
from galaxy.util.bunch import Bunch
@@ -87,10 +87,10 @@ class OutputCheckerTestCase(TestCase):
self.tool.stdio_regexes.append(regex)
def __assertSuccessful(self):
self.assertTrue(self.__check_output())
assert self.__check_output() == DETECTED_JOB_STATE.OK
def __assertNotSuccessful(self):
self.assertFalse(self.__check_output())
assert self.__check_output() != DETECTED_JOB_STATE.OK
def __check_output(self):
return check_output(self.tool, self.stdout, self.stderr, self.tool_exit_code, self.job)
+4 -1
View File
@@ -156,6 +156,9 @@ class MockJobWrapper(object):
build_dependency_shell_commands=lambda: []
)
def check_tool_output(*args, **kwds):
return "ok"
def wait_for_external_id(self):
"""Test method for waiting til an external id has been registered."""
external_id = None
@@ -203,7 +206,7 @@ class MockJobWrapper(object):
self.fail_message = message
self.fail_exception = exception
def finish(self, stdout, stderr, exit_code):
def finish(self, stdout, stderr, exit_code, **kwds):
self.stdout = stdout
self.stderr = stderr
self.exit_code = exit_code
+2 -1
View File
@@ -244,7 +244,8 @@ class XmlLoaderTestCase(BaseLoaderTestCase):
""")
exit, regexes = tool_source.parse_stdio()
assert len(exit) == 2, exit
assert len(regexes) == 2, regexes
# error:, exception: various memory exception...
assert len(regexes) > 2, regexes
def test_sanitize_option(self):
assert self._tool_source.parse_sanitize() is True