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.
Files changed (186) hide show
  1. scale_forecasting/__init__.py +275 -0
  2. scale_forecasting/_entry.py +110 -0
  3. scale_forecasting/_infra_args.py +91 -0
  4. scale_forecasting/agent_surfaces.py +1571 -0
  5. scale_forecasting/airflow_emit.py +325 -0
  6. scale_forecasting/airflow_tasks.py +414 -0
  7. scale_forecasting/automl_submit.py +383 -0
  8. scale_forecasting/backtest.py +1153 -0
  9. scale_forecasting/batch_infra.py +322 -0
  10. scale_forecasting/batch_telemetry.py +241 -0
  11. scale_forecasting/calibration.py +900 -0
  12. scale_forecasting/capacity.py +659 -0
  13. scale_forecasting/cluster_deps.py +113 -0
  14. scale_forecasting/cluster_submit.py +383 -0
  15. scale_forecasting/cluster_telemetry.py +108 -0
  16. scale_forecasting/code_delivery.py +196 -0
  17. scale_forecasting/commands.py +459 -0
  18. scale_forecasting/compute_fallback.py +190 -0
  19. scale_forecasting/config.py +2530 -0
  20. scale_forecasting/dag.py +551 -0
  21. scale_forecasting/data_gen/__init__.py +3 -0
  22. scale_forecasting/data_gen/generator.py +415 -0
  23. scale_forecasting/data_gen/seed_spark.py +505 -0
  24. scale_forecasting/dataproc_cluster.py +869 -0
  25. scale_forecasting/device_audit.py +219 -0
  26. scale_forecasting/diagnostics.py +1510 -0
  27. scale_forecasting/engines/__init__.py +6 -0
  28. scale_forecasting/engines/automl_engine.py +2162 -0
  29. scale_forecasting/engines/bigquery_engine.py +623 -0
  30. scale_forecasting/engines/bigquery_names.py +101 -0
  31. scale_forecasting/engines/bigquery_sql.py +783 -0
  32. scale_forecasting/engines/ray_engine.py +809 -0
  33. scale_forecasting/engines/ray_io.py +1007 -0
  34. scale_forecasting/engines/spark_explode.py +358 -0
  35. scale_forecasting/engines/spark_io.py +763 -0
  36. scale_forecasting/engines/vertex_engine.py +1171 -0
  37. scale_forecasting/ensemble_run.py +1102 -0
  38. scale_forecasting/ensembler.py +1279 -0
  39. scale_forecasting/errors.py +188 -0
  40. scale_forecasting/features.py +1352 -0
  41. scale_forecasting/gce_submit.py +1028 -0
  42. scale_forecasting/gke_submit.py +1587 -0
  43. scale_forecasting/hardware.py +265 -0
  44. scale_forecasting/hpo.py +424 -0
  45. scale_forecasting/identity.py +94 -0
  46. scale_forecasting/job_launch.py +768 -0
  47. scale_forecasting/job_outcome.py +241 -0
  48. scale_forecasting/job_wait.py +111 -0
  49. scale_forecasting/launch_plan.py +1107 -0
  50. scale_forecasting/main.py +799 -0
  51. scale_forecasting/mcp.py +1385 -0
  52. scale_forecasting/metrics/__init__.py +253 -0
  53. scale_forecasting/metrics/_intervals.py +19 -0
  54. scale_forecasting/metrics/base_metric.py +217 -0
  55. scale_forecasting/metrics/bias.py +34 -0
  56. scale_forecasting/metrics/coverage.py +40 -0
  57. scale_forecasting/metrics/cv.py +24 -0
  58. scale_forecasting/metrics/interval_score.py +42 -0
  59. scale_forecasting/metrics/interval_width.py +38 -0
  60. scale_forecasting/metrics/maape.py +38 -0
  61. scale_forecasting/metrics/mae.py +30 -0
  62. scale_forecasting/metrics/mape.py +33 -0
  63. scale_forecasting/metrics/mase.py +45 -0
  64. scale_forecasting/metrics/mase_seasonal.py +49 -0
  65. scale_forecasting/metrics/mse.py +32 -0
  66. scale_forecasting/metrics/msis.py +30 -0
  67. scale_forecasting/metrics/msse.py +28 -0
  68. scale_forecasting/metrics/ope.py +24 -0
  69. scale_forecasting/metrics/pinball.py +49 -0
  70. scale_forecasting/metrics/r2.py +27 -0
  71. scale_forecasting/metrics/rmse.py +30 -0
  72. scale_forecasting/metrics/rmsle.py +25 -0
  73. scale_forecasting/metrics/rmsse.py +41 -0
  74. scale_forecasting/metrics/smape.py +33 -0
  75. scale_forecasting/metrics/wape.py +34 -0
  76. scale_forecasting/models/__init__.py +112 -0
  77. scale_forecasting/models/_lag_forecaster.py +407 -0
  78. scale_forecasting/models/_neuralforecast_base.py +605 -0
  79. scale_forecasting/models/_vertex_automl_base.py +268 -0
  80. scale_forecasting/models/auto_arima.py +142 -0
  81. scale_forecasting/models/auto_ces.py +103 -0
  82. scale_forecasting/models/auto_theta.py +113 -0
  83. scale_forecasting/models/autoets.py +106 -0
  84. scale_forecasting/models/base_model.py +600 -0
  85. scale_forecasting/models/bigquery_native.py +177 -0
  86. scale_forecasting/models/catboost_model.py +159 -0
  87. scale_forecasting/models/croston.py +137 -0
  88. scale_forecasting/models/fft.py +108 -0
  89. scale_forecasting/models/holtwinters.py +85 -0
  90. scale_forecasting/models/kalman.py +95 -0
  91. scale_forecasting/models/lightgbm_model.py +160 -0
  92. scale_forecasting/models/naive_drift.py +69 -0
  93. scale_forecasting/models/naive_mean.py +60 -0
  94. scale_forecasting/models/naive_moving_average.py +93 -0
  95. scale_forecasting/models/naive_seasonal.py +72 -0
  96. scale_forecasting/models/neuralprophet_model.py +421 -0
  97. scale_forecasting/models/patchtst.py +51 -0
  98. scale_forecasting/models/prophet_model.py +121 -0
  99. scale_forecasting/models/random_forest.py +153 -0
  100. scale_forecasting/models/regression_lags.py +171 -0
  101. scale_forecasting/models/sarimax.py +92 -0
  102. scale_forecasting/models/stl_bagging.py +109 -0
  103. scale_forecasting/models/tbats_model.py +102 -0
  104. scale_forecasting/models/tft.py +46 -0
  105. scale_forecasting/models/theta.py +89 -0
  106. scale_forecasting/models/tide.py +47 -0
  107. scale_forecasting/models/tsmixer.py +48 -0
  108. scale_forecasting/models/ucm.py +91 -0
  109. scale_forecasting/models/vertex_l2l.py +31 -0
  110. scale_forecasting/models/vertex_seq2seq.py +30 -0
  111. scale_forecasting/models/vertex_tft.py +32 -0
  112. scale_forecasting/models/vertex_tide.py +32 -0
  113. scale_forecasting/models/xgboost_model.py +164 -0
  114. scale_forecasting/notebook_acceptance.py +755 -0
  115. scale_forecasting/playground.py +793 -0
  116. scale_forecasting/probes/__init__.py +3 -0
  117. scale_forecasting/probes/cancel.py +408 -0
  118. scale_forecasting/probes/reconcile.py +577 -0
  119. scale_forecasting/probes/runtimes.py +941 -0
  120. scale_forecasting/probes/settle.py +466 -0
  121. scale_forecasting/probes/vocabulary.py +245 -0
  122. scale_forecasting/profiling/__init__.py +3 -0
  123. scale_forecasting/profiling/baseline.py +240 -0
  124. scale_forecasting/profiling/cost.py +705 -0
  125. scale_forecasting/profiling/measure.py +440 -0
  126. scale_forecasting/profiling/numeric.py +73 -0
  127. scale_forecasting/profiling/sampling.py +290 -0
  128. scale_forecasting/profiling/signature.py +136 -0
  129. scale_forecasting/profiling/source.py +473 -0
  130. scale_forecasting/profiling/stats.py +221 -0
  131. scale_forecasting/py.typed +0 -0
  132. scale_forecasting/quota.py +1767 -0
  133. scale_forecasting/ray_cluster.py +767 -0
  134. scale_forecasting/ray_entry.py +46 -0
  135. scale_forecasting/ray_infra.py +121 -0
  136. scale_forecasting/ray_jobs.py +462 -0
  137. scale_forecasting/ray_reaper.py +392 -0
  138. scale_forecasting/ray_submit.py +323 -0
  139. scale_forecasting/ray_telemetry.py +106 -0
  140. scale_forecasting/reconciliation.py +929 -0
  141. scale_forecasting/registry/__init__.py +3 -0
  142. scale_forecasting/registry/artifacts.py +223 -0
  143. scale_forecasting/registry/cells.py +115 -0
  144. scale_forecasting/registry/ddl.py +408 -0
  145. scale_forecasting/registry/harvest.py +252 -0
  146. scale_forecasting/registry/header.py +368 -0
  147. scale_forecasting/registry/ids.py +556 -0
  148. scale_forecasting/registry/jobs.py +191 -0
  149. scale_forecasting/registry/lifecycle.py +284 -0
  150. scale_forecasting/registry/ops.py +1666 -0
  151. scale_forecasting/registry/params.py +150 -0
  152. scale_forecasting/registry/reads.py +971 -0
  153. scale_forecasting/registry/rows.py +556 -0
  154. scale_forecasting/registry/tables.py +150 -0
  155. scale_forecasting/registry/views.py +399 -0
  156. scale_forecasting/registry/write_api.py +386 -0
  157. scale_forecasting/report.py +932 -0
  158. scale_forecasting/resources/__init__.py +3 -0
  159. scale_forecasting/resources/audit.py +64 -0
  160. scale_forecasting/resources/catalog.py +263 -0
  161. scale_forecasting/resources/cluster.py +352 -0
  162. scale_forecasting/resources/fleet.py +613 -0
  163. scale_forecasting/resources/serverless.py +428 -0
  164. scale_forecasting/resources/slot.py +295 -0
  165. scale_forecasting/retry_policy.py +378 -0
  166. scale_forecasting/retry_run.py +535 -0
  167. scale_forecasting/review.py +2689 -0
  168. scale_forecasting/router.py +41 -0
  169. scale_forecasting/scenarios.py +564 -0
  170. scale_forecasting/sdk.py +2073 -0
  171. scale_forecasting/seasonality.py +60 -0
  172. scale_forecasting/settings.py +171 -0
  173. scale_forecasting/shared_clusters.py +348 -0
  174. scale_forecasting/smoke_run.py +83 -0
  175. scale_forecasting/spark_entry.py +47 -0
  176. scale_forecasting/staging.py +121 -0
  177. scale_forecasting/submit.py +667 -0
  178. scale_forecasting/submitters.py +515 -0
  179. scale_forecasting/validation.py +201 -0
  180. scale_forecasting/vertex_entry.py +81 -0
  181. scale_forecasting/vertex_submit.py +680 -0
  182. scale_forecasting/worker.py +1432 -0
  183. scale_forecasting-1.0.0.dist-info/METADATA +361 -0
  184. scale_forecasting-1.0.0.dist-info/RECORD +186 -0
  185. scale_forecasting-1.0.0.dist-info/WHEEL +4 -0
  186. 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
+ )