Skip to content

[#12992]feat(core): complete OCC for jobs and job templates - #12994

Open
yuqi1129 wants to merge 5 commits into
apache:mainfrom
yuqi1129:fix/12992-12993-job-occ
Open

[#12992]feat(core): complete OCC for jobs and job templates#12994
yuqi1129 wants to merge 5 commits into
apache:mainfrom
yuqi1129:fix/12992-12993-job-occ

Conversation

@yuqi1129

@yuqi1129 yuqi1129 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Complete version-CAS updates and soft-deletes for Job and Job Template using the shared OCC helpers. Preserve missing-entity and idempotent-delete behavior while reporting stale writes as optimistic-lock conflicts.

Fence job/template insertion with parent row locks. Delete the template root by expected version, check for nonterminal jobs using a locking read, and cascade by stable template ID in one transaction. If a concurrent insertion commits first, reject deletion and roll back the root change.

Keep background polling and staging cleanup alive after conflicts. Translate disappearing parents into not-found exceptions. After template deletion succeeds, clean only the observed job directories so a same-name replacement's files survive. Retain the template parent directory rather than recursively deleting it.

Reuse metalake fencing and terminal-state checks, and fetch only identity columns for template locking reads.

Why are the changes needed?

Unversioned deletes and unfenced inserts can leave orphan metadata or remove child jobs after a failed template deletion. Checking for active jobs outside the deletion transaction misses concurrent inserts. Removing the entire template-name directory after committing can delete files created by a same-name replacement.

Fix: #12992
Fix: #12993

Does this PR introduce any user-facing change?

Stale writes use the existing OCC conflict contract. Concurrent active jobs prevent template deletion with InUseException (409). Failed deletes preserve staging files, and successful deletes do not recursively remove a replacement template's directory.

No public API signatures, configuration keys, schema, job identifiers, runtime-template JSON, or blind create/import overwrite semantics change. External submission and cancellation are not retried; submission compensation remains tracked in #10271.

How was this patch tested?

  • 119 Core tests passed with no failures or skips: TestJobManager, TestJobMetaService, TestJobTemplateMetaService, and TestJobWriteOcc. All relational tests ran against H2, MySQL, and PostgreSQL (-PskipITs -PskipDockerTests=false).
  • All 6 TestExceptionHandlers tests passed, including the active-job 409 response.
  • ./gradlew spotlessApply and git diff --check passed.
  • Core/Server checks passed with the already-run Core tests excluded: ./gradlew :core:spotlessApply :server:spotlessApply :core:check :server:test --tests org.apache.gravitino.server.web.rest.TestExceptionHandlers :server:check -PskipITs -PskipDockerTests=true -x :core:test.

Coverage includes stale writes/deletes, same-name recreation, template rename, parent fencing, cascade rollback, polling/cleanup continuity, cancellation without replay, missing-parent errors, all nonterminal states, and concurrent insertion preventing template deletion. A controlled-interleaving regression reproduced replacement staging-file deletion before the fix.

The full repository suite and external job-executor deployment tests were not run.

Copilot AI lite review requested due to automatic review settings September 8, 2026 11:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@yuqi1129 yuqi1129 self-assigned this Sep 8, 2026
@yuqi1129
yuqi1129 requested a lite review from Copilot September 8, 2026 12:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

}));
} catch (RuntimeException e) {
ExceptionUtils.checkSQLException(e, Entity.EntityType.JOB, jobEntity.id().toString());
throw e;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insertJob now deliberately throws NoSuchEntityException from the new fencing (lockMetalake / lockTemplateForJobWrite just above), and this throw e correctly stops the old silent swallow. But the caller in JobManager.runJob was not updated, so the newly-escaping exception surfaces as HTTP 500.

(The actual gap is at JobManager.java:498-502, which falls outside this PR's diff — commenting here because this is the line that makes the exception escape.)

try {
  entityStore.put(jobEntity, false /* overwrite */);
} catch (IOException e) {   // only IOException
  throw new RuntimeException("Failed to register the job entity " + jobEntity, e);
}

NoSuchEntityException extends RuntimeException, not NotFoundException (api/src/main/java/org/apache/gravitino/exceptions/NoSuchEntityException.java:25), and neither RelationalEntityStore.put nor JDBCBackend.insert converts it. So it reaches JobExceptionHandler (ExceptionHandlers.java:1051-1065), whose instanceof NotFoundException test misses it, falls through to BaseExceptionHandler, and returns Utils.internalError → 500.

Scenario:

  1. Client A calls POST .../jobs/run for template T. runJob passes its getJobTemplate check (JobManager.java:437), creates the staging directory, and submits to the external executor (JobManager.java:473).
  2. Client B deletes template T in that window.
  3. A's entityStore.put reaches insertJob, and lockTemplateForJobWrite throws NoSuchEntityException(job_template, T).
  4. A gets an opaque 500 for what runJob's own signature declares as NoSuchJobTemplateException (404) — while the job is already running on the executor with no JobEntity to poll, cancel, or clean up.

lockMetalake can produce the same escape with NoSuchEntityException(metalake, ...).

Worth noting the inconsistency inside JobManager itself: alterJobTemplate (:343) and cancelJob (:547) both catch NoSuchEntityException and translate it into a proper 404, but runJob and registerJobTemplate do not. On the base commit insertJob had no throw e, so this path was previously swallowed — adding throw e is the right fix, the caller just needs to catch up.

Suggestion: catch NoSuchEntityException around the put in runJob and rethrow it as NoSuchJobTemplateException (logging the now-orphaned jobExecutionId), or add NoSuchEntityException handling to JobExceptionHandler.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix in eb78ed2f7 looks right. runJob now translates this into NoSuchJobTemplateException, which extends NotFoundException, so JobExceptionHandler hits its instanceof NotFoundException branch and returns 404 instead of 500. Thanks also for covering registerJobTemplate in the same pass and for logging the orphaned jobExecutionId — both were exactly the right calls.

One process request: could you reply on each review comment once you have handled it, and resolve the thread? At the moment the code is fixed but both threads are still open with no response, so from the outside it is hard to tell what you consciously addressed versus what is still pending. A one-liner like "fixed in <sha>" or "intentionally leaving this as-is because ..." is plenty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been fixed in eb78ed2.

+ " WHERE job_template_id = #{oldJobTemplateMeta.jobTemplateId}"
+ " AND job_template_name = #{oldJobTemplateMeta.jobTemplateName}"
+ " AND metalake_id = #{oldJobTemplateMeta.metalakeId}"
+ " AND current_version = #{oldJobTemplateMeta.currentVersion}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit / defence-in-depth — not reachable today, flagging only because a guard went away with no replacement.

Dropping last_version is clearly fine: JobTemplatePO.updateJobTemplatePO always sets currentVersion == lastVersion, so that predicate was redundant with the retained current_version check.

The job_template_name / metalake_id predicates were doing slightly more, though. insertJobTemplateMetaOnDuplicateKeyUpdate resurrects a soft-deleted row by the same job_template_id (ON DUPLICATE KEY UPDATE, and ON CONFLICT(job_template_id) on PostgreSQL), resetting deleted_at = 0 and current_version back to 1, and it can change job_template_name at the same time. So version numbers are not monotonic across a resurrection, and a stale snapshot's CAS can match a different logical entity that happens to reuse the id:

  1. Template exists as {id=X, name="T", v=1}. Thread A calls alterJobTemplate(rename -> "T2"); getJobTemplatePO snapshots {id=X, name="T", v=1} and the updater runs.
  2. Thread B deletes it, then does put(entity(id=X, name="T3", ...), overwrite=true), leaving {id=X, name="T3", v=1, deleted_at=0}.
  3. A's CAS now matches on job_template_id = X AND current_version = 1 and silently reverts the row to A's old content and name, reporting success. With the old predicates it matched 0 rows and raised NoSuchEntityException.

Note that writeFailure's name/metalake_id predicate does not cover this, since it only runs when the CAS matches zero rows.

I checked the reachability and it is fine today: the only production entityStore.put for a job template passes overwrite=false (JobManager.java:224), so a same-id resurrection cannot happen through the REST path. So this is not a bug in the current code — just worth a conscious decision, especially since the Function/View PRs in this same OCC series added countDeletedXMetasById guards precisely to reject reusing a soft-deleted ID.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one still reads unchanged as of eb78ed2f7, which is a perfectly defensible call — I said in the original comment that it is not reachable in production today, since the only entityStore.put for a job template passes overwrite=false.

Could you just confirm here that leaving it is a deliberate decision rather than an oversight, and then resolve the thread? Happy either way; I only want the reasoning on record, since the rest of the OCC series (Function/View) added countDeletedXMetasById guards for the same id-reuse concern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is intentional. The current job template create path uses overwrite=false, so it cannot reuse a deleted template ID this way. This PR keeps the existing overwrite behavior. If we add overwrite support to this path later, we should also add a check to prevent ID reuse. Thanks for pointing this out.

@jerryshao

Copy link
Copy Markdown
Contributor

One more nit, on a line that falls outside this PR's diff so it can't be anchored inline — JobManager.java:343-347, in alterJobTemplate:

} catch (NoSuchEntityException e) {
  throw new NoSuchJobTemplateException(
      "Job template with name %s under metalake %s does not exist, this could be due to"
          + " the job template not existing or updated concurrently. For the latter case"
          + " please retry the operation.",
      jobTemplateName, metalake);
}

That message predates OCC. Now that updateJobTemplate routes a losing CAS through OccWriteSupport.updateWithVersion -> writeFailure, the "updated concurrently" case raises OptimisticLockException (409) and no longer reaches this catch — arriving here now genuinely means the template does not exist. The "or updated concurrently ... please retry" half is stale and will send users chasing the wrong cause.

For contrast, cancelJob's equivalent message at :547 ("not existing or being deleted concurrently") is still accurate, since a concurrent delete does still surface as NoSuchEntityException. So only the alterJobTemplate one needs trimming.

@yuqi1129 yuqi1129 changed the title [#12992] fix(core): complete OCC for jobs and job templates [#12992]feat(core): complete OCC for jobs and job templates Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Code Coverage Report

Overall Project 69.71% +0.17% 🟢
Files changed 91.91% 🟢

Module Coverage
aliyun 19.74% 🔴
api 51.57% 🟢
authorization-common 85.96% 🟢
authorization-ranger 4.38% 🔴
aws 53.54% 🟢
azure 32.1% 🔴
catalog-common 25.76% 🔴
catalog-fileset 82.17% 🟢
catalog-glue 69.8% 🟢
catalog-hive 82.96% 🟢
catalog-jdbc-common 45.09% 🟢
catalog-jdbc-doris 82.69% 🟢
catalog-jdbc-mysql 79.33% 🟢
catalog-jdbc-postgresql 83.83% 🟢
catalog-jdbc-starrocks 79.16% 🟢
catalog-kafka 76.99% 🟢
catalog-lakehouse-generic 60.88% 🟢
catalog-lakehouse-hudi 79.1% 🟢
catalog-lakehouse-iceberg 85.9% 🟢
catalog-lakehouse-paimon 84.29% 🟢
catalog-model 77.99% 🟢
cli 44.51% 🟢
client-java 77.5% 🟢
common 57.75% 🟢
core 84.41% +0.19% 🟢
filesystem-hadoop3 76.48% 🟢
flink 0.0% 🔴
flink-common 53.22% 🟢
flink-runtime 0.0% 🔴
gcp 32.2% 🔴
hadoop-auth 68.0% 🟢
hadoop-common 17.84% 🔴
hive-metastore-common 53.5% 🟢
iceberg-aliyun-bundle 0.0% 🔴
iceberg-common 66.89% 🟢
iceberg-rest-server 76.59% 🟢
idp-basic 86.75% 🟢
integration-test-common 0.0% 🔴
jobs 62.92% 🟢
lance-common 32.52% 🔴
lance-rest-server 68.12% 🟢
lineage 59.39% 🟢
optimizer 83.17% 🟢
optimizer-api 21.95% 🔴
server 89.62% 🟢
server-common 81.35% 🟢
spark 56.27% 🟢
tencent 81.78% 🟢
trino-connector 58.36% 🟢
Files
Module File Coverage
core JobMetaBaseSQLProvider.java 100.0% 🟢
JobTemplateMetaBaseSQLProvider.java 100.0% 🟢
JobMetaPostgreSQLProvider.java 100.0% 🟢
JobTemplateMetaPostgreSQLProvider.java 100.0% 🟢
JobTemplateMetaService.java 100.0% 🟢
JobMetaService.java 96.55% 🟢
JobTemplateMetaSQLProviderFactory.java 96.3% 🟢
JobMetaSQLProviderFactory.java 96.0% 🟢
JobManager.java 89.49% 🟢
JobMetaMapper.java 0.0% 🔴
JobTemplateMetaMapper.java 0.0% 🔴

@yuqi1129

yuqi1129 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

One more nit, on a line that falls outside this PR's diff so it can't be anchored inline — JobManager.java:343-347, in alterJobTemplate:

} catch (NoSuchEntityException e) {
  throw new NoSuchJobTemplateException(
      "Job template with name %s under metalake %s does not exist, this could be due to"
          + " the job template not existing or updated concurrently. For the latter case"
          + " please retry the operation.",
      jobTemplateName, metalake);
}

That message predates OCC. Now that updateJobTemplate routes a losing CAS through OccWriteSupport.updateWithVersion -> writeFailure, the "updated concurrently" case raises OptimisticLockException (409) and no longer reaches this catch — arriving here now genuinely means the template does not exist. The "or updated concurrently ... please retry" half is stale and will send users chasing the wrong cause.

For contrast, cancelJob's equivalent message at :547 ("not existing or being deleted concurrently") is still accurate, since a concurrent delete does still surface as NoSuchEntityException. So only the alterJobTemplate one needs trimming.

Changed as suggested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Subtask] Implement version-CAS OCC for Job Template [Subtask] Implement version-CAS OCC for Job

3 participants