3838
3939import argparse
4040import logging
41+ import multiprocessing
4142import os
4243import shutil
43- import signal
4444import subprocess
4545import sys
4646import tempfile
6363
6464log = logging .getLogger (__name__ )
6565
66- # ---------------------------------------------------------------------------
67- # Thumbnail timeout
68- # ---------------------------------------------------------------------------
69-
7066THUMBNAIL_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