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 faae94aba3 [#12992]feat(core): complete OCC for jobs and job templates
(#12994)
faae94aba3 is described below
commit faae94aba346a76ae3a6eb990c66612b1daed8c4
Author: Qi Yu <[email protected]>
AuthorDate: Fri Sep 11 15:24:52 2026 +0800
[#12992]feat(core): complete OCC for jobs and job templates (#12994)
### 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.
---------
Signed-off-by: yuqi <[email protected]>
---
.../java/org/apache/gravitino/job/JobManager.java | 104 ++++--
.../storage/relational/mapper/JobMetaMapper.java | 52 ++-
.../mapper/JobMetaSQLProviderFactory.java | 54 +++-
.../relational/mapper/JobTemplateMetaMapper.java | 43 ++-
.../mapper/JobTemplateMetaSQLProviderFactory.java | 44 ++-
.../provider/base/JobMetaBaseSQLProvider.java | 84 +++--
.../base/JobTemplateMetaBaseSQLProvider.java | 65 +++-
.../postgresql/JobMetaPostgreSQLProvider.java | 47 ++-
.../JobTemplateMetaPostgreSQLProvider.java | 31 +-
.../storage/relational/service/JobMetaService.java | 98 ++++--
.../relational/service/JobTemplateMetaService.java | 138 +++++---
.../relational/service/MetalakeMetaService.java | 13 +
.../org/apache/gravitino/job/TestJobManager.java | 298 ++++++++++++++---
.../service/TestJobTemplateMetaService.java | 15 +
.../relational/service/TestJobWriteOcc.java | 352 +++++++++++++++++++++
.../server/web/rest/TestExceptionHandlers.java | 43 +++
16 files changed, 1198 insertions(+), 283 deletions(-)
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 6db68afb14..e8365cda86 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -57,6 +57,9 @@ import
org.apache.gravitino.exceptions.JobTemplateAlreadyExistsException;
import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.exceptions.NoSuchJobException;
import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.lock.LockType;
import org.apache.gravitino.lock.TreeLockUtils;
@@ -226,6 +229,8 @@ public class JobManager implements JobOperationDispatcher {
throw new JobTemplateAlreadyExistsException(
"Job template with name %s under metalake %s already exists",
jobTemplateEntity.name(), metalake);
+ } catch (NoSuchEntityException e) {
+ throw new NoSuchMetalakeException(e, "Metalake %s does not exist",
metalake);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
}
@@ -267,44 +272,50 @@ public class JobManager implements JobOperationDispatcher
{
return false;
}
- boolean hasActiveJobs =
- jobs.stream()
- .anyMatch(
- job ->
- job.status() != JobHandle.Status.CANCELLED
- && job.status() != JobHandle.Status.SUCCEEDED
- && job.status() != JobHandle.Status.FAILED);
+ boolean hasActiveJobs = jobs.stream().anyMatch(job ->
!isFinishedStatus(job.status()));
if (hasActiveJobs) {
throw new InUseException(
"Job template %s under metalake %s has active jobs associated with
it",
jobTemplateName, metalake);
}
- // Delete all the job staging directories associated with the job template.
- String jobTemplateStagingPath =
- stagingDir.getAbsolutePath() + File.separator + metalake +
File.separator + jobTemplateName;
- File jobTemplateStagingDir = new File(jobTemplateStagingPath);
- if (jobTemplateStagingDir.exists()) {
+ // Delete the job template entity as well as all the jobs associated with
it.
+ boolean deleted =
+ TreeLockUtils.doWithTreeLock(
+ NameIdentifier.of(NamespaceUtil.ofJobTemplate(metalake).levels()),
+ LockType.WRITE,
+ () -> {
+ try {
+ return entityStore.delete(
+ NameIdentifierUtil.ofJobTemplate(metalake,
jobTemplateName),
+ Entity.EntityType.JOB_TEMPLATE);
+ } catch (NonEmptyEntityException e) {
+ throw new InUseException(
+ "Job template %s under metalake %s has active jobs
associated with it",
+ jobTemplateName, metalake);
+ } catch (IOException ioe) {
+ throw new RuntimeException(ioe);
+ }
+ });
+ if (!deleted) {
+ return false;
+ }
+
+ // Only remove directories belonging to the observed jobs. A same-name
template can be
+ // recreated after the metadata transaction commits, so its parent
directory is not ours to
+ // delete.
+ for (JobEntity job : jobs) {
+ String jobStagingPath =
+ stagingDir.getAbsolutePath()
+ + String.format(JOB_STAGING_DIR, metalake,
job.jobTemplateName(), job.id());
try {
- FileUtils.deleteDirectory(jobTemplateStagingDir);
+ FileUtils.deleteDirectory(new File(jobStagingPath));
} catch (IOException e) {
- LOG.error("Failed to delete job template staging directory: {}",
jobTemplateStagingPath, e);
+ LOG.error("Failed to delete job staging directory: {}",
jobStagingPath, e);
}
}
- // Delete the job template entity as well as all the jobs associated with
it.
- return TreeLockUtils.doWithTreeLock(
- NameIdentifier.of(NamespaceUtil.ofJobTemplate(metalake).levels()),
- LockType.WRITE,
- () -> {
- try {
- return entityStore.delete(
- NameIdentifierUtil.ofJobTemplate(metalake, jobTemplateName),
- Entity.EntityType.JOB_TEMPLATE);
- } catch (IOException ioe) {
- throw new RuntimeException(ioe);
- }
- });
+ return true;
}
@Override
@@ -335,9 +346,7 @@ public class JobManager implements JobOperationDispatcher {
updateJobTemplateEntity(jobTemplateIdent,
jobTemplateEntity, changes));
} 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.",
+ "Job template with name %s under metalake %s does not exist",
jobTemplateName, metalake);
} catch (IOException ioe) {
throw new RuntimeException(ioe);
@@ -490,6 +499,20 @@ public class JobManager implements JobOperationDispatcher {
try {
entityStore.put(jobEntity, false /* overwrite */);
+ } catch (NoSuchEntityException e) {
+ LOG.error(
+ "Job {} was submitted as execution {} but could not be registered
because its template "
+ + "{} or metalake {} no longer exists",
+ jobEntity.name(),
+ jobExecutionId,
+ jobTemplateName,
+ metalake,
+ e);
+ throw new NoSuchJobTemplateException(
+ e,
+ "Job template with name %s under metalake %s does not exist",
+ jobTemplateName,
+ metalake);
} catch (IOException e) {
throw new RuntimeException("Failed to register the job entity " +
jobEntity, e);
}
@@ -669,6 +692,14 @@ public class JobManager implements JobOperationDispatcher {
e);
}
});
+ } catch (OptimisticLockException e) {
+ // A later poll re-reads both executor state and metadata.
Never stop the scheduled
+ // task or replay external submission/cancellation because a
metadata CAS lost.
+ LOG.info(
+ "Job {} under metalake {} changed concurrently; deferring
status update",
+ job.name(),
+ metalake);
+ return;
} 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
@@ -767,11 +798,7 @@ public class JobManager implements JobOperationDispatcher {
for (String metalake : metalakes) {
List<JobEntity> finishedJobs =
listJobs(metalake, Optional.empty()).stream()
- .filter(
- job ->
- job.status() == JobHandle.Status.CANCELLED
- || job.status() == JobHandle.Status.SUCCEEDED
- || job.status() == JobHandle.Status.FAILED)
+ .filter(job -> isFinishedStatus(job.status()))
.filter(
job ->
job.finishedAt() > 0
@@ -793,6 +820,13 @@ public class JobManager implements JobOperationDispatcher {
FileUtils.deleteDirectory(jobStagingDir);
LOG.info("Deleted job staging directory {} for job {}",
jobStagingPath, job.name());
}
+ } catch (OptimisticLockException e) {
+ // Keep the files when deletion loses its CAS. The next cleanup
run re-reads the
+ // job and checks retention eligibility again; this batch can
process other jobs.
+ LOG.info(
+ "Job {} under metalake {} changed concurrently; deferring
cleanup",
+ job.name(),
+ metalake);
} catch (IOException e) {
LOG.error("Failed to delete job and staging directory for job
{}", job.name(), e);
}
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 ae667fcad1..c11d1a92e3 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
@@ -19,6 +19,7 @@
package org.apache.gravitino.storage.relational.mapper;
import java.util.List;
+import javax.annotation.Nullable;
import org.apache.gravitino.storage.relational.po.JobPO;
import org.apache.ibatis.annotations.DeleteProvider;
import org.apache.ibatis.annotations.InsertProvider;
@@ -53,12 +54,6 @@ public interface JobMetaMapper {
@UpdateProvider(type = JobMetaSQLProviderFactory.class, method =
"updateJobMeta")
Integer updateJobMeta(@Param("newJobMeta") JobPO newJobPO,
@Param("oldJobMeta") JobPO oldJobPO);
- @UpdateProvider(
- type = JobMetaSQLProviderFactory.class,
- method = "softDeleteJobMetaByMetalakeAndTemplate")
- Integer softDeleteJobMetaByMetalakeAndTemplate(
- @Param("metalakeName") String metalakeName, @Param("jobTemplateName")
String jobTemplateName);
-
@UpdateProvider(type = JobMetaSQLProviderFactory.class, method =
"softDeleteJobMetasByMetalakeId")
void softDeleteJobMetasByMetalakeId(@Param("metalakeId") Long metalakeId);
@@ -71,10 +66,49 @@ public interface JobMetaMapper {
Integer deleteJobMetasByLegacyTimeline(
@Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit);
- @UpdateProvider(type = JobMetaSQLProviderFactory.class, method =
"softDeleteJobMetaByRunId")
- Integer softDeleteJobMetaByRunId(@Param("jobRunId") Long jobRunId);
-
@SelectProvider(type = JobMetaSQLProviderFactory.class, method =
"batchSelectJobByRunIds")
List<JobPO> batchSelectJobByRunIds(
@Param("metalakeName") String metalakeName, @Param("jobRunIds")
List<Long> jobRunIds);
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobRunId the stable job run ID
+ * @param metalakeId the owning metalake ID
+ * @return the active row identity, or null if missing
+ */
+ @Nullable
+ @SelectProvider(type = JobMetaSQLProviderFactory.class, method =
"selectJobRunIdForUpdate")
+ Long selectJobRunIdForUpdate(
+ @Param("jobRunId") Long jobRunId, @Param("metalakeId") Long metalakeId);
+
+ /**
+ * Deletes active metadata using a stable identity and expected version.
+ *
+ * @param jobRunId the stable job run ID
+ * @param currentVersion the expected OCC version
+ * @return the affected row count
+ */
+ @UpdateProvider(
+ type = JobMetaSQLProviderFactory.class,
+ method = "softDeleteJobByRunIdWithVersion")
+ int softDeleteJobByRunIdWithVersion(
+ @Param("jobRunId") Long jobRunId, @Param("currentVersion") Long
currentVersion);
+
+ /**
+ * Deletes active metadata using a stable identity.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the affected row count
+ */
+ @UpdateProvider(type = JobMetaSQLProviderFactory.class, method =
"softDeleteJobsByTemplateId")
+ int softDeleteJobsByTemplateId(@Param("jobTemplateId") Long jobTemplateId);
+ /**
+ * Locks a nonterminal job belonging to the template using a current
database read.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return a nonterminal job ID, or null if there are none
+ */
+ @Nullable
+ @SelectProvider(type = JobMetaSQLProviderFactory.class, method =
"selectNonterminalJobForUpdate")
+ Long selectNonterminalJobForUpdate(@Param("jobTemplateId") Long
jobTemplateId);
}
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 20d6c414e1..f75ce6e18f 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
@@ -81,12 +81,6 @@ public class JobMetaSQLProviderFactory {
return getProvider().updateJobMeta(newJobPO, oldJobPO);
}
- public static String softDeleteJobMetaByMetalakeAndTemplate(
- @Param("metalakeName") String metalakeName,
- @Param("jobTemplateName") String jobTemplateName) {
- return getProvider().softDeleteJobMetaByMetalakeAndTemplate(metalakeName,
jobTemplateName);
- }
-
public static String softDeleteJobMetasByMetalakeId(@Param("metalakeId")
Long metalakeId) {
return getProvider().softDeleteJobMetasByMetalakeId(metalakeId);
}
@@ -101,12 +95,52 @@ public class JobMetaSQLProviderFactory {
return getProvider().softDeleteJobMetasByLegacyTimeline(legacyTimeline);
}
- public static String softDeleteJobMetaByRunId(@Param("jobRunId") Long
jobRunId) {
- return getProvider().softDeleteJobMetaByRunId(jobRunId);
- }
-
public static String batchSelectJobByRunIds(
@Param("metalakeName") String metalakeName, @Param("jobRunIds")
List<Long> jobRunIds) {
return getProvider().batchSelectJobByRunIds(metalakeName, jobRunIds);
}
+
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobRunId the stable job run ID
+ * @param metalakeId the owning metalake ID
+ * @return the SQL statement
+ */
+ public static String selectJobRunIdForUpdate(
+ @Param("jobRunId") Long jobRunId, @Param("metalakeId") Long metalakeId) {
+ return getProvider().selectJobRunIdForUpdate(jobRunId, metalakeId);
+ }
+
+ /**
+ * Deletes active metadata using a stable identity and expected version.
+ *
+ * @param jobRunId the stable job run ID
+ * @param currentVersion the expected OCC version
+ * @return the SQL statement
+ */
+ public static String softDeleteJobByRunIdWithVersion(
+ @Param("jobRunId") Long jobRunId, @Param("currentVersion") Long
currentVersion) {
+ return getProvider().softDeleteJobByRunIdWithVersion(jobRunId,
currentVersion);
+ }
+
+ /**
+ * Deletes active metadata using a stable identity.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public static String softDeleteJobsByTemplateId(@Param("jobTemplateId") Long
jobTemplateId) {
+ return getProvider().softDeleteJobsByTemplateId(jobTemplateId);
+ }
+
+ /**
+ * Builds a locking lookup for a nonterminal job belonging to a template.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public static String selectNonterminalJobForUpdate(@Param("jobTemplateId")
Long jobTemplateId) {
+ return getProvider().selectNonterminalJobForUpdate(jobTemplateId);
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaMapper.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaMapper.java
index 09f5d52f62..d17521b2c8 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaMapper.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaMapper.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.storage.relational.mapper;
import java.util.List;
+import javax.annotation.Nullable;
import org.apache.gravitino.storage.relational.po.JobTemplatePO;
import org.apache.ibatis.annotations.DeleteProvider;
import org.apache.ibatis.annotations.InsertProvider;
@@ -49,12 +50,6 @@ public interface JobTemplateMetaMapper {
JobTemplatePO selectJobTemplatePOByMetalakeAndName(
@Param("metalakeName") String metalakeName, @Param("jobTemplateName")
String jobTemplateName);
- @UpdateProvider(
- type = JobTemplateMetaSQLProviderFactory.class,
- method = "softDeleteJobTemplateMetaByMetalakeAndName")
- Integer softDeleteJobTemplateMetaByMetalakeAndName(
- @Param("metalakeName") String metalakeName, @Param("jobTemplateName")
String jobTemplateName);
-
@UpdateProvider(
type = JobTemplateMetaSQLProviderFactory.class,
method = "softDeleteJobTemplateMetasByMetalakeId")
@@ -92,4 +87,40 @@ public interface JobTemplateMetaMapper {
List<JobTemplatePO> batchSelectJobTemplateByIdentifier(
@Param("metalakeName") String metalakeName,
@Param("jobTemplateNames") List<String> jobTemplateNames);
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the active row identity, or null if missing
+ */
+ @Nullable
+ @SelectProvider(
+ type = JobTemplateMetaSQLProviderFactory.class,
+ method = "selectJobTemplateByIdForUpdate")
+ JobTemplatePO selectJobTemplateByIdForUpdate(@Param("jobTemplateId") Long
jobTemplateId);
+
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the active row identity, or null if missing
+ */
+ @Nullable
+ @SelectProvider(
+ type = JobTemplateMetaSQLProviderFactory.class,
+ method = "selectJobTemplateByIdForShare")
+ JobTemplatePO selectJobTemplateByIdForShare(@Param("jobTemplateId") Long
jobTemplateId);
+
+ /**
+ * Deletes active metadata using a stable identity and expected version.
+ *
+ * @param jobTemplateId the stable template ID
+ * @param currentVersion the expected OCC version
+ * @return the affected row count
+ */
+ @UpdateProvider(
+ type = JobTemplateMetaSQLProviderFactory.class,
+ method = "softDeleteJobTemplateById")
+ int softDeleteJobTemplateById(
+ @Param("jobTemplateId") Long jobTemplateId, @Param("currentVersion")
Long currentVersion);
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaSQLProviderFactory.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaSQLProviderFactory.java
index 04383da401..8e59c1d0b4 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaSQLProviderFactory.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/JobTemplateMetaSQLProviderFactory.java
@@ -51,7 +51,12 @@ public class JobTemplateMetaSQLProviderFactory {
static class JobTemplateMetaMySQLProvider extends
JobTemplateMetaBaseSQLProvider {}
- static class JobTemplateMetaH2Provider extends
JobTemplateMetaBaseSQLProvider {}
+ static class JobTemplateMetaH2Provider extends
JobTemplateMetaBaseSQLProvider {
+ @Override
+ public String selectJobTemplateByIdForShare(Long jobTemplateId) {
+ return selectJobTemplateByIdForUpdate(jobTemplateId);
+ }
+ }
public static String insertJobTemplateMeta(
@Param("jobTemplateMeta") JobTemplatePO jobTemplatePO) {
@@ -73,12 +78,6 @@ public class JobTemplateMetaSQLProviderFactory {
return getProvider().selectJobTemplatePOByMetalakeAndName(metalakeName,
jobTemplateName);
}
- public static String softDeleteJobTemplateMetaByMetalakeAndName(
- @Param("metalakeName") String metalakeName,
- @Param("jobTemplateName") String jobTemplateName) {
- return
getProvider().softDeleteJobTemplateMetaByMetalakeAndName(metalakeName,
jobTemplateName);
- }
-
public static String softDeleteJobTemplateMetasByMetalakeId(
@Param("metalakeId") Long metalakeId) {
return getProvider().softDeleteJobTemplateMetasByMetalakeId(metalakeId);
@@ -114,4 +113,35 @@ public class JobTemplateMetaSQLProviderFactory {
@Param("jobTemplateNames") List<String> jobTemplateNames) {
return getProvider().batchSelectJobTemplateByIdentifier(metalakeName,
jobTemplateNames);
}
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public static String selectJobTemplateByIdForUpdate(@Param("jobTemplateId")
Long jobTemplateId) {
+ return getProvider().selectJobTemplateByIdForUpdate(jobTemplateId);
+ }
+
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public static String selectJobTemplateByIdForShare(@Param("jobTemplateId")
Long jobTemplateId) {
+ return getProvider().selectJobTemplateByIdForShare(jobTemplateId);
+ }
+
+ /**
+ * Deletes active metadata using a stable identity and expected version.
+ *
+ * @param jobTemplateId the stable template ID
+ * @param currentVersion the expected OCC version
+ * @return the SQL statement
+ */
+ public static String softDeleteJobTemplateById(
+ @Param("jobTemplateId") Long jobTemplateId, @Param("currentVersion")
Long currentVersion) {
+ return getProvider().softDeleteJobTemplateById(jobTemplateId,
currentVersion);
+ }
}
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 344f4007fd..89ffd79aed 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
@@ -159,25 +159,6 @@ public class JobMetaBaseSQLProvider {
+ " 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) {
- return "UPDATE "
- + JobMetaMapper.TABLE_NAME
- + " SET deleted_at = "
- + DatabaseTimeSQL.MYSQL
- + " WHERE metalake_id = ("
- + " SELECT metalake_id FROM "
- + MetalakeMetaMapper.TABLE_NAME
- + " WHERE metalake_name = #{metalakeName} AND deleted_at = 0)"
- + " AND job_template_id IN ("
- + " SELECT job_template_id FROM "
- + JobTemplateMetaMapper.TABLE_NAME
- + " WHERE job_template_name = #{jobTemplateName} AND deleted_at = 0)"
+ " AND deleted_at = 0";
}
@@ -189,14 +170,6 @@ public class JobMetaBaseSQLProvider {
+ " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
}
- public String softDeleteJobMetaByRunId(@Param("jobRunId") Long jobRunId) {
- return "UPDATE "
- + JobMetaMapper.TABLE_NAME
- + " SET deleted_at = "
- + DatabaseTimeSQL.MYSQL
- + " WHERE job_run_id = #{jobRunId} AND deleted_at = 0";
- }
-
public String softDeleteJobMetasByLegacyTimeline(@Param("legacyTimeline")
Long legacyTimeline) {
return "UPDATE "
+ JobMetaMapper.TABLE_NAME
@@ -241,4 +214,61 @@ public class JobMetaBaseSQLProvider {
+ " AND jrm.deleted_at = 0 AND jtm.deleted_at = 0 AND mm.deleted_at =
0"
+ "</script>";
}
+
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobRunId the stable job run ID
+ * @param metalakeId the owning metalake ID
+ * @return the SQL statement
+ */
+ public String selectJobRunIdForUpdate(
+ @Param("jobRunId") Long jobRunId, @Param("metalakeId") Long metalakeId) {
+ return "SELECT job_run_id FROM "
+ + JobMetaMapper.TABLE_NAME
+ + " WHERE job_run_id = #{jobRunId} AND metalake_id = #{metalakeId} AND
deleted_at = 0 FOR UPDATE";
+ }
+
+ /**
+ * Deletes active metadata using a stable identity and expected version.
+ *
+ * @param jobRunId the stable job run ID
+ * @param currentVersion the expected OCC version
+ * @return the SQL statement
+ */
+ public String softDeleteJobByRunIdWithVersion(
+ @Param("jobRunId") Long jobRunId, @Param("currentVersion") Long
currentVersion) {
+ return "UPDATE "
+ + JobMetaMapper.TABLE_NAME
+ + " SET deleted_at = "
+ + DatabaseTimeSQL.MYSQL
+ + " WHERE job_run_id = #{jobRunId} AND current_version =
#{currentVersion} AND deleted_at = 0";
+ }
+
+ /**
+ * Deletes active metadata using a stable identity.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public String softDeleteJobsByTemplateId(@Param("jobTemplateId") Long
jobTemplateId) {
+ return "UPDATE "
+ + JobMetaMapper.TABLE_NAME
+ + " SET deleted_at = "
+ + DatabaseTimeSQL.MYSQL
+ + " WHERE job_template_id = #{jobTemplateId} AND deleted_at = 0";
+ }
+
+ /**
+ * Builds a current read that locks a nonterminal job without joining the
deleted template.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public String selectNonterminalJobForUpdate(@Param("jobTemplateId") Long
jobTemplateId) {
+ return "SELECT job_run_id FROM "
+ + JobMetaMapper.TABLE_NAME
+ + " WHERE job_template_id = #{jobTemplateId} AND deleted_at = 0"
+ + " AND job_run_status NOT IN ('SUCCEEDED', 'FAILED', 'CANCELLED')
LIMIT 1 FOR UPDATE";
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobTemplateMetaBaseSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobTemplateMetaBaseSQLProvider.java
index 747e98e6e0..002c57b647 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobTemplateMetaBaseSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/JobTemplateMetaBaseSQLProvider.java
@@ -94,20 +94,6 @@ public class JobTemplateMetaBaseSQLProvider {
+ " AND jtm.deleted_at = 0 AND mm.deleted_at = 0";
}
- public String softDeleteJobTemplateMetaByMetalakeAndName(
- @Param("metalakeName") String metalakeName,
- @Param("jobTemplateName") String jobTemplateName) {
- return "UPDATE "
- + JobTemplateMetaMapper.TABLE_NAME
- + " SET deleted_at = "
- + DatabaseTimeSQL.MYSQL
- + " WHERE job_template_name = #{jobTemplateName} AND metalake_id ="
- + " (SELECT metalake_id FROM "
- + MetalakeMetaMapper.TABLE_NAME
- + " WHERE metalake_name = #{metalakeName} AND deleted_at = 0)"
- + " AND deleted_at = 0";
- }
-
public String softDeleteJobTemplateMetasByMetalakeId(@Param("metalakeId")
Long metalakeId) {
return "UPDATE "
+ JobTemplateMetaMapper.TABLE_NAME
@@ -137,10 +123,7 @@ public class JobTemplateMetaBaseSQLProvider {
+ " last_version = #{newJobTemplateMeta.lastVersion},"
+ " deleted_at = #{newJobTemplateMeta.deletedAt}"
+ " WHERE job_template_id = #{oldJobTemplateMeta.jobTemplateId}"
- + " AND job_template_name = #{oldJobTemplateMeta.jobTemplateName}"
- + " AND metalake_id = #{oldJobTemplateMeta.metalakeId}"
+ " AND current_version = #{oldJobTemplateMeta.currentVersion}"
- + " AND last_version = #{oldJobTemplateMeta.lastVersion}"
+ " AND deleted_at = 0";
}
@@ -208,4 +191,52 @@ public class JobTemplateMetaBaseSQLProvider {
+ " AND jtm.deleted_at = 0 AND mm.deleted_at = 0"
+ "</script>";
}
+
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public String selectJobTemplateByIdForUpdate(@Param("jobTemplateId") Long
jobTemplateId) {
+ return selectJobTemplateIdentityById() + " FOR UPDATE";
+ }
+
+ /**
+ * Locks the active row for OCC identity validation.
+ *
+ * @param jobTemplateId the stable template ID
+ * @return the SQL statement
+ */
+ public String selectJobTemplateByIdForShare(@Param("jobTemplateId") Long
jobTemplateId) {
+ return selectJobTemplateIdentityById() + " LOCK IN SHARE MODE";
+ }
+
+ /**
+ * Deletes active metadata using a stable identity and expected version.
+ *
+ * @param jobTemplateId the stable template ID
+ * @param currentVersion the expected OCC version
+ * @return the SQL statement
+ */
+ public String softDeleteJobTemplateById(
+ @Param("jobTemplateId") Long jobTemplateId, @Param("currentVersion")
Long currentVersion) {
+ return "UPDATE "
+ + JobTemplateMetaMapper.TABLE_NAME
+ + " SET deleted_at = "
+ + DatabaseTimeSQL.MYSQL
+ + " WHERE job_template_id = #{jobTemplateId} AND current_version =
#{currentVersion} AND deleted_at = 0";
+ }
+
+ /**
+ * Builds the identity projection used by locking reads.
+ *
+ * @return SQL selecting the active template's identity fields
+ */
+ protected String selectJobTemplateIdentityById() {
+ return "SELECT job_template_id AS jobTemplateId, job_template_name AS
jobTemplateName,"
+ + " metalake_id AS metalakeId FROM "
+ + JobTemplateMetaMapper.TABLE_NAME
+ + " WHERE job_template_id = #{jobTemplateId} AND deleted_at = 0";
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
index f2169ca4e9..1e73489f09 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobMetaPostgreSQLProvider.java
@@ -20,7 +20,6 @@ package
org.apache.gravitino.storage.relational.mapper.provider.postgresql;
import org.apache.gravitino.storage.relational.mapper.JobMetaMapper;
import org.apache.gravitino.storage.relational.mapper.JobTemplateMetaMapper;
-import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
import
org.apache.gravitino.storage.relational.mapper.provider.base.JobMetaBaseSQLProvider;
import org.apache.gravitino.storage.relational.po.JobPO;
@@ -62,25 +61,6 @@ public class JobMetaPostgreSQLProvider extends
JobMetaBaseSQLProvider {
+ " deleted_at = #{jobMeta.deletedAt}";
}
- @Override
- public String softDeleteJobMetaByMetalakeAndTemplate(
- @Param("metalakeName") String metalakeName,
- @Param("jobTemplateName") String jobTemplateName) {
- return "UPDATE "
- + JobMetaMapper.TABLE_NAME
- + " SET deleted_at = "
- + DatabaseTimeSQL.POSTGRESQL
- + " WHERE metalake_id IN ("
- + " SELECT metalake_id FROM "
- + MetalakeMetaMapper.TABLE_NAME
- + " WHERE metalake_name = #{metalakeName} AND deleted_at = 0)"
- + " AND job_template_id IN ("
- + " SELECT job_template_id FROM "
- + JobTemplateMetaMapper.TABLE_NAME
- + " WHERE job_template_name = #{jobTemplateName} AND deleted_at = 0)"
- + " AND deleted_at = 0";
- }
-
@Override
public String softDeleteJobMetasByMetalakeId(@Param("metalakeId") Long
metalakeId) {
return "UPDATE "
@@ -90,14 +70,6 @@ public class JobMetaPostgreSQLProvider extends
JobMetaBaseSQLProvider {
+ " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
}
- public String softDeleteJobMetaByRunId(@Param("jobRunId") Long jobRunId) {
- return "UPDATE "
- + JobMetaMapper.TABLE_NAME
- + " SET deleted_at = "
- + DatabaseTimeSQL.POSTGRESQL
- + " WHERE job_run_id = #{jobRunId} AND deleted_at = 0";
- }
-
@Override
public String softDeleteJobMetasByLegacyTimeline(@Param("legacyTimeline")
Long legacyTimeline) {
return "UPDATE "
@@ -116,4 +88,23 @@ public class JobMetaPostgreSQLProvider extends
JobMetaBaseSQLProvider {
+ JobMetaMapper.TABLE_NAME
+ " WHERE deleted_at < #{legacyTimeline} AND deleted_at > 0 LIMIT
#{limit})";
}
+
+ @Override
+ public String softDeleteJobByRunIdWithVersion(
+ @Param("jobRunId") Long jobRunId, @Param("currentVersion") Long
currentVersion) {
+ return "UPDATE "
+ + JobMetaMapper.TABLE_NAME
+ + " SET deleted_at = "
+ + DatabaseTimeSQL.POSTGRESQL
+ + " WHERE job_run_id = #{jobRunId} AND current_version =
#{currentVersion} AND deleted_at = 0";
+ }
+
+ @Override
+ public String softDeleteJobsByTemplateId(@Param("jobTemplateId") Long
jobTemplateId) {
+ return "UPDATE "
+ + JobMetaMapper.TABLE_NAME
+ + " SET deleted_at = "
+ + DatabaseTimeSQL.POSTGRESQL
+ + " WHERE job_template_id = #{jobTemplateId} AND deleted_at = 0";
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobTemplateMetaPostgreSQLProvider.java
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobTemplateMetaPostgreSQLProvider.java
index e7426ff45b..50db19ca31 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobTemplateMetaPostgreSQLProvider.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/JobTemplateMetaPostgreSQLProvider.java
@@ -19,7 +19,6 @@
package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
import org.apache.gravitino.storage.relational.mapper.JobTemplateMetaMapper;
-import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
import
org.apache.gravitino.storage.relational.mapper.provider.base.JobTemplateMetaBaseSQLProvider;
import org.apache.gravitino.storage.relational.po.JobTemplatePO;
@@ -27,21 +26,6 @@ import org.apache.ibatis.annotations.Param;
public class JobTemplateMetaPostgreSQLProvider extends
JobTemplateMetaBaseSQLProvider {
- @Override
- public String softDeleteJobTemplateMetaByMetalakeAndName(
- @Param("metalakeName") String metalakeName,
- @Param("jobTemplateName") String jobTemplateName) {
- return "UPDATE "
- + JobTemplateMetaMapper.TABLE_NAME
- + " SET deleted_at = "
- + DatabaseTimeSQL.POSTGRESQL
- + " WHERE metalake_id IN ("
- + " SELECT metalake_id FROM "
- + MetalakeMetaMapper.TABLE_NAME
- + " WHERE metalake_name = #{metalakeName} AND deleted_at = 0)"
- + " AND job_template_name = #{jobTemplateName} AND deleted_at = 0";
- }
-
@Override
public String softDeleteJobTemplateMetasByMetalakeId(@Param("metalakeId")
Long metalakeId) {
return "UPDATE "
@@ -82,4 +66,19 @@ public class JobTemplateMetaPostgreSQLProvider extends
JobTemplateMetaBaseSQLPro
+ JobTemplateMetaMapper.TABLE_NAME
+ " WHERE deleted_at < #{legacyTimeline} AND deleted_at > 0 LIMIT
#{limit})";
}
+
+ @Override
+ public String softDeleteJobTemplateById(
+ @Param("jobTemplateId") Long jobTemplateId, @Param("currentVersion")
Long currentVersion) {
+ return "UPDATE "
+ + JobTemplateMetaMapper.TABLE_NAME
+ + " SET deleted_at = "
+ + DatabaseTimeSQL.POSTGRESQL
+ + " WHERE job_template_id = #{jobTemplateId} AND current_version =
#{currentVersion} AND deleted_at = 0";
+ }
+
+ @Override
+ public String selectJobTemplateByIdForShare(Long jobTemplateId) {
+ return selectJobTemplateIdentityById() + " FOR SHARE";
+ }
}
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 a67b8a21f3..a07a13ce0f 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
@@ -103,17 +103,28 @@ public class JobMetaService {
JobPO.JobPOBuilder builder = JobPO.builder().withMetalakeId(metalakeId);
JobPO jobPO = JobPO.initializeJobPO(jobEntity, builder);
- SessionUtils.doWithCommit(
- JobMetaMapper.class,
- mapper -> {
- if (overwrite) {
- mapper.insertJobMetaOnDuplicateKeyUpdate(jobPO);
- } else {
- mapper.insertJobMeta(jobPO);
- }
- });
+ long templateId =
+ JobTemplateMetaService.getInstance()
+ .getJobTemplateIdByMetalakeIdAndName(metalakeId,
jobEntity.jobTemplateName());
+ SessionUtils.doMultipleWithCommit(
+ () ->
+
MetalakeMetaService.getInstance().lockMetalakeForChildWrite(metalakeName,
metalakeId),
+ () ->
+ JobTemplateMetaService.getInstance()
+ .lockTemplateForJobWrite(jobEntity.jobTemplateName(),
templateId, metalakeId),
+ () ->
+ SessionUtils.doWithoutCommit(
+ JobMetaMapper.class,
+ mapper -> {
+ if (overwrite) {
+ mapper.insertJobMetaOnDuplicateKeyUpdate(jobPO);
+ } else {
+ mapper.insertJobMeta(jobPO);
+ }
+ }));
} catch (RuntimeException e) {
ExceptionUtils.checkSQLException(e, Entity.EntityType.JOB,
jobEntity.id().toString());
+ throw e;
}
}
@@ -132,38 +143,31 @@ public class JobMetaService {
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));
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ OccWriteSupport.updateWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ JobMetaMapper.class, mapper ->
mapper.updateJobMeta(newJobPO, oldJobPO)),
+ () -> writeFailure(jobIdent, oldJobPO)));
} catch (RuntimeException e) {
- ExceptionUtils.checkSQLException(e, Entity.EntityType.JOB,
oldJobEntity.name());
+ ExceptionUtils.checkSQLException(e, Entity.EntityType.JOB,
jobIdent.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;
- }
+ return newJobEntity;
}
@Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "deleteJob")
public boolean deleteJob(NameIdentifier jobIdent) {
- long jobRunIdLong = parseJobRunId(jobIdent.name());
- int result =
- SessionUtils.doWithCommitAndFetchResult(
- JobMetaMapper.class, mapper ->
mapper.softDeleteJobMetaByRunId(jobRunIdLong));
- return result > 0;
+ // Preserve malformed-ID validation even for an otherwise missing job.
+ parseJobRunId(jobIdent.name());
+ try {
+ deleteJobWithVersion(jobIdent, getJobPO(jobIdent));
+ return true;
+ } catch (NoSuchEntityException e) {
+ return false;
+ }
}
@Monitored(
@@ -240,4 +244,32 @@ public class JobMetaService {
.collect(Collectors.toList());
});
}
+
+ /** Deletes a job only if the observed version is still active. */
+ void deleteJobWithVersion(NameIdentifier ident, JobPO observed) {
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ JobMetaMapper.class,
+ mapper ->
+ mapper.softDeleteJobByRunIdWithVersion(
+ observed.jobRunId(),
observed.currentVersion())),
+ () -> writeFailure(ident, observed)));
+ }
+
+ private RuntimeException writeFailure(NameIdentifier ident, JobPO observed) {
+ // Read only the job row: joining and locking the template here would
invert cascade lock order.
+ return OccWriteSupport.writeFailure(
+ ident,
+ Entity.EntityType.JOB,
+ () ->
+ SessionUtils.getWithoutCommit(
+ JobMetaMapper.class,
+ mapper ->
+ mapper.selectJobRunIdForUpdate(observed.jobRunId(),
observed.metalakeId())),
+ null,
+ null);
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
index 15685af492..9f5dffbfa8 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/JobTemplateMetaService.java
@@ -25,7 +25,6 @@ import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
-import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.gravitino.Entity;
@@ -33,6 +32,7 @@ import org.apache.gravitino.HasIdentifier;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
import org.apache.gravitino.meta.JobTemplateEntity;
import org.apache.gravitino.metrics.Monitored;
import org.apache.gravitino.storage.relational.mapper.JobMetaMapper;
@@ -91,15 +91,19 @@ public class JobTemplateMetaService {
JobTemplatePO jobTemplatePO =
JobTemplatePO.initializeJobTemplatePO(jobTemplateEntity, builder);
- SessionUtils.doWithCommit(
- JobTemplateMetaMapper.class,
- mapper -> {
- if (overwrite) {
- mapper.insertJobTemplateMetaOnDuplicateKeyUpdate(jobTemplatePO);
- } else {
- mapper.insertJobTemplateMeta(jobTemplatePO);
- }
- });
+ SessionUtils.doMultipleWithCommit(
+ () ->
+
MetalakeMetaService.getInstance().lockMetalakeForChildWrite(metalakeName,
metalakeId),
+ () ->
+ SessionUtils.doWithoutCommit(
+ JobTemplateMetaMapper.class,
+ mapper -> {
+ if (overwrite) {
+
mapper.insertJobTemplateMetaOnDuplicateKeyUpdate(jobTemplatePO);
+ } else {
+ mapper.insertJobTemplateMeta(jobTemplatePO);
+ }
+ }));
} catch (RuntimeException e) {
ExceptionUtils.checkSQLException(e, Entity.EntityType.JOB_TEMPLATE,
jobTemplateEntity.name());
throw e;
@@ -110,24 +114,12 @@ public class JobTemplateMetaService {
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "deleteJobTemplate")
public boolean deleteJobTemplate(NameIdentifier jobTemplateIdent) {
- String metalakeName = jobTemplateIdent.namespace().level(0);
- String jobTemplateName = jobTemplateIdent.name();
-
- AtomicInteger result = new AtomicInteger(0);
- SessionUtils.doMultipleWithCommit(
- () ->
- SessionUtils.doWithoutCommit(
- JobMetaMapper.class,
- mapper ->
-
mapper.softDeleteJobMetaByMetalakeAndTemplate(metalakeName, jobTemplateName)),
- () ->
- result.set(
- SessionUtils.getWithoutCommit(
- JobTemplateMetaMapper.class,
- mapper ->
- mapper.softDeleteJobTemplateMetaByMetalakeAndName(
- metalakeName, jobTemplateName))));
- return result.get() > 0;
+ try {
+ deleteJobTemplateWithVersion(jobTemplateIdent,
getJobTemplatePO(jobTemplateIdent));
+ return true;
+ } catch (NoSuchEntityException e) {
+ return false;
+ }
}
@Monitored(
@@ -160,31 +152,21 @@ public class JobTemplateMetaService {
JobTemplatePO newJobTemplatePO =
JobTemplatePO.updateJobTemplatePO(oldJobTemplatePO,
newJobTemplateEntity, newBuilder);
- Integer result;
try {
- result =
- SessionUtils.doWithCommitAndFetchResult(
- JobTemplateMetaMapper.class,
- mapper -> mapper.updateJobTemplateMeta(newJobTemplatePO,
oldJobTemplatePO));
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ OccWriteSupport.updateWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ JobTemplateMetaMapper.class,
+ mapper ->
+ mapper.updateJobTemplateMeta(newJobTemplatePO,
oldJobTemplatePO)),
+ () -> writeFailure(jobTemplateIdent, oldJobTemplatePO)));
} catch (RuntimeException e) {
- ExceptionUtils.checkSQLException(
- e, Entity.EntityType.JOB_TEMPLATE, oldJobTemplateEntity.name());
+ ExceptionUtils.checkSQLException(e, Entity.EntityType.JOB_TEMPLATE,
jobTemplateIdent.name());
throw e;
}
-
- if (result == null || result == 0) {
- throw new NoSuchEntityException(
- NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
- Entity.EntityType.JOB_TEMPLATE.name().toLowerCase(Locale.ROOT),
- oldJobTemplateEntity.name());
- } else if (result > 1) {
- throw new IOException(
- String.format(
- "Failed to update job template: %s, because more than one rows
are updated: %d",
- oldJobTemplateEntity.name(), result));
- } else {
- return newJobTemplateEntity;
- }
+ return newJobTemplateEntity;
}
private JobTemplatePO getJobTemplatePO(NameIdentifier jobTemplateIdent) {
@@ -239,4 +221,62 @@ public class JobTemplateMetaService {
.collect(Collectors.toList());
});
}
+
+ /** Deletes the observed template before its jobs, rolling back all changes
on any failure. */
+ void deleteJobTemplateWithVersion(NameIdentifier ident, JobTemplatePO
observed) {
+ SessionUtils.doMultipleWithCommit(
+ () ->
+ OccWriteSupport.deleteWithVersion(
+ () ->
+ SessionUtils.getWithoutCommit(
+ JobTemplateMetaMapper.class,
+ mapper ->
+ mapper.softDeleteJobTemplateById(
+ observed.jobTemplateId(),
observed.currentVersion())),
+ () -> writeFailure(ident, observed)),
+ () -> {
+ // The template CAS holds an exclusive lock, excluding new job
inserts. A locking read
+ // also sees inserts committed while that CAS waited, even under
REPEATABLE READ.
+ Long activeJob =
+ SessionUtils.getWithoutCommit(
+ JobMetaMapper.class,
+ mapper ->
mapper.selectNonterminalJobForUpdate(observed.jobTemplateId()));
+ if (activeJob != null) {
+ throw new NonEmptyEntityException("Job template %s has active
jobs", ident);
+ }
+ },
+ () ->
+ SessionUtils.doWithoutCommit(
+ JobMetaMapper.class,
+ mapper ->
mapper.softDeleteJobsByTemplateId(observed.jobTemplateId())));
+ }
+
+ /** Locks the observed template while a job is inserted in the same
transaction. */
+ void lockTemplateForJobWrite(String name, Long templateId, Long metalakeId) {
+ OccWriteSupport.lockParentForChildWrite(
+ name,
+ Entity.EntityType.JOB_TEMPLATE,
+ () ->
+ SessionUtils.getWithoutCommit(
+ JobTemplateMetaMapper.class,
+ mapper -> mapper.selectJobTemplateByIdForShare(templateId)),
+ null,
+ current ->
+ Objects.equals(current.jobTemplateName(), name)
+ && Objects.equals(current.metalakeId(), metalakeId));
+ }
+
+ private RuntimeException writeFailure(NameIdentifier ident, JobTemplatePO
observed) {
+ return OccWriteSupport.writeFailure(
+ ident,
+ Entity.EntityType.JOB_TEMPLATE,
+ () ->
+ SessionUtils.getWithoutCommit(
+ JobTemplateMetaMapper.class,
+ mapper ->
mapper.selectJobTemplateByIdForUpdate(observed.jobTemplateId())),
+ null,
+ current ->
+ Objects.equals(current.jobTemplateName(),
observed.jobTemplateName())
+ && Objects.equals(current.metalakeId(),
observed.metalakeId()));
+ }
}
diff --git
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
index 174cd97df1..4a2a0faa29 100644
---
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
+++
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
@@ -416,6 +416,19 @@ public class MetalakeMetaService {
() -> metalakeWriteFailure(identifier, metalakeId, identifier.name()));
}
+ /** Locks and validates a metalake while inserting a child in the current
transaction. */
+ void lockMetalakeForChildWrite(String name, Long metalakeId) {
+ OccWriteSupport.lockParentForChildWrite(
+ name,
+ Entity.EntityType.METALAKE,
+ () ->
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class,
+ mapper -> mapper.selectMetalakeMetaByIdForShare(metalakeId)),
+ null,
+ current -> Objects.equals(current.getMetalakeName(), name));
+ }
+
private RuntimeException metalakeWriteFailure(
NameIdentifier identifier, Long metalakeId, String observedName) {
return OccWriteSupport.writeFailure(
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 ac88c2974e..011281f3f0 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -70,6 +70,8 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.exceptions.NoSuchJobException;
import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.lock.LockManager;
import org.apache.gravitino.meta.AuditInfo;
@@ -172,6 +174,63 @@ public class TestJobManager {
}
}
+ @Test
+ public void testRegisterJobTemplateReportsConcurrentMetalakeDeletion()
throws IOException {
+ JobTemplateEntity template = newShellJobTemplateEntity("shell_job", "A
shell job template");
+ NoSuchEntityException missing = new NoSuchEntityException("Metalake was
deleted");
+ doThrow(missing).when(entityStore).put(template, false);
+
+ NoSuchMetalakeException failure =
+ Assertions.assertThrows(
+ NoSuchMetalakeException.class,
+ () -> jobManager.registerJobTemplate(metalake, template));
+ Assertions.assertSame(missing, failure.getCause());
+ }
+
+ @Test
+ public void testRunJobReportsParentDisappearingDuringRegistration() throws
Exception {
+ JobTemplateEntity template = newShellJobTemplateEntity("shell_job", "A
shell job template");
+ doReturn(template).when(jobManager).getJobTemplate(metalake,
template.name());
+ for (Entity.EntityType parent :
+ List.of(Entity.EntityType.METALAKE, Entity.EntityType.JOB_TEMPLATE)) {
+ Mockito.reset(entityStore, jobExecutor);
+ String executionId = "submitted_" + parent.name();
+ when(jobExecutor.submitJob(any())).thenReturn(executionId);
+ NoSuchEntityException missing = new NoSuchEntityException("Parent was
deleted: %s", parent);
+ doThrow(missing).when(entityStore).put(any(JobEntity.class), eq(false));
+
+ NoSuchJobTemplateException failure =
+ Assertions.assertThrows(
+ NoSuchJobTemplateException.class,
+ () -> jobManager.runJob(metalake, template.name(),
Collections.emptyMap()));
+ Assertions.assertSame(missing, failure.getCause());
+ verify(jobExecutor, times(1)).submitJob(any());
+ verify(jobExecutor, never()).cancelJob(any());
+ verify(entityStore, times(1)).put(any(JobEntity.class), eq(false));
+ }
+ }
+
+ @Test
+ public void testAlterJobTemplateDistinguishesMissingFromConflict() throws
IOException {
+ NoSuchEntityException missing = new NoSuchEntityException("Template was
deleted");
+ doThrow(missing).when(entityStore).update(any(), any(), any(), any());
+ NoSuchJobTemplateException failure =
+ Assertions.assertThrows(
+ NoSuchJobTemplateException.class,
+ () -> jobManager.alterJobTemplate(metalake, "shell_job"));
+ Assertions.assertEquals(
+ "Job template with name shell_job under metalake " + metalake + " does
not exist",
+ failure.getMessage());
+
+ OptimisticLockException conflict = new OptimisticLockException("Template
was modified");
+ doThrow(conflict).when(entityStore).update(any(), any(), any(), any());
+ Assertions.assertSame(
+ conflict,
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () -> jobManager.alterJobTemplate(metalake, "shell_job")));
+ }
+
@Test
public void testListJobTemplates() throws IOException {
mockedMetalake
@@ -325,6 +384,86 @@ public class TestJobManager {
RuntimeException.class, () -> jobManager.getJobTemplate(metalake,
"job"));
}
+ /** A failed root CAS must not remove files belonging to the still-active
template. */
+ @Test
+ public void testDeleteJobTemplateConflictPreservesStaging() throws
IOException {
+ JobEntity finishedJob = expiredJob();
+ doReturn(Collections.singletonList(finishedJob))
+ .when(jobManager)
+ .listJobs(metalake, Optional.of("shell_job"));
+ doThrow(new OptimisticLockException("template changed"))
+ .when(entityStore)
+ .delete(
+ NameIdentifierUtil.ofJobTemplate(metalake, "shell_job"),
+ Entity.EntityType.JOB_TEMPLATE);
+ File directory =
+ new File(
+ testStagingDir,
+ metalake + File.separator + "shell_job" + File.separator +
finishedJob.name());
+ Assertions.assertTrue(directory.mkdirs() || directory.isDirectory());
+ File artifact = new File(directory, "artifact");
+ Assertions.assertTrue(artifact.createNewFile());
+ Assertions.assertThrows(
+ OptimisticLockException.class, () ->
jobManager.deleteJobTemplate(metalake, "shell_job"));
+ Assertions.assertTrue(artifact.isFile());
+ doReturn(true)
+ .when(entityStore)
+ .delete(
+ NameIdentifierUtil.ofJobTemplate(metalake, "shell_job"),
+ Entity.EntityType.JOB_TEMPLATE);
+ Assertions.assertTrue(jobManager.deleteJobTemplate(metalake, "shell_job"));
+ Assertions.assertFalse(directory.exists());
+ }
+
+ /** A successful delete must preserve files belonging to a same-name
replacement. */
+ @Test
+ public void testDeletePreservesReplacementStaging() throws IOException {
+ doReturn(Collections.emptyList()).when(jobManager).listJobs(metalake,
Optional.of("shell_job"));
+ File replacementDir =
+ new File(
+ testStagingDir, metalake + File.separator + "shell_job" +
File.separator + "job_999");
+ File replacementArtifact = new File(replacementDir, "new-job-artifact");
+ when(entityStore.delete(
+ NameIdentifierUtil.ofJobTemplate(metalake, "shell_job"),
+ Entity.EntityType.JOB_TEMPLATE))
+ .thenAnswer(
+ invocation -> {
+ // The database delete has committed. Another server recreates
the template and
+ // stages a new job before this server resumes its filesystem
cleanup.
+ Assertions.assertTrue(replacementDir.mkdirs());
+ Assertions.assertTrue(replacementArtifact.createNewFile());
+ return true;
+ });
+
+ Assertions.assertTrue(jobManager.deleteJobTemplate(metalake, "shell_job"));
+ Assertions.assertTrue(replacementArtifact.isFile(), "Replacement job files
must survive");
+ }
+
+ /** A job inserted after the initial check must prevent deletion without
losing staging files. */
+ @Test
+ public void testDeleteJobTemplateReportsConcurrentActiveJob() throws
IOException {
+ JobEntity finishedJob = expiredJob();
+ doReturn(Collections.singletonList(finishedJob))
+ .when(jobManager)
+ .listJobs(metalake, Optional.of("shell_job"));
+ File directory =
+ new File(
+ testStagingDir,
+ metalake + File.separator + "shell_job" + File.separator +
finishedJob.name());
+ Assertions.assertTrue(directory.mkdirs());
+ File artifact = new File(directory, "artifact");
+ Assertions.assertTrue(artifact.createNewFile());
+ doThrow(new NonEmptyEntityException("A job was inserted concurrently"))
+ .when(entityStore)
+ .delete(
+ NameIdentifierUtil.ofJobTemplate(metalake, "shell_job"),
+ Entity.EntityType.JOB_TEMPLATE);
+
+ Assertions.assertThrows(
+ InUseException.class, () -> jobManager.deleteJobTemplate(metalake,
"shell_job"));
+ Assertions.assertTrue(artifact.isFile());
+ }
+
@Test
public void testDeleteJobTemplate() throws IOException {
mockedMetalake
@@ -610,6 +749,19 @@ public class TestJobManager {
}
}
+ /** A metadata conflict must not replay the external cancellation operation.
*/
+ @Test
+ public void testCancelJobDoesNotReplayExecutorOnOccConflict() throws
IOException {
+ JobEntity job = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+ when(jobManager.getJob(metalake, job.name())).thenReturn(job);
+ doNothing().when(jobExecutor).cancelJob(job.jobExecutionId());
+ when(entityStore.update(any(), eq(JobEntity.class),
eq(Entity.EntityType.JOB), any()))
+ .thenThrow(new OptimisticLockException("job changed"));
+ Assertions.assertThrows(
+ OptimisticLockException.class, () -> jobManager.cancelJob(metalake,
job.name()));
+ verify(jobExecutor, times(1)).cancelJob(job.jobExecutionId());
+ }
+
@Test
public void testCancelJob() throws IOException {
mockedMetalake
@@ -1005,53 +1157,13 @@ public class TestJobManager {
@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());
+ assertStatusPollingContinues(new NoSuchEntityException("Job does not
exist"));
+ }
- 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());
+ /** Verifies OCC conflicts do not cancel future status polls. */
+ @Test
+ public void testPullJobStatusContinuesAfterOccConflict() throws IOException {
+ assertStatusPollingContinues(new OptimisticLockException("Job changed
concurrently"));
}
@Test
@@ -1146,6 +1258,38 @@ public class TestJobManager {
Assertions.assertEquals(0L, result.finishedAt());
}
+ /** Conflicts preserve files without stopping this cleanup batch or its next
scheduled run. */
+ @Test
+ public void testCleanUpStagingDirsContinuesAfterOccConflict() throws
IOException {
+ JobEntity conflicted = expiredJob();
+ JobEntity other = expiredJob();
+ mockedMetalake
+ .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+ .thenReturn(ImmutableList.of(metalake));
+ when(jobManager.listJobs(metalake, Optional.empty()))
+ .thenReturn(ImmutableList.of(conflicted, other),
ImmutableList.of(conflicted));
+ NameIdentifier conflictedIdent = NameIdentifierUtil.ofJob(metalake,
conflicted.name());
+ NameIdentifier otherIdent = NameIdentifierUtil.ofJob(metalake,
other.name());
+ when(entityStore.delete(conflictedIdent, Entity.EntityType.JOB))
+ .thenThrow(new OptimisticLockException("job changed"))
+ .thenReturn(true);
+ when(entityStore.delete(otherIdent,
Entity.EntityType.JOB)).thenReturn(true);
+ File conflictedDir = new File(testStagingDir, metalake + "/shell_job/" +
conflicted.name());
+ File otherDir = new File(testStagingDir, metalake + "/shell_job/" +
other.name());
+ Assertions.assertTrue(conflictedDir.mkdirs());
+ Assertions.assertTrue(otherDir.mkdirs());
+ File artifact = new File(conflictedDir, "artifact");
+ Assertions.assertTrue(artifact.createNewFile());
+
+ Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+ Assertions.assertTrue(artifact.isFile());
+ Assertions.assertFalse(otherDir.exists());
+ Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+ Assertions.assertFalse(conflictedDir.exists());
+ verify(entityStore, times(2)).delete(conflictedIdent,
Entity.EntityType.JOB);
+ verify(entityStore, times(1)).delete(otherIdent, Entity.EntityType.JOB);
+ }
+
@Test
public void testCleanUpStagingDirs() throws IOException,
InterruptedException {
JobEntity job = newJobEntity("shell_job", JobHandle.Status.STARTED);
@@ -1400,6 +1544,54 @@ public class TestJobManager {
oldJobTemplateEntity.nameIdentifier(), oldJobTemplateEntity,
invalidChange));
}
+ private void assertStatusPollingContinues(RuntimeException failure) throws
IOException {
+ JobEntity conflictedJob = 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(conflictedJob, survivingJob));
+ when(jobExecutor.getJobStatus(conflictedJob.jobExecutionId()))
+ .thenReturn(JobHandle.Status.SUCCEEDED);
+ when(jobExecutor.getJobStatus(survivingJob.jobExecutionId()))
+ .thenReturn(JobHandle.Status.SUCCEEDED);
+
+ // A losing CAS must not stop this batch or future scheduled polls.
+ NameIdentifier conflictedJobIdent = NameIdentifierUtil.ofJob(metalake,
conflictedJob.name());
+ NameIdentifier survivingJobIdent = NameIdentifierUtil.ofJob(metalake,
survivingJob.name());
+ when(entityStore.update(
+ eq(conflictedJobIdent), eq(JobEntity.class),
eq(Entity.EntityType.JOB), any()))
+ .thenThrow(failure);
+ 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);
+ });
+
+ // Both polls process the other job even when this job keeps conflicting.
+ Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+ Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+ verify(entityStore, times(2))
+ .update(eq(conflictedJobIdent), eq(JobEntity.class),
eq(Entity.EntityType.JOB), any());
+ verify(entityStore, times(2))
+ .update(eq(survivingJobIdent), eq(JobEntity.class),
eq(Entity.EntityType.JOB), any());
+ }
+
private JobTemplateEntity newShellJobTemplateEntity(String name, String
comment) {
ShellJobTemplate shellJobTemplate =
ShellJobTemplate.builder()
@@ -1439,6 +1631,20 @@ public class TestJobManager {
.build();
}
+ private JobEntity expiredJob() {
+ long id = idGenerator.nextId();
+ return JobEntity.builder()
+ .withId(id)
+ .withJobExecutionId(Long.toString(id))
+ .withNamespace(NamespaceUtil.ofJob(metalake))
+ .withJobTemplateName("shell_job")
+ .withStartedAt(1L)
+ .withFinishedAt(2L)
+ .withStatus(JobHandle.Status.SUCCEEDED)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build();
+ }
+
private JobEntity newJobEntity(String templateName, JobHandle.Status status)
{
Random rand = new Random();
return JobEntity.builder()
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
index 1f40cadea2..7d3fec6f43 100644
---
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobTemplateMetaService.java
@@ -23,6 +23,7 @@ import java.time.Instant;
import java.util.List;
import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
import org.apache.gravitino.job.JobHandle;
import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.BaseMetalake;
@@ -196,6 +197,20 @@ public class TestJobTemplateMetaService extends
TestJDBCBackend {
newJobEntity("job_template_with_jobs", JobHandle.Status.SUCCEEDED,
METALAKE_NAME);
backend.insert(jobEntity2, false);
+ Assertions.assertThrows(
+ NonEmptyEntityException.class,
+ () ->
+ jobTemplateMetaService.deleteJobTemplate(
+ NameIdentifierUtil.ofJobTemplate(METALAKE_NAME,
"job_template_with_jobs")));
+ Assertions.assertEquals(
+ 2,
+ JobMetaService.getInstance()
+ .listJobsByNamespace(NamespaceUtil.ofJob(METALAKE_NAME))
+ .size());
+ Assertions.assertTrue(
+ JobMetaService.getInstance()
+ .deleteJob(NameIdentifierUtil.ofJob(METALAKE_NAME,
jobEntity1.name())));
+
boolean deleted =
jobTemplateMetaService.deleteJobTemplate(
NameIdentifierUtil.ofJobTemplate(METALAKE_NAME,
"job_template_with_jobs"));
diff --git
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobWriteOcc.java
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobWriteOcc.java
new file mode 100644
index 0000000000..ffe0652b72
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestJobWriteOcc.java
@@ -0,0 +1,352 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.service;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.meta.JobEntity;
+import org.apache.gravitino.meta.JobTemplateEntity;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.mapper.JobMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.JobTemplateMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
+import org.apache.gravitino.storage.relational.po.JobPO;
+import org.apache.gravitino.storage.relational.po.JobTemplatePO;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.function.Executable;
+
+/** Exercises job and template OCC against each configured relational backend.
*/
+public class TestJobWriteOcc extends TestJDBCBackend {
+ private static final String METALAKE = "job_occ";
+ private final JobMetaService jobs = JobMetaService.getInstance();
+ private final JobTemplateMetaService templates =
JobTemplateMetaService.getInstance();
+ private JobTemplateEntity template;
+ private JobEntity job;
+ private long metalakeId;
+
+ /** A version-only change defeats stale updates and deletes without hiding
the entity. */
+ @TestTemplate
+ public void testJobConflictsAndIdempotentDelete() throws IOException {
+ initialize();
+ JobPO observed = jobPO();
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ jobs.<JobEntity>updateJob(
+ jobIdent(),
+ old -> {
+ Assertions.assertDoesNotThrow(
+ () -> jobs.updateJob(jobIdent(), current -> current));
+ return old;
+ }));
+ Assertions.assertEquals(observed.currentVersion() + 1,
jobPO().currentVersion());
+ Assertions.assertThrows(
+ OptimisticLockException.class, () ->
jobs.deleteJobWithVersion(jobIdent(), observed));
+ Assertions.assertTrue(jobs.deleteJob(jobIdent()));
+ Assertions.assertFalse(jobs.deleteJob(jobIdent()));
+ Assertions.assertThrows(
+ NoSuchEntityException.class, () -> jobs.updateJob(jobIdent(), current
-> current));
+ }
+
+ /** A delete winning after the update read prevents resurrection. */
+ @TestTemplate
+ public void testDeleteWinsOverJobUpdate() throws IOException {
+ initialize();
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () ->
+ jobs.<JobEntity>updateJob(
+ jobIdent(),
+ old -> {
+ Assertions.assertTrue(jobs.deleteJob(jobIdent()));
+ return old;
+ }));
+ Assertions.assertFalse(jobs.deleteJob(jobIdent()));
+ }
+
+ /** Failed template CAS leaves child jobs untouched; a current delete
removes both. */
+ @TestTemplate
+ public void testTemplateConflictsBeforeCascade() throws IOException {
+ initialize();
+ JobTemplatePO observed = templatePO();
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () ->
+ templates.<JobTemplateEntity>updateJobTemplate(
+ templateIdent(),
+ old -> {
+ Assertions.assertDoesNotThrow(
+ () -> templates.updateJobTemplate(templateIdent(),
current -> current));
+ return old;
+ }));
+ Assertions.assertEquals(observed.currentVersion() + 1,
templatePO().currentVersion());
+ Assertions.assertThrows(
+ OptimisticLockException.class,
+ () -> templates.deleteJobTemplateWithVersion(templateIdent(),
observed));
+ Assertions.assertEquals(job.id(),
jobs.getJobByIdentifier(jobIdent()).id());
+ Assertions.assertTrue(templates.deleteJobTemplate(templateIdent()));
+ Assertions.assertFalse(templates.deleteJobTemplate(templateIdent()));
+
Assertions.assertTrue(jobs.listJobsByNamespace(NamespaceUtil.ofJob(METALAKE)).isEmpty());
+ }
+
+ /** Rollback restores the template and its already-deleted jobs. */
+ @TestTemplate
+ public void testCascadeRollback() throws IOException {
+ initialize();
+ JobTemplatePO observed = templatePO();
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () ->
+ SessionUtils.doMultipleWithCommit(
+ () -> templates.deleteJobTemplateWithVersion(templateIdent(),
observed),
+ () -> {
+ throw new IllegalStateException("injected failure after
cascade");
+ }));
+ Assertions.assertEquals(observed.currentVersion(),
templatePO().currentVersion());
+ Assertions.assertEquals(job.id(),
jobs.getJobByIdentifier(jobIdent()).id());
+ }
+
+ /** A stale snapshot cannot delete a same-name replacement or its jobs. */
+ @TestTemplate
+ public void testSameNameRecreation() throws IOException {
+ initialize();
+ JobTemplatePO observed = templatePO();
+ templates.deleteJobTemplate(templateIdent());
+ template =
TestJobTemplateMetaService.newShellJobTemplateEntity("template", "new",
METALAKE);
+ templates.insertJobTemplate(template, false);
+ job = TestJobTemplateMetaService.newJobEntity("template",
JobHandle.Status.QUEUED, METALAKE);
+ jobs.insertJob(job, false);
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () -> templates.deleteJobTemplateWithVersion(templateIdent(),
observed));
+ Assertions.assertEquals(job.id(),
jobs.getJobByIdentifier(jobIdent()).id());
+ }
+
+ /** Job insertion waits for an in-flight template delete and then fails
without inserting. */
+ @TestTemplate
+ public void testJobInsertFencedByTemplateDelete() throws Exception {
+ initialize();
+ JobTemplatePO observed = templatePO();
+ JobEntity candidate =
+ TestJobTemplateMetaService.newJobEntity("template",
JobHandle.Status.QUEUED, METALAKE);
+ Throwable failure =
+ whileWriteUncommitted(
+ () -> templates.deleteJobTemplateWithVersion(templateIdent(),
observed),
+ () -> jobs.insertJob(candidate, false));
+ Assertions.assertInstanceOf(NoSuchEntityException.class, failure);
+ Assertions.assertNull(
+ SessionUtils.getWithoutCommit(
+ JobMetaMapper.class,
+ mapper -> mapper.selectJobRunIdForUpdate(candidate.id(),
metalakeId)));
+ }
+
+ /** Metalake fencing rejects both kinds of insert after parent deletion
commits. */
+ @TestTemplate
+ public void testInsertsFencedByMetalakeDelete() throws Exception {
+ initialize();
+ JobTemplateEntity candidate =
+ TestJobTemplateMetaService.newShellJobTemplateEntity("other", "new",
METALAKE);
+ Throwable failure =
+ whileWriteUncommitted(
+ () ->
+ SessionUtils.doWithoutCommit(
+ MetalakeMetaMapper.class,
+ mapper ->
mapper.softDeleteMetalakeMetaByMetalakeId(metalakeId, 1L)),
+ () -> templates.insertJobTemplate(candidate, false));
+ Assertions.assertInstanceOf(NoSuchEntityException.class, failure);
+ Assertions.assertThrows(NoSuchEntityException.class, () ->
jobs.insertJob(job, false));
+ }
+
+ /** Missing templates are reported instead of silently dropping job
insertion failures. */
+ @TestTemplate
+ public void testMissingTemplateInsertFails() throws IOException {
+ initialize();
+ JobEntity candidate =
+ TestJobTemplateMetaService.newJobEntity("missing",
JobHandle.Status.QUEUED, METALAKE);
+ Assertions.assertThrows(NoSuchEntityException.class, () ->
jobs.insertJob(candidate, false));
+ }
+
+ /** A parent delete waits for an in-flight insertion and preserves the
committed active job. */
+ @TestTemplate
+ public void testTemplateDeleteWaitsForJobInsert() throws Exception {
+ initialize();
+ JobEntity candidate =
+ TestJobTemplateMetaService.newJobEntity("template",
JobHandle.Status.QUEUED, METALAKE);
+ Throwable failure =
+ whileWriteUncommitted(
+ () -> Assertions.assertDoesNotThrow(() ->
jobs.insertJob(candidate, false)),
+ () -> templates.deleteJobTemplate(templateIdent()));
+ Assertions.assertInstanceOf(NonEmptyEntityException.class, failure);
+ Assertions.assertEquals(template.id(), templatePO().jobTemplateId());
+ Assertions.assertEquals(1L, templatePO().currentVersion());
+ Assertions.assertEquals(
+ candidate.id(),
+ jobs.getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE,
candidate.name())).id());
+ }
+
+ /** An identifier under another metalake cannot delete a job solely by its
numeric run ID. */
+ @TestTemplate
+ public void testDeleteChecksMetalakeIdentity() throws IOException {
+ initialize();
+ Assertions.assertFalse(jobs.deleteJob(NameIdentifierUtil.ofJob("other",
job.name())));
+ Assertions.assertEquals(job.id(),
jobs.getJobByIdentifier(jobIdent()).id());
+ }
+
+ /** Renaming invalidates the old name while cascade cleanup continues to use
stable IDs. */
+ @TestTemplate
+ public void testRenameAndCascadeIdentity() throws IOException {
+ initialize();
+ NameIdentifier oldIdent = templateIdent();
+ JobTemplatePO observed = templatePO();
+ template =
+ templates.<JobTemplateEntity>updateJobTemplate(
+ oldIdent,
+ old ->
+ JobTemplateEntity.builder()
+ .withId(old.id())
+ .withName("renamed")
+ .withNamespace(old.namespace())
+ .withComment(old.comment())
+ .withTemplateContent(old.templateContent())
+ .withAuditInfo(old.auditInfo())
+ .build());
+ Assertions.assertThrows(
+ NoSuchEntityException.class,
+ () -> templates.deleteJobTemplateWithVersion(oldIdent, observed));
+ Assertions.assertEquals("renamed",
jobs.getJobByIdentifier(jobIdent()).jobTemplateName());
+ Assertions.assertTrue(templates.deleteJobTemplate(templateIdent()));
+ Assertions.assertNull(
+ SessionUtils.getWithoutCommit(
+ JobMetaMapper.class, mapper ->
mapper.selectJobRunIdForUpdate(job.id(), metalakeId)));
+ }
+
+ /** All nonterminal states reject deletion and roll back the root CAS. */
+ @TestTemplate
+ public void testNonterminalJobsPreventTemplateDeletion() throws IOException {
+ initialize();
+ for (JobHandle.Status status :
+ new JobHandle.Status[] {
+ JobHandle.Status.QUEUED, JobHandle.Status.STARTED,
JobHandle.Status.CANCELLING
+ }) {
+ JobEntity active = TestJobTemplateMetaService.newJobEntity("template",
status, METALAKE);
+ jobs.insertJob(active, false);
+ Assertions.assertThrows(
+ NonEmptyEntityException.class, () ->
templates.deleteJobTemplate(templateIdent()));
+ Assertions.assertEquals(1L, templatePO().currentVersion());
+ Assertions.assertEquals(job.id(),
jobs.getJobByIdentifier(jobIdent()).id());
+ Assertions.assertEquals(
+ active.id(),
+ jobs.getJobByIdentifier(NameIdentifierUtil.ofJob(METALAKE,
active.name())).id());
+ Assertions.assertTrue(jobs.deleteJob(NameIdentifierUtil.ofJob(METALAKE,
active.name())));
+ }
+ for (JobHandle.Status status :
+ new JobHandle.Status[] {JobHandle.Status.CANCELLED,
JobHandle.Status.FAILED}) {
+ jobs.insertJob(TestJobTemplateMetaService.newJobEntity("template",
status, METALAKE), false);
+ }
+ Assertions.assertTrue(templates.deleteJobTemplate(templateIdent()));
+
Assertions.assertTrue(jobs.listJobsByNamespace(NamespaceUtil.ofJob(METALAKE)).isEmpty());
+ }
+
+ private void initialize() throws IOException {
+ metalakeId = RandomIdGenerator.INSTANCE.nextId();
+ backend.insert(createBaseMakeLake(metalakeId, METALAKE, AUDIT_INFO),
false);
+ template =
+ TestJobTemplateMetaService.newShellJobTemplateEntity("template",
"original", METALAKE);
+ templates.insertJobTemplate(template, false);
+ job = TestJobTemplateMetaService.newJobEntity("template",
JobHandle.Status.SUCCEEDED, METALAKE);
+ jobs.insertJob(job, false);
+ }
+
+ private NameIdentifier jobIdent() {
+ return NameIdentifierUtil.ofJob(METALAKE, job.name());
+ }
+
+ private NameIdentifier templateIdent() {
+ return NameIdentifierUtil.ofJobTemplate(METALAKE, template.name());
+ }
+
+ private JobPO jobPO() {
+ return SessionUtils.getWithoutCommit(
+ JobMetaMapper.class, mapper ->
mapper.selectJobPOByMetalakeAndRunId(METALAKE, job.id()));
+ }
+
+ private JobTemplatePO templatePO() {
+ return SessionUtils.getWithoutCommit(
+ JobTemplateMetaMapper.class, mapper ->
mapper.selectJobTemplateById(template.id()));
+ }
+
+ private Throwable whileWriteUncommitted(Runnable write, Executable victim)
throws Exception {
+ CountDownLatch locked = new CountDownLatch(1);
+ CountDownLatch commit = new CountDownLatch(1);
+ CountDownLatch started = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ Future<?> writer =
+ executor.submit(
+ () ->
+ SessionUtils.doMultipleWithCommit(
+ write,
+ () -> {
+ locked.countDown();
+ try {
+ Assertions.assertTrue(commit.await(30,
TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }));
+ Assertions.assertTrue(locked.await(30, TimeUnit.SECONDS));
+ Future<Throwable> reader =
+ executor.submit(
+ () -> {
+ started.countDown();
+ try {
+ victim.execute();
+ return null;
+ } catch (Throwable t) {
+ return t;
+ }
+ });
+ Assertions.assertTrue(started.await(30, TimeUnit.SECONDS));
+ Assertions.assertThrows(TimeoutException.class, () -> reader.get(500,
TimeUnit.MILLISECONDS));
+ commit.countDown();
+ writer.get(30, TimeUnit.SECONDS);
+ return reader.get(30, TimeUnit.SECONDS);
+ } finally {
+ commit.countDown();
+ executor.shutdownNow();
+ Assertions.assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS));
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
index 07d56d6ea8..ff03f3462b 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestExceptionHandlers.java
@@ -22,6 +22,9 @@ import java.util.List;
import javax.ws.rs.core.Response;
import org.apache.gravitino.dto.responses.ErrorConstants;
import org.apache.gravitino.dto.responses.ErrorResponse;
+import org.apache.gravitino.exceptions.InUseException;
+import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
+import org.apache.gravitino.exceptions.NoSuchMetalakeException;
import org.apache.gravitino.exceptions.OptimisticLockException;
import org.apache.gravitino.exceptions.UnmodifiableStatisticException;
import org.junit.jupiter.api.Assertions;
@@ -29,6 +32,46 @@ import org.junit.jupiter.api.Test;
public class TestExceptionHandlers {
+ @Test
+ void testConcurrentActiveJobReturnsConflict() {
+ try (Response response =
+ ExceptionHandlers.handleJobTemplateException(
+ OperationType.DELETE,
+ "template",
+ "metalake",
+ new InUseException("Template has an active job"))) {
+ Assertions.assertEquals(Response.Status.CONFLICT.getStatusCode(),
response.getStatus());
+ Assertions.assertEquals(
+ InUseException.class.getSimpleName(), ((ErrorResponse)
response.getEntity()).getType());
+ }
+ }
+
+ @Test
+ public void testMissingJobParentsReturnNotFound() {
+ Response runResponse =
+ ExceptionHandlers.handleJobException(
+ OperationType.RUN,
+ "template",
+ "metalake",
+ new NoSuchJobTemplateException("Template disappeared"));
+ Assertions.assertEquals(Response.Status.NOT_FOUND.getStatusCode(),
runResponse.getStatus());
+ Assertions.assertEquals(
+ NoSuchJobTemplateException.class.getSimpleName(),
+ ((ErrorResponse) runResponse.getEntity()).getType());
+
+ Response registerResponse =
+ ExceptionHandlers.handleJobTemplateException(
+ OperationType.REGISTER,
+ "template",
+ "metalake",
+ new NoSuchMetalakeException("Metalake disappeared"));
+ Assertions.assertEquals(
+ Response.Status.NOT_FOUND.getStatusCode(),
registerResponse.getStatus());
+ Assertions.assertEquals(
+ NoSuchMetalakeException.class.getSimpleName(),
+ ((ErrorResponse) registerResponse.getEntity()).getType());
+ }
+
@Test
public void testGetErrorMsg() {
Exception e1 = new Exception("test1");