Skip to content

Commit 43cdd29

Browse files
authored
Merge branch 'main' into perf/skip-redundant-validate
2 parents 761a673 + 074d56c commit 43cdd29

12 files changed

Lines changed: 1104 additions & 128 deletions

File tree

changelog/674.feature.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added a `--chunk-size` option to `ref datasets ingest` (CMIP6 and CMIP7) that streams the catalog in directory-aligned batches
2+
instead of loading the whole archive into memory at once.
3+
Peak memory is now bounded by `chunk_size` rather than by the total number of files in the input tree.

packages/climate-ref/src/climate_ref/cli/datasets.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@ def ingest( # noqa
125125
skip_invalid: Annotated[
126126
bool, typer.Option(help="Ignore (but log) any datasets that don't pass validation")
127127
] = True,
128+
chunk_size: Annotated[
129+
int | None,
130+
typer.Option(
131+
help=(
132+
"Stream the catalog in chunks of this many files instead of loading the whole "
133+
"directory at once. Bounds peak memory for large archives. Only supported by "
134+
"adapters that implement iter_local_datasets (currently CMIP6 and CMIP7)."
135+
)
136+
),
137+
] = None,
128138
) -> None:
129139
"""
130140
Ingest a directory of datasets into the database
@@ -134,7 +144,7 @@ def ingest( # noqa
134144
135145
A table of the datasets will be printed to the console at the end of the operation.
136146
"""
137-
from climate_ref.datasets import ingest_datasets
147+
from climate_ref.datasets import IngestionStats, ingest_datasets
138148

