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,804 @@
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
+ """WAL-backed repository for the `.firecube/` control-plane root."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import re
22
+ import time
23
+ import uuid
24
+ from collections.abc import Callable, Generator
25
+ from contextlib import contextmanager
26
+ from dataclasses import dataclass, field
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Any, cast
30
+
31
+ from firecube.core.controlplane._helpers import (
32
+ describe_control_plane,
33
+ )
34
+ from firecube.core.controlplane._helpers import (
35
+ open_controlplane_fs_cached as _open_controlplane_fs_cached,
36
+ )
37
+ from firecube.core.controlplane._projection import ManifestProjection
38
+ from firecube.core.controlplane._snapshot import (
39
+ read_latest_pointer,
40
+ )
41
+ from firecube.core.controlplane._wal_reader import WalReader
42
+ from firecube.core.controlplane._wal_writer import ManifestWalWriter
43
+ from firecube.core.controlplane.claims import FilesystemClaimService
44
+ from firecube.core.controlplane.events import RunEventWriter
45
+ from firecube.core.controlplane.metrics import (
46
+ record_wal_snapshot_rebuild,
47
+ )
48
+ from firecube.core.controlplane.repo_utils import (
49
+ deserialize_slot_group as _deserialize_slot_group, # noqa: F401
50
+ )
51
+ from firecube.core.controlplane.repo_utils import (
52
+ deserialize_slot_range as _deserialize_slot_range, # noqa: F401
53
+ )
54
+ from firecube.core.controlplane.types import (
55
+ CLAIMS_DIRNAME,
56
+ CONTROL_DIRNAME,
57
+ DEFAULT_RUN_STALE_THRESHOLD_S,
58
+ LATEST_POINTER,
59
+ RUNS_DIRNAME,
60
+ SCHEMA_VERSION,
61
+ SNAPSHOT_DIRNAME,
62
+ ChunkInfo,
63
+ ClaimInfo,
64
+ RunInfo,
65
+ SpanCoverage,
66
+ WriteDomain,
67
+ )
68
+ from firecube.core.errors import (
69
+ ManifestError,
70
+ )
71
+ from firecube.core.filesystem import StorageFilesystem, StorageFilesystemFull
72
+ from firecube.core.storage.binding import StorageBinding
73
+ from firecube.core.storage.uri import StorageUri
74
+ from firecube.core.uris import is_remote_target
75
+
76
+ log = logging.getLogger(__name__)
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class _RunEntriesCache:
81
+ entries_by_product: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
82
+
83
+
84
+ class _ControlRootResolver:
85
+ """Derive product and `.firecube/` control-plane paths from the configured output base."""
86
+
87
+ def __init__(
88
+ self,
89
+ repo: ManifestRepository,
90
+ base_uri: StorageUri,
91
+ fs: StorageFilesystem,
92
+ ) -> None:
93
+ self._repo = repo
94
+ self.base_uri = base_uri
95
+ self._fs = fs
96
+
97
+ def product_root(self, product: str) -> tuple[StorageUri, StorageUri]:
98
+ """Return (filesystem_uri, canonical_uri) for a product root directory."""
99
+ product = str(product or "").strip("/")
100
+ if not product:
101
+ raise ManifestError("product must be non-empty")
102
+ from firecube.core.product import ensure_product_uri
103
+
104
+ if product == self._repo.binding.identity.product_name:
105
+ product_uri = self._repo.binding.identity.product_uri
106
+ else:
107
+ product_uri = StorageUri.parse(ensure_product_uri(self.base_uri.to_str(), product))
108
+ _fs, product_fs_uri = self._repo._get_fs(product_uri)
109
+ return product_fs_uri, product_uri
110
+
111
+ def __call__(self, product: str) -> tuple[StorageUri, StorageUri]:
112
+ product_fs_uri, product_uri = self.product_root(product)
113
+ return product_fs_uri.join(CONTROL_DIRNAME), product_uri.join(CONTROL_DIRNAME)
114
+
115
+
116
+ class ManifestRepository:
117
+ def __init__(
118
+ self,
119
+ binding: StorageBinding,
120
+ workspace: Path,
121
+ *,
122
+ filesystem: StorageFilesystem | None = None,
123
+ run_stale_threshold_s: int = DEFAULT_RUN_STALE_THRESHOLD_S,
124
+ ):
125
+ self.binding = binding
126
+ self.workspace = workspace
127
+ self._base_uri: StorageUri = binding.identity.product_uri.parent()
128
+ self.storage_config = self._storage_config_from_binding(binding)
129
+ self.workspace = workspace
130
+ self.run_stale_threshold_s = int(run_stale_threshold_s)
131
+ self._filesystem = filesystem
132
+ self._fs_cache: dict[tuple, StorageFilesystem] = {}
133
+ self._run_entries_cache: _RunEntriesCache | None = None
134
+ self.log = logging.getLogger(f"{__name__}.ManifestRepository")
135
+ self._writers: dict[tuple[str, str], RunEventWriter] = {}
136
+ self._resolver: _ControlRootResolver | None = None
137
+ self._fs: Any | None = None
138
+ self.claims: FilesystemClaimService | None = None
139
+ self._wal_reader: WalReader | None = None
140
+ self._binding_key: str | None = self._binding_key_for(binding)
141
+ self._wal_writer = ManifestWalWriter(self)
142
+ self._projection = ManifestProjection(self)
143
+
144
+ @property
145
+ def base_uri(self) -> str:
146
+ return self._base_uri.to_str()
147
+
148
+ def record_entry(self, product: str, entry: dict[str, Any]) -> None:
149
+ raise ManifestError(
150
+ "ChunkManager v2 no longer accepts arbitrary chunk-entry writes. Use record_run_started(), "
151
+ "record_span(), and record_run_terminal() instead."
152
+ )
153
+
154
+ @contextmanager
155
+ def run_entries_cache_scope(self) -> Generator[None, None, None]:
156
+ prev = self._run_entries_cache
157
+ self._run_entries_cache = _RunEntriesCache()
158
+ try:
159
+ yield
160
+ finally:
161
+ self._run_entries_cache = prev
162
+
163
+ def record_run_started(
164
+ self,
165
+ *,
166
+ product: str,
167
+ run_id: str,
168
+ output_path: str,
169
+ output_format: str,
170
+ size: int,
171
+ meta: dict[str, Any],
172
+ slot_range: tuple[int, int] | None = None,
173
+ slot_group: str | None = None,
174
+ ) -> None:
175
+ self._wal_writer.record_run_started(
176
+ product=product,
177
+ run_id=run_id,
178
+ output_path=output_path,
179
+ output_format=output_format,
180
+ size=size,
181
+ meta=meta,
182
+ slot_range=slot_range,
183
+ slot_group=slot_group,
184
+ )
185
+
186
+ def record_run_terminal(
187
+ self,
188
+ *,
189
+ product: str,
190
+ run_id: str,
191
+ output_path: str,
192
+ output_format: str,
193
+ size: int,
194
+ meta: dict[str, Any],
195
+ status: str,
196
+ error: str | None = None,
197
+ slot_range: tuple[int, int] | None = None,
198
+ slot_group: str | None = None,
199
+ ) -> None:
200
+ self._wal_writer.record_run_terminal(
201
+ product=product,
202
+ run_id=run_id,
203
+ output_path=output_path,
204
+ output_format=output_format,
205
+ size=size,
206
+ meta=meta,
207
+ status=status,
208
+ error=error,
209
+ slot_range=slot_range,
210
+ slot_group=slot_group,
211
+ )
212
+
213
+ def record_run_started_with_replacement(
214
+ self, *, product: str, run_id: str, replaces: list[str]
215
+ ) -> None:
216
+ self._wal_writer.record_run_started_with_replacement(
217
+ product=product, run_id=run_id, replaces=replaces
218
+ )
219
+
220
+ def record_maintenance_started(
221
+ self, *, product: str, run_id: str, op: str, scope_meta: dict[str, Any] | None = None
222
+ ) -> None:
223
+ self._wal_writer.record_maintenance_started(
224
+ product=product, run_id=run_id, op=op, scope_meta=scope_meta
225
+ )
226
+
227
+ def record_maintenance_completed(
228
+ self, *, product: str, run_id: str, op: str, scope_meta: dict[str, Any] | None = None
229
+ ) -> None:
230
+ self._wal_writer.record_maintenance_completed(
231
+ product=product, run_id=run_id, op=op, scope_meta=scope_meta
232
+ )
233
+
234
+ def record_maintenance_failed(
235
+ self,
236
+ *,
237
+ product: str,
238
+ run_id: str,
239
+ op: str,
240
+ scope_meta: dict[str, Any] | None = None,
241
+ error: str,
242
+ ) -> None:
243
+ self._wal_writer.record_maintenance_failed(
244
+ product=product, run_id=run_id, op=op, scope_meta=scope_meta, error=error
245
+ )
246
+
247
+ def record_replacement_committed(
248
+ self, *, product: str, run_id: str, replacing_run_id: str, replaced_span_keys: list[str]
249
+ ) -> None:
250
+ self._wal_writer.record_replacement_committed(
251
+ product=product,
252
+ run_id=run_id,
253
+ replacing_run_id=replacing_run_id,
254
+ replaced_span_keys=replaced_span_keys,
255
+ )
256
+
257
+ def record_span_event(
258
+ self,
259
+ *,
260
+ product: str,
261
+ run_id: str,
262
+ batch_id: str,
263
+ group: str,
264
+ status: str,
265
+ reason: str | None = None,
266
+ coverage: SpanCoverage | None = None,
267
+ meta: dict[str, Any] | None = None,
268
+ ) -> None:
269
+ self._wal_writer.record_span_event(
270
+ product=product,
271
+ run_id=run_id,
272
+ batch_id=batch_id,
273
+ group=group,
274
+ status=status,
275
+ reason=reason,
276
+ coverage=coverage,
277
+ meta=meta,
278
+ )
279
+
280
+ def record_schema_verification_event(
281
+ self,
282
+ *,
283
+ product: str,
284
+ run_id: str,
285
+ group: str,
286
+ plugin: str,
287
+ schema_hash: str,
288
+ verified_at: str,
289
+ expected_time_count: int,
290
+ meta: dict[str, Any] | None = None,
291
+ ) -> None:
292
+ self._wal_writer.record_schema_verification_event(
293
+ product=product,
294
+ run_id=run_id,
295
+ group=group,
296
+ plugin=plugin,
297
+ schema_hash=schema_hash,
298
+ verified_at=verified_at,
299
+ expected_time_count=expected_time_count,
300
+ meta=meta,
301
+ )
302
+
303
+ def record_slot_index_model_event(
304
+ self,
305
+ *,
306
+ product: str,
307
+ run_id: str,
308
+ event_type: str,
309
+ identity_hash: str,
310
+ model_name: str,
311
+ group: str | None = None,
312
+ meta: dict[str, Any] | None = None,
313
+ ) -> None:
314
+ self._wal_writer.record_slot_index_model_event(
315
+ product=product,
316
+ run_id=run_id,
317
+ event_type=event_type,
318
+ identity_hash=identity_hash,
319
+ model_name=model_name,
320
+ group=group,
321
+ meta=meta,
322
+ )
323
+
324
+ def discover_manifests(self) -> list[str]:
325
+ return self._projection.discover_manifests()
326
+
327
+ def parse_manifest(self, manifest_uri: str) -> Generator[ChunkInfo, None, None]:
328
+ yield from self._projection.parse_manifest(manifest_uri)
329
+
330
+ def list_runs(
331
+ self, *, product: str, status: str | None = None, non_terminal: bool = False
332
+ ) -> list[RunInfo]:
333
+ return self._projection.list_runs(product=product, status=status, non_terminal=non_terminal)
334
+
335
+ def list_stale_runs(self, *, product: str) -> list[RunInfo]:
336
+ return [run for run in self.list_runs(product=product, non_terminal=True) if run.stale]
337
+
338
+ def abandon_run(
339
+ self, *, product: str, run_id: str, reason: str, meta: dict[str, Any] | None = None
340
+ ) -> dict[str, Any]:
341
+ return self._wal_writer.abandon_run(
342
+ product=product, run_id=run_id, reason=reason, meta=meta
343
+ )
344
+
345
+ def list_chunks(
346
+ self,
347
+ pattern: str | None = None,
348
+ product: str | None = None,
349
+ before: datetime | None = None,
350
+ after: datetime | None = None,
351
+ chunk_type: str | None = None,
352
+ status: str | None = None,
353
+ include_replaced: bool = False,
354
+ meta: dict[str, Any] | None = None,
355
+ time_min_after: str | None = None,
356
+ time_max_before: str | None = None,
357
+ time_overlaps: tuple[str, str] | None = None,
358
+ filter_fn: Callable[[ChunkInfo], bool] | None = None,
359
+ ) -> list[ChunkInfo]:
360
+ return self._projection.list_chunks(
361
+ pattern=pattern,
362
+ product=product,
363
+ before=before,
364
+ after=after,
365
+ chunk_type=chunk_type,
366
+ status=status,
367
+ include_replaced=include_replaced,
368
+ meta=meta,
369
+ time_min_after=time_min_after,
370
+ time_max_before=time_max_before,
371
+ time_overlaps=time_overlaps,
372
+ filter_fn=filter_fn,
373
+ )
374
+
375
+ def mark_chunks_replaced(
376
+ self, chunk_keys: list[str], product: str, replacement_timestamp: float
377
+ ) -> dict[str, Any]:
378
+ return self._wal_writer.mark_chunks_replaced(chunk_keys, product, replacement_timestamp)
379
+
380
+ def remove_from_manifest(
381
+ self, manifest_uri: str, chunks_to_remove: list[ChunkInfo]
382
+ ) -> tuple[int, int]:
383
+ return self._wal_writer.remove_from_manifest(chunks_to_remove)
384
+
385
+ def acquire_claim(self, *, product: str, domain: WriteDomain, owner_id: str):
386
+ if self.claims is None:
387
+ self._ensure_bound()
388
+ assert self.claims is not None
389
+ return self.claims.acquire(product=product, domain=domain, owner_id=owner_id)
390
+
391
+ def list_claims(self, *, product: str | None = None) -> list[ClaimInfo]:
392
+ if self.claims is None:
393
+ self._ensure_bound()
394
+ assert self.claims is not None
395
+ return self.claims.list_claims(product=product)
396
+
397
+ def list_stale_claims(self, *, product: str) -> list[ClaimInfo]:
398
+ if self.claims is None:
399
+ self._ensure_bound()
400
+ assert self.claims is not None
401
+ return self.claims.list_stale_claims(product=product)
402
+
403
+ def clear_claim(self, *, product: str, domain_id: str, force: bool = False) -> bool:
404
+ if self.claims is None:
405
+ self._ensure_bound()
406
+ assert self.claims is not None
407
+ return self.claims.clear_claim(product=product, domain_id=domain_id, force=force)
408
+
409
+ def rebuild_snapshot(self, product: str) -> dict[str, Any]:
410
+ _rebuild_t0 = time.time()
411
+ self._ensure_product_control_root(product)
412
+ self._assert_compaction_allowed(product)
413
+ if self._resolver is None:
414
+ raise ManifestError("Repository not bound — call bind() first")
415
+ if self._fs is None:
416
+ raise ManifestError("Repository not bound — call bind() first")
417
+ control_path, control_uri = self._resolver(product)
418
+ try:
419
+ self._fs.makedirs(control_path.join(SNAPSHOT_DIRNAME), exist_ok=True)
420
+ except Exception:
421
+ self.log.debug(
422
+ "makedirs failed for %s (non-fatal)", control_path.join(SNAPSHOT_DIRNAME)
423
+ )
424
+
425
+ is_remote = is_remote_target(control_uri.to_str())
426
+ lock_path = control_path.join(SNAPSHOT_DIRNAME, "compact.lock")
427
+ lock_token = uuid.uuid4().hex
428
+ lock_acquired = False
429
+ if is_remote:
430
+ self.log.debug(
431
+ "rebuild_snapshot for %s: skipping local lock (remote target — orchestration handles exclusion)",
432
+ product,
433
+ )
434
+ if not is_remote:
435
+ try:
436
+ lock_bytes = json.dumps({"token": lock_token, "created_at": time.time()}).encode(
437
+ "utf-8"
438
+ )
439
+ self._fs.atomic_writer.write_atomic(lock_path, lock_bytes)
440
+ lock_acquired = True
441
+ except FileExistsError:
442
+ return {"product": product, "skipped": True, "locked": True}
443
+
444
+ completed_runs = [
445
+ run
446
+ for run in self._list_run_entries(product)
447
+ if str(run.get("status", "")) == "complete"
448
+ ]
449
+ records = self._project_records_from_runs(completed_runs)
450
+ completed_before = max(
451
+ (float(run.get("completed_at", 0.0) or 0.0) for run in completed_runs),
452
+ default=0.0,
453
+ )
454
+ generation = str(time.time_ns())
455
+ snapshot_path = control_path.join(SNAPSHOT_DIRNAME, f"snapshot-{generation}.jsonl")
456
+ snapshot_meta_path = control_path.join(SNAPSHOT_DIRNAME, f"snapshot-{generation}.meta.json")
457
+ latest_path = control_path.join(LATEST_POINTER)
458
+
459
+ try:
460
+ payload = "\n".join(
461
+ json.dumps(record, separators=(",", ":"))
462
+ for record in sorted(records.values(), key=lambda item: item.get("key", ""))
463
+ )
464
+ if payload:
465
+ payload += "\n"
466
+ with self._fs.open(snapshot_path, "w") as handle:
467
+ handle.write(payload)
468
+ meta = {
469
+ "schema_version": SCHEMA_VERSION,
470
+ "generation": generation,
471
+ "completed_before": completed_before,
472
+ "created_at": time.time(),
473
+ "records": len(records),
474
+ "product": product,
475
+ }
476
+ with self._fs.open(snapshot_meta_path, "w") as handle:
477
+ json.dump(meta, handle, separators=(",", ":"))
478
+ with self._fs.open(latest_path, "w") as handle:
479
+ json.dump(
480
+ {
481
+ "schema_version": SCHEMA_VERSION,
482
+ "generation": generation,
483
+ "snapshot_path": (
484
+ snapshot_path.path
485
+ if snapshot_path.protocol == "file"
486
+ else snapshot_path.to_str()
487
+ ),
488
+ "snapshot_meta_path": (
489
+ snapshot_meta_path.path
490
+ if snapshot_meta_path.protocol == "file"
491
+ else snapshot_meta_path.to_str()
492
+ ),
493
+ "completed_before": completed_before,
494
+ "product": product,
495
+ },
496
+ handle,
497
+ separators=(",", ":"),
498
+ )
499
+ record_wal_snapshot_rebuild(time.time() - _rebuild_t0)
500
+ self.log.debug("rebuild_snapshot for %s: %.2fs", product, time.time() - _rebuild_t0)
501
+ snapshot_uri = control_uri.join(
502
+ SNAPSHOT_DIRNAME, f"snapshot-{generation}.jsonl"
503
+ ).to_str()
504
+ return {
505
+ "product": product,
506
+ "generation": generation,
507
+ "records": len(records),
508
+ "snapshot_path": snapshot_uri,
509
+ "locked": False,
510
+ "remote": is_remote,
511
+ }
512
+ finally:
513
+ if lock_acquired:
514
+ try:
515
+ with self._fs.open(lock_path, "r") as handle:
516
+ payload = json.load(handle)
517
+ if payload.get("token") == lock_token:
518
+ self._fs.rm(lock_path, recursive=False)
519
+ except Exception:
520
+ self.log.debug("lock cleanup failed for %s (non-fatal)", lock_path)
521
+
522
+ def get_product_root_uri(self, product: str) -> str:
523
+ self._ensure_bound()
524
+ if self._resolver is None:
525
+ raise ManifestError("Repository not bound — call bind() first")
526
+ _path, uri = self._resolver.product_root(product)
527
+ return uri.to_str()
528
+
529
+ def get_control_root_uri(self, product: str) -> str:
530
+ self._ensure_bound()
531
+ if self._resolver is None:
532
+ raise ManifestError("Repository not bound — call bind() first")
533
+ _path, uri = self._resolver(product)
534
+ return uri.to_str()
535
+
536
+ def get_latest_pointer_uri(self, product: str) -> str:
537
+ return describe_control_plane(product_uri=self.get_product_root_uri(product))[
538
+ "latest_pointer"
539
+ ]
540
+
541
+ def _matches_pattern(self, key: str, pattern: str) -> bool:
542
+ regex_pattern = f"^{pattern.replace('*', '.*').replace('?', '.')}$"
543
+ try:
544
+ return bool(re.match(regex_pattern, key))
545
+ except re.error:
546
+ return False
547
+
548
+ def _get_fs(self, uri: str | StorageUri) -> tuple[StorageFilesystem, StorageUri]:
549
+ if not uri:
550
+ raise ManifestError("Invalid manifest URI")
551
+ root_uri = uri if isinstance(uri, StorageUri) else StorageUri.parse(str(uri))
552
+ if self._filesystem is not None:
553
+ return self._filesystem, root_uri
554
+ return _open_controlplane_fs_cached(
555
+ root_uri,
556
+ binding=self.binding,
557
+ cache=self._fs_cache,
558
+ )
559
+
560
+ @staticmethod
561
+ def _entry_to_uri(entry: Any, base_uri: StorageUri) -> StorageUri:
562
+ name = entry.get("name") if isinstance(entry, dict) else entry
563
+ if isinstance(name, StorageUri):
564
+ return name
565
+ raw = str(name or "")
566
+ if "://" in raw:
567
+ return StorageUri.parse(raw)
568
+ if base_uri.protocol == "file" and raw.startswith("/"):
569
+ return StorageUri.from_local_path(raw)
570
+ path = raw
571
+ if base_uri.authority and path.startswith(f"{base_uri.authority}/"):
572
+ path = path[len(base_uri.authority) + 1 :]
573
+ return StorageUri(protocol=base_uri.protocol, authority=base_uri.authority, path=path)
574
+
575
+ def _manifest_uri_for_product(self, product: str) -> str:
576
+ return self.get_control_root_uri(product)
577
+
578
+ def _product_from_manifest_uri(self, manifest_uri: str) -> str:
579
+ parts = StorageUri.parse(str(manifest_uri)).path.rstrip("/").split("/")
580
+ if parts and parts[-1] == CONTROL_DIRNAME:
581
+ return parts[-2]
582
+ return parts[-1]
583
+
584
+ def _ensure_bound(self) -> None:
585
+ if self._resolver is not None and self._fs is not None:
586
+ return
587
+ base_uri = self._base_uri or StorageUri.from_local_path(self.workspace.resolve())
588
+ if not base_uri.to_str():
589
+ raise ManifestError("ChunkManager requires an output base URI or workspace.")
590
+ fs, base_path = self._get_fs(base_uri)
591
+ self._resolver = _ControlRootResolver(self, base_path, fs)
592
+ self._fs = fs
593
+ self.claims = FilesystemClaimService(
594
+ fs=cast(StorageFilesystemFull, fs), control_root_resolver=self._resolver
595
+ )
596
+ self._wal_reader = WalReader(
597
+ fs=fs,
598
+ resolver=self._resolver,
599
+ log=self.log,
600
+ run_stale_threshold_s=self.run_stale_threshold_s,
601
+ )
602
+
603
+ def _writer(
604
+ self,
605
+ product: str,
606
+ run_id: str,
607
+ *,
608
+ resume_existing: bool = False,
609
+ slot_range: tuple[int, int] | None = None,
610
+ slot_group: str | None = None,
611
+ ) -> RunEventWriter:
612
+ return self._wal_writer._writer(
613
+ product,
614
+ run_id,
615
+ resume_existing=resume_existing,
616
+ slot_range=slot_range,
617
+ slot_group=slot_group,
618
+ )
619
+
620
+ def _build_run_record(
621
+ self,
622
+ *,
623
+ run_id: str,
624
+ output_path: str,
625
+ output_format: str,
626
+ status: str,
627
+ size: int,
628
+ meta: dict[str, Any],
629
+ error: str | None = None,
630
+ slot_range: tuple[int, int] | None = None,
631
+ slot_group: str | None = None,
632
+ ) -> dict[str, Any]:
633
+ return ManifestWalWriter._build_run_record(
634
+ run_id=run_id,
635
+ output_path=output_path,
636
+ output_format=output_format,
637
+ status=status,
638
+ size=size,
639
+ meta=meta,
640
+ error=error,
641
+ slot_range=slot_range,
642
+ slot_group=slot_group,
643
+ )
644
+
645
+ def _ensure_product_control_root(self, product: str) -> None:
646
+ self._ensure_bound()
647
+ if self._resolver is None:
648
+ raise ManifestError("Repository not bound — call bind() first")
649
+ if self._fs is None:
650
+ raise ManifestError("Repository not bound — call bind() first")
651
+ control_path, _control_uri = self._resolver(product)
652
+ if self._fs.exists(control_path):
653
+ return
654
+ try:
655
+ self._fs.makedirs(control_path, exist_ok=True)
656
+ except Exception:
657
+ self.log.debug("makedirs failed for %s (non-fatal)", control_path)
658
+ try:
659
+ self._fs.makedirs(control_path.join(RUNS_DIRNAME), exist_ok=True)
660
+ self._fs.makedirs(control_path.join(SNAPSHOT_DIRNAME), exist_ok=True)
661
+ self._fs.makedirs(control_path.join(CLAIMS_DIRNAME), exist_ok=True)
662
+ except Exception:
663
+ self.log.debug("makedirs failed for %s (non-fatal)", control_path)
664
+
665
+ def _product_control_exists(self, product: str) -> bool:
666
+ self._ensure_bound()
667
+ if self._resolver is None:
668
+ raise ManifestError("Repository not bound — call bind() first")
669
+ if self._fs is None:
670
+ raise ManifestError("Repository not bound — call bind() first")
671
+ control_path, _control_uri = self._resolver(product)
672
+ return bool(self._fs.exists(control_path))
673
+
674
+ def _load_current_state(self, product: str) -> dict[str, dict[str, Any]]:
675
+ return self._projection._load_current_state(product)
676
+
677
+ def _list_run_entries(self, product: str) -> list[dict[str, Any]]:
678
+ self._ensure_bound()
679
+ assert self._wal_reader is not None
680
+ if self._run_entries_cache is not None:
681
+ cached = self._run_entries_cache.entries_by_product.get(product)
682
+ if cached is not None:
683
+ return cached
684
+ result = self._wal_reader.list_run_entries(product)
685
+ self._run_entries_cache.entries_by_product[product] = result
686
+ return result
687
+ return self._wal_reader.list_run_entries(product)
688
+
689
+ def _read_run_events(self, product: str, run_entry: dict[str, Any]) -> list[dict[str, Any]]:
690
+ self._ensure_bound()
691
+ assert self._wal_reader is not None
692
+ return self._wal_reader.read_run_events(product, run_entry)
693
+
694
+ def _parse_iso_dt(self, value: Any) -> datetime | None:
695
+ if not isinstance(value, str):
696
+ return None
697
+ raw = value.strip()
698
+ if not raw:
699
+ return None
700
+ try:
701
+ if raw.endswith("Z"):
702
+ raw = raw[:-1] + "+00:00"
703
+ parsed = datetime.fromisoformat(raw)
704
+ except ValueError:
705
+ return None
706
+ if parsed.tzinfo is None:
707
+ return parsed
708
+ return parsed.astimezone(UTC).replace(tzinfo=None)
709
+
710
+ def _meta_time_overlaps(
711
+ self,
712
+ meta: dict[str, Any] | None,
713
+ start: str,
714
+ end: str,
715
+ ) -> bool:
716
+ if not isinstance(meta, dict):
717
+ return False
718
+ chunk_start = self._parse_iso_dt(meta.get("time_min"))
719
+ chunk_end = self._parse_iso_dt(meta.get("time_max"))
720
+ window_start = self._parse_iso_dt(start)
721
+ window_end = self._parse_iso_dt(end)
722
+ if chunk_start is None or chunk_end is None or window_start is None or window_end is None:
723
+ return False
724
+ return chunk_start <= window_end and chunk_end >= window_start
725
+
726
+ def _load_history_records(self, product: str) -> list[dict[str, Any]]:
727
+ return self._projection._load_history_records(product)
728
+
729
+ def _project_records_from_runs(
730
+ self,
731
+ run_entries: list[dict[str, Any]],
732
+ *,
733
+ completed_before: float | None = None,
734
+ ) -> dict[str, dict[str, Any]]:
735
+ return self._projection._project_records_from_runs(
736
+ run_entries,
737
+ completed_before=completed_before,
738
+ )
739
+
740
+ def _read_latest_pointer(self, product: str) -> dict[str, Any] | None:
741
+ self._ensure_bound()
742
+ assert self._fs is not None
743
+ return read_latest_pointer(product, fs=self._fs, resolver=self._resolver, log=self.log)
744
+
745
+ def _read_snapshot_records(self, latest: dict[str, Any]) -> list[dict[str, Any]]:
746
+ return self._projection._read_snapshot_records(latest)
747
+
748
+ def _run_info_from_entry(self, product: str, payload: dict[str, Any]) -> RunInfo:
749
+ return self._projection._run_info_from_entry(product, payload)
750
+
751
+ def _get_run_entry(self, *, product: str, run_id: str) -> dict[str, Any]:
752
+ for entry in self._list_run_entries(product):
753
+ if str(entry.get("run_id", "")) == run_id:
754
+ return entry
755
+ raise ManifestError(f"Run '{run_id}' not found for product '{product}'.")
756
+
757
+ def _assert_compaction_allowed(self, product: str) -> None:
758
+ claims = self.list_claims(product=product)
759
+ if claims:
760
+ rendered = ", ".join(
761
+ f"{claim.domain} (owner={claim.owner_id}, stale={claim.stale})" for claim in claims
762
+ )
763
+ raise ManifestError(
764
+ f"Cannot rebuild snapshot for {product}: blocking claims exist: {rendered}. "
765
+ "Clear claims explicitly first."
766
+ )
767
+
768
+ blocking_runs = [run for run in self.list_runs(product=product) if not run.is_terminal]
769
+ if blocking_runs:
770
+ rendered = ", ".join(
771
+ f"{run.run_id} (status={run.status}, updated_at={run.updated_at:.0f}, stale={run.stale})"
772
+ for run in blocking_runs
773
+ )
774
+ raise ManifestError(
775
+ f"Cannot rebuild snapshot for {product}: non-terminal runs exist: {rendered}. "
776
+ "Abandon stale runs explicitly first."
777
+ )
778
+
779
+ def close(self) -> None:
780
+ """Flush all WAL writers and release bound filesystem resources."""
781
+ for writer in list(self._writers.values()):
782
+ try:
783
+ writer.flush()
784
+ except Exception:
785
+ self.log.warning("Failed to flush WAL writer on close (events may be lost)")
786
+ self._writers.clear()
787
+ self._fs_cache.clear()
788
+ self._resolver = None
789
+ self._fs = None
790
+ self._wal_reader = None
791
+ self.claims = None
792
+
793
+ def _binding_key_for(
794
+ self,
795
+ binding: StorageBinding,
796
+ ) -> str:
797
+ """Stable cache key for filesystem binding."""
798
+ ck = binding.cache_key()
799
+ return f"{ck.driver}:{ck.protocol}:{ck.authority or ''}:{ck.endpoint_url or ''}:{ck.region or ''}:{ck.credential_fingerprint or ''}"
800
+
801
+ def _storage_config_from_binding(self, binding: StorageBinding) -> Any:
802
+ from firecube.core.storage.session import storage_config_from_binding
803
+
804
+ return storage_config_from_binding(binding)