Skip to content

Commit 3170afd

Browse files
rsignellclaude
andcommitted
Replace SIGALRM thumbnail timeout with subprocess hard-kill
SIGALRM doesn't reliably interrupt dask/zarr C-level I/O threads and caused the GitHub Actions runner to crash. Running thumbnail generation in a subprocess allows clean hard-killing via p.kill() after the timeout, terminating all I/O threads cleanly without affecting the main process. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e13f5f8 commit 3170afd

1 file changed

Lines changed: 93 additions & 86 deletions

File tree

scripts/build_dynamical_stac.py

Lines changed: 93 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@
3838

3939
import argparse
4040
import logging
41+
import multiprocessing
4142
import os
4243
import shutil
43-
import signal
4444
import subprocess
4545
import sys
4646
import tempfile
@@ -63,21 +63,9 @@
6363

6464
log = logging.getLogger(__name__)
6565

66-
# ---------------------------------------------------------------------------
67-
# Thumbnail timeout
68-
# ---------------------------------------------------------------------------
69-
7066
THUMBNAIL_TIMEOUT_SECONDS = 60
7167

7268

73-
class _ThumbnailTimeout(Exception):
74-
pass
75-
76-
77-
def _thumbnail_timeout_handler(signum, frame):
78-
raise _ThumbnailTimeout()
79-
80-
8169
# ---------------------------------------------------------------------------
8270
# Constants
8371
# ---------------------------------------------------------------------------
@@ -232,56 +220,71 @@ def xarray_open_snippet(item_id: str, catalog_url: str) -> str:
232220
# Thumbnail generation
233221
# ---------------------------------------------------------------------------
234222