139149
config = ctx.obj.config
140150
db = ctx.obj.database
@@ -150,6 +160,16 @@ def ingest( # noqa
150160

151161
failed_dirs: list[Path] = []
152162

163+
if chunk_size is not None and chunk_size < 1:
164+
raise typer.BadParameter(f"chunk_size must be >= 1, got {chunk_size}", param_hint="--chunk-size")
165+
166+
streaming = chunk_size is not None and hasattr(adapter, "iter_local_datasets")
167+
if chunk_size is not None and not streaming:
168+
logger.warning(
169+
f"Adapter for {source_type.value} does not support streaming ingest; "
170+
"falling back to whole-catalog mode."
171+
)
172+
153173
for _dir in file_or_directory:
154174
_dir = Path(_dir).expanduser()
155175
logger.info(f"Ingesting {_dir}")
@@ -166,6 +186,51 @@ def ingest( # noqa
166186
failed_dirs.append(_dir)
167187
continue
168188

189+
if streaming:
190+
stats = IngestionStats()
191+
preview_printed = False
192+
total_files = 0
193+
total_datasets = 0
194+
try:
195+
for raw_chunk in adapter.iter_local_datasets(_dir, chunk_size=chunk_size): # type: ignore[attr-defined]
196+
validated_chunk = adapter.validate_data_catalog(raw_chunk, skip_invalid=skip_invalid)
197+
if validated_chunk.empty:
198+
continue
199+
if not preview_printed:
200+
pretty_print_df(adapter.pretty_subset(validated_chunk), console=console)
201+
preview_printed = True
202+
total_files += len(validated_chunk)
203+
total_datasets += validated_chunk[adapter.slug_column].nunique()
204+
205+
if dry_run:
206+
for instance_id in validated_chunk[adapter.slug_column].unique():
207+
with db.session.begin():
208+
dataset = (
209+
db.session.query(Dataset)
210+
.filter_by(slug=instance_id, dataset_type=source_type)
211+
.first()
212+
)
213+
if not dataset:
214+
logger.info(f"Would save dataset {instance_id} to the database")
215+
else:
216+
stats += ingest_datasets(adapter, None, db, data_catalog=validated_chunk)
217+
del raw_chunk, validated_chunk
218+
except Exception as e:
219+
logger.exception(f"Error ingesting datasets from {_dir}: {e}")
220+
failed_dirs.append(_dir)
221+
continue
222+
223+
if total_files == 0:
224+
logger.warning(f"No valid datasets found in {_dir}")
225+
continue
226+
227+
logger.info(
228+
f"Streamed {total_files} files across approximately {total_datasets} datasets from {_dir}"
229+
)
230+
if not dry_run:
231+
stats.log_summary()
232+
continue
233+
169234
try:
170235
data_catalog = adapter.find_local_datasets(_dir)
171236
data_catalog = adapter.validate_data_catalog(data_catalog, skip_invalid=skip_invalid)

packages/climate-ref/src/climate_ref/datasets/__init__.py

Lines changed: 111 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,64 @@ def log_summary(self, prefix: str = "") -> None:
7070
" (created/updated/removed/unchanged)"
7171
)
7272

73+
def __iadd__(self, other: "IngestionStats") -> "IngestionStats":
74+
"""Accumulate counts in place from another :class:`IngestionStats`."""
75+
self.datasets_created += other.datasets_created
76+
self.datasets_updated += other.datasets_updated
77+
self.datasets_unchanged += other.datasets_unchanged
78+
self.files_added += other.files_added
79+
self.files_updated += other.files_updated
80+
self.files_removed += other.files_removed
81+
self.files_unchanged += other.files_unchanged
82+
return self
83+
84+
85+
def _ingest_catalog(
86+
adapter: DatasetAdapter,
87+
db: Database,
88+
data_catalog: pd.DataFrame,
89+
) -> IngestionStats:
90+
"""
91+
Register every dataset in ``data_catalog``, committing per-dataset.
92+
93+
The ORM session identity map is expired after each commit so memory
94+
use stays bounded by the largest single dataset, not by the size of
95+
``data_catalog``.
96+
"""
97+
stats = IngestionStats()
98+
99+
for instance_id, data_catalog_dataset in data_catalog.groupby(adapter.slug_column):
100+
logger.debug(f"Processing dataset {instance_id}")
101+
with db.session.begin():
102+
results = adapter.register_dataset(db, data_catalog_dataset)
103+
104+
if results.dataset_state == ModelState.CREATED:
105+
stats.datasets_created += 1
106+
elif results.dataset_state == ModelState.UPDATED:
107+
stats.datasets_updated += 1
108+
else:
109+
stats.datasets_unchanged += 1
110+
stats.files_added += len(results.files_added)
111+
stats.files_updated += len(results.files_updated)
112+
stats.files_removed += len(results.files_removed)
113+
stats.files_unchanged += len(results.files_unchanged)
114+
115+
# Release ORM objects from the session identity map after each commit.
116+
# Without this, all Dataset and DatasetFile objects accumulate in memory
117+
# across the entire ingestion loop.
118+
db.session.expire_all()
119+
120+
return stats
73121

74-
def ingest_datasets(
122+
123+
def ingest_datasets( # noqa: PLR0913
75124
adapter: DatasetAdapter,
76125
directory: Path | None,
77126
db: Database,
78127
*,
79128
data_catalog: pd.DataFrame | None = None,
80129
skip_invalid: bool = True,
130+
chunk_size: int | None = None,
81131
) -> IngestionStats:
82132
"""
83133
Ingest datasets from a directory into the database.
@@ -94,10 +144,18 @@ def ingest_datasets(
94144
db
95145
Database instance
96146
data_catalog
97-
Optional pre-validated data catalog. If provided, directory is ignored and
98-
the catalog is used directly. This avoids redundant find/validate operations.
147+
Optional pre-validated data catalog.
148+
149+
If provided, directory is ignored and the catalog is used directly.
150+
This avoids redundant find/validate operations.
151+
When supplied, ``chunk_size`` is ignored because the catalog is already fully materialised.
99152
skip_invalid
100153
If True, skip datasets that fail validation (default True)
154+
chunk_size
155+
When provided and ``data_catalog`` is None,
156+
stream the directory in batches of ``chunk_size`` files so peak memory is bounded regardless
157+
of how many files live under ``directory``.
158+
Requires the adapter to implement ``iter_local_datasets``.
101159
102160
Returns
103161
-------
@@ -109,51 +167,62 @@ def ingest_datasets(
109167
ValueError
110168
If no valid datasets are found in the directory
111169
"""
112-
if data_catalog is None:
113-
if directory is None:
114-
raise ValueError("Either directory or data_catalog must be provided")
115-
116-
if not directory.exists():
117-
raise ValueError(f"Directory {directory} does not exist")
118-
119-
# Check for .nc files
120-
if not list(directory.rglob("*.nc")):
121-
raise ValueError(f"No .nc files found in {directory}")
122-
123-
data_catalog = adapter.find_local_datasets(directory)
124-
data_catalog = adapter.validate_data_catalog(data_catalog, skip_invalid=skip_invalid)
125-
126-
if data_catalog.empty:
170+
if data_catalog is not None:
171+
return _ingest_catalog(adapter, db, data_catalog)
172+
173+
if directory is None:
174+
raise ValueError("Either directory or data_catalog must be provided")
175+
176+
if not directory.exists():
177+
raise ValueError(f"Directory {directory} does not exist")
178+
179+
# Check for .nc files
180+
if not any(directory.rglob("*.nc")):
181+
raise ValueError(f"No .nc files found in {directory}")
182+
183+
if chunk_size is not None:
184+
if chunk_size < 1:
185+
raise ValueError(f"chunk_size must be >= 1, got {chunk_size}")
186+
iter_fn = getattr(adapter, "iter_local_datasets", None)
187+
if iter_fn is None:
188+
raise ValueError(
189+
f"Adapter {type(adapter).__name__} does not support streaming ingest "
190+
"(missing iter_local_datasets); omit chunk_size to use whole-catalog mode."
191+
)
192+
193+
stats = IngestionStats()
194+
total_files = 0
195+
total_datasets = 0
196+
emitted = False
197+
for raw_chunk in iter_fn(directory, chunk_size=chunk_size):
198+
validated_chunk = adapter.validate_data_catalog(raw_chunk, skip_invalid=skip_invalid)
199+
if validated_chunk.empty:
200+
continue
201+
emitted = True
202+
total_files += len(validated_chunk)
203+
total_datasets += validated_chunk[adapter.slug_column].nunique()
204+
stats += _ingest_catalog(adapter, db, validated_chunk)
205+
# Drop chunk references so the per-chunk pandas memory can be
206+
# reclaimed before the next chunk is parsed.
207+
del raw_chunk, validated_chunk
208+
209+
if not emitted:
127210
raise ValueError(f"No valid datasets found in {directory}")
128211

129-
logger.info(
130-
f"Found {len(data_catalog)} files for {len(data_catalog[adapter.slug_column].unique())} datasets"
131-
)
212+
logger.info(f"Ingested {total_files} files across approximately {total_datasets} datasets (streamed)")
213+
return stats
132214

133-
stats = IngestionStats()
215+
data_catalog = adapter.find_local_datasets(directory)
216+
data_catalog = adapter.validate_data_catalog(data_catalog, skip_invalid=skip_invalid)
134217

135-
for instance_id, data_catalog_dataset in data_catalog.groupby(adapter.slug_column):
136-
logger.debug(f"Processing dataset {instance_id}")
137-
with db.session.begin():
138-
results = adapter.register_dataset(db, data_catalog_dataset)
218+
if data_catalog.empty:
219+
raise ValueError(f"No valid datasets found in {directory}")
139220

140-
if results.dataset_state == ModelState.CREATED:
141-
stats.datasets_created += 1
142-
elif results.dataset_state == ModelState.UPDATED:
143-
stats.datasets_updated += 1
144-
else:
145-
stats.datasets_unchanged += 1
146-
stats.files_added += len(results.files_added)
147-
stats.files_updated += len(results.files_updated)
148-
stats.files_removed += len(results.files_removed)
149-
stats.files_unchanged += len(results.files_unchanged)
221+
logger.info(
222+
f"Found {len(data_catalog)} files for {len(data_catalog[adapter.slug_column].unique())} datasets"
223+
)
150224

151-
# Release ORM objects from the session identity map after each commit.
152-
# Without this, all Dataset and DatasetFile objects accumulate in memory
153-
# across the entire ingestion loop.
154-
db.session.expire_all()
155-
156-
return stats
225+
return _ingest_catalog(adapter, db, data_catalog)
157226

158227

159228
def get_dataset_adapter(source_type: str, **kwargs: Any) -> DatasetAdapter:

0 commit comments

Comments
 (0)