scale-forecasting 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scale_forecasting/__init__.py +275 -0
- scale_forecasting/_entry.py +110 -0
- scale_forecasting/_infra_args.py +91 -0
- scale_forecasting/agent_surfaces.py +1571 -0
- scale_forecasting/airflow_emit.py +325 -0
- scale_forecasting/airflow_tasks.py +414 -0
- scale_forecasting/automl_submit.py +383 -0
- scale_forecasting/backtest.py +1153 -0
- scale_forecasting/batch_infra.py +322 -0
- scale_forecasting/batch_telemetry.py +241 -0
- scale_forecasting/calibration.py +900 -0
- scale_forecasting/capacity.py +659 -0
- scale_forecasting/cluster_deps.py +113 -0
- scale_forecasting/cluster_submit.py +383 -0
- scale_forecasting/cluster_telemetry.py +108 -0
- scale_forecasting/code_delivery.py +196 -0
- scale_forecasting/commands.py +459 -0
- scale_forecasting/compute_fallback.py +190 -0
- scale_forecasting/config.py +2530 -0
- scale_forecasting/dag.py +551 -0
- scale_forecasting/data_gen/__init__.py +3 -0
- scale_forecasting/data_gen/generator.py +415 -0
- scale_forecasting/data_gen/seed_spark.py +505 -0
- scale_forecasting/dataproc_cluster.py +869 -0
- scale_forecasting/device_audit.py +219 -0
- scale_forecasting/diagnostics.py +1510 -0
- scale_forecasting/engines/__init__.py +6 -0
- scale_forecasting/engines/automl_engine.py +2162 -0
- scale_forecasting/engines/bigquery_engine.py +623 -0
- scale_forecasting/engines/bigquery_names.py +101 -0
- scale_forecasting/engines/bigquery_sql.py +783 -0
- scale_forecasting/engines/ray_engine.py +809 -0
- scale_forecasting/engines/ray_io.py +1007 -0
- scale_forecasting/engines/spark_explode.py +358 -0
- scale_forecasting/engines/spark_io.py +763 -0
- scale_forecasting/engines/vertex_engine.py +1171 -0
- scale_forecasting/ensemble_run.py +1102 -0
- scale_forecasting/ensembler.py +1279 -0
- scale_forecasting/errors.py +188 -0
- scale_forecasting/features.py +1352 -0
- scale_forecasting/gce_submit.py +1028 -0
- scale_forecasting/gke_submit.py +1587 -0
- scale_forecasting/hardware.py +265 -0
- scale_forecasting/hpo.py +424 -0
- scale_forecasting/identity.py +94 -0
- scale_forecasting/job_launch.py +768 -0
- scale_forecasting/job_outcome.py +241 -0
- scale_forecasting/job_wait.py +111 -0
- scale_forecasting/launch_plan.py +1107 -0
- scale_forecasting/main.py +799 -0
- scale_forecasting/mcp.py +1385 -0
- scale_forecasting/metrics/__init__.py +253 -0
- scale_forecasting/metrics/_intervals.py +19 -0
- scale_forecasting/metrics/base_metric.py +217 -0
- scale_forecasting/metrics/bias.py +34 -0
- scale_forecasting/metrics/coverage.py +40 -0
- scale_forecasting/metrics/cv.py +24 -0
- scale_forecasting/metrics/interval_score.py +42 -0
- scale_forecasting/metrics/interval_width.py +38 -0
- scale_forecasting/metrics/maape.py +38 -0
- scale_forecasting/metrics/mae.py +30 -0
- scale_forecasting/metrics/mape.py +33 -0
- scale_forecasting/metrics/mase.py +45 -0
- scale_forecasting/metrics/mase_seasonal.py +49 -0
- scale_forecasting/metrics/mse.py +32 -0
- scale_forecasting/metrics/msis.py +30 -0
- scale_forecasting/metrics/msse.py +28 -0
- scale_forecasting/metrics/ope.py +24 -0
- scale_forecasting/metrics/pinball.py +49 -0
- scale_forecasting/metrics/r2.py +27 -0
- scale_forecasting/metrics/rmse.py +30 -0
- scale_forecasting/metrics/rmsle.py +25 -0
- scale_forecasting/metrics/rmsse.py +41 -0
- scale_forecasting/metrics/smape.py +33 -0
- scale_forecasting/metrics/wape.py +34 -0
- scale_forecasting/models/__init__.py +112 -0
- scale_forecasting/models/_lag_forecaster.py +407 -0
- scale_forecasting/models/_neuralforecast_base.py +605 -0
- scale_forecasting/models/_vertex_automl_base.py +268 -0
- scale_forecasting/models/auto_arima.py +142 -0
- scale_forecasting/models/auto_ces.py +103 -0
- scale_forecasting/models/auto_theta.py +113 -0
- scale_forecasting/models/autoets.py +106 -0
- scale_forecasting/models/base_model.py +600 -0
- scale_forecasting/models/bigquery_native.py +177 -0
- scale_forecasting/models/catboost_model.py +159 -0
- scale_forecasting/models/croston.py +137 -0
- scale_forecasting/models/fft.py +108 -0
- scale_forecasting/models/holtwinters.py +85 -0
- scale_forecasting/models/kalman.py +95 -0
- scale_forecasting/models/lightgbm_model.py +160 -0
- scale_forecasting/models/naive_drift.py +69 -0
- scale_forecasting/models/naive_mean.py +60 -0
- scale_forecasting/models/naive_moving_average.py +93 -0
- scale_forecasting/models/naive_seasonal.py +72 -0
- scale_forecasting/models/neuralprophet_model.py +421 -0
- scale_forecasting/models/patchtst.py +51 -0
- scale_forecasting/models/prophet_model.py +121 -0
- scale_forecasting/models/random_forest.py +153 -0
- scale_forecasting/models/regression_lags.py +171 -0
- scale_forecasting/models/sarimax.py +92 -0
- scale_forecasting/models/stl_bagging.py +109 -0
- scale_forecasting/models/tbats_model.py +102 -0
- scale_forecasting/models/tft.py +46 -0
- scale_forecasting/models/theta.py +89 -0
- scale_forecasting/models/tide.py +47 -0
- scale_forecasting/models/tsmixer.py +48 -0
- scale_forecasting/models/ucm.py +91 -0
- scale_forecasting/models/vertex_l2l.py +31 -0
- scale_forecasting/models/vertex_seq2seq.py +30 -0
- scale_forecasting/models/vertex_tft.py +32 -0
- scale_forecasting/models/vertex_tide.py +32 -0
- scale_forecasting/models/xgboost_model.py +164 -0
- scale_forecasting/notebook_acceptance.py +755 -0
- scale_forecasting/playground.py +793 -0
- scale_forecasting/probes/__init__.py +3 -0
- scale_forecasting/probes/cancel.py +408 -0
- scale_forecasting/probes/reconcile.py +577 -0
- scale_forecasting/probes/runtimes.py +941 -0
- scale_forecasting/probes/settle.py +466 -0
- scale_forecasting/probes/vocabulary.py +245 -0
- scale_forecasting/profiling/__init__.py +3 -0
- scale_forecasting/profiling/baseline.py +240 -0
- scale_forecasting/profiling/cost.py +705 -0
- scale_forecasting/profiling/measure.py +440 -0
- scale_forecasting/profiling/numeric.py +73 -0
- scale_forecasting/profiling/sampling.py +290 -0
- scale_forecasting/profiling/signature.py +136 -0
- scale_forecasting/profiling/source.py +473 -0
- scale_forecasting/profiling/stats.py +221 -0
- scale_forecasting/py.typed +0 -0
- scale_forecasting/quota.py +1767 -0
- scale_forecasting/ray_cluster.py +767 -0
- scale_forecasting/ray_entry.py +46 -0
- scale_forecasting/ray_infra.py +121 -0
- scale_forecasting/ray_jobs.py +462 -0
- scale_forecasting/ray_reaper.py +392 -0
- scale_forecasting/ray_submit.py +323 -0
- scale_forecasting/ray_telemetry.py +106 -0
- scale_forecasting/reconciliation.py +929 -0
- scale_forecasting/registry/__init__.py +3 -0
- scale_forecasting/registry/artifacts.py +223 -0
- scale_forecasting/registry/cells.py +115 -0
- scale_forecasting/registry/ddl.py +408 -0
- scale_forecasting/registry/harvest.py +252 -0
- scale_forecasting/registry/header.py +368 -0
- scale_forecasting/registry/ids.py +556 -0
- scale_forecasting/registry/jobs.py +191 -0
- scale_forecasting/registry/lifecycle.py +284 -0
- scale_forecasting/registry/ops.py +1666 -0
- scale_forecasting/registry/params.py +150 -0
- scale_forecasting/registry/reads.py +971 -0
- scale_forecasting/registry/rows.py +556 -0
- scale_forecasting/registry/tables.py +150 -0
- scale_forecasting/registry/views.py +399 -0
- scale_forecasting/registry/write_api.py +386 -0
- scale_forecasting/report.py +932 -0
- scale_forecasting/resources/__init__.py +3 -0
- scale_forecasting/resources/audit.py +64 -0
- scale_forecasting/resources/catalog.py +263 -0
- scale_forecasting/resources/cluster.py +352 -0
- scale_forecasting/resources/fleet.py +613 -0
- scale_forecasting/resources/serverless.py +428 -0
- scale_forecasting/resources/slot.py +295 -0
- scale_forecasting/retry_policy.py +378 -0
- scale_forecasting/retry_run.py +535 -0
- scale_forecasting/review.py +2689 -0
- scale_forecasting/router.py +41 -0
- scale_forecasting/scenarios.py +564 -0
- scale_forecasting/sdk.py +2073 -0
- scale_forecasting/seasonality.py +60 -0
- scale_forecasting/settings.py +171 -0
- scale_forecasting/shared_clusters.py +348 -0
- scale_forecasting/smoke_run.py +83 -0
- scale_forecasting/spark_entry.py +47 -0
- scale_forecasting/staging.py +121 -0
- scale_forecasting/submit.py +667 -0
- scale_forecasting/submitters.py +515 -0
- scale_forecasting/validation.py +201 -0
- scale_forecasting/vertex_entry.py +81 -0
- scale_forecasting/vertex_submit.py +680 -0
- scale_forecasting/worker.py +1432 -0
- scale_forecasting-1.0.0.dist-info/METADATA +361 -0
- scale_forecasting-1.0.0.dist-info/RECORD +186 -0
- scale_forecasting-1.0.0.dist-info/WHEEL +4 -0
- scale_forecasting-1.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""What terminal status did this run earn? — the job tier's, and the header's roll-up of them.
|
|
2
|
+
|
|
3
|
+
Two questions, one module, because the second is only as honest as the first. `audit_cells` decides
|
|
4
|
+
what one family job's ``run_jobs`` row should say; `combined_run_status` folds a run's job statuses
|
|
5
|
+
into the header's. Both `main.run` and the Airflow `finalize_run` task call the second one — a
|
|
6
|
+
single definition rather than a local copy and a mirror, which is how the two tiers came to disagree
|
|
7
|
+
in the first place.
|
|
8
|
+
|
|
9
|
+
**The job tier.** A job row used to go terminal on one fact: the launch call returned without
|
|
10
|
+
raising. That is a
|
|
11
|
+
statement about the *submission*, not about the run. Everything the engine knows — how many cells
|
|
12
|
+
it fitted, how many raised — is computed on the remote driver by
|
|
13
|
+
`engines.spark_io.aggregate_status` and then thrown away, because a submitter hands back a probe
|
|
14
|
+
handle rather than an outcome. The 2026-09-10 negative arms are what made the gap concrete: a
|
|
15
|
+
Dataproc cluster job whose six cells all failed, and a Ray job that wrote nothing at all, both
|
|
16
|
+
closed ``COMPLETED`` on both tiers of the registry.
|
|
17
|
+
|
|
18
|
+
So this asks the question the same way `device_audit` asks its own: **one aggregate over
|
|
19
|
+
``forecast_metadata``, read from the driver after the job has finished, identical for every
|
|
20
|
+
runtime**. Serverless, a Dataproc cluster and Ray all write the same rows, so none of the three
|
|
21
|
+
needs a channel of its own and no engine has to learn to report anything.
|
|
22
|
+
|
|
23
|
+
Scoped by ``created_at`` rather than by attempt, because ``forecast_metadata`` has no attempt
|
|
24
|
+
column and it is append-only: a second attempt of a family sees the first attempt's rows sitting
|
|
25
|
+
under the same ``run_id``. See `read_cell_counts` for why a clock margin is safe here.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
from .errors import get_logger
|
|
33
|
+
from .registry.tables import bigquery_client
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
|
|
38
|
+
from .settings import Settings
|
|
39
|
+
|
|
40
|
+
_log = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
COMPLETED = "COMPLETED"
|
|
43
|
+
PARTIAL = "PARTIAL"
|
|
44
|
+
FAILED = "FAILED"
|
|
45
|
+
|
|
46
|
+
# How far before the launch call to start counting cells. The worker that stamps ``created_at`` is
|
|
47
|
+
# not the process that reads it, and their clocks are only as aligned as the fleet's NTP; a minute
|
|
48
|
+
# is orders of magnitude more than any skew observed and orders of magnitude less than the gap
|
|
49
|
+
# between two attempts of one family, which cannot be shorter than a cluster create.
|
|
50
|
+
_CLOCK_MARGIN_S = 60
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def job_status(*, cells: int, errors: int) -> str:
|
|
54
|
+
"""Fold a family job's cell tallies into its terminal status (pure).
|
|
55
|
+
|
|
56
|
+
The same three-way rule `engines.spark_io.aggregate_status` applies on the remote driver, kept
|
|
57
|
+
deliberately identical: every cell ok is ``COMPLETED``, no cell ok is ``FAILED``, a mix is
|
|
58
|
+
``PARTIAL`` — a run with surviving forecasts is not a failed run, and the cells that landed are
|
|
59
|
+
real and usable.
|
|
60
|
+
|
|
61
|
+
**Zero cells is ``FAILED``, not ``COMPLETED``.** A job that wrote no metadata at all did not
|
|
62
|
+
finish quietly; it did not run. That is the Ray case from 2026-09-10, where the workers crashed
|
|
63
|
+
before any cell could record itself and the only evidence was four ``WorkerCrashedError`` chunks
|
|
64
|
+
in the driver's telemetry.
|
|
65
|
+
"""
|
|
66
|
+
if cells <= 0 or errors >= cells:
|
|
67
|
+
return FAILED
|
|
68
|
+
return COMPLETED if errors <= 0 else PARTIAL
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_cell_counts(
|
|
72
|
+
run_id: str,
|
|
73
|
+
models: list[str],
|
|
74
|
+
*,
|
|
75
|
+
since: datetime | None = None,
|
|
76
|
+
settings: Settings | None = None,
|
|
77
|
+
) -> dict[str, Any]: # pragma: no cover - GCP I/O, exercised by the @gcp smokes
|
|
78
|
+
"""The one aggregate a status is decided from: ``{cells, errors}``.
|
|
79
|
+
|
|
80
|
+
Scoped to this family's ``models`` for the reason `device_audit.read_device_use` is — a run's
|
|
81
|
+
other families wrote into the same table under the same ``run_id`` — and to full-fit rows only,
|
|
82
|
+
since a fold row is bracketed by the full-fit row that covers it and an ensemble row is
|
|
83
|
+
arithmetic rather than a fit.
|
|
84
|
+
|
|
85
|
+
``since`` is the launch time, less `_CLOCK_MARGIN_S`. Without it a re-run counts its own cells
|
|
86
|
+
*and the previous attempt's*, which is how a clean second attempt after a total failure would
|
|
87
|
+
be judged ``PARTIAL`` on the strength of rows it did not write.
|
|
88
|
+
|
|
89
|
+
Never raises: a read that fails returns ``{}`` and the caller keeps the status it already had.
|
|
90
|
+
Getting the status wrong in the pessimistic direction — failing a good job because BigQuery
|
|
91
|
+
was briefly unreachable — is worse than the gap this closes.
|
|
92
|
+
"""
|
|
93
|
+
from google.cloud import bigquery
|
|
94
|
+
|
|
95
|
+
from .registry.tables import resolve_settings
|
|
96
|
+
|
|
97
|
+
resolved = resolve_settings(settings)
|
|
98
|
+
sql = (
|
|
99
|
+
"SELECT COUNT(*) AS cells, COUNTIF(cell_status='error') AS errors "
|
|
100
|
+
f"FROM `{resolved.registry_table_ref('forecast_metadata')}` "
|
|
101
|
+
"WHERE run_id=@run_id AND model_type IN UNNEST(@models) "
|
|
102
|
+
"AND fold_id IS NULL AND ensemble_id IS NULL "
|
|
103
|
+
"AND (@since IS NULL OR created_at >= @since)"
|
|
104
|
+
)
|
|
105
|
+
params = [
|
|
106
|
+
bigquery.ScalarQueryParameter("run_id", "STRING", run_id),
|
|
107
|
+
bigquery.ArrayQueryParameter("models", "STRING", models),
|
|
108
|
+
bigquery.ScalarQueryParameter("since", "TIMESTAMP", since),
|
|
109
|
+
]
|
|
110
|
+
try:
|
|
111
|
+
client = bigquery_client(resolved.project_id)
|
|
112
|
+
rows = list(
|
|
113
|
+
client.query(sql, job_config=bigquery.QueryJobConfig(query_parameters=params)).result()
|
|
114
|
+
)
|
|
115
|
+
except Exception as exc: # noqa: BLE001 - a status audit must never sink the job it audits
|
|
116
|
+
_log.warning("cell-count query failed for run %s: %r", run_id, exc)
|
|
117
|
+
return {}
|
|
118
|
+
return dict(rows[0]) if rows else {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cells_written(
|
|
122
|
+
run_id: str,
|
|
123
|
+
*,
|
|
124
|
+
since: datetime | None = None,
|
|
125
|
+
settings: Settings | None = None,
|
|
126
|
+
) -> int | None: # pragma: no cover - GCP I/O, exercised by the @gcp smokes
|
|
127
|
+
"""How many ``forecast_metadata`` rows this run has written so far — the liveness signal.
|
|
128
|
+
|
|
129
|
+
Deliberately unlike `read_cell_counts`, which is a verdict on a finished family. This is asked
|
|
130
|
+
*while a job is still running* and only has to answer "is anything at all happening", so it
|
|
131
|
+
counts every row the run has: any family, any model, folds and ensemble rows included. Filtering
|
|
132
|
+
to full fits the way the audit does would report zero for a run that is hours into backtesting
|
|
133
|
+
and perfectly healthy, which is precisely the false alarm a watchdog must not raise.
|
|
134
|
+
|
|
135
|
+
``None`` when the read fails — no evidence, therefore no verdict. A watchdog that treated an
|
|
136
|
+
unreachable BigQuery as "the run is dead" would cancel healthy runs during an outage.
|
|
137
|
+
"""
|
|
138
|
+
from google.cloud import bigquery
|
|
139
|
+
|
|
140
|
+
from .registry.tables import resolve_settings
|
|
141
|
+
|
|
142
|
+
resolved = resolve_settings(settings)
|
|
143
|
+
sql = (
|
|
144
|
+
"SELECT COUNT(*) AS cells "
|
|
145
|
+
f"FROM `{resolved.registry_table_ref('forecast_metadata')}` "
|
|
146
|
+
"WHERE run_id=@run_id AND (@since IS NULL OR created_at >= @since)"
|
|
147
|
+
)
|
|
148
|
+
params = [
|
|
149
|
+
bigquery.ScalarQueryParameter("run_id", "STRING", run_id),
|
|
150
|
+
bigquery.ScalarQueryParameter("since", "TIMESTAMP", since),
|
|
151
|
+
]
|
|
152
|
+
try:
|
|
153
|
+
client = bigquery_client(resolved.project_id)
|
|
154
|
+
rows = list(
|
|
155
|
+
client.query(sql, job_config=bigquery.QueryJobConfig(query_parameters=params)).result()
|
|
156
|
+
)
|
|
157
|
+
except Exception as exc: # noqa: BLE001 - a liveness probe must never sink the job it watches
|
|
158
|
+
_log.warning("liveness cell count failed for run %s: %r", run_id, exc)
|
|
159
|
+
return None
|
|
160
|
+
return int(rows[0]["cells"]) if rows else 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def audit_cells(
|
|
164
|
+
run_id: str,
|
|
165
|
+
family: str,
|
|
166
|
+
models: list[str],
|
|
167
|
+
*,
|
|
168
|
+
since: datetime | None = None,
|
|
169
|
+
settings: Settings | None = None,
|
|
170
|
+
read: Any = None,
|
|
171
|
+
) -> tuple[str | None, dict[str, Any]]:
|
|
172
|
+
"""Read the tallies and judge them: ``(status, blob)``, or ``(None, {})`` when unreadable.
|
|
173
|
+
|
|
174
|
+
``None`` means "no opinion" and the caller leaves the row's status alone — an unreadable
|
|
175
|
+
aggregate is not evidence of failure. The blob is filed at ``job_telemetry.$.cells`` beside
|
|
176
|
+
``device_use``, so the numbers behind the status are on the row rather than only in the log.
|
|
177
|
+
|
|
178
|
+
``read`` injects the aggregate for offline tests, exactly as `device_audit.audit_device_use`
|
|
179
|
+
does with its own.
|
|
180
|
+
"""
|
|
181
|
+
agg = (read or read_cell_counts)(run_id, models, since=since, settings=settings)
|
|
182
|
+
if not agg:
|
|
183
|
+
return None, {}
|
|
184
|
+
cells = int(agg.get("cells") or 0)
|
|
185
|
+
errors = int(agg.get("errors") or 0)
|
|
186
|
+
status = job_status(cells=cells, errors=errors)
|
|
187
|
+
if status != COMPLETED:
|
|
188
|
+
_log.warning(
|
|
189
|
+
"family '%s' finished %s: %d of %d cell(s) failed", family, status, errors, cells
|
|
190
|
+
)
|
|
191
|
+
return status, {"cells": cells, "errors": errors, "status": status}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def launch_window_start() -> datetime:
|
|
195
|
+
"""The ``since`` bound for a job about to be launched — now, less the clock margin."""
|
|
196
|
+
from datetime import UTC, datetime, timedelta
|
|
197
|
+
|
|
198
|
+
return datetime.now(UTC) - timedelta(seconds=_CLOCK_MARGIN_S)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def combined_run_status(job_statuses: dict[str, str | None], *, ensemble_enabled: bool) -> str:
|
|
202
|
+
"""Roll the per-family job statuses into the one status the run header carries (pure).
|
|
203
|
+
|
|
204
|
+
Over the base families (every key but ``"ensemble"``): all ``COMPLETED`` → ``COMPLETED``; all
|
|
205
|
+
non-``COMPLETED`` → ``FAILED``; a mix → ``PARTIAL`` (surviving families' forecasts stay usable).
|
|
206
|
+
A missing or still-``RUNNING`` family counts as failed — from here that is indistinguishable
|
|
207
|
+
from a family whose task died before it could finalize. An ensemble that did not complete
|
|
208
|
+
downgrades an otherwise-``COMPLETED`` run to ``FAILED`` (the requested output is incomplete); it
|
|
209
|
+
never masks a family ``PARTIAL``/``FAILED``.
|
|
210
|
+
|
|
211
|
+
A repair's row (``statistical_repair``, from `registry.ids.REPAIR_JOB_FAMILIES`) counts as an
|
|
212
|
+
ordinary job here rather than folding onto the family it repairs, which is the same reading
|
|
213
|
+
`registry.ops.roll_up_job_statuses` gives. A repair only exists because cells were missing, so a
|
|
214
|
+
repair that failed leaves the run genuinely incomplete and the header should say ``PARTIAL``;
|
|
215
|
+
folding it in the other direction — letting a forty-cell repair report its
|
|
216
|
+
hundred-thousand-cell family ``COMPLETED`` — is exactly the lie the separate row exists to
|
|
217
|
+
prevent.
|
|
218
|
+
|
|
219
|
+
Both tiers reach this with the *statuses the job rows hold*, but by different routes, and the
|
|
220
|
+
difference is worth naming. `airflow_tasks.finalize_run` runs in its own process after every
|
|
221
|
+
family task, so it re-reads ``run_jobs``. `main.run` holds the launch calls' return values and
|
|
222
|
+
never re-reads, because a row it just finalized cannot have changed and a second read is a
|
|
223
|
+
second way to be wrong.
|
|
224
|
+
"""
|
|
225
|
+
base = {family: status for family, status in job_statuses.items() if family != "ensemble"}
|
|
226
|
+
n_jobs = len(base)
|
|
227
|
+
n_failed = sum(1 for status in base.values() if status != COMPLETED)
|
|
228
|
+
if n_jobs == 0 or n_failed == 0:
|
|
229
|
+
engine_status = COMPLETED
|
|
230
|
+
elif n_failed == n_jobs:
|
|
231
|
+
engine_status = FAILED
|
|
232
|
+
else:
|
|
233
|
+
engine_status = PARTIAL
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
ensemble_enabled
|
|
237
|
+
and engine_status == COMPLETED
|
|
238
|
+
and job_statuses.get("ensemble") != COMPLETED
|
|
239
|
+
):
|
|
240
|
+
return FAILED
|
|
241
|
+
return engine_status
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Blocking on a long Spark job without abandoning a healthy one or babysitting a dead one.
|
|
2
|
+
|
|
3
|
+
Both submitters — Serverless (`submit.submit_batch`) and cluster
|
|
4
|
+
(`cluster_submit.submit_cluster_job`) — hand a long-running operation to the same problem: block
|
|
5
|
+
until it is terminal, and decide what to do when it is not. The naive answer,
|
|
6
|
+
``operation.result(timeout=...)``, gets one of two failures wrong whichever number you pick. Too
|
|
7
|
+
short and a healthy run that simply takes a while is abandoned; too long and a run that will never
|
|
8
|
+
produce anything bills the whole ceiling before anyone notices.
|
|
9
|
+
|
|
10
|
+
The loop here answers them separately, because they are separate questions.
|
|
11
|
+
|
|
12
|
+
**How long to wait** is a patience setting, and it belongs high. Neither runtime's spend is bounded
|
|
13
|
+
by the client watching it — a batch has its own ttl, a cluster has an idle bound and a max age — so
|
|
14
|
+
a short client wait buys no safety at all. It only converts long runs into lost ones, which is
|
|
15
|
+
exactly what it did on 2026-09-13: a GPU cluster job two hours into a three-hour fit, killed by its
|
|
16
|
+
own launcher's two-hour ceiling, taking 1,941 landed cells and the cluster with it.
|
|
17
|
+
|
|
18
|
+
**Whether the run is alive** is a different question entirely, and the clock cannot answer it. A run
|
|
19
|
+
that has written even one forecast row is alive, however slow; a run past its grace period with zero
|
|
20
|
+
rows is stuck, however recently it started. So the watch is on *output*, not on elapsed time, and it
|
|
21
|
+
switches off permanently the moment the first row appears — after that, wedging is the ttl's problem
|
|
22
|
+
and not this loop's.
|
|
23
|
+
|
|
24
|
+
Nothing here is specific to a runtime. The caller supplies the operation, a label for the logs, and
|
|
25
|
+
its own two numbers.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .errors import EngineError, get_logger
|
|
34
|
+
|
|
35
|
+
_log = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
# How often the wait wakes up to check on the job. Not a timeout in any sense the caller cares
|
|
38
|
+
# about — the loop's own deadline is what bounds the wait — it is only how coarse the watchdog's
|
|
39
|
+
# view of a stall is allowed to be. Five minutes keeps the BigQuery liveness read to at most one a
|
|
40
|
+
# poll on a run that may last a day.
|
|
41
|
+
_WATCHDOG_INTERVAL_SECONDS = 300.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_stalled(*, elapsed_s: float, grace_s: int, cells: int | None) -> bool:
|
|
45
|
+
"""Has this job produced nothing for long enough to call it stuck rather than slow? (pure)
|
|
46
|
+
|
|
47
|
+
Three ways to answer no, and each of them is a false alarm this deliberately refuses to raise.
|
|
48
|
+
``grace_s <= 0`` is the operator switching the watchdog off. Inside the grace period nothing is
|
|
49
|
+
concluded, because a run that has not started is not a run that has failed. And ``cells is
|
|
50
|
+
None`` — the count could not be read — is *no evidence*, not evidence of death; a watchdog that
|
|
51
|
+
cancelled healthy runs during a BigQuery outage would be worse than the failure it guards.
|
|
52
|
+
|
|
53
|
+
So the only yes is: past the grace period, the count was read, and it is zero.
|
|
54
|
+
"""
|
|
55
|
+
if grace_s <= 0 or elapsed_s < grace_s:
|
|
56
|
+
return False
|
|
57
|
+
return cells == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def wait_for_job(
|
|
61
|
+
operation: Any,
|
|
62
|
+
*,
|
|
63
|
+
run_id: str,
|
|
64
|
+
label: str,
|
|
65
|
+
wait_timeout: float,
|
|
66
|
+
grace_s: int,
|
|
67
|
+
since: Any,
|
|
68
|
+
) -> Any: # pragma: no cover - GCP I/O, exercised by the @gcp smokes
|
|
69
|
+
"""Block until the job is terminal, cancelling it if it stops producing anything.
|
|
70
|
+
|
|
71
|
+
Behaviourally identical to the bare ``operation.result(timeout=wait_timeout)`` it replaces for
|
|
72
|
+
every run that writes even one cell: the same terminal object comes back, and the same
|
|
73
|
+
client-side ``TimeoutError`` is raised — the original one, re-raised at the same deadline —
|
|
74
|
+
for a run that outlives the wait. The loop only exists so there is somewhere to look up from.
|
|
75
|
+
|
|
76
|
+
``label`` names the job the way its own runtime does (``batch sf-…``, ``cluster job …``) so the
|
|
77
|
+
log line and the cancellation message read as that runtime's operator would expect.
|
|
78
|
+
"""
|
|
79
|
+
import time
|
|
80
|
+
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
81
|
+
|
|
82
|
+
from .job_outcome import cells_written
|
|
83
|
+
|
|
84
|
+
started = time.monotonic()
|
|
85
|
+
deadline = started + wait_timeout
|
|
86
|
+
watching = grace_s > 0
|
|
87
|
+
while True:
|
|
88
|
+
try:
|
|
89
|
+
return operation.result(timeout=_WATCHDOG_INTERVAL_SECONDS)
|
|
90
|
+
except FuturesTimeoutError:
|
|
91
|
+
if time.monotonic() >= deadline:
|
|
92
|
+
raise
|
|
93
|
+
if not watching:
|
|
94
|
+
continue
|
|
95
|
+
elapsed = time.monotonic() - started
|
|
96
|
+
if elapsed < grace_s:
|
|
97
|
+
continue
|
|
98
|
+
cells = cells_written(run_id, since=since)
|
|
99
|
+
if cells:
|
|
100
|
+
_log.info("%s has written %d cell(s); watchdog stands down", label, cells)
|
|
101
|
+
watching = False
|
|
102
|
+
continue
|
|
103
|
+
if is_stalled(elapsed_s=elapsed, grace_s=grace_s, cells=cells):
|
|
104
|
+
_log.error("%s has written no cells in %.0f min; cancelling", label, elapsed / 60)
|
|
105
|
+
with contextlib.suppress(Exception): # a cancel that fails must not mask the diagnosis
|
|
106
|
+
operation.cancel()
|
|
107
|
+
raise EngineError(
|
|
108
|
+
f"{label} wrote no forecast rows in {elapsed / 60:.0f} minutes and was "
|
|
109
|
+
f"cancelled (stall watchdog; raise SF_STALL_GRACE_S, or set it to 0, if this run "
|
|
110
|
+
f"legitimately takes that long to produce its first cell)"
|
|
111
|
+
)
|