jerryshao commented on code in PR #13250:
URL: https://github.com/apache/gravitino/pull/13250#discussion_r4059004174


##########
clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoMetalake.java:
##########
@@ -1714,14 +1714,17 @@ public JobHandle runJob(String jobTemplateName, 
Map<String, String> jobConf)
   }
 
   @Override
-  public JobHandle getJob(String jobId) throws NoSuchJobException {
+  public JobHandle getJob(String jobId, boolean includeOutput) throws 
NoSuchJobException {
     Preconditions.checkArgument(StringUtils.isNotBlank(jobId), "job id must 
not be null or empty");
 
+    Map<String, String> params =
+        includeOutput ? ImmutableMap.of("includeOutput", "true") : 
Collections.emptyMap();

Review Comment:
   [Nit] The Java client only ever sends `includeOutput`; the 
`outputMaxLines`/`outputMaxBytes` query parameters added on the server 
(`JobOperations.java:386-387`) and documented in 
`docs/manage-jobs-in-gravitino.md:269-278` are unreachable from both official 
clients (the Python client does the same at `gravitino_metalake.py:602`). A 
caller who wants the "quick check that doesn't need the full 1000 lines" the 
docs describe has to hand-build the URL.
   
   Not blocking - just worth either plumbing the two caps through 
`SupportsJobs#getJob` as well, or noting in the docs that they are REST-only 
for now.
   
   Verified by: reading `GravitinoMetalake.java:1717-1731`, 
`gravitino_metalake.py:579-610` and `JobOperations.java:380-400` at `f41df58`.



##########
core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutor.java:
##########
@@ -377,9 +403,93 @@ void cleanupJobStatus() {
       jobStatus
           .entrySet()
           .removeIf(
-              entry ->
-                  entry.getValue().getRight() != UNEXPIRED_TIME_IN_MS
-                      && (currentTime - entry.getValue().getRight()) >= 
jobStatusKeepTimeInMs);
+              entry -> {
+                boolean expired =
+                    entry.getValue().getRight() != UNEXPIRED_TIME_IN_MS
+                        && (currentTime - entry.getValue().getRight()) >= 
jobStatusKeepTimeInMs;
+                if (expired) {
+                  jobWorkingDirs.remove(entry.getKey());
+                }
+                return expired;
+              });
+    }
+  }
+
+  private List<String> getJobOutput(String jobId, String fileName, int 
maxLines, int maxBytes) {
+    File workingDir = getWorkingDir(jobId);
+    if (workingDir == null) {
+      return ImmutableList.of();
+    }
+    return readLastLines(new File(workingDir, fileName), maxLines, maxBytes);
+  }
+
+  @Nullable
+  private File getWorkingDir(String jobId) {
+    return jobWorkingDirs.get(jobId);
+  }
+
+  private List<String> readLastLines(File file, int maxLines, int maxBytes) {
+    if (!file.exists()) {
+      // The job hasn't started (or hasn't produced this stream) yet.
+      return ImmutableList.of();
+    }
+
+    long fileLength = file.length();
+    int windowSize = (int) Math.min(fileLength, maxBytes);
+    long startOffset = fileLength - windowSize;
+
+    // Read one extra leading byte (when available) so we can tell whether the 
window's first
+    // line is already complete - i.e. the file byte immediately before the 
window is itself a
+    // line terminator - rather than always assuming it's a partial line and 
discarding it.
+    long readOffset = Math.max(0, startOffset - 1);
+    byte[] probeWindow = new byte[(int) (fileLength - readOffset)];
+    try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
+      raf.seek(readOffset);
+      raf.readFully(probeWindow);
+    } catch (IOException e) {
+      // An I/O failure while reading an existing file is unexpected (unlike 
the file simply not
+      // existing yet, handled above) and must not be silently reported as "no 
output" - that
+      // would be actively misleading for the debugging use case this method 
exists for.
+      throw new RuntimeException("Failed to read job output file: " + file, e);

Review Comment:
   [Question] Everything else on this path is careful to degrade to empty 
output rather than fail - the `JobExecutor` javadoc, `JobManager.getJob`'s 
comment, and the `!file.exists()` check on line 432 all say so. Here an 
`IOException` becomes a `RuntimeException`, which `JobExceptionHandler` maps to 
a 500.
   
   `JobManager.cleanUpStagingDirs()` (`JobManager.java:795-843`) deletes the 
job entity and then `FileUtils.deleteDirectory(jobStagingDir)`. A `getJob(..., 
includeOutput=true)` that read the entity just before that delete, and reaches 
this open just after, hits `FileNotFoundException` and returns 500 rather than 
the empty output the rest of the design intends. The window is narrow, but it 
is a scheduled background task, not an unlikely event.
   
   Would it be worth catching `FileNotFoundException` specifically and 
returning `ImmutableList.of()`, keeping the hard failure for genuine I/O errors?
   
   Verified by: reading `LocalJobExecutor.java:431-454` and 
`JobManager.cleanUpStagingDirs()` at `JobManager.java:795-843`.



##########
core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutor.java:
##########
@@ -377,9 +403,93 @@ void cleanupJobStatus() {
       jobStatus
           .entrySet()
           .removeIf(
-              entry ->
-                  entry.getValue().getRight() != UNEXPIRED_TIME_IN_MS
-                      && (currentTime - entry.getValue().getRight()) >= 
jobStatusKeepTimeInMs);
+              entry -> {
+                boolean expired =
+                    entry.getValue().getRight() != UNEXPIRED_TIME_IN_MS
+                        && (currentTime - entry.getValue().getRight()) >= 
jobStatusKeepTimeInMs;
+                if (expired) {
+                  jobWorkingDirs.remove(entry.getKey());
+                }
+                return expired;
+              });
+    }
+  }
+
+  private List<String> getJobOutput(String jobId, String fileName, int 
maxLines, int maxBytes) {
+    File workingDir = getWorkingDir(jobId);
+    if (workingDir == null) {
+      return ImmutableList.of();
+    }
+    return readLastLines(new File(workingDir, fileName), maxLines, maxBytes);
+  }
+
+  @Nullable
+  private File getWorkingDir(String jobId) {
+    return jobWorkingDirs.get(jobId);
+  }
+
+  private List<String> readLastLines(File file, int maxLines, int maxBytes) {
+    if (!file.exists()) {
+      // The job hasn't started (or hasn't produced this stream) yet.
+      return ImmutableList.of();
+    }
+
+    long fileLength = file.length();
+    int windowSize = (int) Math.min(fileLength, maxBytes);
+    long startOffset = fileLength - windowSize;
+
+    // Read one extra leading byte (when available) so we can tell whether the 
window's first
+    // line is already complete - i.e. the file byte immediately before the 
window is itself a
+    // line terminator - rather than always assuming it's a partial line and 
discarding it.
+    long readOffset = Math.max(0, startOffset - 1);
+    byte[] probeWindow = new byte[(int) (fileLength - readOffset)];
+    try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
+      raf.seek(readOffset);
+      raf.readFully(probeWindow);
+    } catch (IOException e) {
+      // An I/O failure while reading an existing file is unexpected (unlike 
the file simply not
+      // existing yet, handled above) and must not be silently reported as "no 
output" - that
+      // would be actively misleading for the debugging use case this method 
exists for.
+      throw new RuntimeException("Failed to read job output file: " + file, e);
     }
