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 12ddb87894 [#12669] improvement(core): Use entityStore.update() for 
job status transitions (#12675)
12ddb87894 is described below

commit 12ddb878948ddacea29f66754fb9d81ffa607ce9
Author: Jerry Shao <[email protected]>
AuthorDate: Fri Aug 28 19:02:13 2026 +0800

    [#12669] improvement(core): Use entityStore.update() for job status 
transitions (#12675)
    
    ### What changes were proposed in this pull request?
    
    Replaces the `entityStore.put(entity, overwrite=true)` + manual
    re-fetch-under-lock pattern in
    `JobManager.cancelJob`/`pullAndUpdateJobStatus` with
    `entityStore.update(...)`, matching the pattern already used for
    `JOB_TEMPLATE` and other entities:
    
    - Adds `JobMetaService.updateJob` (plus the `JDBCBackend` dispatch case,
    the `updateJobMeta` mapper/SQL provider method, and `JobPO.updateJobPO`)
    needed to route `JOB` entity writes through `entityStore.update()`.
    - `updateJobMeta`'s `WHERE` clause now does a proper
    `current_version`/`last_version` compare-and-swap, matching the same
    pattern used by `View`/`Table`/`JobTemplate`.
    - `JobManager.cancelJob` now translates a `NoSuchEntityException` from a
    concurrently deleted job into `NoSuchJobException` (matching `getJob`'s
    existing behavior) instead of leaking an undeclared exception.
    - `JobManager.pullAndUpdateJobStatus` now catches
    `NoSuchEntityException` per-job and skips it (logging a warning) instead
    of letting the exception escape the scheduled task — previously this
    would have caused `ScheduledExecutorService` to silently and permanently
    cancel all future status-poll runs.
    - Also includes an incidental, unrelated commit reformatting two long
    `assert_properties_equal` calls in the Python integration tests (no
    behavior change).
    
    ### Why are the changes needed?
    
    The job entity was the only entity type still using a raw
    `put(overwrite)` for updates, requiring every call site to manually
    re-fetch the latest state under a lock before overwriting — a pattern
    that's easy to get wrong (see the removed comment in `cancelJob`
    describing a near-miss with a stale snapshot) and inconsistent with
    every other entity in `JDBCBackend`.
    
    Fix: #12669
    
    ### Does this PR introduce _any_ user-facing change?
    
    No user-facing/API change. Internal storage-layer behavior only.
    
    ### How was this patch tested?
    
    Added/updated unit tests:
    - `TestJobMetaService`: `testUpdateJob`,
    `testUpdateNonExistentJobThrowsNoSuchEntityException`,
    `testUpdateJobWithMismatchedIdThrowsIllegalArgumentException`,
    `testUpdateJobWithMalformedIdentifierThrowsNoSuchEntityException`.
    - `TestJobPO`: `testUpdateJobPO`.
    - `TestJobManager`: updated existing
    `cancelJob`/`pullAndUpdateJobStatus` tests to mock
    `entityStore.update()` instead of `put()`; added
    `testCancelJobThrowsNoSuchJobExceptionWhenJobDeletedConcurrently` and
    `testPullJobStatusSkipsJobDeletedConcurrently`.
    
    All of `TestJobManager`, `TestJobMetaService`, and `TestJobPO` pass
    locally.
    
    ---------
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../tests/integration/test_model_catalog.py        |  72 ++++--
 .../tests/integration/test_relational_catalog.py   |   4 +-
 .../java/org/apache/gravitino/job/JobManager.java  | 241 +++++++++++-------
 .../gravitino/storage/relational/JDBCBackend.java  |   2 +
 .../storage/relational/mapper/JobMetaMapper.java   |   3 +
 .../mapper/JobMetaSQLProviderFactory.java          |   5 +
 .../provider/base/JobMetaBaseSQLProvider.java      |  17 ++
 .../gravitino/storage/relational/po/JobPO.java     |  33 +++
 .../storage/relational/service/JobMetaService.java |  75 +++++-
 .../org/apache/gravitino/job/TestJobManager.java   | 283 +++++++++++++++++++--
 .../gravitino/storage/relational/po/TestJobPO.java |  61 +++++
 .../relational/service/TestJobMetaService.java     | 146 +++++++++++
 12 files changed, 804 insertions(+), 138 deletions(-)

diff --git a/clients/client-python/tests/integration/test_model_catalog.py 
b/clients/client-python/tests/integration/test_model_catalog.py
index 639288b74e..2bef34ef1e 100644
--- a/clients/client-python/tests/integration/test_model_catalog.py
+++ b/clients/client-python/tests/integration/test_model_catalog.py
@@ -255,7 +255,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual(update_property_model.name(), model_name)
         self.assertEqual(update_property_model.comment(), comment)
         self.assertEqual(update_property_model.latest_version(), 0)
-        self.assert_properties_equal({"k1": "v11", "k2": "v2", "k3": "v3"}, 
update_property_model.properties())
+        self.assert_properties_equal(
+            {"k1": "v11", "k2": "v2", "k3": "v3"}, 
update_property_model.properties()
+        )
 
     def test_register_remove_model_property(self):
         model_name = f"model_it_model{str(randint(0, 1000))}"
@@ -327,7 +329,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual("uri", model_version.uri())
         self.assertEqual(["alias1", "alias2"], model_version.aliases())
         self.assertEqual("comment", model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, model_version.properties()
+        )
 
         model_version = 
self._catalog.as_model_catalog().get_model_version_by_alias(
             model_ident, "alias1"
@@ -350,7 +354,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual(0, updated_model_version.version())
         self.assertEqual("new comment", updated_model_version.comment())
         self.assertEqual(["alias1", "alias2"], updated_model_version.aliases())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
updated_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, updated_model_version.properties()
+        )
         self.assertEqual("uri", updated_model_version.uri())
 
     def test_link_update_model_version_property(self):
@@ -379,7 +385,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual("uri", original_model_version.uri())
         self.assertEqual(["alias1", "alias2"], 
original_model_version.aliases())
         self.assertEqual("comment", original_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
original_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, original_model_version.properties()
+        )
 
         changes = [
             ModelVersionChange.set_property("k1", "v11"),
@@ -398,7 +406,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual(update_property_model.uri(), "uri")
         self.assertEqual(update_property_model.comment(), comment)
         self.assertEqual(update_property_model.aliases(), aliases)
-        self.assert_properties_equal({"k1": "v11", "k3": "v3"}, 
update_property_model.properties())
+        self.assert_properties_equal(
+            {"k1": "v11", "k3": "v3"}, update_property_model.properties()
+        )
 
     def test_link_update_model_version_uri(self):
         model_name = f"model_it_model{str(randint(0, 1000))}"
@@ -425,7 +435,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual("uri", original_model_version.uri())
         self.assertEqual(["alias1", "alias2"], 
original_model_version.aliases())
         self.assertEqual("comment", original_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
original_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, original_model_version.properties()
+        )
 
         changes = [ModelVersionChange.update_uri("new_uri")]
         self._catalog.as_model_catalog().alter_model_version(model_ident, 0, 
*changes)
@@ -437,7 +449,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual("new_uri", updated_model_version.uri())
         self.assertEqual(["alias1", "alias2"], updated_model_version.aliases())
         self.assertEqual("comment", updated_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
updated_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, updated_model_version.properties()
+        )
 
     def test_link_add_model_version_uri(self):
         model_name = f"model_it_model{str(randint(0, 1000))}"
@@ -464,7 +478,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual({"n1": "u1"}, original_model_version.uris())
         self.assertEqual(["alias1", "alias2"], 
original_model_version.aliases())
         self.assertEqual("comment", original_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
original_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, original_model_version.properties()
+        )
 
         changes = [ModelVersionChange.add_uri("n2", "u2")]
         self._catalog.as_model_catalog().alter_model_version(model_ident, 0, 
*changes)
@@ -476,7 +492,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual({"n1": "u1", "n2": "u2"}, 
updated_model_version.uris())
         self.assertEqual(["alias1", "alias2"], updated_model_version.aliases())
         self.assertEqual("comment", updated_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
updated_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, updated_model_version.properties()
+        )
 
     def test_link_remove_model_version_uri(self):
         model_name = f"model_it_model{str(randint(0, 1000))}"
@@ -503,7 +521,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual({"n1": "u1", "n2": "u2"}, 
original_model_version.uris())
         self.assertEqual(["alias1", "alias2"], 
original_model_version.aliases())
         self.assertEqual("comment", original_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
original_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, original_model_version.properties()
+        )
 
         changes = [ModelVersionChange.remove_uri("n1")]
         self._catalog.as_model_catalog().alter_model_version(model_ident, 0, 
*changes)
@@ -515,7 +535,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual({"n2": "u2"}, updated_model_version.uris())
         self.assertEqual(["alias1", "alias2"], updated_model_version.aliases())
         self.assertEqual("comment", updated_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
updated_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, updated_model_version.properties()
+        )
 
     def test_link_update_model_version_aliases(self):
         model_name = f"model_it_model{str(randint(0, 1000))}"
@@ -542,7 +564,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual("uri", original_model_version.uri())
         self.assertEqual(["alias1", "alias2"], 
original_model_version.aliases())
         self.assertEqual("comment", original_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
original_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, original_model_version.properties()
+        )
 
         # todo
         changes = [
@@ -560,7 +584,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual("uri", updated_model_version.uri())
         self.assertEqual(["alias2", "alias3"], updated_model_version.aliases())
         self.assertEqual("comment", updated_model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
updated_model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, updated_model_version.properties()
+        )
 
     def test_link_update_model_version_aliases_from_empty(self):
         # Regression test for https://github.com/apache/gravitino/issues/9727:
@@ -647,7 +673,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual("uri", model_version.uri())
         self.assertEqual(["alias1", "alias2"], model_version.aliases())
         self.assertEqual("comment", model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, model_version.properties()
+        )
 
         model_version = 
self._catalog.as_model_catalog().get_model_version_by_alias(
             model_ident, "alias1"
@@ -768,7 +796,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual(model_versions[0].uri(), "uri1")
         self.assertEqual(model_versions[0].comment(), "comment")
         self.assertEqual(model_versions[0].aliases(), ["alias1", "alias2"])
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
model_versions[0].properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, model_versions[0].properties()
+        )
 
         self.assertTrue(
             self._catalog.as_model_catalog().delete_model_version(model_ident, 
0)
@@ -805,7 +835,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual({"n1": "u1", "n2": "u2"}, model_version.uris())
         self.assertEqual(["alias1", "alias2"], model_version.aliases())
         self.assertEqual("comment", model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, model_version.properties()
+        )
 
         # Test get model version by alias
         model_version = 
self._catalog.as_model_catalog().get_model_version_by_alias(
@@ -841,7 +873,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual({"n1": "u1", "n2": "u2"}, model_versions[0].uris())
         self.assertEqual("comment", model_versions[0].comment())
         self.assertEqual(["alias1", "alias2"], model_versions[0].aliases())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
model_versions[0].properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, model_versions[0].properties()
+        )
 
     def test_get_model_version_uri(self):
         model_name = "model_it_model" + str(randint(0, 1000))
@@ -865,7 +899,9 @@ class TestModelCatalog(IntegrationTestEnv):
         self.assertEqual({"n1": "u1", "n2": "u2"}, model_version.uris())
         self.assertEqual(["alias1", "alias2"], model_version.aliases())
         self.assertEqual("comment", model_version.comment())
-        self.assert_properties_equal({"k1": "v1", "k2": "v2"}, 
model_version.properties())
+        self.assert_properties_equal(
+            {"k1": "v1", "k2": "v2"}, model_version.properties()
+        )
 
         # Test get model version uri
         model_version_uri = 
self._catalog.as_model_catalog().get_model_version_uri(
diff --git a/clients/client-python/tests/integration/test_relational_catalog.py 
b/clients/client-python/tests/integration/test_relational_catalog.py
index f336843f9d..dae7fa8f04 100644
--- a/clients/client-python/tests/integration/test_relational_catalog.py
+++ b/clients/client-python/tests/integration/test_relational_catalog.py
@@ -178,7 +178,9 @@ class TestRelationalCatalog(IntegrationTestEnv):
         self.assertIsNotNone(table)
         self.assertEqual(table.name(), TestRelationalCatalog.TABLE_NAME)
         self.assertEqual(table.comment(), TestRelationalCatalog.TABLE_COMMENT)
-        self.assert_properties_equal(TestRelationalCatalog.TABLE_PROPERTIES, 
table.properties())
+        self.assert_properties_equal(
+            TestRelationalCatalog.TABLE_PROPERTIES, table.properties()
+        )
         self.assertEqual(len(table.columns()), 3)
 
         columns = table.columns()
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 aacb39a370..42df607c5e 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -509,34 +509,21 @@ public class JobManager implements JobOperationDispatcher 
{
         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;
+            // entityStore.update() re-fetches the latest entity itself right 
before applying the
+            // updater, 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.
+            return entityStore.update(
+                NameIdentifierUtil.ofJob(metalake, jobId),
+                JobEntity.class,
+                Entity.EntityType.JOB,
+                this::toCancellingJobEntity);
+          } catch (NoSuchEntityException e) {
+            throw new NoSuchJobException(
+                "Job with ID %s under metalake %s does not exist, this could 
be due to the job "
+                    + "not existing or being deleted concurrently.",
+                jobId, metalake);
           } catch (IOException e) {
             throw new RuntimeException(
                 String.format("Failed to update job entity for job %s to 
CANCELING status", jobId),
@@ -545,6 +532,36 @@ public class JobManager implements JobOperationDispatcher {
         });
   }
 
+  private JobEntity toCancellingJobEntity(JobEntity latestJobEntity) {
+    // The external cancel call happens before this locked update, so a 
concurrent status poll
+    // can persist a terminal status (or another cancelJob() call can already 
have moved the job
+    // to CANCELLING) in the gap between the pre-cancel snapshot and this 
re-fetch. Never regress
+    // the latest entity out of a terminal state, or overwrite an 
already-CANCELLING one.
+    if (isFinishedStatus(latestJobEntity.status())
+        || latestJobEntity.status() == JobHandle.Status.CANCELLING) {
+      return latestJobEntity;
+    }
+
+    return 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();
+  }
+
   @Override
   public void close() throws IOException {
     try {
@@ -601,76 +618,126 @@ 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())
-                      .withJobExecutionId(job.jobExecutionId())
-                      .withJobTemplateName(job.jobTemplateName())
-                      .withStatus(newStatus)
-                      .withNamespace(job.namespace())
-                      .withAuditInfo(
-                          AuditInfo.builder()
-                              .withCreator(job.auditInfo().creator())
-                              .withCreateTime(job.auditInfo().createTime())
-                              
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
-                              .withLastModifiedTime(Instant.now())
-                              .build())
-                      .withStartedAt(startedAt)
-                      .withFinishedAt(isFinished ? 
Instant.now().toEpochMilli() : job.finishedAt())
-                      .build();
-
-              // Update the job entity with new status.
+              // Update the job entity with new status. entityStore.update() 
re-fetches the
+              // latest entity itself right before applying the updater, so 
the transition below
+              // is derived from latestJobEntity - the state as of right 
before the write - rather
+              // than the possibly-stale `job` snapshot taken by listJobs() 
above. A concurrent
+              // writer (e.g. cancelJob(), or another poll run) may have 
already moved the job to
+              // a terminal state, into CANCELLING, or recorded a real 
startedAt/finishedAt in the
+              // gap between that snapshot and this point; the updater must 
not regress any of
+              // that using the stale snapshot's view of the world.
               JobHandle.Status finalNewStatus = newStatus;
-              TreeLockUtils.doWithTreeLock(
-                  NameIdentifierUtil.ofJob(metalake, job.name()),
-                  LockType.WRITE,
-                  () -> {
-                    try {
-                      entityStore.put(newJobEntity, true /* overwrite */);
-                      return null;
-                    } catch (IOException e) {
-                      throw new RuntimeException(
-                          String.format(
-                              "Failed to update job entity %s to status %s",
-                              newJobEntity, finalNewStatus),
-                          e);
-                    }
-                  });
+              JobEntity updated;
+              try {
+                updated =
+                    TreeLockUtils.doWithTreeLock(
+                        NameIdentifierUtil.ofJob(metalake, job.name()),
+                        LockType.WRITE,
+                        () -> {
+                          try {
+                            return entityStore.update(
+                                NameIdentifierUtil.ofJob(metalake, job.name()),
+                                JobEntity.class,
+                                Entity.EntityType.JOB,
+                                latestJobEntity ->
+                                    toUpdatedStatusJobEntity(latestJobEntity, 
finalNewStatus));
+                          } catch (IOException e) {
+                            throw new RuntimeException(
+                                String.format(
+                                    "Failed to update job entity %s to status 
%s",
+                                    job.name(), finalNewStatus),
+                                e);
+                          }
+                        });
+              } catch (NoSuchEntityException e) {
+                // The job could have been deleted concurrently (e.g. by 
legacy-timeline cleanup)
+                // in the gap between the listJobs() snapshot above and this 
update. Skip it rather
+                // than letting the exception escape this scheduled task, 
which would silently
+                // cancel all future status-pull runs 
(ScheduledExecutorService semantics).
+                LOG.warn(
+                    "Job {} under metalake {} no longer exists, skipping 
status update to {}. "
+                        + "This could be due to the job being deleted 
concurrently.",
+                    job.name(),
+                    metalake,
+                    finalNewStatus);
+                return;
+              }
 
               LOG.info(
                   "Updated the job {} with execution id {} status to {}",
                   job.name(),
                   job.jobExecutionId(),
-                  newStatus);
+                  updated.status());
             }
           });
     }
   }
 
+  private JobEntity toUpdatedStatusJobEntity(
+      JobEntity latestJobEntity, JobHandle.Status observedStatus) {
+    JobHandle.Status currentStatus = latestJobEntity.status();
+    boolean observedIsFinished = isFinishedStatus(observedStatus);
+
+    // Never regress a job out of a terminal state, and never move a 
CANCELLING job back to a
+    // non-terminal state - both would only be possible here because the 
executor status was
+    // observed against a stale snapshot of the job.
+    if (isFinishedStatus(currentStatus)
+        || (currentStatus == JobHandle.Status.CANCELLING && 
!observedIsFinished)) {
+      return latestJobEntity;
+    }
+
+    // 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 
(latestJobEntity.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.
+    boolean isStarted = observedStatus == JobHandle.Status.STARTED;
+    long startedAt =
+        isStarted && latestJobEntity.startedAt() <= 0
+            ? Instant.now().toEpochMilli()
+            : latestJobEntity.startedAt();
+
+    // Preserve an already-recorded finishedAt (e.g. stamped by a concurrent 
writer) instead of
+    // overwriting it with a later poll's timestamp.
+    long finishedAt =
+        observedIsFinished
+            ? (latestJobEntity.finishedAt() > 0
+                ? latestJobEntity.finishedAt()
+                : Instant.now().toEpochMilli())
+            : latestJobEntity.finishedAt();
+
+    return JobEntity.builder()
+        .withId(latestJobEntity.id())
+        .withJobExecutionId(latestJobEntity.jobExecutionId())
+        .withJobTemplateName(latestJobEntity.jobTemplateName())
+        .withStatus(observedStatus)
+        .withNamespace(latestJobEntity.namespace())
+        .withAuditInfo(
+            AuditInfo.builder()
+                .withCreator(latestJobEntity.auditInfo().creator())
+                .withCreateTime(latestJobEntity.auditInfo().createTime())
+                
.withLastModifier(PrincipalUtils.getCurrentPrincipal().getName())
+                .withLastModifiedTime(Instant.now())
+                .build())
+        .withStartedAt(startedAt)
+        .withFinishedAt(finishedAt)
+        .build();
+  }
+
+  private static boolean isFinishedStatus(JobHandle.Status status) {
+    return status == JobHandle.Status.SUCCEEDED
+        || status == JobHandle.Status.FAILED
+        || status == JobHandle.Status.CANCELLED;
+  }
+
   @VisibleForTesting
   void cleanUpStagingDirs() {
     List<String> metalakes = MetalakeManager.listInUseMetalakes(entityStore);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
index a0f3701d2e..11065934aa 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
@@ -1082,6 +1082,8 @@ public class JDBCBackend implements RelationalBackend, 
SupportsOrphanedRelationC
         return (E) PolicyMetaService.getInstance().updatePolicy(ident, 
updater);
       case JOB_TEMPLATE:
         return (E) 
JobTemplateMetaService.getInstance().updateJobTemplate(ident, updater);
+      case JOB:
+        return (E) JobMetaService.getInstance().updateJob(ident, updater);
       case VIEW:
         return (E) ViewMetaService.getInstance().updateView(ident, updater);
       default:
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaMapper.java
index 7bfe738595..ae667fcad1 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaMapper.java
@@ -50,6 +50,9 @@ public interface JobMetaMapper {
   JobPO selectJobPOByMetalakeAndRunId(
       @Param("metalakeName") String metalakeName, @Param("jobRunId") Long 
jobRunId);
 
+  @UpdateProvider(type = JobMetaSQLProviderFactory.class, method = 
"updateJobMeta")
+  Integer updateJobMeta(@Param("newJobMeta") JobPO newJobPO, 
@Param("oldJobMeta") JobPO oldJobPO);
+
   @UpdateProvider(
       type = JobMetaSQLProviderFactory.class,
       method = "softDeleteJobMetaByMetalakeAndTemplate")
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaSQLProviderFactory.java
index 269e652504..20d6c414e1 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobMetaSQLProviderFactory.java
@@ -76,6 +76,11 @@ public class JobMetaSQLProviderFactory {
     return getProvider().selectJobPOByMetalakeAndRunId(metalakeName, jobRunId);
   }
 
+  public static String updateJobMeta(
+      @Param("newJobMeta") JobPO newJobPO, @Param("oldJobMeta") JobPO 
oldJobPO) {
+    return getProvider().updateJobMeta(newJobPO, oldJobPO);
+  }
+
   public static String softDeleteJobMetaByMetalakeAndTemplate(
       @Param("metalakeName") String metalakeName,
       @Param("jobTemplateName") String jobTemplateName) {
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 5babf351fd..a18d45e73b 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
@@ -138,6 +138,23 @@ public class JobMetaBaseSQLProvider {
         + " AND jrm.deleted_at = 0 AND mm.deleted_at = 0 AND jtm.deleted_at = 
0";
   }
 
+  public String updateJobMeta(
+      @Param("newJobMeta") JobPO newJobPO, @Param("oldJobMeta") JobPO 
oldJobPO) {
+    return "UPDATE "
+        + JobMetaMapper.TABLE_NAME
+        + " SET job_execution_id = #{newJobMeta.jobExecutionId},"
+        + " job_run_status = #{newJobMeta.jobRunStatus},"
+        + " job_started_at = #{newJobMeta.jobStartedAt},"
+        + " job_finished_at = #{newJobMeta.jobFinishedAt},"
+        + " audit_info = #{newJobMeta.auditInfo},"
+        + " current_version = #{newJobMeta.currentVersion},"
+        + " last_version = #{newJobMeta.lastVersion}"
+        + " WHERE job_run_id = #{oldJobMeta.jobRunId}"
+        + " AND current_version = #{oldJobMeta.currentVersion}"
+        + " AND last_version = #{oldJobMeta.lastVersion}"
+        + " AND deleted_at = 0";
+  }
+
   public String softDeleteJobMetaByMetalakeAndTemplate(
       @Param("metalakeName") String metalakeName,
       @Param("jobTemplateName") String jobTemplateName) {
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 c5db26071e..bf0dbdf41d 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
@@ -126,6 +126,39 @@ public class JobPO {
     }
   }
 
+  /**
+   * Builds the {@link JobPO} to persist for an update, carrying forward the 
identity fields ({@code
+   * jobRunId}, {@code jobTemplateName}) from the old PO since a job's 
template is immutable, and
+   * bumping the version counters. This does not perform any 
optimistic-concurrency check; the
+   * caller is responsible for any such guarantee.
+   *
+   * @param oldJobPO the existing {@link JobPO} being updated
+   * @param newJobEntity the {@link JobEntity} with the updated 
status/timestamps/audit info
+   * @param builder the builder to populate, pre-configured with the {@code 
metalakeId}
+   * @return the {@code JobPO} object with updated fields
+   */
+  public static JobPO updateJobPO(JobPO oldJobPO, JobEntity newJobEntity, 
JobPOBuilder builder) {
+    try {
+      Long lastVersion = oldJobPO.lastVersion() + 1;
+      Long currentVersion = lastVersion;
+
+      return builder
+          .withJobRunId(oldJobPO.jobRunId())
+          .withJobTemplateName(oldJobPO.jobTemplateName())
+          .withJobExecutionId(newJobEntity.jobExecutionId())
+          .withJobRunStatus(newJobEntity.status().name())
+          .withJobStartedAt(newJobEntity.startedAt())
+          .withJobFinishedAt(newJobEntity.finishedAt())
+          
.withAuditInfo(JsonUtils.anyFieldMapper().writeValueAsString(newJobEntity.auditInfo()))
+          .withCurrentVersion(currentVersion)
+          .withLastVersion(lastVersion)
+          .withDeletedAt(DEFAULT_DELETED_AT)
+          .build();
+    } catch (JsonProcessingException e) {
+      throw new RuntimeException("Failed to serialize job entity", e);
+    }
+  }
+
   public static JobEntity fromJobPO(JobPO jobPO, Namespace namespace) {
     try {
       return JobEntity.builder()
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
index 85b33548ec..a67b8a21f3 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobMetaService.java
@@ -20,11 +20,15 @@ package org.apache.gravitino.storage.relational.service;
 
 import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
 
+import com.google.common.base.Preconditions;
 import java.io.IOException;
 import java.util.List;
 import java.util.Locale;
+import java.util.Objects;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
+import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.IllegalNamespaceException;
@@ -84,19 +88,7 @@ public class JobMetaService {
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "getJobByIdentifier")
   public JobEntity getJobByIdentifier(NameIdentifier ident) {
-    String metalakeName = ident.namespace().level(0);
-    long jobRunIdLong = parseJobRunId(ident.name());
-
-    JobPO jobPO =
-        SessionUtils.getWithoutCommit(
-            JobMetaMapper.class,
-            mapper -> mapper.selectJobPOByMetalakeAndRunId(metalakeName, 
jobRunIdLong));
-    if (jobPO == null) {
-      throw new NoSuchEntityException(
-          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-          Entity.EntityType.JOB.name().toLowerCase(Locale.ROOT),
-          ident.toString());
-    }
+    JobPO jobPO = getJobPO(ident);
     return JobPO.fromJobPO(jobPO, ident.namespace());
   }
 
@@ -125,6 +117,46 @@ public class JobMetaService {
     }
   }
 
+  @Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME, 
baseMetricName = "updateJob")
+  public <E extends Entity & HasIdentifier> JobEntity updateJob(
+      NameIdentifier jobIdent, Function<E, E> updater) throws IOException {
+    JobPO oldJobPO = getJobPO(jobIdent);
+    JobEntity oldJobEntity = JobPO.fromJobPO(oldJobPO, jobIdent.namespace());
+    JobEntity newJobEntity = (JobEntity) updater.apply((E) oldJobEntity);
+    Preconditions.checkArgument(
+        Objects.equals(oldJobEntity.id(), newJobEntity.id()),
+        "The updated job entity id: %s is not equal to the old one: %s, which 
is unexpected",
+        newJobEntity.id(),
+        oldJobEntity.id());
+
+    JobPO.JobPOBuilder newBuilder = 
JobPO.builder().withMetalakeId(oldJobPO.metalakeId());
+    JobPO newJobPO = JobPO.updateJobPO(oldJobPO, newJobEntity, newBuilder);
+
+    Integer result;
+    try {
+      result =
+          SessionUtils.doWithCommitAndFetchResult(
+              JobMetaMapper.class, mapper -> mapper.updateJobMeta(newJobPO, 
oldJobPO));
+    } catch (RuntimeException e) {
+      ExceptionUtils.checkSQLException(e, Entity.EntityType.JOB, 
oldJobEntity.name());
+      throw e;
+    }
+
+    if (result == null || result == 0) {
+      throw new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          Entity.EntityType.JOB.name().toLowerCase(Locale.ROOT),
+          oldJobEntity.name());
+    } else if (result > 1) {
+      throw new IOException(
+          String.format(
+              "Failed to update job: %s, because more than one rows are 
updated: %d",
+              oldJobEntity.name(), result));
+    } else {
+      return newJobEntity;
+    }
+  }
+
   @Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME, 
baseMetricName = "deleteJob")
   public boolean deleteJob(NameIdentifier jobIdent) {
     long jobRunIdLong = parseJobRunId(jobIdent.name());
@@ -147,6 +179,23 @@ public class JobMetaService {
         mapper -> mapper.deleteJobMetasByLegacyTimeline(legacyTimeline, 
limit));
   }
 
+  private JobPO getJobPO(NameIdentifier ident) {
+    String metalakeName = ident.namespace().level(0);
+    long jobRunIdLong = parseJobRunId(ident.name());
+
+    JobPO jobPO =
+        SessionUtils.getWithoutCommit(
+            JobMetaMapper.class,
+            mapper -> mapper.selectJobPOByMetalakeAndRunId(metalakeName, 
jobRunIdLong));
+    if (jobPO == null) {
+      throw new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          Entity.EntityType.JOB.name().toLowerCase(Locale.ROOT),
+          ident.toString());
+    }
+    return jobPO;
+  }
+
   // Validate and parse a job run identifier of the form "job-<number>";
   // throws NoSuchEntityException for any malformed input instead of leaking 
parsing errors.
   private long parseJobRunId(String jobRunId) {
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 9b5ec6e27b..c1345d0f77 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.job;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
@@ -47,6 +48,7 @@ import java.util.Random;
 import java.util.UUID;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.ArrayUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
@@ -561,7 +563,7 @@ public class TestJobManager {
     JobEntity job = newJobEntity("shell_job", JobHandle.Status.QUEUED);
     when(jobManager.getJob(metalake, job.name())).thenReturn(job);
     doNothing().when(jobExecutor).cancelJob(job.jobExecutionId());
-    doNothing().when(entityStore).put(any(JobEntity.class), anyBoolean());
+    stubEntityStoreUpdateToApply(job);
 
     // Cancel an existing job
     JobEntity cancelledJob = jobManager.cancelJob(metalake, job.name());
@@ -601,12 +603,95 @@ public class TestJobManager {
     // Test when entity store failed to update the job status
     doThrow(new IOException("Entity store error"))
         .when(entityStore)
-        .put(any(JobEntity.class), anyBoolean());
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
 
     Assertions.assertThrows(
         RuntimeException.class, () -> jobManager.cancelJob(metalake, 
job.name()));
   }
 
+  @Test
+  public void 
testCancelJobThrowsNoSuchJobExceptionWhenJobDeletedConcurrently() throws 
IOException {
+    mockedMetalake
+        .when(() -> MetalakeManager.checkMetalake(metalakeIdent, entityStore))
+        .thenAnswer(a -> null);
+
+    JobEntity job = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+    when(jobManager.getJob(metalake, job.name())).thenReturn(job);
+    doNothing().when(jobExecutor).cancelJob(job.jobExecutionId());
+
+    // Simulate the job having been deleted concurrently (e.g. by 
legacy-timeline cleanup) in the
+    // gap between the getJob() snapshot above and the entityStore.update() 
call.
+    when(entityStore.update(any(), eq(JobEntity.class), 
eq(Entity.EntityType.JOB), any()))
+        .thenThrow(new NoSuchEntityException("Job does not exist"));
+
+    Assertions.assertThrows(
+        NoSuchJobException.class, () -> jobManager.cancelJob(metalake, 
job.name()));
+  }
+
+  @Test
+  public void testCancelJobDoesNotRegressConcurrentlyFinishedJob() throws 
IOException {
+    mockedMetalake
+        .when(() -> MetalakeManager.checkMetalake(metalakeIdent, entityStore))
+        .thenAnswer(a -> null);
+
+    // getJob() observes the job as QUEUED (active), so the external cancel 
call fires. But by
+    // the time entityStore.update() re-fetches the entity, a concurrent 
status poll has already
+    // persisted a terminal status - that must not be regressed back to 
CANCELLING.
+    JobEntity queuedSnapshot = newJobEntity("shell_job", 
JobHandle.Status.QUEUED);
+    when(jobManager.getJob(metalake, 
queuedSnapshot.name())).thenReturn(queuedSnapshot);
+    doNothing().when(jobExecutor).cancelJob(queuedSnapshot.jobExecutionId());
+
+    JobEntity latestSucceeded =
+        JobEntity.builder()
+            .withId(queuedSnapshot.id())
+            .withJobExecutionId(queuedSnapshot.jobExecutionId())
+            .withNamespace(queuedSnapshot.namespace())
+            .withJobTemplateName(queuedSnapshot.jobTemplateName())
+            .withStatus(JobHandle.Status.SUCCEEDED)
+            .withAuditInfo(queuedSnapshot.auditInfo())
+            .withStartedAt(12345L)
+            .withFinishedAt(67890L)
+            .build();
+    stubEntityStoreUpdateToApply(latestSucceeded);
+
+    JobEntity result = jobManager.cancelJob(metalake, queuedSnapshot.name());
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, result.status());
+    Assertions.assertEquals(12345L, result.startedAt());
+    Assertions.assertEquals(67890L, result.finishedAt());
+  }
+
+  @Test
+  public void testCancelJobDoesNotRegressAlreadyCancellingJob() throws 
IOException {
+    mockedMetalake
+        .when(() -> MetalakeManager.checkMetalake(metalakeIdent, entityStore))
+        .thenAnswer(a -> null);
+
+    // A concurrent cancelJob() call already moved the job to CANCELLING 
between the getJob()
+    // snapshot and this update; re-applying CANCELLING here must not stamp a 
fresh
+    // lastModifiedTime over the entity the other writer already wrote.
+    JobEntity queuedSnapshot = newJobEntity("shell_job", 
JobHandle.Status.QUEUED);
+    when(jobManager.getJob(metalake, 
queuedSnapshot.name())).thenReturn(queuedSnapshot);
+    doNothing().when(jobExecutor).cancelJob(queuedSnapshot.jobExecutionId());
+
+    JobEntity latestCancelling =
+        JobEntity.builder()
+            .withId(queuedSnapshot.id())
+            .withJobExecutionId(queuedSnapshot.jobExecutionId())
+            .withNamespace(queuedSnapshot.namespace())
+            .withJobTemplateName(queuedSnapshot.jobTemplateName())
+            .withStatus(JobHandle.Status.CANCELLING)
+            .withAuditInfo(queuedSnapshot.auditInfo())
+            .withStartedAt(12345L)
+            .withFinishedAt(0L)
+            .build();
+    stubEntityStoreUpdateToApply(latestCancelling);
+
+    JobEntity result = jobManager.cancelJob(metalake, queuedSnapshot.name());
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, result.status());
+    Assertions.assertEquals(12345L, result.startedAt());
+    Assertions.assertEquals(0L, result.finishedAt());
+  }
+
   @Test
   public void testPullJobStatus() throws IOException {
     JobEntity job = newJobEntity("shell_job", JobHandle.Status.QUEUED);
@@ -629,16 +714,15 @@ public class TestJobManager {
 
     
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.QUEUED);
     Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
-    verify(entityStore, never()).put(any(), anyBoolean());
+    verify(entityStore, never())
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
 
+    stubEntityStoreUpdateToApply(job);
     
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.SUCCEEDED);
     Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
 
-    ArgumentCaptor<JobEntity> captor = 
ArgumentCaptor.forClass(JobEntity.class);
-    verify(entityStore, times(1)).put(captor.capture(), anyBoolean());
-
     // Once a job transitions to a terminal status, finishedAt must be set.
-    JobEntity updatedJob = captor.getValue();
+    JobEntity updatedJob = captureUpdatedJobEntity(job);
     Assertions.assertEquals(JobHandle.Status.SUCCEEDED, updatedJob.status());
     Assertions.assertNotNull(updatedJob.finishedAt());
     Assertions.assertTrue(updatedJob.finishedAt() > 0);
@@ -675,12 +759,11 @@ public class TestJobManager {
     when(jobManager.listJobs(metalake, 
Optional.empty())).thenReturn(ImmutableList.of(job));
 
     // QUEUED -> STARTED: startedAt must be set, finishedAt must remain unset.
+    stubEntityStoreUpdateToApply(job);
     
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();
+    JobEntity startedJob = captureUpdatedJobEntity(job);
     Assertions.assertEquals(JobHandle.Status.STARTED, startedJob.status());
     Assertions.assertNotNull(startedJob.startedAt());
     Assertions.assertTrue(startedJob.startedAt() > 0);
@@ -689,13 +772,12 @@ public class TestJobManager {
     // STARTED -> SUCCEEDED: finishedAt must be set, and the 
previously-recorded startedAt must
     // be carried forward unchanged, not overwritten.
     Mockito.clearInvocations(entityStore);
+    stubEntityStoreUpdateToApply(startedJob);
     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();
+    JobEntity finishedJob = captureUpdatedJobEntity(startedJob);
     Assertions.assertEquals(JobHandle.Status.SUCCEEDED, finishedJob.status());
     Assertions.assertEquals(startedJob.startedAt(), finishedJob.startedAt());
     Assertions.assertNotNull(finishedJob.finishedAt());
@@ -737,13 +819,12 @@ public class TestJobManager {
         .thenReturn(ImmutableList.of(metalake));
 
     when(jobManager.listJobs(metalake, 
Optional.empty())).thenReturn(ImmutableList.of(queuedJob));
+    stubEntityStoreUpdateToApply(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();
+    JobEntity succeededJob = captureUpdatedJobEntity(queuedJob);
     Assertions.assertEquals(JobHandle.Status.SUCCEEDED, succeededJob.status());
     Assertions.assertEquals(0L, succeededJob.startedAt());
     Assertions.assertNotNull(succeededJob.finishedAt());
@@ -782,19 +863,161 @@ public class TestJobManager {
 
     when(jobManager.listJobs(metalake, Optional.empty()))
         .thenReturn(ImmutableList.of(cancellingJob));
+    stubEntityStoreUpdateToApply(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();
+    JobEntity cancelledJob = captureUpdatedJobEntity(cancellingJob);
     Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelledJob.status());
     Assertions.assertEquals(0L, cancelledJob.startedAt());
     Assertions.assertNotNull(cancelledJob.finishedAt());
     Assertions.assertTrue(cancelledJob.finishedAt() > 0);
   }
 
+  @Test
+  public void testPullJobStatusSkipsJobDeletedConcurrently() throws 
IOException {
+    JobEntity deletedJob = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+    JobEntity survivingJob = newJobEntity("shell_job", 
JobHandle.Status.QUEUED);
+
+    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(deletedJob, survivingJob));
+    when(jobExecutor.getJobStatus(deletedJob.jobExecutionId()))
+        .thenReturn(JobHandle.Status.SUCCEEDED);
+    when(jobExecutor.getJobStatus(survivingJob.jobExecutionId()))
+        .thenReturn(JobHandle.Status.SUCCEEDED);
+
+    // Simulate deletedJob having been removed from storage concurrently (e.g. 
by legacy-timeline
+    // cleanup) in the gap between the listJobs() snapshot above and the 
update call, while
+    // survivingJob's update succeeds normally.
+    NameIdentifier deletedJobIdent = NameIdentifierUtil.ofJob(metalake, 
deletedJob.name());
+    NameIdentifier survivingJobIdent = NameIdentifierUtil.ofJob(metalake, 
survivingJob.name());
+    when(entityStore.update(
+            eq(deletedJobIdent), eq(JobEntity.class), 
eq(Entity.EntityType.JOB), any()))
+        .thenThrow(new NoSuchEntityException("Job does not exist"));
+    when(entityStore.update(
+            eq(survivingJobIdent), eq(JobEntity.class), 
eq(Entity.EntityType.JOB), any()))
+        .thenAnswer(
+            invocation -> {
+              Function<JobEntity, JobEntity> updater = 
invocation.getArgument(3);
+              return updater.apply(survivingJob);
+            });
+
+    // The disappearance of one job must not stop the rest of the batch from 
being processed, nor
+    // escape this method - scheduleAtFixedRate() would silently cancel all 
future runs otherwise.
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(entityStore, times(1))
+        .update(eq(deletedJobIdent), eq(JobEntity.class), 
eq(Entity.EntityType.JOB), any());
+    verify(entityStore, times(1))
+        .update(eq(survivingJobIdent), eq(JobEntity.class), 
eq(Entity.EntityType.JOB), any());
+  }
+
+  @Test
+  public void testPullJobStatusDoesNotRegressConcurrentlyFinishedJob() throws 
IOException {
+    // listJobs() observes the job as QUEUED, but by the time 
entityStore.update() re-fetches it,
+    // a concurrent writer (e.g. another poll run) has already finished the 
job with a different
+    // terminal status. The stale QUEUED snapshot - and the executor status 
derived from it - must
+    // not be allowed to regress that terminal state or clobber its recorded 
startedAt/finishedAt.
+    JobEntity queuedSnapshot = newJobEntity("shell_job", 
JobHandle.Status.QUEUED);
+    JobEntity latestSucceeded =
+        JobEntity.builder()
+            .withId(queuedSnapshot.id())
+            .withJobExecutionId(queuedSnapshot.jobExecutionId())
+            .withNamespace(queuedSnapshot.namespace())
+            .withJobTemplateName(queuedSnapshot.jobTemplateName())
+            .withStatus(JobHandle.Status.SUCCEEDED)
+            .withAuditInfo(queuedSnapshot.auditInfo())
+            .withStartedAt(12345L)
+            .withFinishedAt(67890L)
+            .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(queuedSnapshot));
+    stubEntityStoreUpdateToApply(latestSucceeded);
+    // The stale QUEUED snapshot leads the poll to observe (and try to apply) 
FAILED - a
+    // different terminal status than the one the job has actually already 
settled into.
+    when(jobExecutor.getJobStatus(queuedSnapshot.jobExecutionId()))
+        .thenReturn(JobHandle.Status.FAILED);
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    JobEntity result = captureUpdatedJobEntity(latestSucceeded);
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, result.status());
+    Assertions.assertEquals(12345L, result.startedAt());
+    Assertions.assertEquals(67890L, result.finishedAt());
+  }
+
+  @Test
+  public void testPullJobStatusDoesNotRegressConcurrentlyCancellingJob() 
throws IOException {
+    // listJobs() observes the job as QUEUED, but a concurrent cancelJob() 
moves it to CANCELLING
+    // before entityStore.update() re-fetches it. The executor reports STARTED 
for this poll
+    // (a legitimate observation for the same jobExecutionId) - that must not 
move the job back
+    // out of CANCELLING, nor overwrite the startedAt it already carries.
+    JobEntity queuedSnapshot = newJobEntity("shell_job", 
JobHandle.Status.QUEUED);
+    JobEntity latestCancelling =
+        JobEntity.builder()
+            .withId(queuedSnapshot.id())
+            .withJobExecutionId(queuedSnapshot.jobExecutionId())
+            .withNamespace(queuedSnapshot.namespace())
+            .withJobTemplateName(queuedSnapshot.jobTemplateName())
+            .withStatus(JobHandle.Status.CANCELLING)
+            .withAuditInfo(queuedSnapshot.auditInfo())
+            .withStartedAt(12345L)
+            .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(queuedSnapshot));
+    stubEntityStoreUpdateToApply(latestCancelling);
+    when(jobExecutor.getJobStatus(queuedSnapshot.jobExecutionId()))
+        .thenReturn(JobHandle.Status.STARTED);
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    JobEntity result = captureUpdatedJobEntity(latestCancelling);
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, result.status());
+    Assertions.assertEquals(12345L, result.startedAt());
+    Assertions.assertEquals(0L, result.finishedAt());
+  }
+
   @Test
   public void testCleanUpStagingDirs() throws IOException, 
