This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 0bf03bdedf [#12495] feat(job): Expose job finishedAt across the public
surface (#12497)
0bf03bdedf is described below
commit 0bf03bdedfb6551238e9b13ef4c07033cef2f42a
Author: Jerry Shao <[email protected]>
AuthorDate: Wed Aug 19 13:51:07 2026 +0800
[#12495] feat(job): Expose job finishedAt across the public surface (#12497)
### What changes were proposed in this pull request?
Expose the job's finished-execution time (already tracked internally by
`JobEntity`/`JobPO`/storage) through the public surface:
- `JobHandle.finishedAt()` (Java `default` method) / `finished_at()`
(Python) — non-abstract, raising
`UnsupportedOperationException`/`NotImplementedError` by default so
existing external implementers don't break.
- `JobDTO.finishedAt` (Java `Instant`, Python `datetime`), matching the
existing `Audit.createTime` convention.
- `finishedAt` added to the `Job` schema in `docs/open-api/jobs.yaml`.
- `JobEntity.finishedAtAsInstant()` — single conversion point from the
storage layer's raw epoch-millis + "not finished" sentinel to `Instant`,
used by `JobOperations.toDTO` and `JobInfo.fromJobEntity`.
- `GenericJobHandle` (Java + Python) delegate to the DTO.
- `JobManager.pullAndUpdateJobStatus` now stamps `finishedAt` at the
moment a job transitions to a terminal state
(`SUCCEEDED`/`FAILED`/`CANCELLED`).
- `JobEntity.FINISHED_AT` is now a required field, enforced at build
time, so every construction site must decide the value explicitly;
`equals()`/`hashCode()` now include it.
### Why are the changes needed?
Callers could observe a job's status but had no way to know *when* it
actually finished, failed, or was cancelled — only the storage layer
tracked this internally.
Fix: #12495
### Does this PR introduce _any_ user-facing change?
Yes:
- New `finishedAt` field on the `Job` REST API response / OpenAPI
schema.
- New `JobHandle.finishedAt()` (Java) / `finished_at()` (Python) client
API, returning `null`/`None` if the job hasn't finished yet.
- `JobHandle` implementers outside this repo will get a default that
raises `UnsupportedOperationException`/`NotImplementedError` unless they
override it.
### How was this patch tested?
Unit tests across `common`, `core`, `server`, `clients/client-java`, and
`clients/client-python` (DTO ser/de, `JobEntity` builder/equality,
`JobManager` status-polling, `JobOperations`/`JobPO` conversion), plus
Java and Python integration tests (`JobIT`,
`tests/integration/test_supports_jobs.py`) running real jobs to
completion/failure/cancellation and asserting `finishedAt`.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
.../java/org/apache/gravitino/job/JobHandle.java | 14 ++
.../apache/gravitino/client/GenericJobHandle.java | 6 +
.../apache/gravitino/client/TestSupportsJobs.java | 10 +-
.../gravitino/client/integration/test/JobIT.java | 10 ++
.../client-python/gravitino/api/job/job_handle.py | 8 +
.../gravitino/client/generic_job_handle.py | 3 +
clients/client-python/gravitino/dto/job/job_dto.py | 26 +++-
.../tests/integration/test_supports_jobs.py | 11 ++
.../tests/unittests/dto/job/test_job_dto_serde.py | 84 +++++++++++
.../tests/unittests/test_supports_jobs.py | 12 +-
.../java/org/apache/gravitino/dto/job/JobDTO.java | 16 +-
.../org/apache/gravitino/dto/job/TestJobDTO.java | 124 +++++++++++++++
.../java/org/apache/gravitino/job/JobManager.java | 10 ++
.../gravitino/listener/api/info/JobInfo.java | 28 +++-
.../java/org/apache/gravitino/meta/JobEntity.java | 27 +++-
.../gravitino/storage/relational/po/JobPO.java | 16 +-
.../org/apache/gravitino/job/TestJobManager.java | 11 +-
.../listener/api/event/TestJobEventDispatcher.java | 5 +
.../org/apache/gravitino/meta/TestJobEntity.java | 166 +++++++++++++++++++++
.../relational/TestJDBCBackendBatchGet.java | 2 +
.../gravitino/storage/relational/po/TestJobPO.java | 28 ++++
.../service/TestEntityChangeLogService.java | 1 +
.../relational/service/TestJobMetaService.java | 2 +
.../service/TestJobTemplateMetaService.java | 6 +
docs/open-api/jobs.yaml | 14 +-
.../gravitino/server/web/rest/JobOperations.java | 3 +-
.../server/web/rest/TestJobOperations.java | 36 ++++-
27 files changed, 649 insertions(+), 30 deletions(-)
diff --git a/api/src/main/java/org/apache/gravitino/job/JobHandle.java
b/api/src/main/java/org/apache/gravitino/job/JobHandle.java
index 9aded575cc..f5fa209199 100644
--- a/api/src/main/java/org/apache/gravitino/job/JobHandle.java
+++ b/api/src/main/java/org/apache/gravitino/job/JobHandle.java
@@ -18,6 +18,9 @@
*/
package org.apache.gravitino.job;
+import java.time.Instant;
+import javax.annotation.Nullable;
+
/**
* JobHandle is an interface that is returned by the job submission, which
provides methods to get
* the job name, job ID, job status, and to add listeners for jobs.
@@ -69,4 +72,15 @@ public interface JobHandle {
* @return the status of the job
*/
Status jobStatus();
+
+ /**
+ * Get the time when the job finished execution.
+ *
+ * @return the finished time of the job, or null if the job has not finished
execution yet
+ */
+ @Nullable
+ default Instant finishedAt() {
+ throw new UnsupportedOperationException(
+ "finishedAt() is not implemented by " + getClass().getName() + ";
override this method");
+ }
}
diff --git
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericJobHandle.java
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericJobHandle.java
index 0c270cc429..a0ba2bed45 100644
---
a/clients/client-java/src/main/java/org/apache/gravitino/client/GenericJobHandle.java
+++
b/clients/client-java/src/main/java/org/apache/gravitino/client/GenericJobHandle.java
@@ -18,6 +18,7 @@
*/
package org.apache.gravitino.client;
+import java.time.Instant;
import org.apache.gravitino.dto.job.JobDTO;
import org.apache.gravitino.job.JobHandle;
@@ -44,4 +45,9 @@ public class GenericJobHandle implements JobHandle {
public Status jobStatus() {
return jobDTO.status();
}
+
+ @Override
+ public Instant finishedAt() {
+ return jobDTO.finishedAt();
+ }
}
diff --git
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
index b73590b8a9..f7c9c994aa 100644
---
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
+++
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestSupportsJobs.java
@@ -278,7 +278,7 @@ public class TestSupportsJobs extends TestBase {
public void testGetJob() throws JsonProcessingException {
String jobId = "job-1";
String jobTemplateName = "shell-job-template";
- JobDTO expectedJob = newJobDTO(jobId, jobTemplateName);
+ JobDTO expectedJob = newJobDTO(jobId, jobTemplateName, Instant.now());
JobResponse resp = new JobResponse(expectedJob);
buildMockResource(Method.GET, jobRunsPath() + "/" + jobId, null, resp,
HttpStatus.SC_OK);
@@ -340,6 +340,7 @@ public class TestSupportsJobs extends TestBase {
Assertions.assertEquals(expected.jobId(), actual.jobId());
Assertions.assertEquals(expected.jobTemplateName(),
actual.jobTemplateName());
Assertions.assertEquals(expected.status(), actual.jobStatus());
+ Assertions.assertEquals(expected.finishedAt(), actual.finishedAt());
}
private String jobTemplatesPath() {
@@ -381,10 +382,15 @@ public class TestSupportsJobs extends TestBase {
}
private JobDTO newJobDTO(String jobId, String templateName) {
+ return newJobDTO(jobId, templateName, null);
+ }
+
+ private JobDTO newJobDTO(String jobId, String templateName, Instant
finishedAt) {
return new JobDTO(
jobId,
templateName,
JobHandle.Status.QUEUED,
-
AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build());
+
AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build(),
+ finishedAt);
}
}
diff --git
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
index 9dfa11ba8a..d523efd90c 100644
---
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
+++
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
@@ -288,6 +288,7 @@ public class JobIT extends BaseIT {
ImmutableMap.of("arg1", "value1", "arg2", "success", "env_var",
"value2"));
Assertions.assertEquals(JobHandle.Status.QUEUED, jobHandle1.jobStatus());
Assertions.assertEquals(template.name(), jobHandle1.jobTemplateName());
+ Assertions.assertNull(jobHandle1.finishedAt());
JobHandle jobHandle2 =
metalake.runJob(
@@ -295,6 +296,7 @@ public class JobIT extends BaseIT {
ImmutableMap.of("arg1", "value3", "arg2", "success", "env_var",
"value4"));
Assertions.assertEquals(JobHandle.Status.QUEUED, jobHandle2.jobStatus());
Assertions.assertEquals(template.name(), jobHandle2.jobTemplateName());
+ Assertions.assertNull(jobHandle2.finishedAt());
List<JobHandle> jobs = metalake.listJobs(template.name());
Assertions.assertEquals(2, jobs.size());
@@ -324,6 +326,8 @@ public class JobIT extends BaseIT {
updatedJobs.stream().map(JobHandle::jobStatus).collect(Collectors.toSet());
Assertions.assertEquals(1, jobStatuses.size());
Assertions.assertTrue(jobStatuses.contains(JobHandle.Status.SUCCEEDED));
+ // Finished jobs should carry a non-null finishedAt.
+ updatedJobs.forEach(job -> Assertions.assertNotNull(job.finishedAt()));
}
@Test
@@ -338,6 +342,7 @@ public class JobIT extends BaseIT {
ImmutableMap.of("arg1", "value1", "arg2", "success", "env_var",
"value2"));
Assertions.assertEquals(JobHandle.Status.QUEUED, jobHandle.jobStatus());
Assertions.assertEquals(template.name(), jobHandle.jobTemplateName());
+ Assertions.assertNull(jobHandle.finishedAt());
Awaitility.await()
.atMost(3, TimeUnit.MINUTES)
@@ -350,6 +355,7 @@ public class JobIT extends BaseIT {
JobHandle retrievedJob = metalake.getJob(jobHandle.jobId());
Assertions.assertEquals(jobHandle.jobId(), retrievedJob.jobId());
Assertions.assertEquals(JobHandle.Status.SUCCEEDED,
retrievedJob.jobStatus());
+ Assertions.assertNotNull(retrievedJob.finishedAt());
// Test run a failed job
JobHandle failedJobHandle =
@@ -357,6 +363,7 @@ public class JobIT extends BaseIT {
template.name(),
ImmutableMap.of("arg1", "value1", "arg2", "fail", "env_var",
"value2"));
Assertions.assertEquals(JobHandle.Status.QUEUED,
failedJobHandle.jobStatus());
+ Assertions.assertNull(failedJobHandle.finishedAt());
Awaitility.await()
.atMost(3, TimeUnit.MINUTES)
@@ -369,6 +376,7 @@ public class JobIT extends BaseIT {
JobHandle retrievedFailedJob = metalake.getJob(failedJobHandle.jobId());
Assertions.assertEquals(failedJobHandle.jobId(),
retrievedFailedJob.jobId());
Assertions.assertEquals(JobHandle.Status.FAILED,
retrievedFailedJob.jobStatus());
+ Assertions.assertNotNull(retrievedFailedJob.finishedAt());
// Test get a non-existent job
Assertions.assertThrows(NoSuchJobException.class, () ->
metalake.getJob("non_existent_job_id"));
@@ -386,6 +394,7 @@ public class JobIT extends BaseIT {
ImmutableMap.of("arg1", "value1", "arg2", "success", "env_var",
"value2"));
Assertions.assertEquals(JobHandle.Status.QUEUED, jobHandle.jobStatus());
Assertions.assertEquals(template.name(), jobHandle.jobTemplateName());
+ Assertions.assertNull(jobHandle.finishedAt());
// Cancel the job
metalake.cancelJob(jobHandle.jobId());
@@ -401,6 +410,7 @@ public class JobIT extends BaseIT {
JobHandle retrievedJob = metalake.getJob(jobHandle.jobId());
Assertions.assertEquals(jobHandle.jobId(), retrievedJob.jobId());
Assertions.assertEquals(JobHandle.Status.CANCELLED,
retrievedJob.jobStatus());
+ Assertions.assertNotNull(retrievedJob.finishedAt());
// Test cancel a non-existent job
Assertions.assertThrows(
diff --git a/clients/client-python/gravitino/api/job/job_handle.py
b/clients/client-python/gravitino/api/job/job_handle.py
index b8da91fcd3..e57a0bcaf8 100644
--- a/clients/client-python/gravitino/api/job/job_handle.py
+++ b/clients/client-python/gravitino/api/job/job_handle.py
@@ -16,7 +16,9 @@
# under the License.
from abc import ABC, abstractmethod
+from datetime import datetime
from enum import Enum
+from typing import Optional
class JobHandle(ABC):
@@ -52,3 +54,9 @@ class JobHandle(ABC):
@abstractmethod
def job_status(self) -> Status:
pass
+
+ def finished_at(self) -> Optional[datetime]:
+ """Returns the time the job finished execution, or ``None`` if the job
has not finished
+ execution yet.
+ """
+ raise NotImplementedError("finished_at is not implemented")
diff --git a/clients/client-python/gravitino/client/generic_job_handle.py
b/clients/client-python/gravitino/client/generic_job_handle.py
index 95b568a613..b100867ae8 100644
--- a/clients/client-python/gravitino/client/generic_job_handle.py
+++ b/clients/client-python/gravitino/client/generic_job_handle.py
@@ -32,3 +32,6 @@ class GenericJobHandle(JobHandle):
def job_status(self):
return self._job_dto.status()
+
+ def finished_at(self):
+ return self._job_dto.finished_at()
diff --git a/clients/client-python/gravitino/dto/job/job_dto.py
b/clients/client-python/gravitino/dto/job/job_dto.py
index eab42da57c..8be68d4681 100644
--- a/clients/client-python/gravitino/dto/job/job_dto.py
+++ b/clients/client-python/gravitino/dto/job/job_dto.py
@@ -16,10 +16,17 @@
# under the License.
from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Optional
+
from dataclasses_json import config, DataClassJsonMixin
from gravitino.api.job.job_handle import JobHandle
-from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.audit_dto import (
+ AuditDTO,
+ _deserialize_datetime,
+ _serialize_datetime,
+)
@dataclass
@@ -36,6 +43,17 @@ class JobDTO(DataClassJsonMixin):
)
)
_audit: AuditDTO = field(metadata=config(field_name="audit"))
+ _finished_at: Optional[datetime] = field(
+ default=None,
+ metadata=config(
+ field_name="finishedAt",
+ encoder=_serialize_datetime,
+ decoder=_deserialize_datetime,
+ ),
+ )
+
+ def __post_init__(self) -> None:
+ self._finished_at = _deserialize_datetime(self._finished_at)
def job_id(self) -> str:
"""Returns the job ID."""
@@ -53,6 +71,12 @@ class JobDTO(DataClassJsonMixin):
"""Returns the audit information of the job."""
return self._audit
+ def finished_at(self) -> Optional[datetime]:
+ """Returns the time the job finished execution, or ``None`` if the job
has not finished
+ execution yet.
+ """
+ return self._finished_at
+
def validate(self) -> None:
"""Validates the JobDTO, ensuring required fields are present and
non-empty."""
if self._job_id is None or not self._job_id.strip():
diff --git a/clients/client-python/tests/integration/test_supports_jobs.py
b/clients/client-python/tests/integration/test_supports_jobs.py
index e613085cea..a8e3fc56e2 100644
--- a/clients/client-python/tests/integration/test_supports_jobs.py
+++ b/clients/client-python/tests/integration/test_supports_jobs.py
@@ -273,12 +273,14 @@ class TestSupportsJobs(IntegrationTestEnv):
)
self.assertEqual(job_handle1.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle1.job_template_name(), template.name)
+ self.assertIsNone(job_handle1.finished_at())
job_handle2 = self._metalake.run_job(
template.name, {"arg1": "value3", "arg2": "success", "env_var":
"value4"}
)
self.assertEqual(job_handle2.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle2.job_template_name(), template.name)
+ self.assertIsNone(job_handle2.finished_at())
# List jobs
jobs = self._metalake.list_jobs(template.name)
@@ -305,6 +307,9 @@ class TestSupportsJobs(IntegrationTestEnv):
job_statuses = {job.job_status() for job in updated_jobs}
self.assertEqual(len(job_statuses), 1)
self.assertIn(JobHandle.Status.SUCCEEDED, job_statuses)
+ # Finished jobs should carry a non-None finished_at.
+ for job in updated_jobs:
+ self.assertIsNotNone(job.finished_at())
def test_run_and_get_job(self):
template = self.builder.with_name("test_run_get").build()
@@ -316,6 +321,7 @@ class TestSupportsJobs(IntegrationTestEnv):
)
self.assertEqual(job_handle.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle.job_template_name(), template.name)
+ self.assertIsNone(job_handle.finished_at())
# Wait for job to complete
self._wait_until(
@@ -326,12 +332,14 @@ class TestSupportsJobs(IntegrationTestEnv):
retrieved_job = self._metalake.get_job(job_handle.job_id())
self.assertEqual(job_handle.job_id(), retrieved_job.job_id())
self.assertEqual(JobHandle.Status.SUCCEEDED,
retrieved_job.job_status())
+ self.assertIsNotNone(retrieved_job.finished_at())
# Test failed job
failed_job_handle = self._metalake.run_job(
template.name, {"arg1": "value1", "arg2": "fail", "env_var":
"value2"}
)
self.assertEqual(failed_job_handle.job_status(),
JobHandle.Status.QUEUED)
+ self.assertIsNone(failed_job_handle.finished_at())
self._wait_until(
lambda:
self._metalake.get_job(failed_job_handle.job_id()).job_status()
@@ -341,6 +349,7 @@ class TestSupportsJobs(IntegrationTestEnv):
retrieved_failed_job =
self._metalake.get_job(failed_job_handle.job_id())
self.assertEqual(failed_job_handle.job_id(),
retrieved_failed_job.job_id())
self.assertEqual(JobHandle.Status.FAILED,
retrieved_failed_job.job_status())
+ self.assertIsNotNone(retrieved_failed_job.finished_at())
# Test non-existent job
with self.assertRaises(NoSuchJobException):
@@ -356,6 +365,7 @@ class TestSupportsJobs(IntegrationTestEnv):
)
self.assertEqual(job_handle.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle.job_template_name(), template.name)
+ self.assertIsNone(job_handle.finished_at())
sleep(1)
@@ -371,6 +381,7 @@ class TestSupportsJobs(IntegrationTestEnv):
retrieved_job = self._metalake.get_job(job_handle.job_id())
self.assertEqual(job_handle.job_id(), retrieved_job.job_id())
self.assertEqual(JobHandle.Status.CANCELLED,
retrieved_job.job_status())
+ self.assertIsNotNone(retrieved_job.finished_at())
# Test cancel non-existent job
with self.assertRaises(NoSuchJobException):
diff --git
a/clients/client-python/tests/unittests/dto/job/test_job_dto_serde.py
b/clients/client-python/tests/unittests/dto/job/test_job_dto_serde.py
new file mode 100644
index 0000000000..8db836189d
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/job/test_job_dto_serde.py
@@ -0,0 +1,84 @@
+# 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.
+import unittest
+from datetime import datetime, timezone
+
+from gravitino.api.job.job_handle import JobHandle
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.job.job_dto import JobDTO
+
+
+class TestJobDTOSerDe(unittest.TestCase):
+
+ def test_ser_de_with_finished_at(self):
+ finished_at = datetime.now(timezone.utc)
+ job_dto = JobDTO(
+ _job_id="job-123",
+ _job_template_name="test_template",
+ _status=JobHandle.Status.SUCCEEDED,
+ _audit=AuditDTO(_creator="test",
_create_time=datetime.now(timezone.utc)),
+ _finished_at=finished_at,
+ )
+
+ json_str = job_dto.to_json()
+ self.assertIn("finishedAt", json_str)
+
+ deser_job_dto = JobDTO.from_json(json_str)
+ self.assertEqual(job_dto, deser_job_dto)
+ self.assertEqual(finished_at, deser_job_dto.finished_at())
+
+ def test_ser_de_with_none_finished_at(self):
+ job_dto = JobDTO(
+ _job_id="job-456",
+ _job_template_name="test_template",
+ _status=JobHandle.Status.QUEUED,
+ _audit=AuditDTO(_creator="test",
_create_time=datetime.now(timezone.utc)),
+ )
+
+ json_str = job_dto.to_json()
+ deser_job_dto = JobDTO.from_json(json_str)
+ self.assertEqual(job_dto, deser_job_dto)
+ self.assertIsNone(deser_job_dto.finished_at())
+
+ def test_deserialize_from_string(self):
+ json_str = (
+ '{"jobId": "job-789", "jobTemplateName": "test_template", '
+ '"status": "failed", '
+ '"audit": {"creator": "test", "createTime":
"2024-01-01T00:00:00Z"}, '
+ '"finishedAt": "2024-01-01T01:00:00Z"}'
+ )
+
+ job_dto = JobDTO.from_json(json_str)
+
+ self.assertEqual("job-789", job_dto.job_id())
+ self.assertEqual("test_template", job_dto.job_template_name())
+ self.assertEqual(JobHandle.Status.FAILED, job_dto.status())
+ self.assertEqual(
+ datetime(2024, 1, 1, 1, 0, 0, tzinfo=timezone.utc),
job_dto.finished_at()
+ )
+
+ def test_deserialize_from_string_without_finished_at(self):
+ json_str = (
+ '{"jobId": "job-1000", "jobTemplateName": "test_template", '
+ '"status": "queued", '
+ '"audit": {"creator": "test", "createTime":
"2024-01-01T00:00:00Z"}}'
+ )
+
+ job_dto = JobDTO.from_json(json_str, infer_missing=True)
+
+ self.assertEqual("job-1000", job_dto.job_id())
+ self.assertIsNone(job_dto.finished_at())
diff --git a/clients/client-python/tests/unittests/test_supports_jobs.py
b/clients/client-python/tests/unittests/test_supports_jobs.py
index a24766d714..26458c32c1 100644
--- a/clients/client-python/tests/unittests/test_supports_jobs.py
+++ b/clients/client-python/tests/unittests/test_supports_jobs.py
@@ -15,7 +15,9 @@
# specific language governing permissions and limitations
# under the License.
import unittest
+from datetime import datetime, timezone
from http.client import HTTPResponse
+from typing import Optional
from unittest.mock import Mock, patch
from gravitino import GravitinoClient
@@ -205,7 +207,9 @@ class TestSupportsJobs(unittest.TestCase):
)
job_template_name = "test_shell_job"
- job_dto = self._new_job_dto(job_template_name)
+ job_dto = self._new_job_dto(
+ job_template_name, finished_at=datetime.now(timezone.utc)
+ )
resp = JobResponse(_job=job_dto, _code=0)
mock_resp = self._mock_http_response(resp.to_json())
@@ -310,15 +314,19 @@ class TestSupportsJobs(unittest.TestCase):
mock_resp = Response(mock_http_resp)
return mock_resp
- def _new_job_dto(self, job_template_name: str) -> JobDTO:
+ def _new_job_dto(
+ self, job_template_name: str, finished_at: Optional[datetime] = None
+ ) -> JobDTO:
return JobDTO(
_job_id="job-123",
_job_template_name=job_template_name,
_status=JobHandle.Status.QUEUED,
_audit=AuditDTO(_creator="test",
_create_time="2023-10-01T00:00:00Z"),
+ _finished_at=finished_at,
)
def _compare_job_handle(self, job_handle: JobHandle, job_dto: JobDTO):
self.assertEqual(job_handle.job_id(), job_dto.job_id())
self.assertEqual(job_handle.job_template_name(),
job_dto.job_template_name())
self.assertEqual(job_handle.job_status(), job_dto.status())
+ self.assertEqual(job_handle.finished_at(), job_dto.finished_at())
diff --git a/common/src/main/java/org/apache/gravitino/dto/job/JobDTO.java
b/common/src/main/java/org/apache/gravitino/dto/job/JobDTO.java
index 9d57b2c259..d8af081a47 100644
--- a/common/src/main/java/org/apache/gravitino/dto/job/JobDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/job/JobDTO.java
@@ -29,6 +29,7 @@ import
com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.common.base.Preconditions;
import java.io.IOException;
+import java.time.Instant;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
@@ -58,9 +59,12 @@ public class JobDTO {
@JsonProperty("audit")
private final AuditDTO audit;
+ @JsonProperty("finishedAt")
+ private final Instant finishedAt;
+
/** Default constructor for Jackson deserialization. */
private JobDTO() {
- this(null, null, null, null);
+ this(null, null, null, null, null);
}
/**
@@ -70,12 +74,20 @@ public class JobDTO {
* @param jobTemplateName The name of the job template used for this job.
* @param status The current status of the job.
* @param audit The audit information associated with the job.
+ * @param finishedAt The time when the job finished execution, or null if
the job has not finished
+ * execution yet.
*/
- public JobDTO(String jobId, String jobTemplateName, JobHandle.Status status,
AuditDTO audit) {
+ public JobDTO(
+ String jobId,
+ String jobTemplateName,
+ JobHandle.Status status,
+ AuditDTO audit,
+ Instant finishedAt) {
this.jobId = jobId;
this.jobTemplateName = jobTemplateName;
this.status = status;
this.audit = audit;
+ this.finishedAt = finishedAt;
}
/**
diff --git a/common/src/test/java/org/apache/gravitino/dto/job/TestJobDTO.java
b/common/src/test/java/org/apache/gravitino/dto/job/TestJobDTO.java
new file mode 100644
index 0000000000..b422f96828
--- /dev/null
+++ b/common/src/test/java/org/apache/gravitino/dto/job/TestJobDTO.java
@@ -0,0 +1,124 @@
+/*
+ * 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.gravitino.dto.job;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import java.time.Instant;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.json.JsonUtils;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestJobDTO {
+
+ @Test
+ public void testSerDeWithFinishedAt() throws JsonProcessingException {
+ Instant finishedAt = Instant.now();
+ JobDTO jobDTO =
+ new JobDTO(
+ "job-123",
+ "testTemplate",
+ JobHandle.Status.SUCCEEDED,
+
AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build(),
+ finishedAt);
+
+ Assertions.assertDoesNotThrow(jobDTO::validate);
+
+ String serJson = JsonUtils.objectMapper().writeValueAsString(jobDTO);
+ Assertions.assertTrue(serJson.contains("\"finishedAt\""));
+
+ JobDTO deserJobDTO = JsonUtils.objectMapper().readValue(serJson,
JobDTO.class);
+ Assertions.assertEquals(jobDTO, deserJobDTO);
+ Assertions.assertEquals(finishedAt, deserJobDTO.finishedAt());
+ }
+
+ @Test
+ public void testSerDeWithNullFinishedAt() throws JsonProcessingException {
+ JobDTO jobDTO =
+ new JobDTO(
+ "job-456",
+ "testTemplate",
+ JobHandle.Status.QUEUED,
+
AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build(),
+ null);
+
+ Assertions.assertDoesNotThrow(jobDTO::validate);
+
+ String serJson = JsonUtils.objectMapper().writeValueAsString(jobDTO);
+ JobDTO deserJobDTO = JsonUtils.objectMapper().readValue(serJson,
JobDTO.class);
+ Assertions.assertEquals(jobDTO, deserJobDTO);
+ Assertions.assertNull(deserJobDTO.finishedAt());
+ }
+
+ @Test
+ public void testSerializeToString() throws JsonProcessingException {
+ Instant createTime = Instant.parse("2024-01-01T00:00:00Z");
+ Instant finishedAt = Instant.parse("2024-01-01T01:00:00Z");
+ JobDTO jobDTO =
+ new JobDTO(
+ "job-789",
+ "testTemplate",
+ JobHandle.Status.FAILED,
+
AuditDTO.builder().withCreator("test").withCreateTime(createTime).build(),
+ finishedAt);
+
+ String serJson = JsonUtils.objectMapper().writeValueAsString(jobDTO);
+
+ Assertions.assertTrue(serJson.contains("\"jobId\":\"job-789\""));
+
Assertions.assertTrue(serJson.contains("\"jobTemplateName\":\"testTemplate\""));
+ Assertions.assertTrue(serJson.contains("\"status\":\"failed\""));
+
Assertions.assertTrue(serJson.contains("\"finishedAt\":\"2024-01-01T01:00:00Z\""));
+ }
+
+ @Test
+ public void testDeserializeFromString() throws JsonProcessingException {
+ String json =
+ "{"
+ + "\"jobId\":\"job-999\","
+ + "\"jobTemplateName\":\"testTemplate\","
+ + "\"status\":\"succeeded\","
+ +
"\"audit\":{\"creator\":\"test\",\"createTime\":\"2024-01-01T00:00:00Z\"},"
+ + "\"finishedAt\":\"2024-01-01T01:00:00Z\""
+ + "}";
+
+ JobDTO jobDTO = JsonUtils.objectMapper().readValue(json, JobDTO.class);
+
+ Assertions.assertEquals("job-999", jobDTO.jobId());
+ Assertions.assertEquals("testTemplate", jobDTO.jobTemplateName());
+ Assertions.assertEquals(JobHandle.Status.SUCCEEDED, jobDTO.status());
+ Assertions.assertEquals(Instant.parse("2024-01-01T01:00:00Z"),
jobDTO.finishedAt());
+ }
+
+ @Test
+ public void testDeserializeFromStringWithoutFinishedAt() throws
JsonProcessingException {
+ String json =
+ "{"
+ + "\"jobId\":\"job-1000\","
+ + "\"jobTemplateName\":\"testTemplate\","
+ + "\"status\":\"queued\","
+ +
"\"audit\":{\"creator\":\"test\",\"createTime\":\"2024-01-01T00:00:00Z\"}"
+ + "}";
+
+ JobDTO jobDTO = JsonUtils.objectMapper().readValue(json, JobDTO.class);
+
+ Assertions.assertEquals("job-1000", jobDTO.jobId());
+ Assertions.assertNull(jobDTO.finishedAt());
+ }
+}
diff --git a/core/src/main/java/org/apache/gravitino/job/JobManager.java
b/core/src/main/java/org/apache/gravitino/job/JobManager.java
index efa24d70ea..299551dee2 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -465,6 +465,8 @@ public class JobManager implements JobOperationDispatcher {
.withCreator(PrincipalUtils.getCurrentPrincipal().getName())
.withCreateTime(Instant.now())
.build())
+ // A newly submitted job is queued, not finished yet.
+ .withFinishedAt(0L)
.build();
try {
@@ -515,6 +517,8 @@ public class JobManager implements JobOperationDispatcher {
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
.withLastModifiedTime(Instant.now())
.build())
+ // CANCELLING is not a terminal state, so the job is not finished
yet.
+ .withFinishedAt(jobEntity.finishedAt())
.build();
return TreeLockUtils.doWithTreeLock(
NameIdentifierUtil.ofJob(metalake, jobId),
@@ -588,6 +592,11 @@ public class JobManager implements JobOperationDispatcher {
}
if (newStatus != job.status()) {
+ boolean isFinished =
+ newStatus == JobHandle.Status.SUCCEEDED
+ || newStatus == JobHandle.Status.FAILED
+ || newStatus == JobHandle.Status.CANCELLED;
+
JobEntity newJobEntity =
JobEntity.builder()
.withId(job.id())
@@ -602,6 +611,7 @@ public class JobManager implements JobOperationDispatcher {
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
.withLastModifiedTime(Instant.now())
.build())
+ .withFinishedAt(isFinished ?
Instant.now().toEpochMilli() : job.finishedAt())
.build();
// Update the job entity with new status.
diff --git
a/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
b/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
index 156789b13b..5abe692982 100644
--- a/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
+++ b/core/src/main/java/org/apache/gravitino/listener/api/info/JobInfo.java
@@ -19,6 +19,8 @@
package org.apache.gravitino.listener.api.info;
+import java.time.Instant;
+import javax.annotation.Nullable;
import org.apache.gravitino.Audit;
import org.apache.gravitino.annotation.DeveloperApi;
import org.apache.gravitino.job.JobHandle;
@@ -38,11 +40,19 @@ public final class JobInfo {
private final Audit audit;
- private JobInfo(String jobId, String jobTemplateName, JobHandle.Status
jobStatus, Audit audit) {
+ private final Instant finishedAt;
+
+ private JobInfo(
+ String jobId,
+ String jobTemplateName,
+ JobHandle.Status jobStatus,
+ Audit audit,
+ Instant finishedAt) {
this.jobId = jobId;
this.jobTemplateName = jobTemplateName;
this.jobStatus = jobStatus;
this.audit = audit;
+ this.finishedAt = finishedAt;
}
/**
@@ -53,7 +63,11 @@ public final class JobInfo {
*/
public static JobInfo fromJobEntity(JobEntity jobEntity) {
return new JobInfo(
- jobEntity.name(), jobEntity.jobTemplateName(), jobEntity.status(),
jobEntity.auditInfo());
+ jobEntity.name(),
+ jobEntity.jobTemplateName(),
+ jobEntity.status(),
+ jobEntity.auditInfo(),
+ jobEntity.finishedAtAsInstant());
}
/**
@@ -91,4 +105,14 @@ public final class JobInfo {
public Audit auditInfo() {
return audit;
}
+
+ /**
+ * Returns the time when the job finished execution.
+ *
+ * @return the finished time of the job, or null if the job has not finished
execution yet
+ */
+ @Nullable
+ public Instant finishedAt() {
+ return finishedAt;
+ }
}
diff --git a/core/src/main/java/org/apache/gravitino/meta/JobEntity.java
b/core/src/main/java/org/apache/gravitino/meta/JobEntity.java
index 6dff1cc84b..604de3c72e 100644
--- a/core/src/main/java/org/apache/gravitino/meta/JobEntity.java
+++ b/core/src/main/java/org/apache/gravitino/meta/JobEntity.java
@@ -20,9 +20,11 @@
package org.apache.gravitino.meta;
import com.google.common.collect.Maps;
+import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
+import javax.annotation.Nullable;
import lombok.ToString;
import org.apache.gravitino.Auditable;
import org.apache.gravitino.Entity;
@@ -49,7 +51,11 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
Field.required(
"audit_info", AuditInfo.class, "The audit details of the job
template entity.");
public static final Field FINISHED_AT =
- Field.optional("job_finished_at", Long.class, "The time when the job
finished execution.");
+ Field.required(
+ "job_finished_at",
+ Long.class,
+ "The time when the job finished execution, using the storage layer's
"
+ + "\"not finished\" sentinel (<= 0) when the job has not
finished execution yet.");
private Long id;
private String jobExecutionId;
@@ -104,6 +110,19 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
return finishedAt;
}
+ /**
+ * Converts the raw {@code finishedAt} epoch-millis value to an {@link
Instant}, treating {@code
+ * null} or a non-positive value (the "not finished" sentinel used by the
storage layer) as {@code
+ * null}.
+ *
+ * @return the {@link Instant} the job finished execution, or {@code null}
if the job has not
+ * finished execution yet
+ */
+ @Nullable
+ public Instant finishedAtAsInstant() {
+ return (finishedAt == null || finishedAt <= 0) ? null :
Instant.ofEpochMilli(finishedAt);
+ }
+
@Override
public AuditInfo auditInfo() {
return auditInfo;
@@ -129,12 +148,14 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
&& Objects.equals(status, that.status)
&& Objects.equals(jobTemplateName, that.jobTemplateName)
&& Objects.equals(namespace, that.namespace)
- && Objects.equals(auditInfo, that.auditInfo);
+ && Objects.equals(auditInfo, that.auditInfo)
+ && Objects.equals(finishedAt, that.finishedAt);
}
@Override
public int hashCode() {
- return Objects.hash(id, jobExecutionId, namespace, status,
jobTemplateName, auditInfo);
+ return Objects.hash(
+ id, jobExecutionId, namespace, status, jobTemplateName, auditInfo,
finishedAt);
}
public static Builder builder() {
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/po/JobPO.java
b/core/src/main/java/org/apache/gravitino/storage/relational/po/JobPO.java
index 8cb9d2e379..a3c0db8a9b 100644
--- a/core/src/main/java/org/apache/gravitino/storage/relational/po/JobPO.java
+++ b/core/src/main/java/org/apache/gravitino/storage/relational/po/JobPO.java
@@ -99,23 +99,17 @@ public class JobPO {
}
public static JobPO initializeJobPO(JobEntity jobEntity, JobPOBuilder
builder) {
- // We should not keep the terminated job entities in the database forever,
so we set the
- // current time as the finished timestamp if the job is in a terminal
state,
- // So the entity GC cleaner will clean it up later.
- long finished = DEFAULT_DELETED_AT;
- if (jobEntity.status() == JobHandle.Status.CANCELLED
- || jobEntity.status() == JobHandle.Status.FAILED
- || jobEntity.status() == JobHandle.Status.SUCCEEDED) {
- finished = System.currentTimeMillis();
- }
-
+ // finishedAt is a required field on JobEntity - the caller (e.g.
JobManager, when the job
+ // transitions to a terminal state) is guaranteed to have already set it,
using the storage
+ // layer's "not finished" sentinel (<= 0) otherwise. The entity GC cleaner
relies on this
+ // timestamp being set to clean up terminated jobs later.
try {
return builder
.withJobRunId(jobEntity.id())
.withJobTemplateName(jobEntity.jobTemplateName())
.withJobExecutionId(jobEntity.jobExecutionId())
.withJobRunStatus(jobEntity.status().name())
- .withJobFinishedAt(finished)
+ .withJobFinishedAt(jobEntity.finishedAt())
.withAuditInfo(JsonUtils.anyFieldMapper().writeValueAsString(jobEntity.auditInfo()))
.withCurrentVersion(INIT_VERSION)
.withLastVersion(INIT_VERSION)
diff --git a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
index 5edbbc18ef..4665a3320e 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -83,6 +83,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -632,7 +633,15 @@ public class TestJobManager {
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.SUCCEEDED);
Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
- verify(entityStore, times(1)).put(any(JobEntity.class), anyBoolean());
+
+ ArgumentCaptor<JobEntity> captor =
ArgumentCaptor.forClass(JobEntity.class);
+ verify(entityStore, times(1)).put(captor.capture(), anyBoolean());
+
+ // Once a job transitions to a terminal status, finishedAt must be set.
+ JobEntity updatedJob = captor.getValue();
+ Assertions.assertEquals(JobHandle.Status.SUCCEEDED, updatedJob.status());
+ Assertions.assertNotNull(updatedJob.finishedAt());
+ Assertions.assertTrue(updatedJob.finishedAt() > 0);
}
@Test
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
index fd87bc0a0c..6f57ced26d 100644
---
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestJobEventDispatcher.java
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
@@ -486,6 +487,7 @@ public class TestJobEventDispatcher {
Assertions.assertEquals(expected.jobId(), actual.jobId());
Assertions.assertEquals(expected.jobTemplateName(),
actual.jobTemplateName());
Assertions.assertEquals(expected.jobStatus(), actual.jobStatus());
+ Assertions.assertEquals(expected.finishedAt(), actual.finishedAt());
}
private JobOperationDispatcher mockJobDispatcher() {
@@ -547,6 +549,8 @@ public class TestJobEventDispatcher {
when(entity.name()).thenReturn("job-12345");
when(entity.auditInfo()).thenReturn(mock(AuditInfo.class));
when(entity.status()).thenReturn(JobHandle.Status.SUCCEEDED);
+ when(entity.finishedAt()).thenReturn(1700000000000L);
+
when(entity.finishedAtAsInstant()).thenReturn(Instant.ofEpochMilli(1700000000000L));
return entity;
}
@@ -556,6 +560,7 @@ public class TestJobEventDispatcher {
when(info.jobId()).thenReturn("job-12345");
when(info.jobTemplateName()).thenReturn("testJob");
when(info.jobStatus()).thenReturn(JobHandle.Status.SUCCEEDED);
+ when(info.finishedAt()).thenReturn(Instant.ofEpochMilli(1700000000000L));
return info;
}
}
diff --git a/core/src/test/java/org/apache/gravitino/meta/TestJobEntity.java
b/core/src/test/java/org/apache/gravitino/meta/TestJobEntity.java
new file mode 100644
index 0000000000..909798c2e7
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/meta/TestJobEntity.java
@@ -0,0 +1,166 @@
+/*
+ * 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.gravitino.meta;
+
+import java.time.Instant;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestJobEntity {
+
+ private static final AuditInfo AUDIT_INFO =
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+
+ @Test
+ public void testBuildRequiresFinishedAt() {
+ // finishedAt is a required field - it must be explicitly set (using the
storage layer's
+ // "not finished" sentinel, <= 0, when the job hasn't finished),
regardless of status. This
+ // prevents JobPO from having to silently fabricate or default the value.
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .build());
+
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ JobEntity.builder()
+ .withId(2L)
+ .withJobExecutionId("job-execution-2")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.SUCCEEDED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .build());
+ }
+
+ @Test
+ public void testFinishedAt() {
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.SUCCEEDED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(1700000000000L)
+ .build();
+
+ Assertions.assertEquals(1700000000000L, jobEntity.finishedAt());
+ }
+
+ @Test
+ public void testFinishedAtAsInstantWhenNotFinished() {
+ // The storage layer's sentinel (<= 0) means "not finished".
+ JobEntity zeroFinishedAt =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(0L)
+ .build();
+ Assertions.assertNull(zeroFinishedAt.finishedAtAsInstant());
+
+ JobEntity negativeFinishedAt =
+ JobEntity.builder()
+ .withId(2L)
+ .withJobExecutionId("job-execution-2")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.STARTED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(-1L)
+ .build();
+ Assertions.assertNull(negativeFinishedAt.finishedAtAsInstant());
+ }
+
+ @Test
+ public void testFinishedAtAsInstantWhenFinished() {
+ long epochMilli = Instant.now().toEpochMilli();
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.SUCCEEDED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(epochMilli)
+ .build();
+
+ Assertions.assertEquals(Instant.ofEpochMilli(epochMilli),
jobEntity.finishedAtAsInstant());
+ }
+
+ @Test
+ public void testEqualsAndHashCodeIncludeFinishedAt() {
+ JobEntity notFinished =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.STARTED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(0L)
+ .build();
+
+ JobEntity sameAsNotFinished =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.STARTED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(0L)
+ .build();
+
+ Assertions.assertEquals(notFinished, sameAsNotFinished);
+ Assertions.assertEquals(notFinished.hashCode(),
sameAsNotFinished.hashCode());
+
+ // Same identity/status/audit but a different finishedAt (e.g. the same
job just after it
+ // transitioned to a terminal state) must not compare equal.
+ JobEntity finished =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.STARTED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(1700000000000L)
+ .build();
+
+ Assertions.assertNotEquals(notFinished, finished);
+ Assertions.assertNotEquals(notFinished.hashCode(), finished.hashCode());
+ }
+}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackendBatchGet.java
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackendBatchGet.java
index a7410990b3..a67f40d950 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackendBatchGet.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/TestJDBCBackendBatchGet.java
@@ -611,6 +611,7 @@ public class TestJDBCBackendBatchGet extends
TestJDBCBackend {
.withStatus(JobHandle.Status.STARTED)
.withJobTemplateName("template1")
.withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(0L)
.build();
JobEntity job2 =
JobEntity.builder()
@@ -620,6 +621,7 @@ public class TestJDBCBackendBatchGet extends
TestJDBCBackend {
.withStatus(JobHandle.Status.QUEUED)
.withJobTemplateName("template2")
.withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(0L)
.build();
backend.insert(job1, false);
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/po/TestJobPO.java
b/core/src/test/java/org/apache/gravitino/storage/relational/po/TestJobPO.java
index 3fd8f9988b..ea7c2c2fc7 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/po/TestJobPO.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/po/TestJobPO.java
@@ -128,6 +128,7 @@ public class TestJobPO {
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withFinishedAt(0L)
.build();
JobPO.JobPOBuilder builder = JobPO.builder().withMetalakeId(1L);
@@ -140,5 +141,32 @@ public class TestJobPO {
Assertions.assertEquals(jobEntity.status(), resultEntity.status());
Assertions.assertEquals(jobEntity.namespace(), resultEntity.namespace());
Assertions.assertEquals(jobEntity.auditInfo().creator(),
resultEntity.auditInfo().creator());
+ // A queued job has no finishedAt, so it should round-trip as the "not
finished" sentinel.
+ Assertions.assertEquals(0L, resultEntity.finishedAt());
+ }
+
+ @Test
+ public void testJobPOFinishedAt() {
+ // initializeJobPO must trust the finishedAt already set on the entity by
the caller (e.g.
+ // JobManager), rather than deriving it independently from the job's
status.
+ long finishedAt = Instant.now().toEpochMilli();
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.SUCCEEDED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withFinishedAt(finishedAt)
+ .build();
+
+ JobPO.JobPOBuilder builder = JobPO.builder().withMetalakeId(1L);
+ JobPO jobPO = JobPO.initializeJobPO(jobEntity, builder);
+ JobEntity resultEntity = JobPO.fromJobPO(jobPO,
NamespaceUtil.ofJob("test"));
+
+ Assertions.assertEquals(finishedAt, jobPO.jobFinishedAt());
+ Assertions.assertEquals(finishedAt, resultEntity.finishedAt());
}
}
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
index 8611b77db5..70337d5fff 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestEntityChangeLogService.java
@@ -652,6 +652,7 @@ public class TestEntityChangeLogService extends
TestJDBCBackend {
.withJobTemplateName(job.jobTemplateName())
.withStatus(JobHandle.Status.STARTED)
.withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(0L)
.build();
backend.insert(runningJob, true);
assertEntityChange(
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
index 9136d5561f..82d30c8123 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobMetaService.java
@@ -130,6 +130,7 @@ public class TestJobMetaService extends TestJDBCBackend {
.withNamespace(job.namespace())
.withAuditInfo(job.auditInfo())
.withJobTemplateName(job.jobTemplateName())
+ .withFinishedAt(System.currentTimeMillis())
.build();
Assertions.assertDoesNotThrow(() ->
JobMetaService.getInstance().insertJob(jobOverwrite, true));
JobEntity updatedJob =
@@ -179,6 +180,7 @@ public class TestJobMetaService extends TestJDBCBackend {
.withNamespace(job.namespace())
.withAuditInfo(job.auditInfo())
.withJobTemplateName(job.jobTemplateName())
+ .withFinishedAt(timestamp)
.build();
Assertions.assertDoesNotThrow(() ->
JobMetaService.getInstance().insertJob(updatedJob, true));
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
index 3dfe80f408..e83c846f04 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
@@ -277,6 +277,11 @@ public class TestJobTemplateMetaService extends
TestJDBCBackend {
}
static JobEntity newJobEntity(String templateName, JobHandle.Status status,
String metalake) {
+ boolean isFinished =
+ status == JobHandle.Status.SUCCEEDED
+ || status == JobHandle.Status.FAILED
+ || status == JobHandle.Status.CANCELLED;
+
return JobEntity.builder()
.withId(RandomIdGenerator.INSTANCE.nextId())
.withJobExecutionId(RandomIdGenerator.INSTANCE.nextId() + "")
@@ -284,6 +289,7 @@ public class TestJobTemplateMetaService extends
TestJDBCBackend {
.withJobTemplateName(templateName)
.withStatus(status)
.withAuditInfo(AUDIT_INFO)
+ .withFinishedAt(isFinished ? System.currentTimeMillis() : 0L)
.build();
}
}
diff --git a/docs/open-api/jobs.yaml b/docs/open-api/jobs.yaml
index 70d3a80602..34a3ab2a01 100644
--- a/docs/open-api/jobs.yaml
+++ b/docs/open-api/jobs.yaml
@@ -514,6 +514,11 @@ components:
- "canceled"
audit:
$ref: "./openapi.yaml#/components/schemas/Audit"
+ finishedAt:
+ type: string
+ format: date-time
+ nullable: true
+ description: The time when the job finished execution, or null if
the job has not finished execution yet
TemplateUpdate:
@@ -920,7 +925,8 @@ components:
"audit": {
"createTime": "2025-08-12T02:14:28.205023Z",
"creator": "anonymous"
- }
+ },
+ "finishedAt": "2025-08-12T02:15:03.512847Z"
},
{
"jobId": "job-67890",
@@ -929,7 +935,8 @@ components:
"audit": {
"createTime": "2025-08-12T02:14:28.205023Z",
"creator": "anonymous"
- }
+ },
+ "finishedAt": "2025-08-12T02:15:47.891023Z"
}
]
}
@@ -944,7 +951,8 @@ components:
"audit": {
"createTime": "2025-08-12T02:14:28.205023Z",
"creator": "anonymous"
- }
+ },
+ "finishedAt": "2025-08-12T02:15:03.512847Z"
}
}
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
index 77fe02e9d7..1a033abda6 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/JobOperations.java
@@ -476,7 +476,8 @@ public class JobOperations {
jobEntity.name(),
jobEntity.jobTemplateName(),
jobEntity.status(),
- DTOConverters.toDTO(jobEntity.auditInfo()));
+ DTOConverters.toDTO(jobEntity.auditInfo()),
+ jobEntity.finishedAtAsInstant());
}
private static List<JobDTO> toJobDTOs(List<JobEntity> jobEntities) {
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
index 651ab955da..1e2fd543d8 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestJobOperations.java
@@ -42,6 +42,7 @@ import javax.ws.rs.core.Response;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Config;
import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.dto.job.JobDTO;
import org.apache.gravitino.dto.job.JobTemplateDTO;
import org.apache.gravitino.dto.job.ShellTemplateUpdateDTO;
import org.apache.gravitino.dto.requests.JobRunRequest;
@@ -641,7 +642,8 @@ public class TestJobOperations extends JerseyTest {
String templateName = "shell_template_1";
JobEntity job1 = newJobEntity(templateName, JobHandle.Status.QUEUED);
JobEntity job2 = newJobEntity(templateName, JobHandle.Status.STARTED);
- JobEntity job3 = newJobEntity("spark_template_1",
JobHandle.Status.SUCCEEDED);
+ JobEntity job3 =
+ newJobEntity("spark_template_1", JobHandle.Status.SUCCEEDED,
Instant.now().toEpochMilli());
when(jobOperationDispatcher.listJobs(metalake, Optional.empty()))
.thenReturn(Lists.newArrayList(job1, job2, job3));
@@ -663,6 +665,13 @@ public class TestJobOperations extends JerseyTest {
Assertions.assertEquals(JobOperations.toDTO(job2),
jobListResponse.getJobs().get(1));
Assertions.assertEquals(JobOperations.toDTO(job3),
jobListResponse.getJobs().get(2));
+ // Not-yet-finished jobs round-trip finishedAt as null over the wire.
+ Assertions.assertNull(jobListResponse.getJobs().get(0).finishedAt());
+ Assertions.assertNull(jobListResponse.getJobs().get(1).finishedAt());
+ // A finished job round-trips its finishedAt as an Instant over the wire.
+ Assertions.assertEquals(
+ Instant.ofEpochMilli(job3.finishedAt()),
jobListResponse.getJobs().get(2).finishedAt());
+
// Test list jobs by template name
when(jobOperationDispatcher.listJobs(metalake, Optional.of(templateName)))
.thenReturn(Lists.newArrayList(job1, job2));
@@ -793,7 +802,8 @@ public class TestJobOperations extends JerseyTest {
@Test
public void testCancelJob() {
- JobEntity job = newJobEntity("shell_template_1", JobHandle.Status.STARTED);
+ JobEntity job =
+ newJobEntity("shell_template_1", JobHandle.Status.CANCELLED,
Instant.now().toEpochMilli());
when(jobOperationDispatcher.cancelJob(metalake,
job.name())).thenReturn(job);
@@ -810,6 +820,8 @@ public class TestJobOperations extends JerseyTest {
JobResponse jobResp = resp.readEntity(JobResponse.class);
Assertions.assertEquals(0, jobResp.getCode());
Assertions.assertEquals(JobOperations.toDTO(job), jobResp.getJob());
+ // A finished (cancelled) job round-trips its finishedAt as an Instant
over the wire.
+ Assertions.assertEquals(Instant.ofEpochMilli(job.finishedAt()),
jobResp.getJob().finishedAt());
// Test throw NoSuchJobException
doThrow(new NoSuchJobException("mock error"))
@@ -830,6 +842,21 @@ public class TestJobOperations extends JerseyTest {
Assertions.assertEquals(NoSuchJobException.class.getSimpleName(),
errorResp.getType());
}
+ @Test
+ public void testToDTOFinishedAt() {
+ // Sentinel value (<= 0) used by the storage layer means "not finished".
+ JobEntity sentinelJob = newJobEntity("shell_template_1",
JobHandle.Status.STARTED, 0L);
+ JobDTO sentinelJobDTO = JobOperations.toDTO(sentinelJob);
+ Assertions.assertNull(sentinelJobDTO.finishedAt());
+
+ // Finished, finishedAt is converted from epoch millis to an Instant.
+ long epochMilli = Instant.now().toEpochMilli();
+ JobEntity finishedJob =
+ newJobEntity("shell_template_1", JobHandle.Status.SUCCEEDED,
epochMilli);
+ JobDTO finishedJobDTO = JobOperations.toDTO(finishedJob);
+ Assertions.assertEquals(Instant.ofEpochMilli(epochMilli),
finishedJobDTO.finishedAt());
+ }
+
private String jobTemplatePath() {
return "/metalakes/" + metalake + "/jobs/templates";
}
@@ -876,6 +903,10 @@ public class TestJobOperations extends JerseyTest {
}
private JobEntity newJobEntity(String templateName, JobHandle.Status status)
{
+ return newJobEntity(templateName, status, 0L);
+ }
+
+ private JobEntity newJobEntity(String templateName, JobHandle.Status status,
Long finishedAt) {
Random rand = new Random();
return JobEntity.builder()
.withId(rand.nextLong())
@@ -885,6 +916,7 @@ public class TestJobOperations extends JerseyTest {
.withStatus(status)
.withAuditInfo(
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withFinishedAt(finishedAt)
.build();
}
}