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,990 @@
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
+ """ChunkManager facade for Firecube chunk state under the `.firecube/` control-plane root."""
16
+
17
+ from __future__ import annotations
18
+
19
+ # pyright: reportCallIssue=false, reportAttributeAccessIssue=false
20
+ import json
21
+ import logging
22
+ import random
23
+ import time
24
+ from collections.abc import Callable, Generator, Iterable
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from firecube.core.config import StorageConfig
30
+ from firecube.core.controlplane.deletion import DeletionEngine
31
+ from firecube.core.controlplane.repo import ManifestRepository
32
+ from firecube.core.controlplane.types import (
33
+ EVENT_SLOT_INDEX_MODEL_RECORDED,
34
+ EVENT_SLOT_INDEX_MODEL_VERIFIED,
35
+ SLOT_INDEX_CURRENT_FILENAME,
36
+ SLOT_INDEX_DIRNAME,
37
+ AbandonSweepResult,
38
+ ChunkInfo,
39
+ ClaimInfo,
40
+ ClearSweepResult,
41
+ DeletionPlan,
42
+ RunInfo,
43
+ SlotIndexModelRecord,
44
+ SpanCoverage,
45
+ WriteDomain,
46
+ )
47
+ from firecube.core.errors import (
48
+ ClaimConflictError,
49
+ ManifestError,
50
+ SlotIndexModelClaimTimeoutError,
51
+ SlotIndexModelConflictError,
52
+ SlotIndexUnmanagedStoreError,
53
+ )
54
+ from firecube.core.filesystem import StorageFilesystem
55
+ from firecube.core.slot_index import (
56
+ SLOT_INDEX_MODEL_ATTR,
57
+ SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR,
58
+ SlotIndexModel,
59
+ )
60
+ from firecube.core.storage.binding import StorageBinding
61
+
62
+ log = logging.getLogger(__name__)
63
+
64
+
65
+ def _format_duration(seconds: float) -> str:
66
+ if seconds < 60:
67
+ return f"{seconds:.0f}s"
68
+ if seconds < 3600:
69
+ return f"{seconds / 60:.0f}m"
70
+ if seconds < 86400:
71
+ return f"{seconds / 3600:.0f}h {(seconds % 3600) / 60:.0f}m"
72
+ return f"{seconds / 86400:.0f}d {(seconds % 86400) / 3600:.0f}h"
73
+
74
+
75
+ def _slice_dedupe_key(product: str, span_meta: dict[str, Any]) -> tuple[str, Any, Any, Any] | None:
76
+ group = span_meta.get("group")
77
+ t_min = span_meta.get("time_min")
78
+ t_max = span_meta.get("time_max")
79
+ if group is None or t_min is None or t_max is None:
80
+ return None
81
+ return (product, group, t_min, t_max)
82
+
83
+
84
+ def _dedupe_active_spans(chunks: list[ChunkInfo]) -> list[ChunkInfo]:
85
+ """Drop duplicate active spans that cover the same slice during force-reingest in-flight.
86
+
87
+ During the brief window when a force-reingest run has emitted its new
88
+ span (``status="active"``) but has not yet emitted
89
+ ``replacement_committed`` for the prior span, both spans appear active.
90
+ To avoid showing double coverage in the read models, this helper keeps
91
+ the span with the highest ``run_id`` per ``(product, group, time_min,
92
+ time_max)`` key and drops the others. ``run_id`` values are
93
+ UUID/timestamp strings, so lexicographic comparison is deterministic.
94
+
95
+ Non-span chunks and spans whose status is not ``"active"`` pass through
96
+ unchanged. Active spans missing any of ``group``/``time_min``/
97
+ ``time_max`` in their meta also pass through (they cannot be grouped
98
+ into a slice key).
99
+ """
100
+ winners: dict[tuple[str, Any, Any, Any], str] = {}
101
+ for chunk in chunks:
102
+ if chunk.chunk_type != "span" or chunk.status != "active":
103
+ continue
104
+ meta = chunk.meta or {}
105
+ key = _slice_dedupe_key(chunk.product, meta)
106
+ if key is None:
107
+ continue
108
+ run_id = str(meta.get("run_id", ""))
109
+ if winners.get(key, "") < run_id:
110
+ winners[key] = run_id
111
+
112
+ deduped: list[ChunkInfo] = []
113
+ for chunk in chunks:
114
+ if chunk.chunk_type != "span" or chunk.status != "active":
115
+ deduped.append(chunk)
116
+ continue
117
+ meta = chunk.meta or {}
118
+ key = _slice_dedupe_key(chunk.product, meta)
119
+ if key is None:
120
+ deduped.append(chunk)
121
+ continue
122
+ if winners.get(key) == str(meta.get("run_id", "")):
123
+ deduped.append(chunk)
124
+ return deduped
125
+
126
+
127
+ class ChunkManager:
128
+ """Public facade for managing Firecube chunk state."""
129
+
130
+ __slots__ = ("_filesystem", "deletion_engine", "log", "product_name", "repo", "workspace")
131
+
132
+ def __init__(
133
+ self,
134
+ binding: StorageBinding,
135
+ workspace: Path | None = None,
136
+ *,
137
+ filesystem: StorageFilesystem | None = None,
138
+ time_dim_name: str = "timestamp",
139
+ ) -> None:
140
+ import tempfile
141
+
142
+ if workspace is None:
143
+ workspace = Path(tempfile.mkdtemp(prefix="firecube_chunks_"))
144
+
145
+ self.workspace = workspace
146
+ self._filesystem = filesystem
147
+ self.repo = ManifestRepository(
148
+ binding=binding,
149
+ workspace=workspace,
150
+ filesystem=filesystem,
151
+ )
152
+ self.log = logging.getLogger(f"{__name__}.ChunkManager")
153
+ self.deletion_engine = DeletionEngine(
154
+ self.repo, filesystem=filesystem, time_dim_name=time_dim_name
155
+ )
156
+
157
+ @property
158
+ def base_uri(self) -> str:
159
+ """Current base URI for product storage."""
160
+ return self.repo.base_uri
161
+
162
+ @property
163
+ def storage_config(self) -> Any | None:
164
+ """Current storage configuration (e.g. S3 credentials)."""
165
+ return self.repo.storage_config
166
+
167
+ def get_product_root(self, product: str) -> str:
168
+ """Return the resolved product root URI."""
169
+ return self.repo.get_product_root_uri(product)
170
+
171
+ def get_control_root(self, product: str) -> str:
172
+ """Return the resolved .firecube/ control root URI."""
173
+ return self.repo.get_control_root_uri(product)
174
+
175
+ def get_latest_pointer(self, product: str) -> str:
176
+ """Return the resolved LATEST.json pointer URI."""
177
+ return self.repo.get_latest_pointer_uri(product)
178
+
179
+ def close(self) -> None:
180
+ """Close the underlying repository, flushing writers and releasing resources."""
181
+ if hasattr(self, "repo"):
182
+ self.repo.close()
183
+
184
+ def record_run_started(
185
+ self,
186
+ *,
187
+ product: str,
188
+ run_id: str,
189
+ output_path: str,
190
+ output_format: str,
191
+ size: int,
192
+ meta: dict[str, Any],
193
+ slot_range: tuple[int, int] | None = None,
194
+ slot_group: str | None = None,
195
+ ) -> None:
196
+ """Record the start of a new ingestion run."""
197
+ self.repo.record_run_started(
198
+ product=product,
199
+ run_id=run_id,
200
+ output_path=output_path,
201
+ output_format=output_format,
202
+ size=size,
203
+ meta=meta,
204
+ slot_range=slot_range,
205
+ slot_group=slot_group,
206
+ )
207
+
208
+ def record_run_terminal(
209
+ self,
210
+ *,
211
+ product: str,
212
+ run_id: str,
213
+ output_path: str,
214
+ output_format: str,
215
+ size: int,
216
+ meta: dict[str, Any],
217
+ status: str,
218
+ error: str | None = None,
219
+ slot_range: tuple[int, int] | None = None,
220
+ slot_group: str | None = None,
221
+ ) -> None:
222
+ """Record a terminal run state (complete/failed/abandoned)."""
223
+ self.repo.record_run_terminal(
224
+ product=product,
225
+ run_id=run_id,
226
+ output_path=output_path,
227
+ output_format=output_format,
228
+ size=size,
229
+ meta=meta,
230
+ status=status,
231
+ error=error,
232
+ slot_range=slot_range,
233
+ slot_group=slot_group,
234
+ )
235
+
236
+ def record_run_started_with_replacement(
237
+ self,
238
+ *,
239
+ product: str,
240
+ run_id: str,
241
+ replaces: list[str],
242
+ ) -> None:
243
+ """Record informational replacement intent for a run."""
244
+ self.repo.record_run_started_with_replacement(
245
+ product=product,
246
+ run_id=run_id,
247
+ replaces=replaces,
248
+ )
249
+
250
+ def record_replacement_committed(
251
+ self,
252
+ *,
253
+ product: str,
254
+ run_id: str,
255
+ replacing_run_id: str,
256
+ replaced_span_keys: list[str],
257
+ ) -> None:
258
+ """Record terminal replacement commit for prior spans."""
259
+ self.repo.record_replacement_committed(
260
+ product=product,
261
+ run_id=run_id,
262
+ replacing_run_id=replacing_run_id,
263
+ replaced_span_keys=replaced_span_keys,
264
+ )
265
+
266
+ def record_maintenance_started(
267
+ self,
268
+ *,
269
+ product: str,
270
+ run_id: str,
271
+ op: str,
272
+ scope_meta: dict[str, Any] | None = None,
273
+ ) -> None:
274
+ """Record the start of a maintenance run (delete/scrub/archive_restore)."""
275
+ self.repo.record_maintenance_started(
276
+ product=product,
277
+ run_id=run_id,
278
+ op=op,
279
+ scope_meta=scope_meta,
280
+ )
281
+
282
+ def record_maintenance_completed(
283
+ self,
284
+ *,
285
+ product: str,
286
+ run_id: str,
287
+ op: str,
288
+ scope_meta: dict[str, Any] | None = None,
289
+ ) -> None:
290
+ """Record terminal completion of a maintenance run."""
291
+ self.repo.record_maintenance_completed(
292
+ product=product,
293
+ run_id=run_id,
294
+ op=op,
295
+ scope_meta=scope_meta,
296
+ )
297
+
298
+ def record_maintenance_failed(
299
+ self,
300
+ *,
301
+ product: str,
302
+ run_id: str,
303
+ op: str,
304
+ scope_meta: dict[str, Any] | None = None,
305
+ error: str,
306
+ ) -> None:
307
+ """Record terminal failure of a maintenance run."""
308
+ self.repo.record_maintenance_failed(
309
+ product=product,
310
+ run_id=run_id,
311
+ op=op,
312
+ scope_meta=scope_meta,
313
+ error=error,
314
+ )
315
+
316
+ def record_run_failed(
317
+ self,
318
+ *,
319
+ product: str,
320
+ run_id: str,
321
+ output_path: str,
322
+ output_format: str,
323
+ size: int,
324
+ meta: dict[str, Any],
325
+ error: str,
326
+ ) -> None:
327
+ """Record a failed run (convenience wrapper for record_run_terminal with status=failed)."""
328
+ self.repo.record_run_terminal(
329
+ product=product,
330
+ run_id=run_id,
331
+ output_path=output_path,
332
+ output_format=output_format,
333
+ size=size,
334
+ meta=meta,
335
+ status="failed",
336
+ error=error,
337
+ )
338
+
339
+ def record_span(
340
+ self,
341
+ product: str,
342
+ run_id: str,
343
+ batch_id: str,
344
+ group: str,
345
+ status: str,
346
+ reason: str | None = None,
347
+ coverage: SpanCoverage | None = None,
348
+ meta: dict[str, Any] | None = None,
349
+ ) -> None:
350
+ """Record a span event (chunk write or state change) within a run."""
351
+ self.repo.record_span_event(
352
+ product=product,
353
+ run_id=run_id,
354
+ batch_id=batch_id,
355
+ group=group,
356
+ status=status,
357
+ reason=reason,
358
+ coverage=coverage,
359
+ meta=meta,
360
+ )
361
+
362
+ def record_schema_verification(
363
+ self,
364
+ product: str,
365
+ run_id: str,
366
+ group: str,
367
+ plugin: str,
368
+ schema_hash: str,
369
+ verified_at: str,
370
+ expected_time_count: int,
371
+ meta: dict[str, Any] | None = None,
372
+ ) -> None:
373
+ """Record a schema verification audit event for observability.
374
+
375
+ Audit records are NOT consulted for skip logic. Each pod writes its own
376
+ record (verified_at differs).
377
+ """
378
+ self.repo.record_schema_verification_event(
379
+ product=product,
380
+ run_id=run_id,
381
+ group=group,
382
+ plugin=plugin,
383
+ schema_hash=schema_hash,
384
+ verified_at=verified_at,
385
+ expected_time_count=expected_time_count,
386
+ meta=meta,
387
+ )
388
+
389
+ def discover_manifests(self) -> list[str]:
390
+ """Scan the workspace for products with control-plane roots."""
391
+ return self.repo.discover_manifests()
392
+
393
+ def parse_manifest(self, manifest_uri: str) -> Generator[ChunkInfo, None, None]:
394
+ """Yield ChunkInfo records from a manifest URI."""
395
+ return self.repo.parse_manifest(manifest_uri)
396
+
397
+ def list_chunks(
398
+ self,
399
+ pattern: str | None = None,
400
+ product: str | None = None,
401
+ before: datetime | None = None,
402
+ after: datetime | None = None,
403
+ chunk_type: str | None = None,
404
+ status: str | None = None,
405
+ include_replaced: bool = False,
406
+ *,
407
+ meta: dict[str, Any] | None = None,
408
+ time_min_after: str | None = None,
409
+ time_max_before: str | None = None,
410
+ time_overlaps: tuple[str, str] | None = None,
411
+ filter_fn: Callable[[ChunkInfo], bool] | None = None,
412
+ ) -> list[ChunkInfo]:
413
+ """Return projected chunk records with optional filtering.
414
+
415
+ During a force-reingest run there is a brief window where both prior
416
+ spans and new spans have ``status="active"`` (before
417
+ ``replacement_committed`` lands). To prevent double coverage in
418
+ callers, this method deduplicates active spans by
419
+ ``(product, group, time_min, time_max)``: when multiple active spans
420
+ share the same slice key, only the span with the highest ``run_id``
421
+ (lexicographic order — UUID/timestamp run_ids make this
422
+ deterministic) is returned. Non-span records and
423
+ ``replaced``/``failed`` spans pass through unchanged, so this is a
424
+ no-op outside the force-reingest in-flight window.
425
+ """
426
+ chunks = self.repo.list_chunks(
427
+ pattern=pattern,
428
+ product=product,
429
+ before=before,
430
+ after=after,
431
+ chunk_type=chunk_type,
432
+ status=status,
433
+ include_replaced=include_replaced,
434
+ meta=meta,
435
+ time_min_after=time_min_after,
436
+ time_max_before=time_max_before,
437
+ time_overlaps=time_overlaps,
438
+ filter_fn=filter_fn,
439
+ )
440
+ return _dedupe_active_spans(chunks)
441
+
442
+ def mark_chunks_replaced(
443
+ self, chunk_keys: list[str], product: str, timestamp: float
444
+ ) -> dict[str, Any]:
445
+ """Mark chunk records as replaced with a timestamp."""
446
+ return self.repo.mark_chunks_replaced(chunk_keys, product, timestamp)
447
+
448
+ def list_runs(
449
+ self,
450
+ *,
451
+ product: str,
452
+ status: str | None = None,
453
+ non_terminal: bool = False,
454
+ ) -> list[RunInfo]:
455
+ """List runs for a product with optional status/terminal filtering."""
456
+ return self.repo.list_runs(product=product, status=status, non_terminal=non_terminal)
457
+
458
+ def time_coverage_summary(
459
+ self, product: str, *, meta: dict[str, Any] | None = None
460
+ ) -> list[dict[str, Any]]:
461
+ """Return per-group time range boundaries and span counts for a product.
462
+
463
+ Each entry is ``{"group": str, "time_min": str, "time_max": str,
464
+ "span_count": int, "total_timestamps_written": int}``, sorted by group.
465
+ Used by ``ResumeGuard`` for overlap detection and by operators for
466
+ coverage diagnostics.
467
+
468
+ Inherits the active-span dedupe from :meth:`list_chunks`: during a
469
+ force-reingest in-flight window, only the highest-``run_id`` active
470
+ span per slice contributes to the per-group totals, so coverage is
471
+ not double-counted.
472
+ """
473
+ spans = self.list_chunks(
474
+ product=product, chunk_type="span", include_replaced=False, meta=meta
475
+ )
476
+ groups: dict[str, dict[str, Any]] = {}
477
+ for span in spans:
478
+ span_meta = span.meta or {}
479
+ group = span_meta.get("group", "unknown")
480
+ t_min = span_meta.get("time_min")
481
+ t_max = span_meta.get("time_max")
482
+ tw = 0
483
+ if isinstance(span.record, dict):
484
+ span_data = span.record.get("span", {})
485
+ if isinstance(span_data, dict):
486
+ tw = int(span_data.get("timestamps_written", 0))
487
+
488
+ if group not in groups:
489
+ groups[group] = {
490
+ "group": group,
491
+ "time_min": t_min,
492
+ "time_max": t_max,
493
+ "span_count": 0,
494
+ "total_timestamps_written": 0,
495
+ }
496
+ entry = groups[group]
497
+ entry["span_count"] += 1
498
+ entry["total_timestamps_written"] += tw
499
+ if t_min and (entry["time_min"] is None or t_min < entry["time_min"]):
500
+ entry["time_min"] = t_min
501
+ if t_max and (entry["time_max"] is None or t_max > entry["time_max"]):
502
+ entry["time_max"] = t_max
503
+ return sorted(groups.values(), key=lambda x: x["group"])
504
+
505
+ def abandon_run(
506
+ self,
507
+ *,
508
+ product: str,
509
+ run_id: str,
510
+ reason: str,
511
+ meta: dict[str, Any] | None = None,
512
+ ) -> dict[str, Any]:
513
+ """Mark a non-terminal run as abandoned (control-plane only, no data cleanup)."""
514
+ return self.repo.abandon_run(
515
+ product=product,
516
+ run_id=run_id,
517
+ reason=reason,
518
+ meta=meta,
519
+ )
520
+
521
+ def create_deletion_plan(
522
+ self,
523
+ pattern: str | None = None,
524
+ product: str | None = None,
525
+ before: datetime | None = None,
526
+ after: datetime | None = None,
527
+ chunk_type: str | None = None,
528
+ status: str | None = None,
529
+ include_metadata: bool = False,
530
+ *,
531
+ meta: dict[str, Any] | None = None,
532
+ filter_fn: Callable[[ChunkInfo], bool] | None = None,
533
+ ) -> DeletionPlan:
534
+ """Build a deletion plan for chunks matching the given criteria."""
535
+ return self.deletion_engine.create_deletion_plan(
536
+ pattern,
537
+ product,
538
+ before,
539
+ after,
540
+ chunk_type,
541
+ status,
542
+ include_metadata,
543
+ meta=meta,
544
+ filter_fn=filter_fn,
545
+ )
546
+
547
+ def execute_deletion(
548
+ self,
549
+ plan: DeletionPlan,
550
+ delete_storage: bool = True,
551
+ delete_manifest: bool = True,
552
+ storage_config: StorageConfig | None = None,
553
+ dry_run: bool = False,
554
+ ) -> dict[str, Any]:
555
+ """Execute a deletion plan, optionally removing storage and manifest entries."""
556
+ return self.deletion_engine.execute_deletion(
557
+ plan,
558
+ delete_storage,
559
+ delete_manifest,
560
+ storage_config,
561
+ dry_run,
562
+ )
563
+
564
+ def delete_spans(
565
+ self,
566
+ spans: Iterable[ChunkInfo],
567
+ *,
568
+ dry_run: bool = False,
569
+ force: bool = False,
570
+ update_manifest: bool = True,
571
+ update_state: bool = True,
572
+ time_dim_name: str | None = None,
573
+ ) -> dict[str, Any]:
574
+ """Delete specific span records with optional dry-run and manifest updates."""
575
+ return self.deletion_engine.delete_spans(
576
+ spans,
577
+ dry_run=dry_run,
578
+ force=force,
579
+ update_manifest=update_manifest,
580
+ update_state=update_state,
581
+ time_dim_name=time_dim_name,
582
+ )
583
+
584
+ def rebuild_snapshot(self, product: str) -> dict[str, Any]:
585
+ """Compact completed-run WAL events into a snapshot for faster reads."""
586
+ return self.repo.rebuild_snapshot(product)
587
+
588
+ def snapshot_status(self, product: str) -> dict[str, Any]:
589
+ self.repo._ensure_bound()
590
+ latest = self.repo._read_latest_pointer(product)
591
+ if latest is None:
592
+ return {"exists": False}
593
+
594
+ cutoff = float(latest.get("completed_before", 0.0) or 0.0)
595
+ age_seconds = time.time() - cutoff if cutoff > 0 else 0.0
596
+ completed_before_iso: str | None = None
597
+ if cutoff > 0:
598
+ completed_before_iso = datetime.fromtimestamp(cutoff, tz=UTC).isoformat()
599
+
600
+ snapshot_meta_path = str(latest.get("snapshot_meta_path") or "")
601
+ records = 0
602
+ repo_fs = self.repo._fs
603
+ if snapshot_meta_path and repo_fs is not None:
604
+ try:
605
+ with repo_fs.open(snapshot_meta_path, "r") as fh:
606
+ meta = json.load(fh)
607
+ records = int(meta.get("records", meta.get("record_count", 0)))
608
+ except Exception:
609
+ records = 0
610
+
611
+ return {
612
+ "exists": True,
613
+ "completed_before": completed_before_iso,
614
+ "age_seconds": age_seconds,
615
+ "age_human": _format_duration(age_seconds),
616
+ "generation": latest.get("generation"),
617
+ "records": records,
618
+ }
619
+
620
+ def acquire_claim(self, *, product: str, domain: WriteDomain, owner_id: str):
621
+ """Acquire an exclusive write claim for a domain."""
622
+ return self.repo.acquire_claim(product=product, domain=domain, owner_id=owner_id)
623
+
624
+ def list_claims(self, *, product: str | None = None):
625
+ """List active write claims, optionally filtered by product."""
626
+ return self.repo.list_claims(product=product)
627
+
628
+ def list_stale_claims(self, *, product: str) -> list[ClaimInfo]:
629
+ """List stale write claims for one product."""
630
+ return self.repo.list_stale_claims(product=product)
631
+
632
+ def list_stale_runs(self, *, product: str) -> list[RunInfo]:
633
+ """List stale non-terminal runs for one product."""
634
+ return self.repo.list_stale_runs(product=product)
635
+
636
+ def clear_claim(self, *, product: str, domain_id: str, force: bool = False) -> bool:
637
+ """Release a write claim by domain ID."""
638
+ return self.repo.clear_claim(product=product, domain_id=domain_id, force=force)
639
+
640
+ def clear_stale_claims(self, *, product: str, dry_run: bool = True) -> ClearSweepResult:
641
+ """Bulk-clear stale write claims for a product with mutation-time re-check.
642
+
643
+ Between preview (``list_stale_claims``) and mutation (``clear_claim``),
644
+ a live pod could refresh a previously stale claim or another operator
645
+ could delete it. Each mutation re-reads the current state via
646
+ ``list_claims`` and only clears domains that remain stale AND present;
647
+ live claims are never forcibly overridden. Errors are collected
648
+ per-claim, and the sweep continues past any single-claim race.
649
+ """
650
+ result = ClearSweepResult()
651
+ stale = self.list_stale_claims(product=product)
652
+ result.previewed = [claim.domain for claim in stale]
653
+ if dry_run:
654
+ return result
655
+ for claim in stale:
656
+ fresh = self.list_claims(product=product)
657
+ current = next((c for c in fresh if c.domain == claim.domain), None)
658
+ if current is None:
659
+ result.skipped_missing.append(claim.domain)
660
+ continue
661
+ if not current.stale:
662
+ result.skipped_fresh.append(claim.domain)
663
+ continue
664
+ try:
665
+ cleared = self.clear_claim(product=product, domain_id=claim.domain, force=False)
666
+ except ClaimConflictError:
667
+ result.skipped_fresh.append(claim.domain)
668
+ continue
669
+ if cleared:
670
+ result.cleared.append(claim.domain)
671
+ else:
672
+ result.skipped_missing.append(claim.domain)
673
+ return result
674
+
675
+ def abandon_stale_runs(
676
+ self, *, product: str, reason: str, dry_run: bool = True
677
+ ) -> AbandonSweepResult:
678
+ """Bulk-abandon stale non-terminal runs for a product with mutation-time re-check.
679
+
680
+ Between preview (``list_stale_runs``) and mutation (``abandon_run``), a
681
+ live pod could refresh the run's heartbeat or the run could reach a
682
+ terminal status. Each mutation re-reads the current state via
683
+ ``list_runs`` and only abandons runs that remain stale AND non-terminal.
684
+ Runs already terminal are recorded as ``skipped_already_terminal``;
685
+ runs whose heartbeat refreshed are recorded as ``skipped_fresh``. The
686
+ sweep continues past any single-run race so one live pod cannot block
687
+ cleanup of unrelated stale runs.
688
+ """
689
+ result = AbandonSweepResult()
690
+ stale = self.list_stale_runs(product=product)
691
+ result.previewed = [run.run_id for run in stale]
692
+ if dry_run:
693
+ return result
694
+ for run in stale:
695
+ fresh = self.list_runs(product=product)
696
+ current = next((r for r in fresh if r.run_id == run.run_id), None)
697
+ if current is None or current.is_terminal:
698
+ result.skipped_already_terminal.append(run.run_id)
699
+ continue
700
+ if not current.stale:
701
+ result.skipped_fresh.append(run.run_id)
702
+ continue
703
+ try:
704
+ outcome = self.abandon_run(product=product, run_id=run.run_id, reason=reason)
705
+ except ManifestError:
706
+ result.skipped_already_terminal.append(run.run_id)
707
+ continue
708
+ if outcome.get("abandoned"):
709
+ result.abandoned.append(run.run_id)
710
+ else:
711
+ result.skipped_already_terminal.append(run.run_id)
712
+ return result
713
+
714
+ # ------------------------------------------------------------------
715
+ # Slot-index model: control-plane authoritative record + attrs mirror.
716
+ # ------------------------------------------------------------------
717
+
718
+ def get_slot_index_model(self, *, product: str) -> SlotIndexModelRecord | None:
719
+ """Read the stored slot-index model record, or ``None`` if absent.
720
+
721
+ Returns ``None`` when the on-disk ``current.json`` is missing. Other
722
+ I/O errors propagate. ``ManifestError`` propagates on corruption (the
723
+ on-disk record's identity-hash cross-check is enforced by
724
+ :meth:`SlotIndexModelRecord.from_json_bytes`).
725
+ """
726
+ control_root_uri = self.repo.get_control_root_uri(product)
727
+ _fs, control_root = self.repo._get_fs(control_root_uri)
728
+ current_json = control_root.join(SLOT_INDEX_DIRNAME).join(SLOT_INDEX_CURRENT_FILENAME)
729
+ try:
730
+ with _fs.open(current_json, "rb") as fh:
731
+ data = fh.read()
732
+ except FileNotFoundError:
733
+ return None
734
+ return SlotIndexModelRecord.from_json_bytes(data)
735
+
736
+ def ensure_slot_index_model(
737
+ self,
738
+ *,
739
+ product: str,
740
+ model: SlotIndexModel,
741
+ run_id: str,
742
+ max_retries: int = 5,
743
+ initial_backoff_s: float = 0.1,
744
+ ) -> SlotIndexModelRecord:
745
+ """Negotiate the slot-index model for ``product``, returning the persisted record.
746
+
747
+ Acquires the ``slot_index_model:current`` write claim and dispatches into
748
+ :meth:`_apply_slot_model_precedence`. Loser threads (``ClaimConflictError``)
749
+ inspect full convergence: ``current.json`` AND the zarr root identity-hash
750
+ attribute must both match the declared model before the loser emits
751
+ ``EVENT_SLOT_INDEX_MODEL_VERIFIED`` and returns. CP-only match is the race
752
+ window state (winner mid-write) and is NOT a convergence signal; the loop
753
+ keeps retrying with jittered exponential backoff until the budget is spent.
754
+ """
755
+ if not run_id:
756
+ raise ValueError("run_id must be non-empty")
757
+ domain = WriteDomain(product=product, category="slot_index_model", name="current")
758
+ backoff = float(initial_backoff_s)
759
+ for attempt in range(int(max_retries) + 1):
760
+ try:
761
+ with self.acquire_claim(product=product, domain=domain, owner_id=run_id):
762
+ return self._apply_slot_model_precedence(product, model, run_id)
763
+ except ClaimConflictError:
764
+ cp_record = self.get_slot_index_model(product=product)
765
+ if cp_record is not None and cp_record.identity_hash != model.identity_hash:
766
+ raise SlotIndexModelConflictError(
767
+ "concurrent write detected with incompatible model: "
768
+ f"stored={cp_record.identity_hash[:16]} "
769
+ f"declared={model.identity_hash[:16]}"
770
+ ) from None
771
+ if cp_record is not None:
772
+ attrs_hash = self._read_slot_index_attrs_hash(product=product)
773
+ if attrs_hash == model.identity_hash:
774
+ self.repo.record_slot_index_model_event(
775
+ product=product,
776
+ run_id=run_id,
777
+ event_type=EVENT_SLOT_INDEX_MODEL_VERIFIED,
778
+ identity_hash=model.identity_hash,
779
+ model_name=model.name,
780
+ )
781
+ return cp_record
782
+ if attempt == int(max_retries):
783
+ raise SlotIndexModelClaimTimeoutError(
784
+ f"slot_index_model claim held for >{max_retries} retries; "
785
+ "full convergence (CP+attrs) not observed within retry budget"
786
+ ) from None
787
+ time.sleep(backoff + random.random() * backoff)
788
+ backoff *= 2
789
+ raise SlotIndexModelClaimTimeoutError(
790
+ "exhausted retries without converging on slot_index_model"
791
+ )
792
+
793
+ def _apply_slot_model_precedence(
794
+ self, product: str, model: SlotIndexModel, run_id: str
795
+ ) -> SlotIndexModelRecord:
796
+ """Apply the 6-row fresh-store precedence matrix.
797
+
798
+ Must be called INSIDE the ``slot_index_model:current`` claim. The matrix
799
+ is the only legitimate writer of ``current.json`` and the reserved zarr
800
+ root attrs. Rows are enumerated explicitly to keep the decision table
801
+ auditable from the source:
802
+
803
+ +-----+----+-------+--------+-------------------------------------------+
804
+ | Row | CP | Attrs | Plugin | Action |
805
+ +=====+====+=======+========+===========================================+
806
+ | 1 | - | - | X | Write CP + mirror attrs + emit RECORDED |
807
+ | 2 | X | X | X | Happy path; emit VERIFIED |
808
+ | 3 | X | - | X | Crash-recovery: re-mirror attrs + VERIFIED|
809
+ | 4 | X | Y | X | Drift detected; refuse |
810
+ | 5 | X | * | Y | Plugin incompatibility; refuse |
811
+ | 6 | - | present| X | Unmanaged store; refuse |
812
+ +-----+----+-------+--------+-------------------------------------------+
813
+ """
814
+ cp_record = self.get_slot_index_model(product=product)
815
+ attrs_hash = self._read_slot_index_attrs_hash(product=product)
816
+
817
+ if cp_record is None and attrs_hash is None:
818
+ # Row 1: fresh store, declare + mirror + RECORDED.
819
+ recorded_at = datetime.now(tz=UTC).isoformat()
820
+ new_record = SlotIndexModelRecord(
821
+ model=model,
822
+ identity_hash=model.identity_hash,
823
+ schema_version="v1",
824
+ recorded_at=recorded_at,
825
+ recorded_by_run_id=run_id,
826
+ )
827
+ control_root_uri = self.repo.get_control_root_uri(product)
828
+ _fs, control_root = self.repo._get_fs(control_root_uri)
829
+ slot_index_dir = control_root.join(SLOT_INDEX_DIRNAME)
830
+ current_json = slot_index_dir.join(SLOT_INDEX_CURRENT_FILENAME)
831
+ try:
832
+ _fs.makedirs(slot_index_dir, exist_ok=True)
833
+ except Exception:
834
+ self.log.debug(
835
+ "slot_index_model.makedirs_noop",
836
+ extra={"product": product, "path": str(slot_index_dir)},
837
+ )
838
+ _fs.atomic_writer.write_atomic(current_json, new_record.to_json_bytes())
839
+ self._mirror_attrs(product, new_record)
840
+ self.repo.record_slot_index_model_event(
841
+ product=product,
842
+ run_id=run_id,
843
+ event_type=EVENT_SLOT_INDEX_MODEL_RECORDED,
844
+ identity_hash=model.identity_hash,
845
+ model_name=model.name,
846
+ )
847
+ self.log.info(
848
+ "slot_index_model.recorded",
849
+ extra={
850
+ "product": product,
851
+ "identity_hash": model.identity_hash[:16],
852
+ "model_name": model.name,
853
+ },
854
+ )
855
+ return new_record
856
+
857
+ elif (
858
+ cp_record is not None
859
+ and cp_record.identity_hash == model.identity_hash
860
+ and attrs_hash == model.identity_hash
861
+ ):
862
+ # Row 2: happy path; CP and attrs both already match.
863
+ self.repo.record_slot_index_model_event(
864
+ product=product,
865
+ run_id=run_id,
866
+ event_type=EVENT_SLOT_INDEX_MODEL_VERIFIED,
867
+ identity_hash=model.identity_hash,
868
+ model_name=model.name,
869
+ )
870
+ return cp_record
871
+
872
+ elif (
873
+ cp_record is not None
874
+ and cp_record.identity_hash == model.identity_hash
875
+ and attrs_hash is None
876
+ ):
877
+ # Row 3: crash-recovery; CP is authoritative, re-mirror missing attrs.
878
+ self._mirror_attrs(product, cp_record)
879
+ self.repo.record_slot_index_model_event(
880
+ product=product,
881
+ run_id=run_id,
882
+ event_type=EVENT_SLOT_INDEX_MODEL_VERIFIED,
883
+ identity_hash=model.identity_hash,
884
+ model_name=model.name,
885
+ )
886
+ return cp_record
887
+
888
+ elif (
889
+ cp_record is not None
890
+ and cp_record.identity_hash == model.identity_hash
891
+ and attrs_hash != model.identity_hash
892
+ ):
893
+ # Row 4: drift detected — attrs disagree with authoritative CP record.
894
+ raise SlotIndexModelConflictError(
895
+ "zarr root attrs have drifted from authoritative CP record: "
896
+ f"cp_hash={cp_record.identity_hash[:16]} "
897
+ f"attrs_hash={str(attrs_hash)[:16]!r}"
898
+ )
899
+
900
+ elif cp_record is not None and cp_record.identity_hash != model.identity_hash:
901
+ # Row 5: plugin declares an incompatible model (different algorithm/epoch).
902
+ raise SlotIndexModelConflictError(
903
+ "plugin declares incompatible slot-index model: "
904
+ f"stored={cp_record.identity_hash[:16]} "
905
+ f"declared={model.identity_hash[:16]}"
906
+ )
907
+
908
+ elif cp_record is None and attrs_hash is not None:
909
+ # Row 6: unmanaged store — root attrs exist without a control-plane record.
910
+ raise SlotIndexUnmanagedStoreError(
911
+ "zarr root has slot-model attrs but no control-plane record; "
912
+ "this is an unmanaged store — firecube will not adopt it"
913
+ )
914
+
915
+ else: # pragma: no cover - exhaustive guard
916
+ raise AssertionError(
917
+ f"unhandled slot-model precedence state: "
918
+ f"cp_record={cp_record!r} attrs_hash={attrs_hash!r}"
919
+ )
920
+
921
+ def _mirror_attrs(self, product: str, record: SlotIndexModelRecord) -> None:
922
+ """Write the slot-index model attrs to the zarr root group.
923
+
924
+ Must be called INSIDE the ``slot_index_model:current`` claim. Bypasses
925
+ the reserved-root-attrs guard by design: the slot-index service is the
926
+ authoritative writer of these reserved root attrs, and the guard exists
927
+ to block external (user/plugin) code paths only.
928
+ """
929
+ import zarr
930
+ from zarr.storage import LocalStore
931
+
932
+ from firecube.core.filesystem.store_factory import create_zarr_store
933
+ from firecube.core.uris import is_remote_target, local_path_from_target
934
+
935
+ product_root = str(self.get_product_root(product))
936
+ if is_remote_target(product_root):
937
+ storage_config = self.storage_config
938
+ if storage_config is None:
939
+ raise RuntimeError(
940
+ "storage_config is required to open the remote zarr root "
941
+ "for slot-index attr mirroring"
942
+ )
943
+ handle = create_zarr_store(uri=product_root, storage_config=storage_config, mode="a")
944
+ root = zarr.open_group(**handle.zarr_kwargs(), mode="a", zarr_format=3)
945
+ else:
946
+ store = LocalStore(str(local_path_from_target(product_root)))
947
+ root = zarr.open_group(store=store, mode="a", zarr_format=3)
948
+ root.attrs.update(
949
+ {
950
+ SLOT_INDEX_MODEL_ATTR: record.model.canonical_bytes().decode("utf-8"),
951
+ SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR: record.identity_hash,
952
+ }
953
+ )
954
+
955
+ def _read_slot_index_attrs_hash(self, *, product: str) -> str | None:
956
+ """Read the reserved identity-hash root attr, returning ``None`` if absent.
957
+
958
+ Returns ``None`` when the zarr root group is missing, unreadable, or
959
+ does not yet carry the reserved attr. The probe never raises, because
960
+ a missing or pre-existing-but-untouched store is the legitimate Row 1
961
+ fresh-store input — only a real I/O failure under the claim should
962
+ surface, and the precedence-matrix caller will see it during the
963
+ subsequent write step.
964
+ """
965
+ import zarr
966
+ from zarr.storage import LocalStore
967
+
968
+ from firecube.core.filesystem.store_factory import create_zarr_store
969
+ from firecube.core.uris import is_remote_target, local_path_from_target
970
+
971
+ product_root = str(self.get_product_root(product))
972
+ try:
973
+ if is_remote_target(product_root):
974
+ storage_config = self.storage_config
975
+ if storage_config is None:
976
+ return None
977
+ handle = create_zarr_store(
978
+ uri=product_root, storage_config=storage_config, mode="r"
979
+ )
980
+ root = zarr.open_group(**handle.zarr_kwargs(), mode="r", zarr_format=3)
981
+ else:
982
+ local = local_path_from_target(product_root)
983
+ store = LocalStore(str(local))
984
+ root = zarr.open_group(store=store, mode="r", zarr_format=3)
985
+ except Exception:
986
+ return None
987
+ value = root.attrs.get(SLOT_INDEX_MODEL_IDENTITY_HASH_ATTR)
988
+ if value is None:
989
+ return None
990
+ return str(value)