InterruptedException {
     JobEntity job = newJobEntity("shell_job", JobHandle.Status.STARTED);
@@ -1103,6 +1326,28 @@ public class TestJobManager {
         .build();
   }
 
+  // cancelJob/pullAndUpdateJobStatus now go through entityStore.update(), 
which re-fetches the
+  // latest entity and applies an updater function internally. Since 
entityStore is a full mock,
+  // this stubs that re-fetch to hand back the given entity, mirroring what 
the real
+  // JobMetaService.updateJob would read from storage.
+  @SuppressWarnings("unchecked")
+  private void stubEntityStoreUpdateToApply(JobEntity latestJobEntity) throws 
IOException {
+    when(entityStore.update(any(), eq(JobEntity.class), 
eq(Entity.EntityType.JOB), any()))
+        .thenAnswer(
+            invocation -> {
+              Function<JobEntity, JobEntity> updater = 
invocation.getArgument(3);
+              return updater.apply(latestJobEntity);
+            });
+  }
+
+  @SuppressWarnings("unchecked")
+  private JobEntity captureUpdatedJobEntity(JobEntity latestJobEntity) throws 
IOException {
+    ArgumentCaptor<Function<JobEntity, JobEntity>> captor = 
ArgumentCaptor.forClass(Function.class);
+    verify(entityStore, times(1))
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), 
captor.capture());
+    return captor.getValue().apply(latestJobEntity);
+  }
+
   private HttpServer createLoopbackHttpServer(String response) throws 