+
+    boolean windowStartsAtLineBoundary = startOffset == 0 || probeWindow[0] == 
'\n';
+    int contentStart = startOffset == 0 ? 0 : 1;
+    // The window may start mid-character if the file byte at contentStart 
happens to be a UTF-8
+    // continuation byte - skip forward to the next character boundary so the 
decoded content
+    // never begins with a corrupted replacement character. A '\n' byte never 
appears inside a
+    // multi-byte UTF-8 character, so this can't skip past a real line 
boundary.
+    while (contentStart < probeWindow.length && (probeWindow[contentStart] & 
0xC0) == 0x80) {
+      contentStart++;
+    }
+
+    String content =
+        new String(
+            probeWindow, contentStart, probeWindow.length - contentStart, 
StandardCharsets.UTF_8);
+    if (!windowStartsAtLineBoundary) {
+      // The window starts mid-file and the preceding byte isn't a line 
terminator, so its first
+      // line may be a partial line whose true beginning fell outside the 
window - drop up to and
+      // including the first newline. If none is found, the window is entirely 
a single oversized
+      // line; keep it as-is rather than discarding it, since a truncated line 
is more useful for
+      // debugging than silently returning nothing.
+      int firstNewline = content.indexOf('\n');
+      content = firstNewline >= 0 ? content.substring(firstNewline + 1) : 
content;

Review Comment:
   [Important] When the byte window lands entirely inside a single line that 
**is** newline-terminated, this returns an empty list instead of the truncated 
tail.
   
   `windowStartsAtLineBoundary` is false (the byte before the window is not 
`\n`), so this branch drops everything up to and including the first `\n`. For 
an oversized final line that ends with a newline, that `\n` is the *last* 
character of the window, so `content` becomes `""` and lines 479-481 return 
`ImmutableList.of()`. The comment just above (471-474) states the intended 
behaviour - "keep it as-is rather than discarding it, since a truncated line is 
more useful for debugging than silently returning nothing" - but that only 
holds for a file with no trailing newline.
   
   Concretely, with `maxBytes = 1024`:
   - `'x' * 4096` (no trailing newline) -> 1 line, tail preserved. This is what 
`TestLocalJobExecutor.java:377` writes.
   - `'x' * 4096 + '\n'` -> `[]`.
   - `"error: something failed\nstack frame 1\nstack frame 2\n" + 'y' * 4096 + 
'\n'` -> `[]`.
   
   So a job whose last line is a big one-line JSON/base64 blob - exactly the 
shape that motivated the byte cap - reports *no output at all*, and the caller 
cannot distinguish that from "the job printed nothing". Every stdout-producing 
shell builtin (`echo`, `printf '...\n'`, `jq -c`) terminates its last line, so 
this is the common case, not the exotic one.
   
   Suggested fix: only drop the leading partial line when something survives 
it, e.g. compute `firstNewline` and keep the original `content` when 
`firstNewline == content.length() - 1` (or when the result would be empty), so 
an oversized line still comes back truncated.
   
   Verified by: reading `LocalJobExecutor.java:431-493` at `f41df58`, then 
extracting `readLastLines` into a standalone class and running it against the 
three files above (results as listed).



##########
core/src/test/java/org/apache/gravitino/job/local/TestLocalJobExecutor.java:
##########
@@ -259,6 +266,236 @@ public void 
testSubmitSparkJobRejectedWhenSparkSubmitIsNotAvailable() throws IOE
     }
   }
 
