Skip to content

Commit ceb48e1

Browse files
authored
Merge pull request #28 from Neuw84/test/spark-geospatial
fix(aws): Make the in-image Iceberg jar path configurable
2 parents 11ad4a6 + d933710 commit ceb48e1

3 files changed

Lines changed: 176 additions & 4 deletions

File tree

.github/workflows/aws-platform-tests.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ on:
3131
description: EMR release label
3232
type: string
3333
default: emr-spark-8.0.0
34+
iceberg-jar-path:
35+
description: >
36+
In-image Iceberg jar for spark.jars. Release-dependent; leave empty to
37+
omit spark.jars when Iceberg is already on the classpath.
38+
type: string
39+
default: /usr/share/aws/iceberg/lib/iceberg-spark3-runtime.jar
40+
probe:
41+
description: Run only the diagnostic job (reports the image layout)
42+
type: boolean
43+
default: false
3444
dry-run:
3545
description: Assume the role and stop (proves OIDC without spending)
3646
type: boolean
@@ -102,6 +112,8 @@ jobs:
102112
env:
103113
MODES: ${{ inputs.modes }}
104114
EMR_RELEASE_LABEL: ${{ inputs.release-label }}
115+
ICEBERG_JAR_PATH: ${{ inputs.iceberg-jar-path }}
116+
PROBE: ${{ inputs.probe }}
105117
run: uv run --with boto3 python tests/aws/run_emr_serverless.py
106118

107119
- name: Tear down billable resources

