Troubleshooting¶
The single home for known issues seen building and running this at scale, each as symptom → cause → fix / where it's handled. Most are already handled in code or config; this doc explains why the handling exists so you recognize the symptom fast.
Grouped by where it bites: In-flight runs · Capacity · Ray · Spark · Registry / writes · Install / extras · Versions · Deploy / Terraform · Notebooks.
In-flight runs — probe, settle, cancel¶
The rest of this page is symptom-keyed. This section is the tool you reach for when the symptom is "I can't tell what this run is doing" — a bar that has stopped moving, a job that may or may not still exist, a run you want to stop without losing what it already produced.
Why the registry alone can't answer. A run_jobs row is written by the job. A job that dies
without writing — a preempted VM, a torn-down cluster, a driver OOM — leaves its row RUNNING
forever. That row is visually identical to a slow but healthy job. The probe exists to break
that tie by asking the runtime itself.
flowchart TD
Watch["monitor_run(run_id)<br/>Check quiet_seconds"] -->|"Suspiciously quiet"| Probe["main --probe / monitor(probe=True)<br/>Query Dataproc · Vertex Ray · Vertex CustomJob · GCE · GKE · Vertex AutoML · BigQuery"]
Probe -->|"RUNNING_CONFIRMED"| Wait["Healthy & Running<br/>Keep waiting (or --cancel --force)"]
Probe -->|"STALE_REGISTRY or<br/>LIKELY_COMPLETED"| SettleOK["Work Landed<br/>main --settle --force → COMPLETED"]
Probe -->|"LOST or<br/>ABANDONED_WAIT"| SettleFail["Job Gone / Walk Abandoned<br/>main --settle --force → FAILED"]
SettleOK & SettleFail --> Close["All Job Rows Terminal<br/>registry.ops close-runs --yes"]
Probe a run¶
python -m scale_forecasting.main --config CONFIG --probe # whole run
python -m scale_forecasting.main --config CONFIG --probe --job ml # one family
Forecaster(cfg).probe() # the same report
Forecaster(cfg).monitor(probe=True) # progress bars *with* the reconciliation attached
The probe reads the registry, then escalates every non-terminal family to its own runtime
(Dataproc / Vertex Ray / Vertex CustomJob / Compute Engine / GKE / Vertex AutoML / BigQuery) using the probe_handle recorded in
run_jobs.job_telemetry at launch. Three properties are worth knowing before you put it in a loop:
- It is advisory. Every native call is capped (20 s) and any error degrades that family to
UNKNOWN. A probe never raises and never hangs, so it cannot take down the thing that called it. - A terminal run short-circuits. Nothing is escalated and no native call is made, so probing a finished run is free.
- It is not a poll.
quiet_seconds(free, in everymonitor()) is the cheap signal; the probe is the deliberate escalation when an age has grown suspicious. Reach for it by exception.
Read the verdict¶
Each family comes back with one of seven verdicts. The two columns that matter are what the registry
says and what the runtime says; disagreement is set only when they contradict each other.
| Verdict | Means | What to do |
|---|---|---|
TRUST_REGISTRY |
Terminal, or never launched — nothing to reconcile. | Nothing. |
RUNNING_CONFIRMED |
The runtime confirms the job is alive. | Wait. The bar is slow, not dead. |
STALE_REGISTRY |
The runtime is terminal but the row never caught up. | The run is over; --settle writes that down. |
LIKELY_COMPLETED |
Job gone and every expected cell landed. | It finished; the finalize write was lost. --settle. |
LOST |
Job gone, artifacts missing, past the startup grace. | The job died. --settle finalizes it FAILED, then re-run. |
ABANDONED_WAIT |
Still AWAITING_CAPACITY past any walk's own budget. |
The driver that was waiting is gone. --settle, then re-run. |
UNKNOWN |
Couldn't tell — no handle, or the probe degraded. | Re-probe; treat the registry as authoritative meanwhile. |
Some distinctions in that table are deliberate and easy to misread:
LIKELY_COMPLETEDvsLOSTis decided by artifact evidence, not by the runtime. Both are "the job is gone"; the landed-vs-expected cell count is what separates a run that finished from one that died. A family with no known denominator (n_expectedunavailable) can never beLIKELY_COMPLETED— it degrades toLOSTrather than claiming success it can't prove.- A young job that 404s is
UNKNOWN, notLOST. TheRUNNINGrow is written before the native job exists, so a probe fired seconds after launch legitimately finds nothing. Only a family quiet past the startup grace (15 min,stale_after_sto override) is judgedLOST. AnEMITTEDrow — a staged command whose job id has been handed out but which nobody has run yet — gets the same grace for the same reason. - A native family reporting
SUCCEEDEDwith incomplete artifacts isUNKNOWN, not stale. A BigQuery family's statements goDONEone at a time, so an all-DONEreading mid-run is a lull between statements. The probe declines to overrule the registry on that. ABANDONED_WAITis the one verdict with no runtime behind it. A family still waiting for capacity never created a runtime job, so there is nothing to ask; the witness is the clock. A live capacity walk ends itself at its budget (CapacityPolicy.max_wall_seconds, 3600 s) by writingFAILED/CAPACITY_EXHAUSTED, so a row stillAWAITING_CAPACITYat twice that (2 h,abandoned_after_sto override) was written by a walk that stopped existing — a Ctrl-C, a closed shell, a restarted kernel. Under that window the wait is legitimate and readsTRUST_REGISTRY.- A capacity wait is asked, and usually ignored. Every job row carries the runtime id it was
going to submit under from the moment it is written, so the probe can address the job during the
launch window rather than only after it. A side effect is that a family still hopping regions gets
asked about a job that does not exist yet. Its answer — "no such job" — is word-for-word what an
orphaned launcher's vanished job gives, so it is not allowed to change the row: only the runtime
naming a job it actually has, or a complete set of landed cells, can overrule an
AWAITING_CAPACITYstatus. Without that rule every healthy capacity wait in the fleet would reportUNKNOWN.
Settle a stale row¶
The probe knows the deep-learning family finished forty minutes ago. Settle is the verb that writes that down — a probe that writes, at the job tier, taking the probe's own per-family verdict.
python -m scale_forecasting.main --config CONFIG --settle # preview only
python -m scale_forecasting.main --config CONFIG --settle --force \
--reason "driver OOMed after the last cell landed" # execute
Six readings settle; everything else is refused:
| Verdict | Registry says | Runtime says | Cells | Settles to |
|---|---|---|---|---|
STALE_REGISTRY |
any | SUCCEEDED |
all landed | COMPLETED |
STALE_REGISTRY |
any | FAILED |
any | FAILED, failure_reason=RUNTIME_FAILED |
LIKELY_COMPLETED |
any | job gone | all landed | COMPLETED |
LOST |
EMITTED |
job gone | missing | FAILED, failure_reason=NEVER_LAUNCHED |
LOST |
anything else | job gone | missing | FAILED, failure_reason=RUNTIME_LOST |
ABANDONED_WAIT |
AWAITING_CAPACITY |
never launched | missing | FAILED, failure_reason=CAPACITY_ABANDONED |
Refusal is the feature. RUNNING_CONFIRMED is live, TRUST_REGISTRY is already terminal or
deliberately waiting, and UNKNOWN — the probe degraded, no handle was recorded, the runtime claims
SUCCEEDED but the cells have not landed — never writes. The preview lists the families it will
not touch and why, because "left alone: verdict UNKNOWN" is the line that means go look, and a
count of what was settled is exactly how the one row that mattered gets missed.
Six more things worth knowing:
NEVER_LAUNCHEDis notRUNTIME_LOST, and it is the same distinction in the other direction. Both rows read "job gone, work missing".RUNTIME_LOSTmeans a job ran and vanished, so there is something to go and look for — a driver log, an OOM, a cancelled batch.NEVER_LAUNCHEDmeans nothing ever ran: the row was filed bystageto reserve the job id it printed, and nobody pasted the command. There is nothing to investigate; just run it, or drop the run. See A submit is refused for why the row exists at all. Settling it early is safe even if you are merely slow: a launch that happens afterwards files a fresh row at the same attempt, andv_run_jobstakes the later one.CAPACITY_ABANDONEDis notCAPACITY_EXHAUSTED, and the difference is what you do next. Exhausted means the policy did its job and ran out of candidates — raisemax_attempts, add a region, or accept that the region has no room. Abandoned means the driver went away mid-walk, so the run tells you nothing about whether capacity existed. Just re-run it. Note themissingin that row of the table: anAWAITING_CAPACITYrow whose every expected series is already in BigQuery is refused rather than failed, because the work plainly happened whatever the row says.- Settle never deletes and never invents time. The landed data is untouched; the row gets a
status, a
failure_reasonwhen there is a token for it, and an audit blob. It does not stampended_atorruntime_seconds— a row settled three days after the fact would report three days of runtime for a ten-minute job. LIKELY_COMPLETEDsettles toCOMPLETED, and that arm has to exist. A Ray persistent resource is collected when its job finishes, so "no record of the job, and all the work is in BigQuery" is the normal trace of a successful Ray run whose driver died before closing the row.- It leaves the run header alone.
registry.ops close-runsowns that tier, and settling the job rows is what unblocks it (it refuses any run with a non-terminal row). The report prints the hint. - A row that reached a terminal status on its own wins. Every settle write is guarded against all four terminal statuses, because the process that ran the job outranks a repair verb. That skip is silent, so the rows are re-read afterwards and each outcome reports the status it actually ended up with — which is what the headline counts.
The audit trail lands under run_jobs.job_telemetry.$.settle: settled_by (resolved from ADC
unless you pass actor), settled_at, your reason, plus the whole of the evidence — from_status,
verdict, native_state, n_done / n_expected. A settled row is the one row in the registry
whose terminal status was written by somebody who was not running the job, so it carries enough for
a reader a week later to re-derive the decision and disagree with it.
Cancel safely¶
python -m scale_forecasting.main --config CONFIG --cancel # preview only
python -m scale_forecasting.main --config CONFIG --cancel --force \
--reason "superseded by the re-tuned config" # execute
Preview is the default. Without --force / confirm=True, cancel probes, prints the full blast
radius — every family, its runtime state, how many series have landed — and touches no runtime and
no registry. Read it before you commit; it is also the fastest way to see what a run has actually
produced so far.
On execute, each cancellable family's runtime job is stopped, its row is finalized to CANCELLED
with an audit blob, and the run header is rolled up. --job FAMILY narrows the whole operation to
one family.
Three behaviours that surprise people:
- A family already terminal is untouched —
CANCELLEDcounts as terminal, so cancelling twice is a no-op rather than an error. - The ensemble node is suppressed when any base family is cancelled, and the preview says so. An ensemble over a truncated base set would be quietly wrong, so it is skipped rather than computed on partial input.
- The header is only rolled up when every family has settled. All-
CANCELLED⇒ the run isCANCELLED; a mix ofCANCELLEDand other terminals ⇒PARTIAL. If any stop failed, the header is deliberately left alone — a run whose jobs are not all settled is never finalized.
What a cancel keeps¶
Cancel never deletes. Every cell, metric row and artifact that had already landed stays exactly
where it is. What changes is the label: the family's status becomes CANCELLED and its
n_done / n_expected counts record how far it got, so the partial data is identifiable rather than
masquerading as a complete run. The dedupe-on-read views mean a later full re-run of the same config
supersedes it without double-counting.
The audit trail lands under run_jobs.job_telemetry.$.cancel and records both intent and observed
reality: cancelled_by (resolved from ADC unless you pass actor), cancelled_at, your reason,
plus native_state_at_cancel and n_done_at_cancel — what was actually stopped, not just what was
asked for.
To remove the partial data as well, that is a separate, explicit step:
python -m scale_forecasting.registry.ops drop-run RUN_ID (see
operations.md). Keeping the two apart is the point — stopping a run and
discarding its results are different decisions.
Common shapes¶
Symptom: a bar has been quiet for a long time.
Probe. RUNNING_CONFIRMED ⇒ it's slow, wait. LOST ⇒ it died; --settle --force finalizes the row
FAILED, then re-run. Families that write their cells at job end are legitimately quiet for their
whole run, which is why an age alone is never a verdict.
Symptom: the run header says RUNNING but nothing is happening anywhere.
Probe. Expect STALE_REGISTRY or LIKELY_COMPLETED — the work finished but the finalize write was
lost. --settle --force writes the rows to what the runtime says: the landed results are intact
and, on LIKELY_COMPLETED, complete, so the rows go COMPLETED. Do not cancel here — cancel
would stamp CANCELLED over work that succeeded, and the label is the only thing left to get right.
Once every job row is terminal, registry.ops close-runs finalizes the header to what those rows
say. It skips any run whose job rows are still unsettled, which is exactly what settling them
unblocks.
Symptom: a header is stuck RUNNING and the run has no job rows at all.
The driver died in the submit path before it recorded a single family, so there is nothing to probe.
registry.ops close-runs closes it as FAILED; nothing completed and nobody stopped it.
Symptom: every job row says COMPLETED, but a family you configured has no row at all.
The driver died between families — after the last one finished, before the next was submitted — so
the missing family left nothing behind to notice its absence by. close-runs catches this by
comparing the rows against the families the run's own config planned, and closes the header to what
the run would have written: FAILED when the gap is the ensemble (you asked for an ensemble and
there isn't one), PARTIAL when a base family is missing and the others landed (their forecasts are
still usable). Nothing is deleted either way, and the predictions that did land stay queryable.
Symptom: no handle recorded.
The job predates the probe surface, or its telemetry blob is malformed. There is no way to address
the runtime job, so the verdict is UNKNOWN and cancel reports the family as not cancellable. Stop
it in the Cloud Console and use registry.ops to tidy the row.
Symptom: registry.ops drop-run refuses.
It will not touch a run whose header is still RUNNING or PENDING. Probe first — a RUNNING row
can also be a dead job — then settle the rows (--settle --force), cancel it properly if it really
is live, close the header with close-runs, or pass --force. Reach for drop-run --force only
when you actually want the run's data gone; if all you want is an accurate status, --settle plus
close-runs gets there without deleting anything.
Capacity — the cloud has no room¶
A cluster create that fails for want of machines is not a bug in your config, and it is the one failure worth re-submitting unchanged. It gets its own state so it stops looking like a hang and stops looking like a crash.
A family sits at AWAITING_CAPACITY¶
Symptom: monitor shows a job that is neither RUNNING nor terminal, sometimes for many
minutes.
Cause: the launcher is walking candidate regions/zones looking for one that can provision the
cluster. This is a live, non-terminal state — the work has not started and has not failed.
Fix: usually nothing; wait. Two things are worth knowing:
- The row's
capacityledger says exactly what has been tried. Read it fromv_run_jobs:
SELECT family, status, failure_reason, capacity FROM `PROJECT.DATASET.v_run_jobs`
WHERE run_id = 'RUN_ID'
Each attempt carries its candidate, the verdict, and the cloud's verbatim message. A shared
cluster is provisioned before any job row exists, so its ledger is on the run header instead —
same key, in v_run_summary.capacity, one entry per service.
- --cancel cannot interrupt a walk. The loop runs inside the submitting process, so there is
no server-side thing to cancel: stop the launching process, or let the budget expire. The row
itself is addressable — it carries a handle like any other — so cancel will try, and report that
it stopped nothing. That is honest rather than a failure.
A job FAILED with failure_reason = CAPACITY_EXHAUSTED¶
Symptom: a family went terminal without ever running.
Cause: the walk used up its attempt budget or its wall-clock budget without finding room. The
finished ledger is still on the row.
Fix: re-submit — often unchanged, since a stockout is transient. If the ledger shows
HARD_CEILING on every candidate, waiting will not help: that is a quota this project is not
allowed past, so either request more quota or ask for less (fewer GPUs, a different accelerator).
Verdicts, and what each one does to the walk:
| Verdict | Means | Effect |
|---|---|---|
TRANSIENT_CAPACITY |
No machines right now. | Hop to the next candidate, and come back to this one after the back-off. |
HARD_CEILING |
A quota you cannot exceed. | Hop, and drop this candidate from later passes — waiting cannot raise a quota. |
CONFIG_FAULT |
The request itself is wrong (bad machine type, retired image). | Stop immediately and re-raise. Another zone cannot fix it. |
An unrecognised message is treated as transient, deliberately: the cost of one extra hop is smaller than the cost of abandoning a run over a phrasing nobody had seen yet.
The wait is too long, or too short¶
Fix: compute.capacity sets per-service patience — max_attempts, max_passes,
max_wall_seconds, and the back-off. {"enabled": false} collapses every service to a single pass,
which is what you want when you would rather fail fast than wait. None of it moves the run_id: how
patient you are is an operational knob, not part of what you asked for. See the compute block in
configuration_reference.md.
Ray on Vertex¶
Ray job submission hangs, or HTTP 524 on /api/version¶
Symptom: the driver connects but job submission never returns, or the client gets a 524 timeout
hitting the cluster's /api/version.
Cause: the cluster needs both a PSC-I network attachment and a sufficiently large head
node (n1-standard-16+). Either one alone still 524s — it is a cluster-side capacity/networking
issue, not the client's location.
Fix: ensure the Terraform network attachment is applied and compute.ray_head_machine_type is
n1-standard-16 or larger. See deploying_on_gcp.md.
Ray job fails at env setup — No matching distribution for torch==…+cu126¶
Symptom: the cluster comes up and the job is submitted, but it goes straight to FAILED with
runtime_env setup failed and, in the job logs, ERROR: No matching distribution found for
torch==2.13.0+cu126. No cells are written.
Cause: the x86_64/linux torch pin is a CUDA local build (+cu126) that exists only on the
PyTorch wheel index, not PyPI. The Ray runtime_env uv install needs the same --extra-index-url
the container image build uses; without it — and without letting uv prefer that index — the pinned
wheel can't resolve and the whole job dies before any work runs.
Fix: handled — build_runtime_env() passes --extra-index-url
https://download.pytorch.org/whl/cu126 and --index-strategy unsafe-best-match in the uv
plugin's uv_pip_install_options (kept in lockstep with docker/Dockerfile, with PyPI still primary;
unsafe-best-match is what lets uv pick the +cu126 build from the extra index even though a
same-named wheel exists on PyPI). If you re-pin torch to a different CUDA minor, update the URL in both
places.
Ray driver OOM — no traceback, killed at "uploading package"¶
Symptom: the run dies with no Python traceback, often right after logging the package upload.
Cause: the driver/orchestrator RSS outgrew a small VM. An OOM-kill leaves no traceback.
Fix: run the orchestrator on a VM with ≥64 GB (e.g. e2-highmem-8). See
operations.md.
Long Ray run 401s after ~60 minutes¶
Symptom: a run polling for status starts returning 401 Unauthorized well into the run. Cause: the bearer token in the poll loop hit its TTL (~60 min). Neural Prophet on GPU is the long pole that pushes total runtime past the expiry. Fix: handled — the status poller does a single-retry re-auth on a 401. If you see a hard 401, confirm ADC is still valid on the orchestrator.
Regional GPU capacity stockout¶
Symptom: cluster create fails to obtain T4s in a region.
Cause: transient regional accelerator stockout.
Fix: compute.ray_regions is the candidate list the launcher walks, and the walk retries the
whole list rather than giving up after one pass. See
Capacity for the state it reports while it waits, the ledger it
keeps, and how to tune the patience. Add more regions if one is chronically short.
Ray cluster won't shrink when idle / can't grow under load¶
Symptom: the expensive GPU pool stays allocated while idle, or the CPU pool can't grow to work
through the queue.
Cause: the run used fixed-size sizing (ray_autoscale=false).
Fix: autoscaling is the default now (ray_autoscale=true), with independent per-pool
[min, max]. Set a low ray_gpu_min_nodes to shrink idle T4s and a high ray_cpu_max_nodes to
grow under load. See the compute block in
configuration_reference.md.
Spark on Dataproc¶
100k Spark run OOM — exit 137 → FetchFailedException¶
Symptom: executors killed with exit 137, cascading into FetchFailedException at large scale.
Cause: bucket sizing. Sizing buckets by max_parallelism silently fattened each pandas frame
until executors OOM'd.
Fix: buckets are sized by compute.bucket_target_cells (default 8) — buckets = ceil(cells /
target) bounds per-task memory at every scale. Lower bucket_target_cells if a heavy model set
still OOMs. See configuration_reference.md.
When a profile is available the target is applied per model rather than fleet-wide: a model
measured slower than the run's typical cell gets a proportionally smaller target and therefore
more buckets, so a bucket is closer to a fixed unit of work instead of a fixed number of
cells. A model measured faster keeps the global target exactly — the weights are floored at 1.0,
so this can only make frames smaller, never larger. The run's executed fan-out record
(sizing_executed.<family>.fanout) carries cost_weights and allocation, which is where to
look when one stage's tasks finish long after the rest.
100k batch cancelled at ~4 hours¶
Symptom: a large batch is cancelled around the 4-hour mark. Cause: the default Dataproc batch TTL. Fix: handled — the submitter sets an explicit 24h batch TTL.
Registry / writes¶
RegistryError: Storage Write API … 500/503/429, or 400 Cannot route on empty project id¶
Symptom: intermittent write failures over long, high-volume runs.
Cause: transient backend/routing blips on the Storage Write API over sustained streams.
Fix: handled — _append_via_write_api retries with exponential backoff (all engines route
writes through it). A genuine 400 (real bad request) still fails fast.
A submit is refused — the job id is already taken¶
Symptom: a launch fails immediately with ALREADY_EXISTS from Dataproc, or "job with
submission_id … already exists" from Ray. The registry row for it says
failure_reason = JOB_ID_TAKEN.
Cause: two different things decide the same name. The attempt number in a job id comes from the
registry — MAX(attempt) over run_jobs for that run and family — while the uniqueness of the id
is enforced by the platform. Those two agree only while every job that reached a platform also left
a row behind, and one path breaks that: a command that stage, plan or the Airflow emitter
printed is run by you, not by the launcher, so the job exists on Dataproc or Ray while the
registry never hears about it. The next --force reads the same MAX(attempt), hands out the same
id, and the platform refuses it.
Fix: nothing, usually — a forced launch now asks the platform whether the id it is about to
stamp is free, and walks the attempt forward until it finds one that is. You reach this error only
when that check could not get an answer (a degraded probe declines to walk rather than inflating the
attempt on a flaky read) or when twenty consecutive ids were taken. Either way the move is the same:
re-run with --force. To see what the platform actually holds before you do, probe the run:
What you will see in the registry. Staging now files a row for every job id it hands out, with
status EMITTED — "this id has been spent, whether or not anyone ran the command". It is a live
status, so it holds a repair back the way a RUNNING row does, and it is why the counter no longer
loses track of a pasted command. Two things clear it. If you run the command, the launch writes a
real row at the same attempt and the serving view v_run_jobs takes the later one, so the EMITTED
row disappears from every read with no cleanup. If you never run it, --settle reaps the row to
FAILED with failure_reason = NEVER_LAUNCHED once the platform confirms the id was never created
— spelled apart from RUNTIME_LOST because nothing ran, so there is nothing to go looking for.
A staged run has a header too, and its status is STAGED. Staging a config that has never run
opens its run_registry header, because --probe, --settle and --retry read a run's expected
work off that header and skip the run entirely when it is missing — without one they could not see
the EMITTED rows at all. The status is STAGED rather than RUNNING because nothing is running:
the artifacts are up and the job ids are spent, but no compute exists. It is non-terminal, so
--settle and close-runs still treat the run as open work, and it is a live status, so
drop-run will not quietly delete a run whose command you may still be holding. When you finally
paste the command, the launch appends its own RUNNING header and the newest row wins.
Your next command on that config will say "already staged", not "new run" and not "already ran".
An unforced re-stage reuses the attempt the staged command was told to use, which is what you want
if nobody pasted it — the commands you are already holding stay valid. --force spends the next
attempt, which is what you want if somebody might have.
An immediate re-run double-counts rows¶
Symptom: re-running the same config right away appears to duplicate rows.
Cause: the Storage Write API streaming buffer (~90 min) blocks a same-key DELETE, so the design
is append-only + dedupe-on-read, never delete-then-write.
Fix: this is by design and the serving views dedupe on run_id. If you want a physically
separate run, use a fresh/timestamped run_name (which changes the run_id). See
output_schemas.md.
Install / extras¶
MissingExtraError: ... needs the 'gcp' extra, which is not installed¶
Symptom: main.run, Forecaster.run(), a python -m scale_forecasting.*_submit command,
--probe, --dry-run --feasibility, registry.ops, or a plot call raises MissingExtraError
(also an ImportError) naming an extra — gcp, notebook, ray, or models-automl.
Cause: pip install scale-forecasting installs the pure offline layer only (config, every model
and metric, the planner, the playground). Everything that talks to Google Cloud lives behind
[gcp]; plotting behind [notebook]; the Ray client behind [ray]; the AutoML pipeline components
behind [models-automl]. Each entry point checks once, up front, so the failure is a sentence with
the install line rather than a ModuleNotFoundError from inside a client library.
Fix: install the extra the message names — pip install "scale-forecasting[gcp]" (or, from a
clone, uv sync --extra gcp) — and re-run. The full extra-by-extra table is in
runtime_dependencies.md.
ModuleNotFoundError: No module named 'google' from a reading or review method¶
Symptom: a core-only install raises a raw ModuleNotFoundError (not MissingExtraError) from
Forecaster.predictions_df(), Registry, or another method that reads BigQuery.
Cause: the read path constructs a BigQuery client lazily at the call site; only the launch,
submit, ops, and plotting entry points carry the guided check.
Fix: same remedy — pip install "scale-forecasting[gcp]". A notebook or workstation that both
launches and reviews wants [gcp,notebook]; the per-surface install table is in
runtime_dependencies.md.
list_models(available_only=True) returns 15, not 37¶
Symptom: the catalogue lists all 37 models but only 15 report as available.
Cause: availability is honest about what is importable: the 15 are the models whose library is
in the core install (statsmodels, scikit-learn, scipy). The rest declare an optional_extra —
models-stats, models-trees, models-prophet, models-dl, models-foundation, models-automl
for the four Vertex AI AutoML models, or gcp for the two BigQuery-native models.
Fix: install the family extras you intend to run ([models] is all six model extras, AutoML
included) plus [gcp] for the BigQuery-native pair. The model→extra map is the "Granular
installation extras" table in models_reference.md.
Versions / runtimes¶
PYTHON_VERSION_MISMATCH, or "why can't I move to 3.12?"¶
Symptom: a version-mismatch error between driver and workers, or a desire to bump to 3.12. Cause: the whole system is pinned to Python 3.11: Vertex Ray caps at 3.11, and Spark Connect uses runtime 2.3 (not 3.0). 3.11-everywhere is the only configuration that satisfies all runtimes. Fix: stay on 3.11. Full rationale and the per-surface matrix: version_matrix.md.
libgomp1 missing, or neuralprophet + pandas-3 breakage¶
Symptom: an import/link error for libgomp1, or neuralprophet failing against pandas 3.
Cause: neuralprophet needs the OpenMP runtime, and it is incompatible with pandas 3
(Series.view removal).
Fix: handled — the image includes libgomp1 and pandas is pinned <3. neuralprophet is an
optional extra; the registry registers-and-skips it when unavailable.
Deploy / Terraform¶
Cloud Shell Bus error running terraform¶
Symptom: terraform dies with a Bus error.
Cause: the Cloud Shell disk filled up.
Fix: disk-hygiene first (clear caches / large artifacts), then retry. See disk-hygiene in
operations.md. For long runs, prefer a persistent VM over Cloud Shell.
google_dataproc_batch apply errors, but the batch SUCCEEDED¶
Symptom: terraform/apply reports an error after submitting a batch that actually ran fine.
Cause: the provider's client-side wait raced the batch; the batch's state in GCP is the source
of truth.
Fix: treat the GCP batch state as authoritative; terraform import reconciles the resource.
Notebooks¶
--ack-out-of-org FAILED_PRECONDITION on notebook fan-out¶
Symptom: the notebook fan-out step fails a precondition for a project not in the corp org. Cause: the out-of-org acknowledgement flag applies only to corp-org projects. Fix: for a non-corp-org project, the flag is not applicable — see the headless/Colab specifics in notebook_runtimes.md.