Dataproc Serverless¶
executor.name: dataproc_serverless submits an asynchronous PySpark batch
using the active gcloud login. It requires a pre-existing GCS staging bucket,
worker service account, and permissions to use Dataproc Serverless and write
the configured output prefix. Submission never creates IAM bindings, buckets,
clusters, or images.
Principal |
Required existing access |
|---|---|
Submitter (the active |
Permission to submit and inspect the Dataproc batch, and to write the staging URI. Artifact Registry Reader on the maintained private repository is needed only to list or resolve image tags, which submitting against the default pin never does. |
Worker service account |
Dataproc worker access, read/write on the staging and output prefixes, Artifact Registry Reader on the private default-image repository, and |
External-image operator |
Only when overriding the default pin: a pinned image URI that the worker service account can pull. |
Which identity runs the batch¶
Route |
Identity |
Configured by |
|---|---|---|
Generation |
The account you name; required |
|
Annotation |
The project’s Dataproc runtime service account |
Nothing. The annotation |
Grant the worker access above to whichever of the two applies to the route you are submitting. If your project has no dedicated worker account, its Compute Engine default account works once it carries that access. List the accounts you can use:
gcloud iam service-accounts list --project=<project>
When the annotation configuration sets inference.api_key_secret, that
identity additionally needs roles/secretmanager.secretAccessor on the
secret rather than on the project:
gcloud secrets add-iam-policy-binding SECRET \
--member="serviceAccount:SA" \
--role="roles/secretmanager.secretAccessor"
Use the Managed Service for Apache Spark IAM roles guide, custom-container guide, and Artifact Registry access-control guide to provision that access before submitting.
Remote generation requires storage.backend: signal_dataset (or a compatible
native dataset-store plugin) and a gs:// output URI. Workers return strict,
attempt-keyed shard receipts. The coordinator submits all receipts to root-last
publication, which selects winning attempts and creates the ordinal index.
Immutable publication boundary¶
Signal Dataset owns immutable shard writes and root-last publication. RFGen owns Spark task scheduling, attempt receipts, expected-work completeness, and dataset/run identifiers. See Storage layouts.
Runnable configuration and lifecycle¶
Start from the complete maintained baseline, then replace its executor,
storage, and run blocks with the following values (keep the baseline’s
scene, channel, emitter, placement, and label blocks):
rfgen init narrowband-baseline ./config
executor:
name: dataproc_serverless
parallelism: 1
dataproc:
project: rf-foundation-models
region: us-central1
staging_uri: gs://my-precreated-staging/rfgen
service_account: rfgen-worker@my-project.iam.gserviceaccount.com
# image_uri: us-central1-docker.pkg.dev/my-project/rfgen/rfgen@sha256:...
storage:
backend: signal_dataset
path: gs://my-precreated-output/training-set
run:
run_id: training-set-v1
num_samples: 100000
shard_size: 1000
Validate and submit it with:
rfgen validate --config-dir ./config
rfgen generate --config-dir ./config
Generation returns after submission and prints the batch id plus exact status and cancel commands. The corresponding manual forms are:
gcloud dataproc batches describe BATCH_ID --region=REGION --project=PROJECT
gcloud dataproc batches cancel BATCH_ID --region=REGION --project=PROJECT --quiet
wait(handle) and cancel(handle) are the executor API equivalents. The
remote handle is opaque and belongs to the executor that submitted it.
Unified observation submissions¶
A configuration with projections uses the same ScenePlan, projection
preflight, aggregate adapter, and exactly-one-record-per-sample contract as
local execution. It additionally sets
executor.dataproc.observation_contract_version: 1.
Before creating a batch, RFGen requires a digest-pinned image present in the
qualified observation-runtime map, resolves all projection descriptors, checks
store capabilities, validates staged plugin distributions, and rejects an
external ScenePlan world. Projection-enabled Dataproc currently accepts only
no world or a sionna://builtin/... world; other world bundles are not staged
to workers.
The qualification map binds an exact image digest to the runtime build identity measured inside that image. The image workflow compares a real communications observation’s local and container encode/decode semantics before updating the map. A new source commit can therefore contain the feature while its map is still empty: this is a fail-closed release state. Publish and qualify the image, then use the workflow-generated pin before advertising remote availability.
The staged control objects are create-only and generation-verified. The driver and workers independently verify the observation contract, image/runtime identity, ordered package manifest, projection descriptors, and realized archives before plugin discovery or record work. A mismatch stops the job instead of accepting a newer configuration with older behavior. Retry attempts retain the same logical identities; publication selects one winning attempt per work ID.
See Generate one unified observation dataset for the local to remote configuration change.
Runtime image¶
Both remote routes default to a maintained image, pinned by digest in
rfgen.remote_image.DEFAULT_DATAPROC_IMAGE. A configuration that names no
image submits against that pin: annotation leaves dataproc.image_digest
unset, generation leaves executor.dataproc.image_uri unset. Selecting or
building an image is an override, described under Overriding the
pin.
The pin is a digest, not a tag, so a recorded run can prove afterwards what it
ran. A tag can be moved; a digest cannot. Dataproc Serverless documents
--container-image in tag form only, and the digest form is verified here by
use rather than by its documentation: the qualification runs cited under
Validation boundary submitted digest-pinned images.
The worker service account needs Artifact Registry Reader on that repository to
pull the image. The repository is private and lives in the
rf-foundation-models project, so for a worker in any other project this is a
cross-project grant that a maintainer of rf-foundation-models has to make.
When a rebuild is required¶
Rarely, and specifically not because your checkout is newer than the image. Shipping plugin packages is not a reason either: it works against the default pin.
For annotation, a submission carries the whole annotation configuration, and the image ignores configuration keys it does not declare, so adding a field does not by itself break an image that predates it.
That protects new keys, not new values inside a key the image already declares. An image validates the contents of every field it knows about against its own registries, so a value the submitting checkout added is rejected rather than ignored. Both of these are submission-time failures in the driver, against an image that is merely older than the checkout:
Value error, declared_evidence quantity fields have no registered extractor:
['duty_cycle']; supported: ['frequency_offset_hz', 'occupied_bandwidth_hz',
'sinr_db', 'snr_db', 'time_interval_s']
Value error, declared_evidence configuration applies only to the
declared-evidence template
The second is a renamed template: an image predating the rename accepts
neither the new caption.declared.v1 nor the name it replaced. Because a
template id and a quantity-field name are both values, no version handshake
catches either one, and the batch fails after it has been billed. When you
change a template id or add a quantity field, rebuild the image and pin the
new digest.
Two further things force a rebuild. A change to
what the remote driver needs from the configuration bumps
rfgen.annotation.config.REMOTE_CONTRACT_VERSION, and an image implementing an
older contract then refuses the submission and names both versions:
RuntimeError: invalid_bootstrap: the submission could not be read:
submission_requires_remote_contract_v2: this image implements v1. Submit
against a newer pinned annotation image.
A change to the bootstrap descriptor or to the staged PySpark wrapper is refused earlier, before the submission is read, so it reports itself differently:
RuntimeError: invalid_bootstrap: the bootstrap descriptor does not match the
shape this image implements; the submitting checkout is likely newer than the
pinned image
The wrapper check is symmetric: a newer image also refuses an older checkout’s
staged application, with the staged PySpark application is not the expected wrapper.
The signal_dataset
snapshot route stages no submission and no bootstrap descriptor, so it has no
version handshake at all: an image predating the route simply lacks
rfgen.annotation.snapshot_bootstrap, and the batch dies on an
ImportError in the driver rather than on a named refusal.
The maintained pin contains the route and has been run with it, so the route’s
presence is not a reason to rebuild. Its contract still can be: see When a
rebuild is required, which lists the changes that
fail inside the driver with no handshake to catch them. See
Generate, then annotate
and the availability limit in
rfgen.annotation.
Legacy generation behaves differently on purpose. It stages its whole driver from
the submitting checkout, where annotation stages only a two-line wrapper, but
the configuration models and every generation primitive still come from the
image, so its configuration is coupled to the image just as annotation’s is. A
driver that calls into an image API that has since changed fails as an ordinary
ImportError or AttributeError, not as a named refusal. What differs for
configuration is the payload: a generation submission carries only the
settings the operator actually wrote, so a setting the image does not implement
cannot be quietly skipped without producing a dataset nobody asked for. The
batch stops instead, naming what it could not honor:
ValueError: generation_config_not_implemented_by_this_image: channel.chain.0.foo.
Submit against a newer pinned image, or remove those settings.
So a legacy generation configuration that stays within what the pinned image implements submits unchanged, and one that reaches past it says so.
Projection-enabled generation uses the explicit observation contract and qualified image/runtime handshake above. It refuses an unqualified digest before billing.
The remedy in each of these cases is a newer pinned image, which normally means updating rfgen. Build only when you are changing rfgen itself and want to run remote work against your own change before it merges.
A field the image cannot read¶
RuntimeError: invalid_bootstrap: the submission could not be read:
invalid_submission
The submission carried a value the image’s own model rejects. Unknown keys are ignored, so this is not a field the image has never heard of: it is a field it knows with a value or type it does not accept. Read the batch’s driver output, which names the field and the reason. Then use a newer image, or set the field to something the image you are pointing at accepts.
Images built before the contract version existed¶
An image published before remote_contract_version was added to the submission
fails on any submission written by a current checkout, because it predates both
that field and the tolerance that would ignore it. It reports the failure with
no reason attached at all:
RuntimeError: invalid_bootstrap
Every image the repository has ever pinned as the default postdates that field, so you reach this only by naming an image yourself. Point at the default pin, or at any image published after 2026-08-09. Updating rfgen does not help, because the pin your checkout already carries is newer than the image you named.
Overriding the pin¶
Published tags are dated, <rfgen-version>-<YYYYMMDD>, with a -r2, -r3
suffix for a second or later build on the same day. List the newest few:
gcloud artifacts docker images list \
us-central1-docker.pkg.dev/rf-foundation-models/rfgen/rfgen \
--include-tags --sort-by=~CREATE_TIME --limit=5 \
--project=rf-foundation-models
Do not select the tag matching your installed rfgen.__version__. The
package version moves only on release, so many source trees share one version
string, and that tag names whichever of them was built last.
Resolve the tag you chose to the immutable reference a submission records:
gcloud artifacts docker images describe \
us-central1-docker.pkg.dev/rf-foundation-models/rfgen/rfgen:${SELECTED_TAG} \
--project=rf-foundation-models \
--format='value(image_summary.fully_qualified_digest)'
Set the returned ...rfgen@sha256:... value on the route you are submitting:
dataproc.image_digest for annotation, executor.dataproc.image_uri for
generation.
Do not try to judge compatibility in advance from timestamps. An image older than your last commit is usually still fine, because most changes touch neither the remote contract nor the bootstrap shape, and a rule based on commit dates rejects working images. The batch is the check, and it names what it refused.
Building your own¶
Build by hand to run remote work against a change that has not merged yet.
Where image publishing is enabled for the repository, a merge to main that
touches the package or the image build publishes a new image and commits the
new digest into rfgen.remote_image.DEFAULT_DATAPROC_IMAGE; where it is not,
that constant is whatever a person last committed. Pick the next unused suffix
for today, since republishing an existing tag is forbidden.
gcloud builds submit --config=docker/dataproc-serverless/cloudbuild.yaml \
--substitutions=_IMAGE_TAG=<rfgen-version>-<YYYYMMDD>[-rN],_RFGEN_VERSION=<rfgen-version> .
_IMAGE_TAG and _RFGEN_VERSION are independent substitutions. The tag is
free-form and carries the date; _RFGEN_VERSION must equal pyproject.toml’s
version, which the Docker build asserts. Pass _RFGEN_VERSION explicitly,
because cloudbuild.yaml carries a literal fallback that is only correct until
the next version bump. The tag identifies the build; the resolved digest is what
a submission records.
What the image contains¶
The image is built from docker/dataproc-serverless/Dockerfile, which installs
rfgen with its maintained remote dependencies and runtime utilities. It does
not contain every use-case package; ship the ones a
configuration needs with the submission instead, as described under Shipping
plugin packages. Sionna matters: every shipped
rfgen init template except multi-emitter-baseline and radar-response
selects a Sionna propagation backend, so an image without that extra fails a
generation batch with BackendUnavailableError even though the configuration
is correct. The radar-response template additionally requires a separately
supplied RadarSimPy artifact and is not supported by this image. Dataproc
Serverless supplies Spark and PySpark, so the custom container must not package
its own Spark distribution.
The driver resolves the configured BaseDatasetStore. Workers write immutable Signal Dataset shard attempts and return receipts; the coordinator publishes the dataset root only after all expected work IDs arrive.
Dataproc runs the container as UID/GID 1099 (spark): the image uses
PYSPARK_PYTHON to select its rfgen virtual environment instead of the
service-mounted Conda environment, and any runtime cache path must be writable
by that identity. Its CPU PyTorch installation avoids bundling unused CUDA
libraries.
Shipping plugin packages¶
One maintained image serves multiple use cases. A configuration that selects a node from a use-case package the image does not contain ships that package with the submission instead of requiring a rebuild:
executor:
name: dataproc_serverless
dataproc:
# ... project, region, staging_uri, service_account ...
extra_packages:
- dist/rfgen_signal_atlas_cuas-0.2.0-py3-none-any.whl
Build the wheel from the use-case package first:
uv build --wheel use_cases/signal-atlas/cuas-v1 -o dist
Match the version to the package: the wheel name follows its pyproject.toml.
Each entry must be a submitter-local .whl or .zip; nothing else is
supported. A remote (gs:// or any other scheme) reference is rejected,
because RFGen cannot verify its bytes before billing and has no mechanism to
verify them afterward either. Build the artifact where the submission runs.
A reference’s shape is checked when the configuration is read, so an
unusable suffix or scheme fails rfgen validate before anything is staged:
each entry must be non-blank and end in .whl or .zip. Entries are also
deduplicated by the name they would be staged under, not by the string you
wrote: because a .whl is renamed to .zip when it is staged (see below),
dist/foo-1.0-py3-none-any.whl and other/foo-1.0-py3-none-any.zip collide,
and the configuration is refused rather than silently keeping one.
Whether a local file actually exists, is a real zip archive, and declares at
least one rfgen.* entry-point group in its dist-info/entry_points.txt is
a separate, later check: it runs at submission time, immediately before
staging and before the batch is created, because rfgen validate may run
before the artifact is even built and cannot assume the filesystem it is
handed is the one that will submit. A package that fails any of these checks
is refused with zero gcloud calls, before the active-login check and
before anything is billed.
Verified packages are uploaded create-only into a packages/ subdirectory of
the submission’s staging root, and every staged URI is passed to gcloud dataproc batches submit pyspark --py-files, Spark’s own mechanism for
placing files on sys.path for the driver and every worker. Spark’s
--py-files loads only .py, .egg, and .zip files by suffix; a .whl
on that flag is silently ignored. A wheel is already a valid zip archive, so
a local .whl is renamed to .zip when it is staged (the bytes, including
the dist-info/entry_points.txt that makes the plugin discoverable, are
unchanged) purely so Spark agrees to load it.
Dependencies are not resolved: a shipped package’s own third-party
requirements must already be present in the pinned image. extra_packages
travels with the rest of the submitted configuration to the driver, which
revalidates the same shape rules there; the driver does not act on the field
itself, because by the time it runs the packages are already staged and
already on sys.path.
Shipping a package to the worker does not relieve the submitting machine
of needing it too. rfgen generate compiles the whole configured graph
locally — resolving and binding every node, including one this mechanism
ships — before it resolves or submits any executor, so a use-case package
named in the configuration has to be installed in the submitter’s own
environment (for example with uv pip install -e use_cases/<variant> or by
installing the same built wheel) or the submission never gets far enough to
reach extra_packages at all. extra_packages closes only the worker-side
half of that gap.
A version mismatch between the rfgen core installed in the pinned image and
the core a shipped package actually requires is not caught by anything
described on this page. If the package declares its own capability check —
see, for example, the pattern in
use_cases/signal-atlas/pnt-v1/rfgen_signal_atlas_pnt/__init__.py — that
check runs again on the worker at import time and fails the same way it would
locally, but only once a task actually imports the package, which is
mid-batch rather than at submission. It surfaces inside the same per-shard
error evidence any other node failure produces, not as a distinct refusal.
The image must still be new enough for the generation path the submission uses, which is a separate matter. The driver is staged from your checkout while every generation primitive comes from the image, so an image predating a primitive the driver calls fails at driver start:
ImportError: cannot import name 'generate_native_records' from
'rfgen.generation.single_record'
That is what a stale pin looks like: mid-batch, not at submission. The image’s own build-time smoke check imports the remote driver’s entry points so the mismatch surfaces when the image is built instead.
Validation boundary¶
Generation¶
The following is the guarded Dataproc generation E2E. It is excluded from ordinary CI and remains available-only rather than production-qualified:
RFGEN_ANNOTATION_E2E=YES \
RFGEN_ANNOTATION_E2E_REGION=us-central1 \
RFGEN_ANNOTATION_E2E_STAGING_URI=gs://my-staging/rfgen \
RFGEN_ANNOTATION_E2E_OUTPUT_URI=gs://my-output/rfgen-e2e \
RFGEN_ANNOTATION_E2E_SERVICE_ACCOUNT=rfgen-worker@my-project.iam.gserviceaccount.com \
pytest -m dataproc_e2e tests/manual/test_dataproc_serverless.py
GOOGLE_CLOUD_PROJECT optionally overrides the default project for that test.
Annotation¶
The annotation live-E2E is separate and manually gated. Bounded qualifications
are recorded under Evidence, which lists what each
run covers. None qualifies hosted text semantically or establishes fleet
reliability. The Dataproc annotation route has a bounded, manually qualified
workflow that requires an immutable image and a worker-readable dependency
prefix; the Vertex Batch workflow over WebDataset shards that stood beside it
went with that store, and so did the live test that used
RFGEN_ANNOTATION_E2E_DEPS_BUCKET and RFGEN_ANNOTATION_E2E_IMAGE_DIGEST —
nothing in this build reads either variable. See the unified annotation manual
qualification.
Only existing local Golden Paths are production-qualified.