IOException {
     HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 
0), 0);
     server.createContext(
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 76a390b173..9ad14ed737 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
@@ -200,4 +200,65 @@ public class TestJobPO {
     Assertions.assertEquals(finishedAt, jobPO.jobFinishedAt());
     Assertions.assertEquals(finishedAt, resultEntity.finishedAt());
   }
+
+  @Test
+  public void testUpdateJobPO() {
+    JobEntity oldEntity =
+        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(Instant.now()).build())
+            .withStartedAt(0L)
+            .withFinishedAt(0L)
+            .build();
+
+    JobPO.JobPOBuilder initBuilder = JobPO.builder().withMetalakeId(1L);
+    JobPO oldJobPO = JobPO.initializeJobPO(oldEntity, initBuilder);
+
+    long startedAt = Instant.now().toEpochMilli();
+    AuditInfo updatedAuditInfo =
+        AuditInfo.builder()
+            .withCreator(oldEntity.auditInfo().creator())
+            .withCreateTime(oldEntity.auditInfo().createTime())
+            .withLastModifier("updater")
+            .withLastModifiedTime(Instant.now())
+            .build();
+
+    // Deliberately try to change jobTemplateName - updateJobPO must ignore it 
and keep the old
+    // PO's value, since a job's template is immutable once the job is created.
+    JobEntity newEntity =
+        JobEntity.builder()
+            .withId(oldEntity.id())
+            .withJobExecutionId("job-execution-2")
+            .withJobTemplateName("a-different-job-template")
+            .withStatus(JobHandle.Status.STARTED)
+            .withNamespace(oldEntity.namespace())
+            .withAuditInfo(updatedAuditInfo)
+            .withStartedAt(startedAt)
+            .withFinishedAt(0L)
+            .build();
+
+    JobPO.JobPOBuilder updateBuilder = 
JobPO.builder().withMetalakeId(oldJobPO.metalakeId());
+    JobPO newJobPO = JobPO.updateJobPO(oldJobPO, newEntity, updateBuilder);
+
+    Assertions.assertEquals(oldJobPO.jobRunId(), newJobPO.jobRunId());
+    Assertions.assertEquals(oldJobPO.jobTemplateName(), 
newJobPO.jobTemplateName());
+    Assertions.assertEquals("job-execution-2", newJobPO.jobExecutionId());
+    Assertions.assertEquals(JobHandle.Status.STARTED.name(), 
newJobPO.jobRunStatus());
+    Assertions.assertEquals(startedAt, newJobPO.jobStartedAt());
+    Assertions.assertEquals(0L, newJobPO.jobFinishedAt());
+    Assertions.assertEquals(oldJobPO.currentVersion() + 1, 
newJobPO.currentVersion());
+    Assertions.assertEquals(oldJobPO.lastVersion() + 1, 
newJobPO.lastVersion());
+
+    JobEntity resultEntity = JobPO.fromJobPO(newJobPO, 
NamespaceUtil.ofJob("test"));
+    Assertions.assertEquals(JobHandle.Status.STARTED, resultEntity.status());
+    Assertions.assertEquals(startedAt, resultEntity.startedAt());
+    Assertions.assertEquals("test-job-template", 
resultEntity.jobTemplateName());
+    Assertions.assertEquals(
+        updatedAuditInfo.lastModifier(), 
resultEntity.auditInfo().lastModifier());
+  }
 }
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 a45294e553..fd17917d93 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
@@ -232,6 +232,152 @@ public class TestJobMetaService extends TestJDBCBackend {
             .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME, job.name())));
   }
 
