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.
Files changed (197) hide show
  1. firecube/__init__.py +23 -0
  2. firecube/__main__.py +20 -0
  3. firecube/_version.py +46 -0
  4. firecube/cli/_audience.py +135 -0
  5. firecube/cli/_command_schemas.py +112 -0
  6. firecube/cli/_ctx.py +85 -0
  7. firecube/cli/_errors.py +115 -0
  8. firecube/cli/_formatter.py +195 -0
  9. firecube/cli/_product.py +47 -0
  10. firecube/cli/_rename_hints.py +123 -0
  11. firecube/cli/_shared_options.py +247 -0
  12. firecube/cli/_slot_env.py +102 -0
  13. firecube/cli/_slot_planning.py +95 -0
  14. firecube/cli/_typed_options.py +181 -0
  15. firecube/cli/_uri_policy.py +110 -0
  16. firecube/cli/advise.py +321 -0
  17. firecube/cli/archive.py +870 -0
  18. firecube/cli/catalog.py +196 -0
  19. firecube/cli/chunks/__init__.py +74 -0
  20. firecube/cli/chunks/_claims.py +229 -0
  21. firecube/cli/chunks/_common.py +84 -0
  22. firecube/cli/chunks/_delete.py +376 -0
  23. firecube/cli/chunks/_list.py +334 -0
  24. firecube/cli/chunks/_manager.py +161 -0
  25. firecube/cli/chunks/_runs.py +294 -0
  26. firecube/cli/chunks/_snapshots.py +165 -0
  27. firecube/cli/completion.py +73 -0
  28. firecube/cli/main.py +866 -0
  29. firecube/cli/parquet.py +289 -0
  30. firecube/cli/plugins/__init__.py +28 -0
  31. firecube/cli/plugins/commands.py +262 -0
  32. firecube/cli/plugins/introspect.py +304 -0
  33. firecube/cli/plugins/mgmt.py +198 -0
  34. firecube/cli/plugins/registry.py +132 -0
  35. firecube/cli/zarr.py +1043 -0
  36. firecube/core/__init__.py +14 -0
  37. firecube/core/api.py +88 -0
  38. firecube/core/cf/__init__.py +23 -0
  39. firecube/core/cf/check_ids.py +126 -0
  40. firecube/core/cf/report.py +57 -0
  41. firecube/core/cf/validator.py +313 -0
  42. firecube/core/config.py +307 -0
  43. firecube/core/controlplane/__init__.py +47 -0
  44. firecube/core/controlplane/_event_processor.py +136 -0
  45. firecube/core/controlplane/_helpers.py +114 -0
  46. firecube/core/controlplane/_projection.py +253 -0
  47. firecube/core/controlplane/_snapshot.py +215 -0
  48. firecube/core/controlplane/_wal_reader.py +402 -0
  49. firecube/core/controlplane/_wal_writer.py +589 -0
  50. firecube/core/controlplane/claims.py +294 -0
  51. firecube/core/controlplane/deletion.py +704 -0
  52. firecube/core/controlplane/events.py +219 -0
  53. firecube/core/controlplane/manager.py +990 -0
  54. firecube/core/controlplane/metrics.py +97 -0
  55. firecube/core/controlplane/repo.py +804 -0
  56. firecube/core/controlplane/repo_utils.py +73 -0
  57. firecube/core/controlplane/time_dim.py +111 -0
  58. firecube/core/controlplane/types.py +445 -0
  59. firecube/core/credentials.py +36 -0
  60. firecube/core/duckdb/__init__.py +27 -0
  61. firecube/core/duckdb/bridge.py +82 -0
  62. firecube/core/duckdb/io.py +30 -0
  63. firecube/core/errors.py +74 -0
  64. firecube/core/filesystem/__init__.py +60 -0
  65. firecube/core/filesystem/_obstore_compat.py +60 -0
  66. firecube/core/filesystem/fsspec_backend.py +308 -0
  67. firecube/core/filesystem/instrumentation.py +260 -0
  68. firecube/core/filesystem/obstore_backend.py +406 -0
  69. firecube/core/filesystem/ops.py +412 -0
  70. firecube/core/filesystem/protocol.py +130 -0
  71. firecube/core/filesystem/store_factory.py +149 -0
  72. firecube/core/formats/__init__.py +46 -0
  73. firecube/core/formats/_iso.py +84 -0
  74. firecube/core/formats/discovery.py +165 -0
  75. firecube/core/formats/hdf5.py +137 -0
  76. firecube/core/formats/netcdf.py +226 -0
  77. firecube/core/formats/zip.py +192 -0
  78. firecube/core/intake.py +530 -0
  79. firecube/core/observability/__init__.py +72 -0
  80. firecube/core/observability/_state.py +24 -0
  81. firecube/core/observability/logging.py +147 -0
  82. firecube/core/observability/metrics.py +230 -0
  83. firecube/core/observability/telemetry/__init__.py +87 -0
  84. firecube/core/observability/telemetry/pushgateway.py +339 -0
  85. firecube/core/observability/telemetry/sinks.py +155 -0
  86. firecube/core/observability/tracing.py +126 -0
  87. firecube/core/product/__init__.py +45 -0
  88. firecube/core/product/identity.py +76 -0
  89. firecube/core/product/resolver.py +121 -0
  90. firecube/core/product/target.py +120 -0
  91. firecube/core/runtime.py +187 -0
  92. firecube/core/slot_index.py +205 -0
  93. firecube/core/storage/__init__.py +45 -0
  94. firecube/core/storage/binding.py +39 -0
  95. firecube/core/storage/cache_key.py +40 -0
  96. firecube/core/storage/completion.py +245 -0
  97. firecube/core/storage/driver_config.py +125 -0
  98. firecube/core/storage/results.py +30 -0
  99. firecube/core/storage/session.py +431 -0
  100. firecube/core/storage/transfer.py +142 -0
  101. firecube/core/storage/uri.py +173 -0
  102. firecube/core/tensogram/__init__.py +37 -0
  103. firecube/core/tensogram/_compat.py +36 -0
  104. firecube/core/tensogram/controlplane_codec.py +230 -0
  105. firecube/core/tensogram/converter.py +390 -0
  106. firecube/core/tensogram/metadata.py +274 -0
  107. firecube/core/tensogram/restore.py +303 -0
  108. firecube/core/tensogram/schema.py +93 -0
  109. firecube/core/uris.py +182 -0
  110. firecube/core/workspaces.py +68 -0
  111. firecube/core/zarr/__init__.py +69 -0
  112. firecube/core/zarr/_reserved_attrs.py +47 -0
  113. firecube/core/zarr/_reserved_root_attrs.py +74 -0
  114. firecube/core/zarr/io.py +88 -0
  115. firecube/core/zarr/layers.py +184 -0
  116. firecube/core/zarr/multires.py +178 -0
  117. firecube/core/zarr/region_writer.py +782 -0
  118. firecube/core/zarr/scrub.py +200 -0
  119. firecube/core/zarr/state.py +258 -0
  120. firecube/core/zarr/time_decode.py +71 -0
  121. firecube/core/zarr/validation.py +622 -0
  122. firecube/ingestor/__init__.py +27 -0
  123. firecube/ingestor/api.py +215 -0
  124. firecube/ingestor/config/__init__.py +19 -0
  125. firecube/ingestor/config/coercion.py +157 -0
  126. firecube/ingestor/config/engine.py +188 -0
  127. firecube/ingestor/contracts/__init__.py +14 -0
  128. firecube/ingestor/contracts/interfaces.py +235 -0
  129. firecube/ingestor/devtools/_templates/__init__.py +14 -0
  130. firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
  131. firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
  132. firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
  133. firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
  134. firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
  135. firecube/ingestor/devtools/scaffolding.py +229 -0
  136. firecube/ingestor/errors.py +79 -0
  137. firecube/ingestor/extensions/_binning.py +152 -0
  138. firecube/ingestor/extensions/duck.py +153 -0
  139. firecube/ingestor/extensions/grid.py +287 -0
  140. firecube/ingestor/extensions/healpix.py +330 -0
  141. firecube/ingestor/registry/__init__.py +14 -0
  142. firecube/ingestor/registry/loader.py +144 -0
  143. firecube/ingestor/registry/metadata.py +97 -0
  144. firecube/ingestor/registry/version_compat.py +95 -0
  145. firecube/ingestor/runtime/__init__.py +14 -0
  146. firecube/ingestor/runtime/aggregation.py +112 -0
  147. firecube/ingestor/runtime/base.py +836 -0
  148. firecube/ingestor/runtime/base_hooks.py +129 -0
  149. firecube/ingestor/runtime/batching.py +161 -0
  150. firecube/ingestor/runtime/configure.py +195 -0
  151. firecube/ingestor/runtime/coverage.py +177 -0
  152. firecube/ingestor/runtime/engine.py +793 -0
  153. firecube/ingestor/runtime/parallel_evidence.py +75 -0
  154. firecube/ingestor/runtime/parallel_execution_state.py +28 -0
  155. firecube/ingestor/runtime/parallel_gate.py +164 -0
  156. firecube/ingestor/runtime/parallel_run_id.py +48 -0
  157. firecube/ingestor/runtime/preflight.py +40 -0
  158. firecube/ingestor/runtime/recording.py +425 -0
  159. firecube/ingestor/runtime/resume_guard.py +556 -0
  160. firecube/ingestor/runtime/resume_types.py +65 -0
  161. firecube/ingestor/runtime/telemetry.py +171 -0
  162. firecube/ingestor/runtime/tensogram/__init__.py +14 -0
  163. firecube/ingestor/runtime/tensogram/strategy.py +164 -0
  164. firecube/ingestor/runtime/workspace.py +220 -0
  165. firecube/ingestor/runtime/zarr/__init__.py +34 -0
  166. firecube/ingestor/runtime/zarr/append.py +466 -0
  167. firecube/ingestor/runtime/zarr/append_services.py +650 -0
  168. firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
  169. firecube/ingestor/runtime/zarr/contracts.py +58 -0
  170. firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
  171. firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
  172. firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
  173. firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
  174. firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
  175. firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
  176. firecube/ingestor/runtime/zarr/write.py +212 -0
  177. firecube/ingestor/runtime/zarr/write_context.py +139 -0
  178. firecube/ingestor/templates/__init__.py +19 -0
  179. firecube/ingestor/templates/config.py +118 -0
  180. firecube/ingestor/templates/direct_zarr.py +790 -0
  181. firecube/ingestor/templates/generic.py +462 -0
  182. firecube/ingestor/templates/generic_tensogram.py +170 -0
  183. firecube/ingestor/types/__init__.py +19 -0
  184. firecube/ingestor/types/config.py +89 -0
  185. firecube/ingestor/types/context.py +409 -0
  186. firecube/ingestor/types/manifest.py +53 -0
  187. firecube/ingestor/types/planned_range.py +152 -0
  188. firecube/ingestor/types/result_metrics.py +386 -0
  189. firecube/ingestor/utils/__init__.py +14 -0
  190. firecube/ingestor/utils/duckdb_utils.py +235 -0
  191. firecube/ingestor/validation.py +55 -0
  192. firecube-0.1.4.dist-info/METADATA +242 -0
  193. firecube-0.1.4.dist-info/RECORD +197 -0
  194. firecube-0.1.4.dist-info/WHEEL +4 -0
  195. firecube-0.1.4.dist-info/entry_points.txt +2 -0
  196. firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
  197. 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"]