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 0562a9047b [#13703] feat(k8s-job-executor): Apply spark_conf of a
built-in job when submitting it (#13982)
0562a9047b is described below
commit 0562a9047b14733862af9550474991b3593b5184
Author: Jerry Shao <[email protected]>
AuthorDate: Mon Oct 26 19:55:04 2026 +0800
[#13703] feat(k8s-job-executor): Apply spark_conf of a built-in job when
submitting it (#13982)
### What changes were proposed in this pull request?
- k8s job executor: for a `builtin-` template, parse its `--spark-conf`
argument (`spark_conf`) and merge it into the SparkApplication
`spec.sparkConf` (server `spark.conf.*` defaults < environments <
template `configs` < `spark_conf`). In `spark_conf`, `spark.master` /
`spark.submit.deployMode` and all `spark.kubernetes.*` keys are ignored
with a warning; an invalid `spark_conf` rejects the run.
- k8s job executor: reject a `spark.master` that isn't a `k8s://` master
(from the template or the executor defaults), like a client deploy mode,
instead of silently dropping it.
- Blank Spark configuration values mean "unset": the local job executor
doesn't pass them to spark-submit, and the k8s job executor doesn't let
them override its defaults.
- Built-in templates: `spark_master` defaults to empty instead of
`local[*]` (spark-submit's own default), template versions bumped.
- Built-in jobs (driver): ignore `spark.master` /
`spark.submit.deployMode` from `spark_conf` once spark-submit has set
them, drop `spark.kubernetes.*` and blank values.
- Optimizer `submit-update-stats-job`: no longer defaults `spark_master`
to `local[*]` or takes it from `spark.master` in `spark_conf`.
- Docs and the "Missing Iceberg Spark session extensions" error message
no longer recommend `spark.jars` in `spark_conf` for the local job
executor.
### Why are the changes needed?
`spark_conf` of a built-in job is applied by the driver after it starts,
too late for submit-time configurations: `spark.jars` /
`spark.jars.packages` don't reach the driver classpath, and
`spark.master` in `spark_conf` made a job run in local mode inside the
driver pod while reporting success. Built-in templates also defaulted to
`local[*]`, which only worked on Kubernetes because the executor
silently dropped the master.
Fix: #13703
### Does this PR introduce _any_ user-facing change?
- With the k8s job executor, `spark_conf` of a built-in job takes effect
at submit time (e.g. `spark.jars`, `spark.jars.packages`,
`spark.driver.*`); its `spark.kubernetes.*` keys are ignored.
- A job whose `spark.master` isn't a Kubernetes master is rejected by
the k8s job executor; an invalid `spark_conf` of a built-in job is
rejected.
- Built-in template versions: `v3`
(`builtin-iceberg-update-manifest-stats`: `v2`); `spark_master` defaults
to empty.
- No new configuration keys.
### How was this patch tested?
- New unit tests in `TestSparkApplicationUtils`,
`TestSparkProcessBuilder`, `TestIcebergJobUtils`, `TestOptimizerCmd` and
the template tests.
- Manually on OrbStack (spark-kubernetes-operator 0.9.0, Spark 3.5.9,
image without the Iceberg runtime): built-in Iceberg jobs with the
runtime from `spark.jars` (https) and `spark.jars.packages` in
`spark_conf` succeed; `spark.master` and `spark.kubernetes.*` in
`spark_conf` are ignored; a local master and an invalid `spark_conf` are
rejected; an empty `spark_conf` keeps the executor's default
`spark.jars`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Qi Yu <[email protected]>
---
.../gravitino/job/local/SparkProcessBuilder.java | 7 +-
.../job/local/TestSparkProcessBuilder.java | 24 ++
.../optimizer-cli-reference.md | 37 ++-
.../optimizer-configuration.md | 12 +-
.../optimizer-troubleshooting.md | 15 +-
docs/table-maintenance-service/optimizer.md | 9 +-
.../jobs/iceberg/IcebergExpireSnapshotsJob.java | 2 +-
.../maintenance/jobs/iceberg/IcebergJobUtils.java | 96 ++++++-
.../jobs/iceberg/IcebergRemoveOrphanFilesJob.java | 2 +-
.../jobs/iceberg/IcebergRewriteDataFilesJob.java | 2 +-
.../jobs/iceberg/IcebergRewriteManifestsJob.java | 2 +-
.../iceberg/IcebergUpdateManifestStatsJob.java | 4 +-
.../iceberg/IcebergUpdateStatsAndMetricsJob.java | 4 +-
.../maintenance/jobs/spark/SparkPiJob.java | 6 +-
.../iceberg/TestIcebergExpireSnapshotsJob.java | 2 +-
.../jobs/iceberg/TestIcebergJobUtils.java | 43 ++++
.../iceberg/TestIcebergRewriteDataFilesJob.java | 2 +-
.../iceberg/TestIcebergRewriteManifestsJob.java | 2 +-
.../iceberg/TestIcebergUpdateManifestStatsJob.java | 2 +-
.../jobs/iceberg/TestIcebergUpdateStatsJob.java | 2 +-
.../maintenance/jobs/spark/TestSparkPiJob.java | 4 +-
.../common/util/IcebergSparkConfigUtils.java | 4 +-
.../common/util/TestIcebergSparkConfigUtils.java | 7 +
.../command/SubmitUpdateStatsJobCommand.java | 22 +-
.../maintenance/optimizer/TestOptimizerCmd.java | 17 +-
plugins/k8s-job-executor/build.gradle.kts | 3 +
.../job/k8s/spark/SparkApplicationUtils.java | 164 +++++++++++-
.../job/k8s/TestK8sJobExecutorShadedJar.java | 3 +-
.../job/k8s/spark/TestSparkApplicationUtils.java | 279 ++++++++++++++++++++-
29 files changed, 717 insertions(+), 61 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java
b/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java
index c789b26ed0..ec79f5e4b7 100644
--- a/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java
+++ b/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java
@@ -117,8 +117,13 @@ public class SparkProcessBuilder extends
LocalProcessBuilder {
commandList.add(String.join(",", sparkJobTemplate.archives()));
}
- // Add the Spark configs
+ // Add the Spark configs. A blank value means the configuration isn't set,
as it is what an
+ // optional template parameter such as {{spark_jars:-}} becomes, and
spark-submit would reject
+ // or misread a blank value of some configurations, such as spark.master.
for (Map.Entry<String, String> entry :
sparkJobTemplate.configs().entrySet()) {
+ if (StringUtils.isBlank(entry.getValue())) {
+ continue;
+ }
commandList.add("--conf");
commandList.add(entry.getKey() + "=" + entry.getValue());
}
diff --git
a/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
b/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
index e3d8f4295d..c3612a7edc 100644
---
a/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
+++
b/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
@@ -150,6 +150,30 @@ public class TestSparkProcessBuilder {
Assertions.assertTrue(command4.contains("arg2"));
}
+ @Test
+ public void testGenerateSparkSubmitCommandSkipsBlankConfigs() {
+ SparkJobTemplate template =
+ SparkJobTemplate.builder()
+ .withName("template")
+ .withExecutable("/path/to/spark-demo.jar")
+ .withClassName("com.example.MainClass")
+ .withConfigs(
+ ImmutableMap.of(
+ "spark.master", "",
+ "spark.jars", " ",
+ "spark.executor.memory", "2g"))
+ .build();
+
+ List<String> command =
+
SparkProcessBuilder.generateSparkSubmitCommand("/path/to/spark-submit",
template);
+
+ Assertions.assertEquals(1, Collections.frequency(command, "--conf"));
+ int idxConf = command.indexOf("--conf");
+ Assertions.assertEquals("spark.executor.memory=2g", command.get(idxConf +
1));
+ Assertions.assertTrue(command.stream().noneMatch(arg ->
arg.startsWith("spark.master")));
+ Assertions.assertTrue(command.stream().noneMatch(arg ->
arg.startsWith("spark.jars")));
+ }
+
@Test
public void testResolveSparkSubmit() throws IOException {
File validSparkHome =
Files.createTempDirectory("gravitino-test-spark-home").toFile();
diff --git a/docs/table-maintenance-service/optimizer-cli-reference.md
b/docs/table-maintenance-service/optimizer-cli-reference.md
index f01ace00cc..82fdec1ecc 100644
--- a/docs/table-maintenance-service/optimizer-cli-reference.md
+++ b/docs/table-maintenance-service/optimizer-cli-reference.md
@@ -182,7 +182,10 @@ Notes:
- For `stats` or `all`, `--updater-options` must include `gravitino_uri` and
`metalake`.
- If `--updater-options` includes external JDBC metrics settings
(`gravitino.optimizer.jdbcMetrics.*`), ensure the JDBC driver JAR is
available to Spark
- runtime classpath (for example via `spark.jars` in `--spark-conf`).
+ runtime classpath of the driver: with the k8s job executor, for example via
`spark.jars` in
+ `--spark-conf`, which replaces a `spark.jars` default of the k8s job
executor, so list the
+ Iceberg runtime there too if that default provides it; with the local job
executor, by installing
+ it into `SPARK_HOME/jars`, as the driver applies `--spark-conf` only after
it starts.
- `--spark-conf` and `--updater-options` are flat JSON maps.
## List Table Metrics
@@ -229,7 +232,7 @@ Examples:
SUMMARY: statistics totalRecords=3 tableRecords=2 partitionRecords=1
jobRecords=0
DRY-RUN: strategy=iceberg-data-compaction identifier=rest_catalog.db.t1
score=95 jobTemplate=builtin-iceberg-rewrite-data-files
jobOptions={catalog_name=rest_catalog, table_identifier=db.t1}
SUBMIT: strategy=iceberg-data-compaction identifier=rest_catalog.db.t1
score=95 jobTemplate=builtin-iceberg-rewrite-data-files
jobOptions={catalog_name=rest_catalog, table_identifier=db.t1}
jobId=1f54c6d3-4e27-4cc8-bdfa-b05ecf59a4c2
-DRY-RUN: identifier=rest_catalog.db.t1
jobTemplate=builtin-iceberg-update-stats jobConfig={catalog_name=rest_catalog,
table_identifier=db.t1, update_mode=all,
updater_options={"gravitino_uri":"http://localhost:8090","metalake":"test"},
spark_conf={"spark.master":"local[2]","spark.hadoop.fs.defaultFS":"file:///"}}
+DRY-RUN: identifier=rest_catalog.db.t1
jobTemplate=builtin-iceberg-update-stats jobConfig={catalog_name=rest_catalog,
table_identifier=db.t1, update_mode=all,
updater_options={"gravitino_uri":"http://localhost:8090","metalake":"test"},
spark_conf={"spark.hadoop.fs.defaultFS":"file:///"}}
SUMMARY: submit-update-stats-job total=1 submitted=1 dryRun=false
MetricsResult{scopeType=TABLE, identifier=rest_catalog.db.t1,
partitionPath=<table-or-job-scope>, metrics={row_count=[{timestamp=1735689600,
value=100}]}}
EvaluationResult{scopeType=TABLE, identifier=rest_catalog.db.t1,
partitionPath=<table-or-job-scope>, evaluation=true,
evaluatorName=gravitino-metrics-evaluator, actionTimeSeconds=1735689600,
rangeSeconds=86400,
beforeMetrics={row_count=[MetricSample{timestampSeconds=1735686000,
value=120}]},
afterMetrics={row_count=[MetricSample{timestampSeconds=1735689600, value=100}]}}
@@ -254,7 +257,12 @@ These templates set Iceberg Spark session and catalog
classes, but they do not l
runtime in `jars`. `gravitino-jobs` also excludes that runtime from its shaded
JAR, so the version
that runs with your Spark cluster is yours to supply. Provide a matching
`iceberg-spark-runtime-<sparkMajor>_<scala>` JAR on the Spark classpath used
by the job executor —
-commonly through `spark.jars` in `spark_conf`, or by installing it into
`SPARK_HOME`. Align the
+commonly by installing it into `SPARK_HOME` or the Spark image, or, with the
k8s job executor,
+through `spark.jars` in `spark_conf`, which the k8s job executor sets when it
submits the job. It
+replaces a `spark.jars` default of the k8s job executor rather than adding to
it, so list every jar
+the job needs there.
+With the local job executor, `spark.jars` in `spark_conf` doesn't add it, as
the driver applies
+`spark_conf` only after spark-submit has built its classpath. Align the
artifact with the Spark, Scala, and Iceberg versions you actually run. A
reference coordinate used
in Gravitino's own jobs tests is
`org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.11.0`.
Without that runtime, built-in Iceberg jobs fail after Spark starts instead of
continuing without
@@ -265,18 +273,25 @@ left out, and a submission that misses a required key is
rejected with an error
missing keys. An empty string is a value, not an omission: it overrides the
default and means
"not set" to these jobs.
-| Job template | Required keys
| Optional keys and defaults
|
-|--------------------------------------|-----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
-| Every Iceberg job | `catalog_name`, `table_identifier`,
`catalog_type`, `catalog_uri`, `warehouse_location` | `spark_master`
(`local[*]`), `spark_executor_instances` (`1`), `spark_executor_cores` (`1`),
`spark_executor_memory` (`1g`), `spark_driver_memory` (`1g`), `spark_conf`
(empty) |
-| `builtin-iceberg-update-stats` | —
| `update_mode` (`all`),
`updater_options` (empty)
|
-| `builtin-iceberg-rewrite-data-files` | `where_clause` (pass `""` to rewrite
the whole table) | `strategy` (`binpack`),
`sort_order` (empty), `options` (empty)
|
-| `builtin-iceberg-expire-snapshots` | —
| `older_than` (empty),
`retain_last` (empty), `stream_results` (`false`)
|
-| `builtin-iceberg-remove-orphan-files` | —
| `older_than` (empty),
`location` (empty), `dry_run` (`false`)
|
-| `builtin-iceberg-rewrite-manifests` | —
| `use_caching` (empty),
`spec_id` (empty)
|
+| Job template | Required keys
| Optional keys and defaults
|
+|---------------------------------------|-----------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Every Iceberg job | `catalog_name`, `table_identifier`,
`catalog_type`, `catalog_uri`, `warehouse_location` | `spark_master` (empty),
`spark_executor_instances` (`1`), `spark_executor_cores` (`1`),
`spark_executor_memory` (`1g`), `spark_driver_memory` (`1g`), `spark_conf`
(empty) |
+| `builtin-iceberg-update-stats` | —
| `update_mode` (`all`),
`updater_options` (empty)
|
+| `builtin-iceberg-rewrite-data-files` | `where_clause` (pass `""` to rewrite
the whole table) | `strategy` (`binpack`),
`sort_order` (empty), `options` (empty)
|
+| `builtin-iceberg-expire-snapshots` | —
| `older_than` (empty),
`retain_last` (empty), `stream_results` (`false`)
|
+| `builtin-iceberg-remove-orphan-files` | —
| `older_than` (empty),
`location` (empty), `dry_run` (`false`)
|
+| `builtin-iceberg-rewrite-manifests` | —
| `use_caching` (empty),
`spec_id` (empty)
|
`where_clause` has no default on purpose: an empty where clause compacts every
data file in the
table, so it must be asked for explicitly.
+An empty `spark_master` leaves the master to the job executor: the local job
executor runs the job
+with the spark-submit default `local[*]`, and the k8s job executor rejects any
other master than a
+Kubernetes one. `spark.master` and `spark.submit.deployMode` in `spark_conf`
are ignored, as the job
+is submitted with them already set. So are the `spark.kubernetes.*`
configurations in `spark_conf`,
+such as the namespace, the image, the service account, Secrets, and volumes:
whoever runs a job sets
+`spark_conf`, so only the job executor configurations and job templates may
set them.
+
## Update Statistics
`builtin-iceberg-update-stats` reads a table and writes back the statistics
and metrics that policies evaluate. Compaction policies read
`custom-data-file-mse` and `custom-delete-file-number`, so nothing else will
fire until this job has run at least once.
diff --git a/docs/table-maintenance-service/optimizer-configuration.md
b/docs/table-maintenance-service/optimizer-configuration.md
index bd9bd65b46..a7618665b0 100644
--- a/docs/table-maintenance-service/optimizer-configuration.md
+++ b/docs/table-maintenance-service/optimizer-configuration.md
@@ -50,7 +50,7 @@ gravitino.optimizer.jobSubmitterConfig.catalog_type = rest
gravitino.optimizer.jobSubmitterConfig.catalog_uri =
http://localhost:9001/iceberg
# Leave empty for a local filesystem; set to your warehouse URI for cloud or
HDFS storage.
gravitino.optimizer.jobSubmitterConfig.warehouse_location =
-gravitino.optimizer.jobSubmitterConfig.spark_conf =
{"spark.master":"local[2]","spark.hadoop.fs.defaultFS":"file:///"}
+gravitino.optimizer.jobSubmitterConfig.spark_conf =
{"spark.hadoop.fs.defaultFS":"file:///"}
```
For manifest rewrite policies, the mapping
@@ -96,7 +96,7 @@ A direct job submission carries its own `jobConf`. This is
`builtin-iceberg-upda
"table_identifier": "db.t1",
"update_mode": "all",
"updater_options":
"{\"gravitino_uri\":\"http://localhost:8090\",\"metalake\":\"test\",\"statistics_updater\":\"gravitino-statistics-updater\",\"metrics_updater\":\"gravitino-metrics-updater\",\"auth_type\":\"basic\",\"username\":\"admin\",\"password\":\"YourSecureGravitinoPassword\"}",
- "spark_conf":
"{\"spark.master\":\"local[2]\",\"spark.hadoop.fs.defaultFS\":\"file:///\"}",
+ "spark_conf": "{\"spark.hadoop.fs.defaultFS\":\"file:///\"}",
"spark_master": "local[2]",
"spark_executor_instances": "1",
"spark_executor_cores": "1",
@@ -117,8 +117,12 @@ that lists the missing keys. See
and the defaults of each template.
Built-in Iceberg templates also need an Iceberg Spark runtime on the Spark
classpath. They do not
-ship that JAR or fill template `jars`, so include it yourself — for example
-`"spark.jars":"/path/to/iceberg-spark-runtime-....jar"` inside `spark_conf`.
Match the artifact to
+ship that JAR or fill template `jars`, so include it yourself — for example by
installing it into
+`SPARK_HOME/jars`, or, with the k8s job executor, by
+`"spark.jars":"https://.../iceberg-spark-runtime-....jar"` inside
`spark_conf`, which replaces a
+`spark.jars` default of the k8s job executor, so list every jar the job needs
there. With the local job
+executor, `spark.jars` inside `spark_conf` doesn't add it, as the driver
applies `spark_conf` only
+after it starts. Match the artifact to
your Spark, Scala, and Iceberg versions. Details are under
[Built-in Job Templates](./optimizer-cli-reference.md#built-in-job-templates).
diff --git a/docs/table-maintenance-service/optimizer-troubleshooting.md
b/docs/table-maintenance-service/optimizer-troubleshooting.md
index 98e738021b..744ecf867d 100644
--- a/docs/table-maintenance-service/optimizer-troubleshooting.md
+++ b/docs/table-maintenance-service/optimizer-troubleshooting.md
@@ -64,14 +64,17 @@ These are the hard ones, because success and "the policy
decided not to act" loo
spark.hadoop.fs.defaultFS=file:///
```
-**`submit-update-stats-job` fails with JDBC metrics errors** — when
`--updater-options` includes `gravitino.optimizer.jdbcMetrics.*`, the JDBC
driver has to be on the Spark runtime classpath. `ClassNotFoundException` and
`No suitable driver` both mean the same thing:
+**`submit-update-stats-job` fails with JDBC metrics errors** — when
`--updater-options` includes `gravitino.optimizer.jdbcMetrics.*`, the JDBC
driver has to be on the Spark runtime classpath. `ClassNotFoundException` and
`No suitable driver` both mean the same thing. With the k8s job executor, add
it in `--spark-conf`; with the local job executor, install it into
`SPARK_HOME/jars`, as the driver applies `--spark-conf` only after it starts:
```json
{
- "spark.jars": "/path/to/postgresql-42.7.4.jar"
+ "spark.jars":
"https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.4/postgresql-42.7.4.jar"
}
```
+`spark.jars` in `spark_conf` replaces a `spark.jars` default of the k8s job
executor, so if that
+default provides the Iceberg runtime, list it in `spark_conf` as well.
+
**`The provided credentials did not support` or Iceberg `Not authorized`** — a
secured Gravitino endpoint needs `auth_type` / `username` / `password` (or
OAuth client-credentials fields) in `updater_options` for
`builtin-iceberg-update-stats`. A secured Iceberg REST catalog needs
`rest.auth.*` in `spark-conf` for any built-in job that reads the table. See
[Configuration](./optimizer-configuration.md).
**Built-in Iceberg jobs fail with `Missing Iceberg Spark session extensions`**
—
@@ -79,14 +82,18 @@ Spark only warns when `IcebergSparkSessionExtensions` is
missing, so built-in jo
classpath after `SparkSession` starts and exit with a non-zero status when the
Iceberg Spark
runtime is absent. The templates configure Iceberg classes but leave `jars`
empty, and
`gravitino-jobs` does not bundle `iceberg-spark-runtime`. A stock Spark
install is not enough.
-Put a version-matched Iceberg Spark runtime on the job classpath, for example:
+Put a version-matched Iceberg Spark runtime on the job classpath: install it
into `SPARK_HOME/jars`
+or the Spark image, or, with the k8s job executor, set it in `spark_conf`:
```json
{
- "spark.jars": "/path/to/iceberg-spark-runtime-3.5_2.12-1.11.0.jar"
+ "spark.jars":
"https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-3.5_2.12/1.11.0/iceberg-spark-runtime-3.5_2.12-1.11.0.jar"
}
```
+With the local job executor, `spark.jars` in `spark_conf` doesn't add it: the
driver applies
+`spark_conf` only after spark-submit has built its classpath.
+
Use the Spark, Scala, and Iceberg versions that match your cluster. See
[Built-in Job Templates](./optimizer-cli-reference.md#built-in-job-templates).
diff --git a/docs/table-maintenance-service/optimizer.md
b/docs/table-maintenance-service/optimizer.md
index 5048ccb693..eb4d651877 100644
--- a/docs/table-maintenance-service/optimizer.md
+++ b/docs/table-maintenance-service/optimizer.md
@@ -89,8 +89,9 @@ Each step ends with a check. If a check fails, stop there,
since every step depe
`IcebergSparkSessionExtensions` and `SparkCatalog`, but `gravitino-jobs`
does not ship the
Iceberg Spark runtime and the templates leave `jars` empty so your Spark and
Iceberg versions
stay under your control. A stock Spark distribution is not enough. Put a
matching
- `iceberg-spark-runtime-*` JAR on the job classpath — for example with
`spark.jars` in
- `spark_conf`, or by installing it into your Spark environment. Pick the
artifact that matches
+ `iceberg-spark-runtime-*` JAR on the job classpath — for example by
installing it into
+ `SPARK_HOME/jars`. `spark.jars` in `spark_conf` doesn't add it with the
local job executor, as
+ the driver applies `spark_conf` only after it starts. Pick the artifact that
matches
your Spark, Scala, and Iceberg versions. The jobs module is built and tested
against Spark 3.5.x
and Iceberg 1.11.0 (for example
`org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.11.0`). Without it, the
job fails after
@@ -222,7 +223,7 @@ update_stats_job_id=$(curl -sS -X POST -H "Accept:
application/vnd.gravitino.v1+
"table_identifier": "db.t1",
"update_mode": "all",
"updater_options":
"{\"gravitino_uri\":\"http://localhost:8090\",\"metalake\":\"test\",\"statistics_updater\":\"gravitino-statistics-updater\",\"metrics_updater\":\"gravitino-metrics-updater\"}",
- "spark_conf":
"{\"spark.master\":\"local[2]\",\"spark.hadoop.fs.defaultFS\":\"file:///\"}",
+ "spark_conf": "{\"spark.hadoop.fs.defaultFS\":\"file:///\"}",
"spark_master": "local[2]",
"spark_executor_instances": "1",
"spark_executor_cores": "1",
@@ -270,7 +271,7 @@ gravitino.optimizer.jobSubmitterConfig.catalog_type = rest
gravitino.optimizer.jobSubmitterConfig.catalog_uri =
http://localhost:9001/iceberg
# Leave empty for a local filesystem; set to your warehouse URI for cloud or
HDFS storage.
gravitino.optimizer.jobSubmitterConfig.warehouse_location =
-gravitino.optimizer.jobSubmitterConfig.spark_conf =
{"spark.master":"local[2]","spark.hadoop.fs.defaultFS":"file:///"}
+gravitino.optimizer.jobSubmitterConfig.spark_conf =
{"spark.hadoop.fs.defaultFS":"file:///"}
EOF_CONF
```
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergExpireSnapshotsJob.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergExpireSnapshotsJob.java
index 1d80a76029..922f008c39 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergExpireSnapshotsJob.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergExpireSnapshotsJob.java
@@ -39,7 +39,7 @@ public class IcebergExpireSnapshotsJob implements BuiltInJob {
private static final String NAME =
JobTemplateProvider.BUILTIN_NAME_PREFIX + "iceberg-expire-snapshots";
- private static final String VERSION = "v2";
+ private static final String VERSION = "v3";
@Override
public SparkJobTemplate jobTemplate() {
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergJobUtils.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergJobUtils.java
index 0340be3c33..0ac789d174 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergJobUtils.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergJobUtils.java
@@ -18,13 +18,21 @@
*/
package org.apache.gravitino.maintenance.jobs.iceberg;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
import
org.apache.gravitino.maintenance.optimizer.common.util.IcebergSparkConfigUtils;
+import org.apache.spark.SparkConf;
import org.apache.spark.sql.SparkSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* Shared utility methods for Iceberg maintenance jobs.
@@ -34,6 +42,8 @@ import org.apache.spark.sql.SparkSession;
*/
public final class IcebergJobUtils {
+ private static final Logger LOG =
LoggerFactory.getLogger(IcebergJobUtils.class);
+
private static final String ICEBERG_SPARK_CATALOG =
"org.apache.iceberg.spark.SparkCatalog";
/**
* Matches a job template placeholder that no job configuration value
replaced, e.g. {@code
@@ -41,6 +51,26 @@ public final class IcebergJobUtils {
*/
private static final Pattern UNRESOLVED_PLACEHOLDER_PATTERN =
Pattern.compile("^\\{\\{[^{}]*}}$");
+ /**
+ * The Spark configurations that spark-submit decides. Set in the custom
Spark configurations of a
+ * job, which the driver applies, they would override the submitted value,
e.g. run a job in local
+ * mode inside the driver pod of a cluster.
+ *
+ * <p>Keep this and {@link #SPARK_KUBERNETES_PREFIX} aligned with the k8s
job executor's {@code
+ * SparkApplicationUtils}, which ignores the same configurations of
spark_conf when it submits a
+ * built-in job. The jobs jar runs in the driver without the executor
plugin, so the rules can't
+ * be shared as code.
+ */
+ private static final Set<String> SUBMIT_DECIDED_CONFIGS =
+ ImmutableSet.of("spark.master", "spark.submit.deployMode");
+
+ /**
+ * The prefix of the Kubernetes configurations, which a job run may not set
in its custom Spark
+ * configurations: the driver creates the executor pods from them, with
their image, service
+ * account, Secrets, and volumes. Only the job executor configurations and
job templates set them.
+ */
+ private static final String SPARK_KUBERNETES_PREFIX = "spark.kubernetes.";
+
private IcebergJobUtils() {}
/**
@@ -173,7 +203,61 @@ public final class IcebergJobUtils {
* @throws IllegalArgumentException if JSON parsing fails
*/
public static Map<String, String> parseCustomSparkConfigs(String
sparkConfJson) {
- return IcebergSparkConfigUtils.parseFlatJsonMap(sparkConfJson,
"spark-conf");
+ return filterCustomSparkConfigs(
+ IcebergSparkConfigUtils.parseFlatJsonMap(sparkConfJson, "spark-conf"));
+ }
+
+ /**
+ * Filter the custom Spark configurations of a job, which the driver applies
when it creates the
+ * SparkSession, against the configurations spark-submit has already set for
the driver.
+ *
+ * <p>{@code spark.master} and {@code spark.submit.deployMode} are dropped
if spark-submit has set
+ * them, as the job would otherwise run with another master than it was
submitted to. The {@code
+ * spark.kubernetes.*} configurations are dropped, as whoever runs a job
sets them here, while the
+ * driver creates the executor pods from them. A blank value leaves a
configuration unset.
+ *
+ * @param configs the custom Spark configurations
+ * @return the custom Spark configurations to apply
+ */
+ public static Map<String, String> filterCustomSparkConfigs(Map<String,
String> configs) {
+ // new SparkConf() reads the spark.* system properties on purpose:
spark-submit sets the
+ // configurations it submitted the job with as system properties before it
calls main().
+ return filterCustomSparkConfigs(configs, new SparkConf());
+ }
+
+ @VisibleForTesting
+ static Map<String, String> filterCustomSparkConfigs(
+ Map<String, String> configs, SparkConf submittedConf) {
+ Map<String, String> filtered = new LinkedHashMap<>();
+ configs.forEach(
+ (key, value) -> {
+ // A blank value leaves a configuration unset, as the k8s job
executor does.
+ if (StringUtils.isBlank(value)) {
+ return;
+ }
+ if (key.startsWith(SPARK_KUBERNETES_PREFIX)) {
+ // The value isn't logged, as it may name a Secret.
+ LOG.warn(
+ "Ignored {} of the custom Spark configurations, as only the
job executor"
+ + " configurations and job templates may set {}*",
+ key,
+ SPARK_KUBERNETES_PREFIX);
+ return;
+ }
+ if (SUBMIT_DECIDED_CONFIGS.contains(key) &&
submittedConf.contains(key)) {
+ LOG.warn(
+ "Ignored {}={} of the custom Spark configurations, as the job
is submitted with"
+ + " {}={}. Set it when submitting the job instead, for a
built-in job by the"
+ + " spark_master parameter.",
+ key,
+ value,
+ key,
+ submittedConf.get(key));
+ return;
+ }
+ filtered.put(key, value);
+ });
+ return ImmutableMap.copyOf(filtered);
}
/**
@@ -181,8 +265,7 @@ public final class IcebergJobUtils {
*
* <p>Built-in templates configure {@code IcebergSparkSessionExtensions} and
{@code SparkCatalog},
* but Spark only warns when those classes are missing and continues without
Iceberg support. Call
- * this after {@code SparkSession} creation (so {@code spark.jars} from
{@code spark_conf} is
- * visible) and fail the job when the runtime is absent.
+ * this after {@code SparkSession} creation and fail the job when the
runtime is absent.
*
* @throws IllegalStateException when required Iceberg Spark classes cannot
be loaded
*/
@@ -233,8 +316,11 @@ public final class IcebergJobUtils {
throw new IllegalStateException(
String.format(
"Missing %s (%s). Built-in Iceberg jobs need
iceberg-spark-runtime on the Spark "
- + "classpath (for example via spark.jars in spark_conf, or
installed into the "
- + "Spark environment). A stock Spark distribution does not
include it. Match "
+ + "classpath, for example installed into the Spark
environment or the Spark "
+ + "image. With the k8s job executor, spark.jars in
spark_conf or in "
+ + "gravitino.jobExecutor.k8s.spark.conf.* adds it too. With
the local job "
+ + "executor, spark.jars in spark_conf doesn't, as the driver
applies spark_conf "
+ + "only after it starts. A stock Spark distribution does not
include it. Match "
+ "the artifact to your Spark, Scala, and Iceberg versions.",
description, className),
first);
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRemoveOrphanFilesJob.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRemoveOrphanFilesJob.java
index 85c84075e8..3c2e7f4e5d 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRemoveOrphanFilesJob.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRemoveOrphanFilesJob.java
@@ -52,7 +52,7 @@ public class IcebergRemoveOrphanFilesJob implements
BuiltInJob {
private static final Logger LOG =
LoggerFactory.getLogger(IcebergRemoveOrphanFilesJob.class);
private static final String NAME =
JobTemplateProvider.BUILTIN_NAME_PREFIX + "iceberg-remove-orphan-files";
- private static final String VERSION = "v2";
+ private static final String VERSION = "v3";
@Override
public SparkJobTemplate jobTemplate() {
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteDataFilesJob.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteDataFilesJob.java
index 87ca6ab715..000b4da4e2 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteDataFilesJob.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteDataFilesJob.java
@@ -39,7 +39,7 @@ public class IcebergRewriteDataFilesJob implements BuiltInJob
{
private static final String NAME =
JobTemplateProvider.BUILTIN_NAME_PREFIX + "iceberg-rewrite-data-files";
- private static final String VERSION = "v2";
+ private static final String VERSION = "v3";
// Valid strategy values for Iceberg rewrite_data_files procedure
private static final String STRATEGY_BINPACK = "binpack";
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteManifestsJob.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteManifestsJob.java
index 4c4ec7dbd8..25d6f2f509 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteManifestsJob.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteManifestsJob.java
@@ -41,7 +41,7 @@ public class IcebergRewriteManifestsJob implements BuiltInJob
{
private static final String NAME =
JobTemplateProvider.BUILTIN_NAME_PREFIX + "iceberg-rewrite-manifests";
- private static final String VERSION = "v2";
+ private static final String VERSION = "v3";
@Override
public SparkJobTemplate jobTemplate() {
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateManifestStatsJob.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateManifestStatsJob.java
index 2f398aa7d4..1b5d7553c2 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateManifestStatsJob.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateManifestStatsJob.java
@@ -87,7 +87,7 @@ public class IcebergUpdateManifestStatsJob implements
BuiltInJob {
"--spark-conf",
"{{spark_conf:-}}"))
.withConfigs(IcebergSparkConfigUtils.buildTemplateSparkConfigs())
-
.withCustomFields(Collections.singletonMap(JobTemplateProvider.PROPERTY_VERSION_KEY,
"v1"))
+
.withCustomFields(Collections.singletonMap(JobTemplateProvider.PROPERTY_VERSION_KEY,
"v2"))
.build();
}
@@ -118,7 +118,7 @@ public class IcebergUpdateManifestStatsJob implements
BuiltInJob {
arguments.get("updater-options"), "updater-options");
Map<String, String> properties = buildOptimizerProperties(options);
Map<String, String> sparkConfigs =
- IcebergSparkConfigUtils.parseFlatJsonMap(arguments.get("spark-conf"),
"spark-conf");
+ IcebergJobUtils.parseCustomSparkConfigs(arguments.get("spark-conf"));
try (StatisticsUpdater updater =
ProviderUtils.createStatisticsUpdaterInstance(
options.getOrDefault("statistics_updater",
DEFAULT_STATISTICS_UPDATER).trim())) {
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateStatsAndMetricsJob.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateStatsAndMetricsJob.java
index abf482de3b..843b589553 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateStatsAndMetricsJob.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateStatsAndMetricsJob.java
@@ -61,7 +61,7 @@ public class IcebergUpdateStatsAndMetricsJob implements
BuiltInJob {
private static final String NAME =
JobTemplateProvider.BUILTIN_NAME_PREFIX + "iceberg-update-stats";
- private static final String VERSION = "v2";
+ private static final String VERSION = "v3";
private static final String DEFAULT_STATISTICS_UPDATER =
"gravitino-statistics-updater";
private static final String DEFAULT_METRICS_UPDATER =
"gravitino-metrics-updater";
private static final long DEFAULT_TARGET_FILE_SIZE_BYTES = 128L * 1024 *
1024;
@@ -394,7 +394,7 @@ public class IcebergUpdateStatsAndMetricsJob implements
BuiltInJob {
@VisibleForTesting
static Map<String, String> parseCustomSparkConfigs(String sparkConfJson) {
- return parseJsonOptions(sparkConfJson, "spark-conf");
+ return
IcebergJobUtils.filterCustomSparkConfigs(parseJsonOptions(sparkConfJson,
"spark-conf"));
}
/**
diff --git
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/spark/SparkPiJob.java
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/spark/SparkPiJob.java
index e97348bb5e..83cb951aca 100644
---
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/spark/SparkPiJob.java
+++
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/spark/SparkPiJob.java
@@ -40,7 +40,7 @@ public class SparkPiJob implements BuiltInJob {
private static final String NAME = JobTemplateProvider.BUILTIN_NAME_PREFIX +
"sparkpi";
// Bump VERSION whenever SparkPi template behavior changes
(name/executable/class/args/configs).
- private static final String VERSION = "v2";
+ private static final String VERSION = "v3";
@Override
public SparkJobTemplate jobTemplate() {
@@ -94,7 +94,9 @@ public class SparkPiJob implements BuiltInJob {
private Map<String, String> buildSparkConfigs() {
Map<String, String> configs = new HashMap<>();
- configs.put("spark.master", "{{spark_master:-local[*]}}");
+ // An empty value leaves the Spark configuration unset, see
+ // IcebergSparkConfigUtils#buildTemplateSparkConfigs.
+ configs.put("spark.master", "{{spark_master:-}}");
configs.put("spark.executor.instances", "{{spark_executor_instances:-1}}");
configs.put("spark.executor.cores", "{{spark_executor_cores:-1}}");
configs.put("spark.executor.memory", "{{spark_executor_memory:-1g}}");
diff --git
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergExpireSnapshotsJob.java
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergExpireSnapshotsJob.java
index 1795704542..a0344d8301 100644
---
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergExpireSnapshotsJob.java
+++
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergExpireSnapshotsJob.java
@@ -133,7 +133,7 @@ public class TestIcebergExpireSnapshotsJob {
assertTrue(customFields.containsKey(JobTemplateProvider.PROPERTY_VERSION_KEY));
String version =
customFields.get(JobTemplateProvider.PROPERTY_VERSION_KEY);
- assertEquals("v2", version);
+ assertEquals("v3", version);
assertTrue(version.matches(JobTemplateProvider.VERSION_VALUE_PATTERN));
}
diff --git
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergJobUtils.java
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergJobUtils.java
index 89fb606e3d..867ec45591 100644
---
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergJobUtils.java
+++
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergJobUtils.java
@@ -24,11 +24,13 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import com.google.common.collect.ImmutableMap;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
+import org.apache.spark.SparkConf;
import org.junit.jupiter.api.Test;
public class TestIcebergJobUtils {
@@ -99,4 +101,45 @@ public class TestIcebergJobUtils {
() -> IcebergJobUtils.parseCustomSparkConfigs("{not_json}"));
assertTrue(ex.getMessage().contains("--spark-conf"));
}
+
+ @Test
+ public void testFilterCustomSparkConfigsDropsSubmittedMasterAndDeployMode() {
+ Map<String, String> configs =
+ ImmutableMap.of(
+ "spark.master", "local[*]",
+ "spark.submit.deployMode", "client",
+ "spark.jars", "/opt/iceberg-spark-runtime.jar",
+ "spark.sql.shuffle.partitions", "200");
+ SparkConf submitted =
+ new SparkConf(false)
+ .set("spark.master", "k8s://https://kubernetes.default.svc")
+ .set("spark.submit.deployMode", "cluster");
+
+ assertEquals(
+ ImmutableMap.of(
+ "spark.jars", "/opt/iceberg-spark-runtime.jar",
"spark.sql.shuffle.partitions", "200"),
+ IcebergJobUtils.filterCustomSparkConfigs(configs, submitted));
+ }
+
+ @Test
+ public void testFilterCustomSparkConfigsKeepsMasterNotSubmitted() {
+ // A job run without spark-submit, e.g. in a test, has no master unless it
sets one.
+ Map<String, String> configs = ImmutableMap.of("spark.master", "local[1]");
+ assertEquals(configs, IcebergJobUtils.filterCustomSparkConfigs(configs,
new SparkConf(false)));
+ }
+
+ @Test
+ public void testFilterCustomSparkConfigsDropsKubernetesAndBlankConfigs() {
+ Map<String, String> configs =
+ ImmutableMap.of(
+ "spark.kubernetes.executor.secretKeyRef.TOKEN", "secret:token",
+ "spark.kubernetes.container.image", "other:1.0",
+ "spark.sql.shuffle.partitions", "",
+ // Blank like StringUtils.isBlank, as the k8s job executor checks
it: an em space.
+ "spark.driver.memory", "\u2003",
+ "spark.executor.memory", "2g");
+ assertEquals(
+ ImmutableMap.of("spark.executor.memory", "2g"),
+ IcebergJobUtils.filterCustomSparkConfigs(configs, new
SparkConf(false)));
+ }
}
diff --git
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteDataFilesJob.java
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteDataFilesJob.java
index e734991fb6..7aec76aad0 100644
---
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteDataFilesJob.java
+++
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteDataFilesJob.java
@@ -134,7 +134,7 @@ public class TestIcebergRewriteDataFilesJob {
assertTrue(customFields.containsKey(JobTemplateProvider.PROPERTY_VERSION_KEY));
String version =
customFields.get(JobTemplateProvider.PROPERTY_VERSION_KEY);
- assertEquals("v2", version);
+ assertEquals("v3", version);
assertTrue(version.matches(JobTemplateProvider.VERSION_VALUE_PATTERN));
}
diff --git
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJob.java
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJob.java
index 9d5f894acf..7f819d1d41 100644
---
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJob.java
+++
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJob.java
@@ -197,7 +197,7 @@ public class TestIcebergRewriteManifestsJob {
assertTrue(customFields.containsKey(JobTemplateProvider.PROPERTY_VERSION_KEY));
String version =
customFields.get(JobTemplateProvider.PROPERTY_VERSION_KEY);
- assertEquals("v2", version);
+ assertEquals("v3", version);
assertTrue(version.matches(JobTemplateProvider.VERSION_VALUE_PATTERN));
}
diff --git
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJob.java
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJob.java
index 24b1efe30e..71d26bd8c6 100644
---
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJob.java
+++
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJob.java
@@ -64,7 +64,7 @@ public class TestIcebergUpdateManifestStatsJob {
assertTrue(template.arguments().contains("{{spark_conf:-}}"));
assertTrue(template.arguments().contains("{{coordination_directory:-}}"));
assertFalse(template.arguments().contains("--update-mode"));
- assertEquals("v1", template.customFields().get("version"));
+ assertEquals("v2", template.customFields().get("version"));
assertTrue(template.configs().containsKey("spark.sql.extensions"));
SparkJobTemplate existing = (SparkJobTemplate)
templates.get("builtin-iceberg-update-stats");
assertNotNull(existing);
diff --git
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateStatsJob.java
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateStatsJob.java
index d15ddb92ca..3e7d6a488d 100644
---
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateStatsJob.java
+++
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateStatsJob.java
@@ -42,7 +42,7 @@ public class TestIcebergUpdateStatsJob {
assertNotNull(template);
assertEquals("builtin-iceberg-update-stats", template.name());
assertTrue(template.name().matches(JobTemplateProvider.BUILTIN_NAME_PATTERN));
- assertEquals("v2",
template.customFields().get(JobTemplateProvider.PROPERTY_VERSION_KEY));
+ assertEquals("v3",
template.customFields().get(JobTemplateProvider.PROPERTY_VERSION_KEY));
}
@Test
diff --git
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/spark/TestSparkPiJob.java
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/spark/TestSparkPiJob.java
index 768f3c881a..c0d468a673 100644
---
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/spark/TestSparkPiJob.java
+++
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/spark/TestSparkPiJob.java
@@ -93,7 +93,7 @@ public class TestSparkPiJob {
assertTrue(configs.containsKey("spark.driver.memory"));
// Verify placeholders
- assertEquals("{{spark_master:-local[*]}}", configs.get("spark.master"));
+ assertEquals("{{spark_master:-}}", configs.get("spark.master"));
assertEquals("{{spark_executor_instances:-1}}",
configs.get("spark.executor.instances"));
assertEquals("{{spark_executor_cores:-1}}",
configs.get("spark.executor.cores"));
assertEquals("{{spark_executor_memory:-1g}}",
configs.get("spark.executor.memory"));
@@ -110,7 +110,7 @@ public class TestSparkPiJob {
assertTrue(customFields.containsKey(JobTemplateProvider.PROPERTY_VERSION_KEY));
String version =
customFields.get(JobTemplateProvider.PROPERTY_VERSION_KEY);
- assertEquals("v2", version);
+ assertEquals("v3", version);
assertTrue(version.matches(JobTemplateProvider.VERSION_VALUE_PATTERN));
}
diff --git
a/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/IcebergSparkConfigUtils.java
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/IcebergSparkConfigUtils.java
index a1f92aae7e..b0e9c60983 100644
---
a/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/IcebergSparkConfigUtils.java
+++
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/IcebergSparkConfigUtils.java
@@ -50,7 +50,9 @@ public final class IcebergSparkConfigUtils {
/** Build default Spark template configs for Iceberg jobs. */
public static Map<String, String> buildTemplateSparkConfigs() {
Map<String, String> configs = new HashMap<>();
- configs.put("spark.master", "{{spark_master:-local[*]}}");
+ // An empty value leaves the Spark configuration unset: spark-submit then
runs the job with
+ // its default master, local[*], and the k8s job executor lets the
operator decide it.
+ configs.put("spark.master", "{{spark_master:-}}");
configs.put("spark.executor.instances", "{{spark_executor_instances:-1}}");
configs.put("spark.executor.cores", "{{spark_executor_cores:-1}}");
configs.put("spark.executor.memory", "{{spark_executor_memory:-1g}}");
diff --git
a/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestIcebergSparkConfigUtils.java
b/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestIcebergSparkConfigUtils.java
index 0b4da3ea25..c2dead444b 100644
---
a/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestIcebergSparkConfigUtils.java
+++
b/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestIcebergSparkConfigUtils.java
@@ -46,6 +46,13 @@ public class TestIcebergSparkConfigUtils {
IcebergSparkConfigUtils.ICEBERG_SPARK_EXTENSIONS,
configs.get("spark.sql.extensions"));
}
+ @Test
+ public void testBuildTemplateSparkConfigsLeavesMasterUnsetByDefault() {
+ Map<String, String> configs =
IcebergSparkConfigUtils.buildTemplateSparkConfigs();
+
+ assertEquals("{{spark_master:-}}", configs.get("spark.master"));
+ }
+
@Test
public void testParseFlatJsonMap() {
Map<String, String> parsed =
diff --git
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/command/SubmitUpdateStatsJobCommand.java
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/command/SubmitUpdateStatsJobCommand.java
index 1f08d8dfd6..10ef3402a4 100644
---
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/command/SubmitUpdateStatsJobCommand.java
+++
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/command/SubmitUpdateStatsJobCommand.java
@@ -47,7 +47,6 @@ public class SubmitUpdateStatsJobCommand implements
OptimizerCommandExecutor {
private static final String DEFAULT_UPDATE_MODE = "all";
private static final String OPTION_UPDATER_OPTIONS = "updater-options";
private static final String OPTION_SPARK_CONF = "spark-conf";
- private static final String DEFAULT_SPARK_MASTER = "local[*]";
private static final String DEFAULT_SPARK_EXECUTOR_INSTANCES = "1";
private static final String DEFAULT_SPARK_EXECUTOR_CORES = "1";
private static final String DEFAULT_SPARK_EXECUTOR_MEMORY = "1g";
@@ -98,6 +97,17 @@ public class SubmitUpdateStatsJobCommand implements
OptimizerCommandExecutor {
validateUpdaterOptions(updateMode, updaterOptions);
validateSparkConfigs(tableTargets, sparkConfigs);
+ // The job ignores the master of spark_conf, as spark-submit decides it,
so say where it goes.
+ if (StringUtils.isNotBlank(sparkConfigs.get(SPARK_MASTER_KEY))) {
+ context
+ .output()
+ .printf(
+ "WARN: %s=%s in the Spark configurations is ignored by the job.
Set %sspark_master"
+ + " instead.%n",
+ SPARK_MASTER_KEY,
+ sparkConfigs.get(SPARK_MASTER_KEY),
+ OptimizerConfig.JOB_SUBMITTER_CONFIG_PREFIX);
+ }
if (context.dryRun()) {
for (TableTarget tableTarget : tableTargets) {
@@ -333,12 +343,10 @@ public class SubmitUpdateStatsJobCommand implements
OptimizerCommandExecutor {
Map<String, String> placeholders = new LinkedHashMap<>();
String catalogPrefix = SPARK_SQL_CATALOG_PREFIX + catalogName;
- placeholders.put(
- "spark_master",
- firstNonBlank(
- submitterConfigs.get("spark_master"),
- sparkConfigs.get(SPARK_MASTER_KEY),
- DEFAULT_SPARK_MASTER));
+ // An empty master leaves it to the job executor: spark-submit defaults to
local[*], and the
+ // k8s job executor lets the operator decide it. spark.master in
spark_conf isn't used, as the
+ // job ignores it there too.
+ placeholders.put("spark_master",
firstNonBlank(submitterConfigs.get("spark_master")));
placeholders.put(
"spark_executor_instances",
firstNonBlank(
diff --git
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/TestOptimizerCmd.java
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/TestOptimizerCmd.java
index 8ea3772db5..2a05a0b417 100644
---
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/TestOptimizerCmd.java
+++
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/TestOptimizerCmd.java
@@ -26,6 +26,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
+import java.util.regex.Pattern;
import org.apache.gravitino.NameIdentifier;
import
org.apache.gravitino.maintenance.optimizer.monitor.evaluator.MetricsEvaluatorForTest;
import
org.apache.gravitino.maintenance.optimizer.monitor.job.TableJobRelationProviderForTest;
@@ -42,6 +43,9 @@ import org.junit.jupiter.api.Test;
class TestOptimizerCmd {
+ // A spark_master entry with an empty value in the printed job configuration.
+ private static final Pattern EMPTY_SPARK_MASTER =
Pattern.compile("\\bspark_master=\\s*[,}]");
+
@Test
void testGlobalHelpIncludesImplementedCommands() {
String[] output = runCommand("--help");
@@ -497,7 +501,8 @@ class TestOptimizerCmd {
Assertions.assertTrue(output[0].contains("jobTemplate=builtin-iceberg-update-stats"));
Assertions.assertTrue(output[0].contains("update_mode=stats"));
Assertions.assertTrue(output[0].contains("table_identifier=ab.t1"));
- Assertions.assertTrue(output[0].contains("spark_master=local[*]"));
+ // Without a configured master, the job executor decides it.
+ Assertions.assertTrue(EMPTY_SPARK_MASTER.matcher(output[0]).find(),
output[0]);
Assertions.assertTrue(output[0].contains("catalog_type=rest"));
Assertions.assertTrue(output[0].contains("catalog_uri=http://localhost:9001/iceberg"));
Assertions.assertTrue(output[0].contains("warehouse_location=/tmp/warehouse"));
@@ -554,13 +559,21 @@ class TestOptimizerCmd {
+ "\"spark.sql.catalog.rest.type\":\"rest\","
+
"\"spark.sql.catalog.rest.uri\":\"http://localhost:9001/iceberg\","
+ "\"spark.sql.catalog.rest.warehouse\":\"/tmp/warehouse\","
- +
"\"spark.sql.extensions\":\"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions\"}",
+ +
"\"spark.sql.extensions\":\"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions\","
+ + "\"spark.master\":\"local[2]\"}",
"--dry-run",
"--conf-path",
confPath.toString());
Assertions.assertTrue(output[1].isEmpty(), "stderr=" + output[1] + ",
stdout=" + output[0]);
Assertions.assertTrue(output[0].contains("custom-metrics-updater"));
Assertions.assertTrue(output[0].contains("spark.sql.catalog.rest"));
+ // The master of spark_conf isn't passed as the spark_master parameter,
and the user is told.
+ Assertions.assertTrue(EMPTY_SPARK_MASTER.matcher(output[0]).find(),
output[0]);
+ Assertions.assertTrue(
+ output[0].contains(
+ "WARN: spark.master=local[2] in the Spark configurations is
ignored by the job."
+ + " Set gravitino.optimizer.jobSubmitterConfig.spark_master
instead."),
+ output[0]);
}
@Test
diff --git a/plugins/k8s-job-executor/build.gradle.kts
b/plugins/k8s-job-executor/build.gradle.kts
index ec06621218..495bf59b93 100644
--- a/plugins/k8s-job-executor/build.gradle.kts
+++ b/plugins/k8s-job-executor/build.gradle.kts
@@ -48,6 +48,9 @@ dependencies {
exclude(group = "io.fabric8", module = "kubernetes-httpclient-vertx")
}
implementation(libs.fabric8.kubernetes.httpclient.jdk)
+ // Parses the spark_conf of built-in jobs. Resolved to the version fabric8
needs, and relocated
+ // with it.
+ implementation(libs.jackson.databind)
testImplementation(project(":api"))
testImplementation(project(":common"))
diff --git
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
index 8ce2a8a6d2..53b170480c 100644
---
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
+++
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
@@ -18,8 +18,12 @@
*/
package org.apache.gravitino.job.k8s.spark;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import io.fabric8.kubernetes.api.model.GenericKubernetesResourceBuilder;
import io.fabric8.kubernetes.client.dsl.base.ResourceDefinitionContext;
@@ -28,6 +32,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
@@ -80,6 +85,7 @@ public final class SparkApplicationUtils {
private static final String SPARK_MASTER = "spark.master";
private static final String SPARK_DEPLOY_MODE = "spark.submit.deployMode";
private static final String CLUSTER_DEPLOY_MODE = "cluster";
+ private static final String K8S_MASTER_PREFIX = "k8s://";
private static final String SPARK_IMAGE = "spark.kubernetes.container.image";
private static final String SPARK_DRIVER_SERVICE_ACCOUNT =
"spark.kubernetes.authenticate.driver.serviceAccountName";
@@ -89,6 +95,22 @@ public final class SparkApplicationUtils {
private static final String SPARK_DRIVER_ENV_PREFIX =
"spark.kubernetes.driverEnv.";
private static final String SPARK_EXECUTOR_ENV_PREFIX = "spark.executorEnv.";
+ // The argument of a built-in job that carries the Spark configurations of
the job run.
+ private static final String BUILTIN_SPARK_CONF_ARGUMENT = "--spark-conf";
+
+ // The configurations that spark-submit decides, which a built-in job
ignores in its spark_conf.
+ // Keep this and SPARK_KUBERNETES_PREFIX aligned with IcebergJobUtils of the
built-in jobs, whose
+ // driver ignores the same configurations of spark_conf; the jobs jar can't
depend on this plugin.
+ private static final Set<String> SUBMIT_DECIDED_CONFIGS =
+ ImmutableSet.of(SPARK_MASTER, SPARK_DEPLOY_MODE);
+
+ // The prefix of the Kubernetes configurations, such as the namespace, the
image, the service
+ // account, Secrets, and volumes. Whoever can run a built-in job sets its
spark_conf, so only the
+ // job executor configurations and job templates may set them.
+ private static final String SPARK_KUBERNETES_PREFIX = "spark.kubernetes.";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
// The environment variable names that Spark passes to the executors: Spark
3.5 drops any other
// spark.executorEnv.* key with only a warning, while the driver would still
get it. Kubernetes
// without the relaxed environment variable validation accepts the same
names, except '.' and
@@ -111,7 +133,13 @@ public final class SparkApplicationUtils {
* through the Spark configurations {@code spark.kubernetes.driverEnv.*} and
{@code
* spark.executorEnv.*}, which means their values are visible in the
SparkApplication and in its
* pods. They override the default Spark configurations of the job executor,
and the Spark
- * configurations of the job template override them.
+ * configurations of the job override them, except those with a blank value,
which are left unset.
+ * The Spark configurations of a built-in job include those of its {@code
spark_conf} parameter,
+ * so that the configurations that spark-submit reads, such as {@code
spark.jars}, take effect.
+ *
+ * <p>The operator decides the master and the deploy mode, so a job whose
{@code spark.master}
+ * isn't a Kubernetes master, or whose {@code spark.submit.deployMode} isn't
{@code cluster}, is
+ * rejected.
*
* @param context the context of the job run
* @param template the runtime job template, whose resources are the URIs
given in the template
@@ -136,7 +164,8 @@ public final class SparkApplicationUtils {
sparkConf.put(SPARK_DRIVER_ENV_PREFIX + name, value);
sparkConf.put(SPARK_EXECUTOR_ENV_PREFIX + name, value);
});
- sparkConf.putAll(template.configs());
+ Map<String, String> jobSparkConf = getJobSparkConf(context, template);
+ sparkConf.putAll(jobSparkConf);
// The operator only supports cluster mode, so a job that asks for client
mode is rejected
// rather than silently run in cluster mode.
String deployMode =
StringUtils.trimToNull(sparkConf.get(SPARK_DEPLOY_MODE));
@@ -147,6 +176,21 @@ public final class SparkApplicationUtils {
+ " the k8s job executor only runs Spark jobs in %s mode",
SPARK_DEPLOY_MODE, deployMode, template.name(),
CLUSTER_DEPLOY_MODE));
}
+ // Likewise, a job that asks for another master, such as local[*], is
rejected rather than
+ // silently run on Kubernetes.
+ String master = StringUtils.trimToNull(sparkConf.get(SPARK_MASTER));
+ if (master != null && !master.startsWith(K8S_MASTER_PREFIX)) {
+ String hint =
+ template.name().startsWith(JobTemplateProvider.BUILTIN_NAME_PREFIX)
+ ? " For a built-in job template, leave the spark_master
parameter empty."
+ : "";
+ throw new IllegalArgumentException(
+ String.format(
+ "%s is set to %s by job template %s or by the k8s job executor
configurations, but"
+ + " the k8s job executor only runs Spark jobs on Kubernetes.
Leave %s unset in"
+ + " the job template and in the k8s job executor
configurations.%s",
+ SPARK_MASTER, master, template.name(), SPARK_MASTER, hint));
+ }
// The operator decides the master and the deploy mode.
sparkConf.remove(SPARK_MASTER);
sparkConf.remove(SPARK_DEPLOY_MODE);
@@ -154,7 +198,7 @@ public final class SparkApplicationUtils {
putIfBlank(sparkConf, SPARK_DRIVER_SERVICE_ACCOUNT,
configs.sparkServiceAccount());
// Only a job chooses its namespace this way. A default set in the Spark
configurations of
// the executor would silently shadow the namespace of the executor
configurations.
- String namespace =
StringUtils.trimToNull(template.configs().get(SPARK_NAMESPACE));
+ String namespace =
StringUtils.trimToNull(jobSparkConf.get(SPARK_NAMESPACE));
if (namespace == null) {
namespace = configs.namespace();
} else if (!K8sJobResourceUtils.isValidNamespace(namespace)) {
@@ -216,6 +260,112 @@ public final class SparkApplicationUtils {
return app;
}
+ /**
+ * Returns the Spark configurations that a job sets itself, which override
the default Spark
+ * configurations of the job executor.
+ *
+ * <p>They are the configurations of the job template, and for a built-in
job also those of its
+ * {@code spark_conf} parameter, which override them. The driver of a
built-in job applies its
+ * {@code spark_conf} only once it starts, too late for the configurations
that spark-submit
+ * reads, such as {@code spark.jars}, so they are set in the
SparkApplication as well. {@code
+ * spark.master} and {@code spark.submit.deployMode} there are ignored, as
the driver also ignores
+ * them, and so are the {@code spark.kubernetes.*} configurations, which
only the job executor
+ * configurations and job templates may set: whoever can run a built-in job
sets its {@code
+ * spark_conf}. A blank value leaves a configuration unset, as it is what an
optional template
+ * parameter becomes, so it doesn't override the default of the job executor.
+ */
+ private static Map<String, String> getJobSparkConf(
+ JobContext context, SparkJobTemplate template) {
+ Map<String, String> jobSparkConf = new TreeMap<>();
+ template
+ .configs()
+ .forEach(
+ (key, value) -> {
+ if (StringUtils.isNotBlank(value)) {
+ checkExecutorEnvironmentName(template, key);
+ jobSparkConf.put(key, value);
+ }
+ });
+ if (!template.name().startsWith(JobTemplateProvider.BUILTIN_NAME_PREFIX)) {
+ return jobSparkConf;
+ }
+
+ getBuiltinSparkConf(template)
+ .forEach(
+ (key, value) -> {
+ if (StringUtils.isBlank(value)) {
+ return;
+ }
+ if (SUBMIT_DECIDED_CONFIGS.contains(key)) {
+ LOG.warn(
+ "Ignored {}={} in spark_conf of job {} of built-in job
template {}, as the"
+ + " Spark operator decides it",
+ key,
+ value,
+ context.jobId(),
+ template.name());
+ } else if (key.startsWith(SPARK_KUBERNETES_PREFIX)) {
+ // The value isn't logged, as it may name a Secret.
+ LOG.warn(
+ "Ignored {} in spark_conf of job {} of built-in job
template {}, as only the"
+ + " k8s job executor configurations and job templates
may set {}*",
+ key,
+ context.jobId(),
+ template.name(),
+ SPARK_KUBERNETES_PREFIX);
+ } else {
+ checkExecutorEnvironmentName(template, key);
+ jobSparkConf.put(key, value);
+ }
+ });
+ return jobSparkConf;
+ }
+
+ private static Map<String, String> getBuiltinSparkConf(SparkJobTemplate
template) {
+ List<String> arguments = template.arguments();
+ // A built-in template has the argument once. A value of another argument
that equals it would
+ // be followed by a value that isn't JSON, which rejects the job rather
than skipping a check.
+ int index = arguments.indexOf(BUILTIN_SPARK_CONF_ARGUMENT);
+ if (index < 0 || index + 1 >= arguments.size()) {
+ return ImmutableMap.of();
+ }
+ String json = arguments.get(index + 1);
+ if (StringUtils.isBlank(json)) {
+ return ImmutableMap.of();
+ }
+
+ Map<String, Object> parsed;
+ try {
+ parsed = MAPPER.readValue(json, new TypeReference<Map<String, Object>>()
{});
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException(
+ String.format(
+ "spark_conf of built-in job template %s isn't a JSON map: %s",
+ template.name(), e.getOriginalMessage()),
+ e);
+ }
+ // The built-in jobs reject a JSON null as well.
+ if (parsed == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "spark_conf of built-in job template %s isn't a JSON map: null",
template.name()));
+ }
+ // Parsed the way the built-in jobs parse it: a flat map whose null values
are empty.
+ Map<String, String> sparkConf = new LinkedHashMap<>();
+ parsed.forEach(
+ (key, value) -> {
+ if (value instanceof Map || value instanceof List) {
+ throw new IllegalArgumentException(
+ String.format(
+ "spark_conf of built-in job template %s must be a flat
JSON map, but %s has"
+ + " a non-scalar value",
+ template.name(), key));
+ }
+ sparkConf.put(key, value == null ? "" : value.toString());
+ });
+ return ImmutableMap.copyOf(sparkConf);
+ }
+
private static String getExecutableUri(
JobContext context, SparkJobTemplate template, K8sJobExecutorConfigs
configs) {
// The executable of a built-in job template is the path of the jobs jar
on the Gravitino
@@ -238,6 +388,14 @@ public final class SparkApplicationUtils {
return template.executable();
}
+ // Checks the environment variable name of a spark.executorEnv.*
configuration like those of the
+ // environments of the template, as an invalid name would only fail the
executor pods.
+ private static void checkExecutorEnvironmentName(SparkJobTemplate template,
String key) {
+ if (key.startsWith(SPARK_EXECUTOR_ENV_PREFIX)) {
+ checkEnvironmentName(template,
key.substring(SPARK_EXECUTOR_ENV_PREFIX.length()));
+ }
+ }
+
private static void checkEnvironmentName(SparkJobTemplate template, String
name) {
// Checked here, as an invalid name would otherwise only miss the
executors, or fail the job
// when the operator creates the driver pod.
diff --git
a/plugins/k8s-job-executor/src/shadedJarTest/java/org/apache/gravitino/job/k8s/TestK8sJobExecutorShadedJar.java
b/plugins/k8s-job-executor/src/shadedJarTest/java/org/apache/gravitino/job/k8s/TestK8sJobExecutorShadedJar.java
index 023f930d91..facdd1fe7e 100644
---
a/plugins/k8s-job-executor/src/shadedJarTest/java/org/apache/gravitino/job/k8s/TestK8sJobExecutorShadedJar.java
+++
b/plugins/k8s-job-executor/src/shadedJarTest/java/org/apache/gravitino/job/k8s/TestK8sJobExecutorShadedJar.java
@@ -169,7 +169,8 @@ public class TestK8sJobExecutorShadedJar {
.withName("builtin-sparkpi")
.withExecutable("/gravitino/auxlib/gravitino-jobs.jar")
.withClassName("org.apache.gravitino.maintenance.jobs.spark.SparkPiJob")
- .withConfigs(ImmutableMap.of("spark.master", "local[*]"))
+ // What the built-in template's {{spark_master:-}} resolves to
by default.
+ .withConfigs(ImmutableMap.of("spark.master", ""))
.build();
// The factory finds the built-in k8s job executor by its class name.
diff --git
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
index 39da670461..ee4a2b9d3d 100644
---
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
+++
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
@@ -23,6 +23,7 @@ import com.google.common.collect.ImmutableMap;
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import java.io.File;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import org.apache.gravitino.connector.job.JobContext;
import org.apache.gravitino.job.SparkJobTemplate;
@@ -67,6 +68,85 @@ public class TestSparkApplicationUtils {
() -> SparkApplicationUtils.buildSparkApplication(CONTEXT, noMode,
clientMode));
}
+ @Test
+ public void testRejectNonKubernetesMaster() {
+ for (String master : new String[] {"local[*]", " local ", "yarn",
"spark://host:7077"}) {
+ SparkJobTemplate template =
+ newTemplate("s3a://bucket/app.jar", ImmutableMap.of("spark.master",
master));
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> SparkApplicationUtils.buildSparkApplication(CONTEXT,
template, configs));
+ Assertions.assertTrue(e.getMessage().contains(master.trim()),
e.getMessage());
+ // The spark_master parameter only exists in the built-in job templates.
+ Assertions.assertFalse(e.getMessage().contains("spark_master"),
e.getMessage());
+ }
+
+ SparkJobTemplate builtin =
+ newTemplateBuilder()
+ .withName("builtin-sparkpi")
+ .withConfigs(ImmutableMap.of("spark.master", "local[*]"))
+ .build();
+ IllegalArgumentException builtinError =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> SparkApplicationUtils.buildSparkApplication(CONTEXT,
builtin, configs));
+ Assertions.assertTrue(
+ builtinError.getMessage().contains("spark_master"),
builtinError.getMessage());
+
+ // Nor set by the executor configurations.
+ K8sJobExecutorConfigs localMaster =
+ newConfigs(ImmutableMap.of("spark.conf.spark.master", "local[2]"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ SparkApplicationUtils.buildSparkApplication(
+ CONTEXT, newTemplate("s3a://bucket/app.jar",
ImmutableMap.of()), localMaster));
+ }
+
+ @Test
+ public void testIgnoreBlankMaster() {
+ // A blank master, as the built-in templates set by default, is left to
the operator.
+ Map<String, Object> spec =
+ getSpec(
+ newTemplate(
+ "s3a://bucket/app.jar",
+ ImmutableMap.of("spark.master", " ",
"spark.submit.deployMode", "")),
+ configs);
+ @SuppressWarnings("unchecked")
+ Map<String, String> sparkConf = (Map<String, String>)
spec.get("sparkConf");
+ Assertions.assertFalse(sparkConf.containsKey("spark.master"));
+ Assertions.assertFalse(sparkConf.containsKey("spark.submit.deployMode"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testBlankConfigsKeepExecutorDefaults() {
+ K8sJobExecutorConfigs withDefaults =
+ newConfigs(
+ ImmutableMap.of(
+ "spark.conf.spark.jars",
"https://repo/iceberg-spark-runtime.jar",
+ "spark.conf.spark.jars.packages", "org.example:lib:1.0"));
+ SparkJobTemplate template =
+ newTemplate(
+ "s3a://bucket/app.jar",
+ ImmutableMap.of(
+ "spark.jars", "", "spark.jars.packages", " ",
"spark.executor.memory", ""));
+
+ Map<String, String> sparkConf =
+ (Map<String, String>) getSpec(template, withDefaults).get("sparkConf");
+ Assertions.assertEquals("https://repo/iceberg-spark-runtime.jar",
sparkConf.get("spark.jars"));
+ Assertions.assertEquals("org.example:lib:1.0",
sparkConf.get("spark.jars.packages"));
+ Assertions.assertFalse(sparkConf.containsKey("spark.executor.memory"));
+
+ // A value set by the job overrides the executor default.
+ SparkJobTemplate withJars =
+ newTemplate(
+ "s3a://bucket/app.jar", ImmutableMap.of("spark.jars",
"https://repo/other.jar"));
+ sparkConf = (Map<String, String>) getSpec(withJars,
withDefaults).get("sparkConf");
+ Assertions.assertEquals("https://repo/other.jar",
sparkConf.get("spark.jars"));
+ }
+
@Test
@SuppressWarnings("unchecked")
public void testBuildSparkApplication() {
@@ -81,7 +161,7 @@ public class TestSparkApplicationUtils {
.withArchives(ImmutableList.of("hdfs://nn/env.tgz#env"))
.withConfigs(
ImmutableMap.of(
- "spark.master", "local[*]",
+ "spark.master", "k8s://https://kubernetes.default.svc",
"spark.submit.deployMode", "cluster",
"spark.executor.instances", "2",
"spark.jars", "https://repo/other.jar"))
@@ -492,6 +572,203 @@ public class TestSparkApplicationUtils {
"https://repo/gravitino-jobs.jar", getSpec(template,
configs).get("jars"));
}
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testSparkConfOfBuiltinJob() {
+ String json =
+ "{\"spark.jars\":\"https://repo/iceberg-spark-runtime.jar\","
+ + "\"spark.jars.packages\":\"org.example:lib:1.0\","
+ + "\"spark.executor.memory\":\"4g\","
+ + "\"spark.sql.shuffle.partitions\":200,"
+ + "\"spark.master\":\"local[*]\","
+ + "\"spark.submit.deployMode\":\"client\"}";
+ List<String> arguments = ImmutableList.of("--catalog", "demo",
"--spark-conf", json);
+ SparkJobTemplate template =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-rewrite-data-files")
+ .withArguments(arguments)
+ .withConfigs(
+ ImmutableMap.of(
+ "spark.master", "",
+ "spark.executor.memory", "1g",
+ "spark.driver.memory", "1g"))
+ .build();
+
+ Map<String, Object> spec = getSpec(template, configs);
+ Map<String, String> sparkConf = (Map<String, String>)
spec.get("sparkConf");
+ Assertions.assertEquals("https://repo/iceberg-spark-runtime.jar",
sparkConf.get("spark.jars"));
+ Assertions.assertEquals("org.example:lib:1.0",
sparkConf.get("spark.jars.packages"));
+ // spark_conf overrides the configurations of the template.
+ Assertions.assertEquals("4g", sparkConf.get("spark.executor.memory"));
+ Assertions.assertEquals("1g", sparkConf.get("spark.driver.memory"));
+ Assertions.assertEquals("200",
sparkConf.get("spark.sql.shuffle.partitions"));
+ // The master and the deploy mode of spark_conf are ignored rather than
rejected.
+ Assertions.assertFalse(sparkConf.containsKey("spark.master"));
+ Assertions.assertFalse(sparkConf.containsKey("spark.submit.deployMode"));
+ // The driver still gets spark_conf.
+ Assertions.assertEquals(arguments, spec.get("driverArgs"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testSparkConfOfBuiltinJobKeepsExecutorDefaults() {
+ K8sJobExecutorConfigs withDefaults =
+ newConfigs(
+ ImmutableMap.of(
+ K8sJobExecutorConfigs.NAMESPACE,
+ "jobs",
+ "spark.conf.spark.jars",
+ "https://repo/iceberg-spark-runtime.jar"));
+ for (String json : new String[] {"", " ", "{}", "{\"spark.jars\":\" \"}"})
{
+ SparkJobTemplate template =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-expire-snapshots")
+ .withArguments(ImmutableList.of("--spark-conf", json))
+ .build();
+ Map<String, String> sparkConf =
+ (Map<String, String>) getSpec(template,
withDefaults).get("sparkConf");
+ Assertions.assertEquals(
+ "https://repo/iceberg-spark-runtime.jar",
sparkConf.get("spark.jars"), json);
+ Assertions.assertEquals("jobs",
sparkConf.get("spark.kubernetes.namespace"), json);
+ }
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testSparkConfOfBuiltinJobReplacesExecutorDefaults() {
+ K8sJobExecutorConfigs withDefaults =
+ newConfigs(
+ ImmutableMap.of(
+ K8sJobExecutorConfigs.NAMESPACE,
+ "jobs",
+ "spark.conf.spark.jars",
+ "https://repo/iceberg-spark-runtime.jar"));
+ SparkJobTemplate template =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-update-stats")
+ .withArguments(
+ ImmutableList.of(
+ "--spark-conf",
"{\"spark.jars\":\"https://repo/postgresql.jar\"}"))
+ .build();
+
+ // Like any Spark configuration of the job, spark.jars replaces the
executor default.
+ Map<String, String> sparkConf =
+ (Map<String, String>) getSpec(template, withDefaults).get("sparkConf");
+ Assertions.assertEquals("https://repo/postgresql.jar",
sparkConf.get("spark.jars"));
+ }
+
+ @Test
+ public void testRejectInvalidEnvironmentNameInSparkConfOfBuiltinJob() {
+ SparkJobTemplate template =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-update-stats")
+ .withArguments(
+ ImmutableList.of("--spark-conf", "{\"spark.executorEnv.BAD
NAME\":\"v\"}"))
+ .build();
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> SparkApplicationUtils.buildSparkApplication(CONTEXT,
template, configs));
+ Assertions.assertTrue(e.getMessage().contains("BAD NAME"), e.getMessage());
+
+ // Nor in the configs of a template.
+ SparkJobTemplate inConfigs =
+ newTemplate("s3a://bucket/app.jar",
ImmutableMap.of("spark.executorEnv.BAD NAME", "v"));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> SparkApplicationUtils.buildSparkApplication(CONTEXT, inConfigs,
configs));
+
+ SparkJobTemplate valid =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-update-stats")
+ .withArguments(ImmutableList.of("--spark-conf",
"{\"spark.executorEnv.MY_VAR\":\"v\"}"))
+ .build();
+ @SuppressWarnings("unchecked")
+ Map<String, String> sparkConf = (Map<String, String>) getSpec(valid,
configs).get("sparkConf");
+ Assertions.assertEquals("v", sparkConf.get("spark.executorEnv.MY_VAR"));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testSparkConfOfBuiltinJobIgnoresKubernetesConfigs() {
+ String json =
+ "{\"spark.kubernetes.namespace\":\"kube-system\","
+ +
"\"spark.kubernetes.authenticate.driver.serviceAccountName\":\"admin\","
+ + "\"spark.kubernetes.container.image\":\"evil:1.0\","
+ +
"\"spark.kubernetes.driver.secretKeyRef.TOKEN\":\"secret:token\","
+ + "\"spark.executor.memory\":\"2g\"}";
+ SparkJobTemplate template =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-expire-snapshots")
+ .withArguments(ImmutableList.of("--spark-conf", json))
+ .build();
+
+ GenericKubernetesResource app =
+ SparkApplicationUtils.buildSparkApplication(CONTEXT, template,
configs);
+
+ Assertions.assertEquals("default", app.getMetadata().getNamespace());
+ Map<String, String> sparkConf =
+ (Map<String, String>)
+ ((Map<String, Object>)
app.getAdditionalProperties().get("spec")).get("sparkConf");
+ Assertions.assertEquals("default",
sparkConf.get("spark.kubernetes.namespace"));
+ Assertions.assertEquals(
+ "spark",
sparkConf.get("spark.kubernetes.authenticate.driver.serviceAccountName"));
+ Assertions.assertEquals(
+ "gravitino-spark:3.5.9",
sparkConf.get("spark.kubernetes.container.image"));
+
Assertions.assertFalse(sparkConf.containsKey("spark.kubernetes.driver.secretKeyRef.TOKEN"));
+ Assertions.assertEquals("2g", sparkConf.get("spark.executor.memory"));
+ }
+
+ @Test
+ public void testRejectInvalidSparkConfOfBuiltinJob() {
+ for (String json :
+ new String[] {
+ "{bad}",
+ "null",
+ "\"a\"",
+ "[\"a\"]",
+ "{\"spark.jars\":[\"a.jar\"]}",
+ "{\"k\":{\"x\":\"y\"}}"
+ }) {
+ SparkJobTemplate template =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-expire-snapshots")
+ .withArguments(ImmutableList.of("--spark-conf", json))
+ .build();
+ IllegalArgumentException e =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> SparkApplicationUtils.buildSparkApplication(CONTEXT,
template, configs),
+ json);
+ Assertions.assertTrue(e.getMessage().contains("spark_conf"),
e.getMessage());
+ }
+
+ // A jar on the Gravitino server can't be read by the pods.
+ SparkJobTemplate serverJar =
+ newTemplateBuilder()
+ .withName("builtin-iceberg-expire-snapshots")
+ .withArguments(
+ ImmutableList.of("--spark-conf",
"{\"spark.jars\":\"/opt/iceberg.jar\"}"))
+ .build();
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> SparkApplicationUtils.buildSparkApplication(CONTEXT, serverJar,
configs));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void testSparkConfArgumentOfOtherJobsIsNotParsed() {
+ SparkJobTemplate template =
+ newTemplateBuilder()
+ .withName("user-job")
+ .withArguments(
+ ImmutableList.of("--spark-conf",
"{\"spark.jars\":\"https://repo/a.jar\"}"))
+ .build();
+ Map<String, String> sparkConf =
+ (Map<String, String>) getSpec(template, configs).get("sparkConf");
+ Assertions.assertFalse(sparkConf.containsKey("spark.jars"));
+ }
+
@Test
public void testBuiltinJobsJarOnlyReplacesExecutableOfBuiltinJobs() {
// Not a built-in job: "builtin-" must be the prefix of the name.