452 lines
19 KiB
Python
452 lines
19 KiB
Python
|
|
"""Maintain an Apache Iceberg table over the ERA5 lake's hive parquet.
|
||
|
|
|
||
|
|
The lake extractor (backend/gen_era5_lake.py) writes plain hive-partitioned
|
||
|
|
parquet to the era5-thermograph bucket:
|
||
|
|
|
||
|
|
era5/daily/tile=<ti>_<tj>/year=<Y>/month=<M>/part.parquet
|
||
|
|
|
||
|
|
This tool registers those files, in place, into an Iceberg v2 table
|
||
|
|
``era5_daily`` located at ``s3://<bucket>/iceberg/era5_daily`` via pyiceberg
|
||
|
|
``add_files`` — no data is rewritten or copied, the Iceberg metadata simply
|
||
|
|
points at the existing objects. Everything this tool writes lives under the
|
||
|
|
``iceberg/`` prefix; the ``era5/`` prefixes are read-only to it by design.
|
||
|
|
|
||
|
|
Partitioning: the part files carry no tile/year/month columns (those live in
|
||
|
|
the object path), so the table partitions by order-preserving transforms of
|
||
|
|
real columns instead — ``truncate[12](lat_idx)`` / ``truncate[12](lon_idx)``
|
||
|
|
(a 12x12-point tile, so the partition value is ``ti*12``/``tj*12``) and
|
||
|
|
``year(date)`` / ``month(date)``. Each part file holds exactly one tile and
|
||
|
|
one calendar month, so every partition value is single-valued per file and
|
||
|
|
``add_files`` can derive it from the parquet footer statistics alone.
|
||
|
|
|
||
|
|
Incremental and safe under concurrent extraction: ``era5/manifest.parquet``
|
||
|
|
is the source of truth for complete tiles (the extractor uploads all of a
|
||
|
|
tile's objects before adding it to the manifest), so each run diffs the
|
||
|
|
manifest's tiles against the ``thermograph.synced-tiles`` table property and
|
||
|
|
adds only the difference. The property is updated in the same commit as the
|
||
|
|
files, so a run can die at any point and the next one resumes cleanly.
|
||
|
|
|
||
|
|
Catalog: a local pyiceberg SqlCatalog (sqlite, path from
|
||
|
|
THERMOGRAPH_ICEBERG_CATALOG_DB, default /state/iceberg-catalog.db). The
|
||
|
|
catalog is a convenience pointer only — if it is lost, the next run finds the
|
||
|
|
latest ``*.metadata.json`` under the table location and re-registers it. Each
|
||
|
|
run ends by printing the current metadata JSON path, which DuckDB can read
|
||
|
|
directly: ``iceberg_scan('s3://.../metadata/NNNNN-....metadata.json')``.
|
||
|
|
|
||
|
|
S3 config comes from the same THERMOGRAPH_LAKE_S3_* variables (and defaults)
|
||
|
|
as backend/data/era5lake.py; THERMOGRAPH_LAKE_LOCAL_DIR switches to a local
|
||
|
|
mirror of the lake layout (tests, rehearsal).
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
import pyarrow.parquet as pq
|
||
|
|
from pyiceberg.catalog.sql import SqlCatalog
|
||
|
|
from pyiceberg.exceptions import NoSuchTableError
|
||
|
|
from pyiceberg.partitioning import PartitionField, PartitionSpec
|
||
|
|
from pyiceberg.schema import Schema
|
||
|
|
from pyiceberg.table.name_mapping import create_mapping_from_schema
|
||
|
|
from pyiceberg.transforms import MonthTransform, TruncateTransform, YearTransform
|
||
|
|
from pyiceberg.types import (
|
||
|
|
DateType,
|
||
|
|
DoubleType,
|
||
|
|
FloatType,
|
||
|
|
IntegerType,
|
||
|
|
NestedField,
|
||
|
|
)
|
||
|
|
|
||
|
|
PREFIX = "era5"
|
||
|
|
MANIFEST_KEY = f"{PREFIX}/manifest.parquet"
|
||
|
|
ICEBERG_PREFIX = "iceberg"
|
||
|
|
|
||
|
|
NAMESPACE = "lake"
|
||
|
|
TABLE_NAME = "era5_daily"
|
||
|
|
IDENTIFIER = f"{NAMESPACE}.{TABLE_NAME}"
|
||
|
|
SYNCED_PROP = "thermograph.synced-tiles"
|
||
|
|
|
||
|
|
TILE = 12 # grid points per tile side, as in era5lake.TILE
|
||
|
|
DEFAULT_BATCH = 5 # tiles per Iceberg commit
|
||
|
|
DEFAULT_RETRIES = 3 # per-batch retries on transient S3 errors
|
||
|
|
BACKOFF_SECONDS = 15 # first retry delay, doubled per attempt
|
||
|
|
DEFAULT_CATALOG_DB = "/state/iceberg-catalog.db"
|
||
|
|
|
||
|
|
|
||
|
|
class LakeInvariantError(RuntimeError):
|
||
|
|
"""The lake violated an extractor invariant; never retried."""
|
||
|
|
|
||
|
|
# The part-file schema as the extractor writes it (verified against the
|
||
|
|
# bucket): float32 for the heat-index-derived and raw met columns, float64
|
||
|
|
# where polars promoted (fmin/sun/feels), int16 doy, int32 grid indices.
|
||
|
|
# Iceberg has no 16-bit type, so doy maps to int; all fields optional.
|
||
|
|
SCHEMA = Schema(
|
||
|
|
NestedField(1, "date", DateType(), required=False),
|
||
|
|
NestedField(2, "tmax", FloatType(), required=False),
|
||
|
|
NestedField(3, "tmin", FloatType(), required=False),
|
||
|
|
NestedField(4, "precip", FloatType(), required=False),
|
||
|
|
NestedField(5, "wind", FloatType(), required=False),
|
||
|
|
NestedField(6, "gust", FloatType(), required=False),
|
||
|
|
NestedField(7, "humid", FloatType(), required=False),
|
||
|
|
NestedField(8, "fmax", FloatType(), required=False),
|
||
|
|
NestedField(9, "fmin", DoubleType(), required=False),
|
||
|
|
NestedField(10, "sun", DoubleType(), required=False),
|
||
|
|
NestedField(11, "feels", DoubleType(), required=False),
|
||
|
|
NestedField(12, "doy", IntegerType(), required=False),
|
||
|
|
NestedField(13, "lat_idx", IntegerType(), required=False),
|
||
|
|
NestedField(14, "lon_idx", IntegerType(), required=False),
|
||
|
|
)
|
||
|
|
|
||
|
|
# (tile, year, month): truncate[12] of the grid indices is the tile (values
|
||
|
|
# are ti*12 / tj*12), year/month come from the date column. All four
|
||
|
|
# transforms preserve order, which is what lets add_files infer the partition
|
||
|
|
# value from footer min/max stats (single value per file by construction).
|
||
|
|
PARTITION_SPEC = PartitionSpec(
|
||
|
|
PartitionField(source_id=13, field_id=1000,
|
||
|
|
transform=TruncateTransform(TILE), name="tile_lat"),
|
||
|
|
PartitionField(source_id=14, field_id=1001,
|
||
|
|
transform=TruncateTransform(TILE), name="tile_lon"),
|
||
|
|
PartitionField(source_id=1, field_id=1002,
|
||
|
|
transform=YearTransform(), name="year"),
|
||
|
|
PartitionField(source_id=1, field_id=1003,
|
||
|
|
transform=MonthTransform(), name="month"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def s3_config() -> "dict | None":
|
||
|
|
"""Bucket access, or None when unconfigured. Same variable names and
|
||
|
|
defaults as backend/data/era5lake.py's s3_config (Contabo: path-style
|
||
|
|
addressing, region is a signing formality)."""
|
||
|
|
key = os.environ.get("THERMOGRAPH_LAKE_S3_ACCESS_KEY", "")
|
||
|
|
secret = os.environ.get("THERMOGRAPH_LAKE_S3_SECRET_KEY", "")
|
||
|
|
if not (key and secret):
|
||
|
|
return None
|
||
|
|
return {
|
||
|
|
"endpoint": os.environ.get("THERMOGRAPH_LAKE_S3_ENDPOINT",
|
||
|
|
"https://eu2.contabostorage.com"),
|
||
|
|
"bucket": os.environ.get("THERMOGRAPH_LAKE_S3_BUCKET", "era5-thermograph"),
|
||
|
|
"region": os.environ.get("THERMOGRAPH_LAKE_S3_REGION", "default"),
|
||
|
|
"access_key": key,
|
||
|
|
"secret_key": secret,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class HiveLake:
|
||
|
|
"""Read-only view of the extracted lake (bucket or local mirror) plus the
|
||
|
|
iceberg/ prefix the table lives under. ``root`` is an fsspec path prefix
|
||
|
|
("<bucket>" or "/abs/dir"), ``uri`` the equivalent URI prefix pyiceberg
|
||
|
|
sees ("s3://<bucket>" or "file:///abs/dir")."""
|
||
|
|
|
||
|
|
def __init__(self, fs, root: str, uri: str, cfg: "dict | None"):
|
||
|
|
self.fs = fs
|
||
|
|
self.root = root.rstrip("/")
|
||
|
|
self.uri = uri.rstrip("/")
|
||
|
|
self.cfg = cfg
|
||
|
|
|
||
|
|
@property
|
||
|
|
def warehouse(self) -> str:
|
||
|
|
return f"{self.uri}/{ICEBERG_PREFIX}"
|
||
|
|
|
||
|
|
def to_uri(self, path: str) -> str:
|
||
|
|
rel = path.removeprefix(self.root).lstrip("/")
|
||
|
|
return f"{self.uri}/{rel}"
|
||
|
|
|
||
|
|
def tile_rows(self) -> "dict[str, int]":
|
||
|
|
"""tile -> row count, from the manifest (the source of truth for
|
||
|
|
complete tiles: the extractor uploads every object of a tile before
|
||
|
|
its manifest rows appear, so listing incomplete tiles is impossible
|
||
|
|
here even while extraction is running)."""
|
||
|
|
with self.fs.open(f"{self.root}/{MANIFEST_KEY}", "rb") as f:
|
||
|
|
m = pq.read_table(f, columns=["tile", "rows"])
|
||
|
|
out: dict[str, int] = {}
|
||
|
|
for tile, rows in zip(m.column("tile").to_pylist(),
|
||
|
|
m.column("rows").to_pylist()):
|
||
|
|
out[tile] = out.get(tile, 0) + rows
|
||
|
|
return out
|
||
|
|
|
||
|
|
def tile_files(self, tile: str) -> "list[str]":
|
||
|
|
"""URIs of one complete tile's hive part files. A flat find() on the
|
||
|
|
tile prefix, not a glob: fsspec expands year=*/month=* by walking
|
||
|
|
every directory level (~1000 LIST calls per tile on S3), while find()
|
||
|
|
is one paginated listing."""
|
||
|
|
prefix = f"{self.root}/{PREFIX}/daily/tile={tile}"
|
||
|
|
part = re.compile(r"/year=\d+/month=\d+/part\.parquet$")
|
||
|
|
try:
|
||
|
|
paths = self.fs.find(prefix)
|
||
|
|
except FileNotFoundError:
|
||
|
|
return []
|
||
|
|
return sorted(self.to_uri(p) for p in paths if part.search(p))
|
||
|
|
|
||
|
|
def latest_metadata_uri(self) -> "str | None":
|
||
|
|
"""Newest *.metadata.json under the table location, for re-registering
|
||
|
|
the table into a fresh catalog db (pyiceberg names them with a
|
||
|
|
zero-padded, monotonically increasing version prefix)."""
|
||
|
|
prefix = f"{self.root}/{ICEBERG_PREFIX}/{TABLE_NAME}/metadata"
|
||
|
|
try:
|
||
|
|
paths = [p for p in self.fs.find(prefix)
|
||
|
|
if p.endswith(".metadata.json")]
|
||
|
|
except FileNotFoundError:
|
||
|
|
return None
|
||
|
|
if not paths:
|
||
|
|
return None
|
||
|
|
|
||
|
|
def version(p: str) -> int:
|
||
|
|
m = re.match(r"(\d+)", os.path.basename(p))
|
||
|
|
return int(m.group(1)) if m else -1
|
||
|
|
|
||
|
|
return self.to_uri(max(paths, key=version))
|
||
|
|
|
||
|
|
|
||
|
|
def lake_from_env() -> HiveLake:
|
||
|
|
local = os.environ.get("THERMOGRAPH_LAKE_LOCAL_DIR", "")
|
||
|
|
if local:
|
||
|
|
from fsspec.implementations.local import LocalFileSystem
|
||
|
|
root = os.path.abspath(local)
|
||
|
|
return HiveLake(LocalFileSystem(), root, f"file://{root}", None)
|
||
|
|
cfg = s3_config()
|
||
|
|
if cfg is None:
|
||
|
|
sys.exit("no lake configured: set THERMOGRAPH_LAKE_S3_ACCESS_KEY/"
|
||
|
|
"_SECRET_KEY (or THERMOGRAPH_LAKE_LOCAL_DIR for a local mirror)")
|
||
|
|
import s3fs
|
||
|
|
fs = s3fs.S3FileSystem(
|
||
|
|
key=cfg["access_key"], secret=cfg["secret_key"],
|
||
|
|
endpoint_url=cfg["endpoint"],
|
||
|
|
client_kwargs={"region_name": cfg["region"]},
|
||
|
|
config_kwargs={"s3": {"addressing_style": "path"}})
|
||
|
|
return HiveLake(fs, cfg["bucket"], f"s3://{cfg['bucket']}", cfg)
|
||
|
|
|
||
|
|
|
||
|
|
def open_catalog(catalog_db: str, lake: HiveLake) -> SqlCatalog:
|
||
|
|
parent = os.path.dirname(os.path.abspath(catalog_db))
|
||
|
|
os.makedirs(parent, exist_ok=True)
|
||
|
|
props = {"uri": f"sqlite:///{os.path.abspath(catalog_db)}",
|
||
|
|
"warehouse": lake.warehouse}
|
||
|
|
if lake.cfg:
|
||
|
|
props |= {
|
||
|
|
"py-io-impl": "pyiceberg.io.pyarrow.PyArrowFileIO",
|
||
|
|
"s3.endpoint": lake.cfg["endpoint"],
|
||
|
|
"s3.region": lake.cfg["region"],
|
||
|
|
"s3.access-key-id": lake.cfg["access_key"],
|
||
|
|
"s3.secret-access-key": lake.cfg["secret_key"],
|
||
|
|
# Contabo serves buckets on the path, not as a subdomain.
|
||
|
|
"s3.force-virtual-addressing": "false",
|
||
|
|
}
|
||
|
|
return SqlCatalog("thermograph", **props)
|
||
|
|
|
||
|
|
|
||
|
|
def ensure_table(catalog: SqlCatalog, lake: HiveLake, create: bool = True):
|
||
|
|
"""Load the table; adopt existing S3 metadata into a fresh catalog db;
|
||
|
|
otherwise create it. ``create=False`` (dry runs) returns None instead of
|
||
|
|
creating anything."""
|
||
|
|
try:
|
||
|
|
return catalog.load_table(IDENTIFIER)
|
||
|
|
except NoSuchTableError:
|
||
|
|
pass
|
||
|
|
existing = lake.latest_metadata_uri()
|
||
|
|
if existing:
|
||
|
|
catalog.create_namespace_if_not_exists(NAMESPACE)
|
||
|
|
print(f"registering existing table metadata: {existing}")
|
||
|
|
return catalog.register_table(IDENTIFIER, existing)
|
||
|
|
if not create:
|
||
|
|
return None
|
||
|
|
catalog.create_namespace_if_not_exists(NAMESPACE)
|
||
|
|
return catalog.create_table(
|
||
|
|
IDENTIFIER,
|
||
|
|
schema=SCHEMA,
|
||
|
|
location=f"{lake.warehouse}/{TABLE_NAME}",
|
||
|
|
partition_spec=PARTITION_SPEC,
|
||
|
|
properties={
|
||
|
|
"format-version": "2",
|
||
|
|
# The part files carry no Iceberg field ids; every reader needs
|
||
|
|
# this name mapping to resolve columns (DuckDB included).
|
||
|
|
"schema.name-mapping.default":
|
||
|
|
create_mapping_from_schema(SCHEMA).model_dump_json(),
|
||
|
|
})
|
||
|
|
|
||
|
|
|
||
|
|
def synced_tiles(table) -> "set[str]":
|
||
|
|
if table is None:
|
||
|
|
return set()
|
||
|
|
raw = table.properties.get(SYNCED_PROP, "")
|
||
|
|
return {t for t in raw.split(",") if t}
|
||
|
|
|
||
|
|
|
||
|
|
def tile_key(tile: str) -> "tuple[int, int]":
|
||
|
|
ti, tj = tile.split("_")
|
||
|
|
return int(ti), int(tj)
|
||
|
|
|
||
|
|
|
||
|
|
def pending_tiles(manifest_tiles, synced: "set[str]") -> "list[str]":
|
||
|
|
return sorted(set(manifest_tiles) - synced, key=tile_key)
|
||
|
|
|
||
|
|
|
||
|
|
def sync_batch(table, lake: HiveLake, chunk: "list[str]",
|
||
|
|
retries: int = DEFAULT_RETRIES) -> "list[str]":
|
||
|
|
"""Commit one batch of tiles (files + synced-tiles property, atomically),
|
||
|
|
retrying transient S3 failures with backoff. Each attempt re-reads the
|
||
|
|
synced set from the (refreshed) table, so an attempt that committed but
|
||
|
|
errored afterwards is not re-added. Returns the tiles actually added."""
|
||
|
|
for attempt in range(retries + 1):
|
||
|
|
try:
|
||
|
|
table.refresh()
|
||
|
|
left = [t for t in chunk if t not in synced_tiles(table)]
|
||
|
|
if not left:
|
||
|
|
return []
|
||
|
|
files = []
|
||
|
|
for t in left:
|
||
|
|
tf = lake.tile_files(t)
|
||
|
|
if not tf:
|
||
|
|
raise LakeInvariantError(
|
||
|
|
f"tile {t} is in the manifest but has no daily part "
|
||
|
|
f"files — refusing to mark it synced")
|
||
|
|
files.extend(tf)
|
||
|
|
new_prop = ",".join(
|
||
|
|
sorted(synced_tiles(table) | set(left), key=tile_key))
|
||
|
|
with table.transaction() as tx:
|
||
|
|
tx.add_files(
|
||
|
|
file_paths=files,
|
||
|
|
snapshot_properties={"thermograph.tiles-added":
|
||
|
|
",".join(left)})
|
||
|
|
tx.set_properties({SYNCED_PROP: new_prop})
|
||
|
|
return left
|
||
|
|
except LakeInvariantError:
|
||
|
|
raise
|
||
|
|
except Exception as e: # noqa: BLE001 - S3 throttling has many faces
|
||
|
|
if attempt >= retries:
|
||
|
|
raise
|
||
|
|
wait = BACKOFF_SECONDS * 2 ** attempt
|
||
|
|
print(f"batch {chunk[0]}..{chunk[-1]} attempt {attempt + 1} "
|
||
|
|
f"failed ({e}); retrying in {wait}s", file=sys.stderr)
|
||
|
|
time.sleep(wait)
|
||
|
|
return [] # unreachable
|
||
|
|
|
||
|
|
|
||
|
|
def write_version_hint(lake: HiveLake, table) -> "str | None":
|
||
|
|
"""Hadoop-catalog-style pointer next to the metadata files so engines can
|
||
|
|
scan the bare table root — DuckDB: iceberg_scan('s3://…/era5_daily') —
|
||
|
|
without knowing the exact metadata path. Content is the current metadata
|
||
|
|
file's basename minus '.metadata.json' (DuckDB's default
|
||
|
|
version_name_format tries '%s.metadata.json', which resolves it). A
|
||
|
|
convenience pointer only: refreshed every run, so a failed write just
|
||
|
|
lags one version."""
|
||
|
|
hint = os.path.basename(table.metadata_location) \
|
||
|
|
.removesuffix(".metadata.json")
|
||
|
|
path = f"{lake.root}/{ICEBERG_PREFIX}/{TABLE_NAME}/metadata/version-hint.text"
|
||
|
|
for attempt in range(3):
|
||
|
|
try:
|
||
|
|
with lake.fs.open(path, "wb") as f:
|
||
|
|
f.write(hint.encode())
|
||
|
|
return hint
|
||
|
|
except Exception as e: # noqa: BLE001
|
||
|
|
if attempt == 2:
|
||
|
|
print(f"WARNING: version-hint.text write failed ({e}); "
|
||
|
|
f"table-root scans will lag one version", file=sys.stderr)
|
||
|
|
return None
|
||
|
|
time.sleep(BACKOFF_SECONDS)
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def run(lake: HiveLake, catalog_db: str, *, limit: "int | None" = None,
|
||
|
|
batch: int = DEFAULT_BATCH, retries: int = DEFAULT_RETRIES,
|
||
|
|
dry_run: bool = False) -> dict:
|
||
|
|
tile_rows = lake.tile_rows()
|
||
|
|
catalog = open_catalog(catalog_db, lake)
|
||
|
|
table = ensure_table(catalog, lake, create=not dry_run)
|
||
|
|
done = synced_tiles(table)
|
||
|
|
todo = pending_tiles(tile_rows, done)
|
||
|
|
if limit:
|
||
|
|
todo = todo[:limit]
|
||
|
|
print(f"manifest tiles={len(tile_rows)} synced={len(done)} "
|
||
|
|
f"pending this run={len(todo)}")
|
||
|
|
|
||
|
|
added_files = added_records = 0
|
||
|
|
mismatches = []
|
||
|
|
if dry_run:
|
||
|
|
for t in todo:
|
||
|
|
files = lake.tile_files(t)
|
||
|
|
print(f"[dry-run] tile {t}: {len(files)} part files, "
|
||
|
|
f"{tile_rows[t]} manifest rows")
|
||
|
|
else:
|
||
|
|
for k in range(0, len(todo), batch):
|
||
|
|
chunk = todo[k:k + batch]
|
||
|
|
# One commit per batch: the files and the synced-tiles marker land
|
||
|
|
# atomically, so an interrupted run never half-syncs a tile.
|
||
|
|
added = sync_batch(table, lake, chunk, retries)
|
||
|
|
done |= set(chunk)
|
||
|
|
if not added:
|
||
|
|
print(f"[{min(k + batch, len(todo))}/{len(todo)}] tiles "
|
||
|
|
f"{chunk[0]}..{chunk[-1]}: already synced")
|
||
|
|
continue
|
||
|
|
summary = table.current_snapshot().summary
|
||
|
|
got = int(summary.get("added-records", "0"))
|
||
|
|
expected = sum(tile_rows[t] for t in added)
|
||
|
|
n_files = int(summary.get("added-data-files", "0"))
|
||
|
|
added_files += n_files
|
||
|
|
added_records += got
|
||
|
|
status = "ok" if got == expected else "MISMATCH"
|
||
|
|
if got != expected:
|
||
|
|
mismatches.append((added, expected, got))
|
||
|
|
print(f"[{min(k + batch, len(todo))}/{len(todo)}] tiles "
|
||
|
|
f"{added[0]}..{added[-1]}: +{n_files} files, "
|
||
|
|
f"+{got} records (manifest says {expected}: {status})")
|
||
|
|
|
||
|
|
result = {"pending": todo, "added_files": added_files,
|
||
|
|
"added_records": added_records, "mismatches": mismatches,
|
||
|
|
"metadata_location": None, "total_records": 0,
|
||
|
|
"total_files": 0}
|
||
|
|
if table is not None:
|
||
|
|
snap = table.current_snapshot()
|
||
|
|
if snap is not None:
|
||
|
|
result["total_records"] = int(snap.summary.get("total-records", "0"))
|
||
|
|
result["total_files"] = int(snap.summary.get("total-data-files", "0"))
|
||
|
|
result["metadata_location"] = table.metadata_location
|
||
|
|
if not dry_run:
|
||
|
|
result["version_hint"] = write_version_hint(lake, table)
|
||
|
|
expected_total = sum(tile_rows[t] for t in done if t in tile_rows)
|
||
|
|
print(f"table: {result['total_files']} data files, "
|
||
|
|
f"{result['total_records']} records "
|
||
|
|
f"(manifest rows for synced tiles: {expected_total})")
|
||
|
|
print(f"metadata: {table.metadata_location}")
|
||
|
|
if lake.cfg:
|
||
|
|
print("read it from DuckDB (no catalog needed):\n"
|
||
|
|
" LOAD iceberg; LOAD httpfs; -- plus an S3 secret for the "
|
||
|
|
"endpoint\n"
|
||
|
|
f" SELECT count(*) FROM iceberg_scan("
|
||
|
|
f"'{lake.warehouse}/{TABLE_NAME}');\n"
|
||
|
|
f" -- or pin the exact version: iceberg_scan("
|
||
|
|
f"'{table.metadata_location}')")
|
||
|
|
if mismatches:
|
||
|
|
print(f"WARNING: {len(mismatches)} batch(es) added a record count "
|
||
|
|
f"that differs from the manifest — investigate before trusting "
|
||
|
|
f"the table", file=sys.stderr)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
ap = argparse.ArgumentParser(
|
||
|
|
description="Register new ERA5 lake tiles into the Iceberg table")
|
||
|
|
ap.add_argument("--catalog-db",
|
||
|
|
default=os.environ.get("THERMOGRAPH_ICEBERG_CATALOG_DB",
|
||
|
|
DEFAULT_CATALOG_DB),
|
||
|
|
help="sqlite catalog path (default: %(default)s)")
|
||
|
|
ap.add_argument("--batch", type=int, default=DEFAULT_BATCH,
|
||
|
|
help="tiles per Iceberg commit (default: %(default)s)")
|
||
|
|
ap.add_argument("--limit", type=int,
|
||
|
|
help="sync at most this many new tiles this run")
|
||
|
|
ap.add_argument("--retries", type=int, default=DEFAULT_RETRIES,
|
||
|
|
help="per-batch retries on transient S3 errors "
|
||
|
|
"(default: %(default)s)")
|
||
|
|
ap.add_argument("--dry-run", action="store_true",
|
||
|
|
help="list pending tiles; write nothing")
|
||
|
|
args = ap.parse_args()
|
||
|
|
|
||
|
|
lake = lake_from_env()
|
||
|
|
result = run(lake, args.catalog_db, limit=args.limit, batch=args.batch,
|
||
|
|
retries=args.retries, dry_run=args.dry_run)
|
||
|
|
return 1 if result["mismatches"] else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|