+  @TestTemplate
+  public void testUpdateJob() throws IOException {
+    BaseMetalake metalake =
+        createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), METALAKE_NAME, 
AUDIT_INFO);
+    backend.insert(metalake, false);
+
+    JobTemplateEntity jobTemplate =
+        TestJobTemplateMetaService.newShellJobTemplateEntity(
+            "test_job_template", "test_comment", METALAKE_NAME);
+    JobTemplateMetaService.getInstance().insertJobTemplate(jobTemplate, false);
+
+    JobEntity job =
+        TestJobTemplateMetaService.newJobEntity(
+            jobTemplate.name(), JobHandle.Status.QUEUED, METALAKE_NAME);
+    JobMetaService.getInstance().insertJob(job, false);
+
+    // Update the job to STARTED, setting startedAt and a new audit info.
+    long startedAt = System.currentTimeMillis();
+    AuditInfo startedAuditInfo =
+        AuditInfo.builder()
+            .withCreator(job.auditInfo().creator())
+            .withCreateTime(job.auditInfo().createTime())
+            .withLastModifier("updater")
+            .withLastModifiedTime(Instant.now())
+            .build();
+
+    JobEntity startedJob =
+        JobMetaService.getInstance()
+            .updateJob(
+                NameIdentifierUtil.ofJob(METALAKE_NAME, job.name()),
+                (JobEntity oldJob) ->
+                    JobEntity.builder()
+                        .withId(oldJob.id())
+                        .withJobExecutionId(oldJob.jobExecutionId())
+                        .withJobTemplateName(oldJob.jobTemplateName())
+                        .withNamespace(oldJob.namespace())
+                        .withStatus(JobHandle.Status.STARTED)
+                        .withAuditInfo(startedAuditInfo)
+                        .withStartedAt(startedAt)
+                        .withFinishedAt(oldJob.finishedAt())
+                        .build());
+
+    Assertions.assertEquals(JobHandle.Status.STARTED, startedJob.status());
+    Assertions.assertEquals(startedAt, startedJob.startedAt());
+    Assertions.assertEquals(startedAuditInfo, startedJob.auditInfo());
+    Assertions.assertEquals(0L, startedJob.finishedAt());
+
+    // The update must actually be persisted, not just returned.
+    JobEntity fetchedJob =
+        JobMetaService.getInstance()
+            .getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME, 
job.name()));
+    Assertions.assertEquals(startedJob, fetchedJob);
+
+    // A second, independent update must keep working (e.g. an internal 
version counter bumped by
+    // the first update must not break a subsequent one).
+    long finishedAt = startedAt + 1000;
+    JobEntity finishedJob =
+        JobMetaService.getInstance()
+            .updateJob(
+                NameIdentifierUtil.ofJob(METALAKE_NAME, job.name()),
+                (JobEntity oldJob) ->
+                    JobEntity.builder()
+                        .withId(oldJob.id())
+                        .withJobExecutionId(oldJob.jobExecutionId())
+                        .withJobTemplateName(oldJob.jobTemplateName())
+                        .withNamespace(oldJob.namespace())
+                        .withStatus(JobHandle.Status.SUCCEEDED)
+                        .withAuditInfo(oldJob.auditInfo())
+                        .withStartedAt(oldJob.startedAt())
+                        .withFinishedAt(finishedAt)
+                        .build());
+
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, finishedJob.status());
+    Assertions.assertEquals(startedAt, finishedJob.startedAt());
+    Assertions.assertEquals(finishedAt, finishedJob.finishedAt());
+
+    fetchedJob =
+        JobMetaService.getInstance()
+            .getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE_NAME, 
job.name()));
+    Assertions.assertEquals(finishedJob, fetchedJob);
+  }
+
+  @TestTemplate
+  public void testUpdateNonExistentJobThrowsNoSuchEntityException() throws 
IOException {
+    BaseMetalake metalake =
+        createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), METALAKE_NAME, 
AUDIT_INFO);
+    backend.insert(metalake, false);
+
+    Assertions.assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            JobMetaService.getInstance()
+                .updateJob(NameIdentifierUtil.ofJob(METALAKE_NAME, 
"job-999999"), e -> e));
+  }
+
+  @TestTemplate
+  public void testUpdateJobWithMismatchedIdThrowsIllegalArgumentException() 
throws IOException {
+    BaseMetalake metalake =
+        createBaseMakeLake(RandomIdGenerator.INSTANCE.nextId(), METALAKE_NAME, 
AUDIT_INFO);
+    backend.insert(metalake, false);
+
+    JobTemplateEntity jobTemplate =
+        TestJobTemplateMetaService.newShellJobTemplateEntity(
+            "test_job_template", "test_comment", METALAKE_NAME);
+    JobTemplateMetaService.getInstance().insertJobTemplate(jobTemplate, false);
+
+    JobEntity job =
+        TestJobTemplateMetaService.newJobEntity(
+            jobTemplate.name(), JobHandle.Status.QUEUED, METALAKE_NAME);
+    JobMetaService.getInstance().insertJob(job, false);
+
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            JobMetaService.getInstance()
+                .updateJob(
+                    NameIdentifierUtil.ofJob(METALAKE_NAME, job.name()),
+                    (JobEntity oldJob) ->
+                        JobEntity.builder()
+                            .withId(oldJob.id() + 1)
+                            .withJobExecutionId(oldJob.jobExecutionId())
+                            .withJobTemplateName(oldJob.jobTemplateName())
+                            .withNamespace(oldJob.namespace())
+                            .withStatus(oldJob.status())
+                            .withAuditInfo(oldJob.auditInfo())
+                            .withStartedAt(oldJob.startedAt())
+                            .withFinishedAt(oldJob.finishedAt())
+                            .build()));
+  }
+
+  @Test
+  public void 
testUpdateJobWithMalformedIdentifierThrowsNoSuchEntityException() {
+    Assertions.assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            JobMetaService.getInstance()
+                .updateJob(NameIdentifierUtil.ofJob(METALAKE_NAME, "invalid"), 
e -> e));
+
+    Assertions.assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            JobMetaService.getInstance()
+                .updateJob(
+                    NameIdentifierUtil.ofJob(METALAKE_NAME, 
JobHandle.JOB_ID_PREFIX), e -> e));
+  }
+
   @Test
   public void testGetJobWithMalformedIdentifierThrowsNoSuchEntityException() {
     Assertions.assertThrows(

Reply via email to