Skip to content

Commit ae3e604

Browse files
ehighamclaude
andauthored
[batch] support multiple spark versions for jvm jobs (#15675)
## Change Description JVM jobs may now declare which spark version their jar was built against via an optional 'spark_version' field in the job spec. The front end and worker default it to 3.5.3, the version every released client is compatible with, so existing clients are unaffected and need no changes. - The worker no longer bakes pyspark into its image. It downloads an archive of the requested version's spark jars from cloud storage (SPARK_ARCHIVE_URL_PREFIX, plumbed from the new HAIL_QUERY_SPARK_ARCHIVE_SUBFOLDER env var) and extracts it to /spark/\<version>, reusing the query-jar download mechanism via a generic download_artifact. - scripts/upload-spark-jars.sh builds the archive from the pyspark sdist and uploads it to the namespace's jar prefix; the new upload_spark_jars CI step uploads the archive for env/SPARK_VERSION. - The front end validates at job creation that an archive exists for the requested spark version, making a missing archive a 400 at submission rather than a worker-side failure. - JVMEntryway keeps one shared spark classloader per `(spark classpath, jar classpath)` and is now plain java to avoid dependence on scala; the worker image runs jre 21. Other images that never run hail code (tls, infra bootstrap) use jre 25. - Bump INSTANCE_VERSION: the entryway wire protocol now includes the spark classpath. ## Security Assessment - This change potentially impacts the Hail Batch instance as deployed by Broad Institute in GCP ### Impact Rating - This change has a low security impact ### Impact Description This change adds one new user-controllable input: the optional `spark_version` field on JVM job specs, which the front end interpolates into a cloud-storage URL and the worker uses as a path component under `/spark/`. It is validated with `re.fullmatch(r'\d+\.\d+\.\d+')` at job creation (before any use), so no path traversal or URL manipulation is possible, and it can only select among spark-jar archives that batch's own CI uploaded to the batch-owned storage prefix — the same trust model as the existing ACCEPTABLE_QUERY_JAR_URL_PREFIX check for query jars. The worker now fetches spark jars from that batch-owned prefix instead of shipping them in the worker image; the provenance of code executed by JVM jobs is unchanged (CI-built artifacts in batch-controlled storage). The JRE 11→25 upgrade and the JVMEntryway Scala→Java conversion do not change authentication, authorization, or secret handling. ### Appsec Review - [ ] Required: The impact has been assessed and approved by appsec Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ebd7a2d commit ae3e604

26 files changed

Lines changed: 537 additions & 342 deletions

‎Makefile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ check-hail: check-hail-fast pylint-hailtop
6262

6363
.PHONY: check-batch
6464
check-batch: check-batch-fast pylint-batch
65-
cd batch/jvm-entryway && $(MILL) $(MILLOPTS) checkFormat + fix --check
65+
cd batch/jvm-entryway && $(MILL) $(MILLOPTS) palantirformat --check
6666

6767
.PHONY: check-services
6868
check-services: $(CHECK_SERVICES_MODULES)

‎batch/Dockerfile.worker‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ RUN hail-apt-get-install \
66
iproute2 \
77
iptables \
88
ca-certificates-java \
9-
openjdk-11-jre-headless \
9+
openjdk-21-jre-headless \
1010
liblapack3 \
1111
xfsprogs \
1212
libyajl-dev
@@ -45,18 +45,13 @@ COPY batch/pinned-requirements.txt batch-requirements.txt
4545
RUN hail-pip-install \
4646
-r hailtop-requirements.txt \
4747
-r gear-requirements.txt \
48-
-r batch-requirements.txt \
49-
pyspark==3.5.3
48+
-r batch-requirements.txt
5049

5150
RUN curl -sLO https://github.com/async-profiler/async-profiler/releases/download/v4.3/async-profiler-4.3-linux-x64.tar.gz \
5251
&& echo '69a16462c34c06ff55618f41653cffad1f8946822d30842512a3e0e774841c06 async-profiler-4.3-linux-x64.tar.gz' | sha256sum -c --status - \
5352
&& tar -C /opt -zxvf async-profiler-4.3-linux-x64.tar.gz
5453

55-
ENV SPARK_HOME=$VIRTUAL_ENV/lib/python$UV_PYTHON/site-packages/pyspark
56-
ENV PATH="$PATH:$SPARK_HOME/sbin:$SPARK_HOME/bin:/opt/async-profiler-4.3-linux-x64/bin"
57-
ENV PYSPARK_PYTHON=python3
58-
59-
COPY docker/core-site.xml ${SPARK_HOME}/conf/core-site.xml
54+
ENV PATH="$PATH:/opt/async-profiler-4.3-linux-x64/bin"
6055

6156
# Build crun in separate build step
6257
FROM base AS crun_builder

‎batch/batch/cloud/azure/driver/create_instance.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from ....file_store import FileStore
1313
from ....instance_config import InstanceConfig
1414
from ...resource_utils import unreserved_worker_data_disk_size_gib
15-
from ...utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX
15+
from ...utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX, SPARK_ARCHIVE_URL_PREFIX
1616
from ..resource_utils import azure_machine_type_to_parts
1717

1818
log = logging.getLogger('create_instance')
@@ -135,6 +135,7 @@ def create_vm_config(
135135
WORKER_DATA_DISK_NAME="{worker_data_disk_name}"
136136
UNRESERVED_WORKER_DATA_DISK_SIZE_GB="{unreserved_disk_storage_gb}"
137137
ACCEPTABLE_QUERY_JAR_URL_PREFIX="{ACCEPTABLE_QUERY_JAR_URL_PREFIX}"
138+
SPARK_ARCHIVE_URL_PREFIX="{SPARK_ARCHIVE_URL_PREFIX}"
138139
139140
# format worker data disk
140141
sudo mkfs.xfs -f -m reflink=1 -n ftype=1 {disk_location}
@@ -273,6 +274,7 @@ def create_vm_config(
273274
-e INTERNET_INTERFACE=$INTERNET_INTERFACE \
274275
-e UNRESERVED_WORKER_DATA_DISK_SIZE_GB=$UNRESERVED_WORKER_DATA_DISK_SIZE_GB \
275276
-e ACCEPTABLE_QUERY_JAR_URL_PREFIX=$ACCEPTABLE_QUERY_JAR_URL_PREFIX \
277+
-e SPARK_ARCHIVE_URL_PREFIX=$SPARK_ARCHIVE_URL_PREFIX \
276278
-e INTERNAL_GATEWAY_IP=$INTERNAL_GATEWAY_IP \
277279
-v /var/run/docker.sock:/var/run/docker.sock \
278280
-v /var/run/netns:/var/run/netns:shared \

‎batch/batch/cloud/gcp/driver/create_instance.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from ....file_store import FileStore
1313
from ....instance_config import InstanceConfig
1414
from ...resource_utils import unreserved_worker_data_disk_size_gib
15-
from ...utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX
15+
from ...utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX, SPARK_ARCHIVE_URL_PREFIX
1616
from ..resource_utils import GPUConfig, gcp_local_ssd_count, gcp_machine_type_to_parts, machine_type_to_gpu
1717

1818
log = logging.getLogger('create_instance')
@@ -185,6 +185,7 @@ def scheduling() -> dict:
185185
NUM_LOCAL_SSDS="{num_local_ssds}"
186186
UNRESERVED_WORKER_DATA_DISK_SIZE_GB="{unreserved_disk_storage_gb}"
187187
ACCEPTABLE_QUERY_JAR_URL_PREFIX="{ACCEPTABLE_QUERY_JAR_URL_PREFIX}"
188+
SPARK_ARCHIVE_URL_PREFIX="{SPARK_ARCHIVE_URL_PREFIX}"
188189
189190
CORES=$(nproc)
190191
NAMESPACE=$(curl -s -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/attributes/namespace")
@@ -356,6 +357,7 @@ def scheduling() -> dict:
356357
-e INTERNET_INTERFACE=$INTERNET_INTERFACE \
357358
-e UNRESERVED_WORKER_DATA_DISK_SIZE_GB=$UNRESERVED_WORKER_DATA_DISK_SIZE_GB \
358359
-e ACCEPTABLE_QUERY_JAR_URL_PREFIX=$ACCEPTABLE_QUERY_JAR_URL_PREFIX \
360+
-e SPARK_ARCHIVE_URL_PREFIX=$SPARK_ARCHIVE_URL_PREFIX \
359361
-e INTERNAL_GATEWAY_IP=$INTERNAL_GATEWAY_IP \
360362
-v /var/run/docker.sock:/var/run/docker.sock \
361363
-v /var/run/netns:/var/run/netns:shared \

‎batch/batch/cloud/terra/azure/driver/driver.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
azure_worker_memory_per_core_mib,
4242
azure_worker_properties_to_machine_type,
4343
)
44-
from ....utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX
44+
from ....utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX, SPARK_ARCHIVE_URL_PREFIX
4545
from ..instance_config import TerraAzureSlimInstanceConfig
4646

4747
log = logging.getLogger('driver')
@@ -160,6 +160,7 @@ def create_vm_config(
160160
161161
UNRESERVED_WORKER_DATA_DISK_SIZE_GB=50
162162
ACCEPTABLE_QUERY_JAR_URL_PREFIX={shq(ACCEPTABLE_QUERY_JAR_URL_PREFIX)}
163+
SPARK_ARCHIVE_URL_PREFIX={shq(SPARK_ARCHIVE_URL_PREFIX)}
163164
164165
sudo mkdir -p /host/batch
165166
sudo mkdir -p /host/logs
@@ -236,6 +237,7 @@ def create_vm_config(
236237
-e WORKSPACE_ID=$WORKSPACE_ID \
237238
-e UNRESERVED_WORKER_DATA_DISK_SIZE_GB=$UNRESERVED_WORKER_DATA_DISK_SIZE_GB \
238239
-e ACCEPTABLE_QUERY_JAR_URL_PREFIX=$ACCEPTABLE_QUERY_JAR_URL_PREFIX \
240+
-e SPARK_ARCHIVE_URL_PREFIX=$SPARK_ARCHIVE_URL_PREFIX \
239241
-e REGION=$REGION \
240242
-v /var/run/docker.sock:/var/run/docker.sock \
241243
-v /var/run/netns:/var/run/netns:shared \

‎batch/batch/cloud/utils.py‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,23 @@ def possible_cloud_locations(cloud: str) -> Set[str]:
3030
return gcp_config.regions
3131

3232

33-
def _acceptable_query_jar_url_prefix() -> str:
33+
def _query_storage_url_prefix(subfolder_envvar: str) -> str:
3434
cloud = os.environ['CLOUD']
3535
query_storage_uri = os.environ['HAIL_QUERY_STORAGE_URI']
36-
jar_subfolder = os.environ['HAIL_QUERY_ACCEPTABLE_JAR_SUBFOLDER']
37-
acceptable_query_jar_url_prefix = query_storage_uri + jar_subfolder
36+
subfolder = os.environ[subfolder_envvar]
37+
url_prefix = query_storage_uri + subfolder
3838

39-
assert jar_subfolder[0] == '/', (query_storage_uri, jar_subfolder)
40-
assert query_storage_uri[-1] != '/', (query_storage_uri, jar_subfolder)
39+
assert subfolder[0] == '/', (query_storage_uri, subfolder)
40+
assert query_storage_uri[-1] != '/', (query_storage_uri, subfolder)
4141

4242
if cloud == 'gcp':
43-
assert GoogleStorageAsyncFS.valid_url(acceptable_query_jar_url_prefix)
43+
assert GoogleStorageAsyncFS.valid_url(url_prefix)
4444
else:
4545
assert cloud == 'azure'
46-
assert AzureAsyncFS.valid_url(acceptable_query_jar_url_prefix)
46+
assert AzureAsyncFS.valid_url(url_prefix)
4747

48-
return acceptable_query_jar_url_prefix
48+
return url_prefix
4949

5050

51-
ACCEPTABLE_QUERY_JAR_URL_PREFIX = _acceptable_query_jar_url_prefix()
51+
ACCEPTABLE_QUERY_JAR_URL_PREFIX = _query_storage_url_prefix('HAIL_QUERY_ACCEPTABLE_JAR_SUBFOLDER')
52+
SPARK_ARCHIVE_URL_PREFIX = _query_storage_url_prefix('HAIL_QUERY_SPARK_ARCHIVE_SUBFOLDER')

‎batch/batch/front_end/front_end.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
memory_to_worker_type,
9797
valid_machine_types,
9898
)
99-
from ..cloud.utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX
99+
from ..cloud.utils import ACCEPTABLE_QUERY_JAR_URL_PREFIX, SPARK_ARCHIVE_URL_PREFIX
100100
from ..exceptions import (
101101
BatchOperationAlreadyCompletedError,
102102
BatchUserError,
@@ -110,6 +110,7 @@
110110
from ..file_store import FileStore
111111
from ..globals import (
112112
BATCH_FORMAT_VERSION,
113+
DEFAULT_SPARK_VERSION,
113114
HTTP_CLIENT_MAX_SIZE,
114115
RESERVED_STORAGE_GB_PER_CORE,
115116
complete_states,
@@ -1312,6 +1313,10 @@ async def _create_jobs(
13121313
if not jar_url.startswith(ACCEPTABLE_QUERY_JAR_URL_PREFIX):
13131314
raise web.HTTPBadRequest(reason=f'unacceptable JAR url: {jar_url}')
13141315

1316+
spark_version = spec['process'].setdefault('spark_version', DEFAULT_SPARK_VERSION)
1317+
if not await app[AppKeys.SPARK_ARCHIVE_EXISTENCE_CACHE].lookup(spark_version):
1318+
raise web.HTTPBadRequest(reason=f'no spark jars archive exists for spark version {spark_version}')
1319+
13151320
req_memory_bytes: Optional[int]
13161321
if machine_type is None:
13171322
if 'cpu' not in resources:
@@ -3835,6 +3840,7 @@ def log(self, request, response, time):
38353840

38363841
class AppKeys(CommonAiohttpAppKeys):
38373842
QOB_JAR_RESOLUTION_CACHE = web.AppKey('qob_jar_resolution_cache', TimeLimitedMaxSizeCache[Tuple[str, str], str])
3843+
SPARK_ARCHIVE_EXISTENCE_CACHE = web.AppKey('spark_archive_existence_cache', TimeLimitedMaxSizeCache[str, bool])
38383844

38393845

38403846
async def on_startup(app):
@@ -3923,6 +3929,13 @@ async def resolve_qob_jar_url(git_revision: str) -> Optional[str]:
39233929
resolve_qob_jar_url, int(1e10), 100, AppKeys.QOB_JAR_RESOLUTION_CACHE._name
39243930
)
39253931

3932+
async def spark_archive_exists(spark_version: str) -> bool:
3933+
return await fs.exists(SPARK_ARCHIVE_URL_PREFIX + '/spark-' + spark_version + '.tar.gz')
3934+
3935+
app[AppKeys.SPARK_ARCHIVE_EXISTENCE_CACHE] = TimeLimitedMaxSizeCache(
3936+
spark_archive_exists, int(1e10), 100, AppKeys.SPARK_ARCHIVE_EXISTENCE_CACHE._name
3937+
)
3938+
39263939
app['task_manager'].ensure_future(periodically_call(5, _refresh, app))
39273940

39283941

‎batch/batch/front_end/validate.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
}),
8383
required('command'): listof(str_type),
8484
'profile': bool_type,
85+
# the worker uses this as a path component, hence the strict pattern
86+
'spark_version': regex(r'\d+\.\d+\.\d+'),
8587
},
8688
},
8789
),

‎batch/batch/globals.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323

2424
BATCH_FORMAT_VERSION = 7
2525
STATUS_FORMAT_VERSION = 5
26-
INSTANCE_VERSION = 35
26+
INSTANCE_VERSION = 36
27+
28+
# jvm jobs from hail versions that predate multi-spark support were built against spark 3.5
29+
DEFAULT_SPARK_VERSION = '3.5.3'
2730

2831
MAX_PERSISTENT_SSD_SIZE_GIB = 64 * 1024
2932
RESERVED_STORAGE_GB_PER_CORE = 5

0 commit comments

Comments
 (0)