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 62c5a2f2b8 [#12508] feat(job): Expose job queuedAt and startedAt
across the public surface (#12509)
62c5a2f2b8 is described below
commit 62c5a2f2b80b3090e1c115205f579e322ac9a57f
Author: Jerry Shao <[email protected]>
AuthorDate: Thu Aug 20 11:22:37 2026 +0800
[#12508] feat(job): Expose job queuedAt and startedAt across the public
surface (#12509)
### What changes were proposed in this pull request?
Expose two additional job execution timestamps — `queuedAt` (when the
job was submitted) and `startedAt` (when execution began) — through the
public surface, alongside the existing `finishedAt`:
- `queuedAt` — derived from the job's existing audit `createTime`; no
storage schema change.
- `startedAt` — a genuinely new state requiring a `job_started_at`
column in `job_run_meta` (MySQL/PostgreSQL/H2 fresh-install schemas +
the 1.3.0→2.0.0 upgrade scripts), following the same sentinel convention
as `job_finished_at`.
- `JobHandle.queuedAt()`/`startedAt()` (Java `default` methods / Python)
— non-abstract, raising by default so existing external implementers
don't break.
- `JobDTO.queuedAt`/`startedAt`, `JobEntity.startedAtAsInstant()`, wired
through `JobOperations.toDTO`, `JobInfo`, and `GenericJobHandle` (Java +
Python).
- `JobManager.pullAndUpdateJobStatus` stamps `startedAt` at the
`QUEUED`/`CANCELLING`→`STARTED` transition, and falls back to `queuedAt`
when a job reaches `SUCCEEDED`/`FAILED` without ever being observed as
`STARTED` (reaching those states proves it ran). `CANCELLED` is
deliberately excluded from that fallback, since a cancelled job may have
been killed while still `QUEUED` and genuinely never started.
- OpenAPI schema and examples updated for both fields.
### Why are the changes needed?
Callers could already see when a job finished but had no way to see when
it was queued or when execution actually started — useful for measuring
queue wait time and execution duration.
Fix: #12508
### Does this PR introduce _any_ user-facing change?
Yes:
- New `queuedAt`/`startedAt` fields on the `Job` REST API response /
OpenAPI schema.
- New `JobHandle.queuedAt()`/`startedAt()` (Java) /
`queued_at()`/`started_at()` (Python) client API.
- New `job_started_at` column added to the `job_run_meta` table
(fresh-install schema and upgrade script for MySQL/PostgreSQL/H2).
- `JobHandle` implementers outside this repo will get defaults that
raise `UnsupportedOperationException`/`NotImplementedError` unless they
override the new methods.
### 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 including the `startedAt` fallback and its
`CANCELLED` exclusion, `JobOperations`/`JobPO` conversion, real SQL
round-trip through H2), plus Java and Python integration tests (`JobIT`,
`tests/integration/test_supports_jobs.py`) running real jobs to
completion/failure/cancellation and asserting
`queuedAt`/`startedAt`/`finishedAt`.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
.../java/org/apache/gravitino/job/JobHandle.java | 21 +++
.../apache/gravitino/client/GenericJobHandle.java | 10 ++
.../apache/gravitino/client/TestSupportsJobs.java | 14 +-
.../gravitino/client/integration/test/JobIT.java | 29 +++-
.../client-python/gravitino/api/job/job_handle.py | 10 ++
.../gravitino/client/generic_job_handle.py | 6 +
clients/client-python/gravitino/dto/job/job_dto.py | 28 ++++
.../tests/integration/test_supports_jobs.py | 24 ++-
.../tests/unittests/dto/job/test_job_dto_serde.py | 31 +++-
.../tests/unittests/test_supports_jobs.py | 13 +-
.../java/org/apache/gravitino/dto/job/JobDTO.java | 15 +-
.../org/apache/gravitino/dto/job/TestJobDTO.java | 28 +++-
.../java/org/apache/gravitino/job/JobManager.java | 67 ++++++---
.../gravitino/listener/api/info/JobInfo.java | 25 ++++
.../java/org/apache/gravitino/meta/JobEntity.java | 33 ++++-
.../provider/base/JobMetaBaseSQLProvider.java | 29 ++--
.../postgresql/JobMetaPostgreSQLProvider.java | 9 +-
.../gravitino/storage/relational/po/JobPO.java | 15 +-
.../org/apache/gravitino/job/TestJobManager.java | 152 +++++++++++++++++++
.../listener/api/event/TestJobEventDispatcher.java | 11 +-
.../gravitino/listener/api/info/TestJobInfo.java | 79 ++++++++++
.../org/apache/gravitino/meta/TestJobEntity.java | 161 ++++++++++++++++++++-
.../relational/TestJDBCBackendBatchGet.java | 2 +
.../gravitino/storage/relational/po/TestJobPO.java | 33 ++++-
.../service/TestEntityChangeLogService.java | 1 +
.../relational/service/TestJobMetaService.java | 5 +-
.../service/TestJobTemplateMetaService.java | 3 +
docs/open-api/jobs.yaml | 16 ++
scripts/h2/schema-2.0.0-h2.sql | 1 +
scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql | 2 +
scripts/mysql/schema-2.0.0-mysql.sql | 1 +
scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql | 3 +
scripts/postgresql/schema-2.0.0-postgresql.sql | 2 +
.../upgrade-1.3.0-to-2.0.0-postgresql.sql | 3 +
.../gravitino/server/web/rest/JobOperations.java | 2 +
.../server/web/rest/TestJobOperations.java | 54 ++++++-
36 files changed, 877 insertions(+), 61 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 f5fa209199..b365ada40a 100644
--- a/api/src/main/java/org/apache/gravitino/job/JobHandle.java
+++ b/api/src/main/java/org/apache/gravitino/job/JobHandle.java
@@ -73,6 +73,27 @@ public interface JobHandle {
*/
Status jobStatus();
+ /**
+ * Get the time when the job was queued for execution.
+ *
+ * @return the queued time of the job
+ */
+ default Instant queuedAt() {
+ throw new UnsupportedOperationException(
+ "queuedAt() is not implemented by " + getClass().getName() + ";
override this method");
+ }
+
+ /**
+ * Get the time when the job started execution.
+ *
+ * @return the started time of the job, or null if the job has not started
execution yet
+ */
+ @Nullable
+ default Instant startedAt() {
+ throw new UnsupportedOperationException(
+ "startedAt() is not implemented by " + getClass().getName() + ";
override this method");
+ }
+
/**
* Get the time when the job finished execution.
*
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 a0ba2bed45..2c2cc829c7 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
@@ -46,6 +46,16 @@ public class GenericJobHandle implements JobHandle {
return jobDTO.status();
}
+ @Override
+ public Instant queuedAt() {
+ return jobDTO.queuedAt();
+ }
+
+ @Override
+ public Instant startedAt() {
+ return jobDTO.startedAt();
+ }
+
@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 f7c9c994aa..4fa6469fa1 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, Instant.now());
+ JobDTO expectedJob = newJobDTO(jobId, jobTemplateName, Instant.now(),
Instant.now());
JobResponse resp = new JobResponse(expectedJob);
buildMockResource(Method.GET, jobRunsPath() + "/" + jobId, null, resp,
HttpStatus.SC_OK);
@@ -340,6 +340,8 @@ 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.queuedAt(), actual.queuedAt());
+ Assertions.assertEquals(expected.startedAt(), actual.startedAt());
Assertions.assertEquals(expected.finishedAt(), actual.finishedAt());
}
@@ -386,11 +388,19 @@ public class TestSupportsJobs extends TestBase {
}
private JobDTO newJobDTO(String jobId, String templateName, Instant
finishedAt) {
+ return newJobDTO(jobId, templateName, null, finishedAt);
+ }
+
+ private JobDTO newJobDTO(
+ String jobId, String templateName, Instant startedAt, Instant
finishedAt) {
+ Instant now = Instant.now();
return new JobDTO(
jobId,
templateName,
JobHandle.Status.QUEUED,
-
AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build(),
+ AuditDTO.builder().withCreator("test").withCreateTime(now).build(),
+ now,
+ startedAt,
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 d523efd90c..5813751113 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,8 @@ 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.assertNotNull(jobHandle1.queuedAt());
+ Assertions.assertNull(jobHandle1.startedAt());
Assertions.assertNull(jobHandle1.finishedAt());
JobHandle jobHandle2 =
@@ -296,6 +298,8 @@ 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.assertNotNull(jobHandle2.queuedAt());
+ Assertions.assertNull(jobHandle2.startedAt());
Assertions.assertNull(jobHandle2.finishedAt());
List<JobHandle> jobs = metalake.listJobs(template.name());
@@ -326,8 +330,14 @@ 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()));
+ // Finished jobs should carry a non-null queuedAt/finishedAt. startedAt is
not asserted here:
+ // a fast job can transition QUEUED -> SUCCEEDED between two polls without
ever being
+ // observed as STARTED, in which case startedAt legitimately stays null.
+ updatedJobs.forEach(
+ job -> {
+ Assertions.assertNotNull(job.queuedAt());
+ Assertions.assertNotNull(job.finishedAt());
+ });
}
@Test
@@ -342,6 +352,8 @@ 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.assertNotNull(jobHandle.queuedAt());
+ Assertions.assertNull(jobHandle.startedAt());
Assertions.assertNull(jobHandle.finishedAt());
Awaitility.await()
@@ -355,6 +367,9 @@ 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.queuedAt());
+ // startedAt is not asserted here: the job may transition QUEUED ->
SUCCEEDED between two
+ // polls without ever being observed as STARTED, in which case it
legitimately stays null.
Assertions.assertNotNull(retrievedJob.finishedAt());
// Test run a failed job
@@ -363,6 +378,8 @@ public class JobIT extends BaseIT {
template.name(),
ImmutableMap.of("arg1", "value1", "arg2", "fail", "env_var",
"value2"));
Assertions.assertEquals(JobHandle.Status.QUEUED,
failedJobHandle.jobStatus());
+ Assertions.assertNotNull(failedJobHandle.queuedAt());
+ Assertions.assertNull(failedJobHandle.startedAt());
Assertions.assertNull(failedJobHandle.finishedAt());
Awaitility.await()
@@ -376,6 +393,9 @@ 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.queuedAt());
+ // startedAt is not asserted here: FAILED does not prove the job ever
started (it can be
+ // reached directly from QUEUED, e.g. if the executor fails to launch the
job at all).
Assertions.assertNotNull(retrievedFailedJob.finishedAt());
// Test get a non-existent job
@@ -394,6 +414,8 @@ 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.assertNotNull(jobHandle.queuedAt());
+ Assertions.assertNull(jobHandle.startedAt());
Assertions.assertNull(jobHandle.finishedAt());
// Cancel the job
@@ -410,6 +432,9 @@ 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.queuedAt());
+ // startedAt is not asserted here: the job may be cancelled before it is
ever observed as
+ // STARTED, in which case startedAt legitimately stays null.
Assertions.assertNotNull(retrievedJob.finishedAt());
// Test cancel a non-existent job
diff --git a/clients/client-python/gravitino/api/job/job_handle.py
b/clients/client-python/gravitino/api/job/job_handle.py
index e57a0bcaf8..1caebc28c5 100644
--- a/clients/client-python/gravitino/api/job/job_handle.py
+++ b/clients/client-python/gravitino/api/job/job_handle.py
@@ -55,6 +55,16 @@ class JobHandle(ABC):
def job_status(self) -> Status:
pass
+ def queued_at(self) -> Optional[datetime]:
+ """Returns the time the job was queued for execution."""
+ raise NotImplementedError("queued_at is not implemented")
+
+ def started_at(self) -> Optional[datetime]:
+ """Returns the time the job started execution, or ``None`` if the job
has not started
+ execution yet.
+ """
+ raise NotImplementedError("started_at is not implemented")
+
def finished_at(self) -> Optional[datetime]:
"""Returns the time the job finished execution, or ``None`` if the job
has not finished
execution yet.
diff --git a/clients/client-python/gravitino/client/generic_job_handle.py
b/clients/client-python/gravitino/client/generic_job_handle.py
index b100867ae8..e5c2a10a82 100644
--- a/clients/client-python/gravitino/client/generic_job_handle.py
+++ b/clients/client-python/gravitino/client/generic_job_handle.py
@@ -33,5 +33,11 @@ class GenericJobHandle(JobHandle):
def job_status(self):
return self._job_dto.status()
+ def queued_at(self):
+ return self._job_dto.queued_at()
+
+ def started_at(self):
+ return self._job_dto.started_at()
+
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 8be68d4681..5b2e6bd187 100644
--- a/clients/client-python/gravitino/dto/job/job_dto.py
+++ b/clients/client-python/gravitino/dto/job/job_dto.py
@@ -43,6 +43,22 @@ class JobDTO(DataClassJsonMixin):
)
)
_audit: AuditDTO = field(metadata=config(field_name="audit"))
+ _queued_at: Optional[datetime] = field(
+ default=None,
+ metadata=config(
+ field_name="queuedAt",
+ encoder=_serialize_datetime,
+ decoder=_deserialize_datetime,
+ ),
+ )
+ _started_at: Optional[datetime] = field(
+ default=None,
+ metadata=config(
+ field_name="startedAt",
+ encoder=_serialize_datetime,
+ decoder=_deserialize_datetime,
+ ),
+ )
_finished_at: Optional[datetime] = field(
default=None,
metadata=config(
@@ -53,6 +69,8 @@ class JobDTO(DataClassJsonMixin):
)
def __post_init__(self) -> None:
+ self._queued_at = _deserialize_datetime(self._queued_at)
+ self._started_at = _deserialize_datetime(self._started_at)
self._finished_at = _deserialize_datetime(self._finished_at)
def job_id(self) -> str:
@@ -71,6 +89,16 @@ class JobDTO(DataClassJsonMixin):
"""Returns the audit information of the job."""
return self._audit
+ def queued_at(self) -> Optional[datetime]:
+ """Returns the time the job was queued for execution."""
+ return self._queued_at
+
+ def started_at(self) -> Optional[datetime]:
+ """Returns the time the job started execution, or ``None`` if the job
has not started
+ execution yet.
+ """
+ return self._started_at
+
def finished_at(self) -> Optional[datetime]:
"""Returns the time the job finished execution, or ``None`` if the job
has not finished
execution yet.
diff --git a/clients/client-python/tests/integration/test_supports_jobs.py
b/clients/client-python/tests/integration/test_supports_jobs.py
index a8e3fc56e2..f558bebdb2 100644
--- a/clients/client-python/tests/integration/test_supports_jobs.py
+++ b/clients/client-python/tests/integration/test_supports_jobs.py
@@ -273,6 +273,8 @@ class TestSupportsJobs(IntegrationTestEnv):
)
self.assertEqual(job_handle1.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle1.job_template_name(), template.name)
+ self.assertIsNotNone(job_handle1.queued_at())
+ self.assertIsNone(job_handle1.started_at())
self.assertIsNone(job_handle1.finished_at())
job_handle2 = self._metalake.run_job(
@@ -280,6 +282,8 @@ class TestSupportsJobs(IntegrationTestEnv):
)
self.assertEqual(job_handle2.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle2.job_template_name(), template.name)
+ self.assertIsNotNone(job_handle2.queued_at())
+ self.assertIsNone(job_handle2.started_at())
self.assertIsNone(job_handle2.finished_at())
# List jobs
@@ -307,8 +311,11 @@ 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.
+ # Finished jobs should carry a non-None queued_at/finished_at.
started_at is not
+ # asserted here: a fast job can transition QUEUED -> SUCCEEDED between
two polls
+ # without ever being observed as STARTED, in which case it
legitimately stays None.
for job in updated_jobs:
+ self.assertIsNotNone(job.queued_at())
self.assertIsNotNone(job.finished_at())
def test_run_and_get_job(self):
@@ -321,6 +328,8 @@ class TestSupportsJobs(IntegrationTestEnv):
)
self.assertEqual(job_handle.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle.job_template_name(), template.name)
+ self.assertIsNotNone(job_handle.queued_at())
+ self.assertIsNone(job_handle.started_at())
self.assertIsNone(job_handle.finished_at())
# Wait for job to complete
@@ -332,6 +341,9 @@ 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.queued_at())
+ # started_at is not asserted here: the job may transition QUEUED ->
SUCCEEDED between
+ # two polls without ever being observed as STARTED, in which case it
stays None.
self.assertIsNotNone(retrieved_job.finished_at())
# Test failed job
@@ -339,6 +351,8 @@ class TestSupportsJobs(IntegrationTestEnv):
template.name, {"arg1": "value1", "arg2": "fail", "env_var":
"value2"}
)
self.assertEqual(failed_job_handle.job_status(),
JobHandle.Status.QUEUED)
+ self.assertIsNotNone(failed_job_handle.queued_at())
+ self.assertIsNone(failed_job_handle.started_at())
self.assertIsNone(failed_job_handle.finished_at())
self._wait_until(
@@ -349,6 +363,9 @@ 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.queued_at())
+ # started_at is not asserted here: FAILED does not prove the job ever
started (it can
+ # be reached directly from QUEUED, e.g. if the executor fails to
launch the job).
self.assertIsNotNone(retrieved_failed_job.finished_at())
# Test non-existent job
@@ -365,6 +382,8 @@ class TestSupportsJobs(IntegrationTestEnv):
)
self.assertEqual(job_handle.job_status(), JobHandle.Status.QUEUED)
self.assertEqual(job_handle.job_template_name(), template.name)
+ self.assertIsNotNone(job_handle.queued_at())
+ self.assertIsNone(job_handle.started_at())
self.assertIsNone(job_handle.finished_at())
sleep(1)
@@ -381,6 +400,9 @@ 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.queued_at())
+ # started_at is not asserted here: the job may be cancelled before it
is ever
+ # observed as STARTED, in which case started_at legitimately stays
None.
self.assertIsNotNone(retrieved_job.finished_at())
# Test cancel non-existent job
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
index 8db836189d..ab39812b9e 100644
--- 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
@@ -25,33 +25,45 @@ from gravitino.dto.job.job_dto import JobDTO
class TestJobDTOSerDe(unittest.TestCase):
def test_ser_de_with_finished_at(self):
+ queued_at = datetime.now(timezone.utc)
+ started_at = datetime.now(timezone.utc)
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)),
+ _queued_at=queued_at,
+ _started_at=started_at,
_finished_at=finished_at,
)
json_str = job_dto.to_json()
+ self.assertIn("queuedAt", json_str)
+ self.assertIn("startedAt", json_str)
self.assertIn("finishedAt", json_str)
deser_job_dto = JobDTO.from_json(json_str)
self.assertEqual(job_dto, deser_job_dto)
+ self.assertEqual(queued_at, deser_job_dto.queued_at())
+ self.assertEqual(started_at, deser_job_dto.started_at())
self.assertEqual(finished_at, deser_job_dto.finished_at())
- def test_ser_de_with_none_finished_at(self):
+ def test_ser_de_with_none_started_and_finished_at(self):
+ queued_at = datetime.now(timezone.utc)
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)),
+ _queued_at=queued_at,
)
json_str = job_dto.to_json()
deser_job_dto = JobDTO.from_json(json_str)
self.assertEqual(job_dto, deser_job_dto)
+ self.assertEqual(queued_at, deser_job_dto.queued_at())
+ self.assertIsNone(deser_job_dto.started_at())
self.assertIsNone(deser_job_dto.finished_at())
def test_deserialize_from_string(self):
@@ -59,6 +71,8 @@ class TestJobDTOSerDe(unittest.TestCase):
'{"jobId": "job-789", "jobTemplateName": "test_template", '
'"status": "failed", '
'"audit": {"creator": "test", "createTime":
"2024-01-01T00:00:00Z"}, '
+ '"queuedAt": "2024-01-01T00:00:00Z", '
+ '"startedAt": "2024-01-01T00:30:00Z", '
'"finishedAt": "2024-01-01T01:00:00Z"}'
)
@@ -67,18 +81,29 @@ class TestJobDTOSerDe(unittest.TestCase):
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, 0, 0, 0, tzinfo=timezone.utc),
job_dto.queued_at()
+ )
+ self.assertEqual(
+ datetime(2024, 1, 1, 0, 30, 0, tzinfo=timezone.utc),
job_dto.started_at()
+ )
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):
+ def test_deserialize_from_string_without_started_or_finished_at(self):
json_str = (
'{"jobId": "job-1000", "jobTemplateName": "test_template", '
'"status": "queued", '
- '"audit": {"creator": "test", "createTime":
"2024-01-01T00:00:00Z"}}'
+ '"audit": {"creator": "test", "createTime":
"2024-01-01T00:00:00Z"}, '
+ '"queuedAt": "2024-01-01T00:00:00Z"}'
)
job_dto = JobDTO.from_json(json_str, infer_missing=True)
self.assertEqual("job-1000", job_dto.job_id())
+ self.assertEqual(
+ datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
job_dto.queued_at()
+ )
+ self.assertIsNone(job_dto.started_at())
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 26458c32c1..0ca6367490 100644
--- a/clients/client-python/tests/unittests/test_supports_jobs.py
+++ b/clients/client-python/tests/unittests/test_supports_jobs.py
@@ -208,7 +208,9 @@ class TestSupportsJobs(unittest.TestCase):
job_template_name = "test_shell_job"
job_dto = self._new_job_dto(
- job_template_name, finished_at=datetime.now(timezone.utc)
+ job_template_name,
+ finished_at=datetime.now(timezone.utc),
+ started_at=datetime.now(timezone.utc),
)
resp = JobResponse(_job=job_dto, _code=0)
mock_resp = self._mock_http_response(resp.to_json())
@@ -315,13 +317,18 @@ class TestSupportsJobs(unittest.TestCase):
return mock_resp
def _new_job_dto(
- self, job_template_name: str, finished_at: Optional[datetime] = None
+ self,
+ job_template_name: str,
+ finished_at: Optional[datetime] = None,
+ started_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"),
+ _queued_at=datetime(2023, 10, 1, tzinfo=timezone.utc),
+ _started_at=started_at,
_finished_at=finished_at,
)
@@ -329,4 +336,6 @@ class TestSupportsJobs(unittest.TestCase):
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.queued_at(), job_dto.queued_at())
+ self.assertEqual(job_handle.started_at(), job_dto.started_at())
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 d8af081a47..3c810a0866 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
@@ -59,12 +59,18 @@ public class JobDTO {
@JsonProperty("audit")
private final AuditDTO audit;
+ @JsonProperty("queuedAt")
+ private final Instant queuedAt;
+
+ @JsonProperty("startedAt")
+ private final Instant startedAt;
+
@JsonProperty("finishedAt")
private final Instant finishedAt;
/** Default constructor for Jackson deserialization. */
private JobDTO() {
- this(null, null, null, null, null);
+ this(null, null, null, null, null, null, null);
}
/**
@@ -74,6 +80,9 @@ 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 queuedAt The time when the job was queued for execution.
+ * @param startedAt The time when the job started execution, or null if the
job has not started
+ * execution yet.
* @param finishedAt The time when the job finished execution, or null if
the job has not finished
* execution yet.
*/
@@ -82,11 +91,15 @@ public class JobDTO {
String jobTemplateName,
JobHandle.Status status,
AuditDTO audit,
+ Instant queuedAt,
+ Instant startedAt,
Instant finishedAt) {
this.jobId = jobId;
this.jobTemplateName = jobTemplateName;
this.status = status;
this.audit = audit;
+ this.queuedAt = queuedAt;
+ this.startedAt = startedAt;
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
index b422f96828..bf5e78fa45 100644
--- a/common/src/test/java/org/apache/gravitino/dto/job/TestJobDTO.java
+++ b/common/src/test/java/org/apache/gravitino/dto/job/TestJobDTO.java
@@ -30,6 +30,8 @@ public class TestJobDTO {
@Test
public void testSerDeWithFinishedAt() throws JsonProcessingException {
+ Instant queuedAt = Instant.now();
+ Instant startedAt = Instant.now();
Instant finishedAt = Instant.now();
JobDTO jobDTO =
new JobDTO(
@@ -37,26 +39,35 @@ public class TestJobDTO {
"testTemplate",
JobHandle.Status.SUCCEEDED,
AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build(),
+ queuedAt,
+ startedAt,
finishedAt);
Assertions.assertDoesNotThrow(jobDTO::validate);
String serJson = JsonUtils.objectMapper().writeValueAsString(jobDTO);
+ Assertions.assertTrue(serJson.contains("\"queuedAt\""));
+ Assertions.assertTrue(serJson.contains("\"startedAt\""));
Assertions.assertTrue(serJson.contains("\"finishedAt\""));
JobDTO deserJobDTO = JsonUtils.objectMapper().readValue(serJson,
JobDTO.class);
Assertions.assertEquals(jobDTO, deserJobDTO);
+ Assertions.assertEquals(queuedAt, deserJobDTO.queuedAt());
+ Assertions.assertEquals(startedAt, deserJobDTO.startedAt());
Assertions.assertEquals(finishedAt, deserJobDTO.finishedAt());
}
@Test
public void testSerDeWithNullFinishedAt() throws JsonProcessingException {
+ Instant queuedAt = Instant.now();
JobDTO jobDTO =
new JobDTO(
"job-456",
"testTemplate",
JobHandle.Status.QUEUED,
AuditDTO.builder().withCreator("test").withCreateTime(Instant.now()).build(),
+ queuedAt,
+ null,
null);
Assertions.assertDoesNotThrow(jobDTO::validate);
@@ -64,12 +75,16 @@ public class TestJobDTO {
String serJson = JsonUtils.objectMapper().writeValueAsString(jobDTO);
JobDTO deserJobDTO = JsonUtils.objectMapper().readValue(serJson,
JobDTO.class);
Assertions.assertEquals(jobDTO, deserJobDTO);
+ Assertions.assertEquals(queuedAt, deserJobDTO.queuedAt());
+ Assertions.assertNull(deserJobDTO.startedAt());
Assertions.assertNull(deserJobDTO.finishedAt());
}
@Test
public void testSerializeToString() throws JsonProcessingException {
Instant createTime = Instant.parse("2024-01-01T00:00:00Z");
+ Instant queuedAt = Instant.parse("2024-01-01T00:00:00Z");
+ Instant startedAt = Instant.parse("2024-01-01T00:30:00Z");
Instant finishedAt = Instant.parse("2024-01-01T01:00:00Z");
JobDTO jobDTO =
new JobDTO(
@@ -77,6 +92,8 @@ public class TestJobDTO {
"testTemplate",
JobHandle.Status.FAILED,
AuditDTO.builder().withCreator("test").withCreateTime(createTime).build(),
+ queuedAt,
+ startedAt,
finishedAt);
String serJson = JsonUtils.objectMapper().writeValueAsString(jobDTO);
@@ -84,6 +101,8 @@ public class TestJobDTO {
Assertions.assertTrue(serJson.contains("\"jobId\":\"job-789\""));
Assertions.assertTrue(serJson.contains("\"jobTemplateName\":\"testTemplate\""));
Assertions.assertTrue(serJson.contains("\"status\":\"failed\""));
+
Assertions.assertTrue(serJson.contains("\"queuedAt\":\"2024-01-01T00:00:00Z\""));
+
Assertions.assertTrue(serJson.contains("\"startedAt\":\"2024-01-01T00:30:00Z\""));
Assertions.assertTrue(serJson.contains("\"finishedAt\":\"2024-01-01T01:00:00Z\""));
}
@@ -95,6 +114,8 @@ public class TestJobDTO {
+ "\"jobTemplateName\":\"testTemplate\","
+ "\"status\":\"succeeded\","
+
"\"audit\":{\"creator\":\"test\",\"createTime\":\"2024-01-01T00:00:00Z\"},"
+ + "\"queuedAt\":\"2024-01-01T00:00:00Z\","
+ + "\"startedAt\":\"2024-01-01T00:30:00Z\","
+ "\"finishedAt\":\"2024-01-01T01:00:00Z\""
+ "}";
@@ -103,6 +124,8 @@ public class TestJobDTO {
Assertions.assertEquals("job-999", jobDTO.jobId());
Assertions.assertEquals("testTemplate", jobDTO.jobTemplateName());
Assertions.assertEquals(JobHandle.Status.SUCCEEDED, jobDTO.status());
+ Assertions.assertEquals(Instant.parse("2024-01-01T00:00:00Z"),
jobDTO.queuedAt());
+ Assertions.assertEquals(Instant.parse("2024-01-01T00:30:00Z"),
jobDTO.startedAt());
Assertions.assertEquals(Instant.parse("2024-01-01T01:00:00Z"),
jobDTO.finishedAt());
}
@@ -113,12 +136,15 @@ public class TestJobDTO {
+ "\"jobId\":\"job-1000\","
+ "\"jobTemplateName\":\"testTemplate\","
+ "\"status\":\"queued\","
- +
"\"audit\":{\"creator\":\"test\",\"createTime\":\"2024-01-01T00:00:00Z\"}"
+ +
"\"audit\":{\"creator\":\"test\",\"createTime\":\"2024-01-01T00:00:00Z\"},"
+ + "\"queuedAt\":\"2024-01-01T00:00:00Z\""
+ "}";
JobDTO jobDTO = JsonUtils.objectMapper().readValue(json, JobDTO.class);
Assertions.assertEquals("job-1000", jobDTO.jobId());
+ Assertions.assertEquals(Instant.parse("2024-01-01T00:00:00Z"),
jobDTO.queuedAt());
+ Assertions.assertNull(jobDTO.startedAt());
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 299551dee2..aacb39a370 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -465,7 +465,8 @@ public class JobManager implements JobOperationDispatcher {
.withCreator(PrincipalUtils.getCurrentPrincipal().getName())
.withCreateTime(Instant.now())
.build())
- // A newly submitted job is queued, not finished yet.
+ // A newly submitted job is queued, not started or finished yet.
+ .withStartedAt(0L)
.withFinishedAt(0L)
.build();
@@ -503,34 +504,42 @@ public class JobManager implements JobOperationDispatcher
{
}
// Update the job status to CANCELING
- JobEntity newJobEntity =
- JobEntity.builder()
- .withId(jobEntity.id())
- .withJobExecutionId(jobEntity.jobExecutionId())
- .withJobTemplateName(jobEntity.jobTemplateName())
- .withStatus(JobHandle.Status.CANCELLING)
- .withNamespace(jobEntity.namespace())
- .withAuditInfo(
- AuditInfo.builder()
- .withCreator(jobEntity.auditInfo().creator())
- .withCreateTime(jobEntity.auditInfo().createTime())
-
.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),
LockType.WRITE,
() -> {
try {
+ // Re-fetch under the lock rather than reusing the snapshot taken
before the
+ // (potentially slow) external cancel call above - a concurrent
status poll could
+ // have persisted a real startedAt/finishedAt in that gap, and
carrying forward the
+ // stale snapshot would clobber it back to the sentinel.
+ JobEntity latestJobEntity = getJob(metalake, jobId);
+ JobEntity newJobEntity =
+ JobEntity.builder()
+ .withId(latestJobEntity.id())
+ .withJobExecutionId(latestJobEntity.jobExecutionId())
+ .withJobTemplateName(latestJobEntity.jobTemplateName())
+ .withStatus(JobHandle.Status.CANCELLING)
+ .withNamespace(latestJobEntity.namespace())
+ .withAuditInfo(
+ AuditInfo.builder()
+ .withCreator(latestJobEntity.auditInfo().creator())
+
.withCreateTime(latestJobEntity.auditInfo().createTime())
+
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
+ .withLastModifiedTime(Instant.now())
+ .build())
+ // CANCELLING is not a terminal state; carry forward
whatever
+ // startedAt/finishedAt the job already had.
+ .withStartedAt(latestJobEntity.startedAt())
+ .withFinishedAt(latestJobEntity.finishedAt())
+ .build();
+
// Update the job entity in the entity store
entityStore.put(newJobEntity, true /* overwrite */);
return newJobEntity;
} catch (IOException e) {
throw new RuntimeException(
- String.format("Failed to update job entity %s to CANCELING
status", newJobEntity),
+ String.format("Failed to update job entity for job %s to
CANCELING status", jobId),
e);
}
});
@@ -592,11 +601,30 @@ public class JobManager implements JobOperationDispatcher
{
}
if (newStatus != job.status()) {
+ boolean isStarted = newStatus == JobHandle.Status.STARTED;
boolean isFinished =
newStatus == JobHandle.Status.SUCCEEDED
|| newStatus == JobHandle.Status.FAILED
|| newStatus == JobHandle.Status.CANCELLED;
+ // Only a directly-observed STARTED transition is trustworthy
evidence of when a
+ // job started. SUCCEEDED/FAILED do not prove the job ever
reached STARTED: FAILED
+ // in particular can be reached directly from QUEUED (e.g.
NoSuchJobException from
+ // the executor, or LocalJobExecutor failing before it records
STARTED), and even
+ // for SUCCEEDED, backfilling startedAt from the queued time
would understate queue
+ // latency and overstate execution duration in any derived
metric. So startedAt is
+ // left unset unless a STARTED transition was actually observed.
+ //
+ // Only stamp startedAt on the first STARTED observation
(job.startedAt() <= 0).
+ // A CANCELLING job already carries forward a real startedAt
from cancelJob, and
+ // since cancellation is asynchronous, a poll can still observe
STARTED while
+ // cancellation is in flight - overwriting the recorded start
time with this later
+ // poll timestamp would lose the accurate value.
+ long startedAt =
+ isStarted && job.startedAt() <= 0
+ ? Instant.now().toEpochMilli()
+ : job.startedAt();
+
JobEntity newJobEntity =
JobEntity.builder()
.withId(job.id())
@@ -611,6 +639,7 @@ public class JobManager implements JobOperationDispatcher {
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
.withLastModifiedTime(Instant.now())
.build())
+ .withStartedAt(startedAt)
.withFinishedAt(isFinished ?
Instant.now().toEpochMilli() : job.finishedAt())
.build();
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 5abe692982..121c1b0112 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
@@ -40,6 +40,8 @@ public final class JobInfo {
private final Audit audit;
+ private final Instant startedAt;
+
private final Instant finishedAt;
private JobInfo(
@@ -47,11 +49,13 @@ public final class JobInfo {
String jobTemplateName,
JobHandle.Status jobStatus,
Audit audit,
+ Instant startedAt,
Instant finishedAt) {
this.jobId = jobId;
this.jobTemplateName = jobTemplateName;
this.jobStatus = jobStatus;
this.audit = audit;
+ this.startedAt = startedAt;
this.finishedAt = finishedAt;
}
@@ -67,6 +71,7 @@ public final class JobInfo {
jobEntity.jobTemplateName(),
jobEntity.status(),
jobEntity.auditInfo(),
+ jobEntity.startedAtAsInstant(),
jobEntity.finishedAtAsInstant());
}
@@ -106,6 +111,26 @@ public final class JobInfo {
return audit;
}
+ /**
+ * Returns the time when the job was queued for execution. This is the same
as the job's creation
+ * time.
+ *
+ * @return the queued time of the job
+ */
+ public Instant queuedAt() {
+ return audit.createTime();
+ }
+
+ /**
+ * Returns the time when the job started execution.
+ *
+ * @return the started time of the job, or null if the job has not started
execution yet
+ */
+ @Nullable
+ public Instant startedAt() {
+ return startedAt;
+ }
+
/**
* Returns the time when the job finished execution.
*
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 604de3c72e..c7c2a0bec5 100644
--- a/core/src/main/java/org/apache/gravitino/meta/JobEntity.java
+++ b/core/src/main/java/org/apache/gravitino/meta/JobEntity.java
@@ -50,6 +50,12 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
public static final Field AUDIT_INFO =
Field.required(
"audit_info", AuditInfo.class, "The audit details of the job
template entity.");
+ public static final Field STARTED_AT =
+ Field.required(
+ "job_started_at",
+ Long.class,
+ "The time when the job started execution, using the storage layer's "
+ + "\"not started\" sentinel (<= 0) when the job has not started
execution yet.");
public static final Field FINISHED_AT =
Field.required(
"job_finished_at",
@@ -63,6 +69,7 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
private String jobTemplateName;
private Namespace namespace;
private AuditInfo auditInfo;
+ private Long startedAt;
private Long finishedAt;
private JobEntity() {}
@@ -75,6 +82,7 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
fields.put(TEMPLATE_NAME, jobTemplateName);
fields.put(STATUS, status);
fields.put(AUDIT_INFO, auditInfo);
+ fields.put(STARTED_AT, startedAt);
fields.put(FINISHED_AT, finishedAt);
return Collections.unmodifiableMap(fields);
}
@@ -106,6 +114,23 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
return jobTemplateName;
}
+ public Long startedAt() {
+ return startedAt;
+ }
+
+ /**
+ * Converts the raw {@code startedAt} epoch-millis value to an {@link
Instant}, treating {@code
+ * null} or a non-positive value (the "not started" sentinel used by the
storage layer) as {@code
+ * null}.
+ *
+ * @return the {@link Instant} the job started execution, or {@code null} if
the job has not
+ * started execution yet
+ */
+ @Nullable
+ public Instant startedAtAsInstant() {
+ return (startedAt == null || startedAt <= 0) ? null :
Instant.ofEpochMilli(startedAt);
+ }
+
public Long finishedAt() {
return finishedAt;
}
@@ -149,13 +174,14 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
&& Objects.equals(jobTemplateName, that.jobTemplateName)
&& Objects.equals(namespace, that.namespace)
&& Objects.equals(auditInfo, that.auditInfo)
+ && Objects.equals(startedAt, that.startedAt)
&& Objects.equals(finishedAt, that.finishedAt);
}
@Override
public int hashCode() {
return Objects.hash(
- id, jobExecutionId, namespace, status, jobTemplateName, auditInfo,
finishedAt);
+ id, jobExecutionId, namespace, status, jobTemplateName, auditInfo,
startedAt, finishedAt);
}
public static Builder builder() {
@@ -199,6 +225,11 @@ public class JobEntity implements Entity, Auditable,
HasIdentifier {
return this;
}
+ public Builder withStartedAt(Long startedAt) {
+ jobEntity.startedAt = startedAt;
+ return this;
+ }
+
public Builder withFinishedAt(Long finishedAt) {
jobEntity.finishedAt = finishedAt;
return this;
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobMetaBaseSQLProvider.java
index 054c093311..5babf351fd 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobMetaBaseSQLProvider.java
@@ -31,16 +31,16 @@ public class JobMetaBaseSQLProvider {
return "INSERT INTO "
+ JobMetaMapper.TABLE_NAME
+ " (job_run_id, job_template_id, metalake_id,"
- + " job_execution_id, job_run_status, job_finished_at, audit_info,
current_version,"
- + " last_version, deleted_at)"
+ + " job_execution_id, job_run_status, job_started_at, job_finished_at,
audit_info,"
+ + " current_version, last_version, deleted_at)"
+ " VALUES (#{jobMeta.jobRunId},"
+ " (SELECT job_template_id FROM "
+ JobTemplateMetaMapper.TABLE_NAME
+ " WHERE job_template_name = #{jobMeta.jobTemplateName}"
+ " AND metalake_id = #{jobMeta.metalakeId} AND deleted_at = 0),"
+ " #{jobMeta.metalakeId}, #{jobMeta.jobExecutionId},"
- + " #{jobMeta.jobRunStatus}, #{jobMeta.jobFinishedAt},
#{jobMeta.auditInfo},"
- + " #{jobMeta.currentVersion}, #{jobMeta.lastVersion},"
+ + " #{jobMeta.jobRunStatus}, #{jobMeta.jobStartedAt},
#{jobMeta.jobFinishedAt},"
+ + " #{jobMeta.auditInfo}, #{jobMeta.currentVersion},
#{jobMeta.lastVersion},"
+ " #{jobMeta.deletedAt})";
}
@@ -48,16 +48,16 @@ public class JobMetaBaseSQLProvider {
return "INSERT INTO "
+ JobMetaMapper.TABLE_NAME
+ " (job_run_id, job_template_id, metalake_id,"
- + " job_execution_id, job_run_status, job_finished_at, audit_info,
current_version,"
- + " last_version, deleted_at)"
+ + " job_execution_id, job_run_status, job_started_at, job_finished_at,
audit_info,"
+ + " current_version, last_version, deleted_at)"
+ " VALUES (#{jobMeta.jobRunId},"
+ " (SELECT job_template_id FROM "
+ JobTemplateMetaMapper.TABLE_NAME
+ " WHERE job_template_name = #{jobMeta.jobTemplateName}"
+ " AND metalake_id = #{jobMeta.metalakeId} AND deleted_at = 0),"
+ " #{jobMeta.metalakeId}, #{jobMeta.jobExecutionId},"
- + " #{jobMeta.jobRunStatus}, #{jobMeta.jobFinishedAt},
#{jobMeta.auditInfo},"
- + " #{jobMeta.currentVersion}, #{jobMeta.lastVersion},"
+ + " #{jobMeta.jobRunStatus}, #{jobMeta.jobStartedAt},
#{jobMeta.jobFinishedAt},"
+ + " #{jobMeta.auditInfo}, #{jobMeta.currentVersion},
#{jobMeta.lastVersion},"
+ " #{jobMeta.deletedAt})"
+ " ON DUPLICATE KEY UPDATE"
+ " job_template_id = (SELECT job_template_id FROM "
@@ -67,6 +67,7 @@ public class JobMetaBaseSQLProvider {
+ " metalake_id = #{jobMeta.metalakeId},"
+ " job_execution_id = #{jobMeta.jobExecutionId},"
+ " job_run_status = #{jobMeta.jobRunStatus},"
+ + " job_started_at = #{jobMeta.jobStartedAt},"
+ " job_finished_at = #{jobMeta.jobFinishedAt},"
+ " audit_info = #{jobMeta.auditInfo},"
+ " current_version = #{jobMeta.currentVersion},"
@@ -77,7 +78,8 @@ public class JobMetaBaseSQLProvider {
public String listJobPOsByMetalake(@Param("metalakeName") String
metalakeName) {
return "SELECT jrm.job_run_id AS jobRunId, jtm.job_template_name AS
jobTemplateName,"
+ " jrm.metalake_id AS metalakeId, jrm.job_execution_id AS
jobExecutionId,"
- + " jrm.job_run_status AS jobRunStatus, jrm.job_finished_at AS
jobFinishedAt,"
+ + " jrm.job_run_status AS jobRunStatus, jrm.job_started_at AS
jobStartedAt,"
+ + " jrm.job_finished_at AS jobFinishedAt,"
+ " jrm.audit_info AS auditInfo,"
+ " jrm.current_version AS currentVersion, jrm.last_version AS
lastVersion,"
+ " jrm.deleted_at AS deletedAt"
@@ -98,7 +100,8 @@ public class JobMetaBaseSQLProvider {
@Param("jobTemplateName") String jobTemplateName) {
return "SELECT jrm.job_run_id AS jobRunId, jtm.job_template_name AS
jobTemplateName,"
+ " jrm.metalake_id AS metalakeId, jrm.job_execution_id AS
jobExecutionId,"
- + " jrm.job_run_status AS jobRunStatus, jrm.job_finished_at AS
jobFinishedAt,"
+ + " jrm.job_run_status AS jobRunStatus, jrm.job_started_at AS
jobStartedAt,"
+ + " jrm.job_finished_at AS jobFinishedAt,"
+ " jrm.audit_info AS auditInfo,"
+ " jrm.current_version AS currentVersion, jrm.last_version AS
lastVersion,"
+ " jrm.deleted_at AS deletedAt"
@@ -118,7 +121,8 @@ public class JobMetaBaseSQLProvider {
@Param("metalakeName") String metalakeName, @Param("jobRunId") Long
jobRunId) {
return "SELECT jrm.job_run_id AS jobRunId, jtm.job_template_name AS
jobTemplateName,"
+ " jrm.metalake_id AS metalakeId, jrm.job_execution_id AS
jobExecutionId,"
- + " jrm.job_run_status AS jobRunStatus, jrm.job_finished_at AS
jobFinishedAt,"
+ + " jrm.job_run_status AS jobRunStatus, jrm.job_started_at AS
jobStartedAt,"
+ + " jrm.job_finished_at AS jobFinishedAt,"
+ " jrm.audit_info AS auditInfo,"
+ " jrm.current_version AS currentVersion, jrm.last_version AS
lastVersion,"
+ " jrm.deleted_at AS deletedAt"
@@ -188,7 +192,8 @@ public class JobMetaBaseSQLProvider {
return "<script>"
+ "SELECT jrm.job_run_id AS jobRunId, jtm.job_template_name AS
jobTemplateName,"
+ " jrm.metalake_id AS metalakeId, jrm.job_execution_id AS
jobExecutionId,"
- + " jrm.job_run_status AS jobRunStatus, jrm.job_finished_at AS
jobFinishedAt,"
+ + " jrm.job_run_status AS jobRunStatus, jrm.job_started_at AS
jobStartedAt,"
+ + " jrm.job_finished_at AS jobFinishedAt,"
+ " jrm.audit_info AS auditInfo,"
+ " jrm.current_version AS currentVersion, jrm.last_version AS
lastVersion,"
+ " jrm.deleted_at AS deletedAt"
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
index 1addd89e54..ffe4ca8ea8 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
@@ -32,16 +32,16 @@ public class JobMetaPostgreSQLProvider extends
JobMetaBaseSQLProvider {
return "INSERT INTO "
+ JobMetaMapper.TABLE_NAME
+ " (job_run_id, job_template_id, metalake_id,"
- + " job_execution_id, job_run_status, job_finished_at, audit_info,
current_version,"
- + " last_version, deleted_at)"
+ + " job_execution_id, job_run_status, job_started_at, job_finished_at,
audit_info,"
+ + " current_version, last_version, deleted_at)"
+ " VALUES (#{jobMeta.jobRunId},"
+ " (SELECT job_template_id FROM "
+ JobTemplateMetaMapper.TABLE_NAME
+ " WHERE job_template_name = #{jobMeta.jobTemplateName}"
+ " AND metalake_id = #{jobMeta.metalakeId} AND deleted_at = 0),"
+ " #{jobMeta.metalakeId}, #{jobMeta.jobExecutionId},"
- + " #{jobMeta.jobRunStatus}, #{jobMeta.jobFinishedAt},
#{jobMeta.auditInfo},"
- + " #{jobMeta.currentVersion}, #{jobMeta.lastVersion},"
+ + " #{jobMeta.jobRunStatus}, #{jobMeta.jobStartedAt},
#{jobMeta.jobFinishedAt},"
+ + " #{jobMeta.auditInfo}, #{jobMeta.currentVersion},
#{jobMeta.lastVersion},"
+ " #{jobMeta.deletedAt})"
+ " ON CONFLICT (job_run_id) DO UPDATE SET"
+ " job_template_id = (SELECT job_template_id FROM "
@@ -51,6 +51,7 @@ public class JobMetaPostgreSQLProvider extends
JobMetaBaseSQLProvider {
+ " metalake_id = #{jobMeta.metalakeId},"
+ " job_execution_id = #{jobMeta.jobExecutionId},"
+ " job_run_status = #{jobMeta.jobRunStatus},"
+ + " job_started_at = #{jobMeta.jobStartedAt},"
+ " job_finished_at = #{jobMeta.jobFinishedAt},"
+ " audit_info = #{jobMeta.auditInfo},"
+ " current_version = #{jobMeta.currentVersion},"
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 a3c0db8a9b..c5db26071e 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
@@ -45,6 +45,7 @@ public class JobPO {
private Long metalakeId;
private String jobExecutionId;
private String jobRunStatus;
+ private Long jobStartedAt;
private Long jobFinishedAt;
private String auditInfo;
private Long currentVersion;
@@ -62,6 +63,7 @@ public class JobPO {
Long metalakeId,
String jobExecutionId,
String jobRunStatus,
+ Long jobStartedAt,
Long jobFinishedAt,
String auditInfo,
Long currentVersion,
@@ -75,6 +77,7 @@ public class JobPO {
StringUtils.isNotBlank(jobExecutionId), "jobExecutionId cannot be
blank");
Preconditions.checkArgument(
StringUtils.isNotBlank(jobRunStatus), "jobRunStatus cannot be blank");
+ Preconditions.checkArgument(jobStartedAt != null, "jobStartedAt cannot be
null");
Preconditions.checkArgument(jobFinishedAt != null, "jobFinishedAt cannot
be null");
Preconditions.checkArgument(StringUtils.isNotBlank(auditInfo), "auditInfo
cannot be blank");
Preconditions.checkArgument(currentVersion != null, "currentVersion cannot
be null");
@@ -86,6 +89,7 @@ public class JobPO {
this.metalakeId = metalakeId;
this.jobExecutionId = jobExecutionId;
this.jobRunStatus = jobRunStatus;
+ this.jobStartedAt = jobStartedAt;
this.jobFinishedAt = jobFinishedAt;
this.auditInfo = auditInfo;
this.currentVersion = currentVersion;
@@ -99,16 +103,18 @@ public class JobPO {
}
public static JobPO initializeJobPO(JobEntity jobEntity, JobPOBuilder
builder) {
- // 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.
+ // startedAt/finishedAt are required fields on JobEntity - the caller
(e.g. JobManager, when
+ // the job transitions to STARTED/a terminal state) is guaranteed to have
already set them,
+ // using the storage layer's "not started"/"not finished" sentinel (<= 0)
otherwise. The
+ // entity GC cleaner relies on the finishedAt 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())
+ .withJobStartedAt(jobEntity.startedAt())
.withJobFinishedAt(jobEntity.finishedAt())
.withAuditInfo(JsonUtils.anyFieldMapper().writeValueAsString(jobEntity.auditInfo()))
.withCurrentVersion(INIT_VERSION)
@@ -129,6 +135,7 @@ public class JobPO {
.withStatus(JobHandle.Status.valueOf(jobPO.jobRunStatus))
.withJobTemplateName(jobPO.jobTemplateName)
.withAuditInfo(JsonUtils.anyFieldMapper().readValue(jobPO.auditInfo,
AuditInfo.class))
+ .withStartedAt(jobPO.jobStartedAt())
.withFinishedAt(jobPO.jobFinishedAt())
.build();
} catch (JsonProcessingException e) {
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 4665a3320e..9b5ec6e27b 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -644,6 +644,157 @@ public class TestJobManager {
Assertions.assertTrue(updatedJob.finishedAt() > 0);
}
+ @Test
+ public void testPullJobStatusStartedAt() throws IOException {
+ JobEntity job =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withNamespace(NamespaceUtil.ofJob(metalake))
+ .withJobTemplateName("shell_job")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .build();
+
+ BaseMetalake mockMetalake =
+ BaseMetalake.builder()
+ .withName(metalake)
+ .withId(idGenerator.nextId())
+ .withVersion(SchemaVersion.V_0_1)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build();
+ when(entityStore.list(Namespace.empty(), BaseMetalake.class,
Entity.EntityType.METALAKE))
+ .thenReturn(ImmutableList.of(mockMetalake));
+ mockedMetalake
+ .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+ .thenReturn(ImmutableList.of(metalake));
+
+ when(jobManager.listJobs(metalake,
Optional.empty())).thenReturn(ImmutableList.of(job));
+
+ // QUEUED -> STARTED: startedAt must be set, finishedAt must remain unset.
+
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.STARTED);
+ Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+ ArgumentCaptor<JobEntity> startedCaptor =
ArgumentCaptor.forClass(JobEntity.class);
+ verify(entityStore, times(1)).put(startedCaptor.capture(), anyBoolean());
+ JobEntity startedJob = startedCaptor.getValue();
+ Assertions.assertEquals(JobHandle.Status.STARTED, startedJob.status());
+ Assertions.assertNotNull(startedJob.startedAt());
+ Assertions.assertTrue(startedJob.startedAt() > 0);
+ Assertions.assertEquals(0L, startedJob.finishedAt());
+
+ // STARTED -> SUCCEEDED: finishedAt must be set, and the
previously-recorded startedAt must
+ // be carried forward unchanged, not overwritten.
+ Mockito.clearInvocations(entityStore);
+ when(jobManager.listJobs(metalake,
Optional.empty())).thenReturn(ImmutableList.of(startedJob));
+
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.SUCCEEDED);
+ Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+ ArgumentCaptor<JobEntity> finishedCaptor =
ArgumentCaptor.forClass(JobEntity.class);
+ verify(entityStore, times(1)).put(finishedCaptor.capture(), anyBoolean());
+ JobEntity finishedJob = finishedCaptor.getValue();
+ Assertions.assertEquals(JobHandle.Status.SUCCEEDED, finishedJob.status());
+ Assertions.assertEquals(startedJob.startedAt(), finishedJob.startedAt());
+ Assertions.assertNotNull(finishedJob.finishedAt());
+ Assertions.assertTrue(finishedJob.finishedAt() > 0);
+ }
+
+ @Test
+ public void
testPullJobStatusStartedAtNotBackfilledOnDirectTerminalTransition()
+ throws IOException {
+ // A job that transitions QUEUED -> SUCCEEDED directly (skipping any poll
that observes it
+ // as STARTED) does not prove exactly when it started - e.g.
LocalJobExecutor can also reach
+ // FAILED directly from QUEUED without ever recording STARTED. Backfilling
startedAt from the
+ // queued time would understate queue latency and overstate execution
duration, so startedAt
+ // stays unset (0) unless a STARTED transition was actually observed.
+ Instant queuedAt = Instant.now();
+ JobEntity queuedJob =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withNamespace(NamespaceUtil.ofJob(metalake))
+ .withJobTemplateName("shell_job")
+ .withStatus(JobHandle.Status.QUEUED)
+
.withAuditInfo(AuditInfo.builder().withCreator("test").withCreateTime(queuedAt).build())
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .build();
+
+ BaseMetalake mockMetalake =
+ BaseMetalake.builder()
+ .withName(metalake)
+ .withId(idGenerator.nextId())
+ .withVersion(SchemaVersion.V_0_1)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build();
+ when(entityStore.list(Namespace.empty(), BaseMetalake.class,
Entity.EntityType.METALAKE))
+ .thenReturn(ImmutableList.of(mockMetalake));
+ mockedMetalake
+ .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+ .thenReturn(ImmutableList.of(metalake));
+
+ when(jobManager.listJobs(metalake,
Optional.empty())).thenReturn(ImmutableList.of(queuedJob));
+ when(jobExecutor.getJobStatus(queuedJob.jobExecutionId()))
+ .thenReturn(JobHandle.Status.SUCCEEDED);
+ Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+ ArgumentCaptor<JobEntity> captor =
ArgumentCaptor.forClass(JobEntity.class);
+ verify(entityStore, times(1)).put(captor.capture(), anyBoolean());
+ JobEntity succeededJob = captor.getValue();
+ Assertions.assertEquals(JobHandle.Status.SUCCEEDED, succeededJob.status());
+ Assertions.assertEquals(0L, succeededJob.startedAt());
+ Assertions.assertNotNull(succeededJob.finishedAt());
+ Assertions.assertTrue(succeededJob.finishedAt() > 0);
+ }
+
+ @Test
+ public void testPullJobStatusStartedAtNotBackfilledOnDirectCancellation()
throws IOException {
+ // CANCELLED does not prove the job ever started (it may have been
cancelled while still
+ // QUEUED), so startedAt must NOT fall back to the queued time here - it
stays unset.
+ JobEntity cancellingJob =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withNamespace(NamespaceUtil.ofJob(metalake))
+ .withJobTemplateName("shell_job")
+ .withStatus(JobHandle.Status.CANCELLING)
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .build();
+
+ BaseMetalake mockMetalake =
+ BaseMetalake.builder()
+ .withName(metalake)
+ .withId(idGenerator.nextId())
+ .withVersion(SchemaVersion.V_0_1)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build();
+ when(entityStore.list(Namespace.empty(), BaseMetalake.class,
Entity.EntityType.METALAKE))
+ .thenReturn(ImmutableList.of(mockMetalake));
+ mockedMetalake
+ .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+ .thenReturn(ImmutableList.of(metalake));
+
+ when(jobManager.listJobs(metalake, Optional.empty()))
+ .thenReturn(ImmutableList.of(cancellingJob));
+ when(jobExecutor.getJobStatus(cancellingJob.jobExecutionId()))
+ .thenReturn(JobHandle.Status.CANCELLED);
+ Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+ ArgumentCaptor<JobEntity> captor =
ArgumentCaptor.forClass(JobEntity.class);
+ verify(entityStore, times(1)).put(captor.capture(), anyBoolean());
+ JobEntity cancelledJob = captor.getValue();
+ Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelledJob.status());
+ Assertions.assertEquals(0L, cancelledJob.startedAt());
+ Assertions.assertNotNull(cancelledJob.finishedAt());
+ Assertions.assertTrue(cancelledJob.finishedAt() > 0);
+ }
+
@Test
public void testCleanUpStagingDirs() throws IOException,
InterruptedException {
JobEntity job = newJobEntity("shell_job", JobHandle.Status.STARTED);
@@ -944,6 +1095,7 @@ public class TestJobManager {
.withJobExecutionId(rand.nextLong() + "")
.withNamespace(NamespaceUtil.ofJob(metalake))
.withJobTemplateName(templateName)
+ .withStartedAt(System.currentTimeMillis())
.withFinishedAt(System.currentTimeMillis())
.withStatus(status)
.withAuditInfo(
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 6f57ced26d..cd9eaf3a7a 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
@@ -487,6 +487,8 @@ public class TestJobEventDispatcher {
Assertions.assertEquals(expected.jobId(), actual.jobId());
Assertions.assertEquals(expected.jobTemplateName(),
actual.jobTemplateName());
Assertions.assertEquals(expected.jobStatus(), actual.jobStatus());
+ Assertions.assertEquals(expected.queuedAt(), actual.queuedAt());
+ Assertions.assertEquals(expected.startedAt(), actual.startedAt());
Assertions.assertEquals(expected.finishedAt(), actual.finishedAt());
}
@@ -544,11 +546,16 @@ public class TestJobEventDispatcher {
}
private JobEntity mockJobEntity() {
+ AuditInfo auditInfo = mock(AuditInfo.class);
+
when(auditInfo.createTime()).thenReturn(Instant.ofEpochMilli(1699999000000L));
+
JobEntity entity = mock(JobEntity.class);
when(entity.jobTemplateName()).thenReturn("testJob");
when(entity.name()).thenReturn("job-12345");
- when(entity.auditInfo()).thenReturn(mock(AuditInfo.class));
+ when(entity.auditInfo()).thenReturn(auditInfo);
when(entity.status()).thenReturn(JobHandle.Status.SUCCEEDED);
+ when(entity.startedAt()).thenReturn(1699999500000L);
+
when(entity.startedAtAsInstant()).thenReturn(Instant.ofEpochMilli(1699999500000L));
when(entity.finishedAt()).thenReturn(1700000000000L);
when(entity.finishedAtAsInstant()).thenReturn(Instant.ofEpochMilli(1700000000000L));
@@ -560,6 +567,8 @@ public class TestJobEventDispatcher {
when(info.jobId()).thenReturn("job-12345");
when(info.jobTemplateName()).thenReturn("testJob");
when(info.jobStatus()).thenReturn(JobHandle.Status.SUCCEEDED);
+ when(info.queuedAt()).thenReturn(Instant.ofEpochMilli(1699999000000L));
+ when(info.startedAt()).thenReturn(Instant.ofEpochMilli(1699999500000L));
when(info.finishedAt()).thenReturn(Instant.ofEpochMilli(1700000000000L));
return info;
}
diff --git
a/core/src/test/java/org/apache/gravitino/listener/api/info/TestJobInfo.java
b/core/src/test/java/org/apache/gravitino/listener/api/info/TestJobInfo.java
new file mode 100644
index 0000000000..833a37fbdc
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/listener/api/info/TestJobInfo.java
@@ -0,0 +1,79 @@
+/*
+ * 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.listener.api.info;
+
+import java.time.Instant;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.JobEntity;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestJobInfo {
+
+ @Test
+ public void testFromJobEntityWhenNotStartedOrFinished() {
+ Instant queuedAt = Instant.now();
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+
.withAuditInfo(AuditInfo.builder().withCreator("test").withCreateTime(queuedAt).build())
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .build();
+
+ JobInfo jobInfo = JobInfo.fromJobEntity(jobEntity);
+
+ Assertions.assertEquals(jobEntity.name(), jobInfo.jobId());
+ Assertions.assertEquals(jobEntity.jobTemplateName(),
jobInfo.jobTemplateName());
+ Assertions.assertEquals(jobEntity.status(), jobInfo.jobStatus());
+ Assertions.assertEquals(queuedAt, jobInfo.queuedAt());
+ Assertions.assertNull(jobInfo.startedAt());
+ Assertions.assertNull(jobInfo.finishedAt());
+ }
+
+ @Test
+ public void testFromJobEntityWhenStartedAndFinished() {
+ Instant queuedAt = Instant.now();
+ long startedAt = queuedAt.toEpochMilli() + 1000;
+ long finishedAt = queuedAt.toEpochMilli() + 2000;
+ 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(queuedAt).build())
+ .withStartedAt(startedAt)
+ .withFinishedAt(finishedAt)
+ .build();
+
+ JobInfo jobInfo = JobInfo.fromJobEntity(jobEntity);
+
+ Assertions.assertEquals(queuedAt, jobInfo.queuedAt());
+ Assertions.assertEquals(Instant.ofEpochMilli(startedAt),
jobInfo.startedAt());
+ Assertions.assertEquals(Instant.ofEpochMilli(finishedAt),
jobInfo.finishedAt());
+ }
+}
diff --git a/core/src/test/java/org/apache/gravitino/meta/TestJobEntity.java
b/core/src/test/java/org/apache/gravitino/meta/TestJobEntity.java
index 909798c2e7..cc9854849f 100644
--- a/core/src/test/java/org/apache/gravitino/meta/TestJobEntity.java
+++ b/core/src/test/java/org/apache/gravitino/meta/TestJobEntity.java
@@ -29,11 +29,45 @@ public class TestJobEntity {
private static final AuditInfo AUDIT_INFO =
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ @Test
+ public void testBuildRequiresStartedAt() {
+ // startedAt is a required field - it must be explicitly set (using the
storage layer's
+ // "not started" sentinel, <= 0, when the job hasn't started), regardless
of status. finishedAt
+ // is set here so only the missing startedAt triggers the failure.
+ 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)
+ .withFinishedAt(0L)
+ .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)
+ .withFinishedAt(1700000000000L)
+ .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.
+ // "not finished" sentinel, <= 0, when the job hasn't finished),
regardless of status.
+ // startedAt is set here so only the missing finishedAt triggers the
failure. Together with
+ // testBuildRequiresStartedAt, this prevents JobPO from having to silently
fabricate or
+ // default either value.
Assertions.assertThrows(
IllegalArgumentException.class,
() ->
@@ -44,6 +78,7 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.QUEUED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(0L)
.build());
Assertions.assertThrows(
@@ -56,9 +91,27 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.SUCCEEDED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(1700000000000L)
.build());
}
+ @Test
+ public void testStartedAt() {
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.STARTED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(1700000000000L)
+ .withFinishedAt(0L)
+ .build();
+
+ Assertions.assertEquals(1700000000000L, jobEntity.startedAt());
+ }
+
@Test
public void testFinishedAt() {
JobEntity jobEntity =
@@ -69,12 +122,43 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.SUCCEEDED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(1699999999000L)
.withFinishedAt(1700000000000L)
.build();
Assertions.assertEquals(1700000000000L, jobEntity.finishedAt());
}
+ @Test
+ public void testStartedAtAsInstantWhenNotStarted() {
+ // The storage layer's sentinel (<= 0) means "not started".
+ JobEntity zeroStartedAt =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .build();
+ Assertions.assertNull(zeroStartedAt.startedAtAsInstant());
+
+ JobEntity negativeStartedAt =
+ JobEntity.builder()
+ .withId(2L)
+ .withJobExecutionId("job-execution-2")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(-1L)
+ .withFinishedAt(0L)
+ .build();
+ Assertions.assertNull(negativeStartedAt.startedAtAsInstant());
+ }
+
@Test
public void testFinishedAtAsInstantWhenNotFinished() {
// The storage layer's sentinel (<= 0) means "not finished".
@@ -86,6 +170,7 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.QUEUED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(0L)
.withFinishedAt(0L)
.build();
Assertions.assertNull(zeroFinishedAt.finishedAtAsInstant());
@@ -98,11 +183,30 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.STARTED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(1700000000000L)
.withFinishedAt(-1L)
.build();
Assertions.assertNull(negativeFinishedAt.finishedAtAsInstant());
}
+ @Test
+ public void testStartedAtAsInstantWhenStarted() {
+ long epochMilli = Instant.now().toEpochMilli();
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.STARTED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(epochMilli)
+ .withFinishedAt(0L)
+ .build();
+
+ Assertions.assertEquals(Instant.ofEpochMilli(epochMilli),
jobEntity.startedAtAsInstant());
+ }
+
@Test
public void testFinishedAtAsInstantWhenFinished() {
long epochMilli = Instant.now().toEpochMilli();
@@ -114,12 +218,60 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.SUCCEEDED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(epochMilli - 1000)
.withFinishedAt(epochMilli)
.build();
Assertions.assertEquals(Instant.ofEpochMilli(epochMilli),
jobEntity.finishedAtAsInstant());
}
+ @Test
+ public void testEqualsAndHashCodeIncludeStartedAt() {
+ JobEntity notStarted =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .build();
+
+ JobEntity sameAsNotStarted =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(0L)
+ .withFinishedAt(0L)
+ .build();
+
+ Assertions.assertEquals(notStarted, sameAsNotStarted);
+ Assertions.assertEquals(notStarted.hashCode(),
sameAsNotStarted.hashCode());
+
+ // Same identity/status/audit but a different startedAt (e.g. the same job
just after it
+ // transitioned to STARTED) must not compare equal.
+ JobEntity started =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.QUEUED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(1700000000000L)
+ .withFinishedAt(0L)
+ .build();
+
+ Assertions.assertNotEquals(notStarted, started);
+ Assertions.assertNotEquals(notStarted.hashCode(), started.hashCode());
+ }
+
@Test
public void testEqualsAndHashCodeIncludeFinishedAt() {
JobEntity notFinished =
@@ -130,6 +282,7 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.STARTED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(1700000000000L)
.withFinishedAt(0L)
.build();
@@ -141,6 +294,7 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.STARTED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(1700000000000L)
.withFinishedAt(0L)
.build();
@@ -157,7 +311,8 @@ public class TestJobEntity {
.withStatus(JobHandle.Status.STARTED)
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(AUDIT_INFO)
- .withFinishedAt(1700000000000L)
+ .withStartedAt(1700000000000L)
+ .withFinishedAt(1700000001000L)
.build();
Assertions.assertNotEquals(notFinished, finished);
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 a67f40d950..36ddc2e942 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)
+ .withStartedAt(System.currentTimeMillis())
.withFinishedAt(0L)
.build();
JobEntity job2 =
@@ -621,6 +622,7 @@ public class TestJDBCBackendBatchGet extends
TestJDBCBackend {
.withStatus(JobHandle.Status.QUEUED)
.withJobTemplateName("template2")
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(0L)
.withFinishedAt(0L)
.build();
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 ea7c2c2fc7..76a390b173 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())
+ .withStartedAt(0L)
.withFinishedAt(0L)
.build();
@@ -141,14 +142,43 @@ 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.
+ // A queued job has no startedAt/finishedAt, so both should round-trip as
the "not
+ // started"/"not finished" sentinel.
+ Assertions.assertEquals(0L, resultEntity.startedAt());
Assertions.assertEquals(0L, resultEntity.finishedAt());
}
+ @Test
+ public void testJobPOStartedAt() {
+ // initializeJobPO must trust the startedAt already set on the entity by
the caller (e.g.
+ // JobManager), rather than deriving it independently from the job's
status.
+ long startedAt = Instant.now().toEpochMilli();
+ JobEntity jobEntity =
+ JobEntity.builder()
+ .withId(1L)
+ .withJobExecutionId("job-execution-1")
+ .withJobTemplateName("test-job-template")
+ .withStatus(JobHandle.Status.STARTED)
+ .withNamespace(NamespaceUtil.ofJob("test"))
+ .withAuditInfo(
+
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withStartedAt(startedAt)
+ .withFinishedAt(0L)
+ .build();
+
+ JobPO.JobPOBuilder builder = JobPO.builder().withMetalakeId(1L);
+ JobPO jobPO = JobPO.initializeJobPO(jobEntity, builder);
+ JobEntity resultEntity = JobPO.fromJobPO(jobPO,
NamespaceUtil.ofJob("test"));
+
+ Assertions.assertEquals(startedAt, jobPO.jobStartedAt());
+ Assertions.assertEquals(startedAt, resultEntity.startedAt());
+ }
+
@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 startedAt = Instant.now().toEpochMilli() - 1000;
long finishedAt = Instant.now().toEpochMilli();
JobEntity jobEntity =
JobEntity.builder()
@@ -159,6 +189,7 @@ public class TestJobPO {
.withNamespace(NamespaceUtil.ofJob("test"))
.withAuditInfo(
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withStartedAt(startedAt)
.withFinishedAt(finishedAt)
.build();
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 70337d5fff..1f30e64612 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)
+ .withStartedAt(System.currentTimeMillis())
.withFinishedAt(0L)
.build();
backend.insert(runningJob, true);
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 82d30c8123..a45294e553 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())
+ .withStartedAt(System.currentTimeMillis())
.withFinishedAt(System.currentTimeMillis())
.build();
Assertions.assertDoesNotThrow(() ->
JobMetaService.getInstance().insertJob(jobOverwrite, true));
@@ -138,7 +139,7 @@ public class TestJobMetaService extends TestJDBCBackend {
.getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME,
jobOverwrite.name()));
Assertions.assertEquals(jobOverwrite, updatedJob);
- // Test insert and get job with finishedAt
+ // Test insert and get job with startedAt/finishedAt
JobEntity finishedJob =
TestJobTemplateMetaService.newJobEntity(
jobTemplate.name(), JobHandle.Status.SUCCEEDED, METALAKE_NAME);
@@ -147,6 +148,7 @@ public class TestJobMetaService extends TestJDBCBackend {
JobEntity retrievedFinishedJob =
JobMetaService.getInstance()
.getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME,
finishedJob.name()));
+ Assertions.assertTrue(retrievedFinishedJob.startedAt() > 0);
Assertions.assertTrue(retrievedFinishedJob.finishedAt() > 0);
}
@@ -180,6 +182,7 @@ public class TestJobMetaService extends TestJDBCBackend {
.withNamespace(job.namespace())
.withAuditInfo(job.auditInfo())
.withJobTemplateName(job.jobTemplateName())
+ .withStartedAt(timestamp)
.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 e83c846f04..1f40cadea2 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,8 @@ public class TestJobTemplateMetaService extends
TestJDBCBackend {
}
static JobEntity newJobEntity(String templateName, JobHandle.Status status,
String metalake) {
+ // Any status other than QUEUED implies the job has at least started.
+ boolean isStarted = status != JobHandle.Status.QUEUED;
boolean isFinished =
status == JobHandle.Status.SUCCEEDED
|| status == JobHandle.Status.FAILED
@@ -289,6 +291,7 @@ public class TestJobTemplateMetaService extends
TestJDBCBackend {
.withJobTemplateName(templateName)
.withStatus(status)
.withAuditInfo(AUDIT_INFO)
+ .withStartedAt(isStarted ? System.currentTimeMillis() : 0L)
.withFinishedAt(isFinished ? System.currentTimeMillis() : 0L)
.build();
}
diff --git a/docs/open-api/jobs.yaml b/docs/open-api/jobs.yaml
index 34a3ab2a01..5237d5e958 100644
--- a/docs/open-api/jobs.yaml
+++ b/docs/open-api/jobs.yaml
@@ -495,6 +495,7 @@ components:
- jobTemplateName
- status
- audit
+ - queuedAt
properties:
jobId:
type: string
@@ -514,6 +515,15 @@ components:
- "canceled"
audit:
$ref: "./openapi.yaml#/components/schemas/Audit"
+ queuedAt:
+ type: string
+ format: date-time
+ description: The time when the job was queued for execution, same as
the job's creation time
+ startedAt:
+ type: string
+ format: date-time
+ nullable: true
+ description: The time when the job started execution, or null if the
job has not started execution yet
finishedAt:
type: string
format: date-time
@@ -926,6 +936,8 @@ components:
"createTime": "2025-08-12T02:14:28.205023Z",
"creator": "anonymous"
},
+ "queuedAt": "2025-08-12T02:14:28.205023Z",
+ "startedAt": "2025-08-12T02:14:31.098231Z",
"finishedAt": "2025-08-12T02:15:03.512847Z"
},
{
@@ -936,6 +948,8 @@ components:
"createTime": "2025-08-12T02:14:28.205023Z",
"creator": "anonymous"
},
+ "queuedAt": "2025-08-12T02:14:28.205023Z",
+ "startedAt": "2025-08-12T02:14:35.442190Z",
"finishedAt": "2025-08-12T02:15:47.891023Z"
}
]
@@ -952,6 +966,8 @@ components:
"createTime": "2025-08-12T02:14:28.205023Z",
"creator": "anonymous"
},
+ "queuedAt": "2025-08-12T02:14:28.205023Z",
+ "startedAt": "2025-08-12T02:14:31.098231Z",
"finishedAt": "2025-08-12T02:15:03.512847Z"
}
}
diff --git a/scripts/h2/schema-2.0.0-h2.sql b/scripts/h2/schema-2.0.0-h2.sql
index 834d234ff1..214d6f3af8 100644
--- a/scripts/h2/schema-2.0.0-h2.sql
+++ b/scripts/h2/schema-2.0.0-h2.sql
@@ -469,6 +469,7 @@ CREATE TABLE IF NOT EXISTS `job_run_meta` (
`metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
`job_execution_id` varchar(256) NOT NULL COMMENT 'job execution id',
`job_run_status` varchar(64) NOT NULL COMMENT 'job run status',
+ `job_started_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'job
started at',
`job_finished_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'job
finished at',
`audit_info` CLOB NOT NULL COMMENT 'job run audit info',
`current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'job run current
version',
diff --git a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
index 489a8ea057..e9b03c2996 100644
--- a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
+++ b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
@@ -38,3 +38,5 @@ ALTER TABLE `idp_group_meta` ADD COLUMN `group_comment`
VARCHAR(1024) DEFAULT ''
CREATE UNIQUE INDEX IF NOT EXISTS `uk_ti_mi_mo_tv_del` ON `tag_relation_meta`
(`tag_id`, `metadata_object_id`, `metadata_object_type`, `tag_value`,
`deleted_at`);
CREATE INDEX IF NOT EXISTS `idx_tid_value` ON `tag_relation_meta` (`tag_id`,
`tag_value`);
+
+ALTER TABLE `job_run_meta` ADD COLUMN `job_started_at` BIGINT(20) UNSIGNED NOT
NULL DEFAULT 0 COMMENT 'job started at' AFTER `job_run_status`;
diff --git a/scripts/mysql/schema-2.0.0-mysql.sql
b/scripts/mysql/schema-2.0.0-mysql.sql
index 174af1dab9..c591af5e34 100644
--- a/scripts/mysql/schema-2.0.0-mysql.sql
+++ b/scripts/mysql/schema-2.0.0-mysql.sql
@@ -460,6 +460,7 @@ CREATE TABLE IF NOT EXISTS `job_run_meta` (
`metalake_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'metalake id',
`job_execution_id` varchar(256) NOT NULL COMMENT 'job execution id',
`job_run_status` varchar(64) NOT NULL COMMENT 'job run status',
+ `job_started_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'job
started at',
`job_finished_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'job
finished at',
`audit_info` MEDIUMTEXT NOT NULL COMMENT 'job run audit info',
`current_version` INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'job run current
version',
diff --git a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
index 2d0b8e1417..71b17ed5de 100644
--- a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
+++ b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
@@ -96,3 +96,6 @@ ALTER TABLE `table_version_info`
MODIFY COLUMN `deleted_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT
'table deletion timestamp, 0 means not deleted',
DROP INDEX `uk_table_id_version_deleted_at`,
ADD PRIMARY KEY (`table_id`, `version`, `deleted_at`);
+
+ALTER TABLE `job_run_meta`
+ ADD COLUMN `job_started_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT
'job started at' AFTER `job_run_status`;
diff --git a/scripts/postgresql/schema-2.0.0-postgresql.sql
b/scripts/postgresql/schema-2.0.0-postgresql.sql
index 28b19dd2ec..0f3b39eeb8 100644
--- a/scripts/postgresql/schema-2.0.0-postgresql.sql
+++ b/scripts/postgresql/schema-2.0.0-postgresql.sql
@@ -813,6 +813,7 @@ CREATE TABLE IF NOT EXISTS job_run_meta (
metalake_id BIGINT NOT NULL,
job_execution_id VARCHAR(256) NOT NULL,
job_run_status VARCHAR(64) NOT NULL,
+ job_started_at BIGINT NOT NULL DEFAULT 0,
job_finished_at BIGINT NOT NULL DEFAULT 0,
audit_info TEXT NOT NULL,
current_version INT NOT NULL DEFAULT 1,
@@ -830,6 +831,7 @@ COMMENT ON COLUMN job_run_meta.job_template_id IS 'job
template id';
COMMENT ON COLUMN job_run_meta.metalake_id IS 'metalake id';
COMMENT ON COLUMN job_run_meta.job_execution_id IS 'job execution id';
COMMENT ON COLUMN job_run_meta.job_run_status IS 'job run status';
+COMMENT ON COLUMN job_run_meta.job_started_at IS 'job run started at';
COMMENT ON COLUMN job_run_meta.job_finished_at IS 'job run finished at';
COMMENT ON COLUMN job_run_meta.audit_info IS 'job run audit info';
COMMENT ON COLUMN job_run_meta.current_version IS 'job run current version';
diff --git a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
index 0244409955..90ce5bc69a 100644
--- a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
+++ b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
@@ -45,3 +45,6 @@ ALTER TABLE tag_relation_meta DROP CONSTRAINT IF EXISTS
tag_relation_meta_tag_id
CREATE UNIQUE INDEX IF NOT EXISTS uk_ti_mi_mo_tv_del ON tag_relation_meta
(tag_id, metadata_object_id, metadata_object_type, tag_value, deleted_at);
CREATE INDEX IF NOT EXISTS tag_relation_meta_idx_tag_id_value ON
tag_relation_meta (tag_id, tag_value);
+
+ALTER TABLE job_run_meta ADD COLUMN IF NOT EXISTS job_started_at BIGINT NOT
NULL DEFAULT 0;
+COMMENT ON COLUMN job_run_meta.job_started_at IS 'job run started at';
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 1a033abda6..74d1e0e5fd 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
@@ -477,6 +477,8 @@ public class JobOperations {
jobEntity.jobTemplateName(),
jobEntity.status(),
DTOConverters.toDTO(jobEntity.auditInfo()),
+ jobEntity.auditInfo().createTime(),
+ jobEntity.startedAtAsInstant(),
jobEntity.finishedAtAsInstant());
}
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 1e2fd543d8..cdd53e6cd8 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
@@ -641,7 +641,8 @@ public class TestJobOperations extends JerseyTest {
public void testListJobs() {
String templateName = "shell_template_1";
JobEntity job1 = newJobEntity(templateName, JobHandle.Status.QUEUED);
- JobEntity job2 = newJobEntity(templateName, JobHandle.Status.STARTED);
+ JobEntity job2 =
+ newJobEntity(templateName, JobHandle.Status.STARTED,
Instant.now().toEpochMilli(), 0L);
JobEntity job3 =
newJobEntity("spark_template_1", JobHandle.Status.SUCCEEDED,
Instant.now().toEpochMilli());
@@ -672,6 +673,17 @@ public class TestJobOperations extends JerseyTest {
Assertions.assertEquals(
Instant.ofEpochMilli(job3.finishedAt()),
jobListResponse.getJobs().get(2).finishedAt());
+ // queuedAt is always present, regardless of status.
+ Assertions.assertNotNull(jobListResponse.getJobs().get(0).queuedAt());
+ Assertions.assertNotNull(jobListResponse.getJobs().get(1).queuedAt());
+ Assertions.assertNotNull(jobListResponse.getJobs().get(2).queuedAt());
+
+ // A not-yet-started job round-trips startedAt as null over the wire.
+ Assertions.assertNull(jobListResponse.getJobs().get(0).startedAt());
+ // A started job round-trips its startedAt as an Instant over the wire.
+ Assertions.assertEquals(
+ Instant.ofEpochMilli(job2.startedAt()),
jobListResponse.getJobs().get(1).startedAt());
+
// Test list jobs by template name
when(jobOperationDispatcher.listJobs(metalake, Optional.of(templateName)))
.thenReturn(Lists.newArrayList(job1, job2));
@@ -802,8 +814,10 @@ public class TestJobOperations extends JerseyTest {
@Test
public void testCancelJob() {
+ long startedAt = Instant.now().toEpochMilli() - 1000;
+ long finishedAt = Instant.now().toEpochMilli();
JobEntity job =
- newJobEntity("shell_template_1", JobHandle.Status.CANCELLED,
Instant.now().toEpochMilli());
+ newJobEntity("shell_template_1", JobHandle.Status.CANCELLED,
startedAt, finishedAt);
when(jobOperationDispatcher.cancelJob(metalake,
job.name())).thenReturn(job);
@@ -820,6 +834,10 @@ public class TestJobOperations extends JerseyTest {
JobResponse jobResp = resp.readEntity(JobResponse.class);
Assertions.assertEquals(0, jobResp.getCode());
Assertions.assertEquals(JobOperations.toDTO(job), jobResp.getJob());
+ // queuedAt is always present, regardless of status.
+ Assertions.assertNotNull(jobResp.getJob().queuedAt());
+ // A cancelled job that had started round-trips its startedAt as an
Instant over the wire.
+ Assertions.assertEquals(Instant.ofEpochMilli(job.startedAt()),
jobResp.getJob().startedAt());
// A finished (cancelled) job round-trips its finishedAt as an Instant
over the wire.
Assertions.assertEquals(Instant.ofEpochMilli(job.finishedAt()),
jobResp.getJob().finishedAt());
@@ -857,6 +875,30 @@ public class TestJobOperations extends JerseyTest {
Assertions.assertEquals(Instant.ofEpochMilli(epochMilli),
finishedJobDTO.finishedAt());
}
+ @Test
+ public void testToDTOStartedAt() {
+ // Sentinel value (<= 0) used by the storage layer means "not started".
+ JobEntity sentinelJob = newJobEntity("shell_template_1",
JobHandle.Status.QUEUED, 0L, 0L);
+ JobDTO sentinelJobDTO = JobOperations.toDTO(sentinelJob);
+ Assertions.assertNull(sentinelJobDTO.startedAt());
+
+ // Started, startedAt is converted from epoch millis to an Instant.
+ long epochMilli = Instant.now().toEpochMilli();
+ JobEntity startedJob =
+ newJobEntity("shell_template_1", JobHandle.Status.STARTED, epochMilli,
0L);
+ JobDTO startedJobDTO = JobOperations.toDTO(startedJob);
+ Assertions.assertEquals(Instant.ofEpochMilli(epochMilli),
startedJobDTO.startedAt());
+ }
+
+ @Test
+ public void testToDTOQueuedAt() {
+ // queuedAt is always present - it's the job's creation time, not a
sentinel-backed field.
+ JobEntity job = newJobEntity("shell_template_1", JobHandle.Status.QUEUED);
+ JobDTO jobDTO = JobOperations.toDTO(job);
+ Assertions.assertEquals(job.auditInfo().createTime(), jobDTO.queuedAt());
+ Assertions.assertNotNull(jobDTO.queuedAt());
+ }
+
private String jobTemplatePath() {
return "/metalakes/" + metalake + "/jobs/templates";
}
@@ -903,10 +945,15 @@ public class TestJobOperations extends JerseyTest {
}
private JobEntity newJobEntity(String templateName, JobHandle.Status status)
{
- return newJobEntity(templateName, status, 0L);
+ return newJobEntity(templateName, status, 0L, 0L);
}
private JobEntity newJobEntity(String templateName, JobHandle.Status status,
Long finishedAt) {
+ return newJobEntity(templateName, status, 0L, finishedAt);
+ }
+
+ private JobEntity newJobEntity(
+ String templateName, JobHandle.Status status, Long startedAt, Long
finishedAt) {
Random rand = new Random();
return JobEntity.builder()
.withId(rand.nextLong())
@@ -916,6 +963,7 @@ public class TestJobOperations extends JerseyTest {
.withStatus(status)
.withAuditInfo(
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+ .withStartedAt(startedAt)
.withFinishedAt(finishedAt)
.build();
}