firecube 0.1.4__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.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# Copyright 2025-2026 EUMETSAT
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Resume/overwrite safety guard based on ChunkManager manifests (optional Zarr validation)."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from firecube.core.observability.metrics import ResumeGuardMetrics, resume_guard_span
|
|
25
|
+
from firecube.core.zarr.validation import validate_group_with_fs
|
|
26
|
+
from firecube.ingestor.errors import RangeOverlapError, ResumeConflictError
|
|
27
|
+
from firecube.ingestor.runtime.resume_types import ResumeDecision, ResumeVerdict
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ranges_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool:
|
|
31
|
+
"""Return True if half-open ranges [start, end) overlap.
|
|
32
|
+
|
|
33
|
+
Back-to-back ranges like [0,100) and [100,200) are disjoint.
|
|
34
|
+
"""
|
|
35
|
+
return a[0] < b[1] and b[0] < a[1]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ranges_overlap_inclusive_vs_halfopen(
|
|
39
|
+
inclusive: tuple[int, int], halfopen: tuple[int, int]
|
|
40
|
+
) -> bool:
|
|
41
|
+
"""Check if INCLUSIVE [a, b] overlaps HALF-OPEN [c, d).
|
|
42
|
+
|
|
43
|
+
Boundary cases:
|
|
44
|
+
- [0, 9] vs [10, 20) → DISJOINT (0 < 20 AND 10 <= 9 → False)
|
|
45
|
+
- [0, 10] vs [10, 20) → OVERLAP (0 < 20 AND 10 <= 10 → True)
|
|
46
|
+
- [10, 20] vs [0, 10) → DISJOINT (10 < 10 → False)
|
|
47
|
+
- [9, 20] vs [0, 10) → OVERLAP (9 < 10 AND 0 <= 20 → True)
|
|
48
|
+
"""
|
|
49
|
+
inc_start, inc_end = inclusive
|
|
50
|
+
ho_start, ho_end = halfopen
|
|
51
|
+
return inc_start < ho_end and ho_start <= inc_end
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class ResumeGuard:
|
|
56
|
+
"""Enforce resume / overwrite safety before an ingest run starts.
|
|
57
|
+
|
|
58
|
+
Decision matrix (evaluated in order):
|
|
59
|
+
|
|
60
|
+
┌──────────────────────────┬─────────────────┬───────────────┬─────────────────────┐
|
|
61
|
+
│ non-terminal run exists │ force_reingest │ resume_exist. │ outcome │
|
|
62
|
+
├──────────────────────────┼─────────────────┼───────────────┼─────────────────────┤
|
|
63
|
+
│ True │ False │ any │ BLOCK_STALE_RUN │
|
|
64
|
+
│ True │ True │ any │ continue (overwrite)│
|
|
65
|
+
│ False │ False │ False │ RuntimeError(conf.) │
|
|
66
|
+
│ False │ False │ True │ continue (resume) │
|
|
67
|
+
│ False │ True │ any │ continue (overwrite)│
|
|
68
|
+
└──────────────────────────┴─────────────────┴───────────────┴─────────────────────┘
|
|
69
|
+
|
|
70
|
+
When ``validate_zarr=true`` is also set, ``_run_optional_validation`` is
|
|
71
|
+
called to check whether the Zarr store actually contains chunks matching
|
|
72
|
+
the manifest. This can be slow on large stores and should be reserved for
|
|
73
|
+
debugging or post-incident analysis.
|
|
74
|
+
|
|
75
|
+
Slice matching: if a plugin declares ``slice_meta_keys`` (e.g.
|
|
76
|
+
``["msg_region", "forecast_horizons"]``), the guard compares those values
|
|
77
|
+
against existing span records to narrow conflicts to the same logical slice
|
|
78
|
+
rather than the entire product. Records that lack those keys are treated
|
|
79
|
+
conservatively as potential conflicts.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
plugin_name: str
|
|
83
|
+
chunk_manager: Any
|
|
84
|
+
log: logging.Logger
|
|
85
|
+
slice_meta_keys: Sequence[str]
|
|
86
|
+
last_metrics: ResumeGuardMetrics | None = None
|
|
87
|
+
|
|
88
|
+
def _log_decision(self, decision: ResumeDecision) -> None:
|
|
89
|
+
self.log.debug("Resume decision: %s — %s", decision.verdict.value, decision.reason)
|
|
90
|
+
|
|
91
|
+
def enforce(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
ctx: Any,
|
|
95
|
+
product: str,
|
|
96
|
+
slice_meta: dict[str, Any] | None = None,
|
|
97
|
+
slot_range: tuple[int, int] | None = None,
|
|
98
|
+
slot_group: str | None = None,
|
|
99
|
+
validation_group: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
with resume_guard_span() as metrics:
|
|
102
|
+
try:
|
|
103
|
+
with self.chunk_manager.repo.run_entries_cache_scope():
|
|
104
|
+
self._enforce(
|
|
105
|
+
ctx=ctx,
|
|
106
|
+
product=product,
|
|
107
|
+
slice_meta=slice_meta,
|
|
108
|
+
slot_range=slot_range,
|
|
109
|
+
slot_group=slot_group,
|
|
110
|
+
validation_group=validation_group,
|
|
111
|
+
metrics=metrics,
|
|
112
|
+
)
|
|
113
|
+
finally:
|
|
114
|
+
self.last_metrics = metrics
|
|
115
|
+
self.log.info(
|
|
116
|
+
"resume-guard: duration=%.4fs runs_enumerated=%d spans_scanned=%d product=%s",
|
|
117
|
+
metrics.enforce_duration_s,
|
|
118
|
+
metrics.runs_enumerated,
|
|
119
|
+
metrics.spans_scanned,
|
|
120
|
+
product,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _record_runs_enumerated(self, *, product: str, metrics: ResumeGuardMetrics) -> None:
|
|
124
|
+
"""Read cache size defensively; tolerates MagicMock chunk_managers in existing tests."""
|
|
125
|
+
repo = getattr(self.chunk_manager, "repo", None)
|
|
126
|
+
cache = getattr(repo, "_run_entries_cache", None) if repo is not None else None
|
|
127
|
+
if cache is None or not hasattr(cache, "entries_by_product"):
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
entries = cache.entries_by_product.get(product, [])
|
|
131
|
+
except (AttributeError, TypeError):
|
|
132
|
+
return
|
|
133
|
+
if isinstance(entries, list):
|
|
134
|
+
metrics.runs_enumerated = len(entries)
|
|
135
|
+
|
|
136
|
+
def _enforce(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
ctx: Any,
|
|
140
|
+
product: str,
|
|
141
|
+
slice_meta: dict[str, Any] | None = None,
|
|
142
|
+
slot_range: tuple[int, int] | None = None,
|
|
143
|
+
slot_group: str | None = None,
|
|
144
|
+
validation_group: str | None = None,
|
|
145
|
+
metrics: ResumeGuardMetrics,
|
|
146
|
+
) -> None:
|
|
147
|
+
resume_existing = bool(ctx.option("resume_existing", False))
|
|
148
|
+
validate_zarr = bool(ctx.option("validate_zarr", False))
|
|
149
|
+
force_reingest = bool(getattr(ctx, "force_reingest", False)) or bool(
|
|
150
|
+
ctx.option("force_reingest", False)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
slice_meta = dict(slice_meta or {})
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
self._check_non_terminal_runs(
|
|
157
|
+
product=product,
|
|
158
|
+
force_reingest=force_reingest,
|
|
159
|
+
resume_existing=resume_existing,
|
|
160
|
+
slot_range=slot_range,
|
|
161
|
+
new_slot_group=slot_group,
|
|
162
|
+
)
|
|
163
|
+
except ResumeConflictError:
|
|
164
|
+
self._log_decision(
|
|
165
|
+
ResumeDecision(
|
|
166
|
+
verdict=ResumeVerdict.BLOCK_STALE_RUN,
|
|
167
|
+
reason="Non-terminal run(s) exist",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
raise
|
|
171
|
+
finally:
|
|
172
|
+
self._record_runs_enumerated(product=product, metrics=metrics)
|
|
173
|
+
|
|
174
|
+
# Phase 3.1 T5: Slot-range-aware completed-span check (split bypass).
|
|
175
|
+
# When slot_range is set, the new pod uses group+range-aware overlap instead of
|
|
176
|
+
# the broad legacy check below (which treats ANY plugin span as a conflict and
|
|
177
|
+
# incorrectly blocks disjoint slot-range pods).
|
|
178
|
+
if slot_range is not None:
|
|
179
|
+
if not force_reingest:
|
|
180
|
+
metrics.spans_scanned = self._check_completed_spans_for_slot_overlap(
|
|
181
|
+
product=product,
|
|
182
|
+
slot_range=slot_range,
|
|
183
|
+
slot_group=slot_group,
|
|
184
|
+
)
|
|
185
|
+
self._log_decision(
|
|
186
|
+
ResumeDecision(
|
|
187
|
+
verdict=ResumeVerdict.PROCEED_FRESH,
|
|
188
|
+
reason="Slot-range mode: completed-span overlap check passed "
|
|
189
|
+
"(or bypassed via force_reingest)",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return # CRITICAL: Skip legacy broad completed-span check below
|
|
193
|
+
|
|
194
|
+
# Below: legacy/single-pod path (slot_range is None)
|
|
195
|
+
plugin_filter = {"plugin": self.plugin_name}
|
|
196
|
+
run_time_min = slice_meta.get("time_min")
|
|
197
|
+
run_time_max = slice_meta.get("time_max")
|
|
198
|
+
|
|
199
|
+
if run_time_min and run_time_max:
|
|
200
|
+
existing_records = list(
|
|
201
|
+
self.chunk_manager.list_chunks(
|
|
202
|
+
product=product,
|
|
203
|
+
chunk_type="span",
|
|
204
|
+
include_replaced=False,
|
|
205
|
+
meta=plugin_filter,
|
|
206
|
+
time_overlaps=(run_time_min, run_time_max),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
all_plugin_spans = self.chunk_manager.list_chunks(
|
|
210
|
+
product=product,
|
|
211
|
+
chunk_type="span",
|
|
212
|
+
include_replaced=False,
|
|
213
|
+
meta=plugin_filter,
|
|
214
|
+
)
|
|
215
|
+
seen_keys = {getattr(record, "key", None) for record in existing_records}
|
|
216
|
+
for span in all_plugin_spans:
|
|
217
|
+
meta = getattr(span, "meta", None) or {}
|
|
218
|
+
if (
|
|
219
|
+
"time_min" not in meta
|
|
220
|
+
and "time_max" not in meta
|
|
221
|
+
and getattr(span, "key", None) not in seen_keys
|
|
222
|
+
):
|
|
223
|
+
existing_records.append(span)
|
|
224
|
+
else:
|
|
225
|
+
existing_records = self.chunk_manager.list_chunks(
|
|
226
|
+
product=product,
|
|
227
|
+
chunk_type="span",
|
|
228
|
+
include_replaced=False,
|
|
229
|
+
meta=plugin_filter,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
metrics.spans_scanned = len(existing_records)
|
|
233
|
+
|
|
234
|
+
if self.slice_meta_keys and not (run_time_min and run_time_max):
|
|
235
|
+
missing_ctx_keys = [k for k in self.slice_meta_keys if k not in slice_meta]
|
|
236
|
+
if missing_ctx_keys and existing_records:
|
|
237
|
+
raise ResumeConflictError(
|
|
238
|
+
f"Existing entries for product '{product}' (plugin={self.plugin_name}) detected but "
|
|
239
|
+
f"this run is missing slice options needed for safe matching: {missing_ctx_keys}. "
|
|
240
|
+
"Provide those options to enable safe slice matching, or rerun with "
|
|
241
|
+
"--option resume_existing=true / --option force_reingest=true "
|
|
242
|
+
"(optionally --option validate_zarr=true to inspect storage; may be slow)."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
comparable: list[Any] = []
|
|
246
|
+
unknown: list[Any] = []
|
|
247
|
+
matching: list[Any] = []
|
|
248
|
+
|
|
249
|
+
for rec in existing_records:
|
|
250
|
+
meta = getattr(rec, "meta", None)
|
|
251
|
+
if not isinstance(meta, dict):
|
|
252
|
+
unknown.append(rec)
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
if all(k in meta for k in self.slice_meta_keys):
|
|
256
|
+
comparable.append(rec)
|
|
257
|
+
if all(meta.get(k) == slice_meta.get(k) for k in self.slice_meta_keys):
|
|
258
|
+
matching.append(rec)
|
|
259
|
+
else:
|
|
260
|
+
unknown.append(rec)
|
|
261
|
+
|
|
262
|
+
# Exact slice match is a conflict.
|
|
263
|
+
if matching:
|
|
264
|
+
existing_records = matching
|
|
265
|
+
elif unknown:
|
|
266
|
+
# Legacy entries exist without full slice meta; be conservative.
|
|
267
|
+
existing_records = unknown
|
|
268
|
+
else:
|
|
269
|
+
existing_records = []
|
|
270
|
+
|
|
271
|
+
if not existing_records:
|
|
272
|
+
self._log_decision(
|
|
273
|
+
ResumeDecision(
|
|
274
|
+
verdict=ResumeVerdict.PROCEED_FRESH,
|
|
275
|
+
reason="No existing spans",
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
if force_reingest:
|
|
281
|
+
if validate_zarr:
|
|
282
|
+
self._run_optional_validation(
|
|
283
|
+
ctx=ctx,
|
|
284
|
+
product=product,
|
|
285
|
+
validation_group=validation_group,
|
|
286
|
+
warn_only=True,
|
|
287
|
+
)
|
|
288
|
+
self._log_decision(
|
|
289
|
+
ResumeDecision(
|
|
290
|
+
verdict=ResumeVerdict.PROCEED_RESUME,
|
|
291
|
+
reason="force_reingest=True",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
if resume_existing:
|
|
297
|
+
if validate_zarr:
|
|
298
|
+
self._run_optional_validation(
|
|
299
|
+
ctx=ctx,
|
|
300
|
+
product=product,
|
|
301
|
+
validation_group=validation_group,
|
|
302
|
+
warn_only=True,
|
|
303
|
+
)
|
|
304
|
+
self._log_decision(
|
|
305
|
+
ResumeDecision(
|
|
306
|
+
verdict=ResumeVerdict.PROCEED_RESUME,
|
|
307
|
+
reason="resume_existing=True",
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
if validate_zarr:
|
|
313
|
+
has_chunks = self._run_optional_validation(
|
|
314
|
+
ctx=ctx,
|
|
315
|
+
product=product,
|
|
316
|
+
validation_group=validation_group,
|
|
317
|
+
warn_only=False,
|
|
318
|
+
)
|
|
319
|
+
if has_chunks:
|
|
320
|
+
self._log_decision(
|
|
321
|
+
ResumeDecision(
|
|
322
|
+
verdict=ResumeVerdict.BLOCK_CONFLICT,
|
|
323
|
+
reason="Existing data, no resume flag",
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
raise ResumeConflictError(
|
|
327
|
+
f"Existing entries for product '{product}' (plugin={self.plugin_name}) detected. "
|
|
328
|
+
"Rerun with --option resume_existing=true to continue, "
|
|
329
|
+
"--option force_reingest=true to overwrite."
|
|
330
|
+
)
|
|
331
|
+
self._log_decision(
|
|
332
|
+
ResumeDecision(
|
|
333
|
+
verdict=ResumeVerdict.BLOCK_CONFLICT,
|
|
334
|
+
reason="Existing data, no resume flag",
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
raise ResumeConflictError(
|
|
338
|
+
f"Manifest entries for product '{product}' (plugin={self.plugin_name}) exist but no chunks were found. "
|
|
339
|
+
"Consider scrubbing or rerun with --option force_reingest=true to overwrite."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
self._log_decision(
|
|
343
|
+
ResumeDecision(
|
|
344
|
+
verdict=ResumeVerdict.BLOCK_CONFLICT,
|
|
345
|
+
reason="Existing data, no resume flag",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
raise ResumeConflictError(
|
|
349
|
+
f"Existing entries for product '{product}' (plugin={self.plugin_name}) detected. "
|
|
350
|
+
"Rerun with --option resume_existing=true to continue, "
|
|
351
|
+
"or --option force_reingest=true to overwrite. "
|
|
352
|
+
"Use --option validate_zarr=true to inspect storage (may be slow)."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _check_non_terminal_runs(
|
|
356
|
+
self,
|
|
357
|
+
*,
|
|
358
|
+
product: str,
|
|
359
|
+
force_reingest: bool,
|
|
360
|
+
resume_existing: bool = False,
|
|
361
|
+
slot_range: tuple[int, int] | None = None,
|
|
362
|
+
new_slot_group: str | None = None,
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Block if any conflicting non-terminal runs exist for this product+plugin."""
|
|
365
|
+
non_terminal = self.chunk_manager.list_runs(product=product, non_terminal=True)
|
|
366
|
+
if not non_terminal:
|
|
367
|
+
return
|
|
368
|
+
if force_reingest:
|
|
369
|
+
self.log.warning(
|
|
370
|
+
"Non-terminal run(s) exist for product=%s but force_reingest=true, proceeding.",
|
|
371
|
+
product,
|
|
372
|
+
)
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
for run in non_terminal:
|
|
376
|
+
existing_slot_range = run.slot_range
|
|
377
|
+
abandon_cmd = (
|
|
378
|
+
f"firecube chunks runs abandon --product-name {product} --run-id {run.run_id} "
|
|
379
|
+
'--reason "<reason>"'
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if slot_range is not None and existing_slot_range is None:
|
|
383
|
+
if run.stale:
|
|
384
|
+
self.log.info(
|
|
385
|
+
"Non-range run %s for product=%s is stale; allowing range invocation.",
|
|
386
|
+
run.run_id,
|
|
387
|
+
product,
|
|
388
|
+
)
|
|
389
|
+
continue
|
|
390
|
+
raise ResumeConflictError(
|
|
391
|
+
f"Non-range run {run.run_id!r} is active for product '{product}'. "
|
|
392
|
+
"Abandon it first to start a slot-range parallel run:\n"
|
|
393
|
+
f" {abandon_cmd}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if slot_range is not None and existing_slot_range is not None:
|
|
397
|
+
existing_slot_group = getattr(run, "slot_group", None)
|
|
398
|
+
if (
|
|
399
|
+
new_slot_group is not None
|
|
400
|
+
and existing_slot_group is not None
|
|
401
|
+
and new_slot_group != existing_slot_group
|
|
402
|
+
):
|
|
403
|
+
self.log.info(
|
|
404
|
+
"Active run %s (slot_group=%s) targets different group than new run "
|
|
405
|
+
"(slot_group=%s); allowing.",
|
|
406
|
+
run.run_id,
|
|
407
|
+
existing_slot_group,
|
|
408
|
+
new_slot_group,
|
|
409
|
+
)
|
|
410
|
+
continue
|
|
411
|
+
if not _ranges_overlap(existing_slot_range, slot_range):
|
|
412
|
+
self.log.info(
|
|
413
|
+
"Active run %s (slot_range=%s) is disjoint from new range %s; allowing.",
|
|
414
|
+
run.run_id,
|
|
415
|
+
existing_slot_range,
|
|
416
|
+
slot_range,
|
|
417
|
+
)
|
|
418
|
+
continue
|
|
419
|
+
if existing_slot_range == slot_range:
|
|
420
|
+
if resume_existing:
|
|
421
|
+
self.log.warning(
|
|
422
|
+
"Resuming active run %s with same slot_range=%s for product=%s.",
|
|
423
|
+
run.run_id,
|
|
424
|
+
slot_range,
|
|
425
|
+
product,
|
|
426
|
+
)
|
|
427
|
+
continue
|
|
428
|
+
raise ResumeConflictError(
|
|
429
|
+
f"Run {run.run_id!r} with same slot_range={slot_range} is active "
|
|
430
|
+
f"for product '{product}'. Resume with --option resume_existing=true or "
|
|
431
|
+
f"abandon:\n {abandon_cmd}"
|
|
432
|
+
)
|
|
433
|
+
raise RangeOverlapError(
|
|
434
|
+
f"Run {run.run_id!r} with slot_range={existing_slot_range} overlaps "
|
|
435
|
+
f"new slot_range={slot_range} for product '{product}'. "
|
|
436
|
+
"Overlapping ranges risk Zarr chunk corruption. "
|
|
437
|
+
f"Abandon the conflicting run first:\n {abandon_cmd}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
raise ResumeConflictError(
|
|
441
|
+
f"Non-terminal run(s) [{run.run_id}] exist for product '{product}'. "
|
|
442
|
+
f"Abandon them first:\n {abandon_cmd}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def _check_completed_spans_for_slot_overlap(
|
|
446
|
+
self,
|
|
447
|
+
*,
|
|
448
|
+
product: str,
|
|
449
|
+
slot_range: tuple[int, int],
|
|
450
|
+
slot_group: str | None,
|
|
451
|
+
) -> int:
|
|
452
|
+
"""Block if any completed span overlaps the pod's slot_range within slot_group.
|
|
453
|
+
|
|
454
|
+
Uses inclusive [start, end] vs half-open [start, end) overlap formula.
|
|
455
|
+
Group-aware: when slot_group is set, only considers spans for that group.
|
|
456
|
+
Returns the number of completed spans examined (for telemetry).
|
|
457
|
+
"""
|
|
458
|
+
completed_spans = self.chunk_manager.list_chunks(
|
|
459
|
+
product=product,
|
|
460
|
+
chunk_type="span",
|
|
461
|
+
include_replaced=False,
|
|
462
|
+
meta={"plugin": self.plugin_name},
|
|
463
|
+
)
|
|
464
|
+
overlapping = []
|
|
465
|
+
for span in completed_spans:
|
|
466
|
+
if not isinstance(getattr(span, "record", None), dict):
|
|
467
|
+
continue
|
|
468
|
+
span_payload = span.record.get("span") or {}
|
|
469
|
+
if not isinstance(span_payload, dict):
|
|
470
|
+
continue
|
|
471
|
+
span_group = (getattr(span, "meta", None) or {}).get("group")
|
|
472
|
+
# Group-aware: if slot_group set, only check spans for same group;
|
|
473
|
+
# if slot_group is None (covers all groups), check all spans.
|
|
474
|
+
if slot_group is not None and span_group is not None and span_group != slot_group:
|
|
475
|
+
continue
|
|
476
|
+
time_index_ranges = span_payload.get("time_index_ranges") or []
|
|
477
|
+
for range_entry in time_index_ranges:
|
|
478
|
+
if len(range_entry) < 2:
|
|
479
|
+
continue
|
|
480
|
+
range_start, range_end = int(range_entry[0]), int(range_entry[1])
|
|
481
|
+
if _ranges_overlap_inclusive_vs_halfopen((range_start, range_end), slot_range):
|
|
482
|
+
overlapping.append(
|
|
483
|
+
(getattr(span, "key", None), span_group, [range_start, range_end])
|
|
484
|
+
)
|
|
485
|
+
break
|
|
486
|
+
if overlapping:
|
|
487
|
+
raise ResumeConflictError(
|
|
488
|
+
f"Completed spans overlap new slot_range={slot_range} "
|
|
489
|
+
f"(slot_group={slot_group!r}) for product '{product}'. "
|
|
490
|
+
f"Overlapping: {overlapping[:5]}{'...' if len(overlapping) > 5 else ''}. "
|
|
491
|
+
"Rerun with --option force_reingest=true to overwrite, "
|
|
492
|
+
"or pass a disjoint --slot-start/--slot-end range."
|
|
493
|
+
)
|
|
494
|
+
return len(completed_spans)
|
|
495
|
+
|
|
496
|
+
def _run_optional_validation(
|
|
497
|
+
self,
|
|
498
|
+
*,
|
|
499
|
+
ctx: Any,
|
|
500
|
+
product: str,
|
|
501
|
+
validation_group: str | None,
|
|
502
|
+
warn_only: bool,
|
|
503
|
+
) -> bool:
|
|
504
|
+
"""Probe the Zarr store to check whether chunks actually exist.
|
|
505
|
+
|
|
506
|
+
Returns ``True`` if at least one array in the group has written indices
|
|
507
|
+
(``max_index >= 0``), ``False`` otherwise (empty store or error).
|
|
508
|
+
|
|
509
|
+
When ``warn_only=True`` (resume or force_reingest path), structural
|
|
510
|
+
issues are logged as warnings but do not block execution.
|
|
511
|
+
When ``warn_only=False`` (blocked path), the caller uses the return
|
|
512
|
+
value to distinguish "manifest without data" from "manifest with data".
|
|
513
|
+
"""
|
|
514
|
+
storage = getattr(ctx, "storage", None)
|
|
515
|
+
output = getattr(storage, "output", None) if storage is not None else None
|
|
516
|
+
if output is None:
|
|
517
|
+
return False
|
|
518
|
+
|
|
519
|
+
store_uri = output.product.product_uri
|
|
520
|
+
|
|
521
|
+
group = str(ctx.option("validate_zarr_group", "")).strip("/")
|
|
522
|
+
if not group and validation_group:
|
|
523
|
+
group = str(validation_group).strip("/")
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
_timeout_raw = ctx.option("validate_zarr_timeout_s", None)
|
|
527
|
+
_max_chunks_raw = ctx.option("validate_zarr_max_chunks", None)
|
|
528
|
+
_on_timeout = str(ctx.option("validate_zarr_on_timeout", "warn"))
|
|
529
|
+
_timeout_s = float(_timeout_raw) if _timeout_raw is not None else None
|
|
530
|
+
_max_chunks = int(_max_chunks_raw) if _max_chunks_raw is not None else None
|
|
531
|
+
report = validate_group_with_fs(
|
|
532
|
+
output.fs(),
|
|
533
|
+
store_uri,
|
|
534
|
+
group,
|
|
535
|
+
timeout_s=_timeout_s,
|
|
536
|
+
max_chunks=_max_chunks,
|
|
537
|
+
on_timeout=_on_timeout,
|
|
538
|
+
)
|
|
539
|
+
has_chunks = any(v >= 0 for v in report.max_indices.values())
|
|
540
|
+
if warn_only and (report.extra_chunks or report.missing_indices):
|
|
541
|
+
self.log.warning(
|
|
542
|
+
"validate_zarr detected structural issues (extra=%d, missing=%d) for product=%s group=%s",
|
|
543
|
+
len(report.extra_chunks),
|
|
544
|
+
sum(len(v) for v in report.missing_indices.values()),
|
|
545
|
+
product,
|
|
546
|
+
group or "/",
|
|
547
|
+
)
|
|
548
|
+
return has_chunks
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
self.log.warning(
|
|
551
|
+
"validate_zarr failed for product=%s group=%s: %s",
|
|
552
|
+
product,
|
|
553
|
+
group or "/",
|
|
554
|
+
exc,
|
|
555
|
+
)
|
|
556
|
+
return False
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Copyright 2025-2026 EUMETSAT
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Resume authority decision types.
|
|
16
|
+
|
|
17
|
+
These types model the outcome of ``ResumeGuard.enforce()`` — the safety
|
|
18
|
+
check that runs before every ingestion to decide whether to proceed,
|
|
19
|
+
resume, or block. ``enforce()`` logs a ``ResumeDecision`` at each
|
|
20
|
+
decision point for diagnostics, while still raising
|
|
21
|
+
``ResumeConflictError`` on blocking verdicts to preserve the existing API.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import Any, Protocol
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResumeVerdict(Enum):
|
|
32
|
+
"""Outcome of a resume authority decision."""
|
|
33
|
+
|
|
34
|
+
PROCEED_FRESH = "proceed_fresh"
|
|
35
|
+
PROCEED_RESUME = "proceed_resume"
|
|
36
|
+
BLOCK_STALE_RUN = "block_stale_run"
|
|
37
|
+
BLOCK_CONFLICT = "block_conflict"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ResumeDecision:
|
|
42
|
+
"""Immutable result of a resume authority check."""
|
|
43
|
+
|
|
44
|
+
verdict: ResumeVerdict
|
|
45
|
+
reason: str
|
|
46
|
+
blocking_run_ids: list[str] = field(default_factory=list)
|
|
47
|
+
overlap_groups: list[str] = field(default_factory=list)
|
|
48
|
+
time_coverage: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ResumeAuthority(Protocol):
|
|
52
|
+
"""Contract for objects that can make resume decisions."""
|
|
53
|
+
|
|
54
|
+
def decide(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
product: str,
|
|
58
|
+
plugin: str,
|
|
59
|
+
slice_meta: dict[str, Any],
|
|
60
|
+
force_reingest: bool,
|
|
61
|
+
resume_existing: bool,
|
|
62
|
+
) -> ResumeDecision: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = ["ResumeAuthority", "ResumeDecision", "ResumeVerdict"]
|