diffract-core 0.2.1__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.
- diffract/__init__.py +52 -0
- diffract/configs/fast_speed_without_disk.ini +48 -0
- diffract/configs/hybrid.ini +74 -0
- diffract/configs/sqlite.ini +62 -0
- diffract/containers.py +405 -0
- diffract/core/__init__.py +23 -0
- diffract/core/cache/__init__.py +24 -0
- diffract/core/cache/containers.py +93 -0
- diffract/core/cache/interface.py +117 -0
- diffract/core/cache/redis_manager.py +464 -0
- diffract/core/cache/simple_manager.py +478 -0
- diffract/core/compute/__init__.py +46 -0
- diffract/core/compute/config.py +48 -0
- diffract/core/compute/containers.py +81 -0
- diffract/core/compute/decorator.py +125 -0
- diffract/core/compute/exceptions.py +31 -0
- diffract/core/compute/execution/__init__.py +14 -0
- diffract/core/compute/execution/_types.py +70 -0
- diffract/core/compute/execution/aggregation.py +74 -0
- diffract/core/compute/execution/aggregation_runner.py +362 -0
- diffract/core/compute/execution/enums.py +38 -0
- diffract/core/compute/execution/executor.py +125 -0
- diffract/core/compute/execution/parameter_runner.py +125 -0
- diffract/core/compute/execution/restrictions.py +61 -0
- diffract/core/compute/execution/strategy.py +118 -0
- diffract/core/compute/execution/utils.py +112 -0
- diffract/core/compute/extensions/__init__.py +0 -0
- diffract/core/compute/extensions/power_law.py +1051 -0
- diffract/core/compute/extensions/rmt.py +150 -0
- diffract/core/compute/extensions/utils.py +36 -0
- diffract/core/compute/field_signature.py +183 -0
- diffract/core/compute/kernels/__init__.py +48 -0
- diffract/core/compute/kernels/alignment.py +106 -0
- diffract/core/compute/kernels/heavy_tailed.py +394 -0
- diffract/core/compute/kernels/marchenko_pastur.py +99 -0
- diffract/core/compute/kernels/mat_decomposition.py +101 -0
- diffract/core/compute/kernels/mat_properties.py +53 -0
- diffract/core/compute/kernels/model_quality.py +27 -0
- diffract/core/compute/kernels/norms.py +88 -0
- diffract/core/compute/kernels/ranks.py +35 -0
- diffract/core/compute/kernels/tracy_widom.py +36 -0
- diffract/core/compute/metadata.py +67 -0
- diffract/core/compute/registry.py +485 -0
- diffract/core/constants.py +147 -0
- diffract/core/data/__init__.py +32 -0
- diffract/core/data/interface.py +304 -0
- diffract/core/data/metadata/__init__.py +15 -0
- diffract/core/data/metadata/containers.py +60 -0
- diffract/core/data/metadata/interface.py +208 -0
- diffract/core/data/metadata/sqlite_index.py +604 -0
- diffract/core/data/nn/__init__.py +43 -0
- diffract/core/data/nn/aggregates/__init__.py +35 -0
- diffract/core/data/nn/aggregates/metadata.py +96 -0
- diffract/core/data/nn/aggregates/proxy.py +57 -0
- diffract/core/data/nn/aggregates/repository.py +87 -0
- diffract/core/data/nn/aggregates/view.py +307 -0
- diffract/core/data/nn/containers.py +86 -0
- diffract/core/data/nn/extractors/__init__.py +49 -0
- diffract/core/data/nn/extractors/base.py +300 -0
- diffract/core/data/nn/extractors/factory.py +234 -0
- diffract/core/data/nn/extractors/flax.py +112 -0
- diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
- diffract/core/data/nn/extractors/handlers/base.py +110 -0
- diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
- diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
- diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
- diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
- diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
- diffract/core/data/nn/extractors/interface.py +56 -0
- diffract/core/data/nn/extractors/numpy.py +94 -0
- diffract/core/data/nn/extractors/onnx.py +108 -0
- diffract/core/data/nn/extractors/tensorflow.py +99 -0
- diffract/core/data/nn/extractors/torch.py +204 -0
- diffract/core/data/nn/params/__init__.py +27 -0
- diffract/core/data/nn/params/interface.py +402 -0
- diffract/core/data/nn/params/metadata.py +110 -0
- diffract/core/data/nn/params/proxy.py +93 -0
- diffract/core/data/nn/params/repository.py +45 -0
- diffract/core/data/nn/params/schema.py +82 -0
- diffract/core/data/nn/params/view.py +393 -0
- diffract/core/data/proxy.py +246 -0
- diffract/core/data/repository.py +227 -0
- diffract/core/data/utils.py +33 -0
- diffract/core/data/view.py +389 -0
- diffract/core/export/__init__.py +27 -0
- diffract/core/export/containers.py +47 -0
- diffract/core/export/exporters.py +191 -0
- diffract/core/export/formatters/__init__.py +23 -0
- diffract/core/export/formatters/base.py +68 -0
- diffract/core/export/formatters/dict_formatter.py +38 -0
- diffract/core/export/formatters/json_formatter.py +58 -0
- diffract/core/export/formatters/list_formatter.py +41 -0
- diffract/core/export/formatters/pandas_formatter.py +86 -0
- diffract/core/export/formatters/polars_formatter.py +86 -0
- diffract/core/export/formatters/registry.py +84 -0
- diffract/core/export/interface.py +105 -0
- diffract/core/parallel/__init__.py +21 -0
- diffract/core/parallel/containers.py +57 -0
- diffract/core/parallel/execution.py +91 -0
- diffract/core/parallel/runtime.py +91 -0
- diffract/core/storage/__init__.py +35 -0
- diffract/core/storage/base_manager.py +330 -0
- diffract/core/storage/containers.py +122 -0
- diffract/core/storage/hdf5_manager.py +856 -0
- diffract/core/storage/hybrid_manager.py +218 -0
- diffract/core/storage/interface.py +222 -0
- diffract/core/storage/metadata.py +89 -0
- diffract/core/storage/ram_manager.py +159 -0
- diffract/core/storage/sqlite_manager.py +1062 -0
- diffract/core/storage/zarr_manager.py +992 -0
- diffract/core/utils/__init__.py +45 -0
- diffract/core/utils/build.py +46 -0
- diffract/core/utils/exceptions.py +11 -0
- diffract/core/utils/hashing.py +90 -0
- diffract/core/utils/imports.py +292 -0
- diffract/core/utils/math.py +15 -0
- diffract/py.typed +0 -0
- diffract/session/__init__.py +24 -0
- diffract/session/errors.py +17 -0
- diffract/session/field_cache.py +216 -0
- diffract/session/namespaces/__init__.py +15 -0
- diffract/session/namespaces/compute/__init__.py +219 -0
- diffract/session/namespaces/models/__init__.py +282 -0
- diffract/session/namespaces/models/parameters/__init__.py +133 -0
- diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
- diffract/session/namespaces/results/__init__.py +378 -0
- diffract/session/namespaces/results/eraser.py +129 -0
- diffract/session/namespaces/results/injester.py +249 -0
- diffract/session/namespaces/utils/__init__.py +141 -0
- diffract/session/namespaces/utils/merger.py +295 -0
- diffract/session/namespaces/viz/__init__.py +91 -0
- diffract/session/namespaces/viz/_utils.py +14 -0
- diffract/session/namespaces/viz/box.py +207 -0
- diffract/session/namespaces/viz/grid.py +160 -0
- diffract/session/namespaces/viz/heatmap.py +164 -0
- diffract/session/namespaces/viz/scatter.py +202 -0
- diffract/session/namespaces/viz/sparkline.py +270 -0
- diffract/session/namespaces/viz/violin.py +212 -0
- diffract/session/session.py +260 -0
- diffract/session/utils.py +81 -0
- diffract/viz/__init__.py +42 -0
- diffract/viz/data/__init__.py +34 -0
- diffract/viz/data/detection.py +57 -0
- diffract/viz/data/extraction.py +117 -0
- diffract/viz/data/filtering.py +57 -0
- diffract/viz/data/ordering.py +129 -0
- diffract/viz/data/provider.py +99 -0
- diffract/viz/data/types.py +98 -0
- diffract/viz/plots/__init__.py +37 -0
- diffract/viz/plots/base/__init__.py +20 -0
- diffract/viz/plots/base/axis.py +219 -0
- diffract/viz/plots/base/coloraxis.py +133 -0
- diffract/viz/plots/base/configurator.py +18 -0
- diffract/viz/plots/base/jitter.py +368 -0
- diffract/viz/plots/base/line.py +197 -0
- diffract/viz/plots/base/marker.py +278 -0
- diffract/viz/plots/base/overlay.py +14 -0
- diffract/viz/plots/base/plot.py +110 -0
- diffract/viz/plots/base/update.py +50 -0
- diffract/viz/plots/boxplot.py +283 -0
- diffract/viz/plots/cluster.py +374 -0
- diffract/viz/plots/heatmap.py +168 -0
- diffract/viz/plots/scatter.py +233 -0
- diffract/viz/plots/sparkline.py +405 -0
- diffract/viz/plots/subplots/__init__.py +32 -0
- diffract/viz/plots/subplots/coloraxis.py +339 -0
- diffract/viz/plots/subplots/factory.py +713 -0
- diffract/viz/plots/subplots/grid.py +214 -0
- diffract/viz/plots/subplots/layout.py +370 -0
- diffract/viz/plots/subplots/spec.py +39 -0
- diffract/viz/plots/violin.py +298 -0
- diffract/viz/renderer.py +513 -0
- diffract/viz/styling/__init__.py +78 -0
- diffract/viz/styling/palettes/__init__.py +20 -0
- diffract/viz/styling/palettes/bundle.py +16 -0
- diffract/viz/styling/palettes/color.py +83 -0
- diffract/viz/styling/palettes/dashes.py +27 -0
- diffract/viz/styling/palettes/symbols.py +42 -0
- diffract/viz/styling/resolvers/__init__.py +11 -0
- diffract/viz/styling/resolvers/categorical.py +68 -0
- diffract/viz/styling/resolvers/color.py +77 -0
- diffract/viz/styling/resolvers/numeric.py +108 -0
- diffract/viz/styling/sources.py +27 -0
- diffract/viz/styling/theme/__init__.py +29 -0
- diffract/viz/styling/theme/applier.py +114 -0
- diffract/viz/styling/theme/components.py +66 -0
- diffract/viz/styling/theme/presets.py +58 -0
- diffract/viz/styling/theme/theme.py +36 -0
- diffract_core-0.2.1.dist-info/METADATA +530 -0
- diffract_core-0.2.1.dist-info/RECORD +193 -0
- diffract_core-0.2.1.dist-info/WHEEL +4 -0
- diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
- diffract_core-0.2.1.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
"""HDF5-backed storage manager optimized for high-throughput reads.
|
|
2
|
+
|
|
3
|
+
Design notes:
|
|
4
|
+
- Writes are serialized via a single write handle under a lock.
|
|
5
|
+
- Reads use per-thread read-only file handles (thread-local) to avoid
|
|
6
|
+
Python-level locks.
|
|
7
|
+
- Object existence is tracked by a dataset-based index (resizable 1D string dataset).
|
|
8
|
+
- Data is organized by tables for logical separation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import pickle
|
|
17
|
+
import threading
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
import diffract.core.utils.imports as import_utils
|
|
24
|
+
from diffract.core.constants import (
|
|
25
|
+
HDF5_INDEX_DATASET,
|
|
26
|
+
HDF5_INDEX_GROUP,
|
|
27
|
+
HDF5_INDEX_TOMBSTONE,
|
|
28
|
+
STORAGE_ATTR_META,
|
|
29
|
+
STORAGE_ATTR_TYPE,
|
|
30
|
+
)
|
|
31
|
+
from diffract.core.utils.exceptions import format_exception_message
|
|
32
|
+
|
|
33
|
+
from .base_manager import BaseStorageManager
|
|
34
|
+
from .interface import DEFAULT_TABLE, UID
|
|
35
|
+
from .metadata import infer_value_metadata
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Generator
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if not import_utils.is_available("h5py"):
|
|
44
|
+
logger.debug("h5py not available, disabling HDF5 storage manager")
|
|
45
|
+
|
|
46
|
+
class HDF5StorageManager(BaseStorageManager):
|
|
47
|
+
"""Stub implementation when h5py is not available."""
|
|
48
|
+
|
|
49
|
+
def __new__(cls, *_args: Any, **_kwargs: Any) -> Self:
|
|
50
|
+
"""Raise ImportError because the optional dependency is missing."""
|
|
51
|
+
msg = "h5py package not available"
|
|
52
|
+
raise ImportError(msg)
|
|
53
|
+
|
|
54
|
+
else:
|
|
55
|
+
h5py = import_utils.require("h5py")
|
|
56
|
+
|
|
57
|
+
class HDF5StorageManager(BaseStorageManager):
|
|
58
|
+
"""Persistent storage manager using an HDF5 file.
|
|
59
|
+
|
|
60
|
+
Supports storing arbitrary Python values (pickled fallback) and NumPy arrays.
|
|
61
|
+
Intended for workloads with frequent multi-threaded reads and serialized writes.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> storage = HDF5StorageManager("data.h5")
|
|
65
|
+
>>> with storage:
|
|
66
|
+
... storage.set_field("param_001", "weights", np.random.randn(100, 100))
|
|
67
|
+
... weights = storage.get_field("param_001", "weights")
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
root: str = "root",
|
|
75
|
+
compression: str | None = None,
|
|
76
|
+
compression_opts: int | None = None,
|
|
77
|
+
shuffle: bool = False,
|
|
78
|
+
fletcher: bool = False,
|
|
79
|
+
swmr: bool = True,
|
|
80
|
+
verify_index: bool = False,
|
|
81
|
+
keep_file_open: bool = True,
|
|
82
|
+
readonly: bool = False,
|
|
83
|
+
refresh_on_read: bool = False,
|
|
84
|
+
chunks: bool | tuple[int, ...] | None = None,
|
|
85
|
+
index_cache: bool = True,
|
|
86
|
+
index_group: str = HDF5_INDEX_GROUP,
|
|
87
|
+
index_all_objs: str = HDF5_INDEX_DATASET,
|
|
88
|
+
index_tombstone: str = HDF5_INDEX_TOMBSTONE,
|
|
89
|
+
index_encoding: str = "utf-8",
|
|
90
|
+
index_chunk_len: int = 4096,
|
|
91
|
+
sort_list_objs: bool = True,
|
|
92
|
+
libver: str = "latest",
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Create a storage manager.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
path: Path to the HDF5 file.
|
|
99
|
+
root: Top-level group name used for data storage.
|
|
100
|
+
compression: Compression algorithm (e.g., "gzip", "lzf").
|
|
101
|
+
compression_opts: Compression options (e.g., compression level).
|
|
102
|
+
shuffle: Enable shuffle filter for compression.
|
|
103
|
+
fletcher: Enable Fletcher32 checksum.
|
|
104
|
+
swmr: Enable SWMR mode (Single Writer Multiple Reader) when supported.
|
|
105
|
+
keep_file_open: Keep file handles open between operations.
|
|
106
|
+
readonly: If True, reject any write operation.
|
|
107
|
+
refresh_on_read: If True and SWMR is enabled, call `file.refresh()`
|
|
108
|
+
on reads.
|
|
109
|
+
chunks: Chunking policy for ndarray datasets; use contiguous layout
|
|
110
|
+
when possible.
|
|
111
|
+
verify_index: If True, verify index consistency in `list_objs()`
|
|
112
|
+
and rebuild if needed.
|
|
113
|
+
index_cache: Enable in-memory index cache.
|
|
114
|
+
index_group: Group name for object index.
|
|
115
|
+
index_all_objs: Dataset name for "all objects" index.
|
|
116
|
+
index_tombstone: Dataset name for tombstone markers.
|
|
117
|
+
index_encoding: Encoding for index string datasets.
|
|
118
|
+
index_chunk_len: Chunk size for index datasets.
|
|
119
|
+
sort_list_objs: Sort object IDs in list_objs() output.
|
|
120
|
+
libver: HDF5 library format bounds (e.g. "latest").
|
|
121
|
+
**kwargs: Additional keyword arguments for BaseStorageManager.
|
|
122
|
+
"""
|
|
123
|
+
super().__init__(**kwargs)
|
|
124
|
+
|
|
125
|
+
self._path = path
|
|
126
|
+
self._root = root
|
|
127
|
+
self._compression = compression
|
|
128
|
+
self._compression_opts = compression_opts
|
|
129
|
+
self._shuffle = shuffle
|
|
130
|
+
self._fletcher = fletcher
|
|
131
|
+
self._swmr = swmr
|
|
132
|
+
self._verify_index = verify_index
|
|
133
|
+
self._keep_file_open = keep_file_open
|
|
134
|
+
self._readonly = readonly
|
|
135
|
+
self._refresh_on_read = refresh_on_read
|
|
136
|
+
self._chunks = chunks
|
|
137
|
+
self._index_cache_enabled = index_cache
|
|
138
|
+
self._index_group = index_group
|
|
139
|
+
self._index_all_objs = index_all_objs
|
|
140
|
+
self._index_tombstone = index_tombstone
|
|
141
|
+
self._index_encoding = index_encoding
|
|
142
|
+
if index_chunk_len <= 0:
|
|
143
|
+
msg = "index_chunk_len must be > 0"
|
|
144
|
+
raise ValueError(msg)
|
|
145
|
+
self._index_chunk_len = index_chunk_len
|
|
146
|
+
self._sort_list_objs = sort_list_objs
|
|
147
|
+
self._libver = libver
|
|
148
|
+
|
|
149
|
+
# Per-table index caches (only created when index_cache=True)
|
|
150
|
+
self._index_caches: dict[str, set[str]] = {}
|
|
151
|
+
self._index_cache_enabled = index_cache
|
|
152
|
+
|
|
153
|
+
# Writes serialized; reads use per-thread read-only file handles.
|
|
154
|
+
self._write_lock = threading.Lock()
|
|
155
|
+
self._write_handle: h5py.File | None = None
|
|
156
|
+
self._read_local = threading.local()
|
|
157
|
+
|
|
158
|
+
dirpath = Path(self._path).parent
|
|
159
|
+
if not dirpath.exists():
|
|
160
|
+
dirpath.mkdir(parents=True)
|
|
161
|
+
|
|
162
|
+
# Ensure schema exists unless in readonly mode.
|
|
163
|
+
if not self._readonly:
|
|
164
|
+
with h5py.File(self._path, "a", libver=self._libver) as f:
|
|
165
|
+
f.require_group(self._root)
|
|
166
|
+
if self._swmr:
|
|
167
|
+
with contextlib.suppress(Exception):
|
|
168
|
+
if not f.swmr_mode:
|
|
169
|
+
f.swmr_mode = True
|
|
170
|
+
|
|
171
|
+
# Track read handles across threads so we can close them before opening a
|
|
172
|
+
# write handle. HDF5 disallows opening the same file read-only and
|
|
173
|
+
# read-write concurrently within a single process.
|
|
174
|
+
self._read_handles_lock = threading.Lock()
|
|
175
|
+
self._read_handles_by_thread: dict[int, h5py.File] = {}
|
|
176
|
+
|
|
177
|
+
def _register_read_handle(self, handle: h5py.File) -> None:
|
|
178
|
+
tid = threading.get_ident()
|
|
179
|
+
with self._read_handles_lock:
|
|
180
|
+
self._read_handles_by_thread[tid] = handle
|
|
181
|
+
|
|
182
|
+
def _unregister_read_handle(self, handle: h5py.File | None) -> None:
|
|
183
|
+
if handle is None:
|
|
184
|
+
return
|
|
185
|
+
tid = threading.get_ident()
|
|
186
|
+
with self._read_handles_lock:
|
|
187
|
+
current = self._read_handles_by_thread.get(tid)
|
|
188
|
+
if current is handle:
|
|
189
|
+
self._read_handles_by_thread.pop(tid, None)
|
|
190
|
+
|
|
191
|
+
def _close_all_read_handles(self) -> int:
|
|
192
|
+
"""Close all known thread-local read handles (best-effort)."""
|
|
193
|
+
with self._read_handles_lock:
|
|
194
|
+
items = list(self._read_handles_by_thread.items())
|
|
195
|
+
self._read_handles_by_thread.clear()
|
|
196
|
+
|
|
197
|
+
closed = 0
|
|
198
|
+
for _, handle in items:
|
|
199
|
+
try:
|
|
200
|
+
if handle is not None and handle.id.valid:
|
|
201
|
+
handle.close()
|
|
202
|
+
closed += 1
|
|
203
|
+
except Exception as exc: # noqa: BLE001
|
|
204
|
+
logger.warning(
|
|
205
|
+
"Failed to close read handle %s: %s",
|
|
206
|
+
handle,
|
|
207
|
+
format_exception_message(exc),
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
return closed
|
|
211
|
+
|
|
212
|
+
def _close_current_thread_read_handle(self) -> None:
|
|
213
|
+
"""Close the current thread's read handle if valid."""
|
|
214
|
+
read_handle = getattr(self._read_local, "handle", None)
|
|
215
|
+
if read_handle is not None and read_handle.id.valid:
|
|
216
|
+
with contextlib.suppress(Exception):
|
|
217
|
+
read_handle.close()
|
|
218
|
+
self._read_local.handle = None
|
|
219
|
+
self._unregister_read_handle(read_handle)
|
|
220
|
+
|
|
221
|
+
def _need_reopen_write(self, reopen: bool) -> bool:
|
|
222
|
+
"""Check if write handle needs to be reopened."""
|
|
223
|
+
if self._write_handle is None:
|
|
224
|
+
return True
|
|
225
|
+
if not self._write_handle.id.valid:
|
|
226
|
+
return True
|
|
227
|
+
if reopen:
|
|
228
|
+
return True
|
|
229
|
+
return self._write_handle.mode != "r+"
|
|
230
|
+
|
|
231
|
+
def _open_write_handle(self) -> h5py.File:
|
|
232
|
+
"""Open or reopen write handle, closing existing handles first."""
|
|
233
|
+
if self._write_handle is not None:
|
|
234
|
+
with contextlib.suppress(Exception):
|
|
235
|
+
self._write_handle.close()
|
|
236
|
+
|
|
237
|
+
self._close_current_thread_read_handle()
|
|
238
|
+
self._close_all_read_handles()
|
|
239
|
+
|
|
240
|
+
self._write_handle = h5py.File(self._path, "a", libver=self._libver)
|
|
241
|
+
if self._swmr and not self._write_handle.swmr_mode:
|
|
242
|
+
with contextlib.suppress(Exception):
|
|
243
|
+
self._write_handle.swmr_mode = True
|
|
244
|
+
return self._write_handle
|
|
245
|
+
|
|
246
|
+
def _need_reopen_read(self, handle: h5py.File | None, reopen: bool) -> bool:
|
|
247
|
+
"""Check if read handle needs to be reopened."""
|
|
248
|
+
if handle is None or not handle.id.valid:
|
|
249
|
+
return True
|
|
250
|
+
if reopen:
|
|
251
|
+
return True
|
|
252
|
+
mode = None
|
|
253
|
+
with contextlib.suppress(Exception):
|
|
254
|
+
mode = str(handle.mode)
|
|
255
|
+
return mode is not None and not mode.startswith("r")
|
|
256
|
+
|
|
257
|
+
def _open_read_handle(self, handle: h5py.File | None) -> h5py.File:
|
|
258
|
+
"""Open or reopen read handle."""
|
|
259
|
+
if handle is not None:
|
|
260
|
+
with contextlib.suppress(Exception):
|
|
261
|
+
handle.close()
|
|
262
|
+
self._unregister_read_handle(handle)
|
|
263
|
+
handle = h5py.File(self._path, "r", libver=self._libver, swmr=self._swmr)
|
|
264
|
+
self._read_local.handle = handle
|
|
265
|
+
self._register_read_handle(handle)
|
|
266
|
+
return handle
|
|
267
|
+
|
|
268
|
+
def _get_or_open_file(self, *, write: bool, reopen: bool) -> h5py.File:
|
|
269
|
+
"""Return an HDF5 file handle (write-shared or read thread-local)."""
|
|
270
|
+
if write and self._readonly:
|
|
271
|
+
msg = "HDF5StorageManager is readonly; write operation is not allowed"
|
|
272
|
+
raise OSError(msg)
|
|
273
|
+
|
|
274
|
+
if write:
|
|
275
|
+
if self._need_reopen_write(reopen):
|
|
276
|
+
return self._open_write_handle()
|
|
277
|
+
return self._write_handle # type: ignore[return-value]
|
|
278
|
+
|
|
279
|
+
# Read-only: use thread-local handle to avoid python-level locks on read.
|
|
280
|
+
handle = getattr(self._read_local, "handle", None)
|
|
281
|
+
if self._need_reopen_read(handle, reopen):
|
|
282
|
+
return self._open_read_handle(handle)
|
|
283
|
+
return handle # type: ignore[return-value]
|
|
284
|
+
|
|
285
|
+
@contextlib.contextmanager
|
|
286
|
+
def _open(self, *, write: bool = False) -> Generator[h5py.File, None, None]:
|
|
287
|
+
"""Context manager that yields an opened HDF5 file handle."""
|
|
288
|
+
if write:
|
|
289
|
+
with self._write_lock:
|
|
290
|
+
file = self._get_or_open_file(
|
|
291
|
+
write=True, reopen=(not self._keep_file_open)
|
|
292
|
+
)
|
|
293
|
+
try:
|
|
294
|
+
yield file
|
|
295
|
+
file.flush()
|
|
296
|
+
finally:
|
|
297
|
+
if not self._keep_file_open:
|
|
298
|
+
with contextlib.suppress(Exception):
|
|
299
|
+
file.close()
|
|
300
|
+
self._write_handle = None
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
reopen = not self._keep_file_open
|
|
304
|
+
file = self._get_or_open_file(write=False, reopen=reopen)
|
|
305
|
+
if self._swmr and self._refresh_on_read:
|
|
306
|
+
with contextlib.suppress(Exception):
|
|
307
|
+
file.refresh()
|
|
308
|
+
try:
|
|
309
|
+
yield file
|
|
310
|
+
finally:
|
|
311
|
+
if not self._keep_file_open:
|
|
312
|
+
with contextlib.suppress(Exception):
|
|
313
|
+
file.close()
|
|
314
|
+
if getattr(self._read_local, "handle", None) is file:
|
|
315
|
+
self._read_local.handle = None
|
|
316
|
+
|
|
317
|
+
def _table_path(self, table: str) -> str:
|
|
318
|
+
"""Build path to a table group."""
|
|
319
|
+
return f"{self._root}/{table}"
|
|
320
|
+
|
|
321
|
+
def _h5path(self, table: str, field: str, uid: UID) -> str:
|
|
322
|
+
"""Build an absolute HDF5 path for a stored field dataset."""
|
|
323
|
+
return f"{self._root}/{table}/{field}/{uid}"
|
|
324
|
+
|
|
325
|
+
def _index_group_path(self, table: str) -> str:
|
|
326
|
+
"""Path to the index group under table."""
|
|
327
|
+
return f"{self._root}/{table}/{self._index_group}"
|
|
328
|
+
|
|
329
|
+
def _index_all_objs_path(self, table: str) -> str:
|
|
330
|
+
"""Path to the dataset-based all-objs index for a table."""
|
|
331
|
+
return f"{self._root}/{table}/{self._index_group}/{self._index_all_objs}"
|
|
332
|
+
|
|
333
|
+
def _decode_index_value(self, raw_value: bytes | str) -> str:
|
|
334
|
+
"""Decode a raw index value to string."""
|
|
335
|
+
if isinstance(raw_value, bytes):
|
|
336
|
+
return raw_value.decode(self._index_encoding)
|
|
337
|
+
return str(raw_value)
|
|
338
|
+
|
|
339
|
+
def _ensure_table(self, f: h5py.File, table: str) -> h5py.Group:
|
|
340
|
+
"""Ensure table group and its index exist."""
|
|
341
|
+
table_group = f.require_group(self._table_path(table))
|
|
342
|
+
idx_root = table_group.require_group(self._index_group)
|
|
343
|
+
self._ensure_index(idx_root)
|
|
344
|
+
return table_group
|
|
345
|
+
|
|
346
|
+
def _ensure_index(self, idx_root: h5py.Group) -> None:
|
|
347
|
+
"""Ensure the all-objs index exists."""
|
|
348
|
+
if self._index_all_objs not in idx_root:
|
|
349
|
+
dt = h5py.string_dtype(encoding=self._index_encoding)
|
|
350
|
+
idx_root.create_dataset(
|
|
351
|
+
self._index_all_objs,
|
|
352
|
+
shape=(0,),
|
|
353
|
+
maxshape=(None,),
|
|
354
|
+
dtype=dt,
|
|
355
|
+
chunks=(self._index_chunk_len,),
|
|
356
|
+
)
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
obj = idx_root[self._index_all_objs]
|
|
360
|
+
if isinstance(obj, h5py.Dataset):
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
msg = "Unsupported index format for HDF5 all-objs index."
|
|
364
|
+
raise TypeError(msg)
|
|
365
|
+
|
|
366
|
+
def _index_dataset(self, f: h5py.File, table: str) -> h5py.Dataset | None:
|
|
367
|
+
"""Return the dataset-based all-objs index dataset for a table."""
|
|
368
|
+
idx_path = self._index_all_objs_path(table)
|
|
369
|
+
if idx_path not in f:
|
|
370
|
+
return None
|
|
371
|
+
obj = f[idx_path]
|
|
372
|
+
return obj if isinstance(obj, h5py.Dataset) else None
|
|
373
|
+
|
|
374
|
+
def _get_index_cache(self, table: str) -> set[str] | None:
|
|
375
|
+
"""Get or create index cache for a table."""
|
|
376
|
+
if not self._index_cache_enabled:
|
|
377
|
+
return None
|
|
378
|
+
if table not in self._index_caches:
|
|
379
|
+
self._index_caches[table] = set()
|
|
380
|
+
return self._index_caches[table]
|
|
381
|
+
|
|
382
|
+
def _load_index_cache(self, f: h5py.File, table: str) -> None:
|
|
383
|
+
"""Load the all-objs index into memory for a table."""
|
|
384
|
+
cache = self._get_index_cache(table)
|
|
385
|
+
if cache is None:
|
|
386
|
+
return
|
|
387
|
+
if cache:
|
|
388
|
+
return
|
|
389
|
+
index_dataset = self._index_dataset(f, table)
|
|
390
|
+
if index_dataset is not None:
|
|
391
|
+
vals = [self._decode_index_value(raw) for raw in index_dataset[:]]
|
|
392
|
+
cache.update(x for x in vals if x and x != self._index_tombstone)
|
|
393
|
+
|
|
394
|
+
def _index_add_obj(self, f: h5py.File, table: str, uid: UID) -> None:
|
|
395
|
+
"""Ensure `uid` is present in the index dataset."""
|
|
396
|
+
index_dataset = self._index_dataset(f, table)
|
|
397
|
+
if index_dataset is None:
|
|
398
|
+
msg = "Missing dataset-based all-objs index."
|
|
399
|
+
raise KeyError(msg)
|
|
400
|
+
|
|
401
|
+
cache = self._get_index_cache(table)
|
|
402
|
+
if cache is not None:
|
|
403
|
+
self._load_index_cache(f, table)
|
|
404
|
+
if uid in cache:
|
|
405
|
+
return
|
|
406
|
+
cache.add(uid)
|
|
407
|
+
else:
|
|
408
|
+
# Use set for O(1) lookup instead of O(n) any() iteration
|
|
409
|
+
existing = {self._decode_index_value(raw) for raw in index_dataset[:]}
|
|
410
|
+
if uid in existing:
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
current_len = index_dataset.shape[0]
|
|
414
|
+
index_dataset.resize((current_len + 1,))
|
|
415
|
+
index_dataset[current_len] = uid
|
|
416
|
+
|
|
417
|
+
def _index_remove_obj(self, f: h5py.File, table: str, uid: UID) -> None:
|
|
418
|
+
"""Mark `uid` as removed in the index dataset (tombstone).
|
|
419
|
+
|
|
420
|
+
If the index dataset doesn't exist, this is a no-op (object was never
|
|
421
|
+
stored in this backend, common in hybrid storage scenarios).
|
|
422
|
+
"""
|
|
423
|
+
index_dataset = self._index_dataset(f, table)
|
|
424
|
+
if index_dataset is None:
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
vals = index_dataset[:]
|
|
428
|
+
for index, raw_value in enumerate(vals):
|
|
429
|
+
if self._decode_index_value(raw_value) == uid:
|
|
430
|
+
index_dataset[index] = self._index_tombstone
|
|
431
|
+
break
|
|
432
|
+
cache = self._get_index_cache(table)
|
|
433
|
+
if cache is not None:
|
|
434
|
+
cache.discard(uid)
|
|
435
|
+
|
|
436
|
+
def _scan_actual_objs(self, f: h5py.File, table: str) -> set[str]:
|
|
437
|
+
"""Scan and return UIDs that exist in any field for a table."""
|
|
438
|
+
objs: set[str] = set()
|
|
439
|
+
table_path = self._table_path(table)
|
|
440
|
+
if table_path not in f:
|
|
441
|
+
return objs
|
|
442
|
+
|
|
443
|
+
table_group = f[table_path]
|
|
444
|
+
for field in table_group:
|
|
445
|
+
if field == self._index_group:
|
|
446
|
+
continue
|
|
447
|
+
group = f[f"{table_path}/{field}"]
|
|
448
|
+
for uid in group:
|
|
449
|
+
objs.add(uid)
|
|
450
|
+
|
|
451
|
+
return objs
|
|
452
|
+
|
|
453
|
+
def _rebuild_index(self, f: h5py.File, table: str) -> None:
|
|
454
|
+
"""Rebuild the dataset-based all-objs index from actual file contents."""
|
|
455
|
+
actual = self._scan_actual_objs(f, table)
|
|
456
|
+
idx_root = f[self._index_group_path(table)]
|
|
457
|
+
|
|
458
|
+
self._ensure_index(idx_root)
|
|
459
|
+
index_dataset = self._index_dataset(f, table)
|
|
460
|
+
if index_dataset is None:
|
|
461
|
+
return
|
|
462
|
+
uids = sorted(actual)
|
|
463
|
+
|
|
464
|
+
index_dataset.resize((len(uids),))
|
|
465
|
+
if uids:
|
|
466
|
+
index_dataset[:] = uids
|
|
467
|
+
cache = self._get_index_cache(table)
|
|
468
|
+
if cache is not None:
|
|
469
|
+
cache.clear()
|
|
470
|
+
cache.update(uids)
|
|
471
|
+
|
|
472
|
+
def _has_field(
|
|
473
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
474
|
+
) -> bool:
|
|
475
|
+
"""Return True if the given object has the specified field."""
|
|
476
|
+
with self._open(write=False) as f:
|
|
477
|
+
return self._h5path(table, field_name, obj_uid) in f
|
|
478
|
+
|
|
479
|
+
def _get_field(
|
|
480
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
481
|
+
) -> Any:
|
|
482
|
+
"""Load a stored field value."""
|
|
483
|
+
try:
|
|
484
|
+
with self._open(write=False) as f:
|
|
485
|
+
path = self._h5path(table, field_name, obj_uid)
|
|
486
|
+
|
|
487
|
+
if path not in f:
|
|
488
|
+
msg = f"Field '{field_name}' of '{obj_uid}' not found"
|
|
489
|
+
raise KeyError(msg) # noqa: TRY301
|
|
490
|
+
|
|
491
|
+
dataset = f[path]
|
|
492
|
+
kind = dataset.attrs.get(STORAGE_ATTR_TYPE, "ndarray")
|
|
493
|
+
if kind == "ndarray":
|
|
494
|
+
data = dataset[()]
|
|
495
|
+
if not data.shape:
|
|
496
|
+
data = data.item()
|
|
497
|
+
return data
|
|
498
|
+
|
|
499
|
+
if kind == "bytes":
|
|
500
|
+
raw = dataset[()]
|
|
501
|
+
if isinstance(raw, np.ndarray):
|
|
502
|
+
data_bytes = raw.tobytes()
|
|
503
|
+
return pickle.loads(data_bytes) # noqa: S301
|
|
504
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
505
|
+
return raw
|
|
506
|
+
return bytes(raw)
|
|
507
|
+
|
|
508
|
+
msg = f"Unknown stored type: {kind}"
|
|
509
|
+
raise TypeError(msg) # noqa: TRY301
|
|
510
|
+
|
|
511
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
512
|
+
msg = f"Data corruption in '{field_name}/{obj_uid}': {exc}"
|
|
513
|
+
raise ValueError(msg) from exc
|
|
514
|
+
|
|
515
|
+
except Exception:
|
|
516
|
+
logger.exception(
|
|
517
|
+
"Failed to read field '%s' for '%s'", field_name, obj_uid
|
|
518
|
+
)
|
|
519
|
+
raise
|
|
520
|
+
|
|
521
|
+
def _get_field_metadata(
|
|
522
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
523
|
+
) -> dict[str, Any] | None:
|
|
524
|
+
"""Return stored metadata (dtype/shape/kind) if present."""
|
|
525
|
+
with self._open(write=False) as f:
|
|
526
|
+
path = self._h5path(table, field_name, obj_uid)
|
|
527
|
+
if path not in f:
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
dataset = f[path]
|
|
531
|
+
raw_meta = dataset.attrs.get(STORAGE_ATTR_META)
|
|
532
|
+
if raw_meta is None:
|
|
533
|
+
return None
|
|
534
|
+
try:
|
|
535
|
+
if isinstance(raw_meta, bytes):
|
|
536
|
+
raw_meta = raw_meta.decode("utf-8")
|
|
537
|
+
return json.loads(raw_meta)
|
|
538
|
+
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
|
|
539
|
+
return None
|
|
540
|
+
|
|
541
|
+
def _list_fields(
|
|
542
|
+
self, obj_uid: UID = None, *, table: str = DEFAULT_TABLE
|
|
543
|
+
) -> list[str]:
|
|
544
|
+
"""List stored field names."""
|
|
545
|
+
with self._open(write=False) as f:
|
|
546
|
+
table_path = self._table_path(table)
|
|
547
|
+
if table_path not in f:
|
|
548
|
+
return []
|
|
549
|
+
table_group = f[table_path]
|
|
550
|
+
|
|
551
|
+
out: list[str] = []
|
|
552
|
+
|
|
553
|
+
if obj_uid is None:
|
|
554
|
+
return [fld for fld in table_group if fld != self._index_group]
|
|
555
|
+
for field in table_group:
|
|
556
|
+
if field == self._index_group:
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
if f"{table_path}/{field}/{obj_uid}" in f:
|
|
560
|
+
out.append(field)
|
|
561
|
+
|
|
562
|
+
return out
|
|
563
|
+
|
|
564
|
+
def _parse_index_dataset(self, index_dataset: h5py.Dataset) -> set[str]:
|
|
565
|
+
"""Parse index dataset into a set of valid UIDs."""
|
|
566
|
+
vals = [self._decode_index_value(raw) for raw in index_dataset[:]]
|
|
567
|
+
return {x for x in vals if x and x != self._index_tombstone}
|
|
568
|
+
|
|
569
|
+
def _format_uid_list(self, uids: set[str]) -> list[str]:
|
|
570
|
+
"""Format UID set as list, optionally sorted."""
|
|
571
|
+
return sorted(uids) if self._sort_list_objs else list(uids)
|
|
572
|
+
|
|
573
|
+
def _list_objs(self, *, table: str = DEFAULT_TABLE) -> list[str]:
|
|
574
|
+
"""List known object UIDs."""
|
|
575
|
+
with self._open(write=False) as f:
|
|
576
|
+
table_path = self._table_path(table)
|
|
577
|
+
if table_path not in f:
|
|
578
|
+
return []
|
|
579
|
+
|
|
580
|
+
index_dataset = self._index_dataset(f, table)
|
|
581
|
+
if index_dataset is None and self._readonly:
|
|
582
|
+
actual = self._scan_actual_objs(f, table)
|
|
583
|
+
return self._format_uid_list(actual)
|
|
584
|
+
|
|
585
|
+
if index_dataset is not None:
|
|
586
|
+
indexed = self._parse_index_dataset(index_dataset)
|
|
587
|
+
if not self._verify_index:
|
|
588
|
+
return self._format_uid_list(indexed)
|
|
589
|
+
|
|
590
|
+
actual = self._scan_actual_objs(f, table)
|
|
591
|
+
if indexed == actual:
|
|
592
|
+
return self._format_uid_list(indexed)
|
|
593
|
+
|
|
594
|
+
logger.warning(
|
|
595
|
+
"HDF5 index out of sync; rebuilding or returning actual."
|
|
596
|
+
)
|
|
597
|
+
if self._readonly:
|
|
598
|
+
return self._format_uid_list(actual)
|
|
599
|
+
|
|
600
|
+
# Rebuild index
|
|
601
|
+
with self._open(write=True, reopen=True) as f:
|
|
602
|
+
self._ensure_table(f, table)
|
|
603
|
+
self._rebuild_index(f, table)
|
|
604
|
+
|
|
605
|
+
with self._open(write=False, reopen=True) as f:
|
|
606
|
+
actual = self._scan_actual_objs(f, table)
|
|
607
|
+
return self._format_uid_list(actual)
|
|
608
|
+
|
|
609
|
+
def _list_objs_has_field(
|
|
610
|
+
self, field_name: str, *, table: str = DEFAULT_TABLE
|
|
611
|
+
) -> list[UID]:
|
|
612
|
+
"""List object UIDs that have the given field."""
|
|
613
|
+
with self._open(write=False) as f:
|
|
614
|
+
field_path = f"{self._table_path(table)}/{field_name}"
|
|
615
|
+
if field_path not in f:
|
|
616
|
+
return []
|
|
617
|
+
return list(f[field_path].keys())
|
|
618
|
+
|
|
619
|
+
def _get_chunk_config(self) -> bool | tuple[int, ...] | None:
|
|
620
|
+
"""Determine chunk configuration for dataset creation."""
|
|
621
|
+
if self._chunks is None and self._compression is None:
|
|
622
|
+
return None
|
|
623
|
+
return self._chunks if self._chunks is not None else True
|
|
624
|
+
|
|
625
|
+
def _write_ndarray_dataset(
|
|
626
|
+
self, group: h5py.Group, obj_uid: UID, value: np.ndarray, meta_json: str
|
|
627
|
+
) -> None:
|
|
628
|
+
"""Write a numpy array as an HDF5 dataset."""
|
|
629
|
+
chunks = self._get_chunk_config()
|
|
630
|
+
dataset = group.create_dataset(
|
|
631
|
+
obj_uid,
|
|
632
|
+
data=value,
|
|
633
|
+
compression=self._compression,
|
|
634
|
+
compression_opts=self._compression_opts,
|
|
635
|
+
shuffle=self._shuffle,
|
|
636
|
+
fletcher32=self._fletcher,
|
|
637
|
+
chunks=chunks,
|
|
638
|
+
)
|
|
639
|
+
dataset.attrs[STORAGE_ATTR_TYPE] = "ndarray"
|
|
640
|
+
dataset.attrs[STORAGE_ATTR_META] = meta_json
|
|
641
|
+
|
|
642
|
+
def _write_generic_dataset(
|
|
643
|
+
self, group: h5py.Group, obj_uid: UID, value: Any, meta_json: str
|
|
644
|
+
) -> None:
|
|
645
|
+
"""Write a non-ndarray value as an HDF5 dataset."""
|
|
646
|
+
try:
|
|
647
|
+
dataset = group.create_dataset(obj_uid, data=np.array(value))
|
|
648
|
+
dataset.attrs[STORAGE_ATTR_TYPE] = "ndarray"
|
|
649
|
+
except (TypeError, ValueError):
|
|
650
|
+
dataset = group.create_dataset(
|
|
651
|
+
obj_uid,
|
|
652
|
+
data=np.frombuffer(
|
|
653
|
+
pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL),
|
|
654
|
+
dtype=np.uint8,
|
|
655
|
+
),
|
|
656
|
+
chunks=True,
|
|
657
|
+
)
|
|
658
|
+
dataset.attrs[STORAGE_ATTR_TYPE] = "bytes"
|
|
659
|
+
dataset.attrs[STORAGE_ATTR_META] = meta_json
|
|
660
|
+
|
|
661
|
+
def _flush_set_field_batch(self) -> None:
|
|
662
|
+
"""Flush queued `set_field` operations."""
|
|
663
|
+
done: list[tuple[str, UID, str]] = []
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
with self._open(write=True) as f:
|
|
667
|
+
for key, value in self._set_field_batch.items():
|
|
668
|
+
tbl, obj_uid, field_name = key
|
|
669
|
+
self._ensure_table(f, tbl)
|
|
670
|
+
table_path = self._table_path(tbl)
|
|
671
|
+
group = f.require_group(f"{table_path}/{field_name}")
|
|
672
|
+
dest = self._h5path(tbl, field_name, obj_uid)
|
|
673
|
+
meta = infer_value_metadata(value).to_jsonable()
|
|
674
|
+
meta_json = json.dumps(meta, ensure_ascii=False)
|
|
675
|
+
|
|
676
|
+
if dest in f:
|
|
677
|
+
del f[dest]
|
|
678
|
+
|
|
679
|
+
if isinstance(value, np.ndarray):
|
|
680
|
+
self._write_ndarray_dataset(
|
|
681
|
+
group, obj_uid, value, meta_json
|
|
682
|
+
)
|
|
683
|
+
else:
|
|
684
|
+
self._write_generic_dataset(
|
|
685
|
+
group, obj_uid, value, meta_json
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
self._index_add_obj(f, tbl, obj_uid)
|
|
689
|
+
done.append((tbl, obj_uid, field_name))
|
|
690
|
+
|
|
691
|
+
except Exception:
|
|
692
|
+
logger.exception("Failed to flush set_field batch")
|
|
693
|
+
raise
|
|
694
|
+
|
|
695
|
+
finally:
|
|
696
|
+
for key in done:
|
|
697
|
+
del self._set_field_batch[key]
|
|
698
|
+
size = self._set_field_sizes.pop(key, 0)
|
|
699
|
+
if size:
|
|
700
|
+
self._pending_set_bytes = max(0, self._pending_set_bytes - size)
|
|
701
|
+
if not self._set_field_sizes:
|
|
702
|
+
self._pending_set_bytes = 0
|
|
703
|
+
|
|
704
|
+
def _obj_has_any_field(
|
|
705
|
+
self, f: h5py.File, table_path: str, obj_uid: UID
|
|
706
|
+
) -> bool:
|
|
707
|
+
"""Check if object has any field in the table."""
|
|
708
|
+
if table_path not in f:
|
|
709
|
+
return False
|
|
710
|
+
table_group = f[table_path]
|
|
711
|
+
for field in table_group:
|
|
712
|
+
if field == self._index_group:
|
|
713
|
+
continue
|
|
714
|
+
if f"{table_path}/{field}/{obj_uid}" in f:
|
|
715
|
+
return True
|
|
716
|
+
return False
|
|
717
|
+
|
|
718
|
+
def _flush_erase_field_batch(self) -> None:
|
|
719
|
+
"""Flush queued `erase_field` operations."""
|
|
720
|
+
done: list[tuple[str, UID, str]] = []
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
with self._open(write=True) as f:
|
|
724
|
+
for tbl, obj_uid, field_name in self._erase_field_batch:
|
|
725
|
+
path = self._h5path(tbl, field_name, obj_uid)
|
|
726
|
+
if path in f:
|
|
727
|
+
del f[path]
|
|
728
|
+
|
|
729
|
+
table_path = self._table_path(tbl)
|
|
730
|
+
if not self._obj_has_any_field(f, table_path, obj_uid):
|
|
731
|
+
self._index_remove_obj(f, tbl, obj_uid)
|
|
732
|
+
|
|
733
|
+
done.append((tbl, obj_uid, field_name))
|
|
734
|
+
|
|
735
|
+
except Exception:
|
|
736
|
+
logger.exception("Failed to flush erase_field batch")
|
|
737
|
+
raise
|
|
738
|
+
|
|
739
|
+
finally:
|
|
740
|
+
for key in done:
|
|
741
|
+
self._erase_field_batch.discard(key)
|
|
742
|
+
|
|
743
|
+
def _flush_erase_obj_batch(self) -> None:
|
|
744
|
+
"""Flush queued `erase_obj` operations."""
|
|
745
|
+
done: list[tuple[str, UID]] = []
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
with self._open(write=True) as f:
|
|
749
|
+
for tbl, obj_uid in self._erase_obj_batch:
|
|
750
|
+
table_path = self._table_path(tbl)
|
|
751
|
+
if table_path not in f:
|
|
752
|
+
done.append((tbl, obj_uid))
|
|
753
|
+
continue
|
|
754
|
+
|
|
755
|
+
table_group = f[table_path]
|
|
756
|
+
for field in tuple(table_group.keys()):
|
|
757
|
+
if field == self._index_group:
|
|
758
|
+
continue
|
|
759
|
+
|
|
760
|
+
path = self._h5path(tbl, field, obj_uid)
|
|
761
|
+
if path in f:
|
|
762
|
+
del f[path]
|
|
763
|
+
|
|
764
|
+
self._index_remove_obj(f, tbl, obj_uid)
|
|
765
|
+
done.append((tbl, obj_uid))
|
|
766
|
+
|
|
767
|
+
except Exception:
|
|
768
|
+
logger.exception("Failed to flush erase_obj batch")
|
|
769
|
+
raise
|
|
770
|
+
|
|
771
|
+
finally:
|
|
772
|
+
for key in done:
|
|
773
|
+
self._erase_obj_batch.discard(key)
|
|
774
|
+
|
|
775
|
+
def _sync_index_after_field_erase(
|
|
776
|
+
self, f: h5py.File, uids_by_table: dict[str, set[UID]]
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Remove UIDs from index if they have no remaining fields."""
|
|
779
|
+
for tbl, uids in uids_by_table.items():
|
|
780
|
+
if not uids:
|
|
781
|
+
continue
|
|
782
|
+
actual = self._scan_actual_objs(f, tbl)
|
|
783
|
+
for uid in uids:
|
|
784
|
+
if uid not in actual:
|
|
785
|
+
self._index_remove_obj(f, tbl, uid)
|
|
786
|
+
|
|
787
|
+
def _flush_erase_field_for_all_batch(self) -> None:
|
|
788
|
+
"""Flush queued `erase_field_for_all` operations."""
|
|
789
|
+
done: list[tuple[str, str]] = []
|
|
790
|
+
uids_by_table: dict[str, set[UID]] = {}
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
with self._open(write=True) as f:
|
|
794
|
+
for tbl, field_name in self._erase_field_for_all_batch:
|
|
795
|
+
field_path = f"{self._table_path(tbl)}/{field_name}"
|
|
796
|
+
|
|
797
|
+
if field_path not in f:
|
|
798
|
+
continue
|
|
799
|
+
|
|
800
|
+
if tbl not in uids_by_table:
|
|
801
|
+
uids_by_table[tbl] = set()
|
|
802
|
+
uids_by_table[tbl].update(f[field_path].keys())
|
|
803
|
+
|
|
804
|
+
del f[field_path]
|
|
805
|
+
done.append((tbl, field_name))
|
|
806
|
+
|
|
807
|
+
self._sync_index_after_field_erase(f, uids_by_table)
|
|
808
|
+
|
|
809
|
+
except Exception:
|
|
810
|
+
logger.exception("Failed to flush erase_field_for_all batch")
|
|
811
|
+
raise
|
|
812
|
+
|
|
813
|
+
finally:
|
|
814
|
+
for key in done:
|
|
815
|
+
self._erase_field_for_all_batch.discard(key)
|
|
816
|
+
|
|
817
|
+
def _clear(self, *, table: str | None = None) -> None:
|
|
818
|
+
"""Remove data and recreate structure."""
|
|
819
|
+
with self._open(write=True) as f:
|
|
820
|
+
if table is None:
|
|
821
|
+
# Clear all data
|
|
822
|
+
if self._root in f:
|
|
823
|
+
del f[self._root]
|
|
824
|
+
f.create_group(self._root)
|
|
825
|
+
self._index_caches.clear()
|
|
826
|
+
else:
|
|
827
|
+
# Clear specific table
|
|
828
|
+
table_path = self._table_path(table)
|
|
829
|
+
if table_path in f:
|
|
830
|
+
del f[table_path]
|
|
831
|
+
self._ensure_table(f, table)
|
|
832
|
+
if table in self._index_caches:
|
|
833
|
+
self._index_caches[table].clear()
|
|
834
|
+
|
|
835
|
+
def close(self) -> None:
|
|
836
|
+
"""Close open file handles owned by this manager (best-effort)."""
|
|
837
|
+
handle = getattr(getattr(self, "_read_local", None), "handle", None)
|
|
838
|
+
if handle is not None and handle.id.valid:
|
|
839
|
+
with contextlib.suppress(Exception):
|
|
840
|
+
handle.close()
|
|
841
|
+
self._read_local.handle = None
|
|
842
|
+
self._unregister_read_handle(handle)
|
|
843
|
+
|
|
844
|
+
self._close_all_read_handles()
|
|
845
|
+
|
|
846
|
+
write_handle = getattr(self, "_write_handle", None)
|
|
847
|
+
if write_handle is not None and write_handle.id.valid:
|
|
848
|
+
with contextlib.suppress(Exception):
|
|
849
|
+
write_handle.close()
|
|
850
|
+
self._write_handle = None
|
|
851
|
+
logger.debug("HDF5 file descriptor(s) closed")
|
|
852
|
+
|
|
853
|
+
def __del__(self) -> None:
|
|
854
|
+
"""Best-effort cleanup for GC/abnormal shutdown scenarios."""
|
|
855
|
+
with contextlib.suppress(Exception):
|
|
856
|
+
self.close()
|