Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature - stdout live reporting #16975

Merged
merged 36 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
58d24dc
Add option to load stdout and stderr when requesting job status
gecage952 Oct 17, 2023
89cc78a
Update api endpoint for jobs
gecage952 Nov 3, 2023
870917b
Add missing variables and import
gecage952 Nov 3, 2023
6dfbc7a
Add capability for UI to load live job stdout and stderr
gecage952 Nov 3, 2023
fba01ad
Merge pull request #1 from gecage952/dev
gecage952 Nov 3, 2023
de7cadf
Linting / format.
dannon Nov 6, 2023
31065a6
Fix jest test
dannon Nov 6, 2023
24e7d15
Update API schema
dannon Nov 6, 2023
d336cad
Configure pulsar runner to read stdout from file if finish message st…
gecage952 Nov 17, 2023
afa0b25
XMerge branch 'feature_stdout_live_reporting' of github.com:gecage952…
gecage952 Nov 17, 2023
d2be71f
Merge branch 'dev' into feature_stdout_live_reporting
gecage952 Nov 17, 2023
fd0237f
Add new api endpoint to fetch job stdout and stderr
gecage952 Dec 15, 2023
70bfcc4
Use stdout api endpoint in job info ui while job is running
gecage952 Dec 15, 2023
7516d6a
Merge upstream into branch
gecage952 Dec 15, 2023
7052e90
Merge dev into branch and fix conflicts
gecage952 Mar 21, 2024
89113d0
Update stdout reporting code to vue3
gecage952 Mar 22, 2024
791348a
Fix linting errors
gecage952 Mar 27, 2024
87f5126
Fix more formatting and schema issues
gecage952 Mar 27, 2024
1b2ba86
Merge branch 'dev' into feature_stdout_live_reporting
gecage952 Apr 19, 2024
f1f8eec
format imports
martenson May 3, 2024
6faf683
Fix issue where only one of stdout or stderr is present
gecage952 May 9, 2024
beb5d5b
Merge branch 'feature_stdout_live_reporting' of github.com:gecage952/…
gecage952 May 13, 2024
63ede70
Fix docstrings that refer to tool stdout as job stdout
gecage952 May 29, 2024
735d5ef
Restrict tool stdout updating to properly configured job destinations
gecage952 Jun 14, 2024
deaba39
update client api schema
gecage952 Jun 14, 2024
907328e
Fix merge conflicts
gecage952 Jul 12, 2024
2e472a9
Update client api schema
gecage952 Jul 12, 2024
811a897
Update client api schema
gecage952 Sep 11, 2024
53051e7
Update client api schema
gecage952 Sep 23, 2024
858a245
Merge remote-tracking branch 'origin/dev' into feature_stdout_live_re…
jmchilton Nov 12, 2024
a2bffb8
Update JobInformation.test.js for live console output.
jmchilton Nov 12, 2024
8063632
Improved error handling for live console for tool execution.
jmchilton Nov 12, 2024
be11412
xfail the test for the freeze.
jmchilton Nov 18, 2024
a69031b
Merge remote-tracking branch 'origin/dev' into feature_stdout_live_re…
jmchilton Nov 18, 2024
f0aa89a
linting fix
jmchilton Nov 18, 2024
394fb16
Ahhh... the set metadata file is JSON and cannot be appended.
jmchilton Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2838,6 +2838,28 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/jobs/{job_id}/console_output": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Returns STDOUT and STDERR from the tool running in a specific job.
* @description Get the stdout and/or stderr from the tool running in a specific job. The position parameters are the index
* of where to start reading stdout/stderr. The length parameters control how much
* stdout/stderr is read.
*/
get: operations["get_console_output_api_jobs__job_id__console_output_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/jobs/{job_id}/destination_params": {
parameters: {
query?: never;
Expand Down Expand Up @@ -12654,6 +12676,24 @@ export interface components {
*/
update_time: string;
};
/** JobConsoleOutput */
JobConsoleOutput: {
/**
* Job State
* @description The current job's state
*/
state?: components["schemas"]["JobState"] | null;
/**
* STDERR
* @description Tool STDERR from job.
*/
stderr?: string | null;
/**
* STDOUT
* @description Tool STDOUT from job.
*/
stdout?: string | null;
};
/** JobDestinationParams */
JobDestinationParams: {
/**
Expand Down Expand Up @@ -27871,6 +27911,54 @@ export interface operations {
};
};
};
get_console_output_api_jobs__job_id__console_output_get: {
parameters: {
query: {
stdout_position: number;
stdout_length: number;
stderr_position: number;
stderr_length: number;
};
header?: {
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
"run-as"?: string | null;
};
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["JobConsoleOutput"];
};
};
/** @description Request Error */
"4XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
/** @description Server Error */
"5XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
};
};
destination_params_job_api_jobs__job_id__destination_params_get: {
parameters: {
query?: never;
Expand Down
32 changes: 25 additions & 7 deletions client/src/components/JobInformation/CodeRow.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
<template>
<tr
v-b-tooltip.hover
:title="`click to ${action}`"
@mousedown="mouseIsDown = true"
@mousemove="mouseIsDown ? (mouseMoved = true) : (mouseMoved = false)"
@mouseup="toggleExpanded()">
<tr>
<td>
<span v-if="helpUri">
<HelpText :uri="helpUri" :text="codeLabel" />
Expand All @@ -18,7 +13,13 @@
<b-col cols="11">
<pre :class="codeClass">{{ codeItem }}</pre>
</b-col>
<b-col class="nopadding pointer">
<b-col
v-b-tooltip.hover
class="nopadding pointer"
:title="`click to ${action}`"
@mousedown="mouseIsDown = true"
@mousemove="mouseIsDown ? (mouseMoved = true) : (mouseMoved = false)"
@mouseup="toggleExpanded()">
<FontAwesomeIcon :icon="iconClass" />
</b-col>
</b-row>
Expand Down Expand Up @@ -48,6 +49,7 @@ export default {
mouseIsDown: false,
mouseMoved: false,
expanded: false,
lastPos: 0,
};
},
computed: {
Expand All @@ -61,6 +63,18 @@ export default {
return this.expanded ? ["fas", "compress-alt"] : ["fas", "expand-alt"];
},
},
updated() {
try {
var codeDiv = this.$el.querySelector(".code");
if (codeDiv.scrollTop + codeDiv.offsetHeight >= this.lastPos - 5) {
// scroll is at the bottom
codeDiv.scrollTop = codeDiv.scrollHeight;
}
this.lastPos = codeDiv.scrollHeight;
} catch (exception) {
console.debug("Code div is not present");
}
},
methods: {
toggleExpanded() {
this.mouseIsDown = false;
Expand All @@ -80,4 +94,8 @@ export default {
padding: 0;
margin: 0;
}
.code {
max-height: 50em;
overflow: auto;
}
</style>
49 changes: 44 additions & 5 deletions client/src/components/JobInformation/JobInformation.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup>
import CopyToClipboard from "components/CopyToClipboard";
import HelpText from "components/Help/HelpText";
import { JobDetailsProvider } from "components/providers/JobProvider";
import { JobConsoleOutputProvider, JobDetailsProvider } from "components/providers/JobProvider";
import UtcDate from "components/UtcDate";
import { NON_TERMINAL_STATES } from "components/WorkflowInvocationState/util";
import { computed, ref, watch } from "vue";
Expand All @@ -28,9 +28,21 @@ const props = defineProps({
},
});

const stdout_length = ref(50000);
const stdout_text = ref("");
const stderr_length = ref(50000);
const stderr_text = ref("");

const stdout_position = computed(() => stdout_text.value.length);
const stderr_position = computed(() => stderr_text.value.length);

const runTime = computed(() => getJobDuration(job.value));

const jobIsTerminal = computed(() => job.value && !NON_TERMINAL_STATES.includes(job.value.state));
function jobStateIsTerminal(jobState) {
return jobState && !NON_TERMINAL_STATES.includes(job.value.state);
}

const jobIsTerminal = computed(() => jobStateIsTerminal(job?.value?.state));

const routeToInvocation = computed(() => `/workflows/invocations/${invocationId.value}`);

Expand All @@ -41,6 +53,26 @@ const metadataDetail = ref({

function updateJob(newJob) {
job.value = newJob;
if (jobStateIsTerminal(newJob?.state)) {
if (newJob.tool_stdout) {
stdout_text.value = newJob.tool_stdout;
}
if (newJob.tool_stderr) {
stderr_text.value = newJob.tool_stderr;
}
}
}

function updateConsoleOutputs(output) {
// Keep stdout in memory and only fetch new text via JobProvider
if (output) {
if (output.stdout != null) {
stdout_text.value += output.stdout;
}
if (output.stderr != null) {
stderr_text.value += output.stderr;
}
}
}

function filterMetadata(jobMessages) {
Expand Down Expand Up @@ -94,6 +126,14 @@ watch(
<template>
<div>
<JobDetailsProvider auto-refresh :job-id="props.job_id" @update:result="updateJob" />
<JobConsoleOutputProvider
auto-refresh
:job-id="props.job_id"
:stdout_position="stdout_position"
:stdout_length="stdout_length"
:stderr_position="stderr_position"
:stderr_length="stderr_length"
@update:result="updateConsoleOutputs" />
<h2 class="h-md">Job Information</h2>
<table id="job-information" class="tabletip info_data_table">
<tbody>
Expand Down Expand Up @@ -146,13 +186,13 @@ watch(
id="stdout"
help-uri="unix.stdout"
:code-label="'Tool Standard Output'"
:code-item="job.tool_stdout" />
:code-item="stdout_text" />
<CodeRow
v-if="job"
id="stderr"
help-uri="unix.stderr"
:code-label="'Tool Standard Error'"
:code-item="job.tool_stderr" />
:code-item="stderr_text" />
<CodeRow
v-if="job && job.traceback"
id="traceback"
Expand Down Expand Up @@ -211,7 +251,6 @@ watch(
</table>
</div>
</template>

<style scoped>
.tooltipJobInfo {
text-decoration-line: underline;
Expand Down
19 changes: 19 additions & 0 deletions client/src/components/providers/JobProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,26 @@ async function jobDetails({ jobId }) {
}
}

async function jobConsoleOutput({
jobId,
stdout_position = 0,
stdout_length = 0,
stderr_position = 0,
stderr_length = 0,
}) {
const url =
`${getAppRoot()}api/jobs/${jobId}/console_output?stdout_position=${stdout_position}&stdout_length=${stdout_length}` +
`&stderr_position=${stderr_position}&stderr_length=${stderr_length}`;
try {
const { data } = await axios.get(url);
return data;
} catch (e) {
rethrowSimple(e);
}
}

export const JobDetailsProvider = SingleQueryProvider(jobDetails, stateIsTerminal);
export const JobConsoleOutputProvider = SingleQueryProvider(jobConsoleOutput, stateIsTerminal);

export function jobsProvider(ctx, callback, extraParams = {}) {
const { root, ...requestParams } = ctx;
Expand Down
12 changes: 12 additions & 0 deletions lib/galaxy/jobs/runners/pulsar.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,18 @@ def finish_job(self, job_state: JobState):
remote_metadata_directory = run_results.get("metadata_directory", None)
tool_stdout = unicodify(run_results.get("stdout", ""), strip_null=True)
tool_stderr = unicodify(run_results.get("stderr", ""), strip_null=True)
for file in ("tool_stdout", "tool_stderr"):
if tool_stdout and tool_stderr:
pass
try:
file_path = os.path.join(job_wrapper.working_directory, "outputs", file)
file_content = open(file_path)
if tool_stdout is None and file == "tool_stdout":
tool_stdout = file_content.read()
elif tool_stderr is None and file == "tool_stderr":
tool_stderr = file_content.read()
except Exception:
pass
job_stdout = run_results.get("job_stdout")
job_stderr = run_results.get("job_stderr")
exit_code = run_results.get("returncode")
Expand Down
48 changes: 48 additions & 0 deletions lib/galaxy/managers/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
date,
datetime,
)
from pathlib import Path
from typing import (
Any,
cast,
Expand Down Expand Up @@ -33,6 +34,7 @@

from galaxy import model
from galaxy.exceptions import (
ConfigDoesNotAllowException,
ItemAccessibilityException,
ObjectNotFound,
RequestParameterInvalidException,
Expand Down Expand Up @@ -83,6 +85,7 @@
defaultdict,
ExecutionTimer,
listify,
string_as_bool_or_none,
)
from galaxy.util.search import (
FilteredTerm,
Expand All @@ -96,6 +99,10 @@
JobStatesT = Union[JobStateT, List[JobStateT]]


STDOUT_LOCATION = "outputs/tool_stdout"
STDERR_LOCATION = "outputs/tool_stderr"


class JobLock(BaseModel):
active: bool = Field(title="Job lock status", description="If active, jobs will not dispatch")

Expand Down Expand Up @@ -296,6 +303,47 @@ def get_accessible_job(self, trans: ProvidesUserContext, decoded_job_id) -> Job:
trans.sa_session.refresh(job)
return job

def get_job_console_output(
self, trans, job, stdout_position=-1, stdout_length=0, stderr_position=-1, stderr_length=0
):
if job is None:
raise ObjectNotFound()

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a bunch of reasons this might not be possible I think. Older Pulsar, remote execution outside of Pulsar, a Pulsar modality that doesn't allow this, runners that respect the location of working directory stuff in this fashion. It feels like the right thing to do here is to throw a galaxy.exceptions.ConfigDoesNotAllowException and then catch this on the client and not poll. Does that make sense to you?

Ideally I think we would make all this opt-in based on the job destination until we've hardened it and seen it work well in production without performance issues or confusing user experience. The job object has a job.job_destination YAML thing we could use to configure things per environment/destination.

Copy link
Contributor Author

@gecage952 gecage952 May 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that exception definitely makes sense. The compatibility just isn't going to be there for some stuff, I guess. Opt-in is a good idea as well.

# Check job destination params to see if stdout reporting is enabled
dest_params = job.destination_params
if not string_as_bool_or_none(dest_params.get("live_tool_output_reporting", False)):
raise ConfigDoesNotAllowException()

# If stdout_length and stdout_position are good values, then load standard out and add it to status
console_output = {}
console_output["state"] = job.state
if job.state == job.states.RUNNING:
working_directory = trans.app.object_store.get_filename(
job, base_dir="job_work", dir_only=True, obj_dir=True
)
if stdout_length > -1 and stdout_position > -1:
try:
stdout_path = Path(working_directory) / STDOUT_LOCATION
stdout_file = open(stdout_path)
stdout_file.seek(stdout_position)
console_output["stdout"] = stdout_file.read(stdout_length)
except Exception as e:
log.error("Could not read STDOUT: %s", e)
console_output["stdout"] = ""
if stderr_length > -1 and stderr_position > -1:
try:
stderr_path = Path(working_directory) / STDERR_LOCATION
stderr_file = open(stderr_path)
stderr_file.seek(stderr_position)
console_output["stderr"] = stderr_file.read(stderr_length)
except Exception as e:
log.error("Could not read STDERR: %s", e)
console_output["stderr"] = ""
else:
console_output["stdout"] = job.tool_stdout
console_output["stderr"] = job.tool_stderr
return console_output

def stop(self, job, message=None):
if not job.finished:
job.mark_deleted(self.app.config.track_jobs_in_database)
Expand Down
Loading
Loading