+  @Test
+  public void testGetJobOutputSuccessfully() throws IOException {
+    Map<String, String> jobConf =
+        ImmutableMap.of(
+            "arg1", "value1",
+            "arg2", "success",
+            "var", "value3");
+
+    JobTemplate template =
+        JobManager.createRuntimeJobTemplate(jobTemplateEntity, jobConf, 
workingDir);
+
+    String jobId = jobExecutor.submitJob(template);
+    Awaitility.await()
+        .atMost(3, TimeUnit.MINUTES)
+        .until(() -> jobExecutor.getJobStatus(jobId) == 
JobHandle.Status.SUCCEEDED);
+
+    List<String> stdout = jobExecutor.getJobStdout(jobId, 1000, 
DEFAULT_TEST_MAX_BYTES);
+    Assertions.assertEquals(6, stdout.size());
+    Assertions.assertEquals("starting test test job", stdout.get(0));
+    Assertions.assertEquals("in common script", stdout.get(1));
+    Assertions.assertTrue(stdout.get(2).startsWith("Submitting job with 
name:"));
+    Assertions.assertEquals("value1", stdout.get(3));
+    Assertions.assertEquals("success", stdout.get(4));
+    Assertions.assertEquals("value3", stdout.get(5));
+
+    // The test script never writes to stderr.
+    Assertions.assertEquals(
+        Collections.emptyList(), jobExecutor.getJobStderr(jobId, 1000, 
DEFAULT_TEST_MAX_BYTES));
+
+    // The full output has 6 lines; only the last 3 should be returned when 
capped.
+    Assertions.assertEquals(
+        ImmutableList.of("value1", "success", "value3"),
+        jobExecutor.getJobStdout(jobId, 3, DEFAULT_TEST_MAX_BYTES));
+  }
+
+  @Test
+  public void testGetJobOutputForUnknownJobReturnsEmpty() {
+    // A job unknown to this executor - whether it never existed here, or its 
bookkeeping has
+    // expired/been lost - reports empty output rather than throwing: the job 
entity itself may
+    // still exist, and querying its output must not turn that into an error.
+    Assertions.assertEquals(
+        Collections.emptyList(),
+        jobExecutor.getJobStdout("no-such-job", 100, DEFAULT_TEST_MAX_BYTES));
+    Assertions.assertEquals(
+        Collections.emptyList(),
+        jobExecutor.getJobStderr("no-such-job", 100, DEFAULT_TEST_MAX_BYTES));
+  }
+
+  @Test
+  public void testGetJobOutputForQueuedJobReturnsEmpty() throws IOException {
+    LocalJobExecutor exec = new LocalJobExecutor();
+    exec.initialize(ImmutableMap.of(LocalJobExecutorConfigs.MAX_RUNNING_JOBS, 
"1"));
+
+    File workingDirA = 
Files.createTempDirectory("gravitino-test-local-job-executor-a").toFile();
+    File workingDirB = 
Files.createTempDirectory("gravitino-test-local-job-executor-b").toFile();
+    try {
+      Map<String, String> jobConf =
+          ImmutableMap.of(
+              "arg1", "value1",
+              "arg2", "success",
+              "var", "value3");
+
+      // Submit two jobs to a single-threaded executor - the second one stays 
QUEUED until the
+      // first (which sleeps for a few seconds) finishes.
+      JobTemplate templateA =
+          JobManager.createRuntimeJobTemplate(jobTemplateEntity, jobConf, 
workingDirA);
+      JobTemplate templateB =
+          JobManager.createRuntimeJobTemplate(jobTemplateEntity, jobConf, 
workingDirB);
+      exec.submitJob(templateA);
+      String jobIdB = exec.submitJob(templateB);
+
+      Assertions.assertEquals(JobHandle.Status.QUEUED, 
exec.getJobStatus(jobIdB));
+      Assertions.assertEquals(
+          Collections.emptyList(), exec.getJobStdout(jobIdB, 100, 
DEFAULT_TEST_MAX_BYTES));
+      Assertions.assertEquals(
+          Collections.emptyList(), exec.getJobStderr(jobIdB, 100, 
DEFAULT_TEST_MAX_BYTES));
+
+      Awaitility.await()
+          .atMost(3, TimeUnit.MINUTES)
+          .until(() -> exec.getJobStatus(jobIdB) == 
JobHandle.Status.SUCCEEDED);
+    } finally {
+      exec.close();
+      FileUtils.deleteDirectory(workingDirA);
+      FileUtils.deleteDirectory(workingDirB);
+    }
+  }
+
+  @Test
+  public void testGetJobOutputWithOversizedSingleLineIsBoundedByMaxBytes() 
throws IOException {
+    Map<String, String> jobConf =
+        ImmutableMap.of(
+            "arg1", "value1",
+            "arg2", "success",
+            "var", "value3");
+
+    JobTemplate template =
+        JobManager.createRuntimeJobTemplate(jobTemplateEntity, jobConf, 
workingDir);
+
+    String jobId = jobExecutor.submitJob(template);
+    Awaitility.await()
+        .atMost(3, TimeUnit.MINUTES)
+        .until(() -> jobExecutor.getJobStatus(jobId) == 
JobHandle.Status.SUCCEEDED);
+
+    // Overwrite the captured output with a single line far larger than the 
byte window, with no
+    // trailing newline - the pathological case a byte-bounded tail read must 
stay safe against
+    // (a naive line-oriented reverse reader can degrade badly on content 
shaped like this).
+    int maxBytes = 1024;
+    String oversizedLine = StringUtils.repeat('x', maxBytes * 4);
+    FileUtils.writeStringToFile(new File(workingDir, "output.log"), 
oversizedLine, "UTF-8");

Review Comment:
   [Nit] This writes the oversized line without a trailing newline, which is 
the single file shape that survives the partial-line drop at line 476. Two 
variants would pin down the behaviour the method's own comment promises, and 
both currently return `[]`:
   
   - the same line with a trailing `\n`;
   - a few ordinary lines followed by an oversized, newline-terminated final 
line (asserting the truncated tail of that last line is returned).
   
   Verified by: running the extracted `readLastLines` logic against both shapes 
with `maxBytes = 1024`; see the comment on `LocalJobExecutor.java:476`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to