yuqi1129 commented on code in PR #12675:
URL: https://github.com/apache/gravitino/pull/12675#discussion_r3871476761


##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -625,41 +633,46 @@ void pullAndUpdateJobStatus() {
                       ? 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 identity/audit
+              // fields it carries forward can't clobber a concurrent change 
made in the gap
+              // between the listJobs() snapshot above and this point (e.g. by 
cancelJob()).
               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);
-                    }
-                  });
+              boolean finalIsFinished = isFinished;
+              try {
+                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, 
startedAt, finalIsFinished));

Review Comment:
   The updater receives the latest entity, but `finalNewStatus`, `startedAt`, 
and `finalIsFinished` were computed from the earlier `listJobs()` snapshot. In 
a multi-node race, another writer may already have moved the row to 
`CANCELLING` or a terminal state, or recorded an earlier 
`startedAt`/`finishedAt`, before this update reads it. This lambda can then 
regress the latest status or overwrite those timestamps even though the SQL CAS 
itself is correct. Could we derive and validate the transition inside the 
updater from `latestJobEntity`, preserving terminal/cancelling states and 
already-recorded timestamps?



##########
core/src/test/java/org/apache/gravitino/job/TestJobManager.java:
##########
@@ -782,19 +799,69 @@ public void 
testPullJobStatusStartedAtNotBackfilledOnDirectCancellation() throws
 
     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 {

Review Comment:
   Could we add a race-focused test where `listJobs()` returns a stale 
`QUEUED`/`STARTED` snapshot while `EntityStore.update()` invokes the updater 
with a different, newer entity? At minimum, please verify that a latest 
`CANCELLING` or terminal state is not regressed and that an already-recorded 
`startedAt`/`finishedAt` is preserved. The current helpers pass the same entity 
as both the list snapshot and the latest entity, so they cannot catch the 
stale-merge issue above.



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

To unsubscribe, e-mail: [email protected]

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

Reply via email to