tests/aws/emr_probe.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Diagnostic job: report what the EMR Serverless image actually provides.
2+
3+
Submitted by run_emr_serverless.py when PROBE=1. Costs one short job run and
4+
answers the questions that otherwise turn into guesswork:
5+
6+
- where the Iceberg runtime jar lives, and what it is called
7+
- whether Iceberg is already on the default classpath (no spark.jars needed)
8+
- whether the S3 Tables catalog implementation is present
9+
- which Spark and Iceberg versions the release label actually gives you
10+
11+
Reads nothing and writes nothing. Deliberately does not create a SparkSession
12+
with Iceberg extensions, so it cannot fail for reasons unrelated to discovery.
13+
"""
14+
15+
import glob
16+
import os
17+
import subprocess
18+
import sys
19+
20+
CANDIDATE_DIRS = [
21+
"/usr/share/aws/iceberg/lib",
22+
"/usr/share/aws/iceberg",
23+
"/usr/lib/spark/jars",
24+
"/usr/share/aws/aws-java-sdk",
25+
"/usr/share/aws/s3tables",
26+
]
27+
28+
PATTERNS = ["*iceberg*", "*s3tables*", "*s3-tables*"]
29+
30+
31+
def section(title: str) -> None:
32+
print(f"\nPROBE ===== {title} =====")
33+
34+
35+
def main() -> int:
36+
section("python / spark")
37+
print(f"PROBE python: {sys.version.split()[0]}")
38+
try:
39+
import pyspark
40+
print(f"PROBE pyspark: {pyspark.__version__}")
41+
print(f"PROBE SPARK_HOME: {os.environ.get('SPARK_HOME', '(unset)')}")
42+
except Exception as e: # noqa: BLE001
43+
print(f"PROBE pyspark import failed: {e}")
44+
45+
section("candidate directories")
46+
for d in CANDIDATE_DIRS:
47+
if os.path.isdir(d):
48+
entries = sorted(os.listdir(d))
49+
print(f"PROBE dir {d}: {len(entries)} entries")
50+
for name in entries[:40]:
51+
print(f"PROBE {name}")
52+
else:
53+
print(f"PROBE dir {d}: MISSING")
54+
55+
section("jar search")
56+
for pattern in PATTERNS:
57+
hits = []
58+
for root in ("/usr/share/aws", "/usr/lib/spark", "/usr/lib", "/opt"):
59+
if os.path.isdir(root):
60+
hits += glob.glob(os.path.join(root, "**", pattern + ".jar"), recursive=True)
61+
print(f"PROBE pattern {pattern}: {len(hits)} hit(s)")
62+
for h in sorted(set(hits))[:25]:
63+
print(f"PROBE {h}")
64+
65+
section("classpath resolution")
66+
# Ask the JVM whether the Iceberg classes are already reachable without any
67+
# spark.jars: if they are, the extension config alone is enough.
68+
for cls in (
69+
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions",
70+
"org.apache.iceberg.aws.glue.GlueCatalog",
71+
"software.amazon.s3tables.iceberg.S3TablesCatalog",
72+
):
73+
try:
74+
from pyspark.sql import SparkSession
75+
spark = SparkSession.builder.appName("probe").getOrCreate()
76+
jvm = spark.sparkContext._jvm
77+
loader = jvm.java.lang.Thread.currentThread().getContextClassLoader()
78+
try:
79+
jvm.java.lang.Class.forName(cls, False, loader)
80+
print(f"PROBE class {cls}: PRESENT")
81+
except Exception:
82+
print(f"PROBE class {cls}: NOT on default classpath")
83+
except Exception as e: # noqa: BLE001
84+
print(f"PROBE class {cls}: check failed ({type(e).__name__}: {e})")
85+
86+
section("iceberg version hint")
87+
try:
88+
out = subprocess.run(
89+
["bash", "-lc", "ls /usr/share/aws/iceberg/lib 2>/dev/null || true"],
90+
capture_output=True, text=True, timeout=30,
91+
)
92+
print(f"PROBE ls: {out.stdout.strip() or '(empty)'}")
93+
except Exception as e: # noqa: BLE001
94+
print(f"PROBE ls failed: {e}")
95+
96+
print("\nPROBE done")
97+
return 0
98+
99+
100+
if __name__ == "__main__":
101+
sys.exit(main())

tests/aws/run_emr_serverless.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,17 @@
5454

5555
# Iceberg ships inside the EMR image; this is a local path in the container, not
5656
# a download. See the EMR Serverless "Using Apache Iceberg" documentation.
57-
ICEBERG_JAR = "/usr/share/aws/iceberg/lib/iceberg-spark3-runtime.jar"
57+
#
58+
# The documented path is the default below. It is overridable because the layout
59+
# is release-dependent: this exact value does not exist on emr-spark-8.0.0 (the
60+
# jar name carries "spark3", and EMR 8 runs Spark 4), where the job fails with
61+
# NoSuchFileException. Set ICEBERG_JAR_PATH to the real path for your release,
62+
# or to an empty string to omit spark.jars entirely when Iceberg is already on
63+
# the default classpath. Run with PROBE=1 to have the image report its layout.
64+
ICEBERG_JAR = os.environ.get(
65+
"ICEBERG_JAR_PATH", "/usr/share/aws/iceberg/lib/iceberg-spark3-runtime.jar"
66+
)
67+
PROBE = os.environ.get("PROBE", "").lower() in ("1", "true", "yes")
5868

5969
# Catalog implementations per storage mode.
6070
GLUE_CATALOG_IMPL = "org.apache.iceberg.aws.glue.GlueCatalog"
@@ -141,10 +151,12 @@ def _wait_for(get_state, want: set, bad: set, what: str, timeout_s: int = 600) -
141151
def spark_submit_params(mode: str, catalog_impl: str, warehouse: str) -> str:
142152
jars = ICEBERG_JAR
143153
if mode == "s3tables" and S3TABLES_EXTRA_JARS:
144-
jars = f"{jars},{S3TABLES_EXTRA_JARS}"
154+
jars = ",".join(j for j in (jars, S3TABLES_EXTRA_JARS) if j)
145155

146-
params = [
147-
f"--conf spark.jars={jars}",
156+
params = []
157+
if jars:
158+
params.append(f"--conf spark.jars={jars}")
159+
params += [
148160
"--conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions",
149161
"--conf spark.sql.catalog.local=org.apache.iceberg.spark.SparkCatalog",
150162
f"--conf spark.sql.catalog.local.catalog-impl={catalog_impl}",
@@ -159,6 +171,43 @@ def spark_submit_params(mode: str, catalog_impl: str, warehouse: str) -> str:
159171
return " ".join(params)
160172

161173

174+
def run_probe(app_id: str, probe_uri: str) -> int:
175+
"""Submit the diagnostic job and print where its output landed.
176+
177+
Cheap way to settle release-dependent questions (jar path, whether Iceberg is
178+
already on the classpath, whether the S3 Tables catalog exists) instead of
179+
guessing across job runs. Output goes to the driver stdout log in S3.
180+
"""
181+
print("\n[driver] === probe: reporting the image layout ===")
182+
resp = emr.start_job_run(
183+
applicationId=app_id,
184+
executionRoleArn=JOB_ROLE_ARN,
185+
name=f"{RESOURCE_PREFIX}-probe"[:64],
186+
executionTimeoutMinutes=15,
187+
jobDriver={"sparkSubmit": {"entryPoint": probe_uri, "entryPointArguments": [],
188+
"sparkSubmitParameters": ""}},
189+
configurationOverrides={
190+
"monitoringConfiguration": {
191+
"s3MonitoringConfiguration": {"logUri": s3_uri(ENGINE, "logs", RUN_TAG) + "/"}
192+
}
193+
},
194+
tags={"project": "iceberg-matrix", "run": RUN_TAG, "mode": "probe"},
195+
)
196+
job_id = resp["jobRunId"]
197+
state = _wait_for(
198+
lambda: emr.get_job_run(applicationId=app_id, jobRunId=job_id)["jobRun"]["state"],
199+
want={"SUCCESS", "FAILED", "CANCELLED"}, bad=set(),
200+
what=f"probe job {job_id}", timeout_s=1200,
201+
)
202+
log_prefix = f"{ENGINE}/logs/{RUN_TAG}/applications/{app_id}/jobs/{job_id}/"
203+
print(f"[driver] probe {state}")
204+
print(f"[driver] read the PROBE lines from the driver stdout under:")
205+
print(f"[driver] s3://{DATA_BUCKET}/{log_prefix}SPARK_DRIVER/stdout.gz")
206+
print(f"[driver] e.g. aws s3 cp s3://{DATA_BUCKET}/{log_prefix}SPARK_DRIVER/stdout.gz - "
207+
"| gunzip | grep '^PROBE'")
208+
return 0 if state == "SUCCESS" else 1
209+
210+
162211
def run_mode(app_id: str, mode: str, bundle_uri: str, entry_uri: str) -> dict:
163212
if mode == "s3buckets":
164213
catalog_impl = GLUE_CATALOG_IMPL
@@ -294,6 +343,16 @@ def main() -> int:
294343
modes = ["s3buckets", "s3tables"] if MODES == "both" else [MODES]
295344
print(f"[driver] region={REGION} bucket={DATA_BUCKET} modes={modes}")
296345

346+
if PROBE:
347+
probe_uri = upload(Path(__file__).with_name("emr_probe.py"),
348+
f"{ENGINE}/scripts/{RUN_TAG}/emr_probe.py")
349+
app_id = create_application()
350+
Path("/tmp/emr-application-id").write_text(app_id)
351+
if os.environ.get("GITHUB_ENV"):
352+
with open(os.environ["GITHUB_ENV"], "a") as f:
353+
f.write(f"EMR_APPLICATION_ID={app_id}\n")
354+
return run_probe(app_id, probe_uri)
355+
297356
bundle = build_bundle(Path("/tmp") / f"{RUN_TAG}-bundle.zip")
298357
bundle_uri = upload(bundle, f"{ENGINE}/scripts/{RUN_TAG}/bundle.zip")
299358
entry_uri = upload(Path(__file__).with_name("emr_entrypoint.py"),

0 commit comments

Comments
 (0)