235-
def generate_thumbnail(
236-
session,
237-
ds: "xr.Dataset",
223+
def _thumbnail_worker(
224+
bucket: str,
225+
prefix: str,
226+
region: str,
238227
item_id: str,
239-
output_dir: Path,
228+
output_dir_str: str,
240229
temporal_dimension: str,
241-
) -> "Path | None":
242-
"""Generate a PNG temperature map thumbnail for a dataset.
243-
244-
Re-opens the store with dask chunks of size 1 on non-spatial dims so that
245-
only the exact slice is fetched from S3, not the full multi-dim zarr chunk.
230+
result_queue: "multiprocessing.Queue",
231+
) -> None:
232+
"""Subprocess worker: opens the icechunk store and generates a thumbnail PNG.
246233
247-
Returns the path to the saved PNG, or None if generation fails.
234+
Runs in a separate process so it can be hard-killed on timeout without
235+
leaving dask/zarr threads in a broken state in the main process.
248236
"""
249-
if "temperature_2m" not in ds:
250-
log.warning(" No temperature_2m in %s -- skipping thumbnail", item_id)
251-
return None
252-
253-
signal.signal(signal.SIGALRM, _thumbnail_timeout_handler)
254-
signal.alarm(THUMBNAIL_TIMEOUT_SECONDS)
237+
import warnings
238+
import numpy as np
239+
import matplotlib
240+
matplotlib.use("Agg")
241+
import matplotlib.pyplot as plt
242+
import icechunk
243+
import xarray as xr
244+
from pathlib import Path
245+
246+
output_dir = Path(output_dir_str)
255247
try:
256248
import cartopy.crs as ccrs
257249
import cartopy.feature as cfeature
258-
import numpy as np
259250

260-
# Re-open with dask using chunk size 1 on non-spatial dims.
261-
# Without this, zarr loads the full stored chunk (which can span many
262-
# lead_time / ensemble_member slices = many GB) just to extract one slice.
263-
chunks = {d: 1 for d in ds.dims
264-
if d not in ("latitude", "longitude", "x", "y")}
251+
storage = icechunk.s3_storage(
252+
bucket=bucket, prefix=prefix, region=region, anonymous=True
253+
)
254+
repo = icechunk.Repository.open(storage=storage)
255+
session = repo.readonly_session(branch="main")
256+
257+
# Open with chunk size 1 on non-spatial dims so zarr loads the minimum
258+
# necessary data per slice (avoids pulling in huge stored chunks).
259+
with warnings.catch_warnings():
260+
warnings.filterwarnings(
261+
"ignore",
262+
message="Numcodecs codecs are not in the Zarr version 3 specification.*",
263+
)
264+
ds = xr.open_zarr(session.store, chunks=None, consolidated=False, zarr_format=3)
265+
266+
if "temperature_2m" not in ds:
267+
result_queue.put(None)
268+
return
269+
270+
chunks = {d: 1 for d in ds.dims if d not in ("latitude", "longitude", "x", "y")}
265271
with warnings.catch_warnings():
266272
warnings.filterwarnings(
267273
"ignore",
268274
message="Numcodecs codecs are not in the Zarr version 3 specification.*",
269275
)
270276
ds_lazy = xr.open_zarr(
271-
session.store, chunks=chunks,
272-
consolidated=False, zarr_format=3
277+
session.store, chunks=chunks, consolidated=False, zarr_format=3
273278
)
274279

275280
da = ds_lazy["temperature_2m"]
276-
log.info(" [thumb] dims=%s", dict(da.sizes))
277281

278-
# Select the most recent time slice
282+
# Select a single time slice
279283
if temporal_dimension == "init_time" and "init_time" in da.dims:
280284
da = da.isel(init_time=-1, lead_time=0)
281285
elif temporal_dimension in da.dims:
282286
da = da.isel({temporal_dimension: -1})
283287

284-
# Pick one ensemble member to avoid loading all members
285288
if "ensemble_member" in da.dims:
286289
da = da.isel(ensemble_member=0)
287290

@@ -297,88 +300,92 @@ def generate_thumbnail(
297300
da = da.isel(y=slice(None, None, y_step),
298301
x=slice(None, None, x_step))
299302

300-
log.info(" [thumb] loading %s values...", dict(da.sizes))
301-
302-
# .compute() with dask fetches only the chunks we need
303303
data_c = da.compute().values - 273.15
304-
log.info(" [thumb] loaded shape=%s min=%.1f max=%.1f",
305-
data_c.shape, data_c.min(), data_c.max())
306304

307305
is_projected = "x" in ds.dims and "y" in ds.dims
308306

309307
if is_projected:
310-
proj = ccrs.LambertConformal(
311-
central_longitude=-97.5, central_latitude=38.5
312-
)
313-
fig, ax = plt.subplots(
314-
figsize=(10, 6), subplot_kw={"projection": proj}
315-
)
308+
proj = ccrs.LambertConformal(central_longitude=-97.5, central_latitude=38.5)
309+
fig, ax = plt.subplots(figsize=(10, 6), subplot_kw={"projection": proj})
316310
lons = da["longitude"].compute().values
317311
lats = da["latitude"].compute().values
318-
img = ax.pcolormesh(
319-
lons, lats, data_c,
320-
cmap="RdBu_r", vmin=-40, vmax=40,
321-
transform=ccrs.PlateCarree(),
322-
shading="auto",
323-
)
312+
img = ax.pcolormesh(lons, lats, data_c, cmap="RdBu_r", vmin=-40, vmax=40,
313+
transform=ccrs.PlateCarree(), shading="auto")
324314
ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
325315
ax.add_feature(cfeature.STATES, linewidth=0.3)
326316
ax.set_extent([-130, -60, 20, 55], crs=ccrs.PlateCarree())
327317
else:
328318
proj = ccrs.PlateCarree()
329-
fig, ax = plt.subplots(
330-
figsize=(10, 5), subplot_kw={"projection": proj}
331-
)
332-
# Determine lon/lat coordinate names
319+
fig, ax = plt.subplots(figsize=(10, 5), subplot_kw={"projection": proj})
333320
lon_name = "longitude" if "longitude" in da.dims else "lon"
334321
lat_name = "latitude" if "latitude" in da.dims else "lat"
335-
lons = da[lon_name].values
336-
lats = da[lat_name].values
337-
img = ax.pcolormesh(
338-
lons, lats, data_c,
339-
cmap="RdBu_r", vmin=-40, vmax=40,
340-
transform=proj,
341-
shading="auto",
342-
)
322+
img = ax.pcolormesh(da[lon_name].values, da[lat_name].values, data_c,
323+
cmap="RdBu_r", vmin=-40, vmax=40, transform=proj,
324+
shading="auto")
343325
ax.set_global()
344326
ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
345327

346328
plt.colorbar(img, ax=ax, orientation="horizontal", pad=0.04,
347329
label="2m Temperature (°C)", shrink=0.7)
348330

349-
# Build timestamp string for title
350331
try:
351332
ts_val = da[temporal_dimension].values if temporal_dimension in da.coords else None
352333
ts_str = str(np.datetime_as_string(ts_val, unit="h")) if ts_val is not None else ""
353334
except Exception:
354335
ts_str = ""
355336

356-
title = item_id
357-
if ts_str:
358-
title += f" | {ts_str}"
359-
ax.set_title(title, fontsize=9)
337+
ax.set_title(f"{item_id} | {ts_str}" if ts_str else item_id, fontsize=9)
360338

361339
thumb_dir = output_dir / "thumbnails"
362340
thumb_dir.mkdir(parents=True, exist_ok=True)
363341
out_path = thumb_dir / f"{item_id}.png"
364342
fig.savefig(out_path, dpi=100, bbox_inches="tight")
365343
plt.close(fig)
366-
log.info(" Thumbnail saved: %s", out_path)
367-
return out_path
344+
result_queue.put(str(out_path))
345+
346+
except Exception as exc:
347+
result_queue.put(None)
348+
raise # surfaces in the subprocess stderr for debugging
349+
350+
351+
def generate_thumbnail(
352+
bucket: str,
353+
prefix: str,
354+
region: str,
355+
item_id: str,
356+
output_dir: Path,
357+
temporal_dimension: str,
358+
) -> "Path | None":
359+
"""Generate a thumbnail PNG in a subprocess, hard-killing it after a timeout.
368360
369-
except _ThumbnailTimeout:
361+
Using a subprocess (rather than a thread or SIGALRM) ensures that all
362+
dask/zarr I/O threads are cleanly terminated if data loading stalls.
363+
"""
364+
result_queue: multiprocessing.Queue = multiprocessing.Queue()
365+
p = multiprocessing.Process(
366+
target=_thumbnail_worker,
367+
args=(bucket, prefix, region, item_id, str(output_dir),
368+
temporal_dimension, result_queue),
369+
)
370+
p.start()
371+
p.join(timeout=THUMBNAIL_TIMEOUT_SECONDS)
372+
373+
if p.is_alive():
370374
log.warning(" Thumbnail timed out after %ds for %s -- skipping",
371375
THUMBNAIL_TIMEOUT_SECONDS, item_id)
376+
p.kill()
377+
p.join()
372378
return None
373-
except Exception as exc:
374-
log.warning(" Thumbnail generation failed for %s: %s", item_id, exc)
379+
380+
if p.exitcode != 0:
381+
log.warning(" Thumbnail subprocess failed for %s (exit code %d)",
382+
item_id, p.exitcode)
375383
return None
376-
finally:
377-
signal.alarm(0) # cancel any remaining alarm
378-
try:
379-
plt.close("all")
380-
except Exception:
381-
pass
384+
385+
if not result_queue.empty():
386+
result = result_queue.get_nowait()
387+
return Path(result) if result else None
388+
return None
382389

383390

384391
# ---------------------------------------------------------------------------
@@ -446,7 +453,7 @@ def build_item_for_store(
446453
log.info(" Built item: %s bbox=%s", item_id, item_dict["bbox"])
447454

448455
if output_dir and thumbnail_url_base:
449-
thumb_path = generate_thumbnail(session, ds, item_id, output_dir, temporal_dim)
456+
thumb_path = generate_thumbnail(bucket, prefix, region, item_id, output_dir, temporal_dim)
450457
if thumb_path:
451458
item_dict["assets"]["thumbnail"] = {
452459
"href": f"{thumbnail_url_base}/{item_id}.png",

0 commit comments

Comments
 (0)