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,992 @@
|
|
|
1
|
+
"""Zarr-backed storage manager (cloud-friendly via fsspec)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import pickle
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
12
|
+
from urllib.parse import quote, unquote, urlparse
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
import diffract.core.utils.imports as import_utils
|
|
17
|
+
from diffract.core.constants import (
|
|
18
|
+
STORAGE_ATTR_META,
|
|
19
|
+
STORAGE_ATTR_TYPE,
|
|
20
|
+
ZARR_INDEX_FIELDS,
|
|
21
|
+
ZARR_INDEX_GROUP,
|
|
22
|
+
ZARR_INDEX_OBJS,
|
|
23
|
+
)
|
|
24
|
+
from diffract.core.utils.exceptions import format_exception_message
|
|
25
|
+
|
|
26
|
+
from .base_manager import BaseStorageManager
|
|
27
|
+
from .interface import DEFAULT_TABLE, UID
|
|
28
|
+
from .metadata import infer_value_metadata
|
|
29
|
+
|
|
30
|
+
# Storage type markers for serialization
|
|
31
|
+
_TYPE_NDARRAY = "ndarray"
|
|
32
|
+
_TYPE_JSON = "json"
|
|
33
|
+
_TYPE_PICKLE = "pickle"
|
|
34
|
+
|
|
35
|
+
_READONLY_ERROR = "ZarrStorageManager is readonly; write operation is not allowed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _TableRemovalInfo(NamedTuple):
|
|
39
|
+
"""Tracks removed objects and fields for a table during batch operations."""
|
|
40
|
+
|
|
41
|
+
removed_objs: set[str]
|
|
42
|
+
removed_fields: set[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from typing import Self
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if not import_utils.is_available("zarr"):
|
|
52
|
+
logger.debug("zarr not available, disabling Zarr storage manager")
|
|
53
|
+
|
|
54
|
+
class ZarrStorageManager(BaseStorageManager):
|
|
55
|
+
"""Stub implementation when zarr is not available."""
|
|
56
|
+
|
|
57
|
+
def __new__(cls, *_args: Any, **_kwargs: Any) -> Self:
|
|
58
|
+
"""Raise ImportError because the optional dependency is missing."""
|
|
59
|
+
msg = (
|
|
60
|
+
"zarr package not available; "
|
|
61
|
+
'install with: pip install "diffract-core[zarr]"'
|
|
62
|
+
)
|
|
63
|
+
raise ImportError(msg)
|
|
64
|
+
|
|
65
|
+
else:
|
|
66
|
+
zarr = import_utils.require("zarr")
|
|
67
|
+
|
|
68
|
+
class ZarrStorageManager(BaseStorageManager):
|
|
69
|
+
"""Persistent storage manager using Zarr v3 with fsspec-compatible stores.
|
|
70
|
+
|
|
71
|
+
Layout:
|
|
72
|
+
<root>/<table>/<field_name>/<obj_uid>
|
|
73
|
+
|
|
74
|
+
Each value is stored as a Zarr array with attributes:
|
|
75
|
+
- type: "ndarray" | "json" | "pickle"
|
|
76
|
+
- value_meta: JSON string from infer_value_metadata(...)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
store_url: str,
|
|
82
|
+
*,
|
|
83
|
+
storage_options: dict[str, Any] | None = None,
|
|
84
|
+
root: str = "root",
|
|
85
|
+
readonly: bool = False,
|
|
86
|
+
chunks: bool | tuple[int, ...] | None = None,
|
|
87
|
+
sort_list_objs: bool = True,
|
|
88
|
+
compressor: str | None = "lz4",
|
|
89
|
+
target_chunk_mb: float = 16.0,
|
|
90
|
+
lazy_index_sync: bool = True,
|
|
91
|
+
max_concurrency: int = 16,
|
|
92
|
+
**kwargs: Any,
|
|
93
|
+
) -> None:
|
|
94
|
+
super().__init__(**kwargs)
|
|
95
|
+
self._store_url = store_url
|
|
96
|
+
self._storage_options = storage_options or {}
|
|
97
|
+
self._root = root
|
|
98
|
+
self._readonly = readonly
|
|
99
|
+
self._chunks = chunks
|
|
100
|
+
self._sort_list_objs = sort_list_objs
|
|
101
|
+
self._compressor = compressor
|
|
102
|
+
self._target_chunk_bytes = int(target_chunk_mb * 1024 * 1024)
|
|
103
|
+
self._lazy_index_sync = lazy_index_sync
|
|
104
|
+
self._max_concurrency = max_concurrency
|
|
105
|
+
|
|
106
|
+
self._write_lock = threading.Lock()
|
|
107
|
+
self._store: Any | None = None
|
|
108
|
+
self._root_group: Any | None = None
|
|
109
|
+
|
|
110
|
+
# Per-table index caches
|
|
111
|
+
self._index_cache_objs: dict[str, frozenset[str]] = {}
|
|
112
|
+
self._index_cache_fields: dict[str, frozenset[str]] = {}
|
|
113
|
+
self._index_dirty: dict[str, bool] = {}
|
|
114
|
+
|
|
115
|
+
def connect(self) -> None:
|
|
116
|
+
"""Initialize store and root group (idempotent)."""
|
|
117
|
+
if self._root_group is not None:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
with self._write_lock:
|
|
121
|
+
if self._root_group is not None:
|
|
122
|
+
return
|
|
123
|
+
store = self._create_store()
|
|
124
|
+
mode = "r" if self._readonly else "a"
|
|
125
|
+
try:
|
|
126
|
+
root_group = zarr.open_group(
|
|
127
|
+
store=store, path=self._root, mode=mode
|
|
128
|
+
)
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
msg = (
|
|
131
|
+
"Failed to open Zarr store at "
|
|
132
|
+
f"'{self._store_url}': {format_exception_message(exc)}"
|
|
133
|
+
)
|
|
134
|
+
raise OSError(msg) from exc
|
|
135
|
+
|
|
136
|
+
self._store = store
|
|
137
|
+
self._root_group = root_group
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
"""Close underlying store (best-effort). Syncs index if dirty."""
|
|
141
|
+
with self._write_lock:
|
|
142
|
+
self._sync_dirty_indexes()
|
|
143
|
+
store = self._store
|
|
144
|
+
self._store = None
|
|
145
|
+
self._root_group = None
|
|
146
|
+
self._index_cache_objs.clear()
|
|
147
|
+
self._index_cache_fields.clear()
|
|
148
|
+
self._index_dirty.clear()
|
|
149
|
+
|
|
150
|
+
if store is not None:
|
|
151
|
+
self._close_store(store)
|
|
152
|
+
|
|
153
|
+
def _sync_dirty_indexes(self) -> None:
|
|
154
|
+
"""Sync all dirty indexes to persistent store."""
|
|
155
|
+
if self._readonly or self._root_group is None:
|
|
156
|
+
return
|
|
157
|
+
for table, is_dirty in self._index_dirty.items():
|
|
158
|
+
if not is_dirty:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
self._sync_index(table)
|
|
162
|
+
except Exception as exc: # noqa: BLE001
|
|
163
|
+
logger.warning(
|
|
164
|
+
"Failed to sync index on close for table %s: %s",
|
|
165
|
+
table,
|
|
166
|
+
format_exception_message(exc),
|
|
167
|
+
exc_info=True,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _close_store(self, store: Any) -> None:
|
|
171
|
+
"""Close store handle if it has a close method."""
|
|
172
|
+
try:
|
|
173
|
+
close_fn = getattr(store, "close", None)
|
|
174
|
+
if callable(close_fn):
|
|
175
|
+
close_fn()
|
|
176
|
+
except Exception as exc: # noqa: BLE001
|
|
177
|
+
logger.debug(
|
|
178
|
+
"Failed to close Zarr store: %s",
|
|
179
|
+
format_exception_message(exc),
|
|
180
|
+
exc_info=True,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _create_store(self) -> Any:
|
|
184
|
+
parsed = urlparse(self._store_url)
|
|
185
|
+
scheme = parsed.scheme
|
|
186
|
+
if scheme in ("", "file"):
|
|
187
|
+
from zarr.storage import LocalStore # type: ignore[import-untyped]
|
|
188
|
+
|
|
189
|
+
local_path = parsed.path if scheme == "file" else self._store_url
|
|
190
|
+
return LocalStore(local_path)
|
|
191
|
+
|
|
192
|
+
import fsspec
|
|
193
|
+
from zarr.storage import FsspecStore # type: ignore[import-untyped]
|
|
194
|
+
|
|
195
|
+
fs_kwargs = {
|
|
196
|
+
**self._storage_options,
|
|
197
|
+
"asynchronous": True,
|
|
198
|
+
"max_concurrency": self._max_concurrency,
|
|
199
|
+
}
|
|
200
|
+
fs = fsspec.filesystem(scheme, **fs_kwargs)
|
|
201
|
+
path = f"{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path
|
|
202
|
+
return FsspecStore(fs=fs, path=path)
|
|
203
|
+
|
|
204
|
+
def _get_root(self) -> Any:
|
|
205
|
+
if self._root_group is None:
|
|
206
|
+
self.connect()
|
|
207
|
+
assert self._root_group is not None
|
|
208
|
+
return self._root_group
|
|
209
|
+
|
|
210
|
+
def _get_table_group(self, table: str) -> Any:
|
|
211
|
+
"""Get or create table group."""
|
|
212
|
+
root = self._get_root()
|
|
213
|
+
if not self._readonly:
|
|
214
|
+
table_group = root.require_group(table)
|
|
215
|
+
self._ensure_index(table_group, table)
|
|
216
|
+
return table_group
|
|
217
|
+
if table in root:
|
|
218
|
+
return root[table]
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def _encode_component(self, value: str) -> str:
|
|
222
|
+
return quote(value, safe="")
|
|
223
|
+
|
|
224
|
+
def _decode_component(self, value: str) -> str:
|
|
225
|
+
return unquote(value)
|
|
226
|
+
|
|
227
|
+
def _compute_optimal_chunks(
|
|
228
|
+
self, shape: tuple[int, ...], dtype: np.dtype
|
|
229
|
+
) -> tuple[int, ...]:
|
|
230
|
+
"""Compute chunks targeting _target_chunk_bytes for optimal cloud I/O."""
|
|
231
|
+
if not shape:
|
|
232
|
+
return ()
|
|
233
|
+
|
|
234
|
+
item_size = dtype.itemsize
|
|
235
|
+
total_elements = max(1, self._target_chunk_bytes // item_size)
|
|
236
|
+
|
|
237
|
+
ndim = len(shape)
|
|
238
|
+
if ndim == 2: # noqa: PLR2004
|
|
239
|
+
rows_per_chunk = max(1, total_elements // shape[1])
|
|
240
|
+
return (min(rows_per_chunk, shape[0]), shape[1])
|
|
241
|
+
|
|
242
|
+
if ndim == 1:
|
|
243
|
+
return (min(total_elements, shape[0]),)
|
|
244
|
+
|
|
245
|
+
chunk_side = max(1, int(total_elements ** (1.0 / ndim)))
|
|
246
|
+
return tuple(min(chunk_side, dim) for dim in shape)
|
|
247
|
+
|
|
248
|
+
_MIN_BYTES_FOR_COMPRESSION = 1024
|
|
249
|
+
|
|
250
|
+
def _get_compressors(self, nbytes: int) -> list[Any] | None:
|
|
251
|
+
"""Get compressor list for array creation (None if no compression)."""
|
|
252
|
+
if not self._compressor or nbytes < self._MIN_BYTES_FOR_COMPRESSION:
|
|
253
|
+
return None
|
|
254
|
+
try:
|
|
255
|
+
from zarr.codecs import BloscCodec
|
|
256
|
+
|
|
257
|
+
return [BloscCodec(cname=self._compressor, clevel=5, shuffle="shuffle")]
|
|
258
|
+
except ImportError:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def _ensure_index(self, table_group: Any, _table: str) -> None:
|
|
262
|
+
"""Ensure index exists for a table."""
|
|
263
|
+
idx_group = table_group.require_group(ZARR_INDEX_GROUP)
|
|
264
|
+
if ZARR_INDEX_OBJS not in idx_group:
|
|
265
|
+
self._write_index_set(idx_group, ZARR_INDEX_OBJS, set())
|
|
266
|
+
if ZARR_INDEX_FIELDS not in idx_group:
|
|
267
|
+
self._write_index_set(idx_group, ZARR_INDEX_FIELDS, set())
|
|
268
|
+
|
|
269
|
+
def _read_index_set(self, table_group: Any, name: str) -> set[str] | None:
|
|
270
|
+
idx_group = table_group.get(ZARR_INDEX_GROUP)
|
|
271
|
+
if idx_group is None or name not in idx_group:
|
|
272
|
+
return None
|
|
273
|
+
dataset = idx_group[name]
|
|
274
|
+
try:
|
|
275
|
+
payload = bytes(dataset[:]).decode("utf-8")
|
|
276
|
+
values = json.loads(payload)
|
|
277
|
+
if not isinstance(values, list):
|
|
278
|
+
msg = "Index payload is not a list"
|
|
279
|
+
raise TypeError(msg) # noqa: TRY301
|
|
280
|
+
return {str(v) for v in values}
|
|
281
|
+
except Exception as exc: # noqa: BLE001
|
|
282
|
+
logger.warning(
|
|
283
|
+
"Failed to read Zarr index '%s': %s",
|
|
284
|
+
name,
|
|
285
|
+
format_exception_message(exc),
|
|
286
|
+
exc_info=True,
|
|
287
|
+
)
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
def _write_index_set(self, idx_group: Any, name: str, values: set[str]) -> None:
|
|
291
|
+
payload = json.dumps(sorted(values), ensure_ascii=False).encode("utf-8")
|
|
292
|
+
data = np.frombuffer(payload, dtype=np.uint8)
|
|
293
|
+
idx_group.create_array(name, data=data, overwrite=True)
|
|
294
|
+
|
|
295
|
+
def _get_index_cache(self, table: str, name: str) -> frozenset[str] | None:
|
|
296
|
+
if name == ZARR_INDEX_OBJS:
|
|
297
|
+
return self._index_cache_objs.get(table)
|
|
298
|
+
return self._index_cache_fields.get(table)
|
|
299
|
+
|
|
300
|
+
def _set_index_cache(
|
|
301
|
+
self, table: str, name: str, values: frozenset[str] | None
|
|
302
|
+
) -> None:
|
|
303
|
+
if name == ZARR_INDEX_OBJS:
|
|
304
|
+
if values is not None:
|
|
305
|
+
self._index_cache_objs[table] = values
|
|
306
|
+
elif table in self._index_cache_objs:
|
|
307
|
+
del self._index_cache_objs[table]
|
|
308
|
+
elif values is not None:
|
|
309
|
+
self._index_cache_fields[table] = values
|
|
310
|
+
elif table in self._index_cache_fields:
|
|
311
|
+
del self._index_cache_fields[table]
|
|
312
|
+
|
|
313
|
+
def _load_index_or_scan(
|
|
314
|
+
self,
|
|
315
|
+
table_group: Any,
|
|
316
|
+
table: str,
|
|
317
|
+
name: str,
|
|
318
|
+
scan_func: Callable[[Any], set[str]],
|
|
319
|
+
) -> set[str]:
|
|
320
|
+
cached = self._get_index_cache(table, name)
|
|
321
|
+
if cached is not None:
|
|
322
|
+
return set(cached)
|
|
323
|
+
|
|
324
|
+
stored = self._read_index_set(table_group, name)
|
|
325
|
+
if stored is not None:
|
|
326
|
+
self._set_index_cache(table, name, frozenset(stored))
|
|
327
|
+
return stored
|
|
328
|
+
|
|
329
|
+
actual = scan_func(table_group)
|
|
330
|
+
if not self._readonly:
|
|
331
|
+
idx_group = table_group.require_group(ZARR_INDEX_GROUP)
|
|
332
|
+
self._write_index_set(idx_group, name, actual)
|
|
333
|
+
self._set_index_cache(table, name, frozenset(actual))
|
|
334
|
+
return actual
|
|
335
|
+
|
|
336
|
+
def _scan_actual_fields(self, table_group: Any) -> set[str]:
|
|
337
|
+
return {k for k in table_group if k != ZARR_INDEX_GROUP}
|
|
338
|
+
|
|
339
|
+
def _scan_actual_objs(self, table_group: Any) -> set[str]:
|
|
340
|
+
objs: set[str] = set()
|
|
341
|
+
for field in table_group:
|
|
342
|
+
if field == ZARR_INDEX_GROUP:
|
|
343
|
+
continue
|
|
344
|
+
objs.update(table_group[field].keys())
|
|
345
|
+
return objs
|
|
346
|
+
|
|
347
|
+
def _update_index_sets(
|
|
348
|
+
self,
|
|
349
|
+
table_group: Any,
|
|
350
|
+
table: str,
|
|
351
|
+
*,
|
|
352
|
+
add_objs: set[str] | None = None,
|
|
353
|
+
add_fields: set[str] | None = None,
|
|
354
|
+
remove_objs: set[str] | None = None,
|
|
355
|
+
remove_fields: set[str] | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
if self._readonly:
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
add_objs = add_objs or set()
|
|
361
|
+
add_fields = add_fields or set()
|
|
362
|
+
remove_objs = remove_objs or set()
|
|
363
|
+
remove_fields = remove_fields or set()
|
|
364
|
+
|
|
365
|
+
objs = self._load_index_or_scan(
|
|
366
|
+
table_group, table, ZARR_INDEX_OBJS, self._scan_actual_objs
|
|
367
|
+
)
|
|
368
|
+
fields = self._load_index_or_scan(
|
|
369
|
+
table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if add_objs:
|
|
373
|
+
objs.update(add_objs)
|
|
374
|
+
if remove_objs:
|
|
375
|
+
objs.difference_update(remove_objs)
|
|
376
|
+
if add_fields:
|
|
377
|
+
fields.update(add_fields)
|
|
378
|
+
if remove_fields:
|
|
379
|
+
fields.difference_update(remove_fields)
|
|
380
|
+
|
|
381
|
+
self._set_index_cache(table, ZARR_INDEX_OBJS, frozenset(objs))
|
|
382
|
+
self._set_index_cache(table, ZARR_INDEX_FIELDS, frozenset(fields))
|
|
383
|
+
|
|
384
|
+
if self._lazy_index_sync:
|
|
385
|
+
self._index_dirty[table] = True
|
|
386
|
+
else:
|
|
387
|
+
self._write_index_to_store(table_group, objs, fields)
|
|
388
|
+
self._index_dirty[table] = False
|
|
389
|
+
|
|
390
|
+
def _write_index_to_store(
|
|
391
|
+
self, table_group: Any, objs: set[str], fields: set[str]
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Write index sets to persistent store."""
|
|
394
|
+
idx_group = table_group.require_group(ZARR_INDEX_GROUP)
|
|
395
|
+
self._write_index_set(idx_group, ZARR_INDEX_OBJS, objs)
|
|
396
|
+
self._write_index_set(idx_group, ZARR_INDEX_FIELDS, fields)
|
|
397
|
+
|
|
398
|
+
def _sync_index(self, table: str) -> None:
|
|
399
|
+
"""Sync cached index to persistent store if dirty."""
|
|
400
|
+
if not self._index_dirty.get(table) or self._readonly:
|
|
401
|
+
return
|
|
402
|
+
table_group = self._get_table_group(table)
|
|
403
|
+
if table_group is None:
|
|
404
|
+
return
|
|
405
|
+
objs = self._get_index_cache(table, ZARR_INDEX_OBJS) or frozenset()
|
|
406
|
+
fields = self._get_index_cache(table, ZARR_INDEX_FIELDS) or frozenset()
|
|
407
|
+
self._write_index_to_store(table_group, set(objs), set(fields))
|
|
408
|
+
self._index_dirty[table] = False
|
|
409
|
+
|
|
410
|
+
def _obj_exists_anywhere(
|
|
411
|
+
self,
|
|
412
|
+
table_group: Any,
|
|
413
|
+
table: str,
|
|
414
|
+
enc_uid: str,
|
|
415
|
+
skip_field: str | None,
|
|
416
|
+
*,
|
|
417
|
+
use_cache: bool = True,
|
|
418
|
+
) -> bool:
|
|
419
|
+
"""Check if object exists in any field."""
|
|
420
|
+
if use_cache:
|
|
421
|
+
cached_objs = self._get_index_cache(table, ZARR_INDEX_OBJS)
|
|
422
|
+
if cached_objs is not None:
|
|
423
|
+
return enc_uid in cached_objs
|
|
424
|
+
|
|
425
|
+
fields = self._load_index_or_scan(
|
|
426
|
+
table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
|
|
427
|
+
)
|
|
428
|
+
for field in fields:
|
|
429
|
+
if field == skip_field:
|
|
430
|
+
continue
|
|
431
|
+
if field not in table_group:
|
|
432
|
+
continue
|
|
433
|
+
if enc_uid in table_group[field]:
|
|
434
|
+
return True
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
def _has_field(
|
|
438
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
439
|
+
) -> bool:
|
|
440
|
+
table_group = self._get_table_group(table)
|
|
441
|
+
if table_group is None:
|
|
442
|
+
return False
|
|
443
|
+
enc_field = self._encode_component(field_name)
|
|
444
|
+
enc_uid = self._encode_component(obj_uid)
|
|
445
|
+
try:
|
|
446
|
+
if enc_field not in table_group:
|
|
447
|
+
return False
|
|
448
|
+
return enc_uid in table_group[enc_field]
|
|
449
|
+
except Exception as exc:
|
|
450
|
+
msg = (
|
|
451
|
+
f"Failed to read Zarr field '{field_name}' for '{obj_uid}': "
|
|
452
|
+
f"{format_exception_message(exc)}"
|
|
453
|
+
)
|
|
454
|
+
raise OSError(msg) from exc
|
|
455
|
+
|
|
456
|
+
def _get_field(
|
|
457
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
458
|
+
) -> Any:
|
|
459
|
+
table_group = self._get_table_group(table)
|
|
460
|
+
if table_group is None:
|
|
461
|
+
msg = f"Field '{field_name}' of '{obj_uid}' not found"
|
|
462
|
+
raise KeyError(msg)
|
|
463
|
+
enc_field = self._encode_component(field_name)
|
|
464
|
+
enc_uid = self._encode_component(obj_uid)
|
|
465
|
+
try:
|
|
466
|
+
field_missing = (
|
|
467
|
+
enc_field not in table_group
|
|
468
|
+
or enc_uid not in table_group[enc_field]
|
|
469
|
+
)
|
|
470
|
+
if field_missing:
|
|
471
|
+
msg = f"Field '{field_name}' of '{obj_uid}' not found"
|
|
472
|
+
raise KeyError(msg) # noqa: TRY301
|
|
473
|
+
|
|
474
|
+
arr = table_group[enc_field][enc_uid]
|
|
475
|
+
kind = arr.attrs.get(STORAGE_ATTR_TYPE)
|
|
476
|
+
|
|
477
|
+
if kind == _TYPE_NDARRAY:
|
|
478
|
+
return arr[()]
|
|
479
|
+
if kind == _TYPE_JSON:
|
|
480
|
+
payload = bytes(arr[:]).decode("utf-8")
|
|
481
|
+
return json.loads(payload)
|
|
482
|
+
if kind == _TYPE_PICKLE:
|
|
483
|
+
payload = bytes(arr[:])
|
|
484
|
+
return pickle.loads(payload) # noqa: S301
|
|
485
|
+
|
|
486
|
+
msg = f"Unknown stored type: {kind}"
|
|
487
|
+
raise ValueError(msg) # noqa: TRY301
|
|
488
|
+
except KeyError:
|
|
489
|
+
raise
|
|
490
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
491
|
+
msg = (
|
|
492
|
+
f"Data corruption in '{field_name}/{obj_uid}': "
|
|
493
|
+
f"{format_exception_message(exc)}"
|
|
494
|
+
)
|
|
495
|
+
raise ValueError(msg) from exc
|
|
496
|
+
except Exception as exc:
|
|
497
|
+
msg = (
|
|
498
|
+
f"Failed to read Zarr field '{field_name}' for '{obj_uid}': "
|
|
499
|
+
f"{format_exception_message(exc)}"
|
|
500
|
+
)
|
|
501
|
+
raise OSError(msg) from exc
|
|
502
|
+
|
|
503
|
+
def _get_field_metadata(
|
|
504
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
505
|
+
) -> dict[str, Any] | None:
|
|
506
|
+
table_group = self._get_table_group(table)
|
|
507
|
+
if table_group is None:
|
|
508
|
+
return None
|
|
509
|
+
enc_field = self._encode_component(field_name)
|
|
510
|
+
enc_uid = self._encode_component(obj_uid)
|
|
511
|
+
try:
|
|
512
|
+
field_missing = (
|
|
513
|
+
enc_field not in table_group
|
|
514
|
+
or enc_uid not in table_group[enc_field]
|
|
515
|
+
)
|
|
516
|
+
if field_missing:
|
|
517
|
+
return None
|
|
518
|
+
arr = table_group[enc_field][enc_uid]
|
|
519
|
+
raw_meta = arr.attrs.get(STORAGE_ATTR_META)
|
|
520
|
+
if raw_meta is None:
|
|
521
|
+
return None
|
|
522
|
+
if isinstance(raw_meta, bytes):
|
|
523
|
+
raw_meta = raw_meta.decode("utf-8")
|
|
524
|
+
return json.loads(raw_meta)
|
|
525
|
+
except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
|
|
526
|
+
return None
|
|
527
|
+
except Exception as exc:
|
|
528
|
+
msg = (
|
|
529
|
+
f"Failed to read metadata for '{field_name}/{obj_uid}': "
|
|
530
|
+
f"{format_exception_message(exc)}"
|
|
531
|
+
)
|
|
532
|
+
raise OSError(msg) from exc
|
|
533
|
+
|
|
534
|
+
def _list_fields(
|
|
535
|
+
self, obj_uid: UID = None, *, table: str = DEFAULT_TABLE
|
|
536
|
+
) -> list[str]:
|
|
537
|
+
table_group = self._get_table_group(table)
|
|
538
|
+
if table_group is None:
|
|
539
|
+
return []
|
|
540
|
+
|
|
541
|
+
if obj_uid is None:
|
|
542
|
+
fields = self._load_index_or_scan(
|
|
543
|
+
table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
|
|
544
|
+
)
|
|
545
|
+
return [self._decode_component(f) for f in sorted(fields)]
|
|
546
|
+
|
|
547
|
+
enc_uid = self._encode_component(obj_uid)
|
|
548
|
+
fields = self._load_index_or_scan(
|
|
549
|
+
table_group, table, ZARR_INDEX_FIELDS, self._scan_actual_fields
|
|
550
|
+
)
|
|
551
|
+
out = []
|
|
552
|
+
for field in fields:
|
|
553
|
+
if field not in table_group:
|
|
554
|
+
continue
|
|
555
|
+
# Direct membership check avoids creating intermediate set
|
|
556
|
+
if enc_uid in table_group[field]:
|
|
557
|
+
out.append(self._decode_component(field))
|
|
558
|
+
return sorted(out)
|
|
559
|
+
|
|
560
|
+
def _list_objs(self, *, table: str = DEFAULT_TABLE) -> list[str]:
|
|
561
|
+
table_group = self._get_table_group(table)
|
|
562
|
+
if table_group is None:
|
|
563
|
+
return []
|
|
564
|
+
objs = self._load_index_or_scan(
|
|
565
|
+
table_group, table, ZARR_INDEX_OBJS, self._scan_actual_objs
|
|
566
|
+
)
|
|
567
|
+
decoded = [self._decode_component(o) for o in objs]
|
|
568
|
+
return sorted(decoded) if self._sort_list_objs else decoded
|
|
569
|
+
|
|
570
|
+
def _list_objs_has_field(
|
|
571
|
+
self, field_name: str, *, table: str = DEFAULT_TABLE
|
|
572
|
+
) -> list[UID]:
|
|
573
|
+
table_group = self._get_table_group(table)
|
|
574
|
+
if table_group is None:
|
|
575
|
+
return []
|
|
576
|
+
enc_field = self._encode_component(field_name)
|
|
577
|
+
try:
|
|
578
|
+
if enc_field not in table_group:
|
|
579
|
+
return []
|
|
580
|
+
return [self._decode_component(x) for x in table_group[enc_field]]
|
|
581
|
+
except Exception as exc:
|
|
582
|
+
msg = (
|
|
583
|
+
f"Failed to list objects for field '{field_name}': "
|
|
584
|
+
f"{format_exception_message(exc)}"
|
|
585
|
+
)
|
|
586
|
+
raise OSError(msg) from exc
|
|
587
|
+
|
|
588
|
+
def _serialize_value(
|
|
589
|
+
self, field_group: Any, enc_uid: str, value: Any
|
|
590
|
+
) -> tuple[Any, str]:
|
|
591
|
+
"""Serialize value to Zarr array. Returns (array, type_marker)."""
|
|
592
|
+
if isinstance(value, np.ndarray):
|
|
593
|
+
chunks = (
|
|
594
|
+
self._chunks
|
|
595
|
+
if isinstance(self._chunks, tuple)
|
|
596
|
+
else self._compute_optimal_chunks(value.shape, value.dtype)
|
|
597
|
+
)
|
|
598
|
+
compressors = self._get_compressors(value.nbytes)
|
|
599
|
+
arr = field_group.create_array(
|
|
600
|
+
enc_uid,
|
|
601
|
+
data=value,
|
|
602
|
+
chunks=chunks,
|
|
603
|
+
compressors=compressors,
|
|
604
|
+
overwrite=True,
|
|
605
|
+
)
|
|
606
|
+
return arr, _TYPE_NDARRAY
|
|
607
|
+
|
|
608
|
+
if isinstance(value, np.generic):
|
|
609
|
+
value = value.item()
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
payload = json.dumps(value, ensure_ascii=False).encode("utf-8")
|
|
613
|
+
data = np.frombuffer(payload, dtype=np.uint8)
|
|
614
|
+
arr = field_group.create_array(
|
|
615
|
+
enc_uid, data=data, chunks=(len(data),), overwrite=True
|
|
616
|
+
)
|
|
617
|
+
except (TypeError, ValueError):
|
|
618
|
+
payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
|
|
619
|
+
data = np.frombuffer(payload, dtype=np.uint8)
|
|
620
|
+
arr = field_group.create_array(
|
|
621
|
+
enc_uid, data=data, chunks=(len(data),), overwrite=True
|
|
622
|
+
)
|
|
623
|
+
return arr, _TYPE_PICKLE
|
|
624
|
+
else:
|
|
625
|
+
return arr, _TYPE_JSON
|
|
626
|
+
|
|
627
|
+
def _flush_set_field_batch(self) -> None:
|
|
628
|
+
if not self._set_field_batch:
|
|
629
|
+
return
|
|
630
|
+
if self._readonly:
|
|
631
|
+
raise OSError(_READONLY_ERROR)
|
|
632
|
+
|
|
633
|
+
done: list[tuple[str, UID, str]] = []
|
|
634
|
+
new_objs_by_table: dict[str, set[str]] = {}
|
|
635
|
+
new_fields_by_table: dict[str, set[str]] = {}
|
|
636
|
+
|
|
637
|
+
# Cache table_groups and field_group.keys() to reduce remote calls
|
|
638
|
+
table_groups: dict[str, Any] = {}
|
|
639
|
+
field_keys_cache: dict[tuple[str, str], set[str]] = {}
|
|
640
|
+
|
|
641
|
+
with self._write_lock:
|
|
642
|
+
for (tbl, obj_uid, field_name), value in self._set_field_batch.items():
|
|
643
|
+
# Cache table_group lookup
|
|
644
|
+
if tbl not in table_groups:
|
|
645
|
+
table_groups[tbl] = self._get_table_group(tbl)
|
|
646
|
+
table_group = table_groups[tbl]
|
|
647
|
+
|
|
648
|
+
enc_field = self._encode_component(field_name)
|
|
649
|
+
enc_uid = self._encode_component(obj_uid)
|
|
650
|
+
field_group = table_group.require_group(enc_field)
|
|
651
|
+
|
|
652
|
+
# Cache field keys lookup
|
|
653
|
+
cache_key = (tbl, enc_field)
|
|
654
|
+
if cache_key not in field_keys_cache:
|
|
655
|
+
field_keys_cache[cache_key] = set(field_group.keys())
|
|
656
|
+
|
|
657
|
+
if enc_uid in field_keys_cache[cache_key]:
|
|
658
|
+
del field_group[enc_uid]
|
|
659
|
+
field_keys_cache[cache_key].discard(enc_uid)
|
|
660
|
+
|
|
661
|
+
meta_json = json.dumps(
|
|
662
|
+
infer_value_metadata(value).to_jsonable(), ensure_ascii=False
|
|
663
|
+
)
|
|
664
|
+
arr, type_marker = self._serialize_value(
|
|
665
|
+
field_group, enc_uid, value
|
|
666
|
+
)
|
|
667
|
+
arr.attrs[STORAGE_ATTR_TYPE] = type_marker
|
|
668
|
+
arr.attrs[STORAGE_ATTR_META] = meta_json
|
|
669
|
+
|
|
670
|
+
new_objs_by_table.setdefault(tbl, set()).add(enc_uid)
|
|
671
|
+
new_fields_by_table.setdefault(tbl, set()).add(enc_field)
|
|
672
|
+
done.append((tbl, obj_uid, field_name))
|
|
673
|
+
|
|
674
|
+
# Update indexes once per table
|
|
675
|
+
for tbl, table_group in table_groups.items():
|
|
676
|
+
self._update_index_sets(
|
|
677
|
+
table_group,
|
|
678
|
+
tbl,
|
|
679
|
+
add_objs=new_objs_by_table.get(tbl),
|
|
680
|
+
add_fields=new_fields_by_table.get(tbl),
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
self._clear_done_from_batch(done)
|
|
684
|
+
|
|
685
|
+
def _clear_done_from_batch(self, done: list[tuple[str, UID, str]]) -> None:
|
|
686
|
+
"""Remove completed items from batch and update pending bytes."""
|
|
687
|
+
for key in done:
|
|
688
|
+
self._set_field_batch.pop(key, None)
|
|
689
|
+
size = self._set_field_sizes.pop(key, 0)
|
|
690
|
+
if size:
|
|
691
|
+
self._pending_set_bytes = max(0, self._pending_set_bytes - size)
|
|
692
|
+
if not self._set_field_sizes:
|
|
693
|
+
self._pending_set_bytes = 0
|
|
694
|
+
|
|
695
|
+
def _group_erase_batch_by_table_field(
|
|
696
|
+
self,
|
|
697
|
+
) -> dict[tuple[str, str], set[str]]:
|
|
698
|
+
"""Group erase_field_batch by (table, enc_field) -> set of enc UIDs."""
|
|
699
|
+
grouped: dict[tuple[str, str], set[str]] = {}
|
|
700
|
+
for tbl, obj_uid, field_name in self._erase_field_batch:
|
|
701
|
+
enc_field = self._encode_component(field_name)
|
|
702
|
+
enc_uid = self._encode_component(obj_uid)
|
|
703
|
+
grouped.setdefault((tbl, enc_field), set()).add(enc_uid)
|
|
704
|
+
return grouped
|
|
705
|
+
|
|
706
|
+
def _delete_uids_from_field(
|
|
707
|
+
self,
|
|
708
|
+
table_group: Any,
|
|
709
|
+
enc_field: str,
|
|
710
|
+
enc_uids_to_remove: set[str],
|
|
711
|
+
) -> tuple[set[str], bool]:
|
|
712
|
+
"""Delete UIDs from a field group.
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
Tuple of (actually deleted UIDs, whether field became empty).
|
|
716
|
+
"""
|
|
717
|
+
if enc_field not in table_group:
|
|
718
|
+
return set(), False
|
|
719
|
+
|
|
720
|
+
field_group = table_group[enc_field]
|
|
721
|
+
existing_uids = set(field_group.keys())
|
|
722
|
+
uids_to_delete = enc_uids_to_remove & existing_uids
|
|
723
|
+
|
|
724
|
+
for enc_uid in uids_to_delete:
|
|
725
|
+
del field_group[enc_uid]
|
|
726
|
+
|
|
727
|
+
if uids_to_delete:
|
|
728
|
+
remaining = existing_uids - uids_to_delete
|
|
729
|
+
if not remaining:
|
|
730
|
+
del table_group[enc_field]
|
|
731
|
+
return uids_to_delete, True
|
|
732
|
+
|
|
733
|
+
return uids_to_delete, False
|
|
734
|
+
|
|
735
|
+
def _find_orphaned_objs(
|
|
736
|
+
self,
|
|
737
|
+
table_group: Any,
|
|
738
|
+
affected_objs: set[str],
|
|
739
|
+
) -> set[str]:
|
|
740
|
+
"""Find objects that no longer exist in any field."""
|
|
741
|
+
cached_fields = [f for f in table_group if f != ZARR_INDEX_GROUP]
|
|
742
|
+
# Build set of all existing UIDs across fields in one pass
|
|
743
|
+
all_existing_uids: set[str] = set()
|
|
744
|
+
for field in cached_fields:
|
|
745
|
+
all_existing_uids.update(table_group[field].keys())
|
|
746
|
+
|
|
747
|
+
return affected_objs - all_existing_uids
|
|
748
|
+
|
|
749
|
+
def _update_indexes_for_removals(
|
|
750
|
+
self,
|
|
751
|
+
table_groups: dict[str, Any],
|
|
752
|
+
removed_by_table: dict[str, _TableRemovalInfo],
|
|
753
|
+
) -> None:
|
|
754
|
+
"""Update indexes for all tables with removals."""
|
|
755
|
+
for tbl, removal_info in removed_by_table.items():
|
|
756
|
+
if not removal_info.removed_fields and not removal_info.removed_objs:
|
|
757
|
+
continue
|
|
758
|
+
table_group = table_groups.get(tbl)
|
|
759
|
+
if table_group is not None:
|
|
760
|
+
self._update_index_sets(
|
|
761
|
+
table_group,
|
|
762
|
+
tbl,
|
|
763
|
+
remove_fields=removal_info.removed_fields,
|
|
764
|
+
remove_objs=removal_info.removed_objs,
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def _flush_erase_field_batch(self) -> None:
|
|
768
|
+
if not self._erase_field_batch:
|
|
769
|
+
return
|
|
770
|
+
if self._readonly:
|
|
771
|
+
raise OSError(_READONLY_ERROR)
|
|
772
|
+
|
|
773
|
+
by_table_field = self._group_erase_batch_by_table_field()
|
|
774
|
+
removed_by_table: dict[str, _TableRemovalInfo] = {}
|
|
775
|
+
affected_by_table: dict[str, set[str]] = {}
|
|
776
|
+
table_groups: dict[str, Any] = {}
|
|
777
|
+
|
|
778
|
+
with self._write_lock:
|
|
779
|
+
self._process_field_deletions(
|
|
780
|
+
by_table_field, table_groups, removed_by_table, affected_by_table
|
|
781
|
+
)
|
|
782
|
+
self._process_orphaned_objects(
|
|
783
|
+
table_groups, affected_by_table, removed_by_table
|
|
784
|
+
)
|
|
785
|
+
self._update_indexes_for_removals(table_groups, removed_by_table)
|
|
786
|
+
|
|
787
|
+
self._erase_field_batch.clear()
|
|
788
|
+
|
|
789
|
+
def _process_field_deletions(
|
|
790
|
+
self,
|
|
791
|
+
by_table_field: dict[tuple[str, str], set[str]],
|
|
792
|
+
table_groups: dict[str, Any],
|
|
793
|
+
removed_by_table: dict[str, _TableRemovalInfo],
|
|
794
|
+
affected_by_table: dict[str, set[str]],
|
|
795
|
+
) -> None:
|
|
796
|
+
"""Delete UIDs from fields and track affected state."""
|
|
797
|
+
for (tbl, enc_field), enc_uids in by_table_field.items():
|
|
798
|
+
if tbl not in table_groups:
|
|
799
|
+
table_groups[tbl] = self._get_table_group(tbl)
|
|
800
|
+
table_group = table_groups[tbl]
|
|
801
|
+
if table_group is None:
|
|
802
|
+
continue
|
|
803
|
+
|
|
804
|
+
deleted, field_empty = self._delete_uids_from_field(
|
|
805
|
+
table_group, enc_field, enc_uids
|
|
806
|
+
)
|
|
807
|
+
if deleted:
|
|
808
|
+
affected_by_table.setdefault(tbl, set()).update(deleted)
|
|
809
|
+
if field_empty:
|
|
810
|
+
self._ensure_removal_info(removed_by_table, tbl).removed_fields.add(
|
|
811
|
+
enc_field
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
def _ensure_removal_info(
|
|
815
|
+
self, removed_by_table: dict[str, _TableRemovalInfo], tbl: str
|
|
816
|
+
) -> _TableRemovalInfo:
|
|
817
|
+
"""Get or create removal info for a table."""
|
|
818
|
+
if tbl not in removed_by_table:
|
|
819
|
+
removed_by_table[tbl] = _TableRemovalInfo(set(), set())
|
|
820
|
+
return removed_by_table[tbl]
|
|
821
|
+
|
|
822
|
+
def _process_orphaned_objects(
|
|
823
|
+
self,
|
|
824
|
+
table_groups: dict[str, Any],
|
|
825
|
+
affected_by_table: dict[str, set[str]],
|
|
826
|
+
removed_by_table: dict[str, _TableRemovalInfo],
|
|
827
|
+
) -> None:
|
|
828
|
+
"""Find and mark orphaned objects for removal."""
|
|
829
|
+
for tbl, affected_objs in affected_by_table.items():
|
|
830
|
+
table_group = table_groups.get(tbl)
|
|
831
|
+
if table_group is None:
|
|
832
|
+
continue
|
|
833
|
+
orphaned = self._find_orphaned_objs(table_group, affected_objs)
|
|
834
|
+
self._ensure_removal_info(removed_by_table, tbl).removed_objs.update(
|
|
835
|
+
orphaned
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
def _group_erase_obj_batch_by_table(self) -> dict[str, set[str]]:
|
|
839
|
+
"""Group erase_obj_batch by table -> set of encoded UIDs."""
|
|
840
|
+
grouped: dict[str, set[str]] = {}
|
|
841
|
+
for tbl, obj_uid in self._erase_obj_batch:
|
|
842
|
+
enc_uid = self._encode_component(obj_uid)
|
|
843
|
+
grouped.setdefault(tbl, set()).add(enc_uid)
|
|
844
|
+
return grouped
|
|
845
|
+
|
|
846
|
+
def _erase_objs_from_table(
|
|
847
|
+
self,
|
|
848
|
+
table_group: Any,
|
|
849
|
+
enc_uids_to_remove: set[str],
|
|
850
|
+
) -> set[str]:
|
|
851
|
+
"""Erase objects from all fields in table.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
Set of fields that became empty.
|
|
855
|
+
"""
|
|
856
|
+
cached_fields = [f for f in table_group if f != ZARR_INDEX_GROUP]
|
|
857
|
+
empty_fields: set[str] = set()
|
|
858
|
+
|
|
859
|
+
for field in cached_fields:
|
|
860
|
+
field_group = table_group[field]
|
|
861
|
+
existing_uids = set(field_group.keys())
|
|
862
|
+
uids_to_delete = enc_uids_to_remove & existing_uids
|
|
863
|
+
for enc_uid in uids_to_delete:
|
|
864
|
+
del field_group[enc_uid]
|
|
865
|
+
# Track remaining to avoid extra remote call for emptiness check
|
|
866
|
+
remaining = existing_uids - uids_to_delete
|
|
867
|
+
if not remaining:
|
|
868
|
+
del table_group[field]
|
|
869
|
+
empty_fields.add(field)
|
|
870
|
+
|
|
871
|
+
return empty_fields
|
|
872
|
+
|
|
873
|
+
def _flush_erase_obj_batch(self) -> None:
|
|
874
|
+
if not self._erase_obj_batch:
|
|
875
|
+
return
|
|
876
|
+
if self._readonly:
|
|
877
|
+
raise OSError(_READONLY_ERROR)
|
|
878
|
+
|
|
879
|
+
objs_by_table = self._group_erase_obj_batch_by_table()
|
|
880
|
+
removed_by_table: dict[str, _TableRemovalInfo] = {}
|
|
881
|
+
table_groups: dict[str, Any] = {}
|
|
882
|
+
|
|
883
|
+
with self._write_lock:
|
|
884
|
+
for tbl, enc_uids_to_remove in objs_by_table.items():
|
|
885
|
+
if tbl not in table_groups:
|
|
886
|
+
table_groups[tbl] = self._get_table_group(tbl)
|
|
887
|
+
table_group = table_groups[tbl]
|
|
888
|
+
if table_group is None:
|
|
889
|
+
continue
|
|
890
|
+
|
|
891
|
+
empty_fields = self._erase_objs_from_table(
|
|
892
|
+
table_group, enc_uids_to_remove
|
|
893
|
+
)
|
|
894
|
+
removed_by_table[tbl] = _TableRemovalInfo(
|
|
895
|
+
enc_uids_to_remove, empty_fields
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
self._update_indexes_for_removals(table_groups, removed_by_table)
|
|
899
|
+
|
|
900
|
+
self._erase_obj_batch.clear()
|
|
901
|
+
|
|
902
|
+
def _erase_field_from_table(
|
|
903
|
+
self,
|
|
904
|
+
table_group: Any,
|
|
905
|
+
enc_field: str,
|
|
906
|
+
) -> set[str]:
|
|
907
|
+
"""Erase field from table and return affected UIDs."""
|
|
908
|
+
if enc_field not in table_group:
|
|
909
|
+
return set()
|
|
910
|
+
field_group = table_group[enc_field]
|
|
911
|
+
affected_uids = set(field_group.keys())
|
|
912
|
+
del table_group[enc_field]
|
|
913
|
+
return affected_uids
|
|
914
|
+
|
|
915
|
+
def _flush_erase_field_for_all_batch(self) -> None:
|
|
916
|
+
if not self._erase_field_for_all_batch:
|
|
917
|
+
return
|
|
918
|
+
if self._readonly:
|
|
919
|
+
raise OSError(_READONLY_ERROR)
|
|
920
|
+
|
|
921
|
+
removed_by_table: dict[str, _TableRemovalInfo] = {}
|
|
922
|
+
affected_by_table: dict[str, set[str]] = {}
|
|
923
|
+
table_groups: dict[str, Any] = {}
|
|
924
|
+
|
|
925
|
+
with self._write_lock:
|
|
926
|
+
self._collect_fields_to_erase(
|
|
927
|
+
table_groups, removed_by_table, affected_by_table
|
|
928
|
+
)
|
|
929
|
+
self._process_orphaned_objects(
|
|
930
|
+
table_groups, affected_by_table, removed_by_table
|
|
931
|
+
)
|
|
932
|
+
self._update_indexes_for_removals(table_groups, removed_by_table)
|
|
933
|
+
|
|
934
|
+
self._erase_field_for_all_batch.clear()
|
|
935
|
+
|
|
936
|
+
def _collect_fields_to_erase(
|
|
937
|
+
self,
|
|
938
|
+
table_groups: dict[str, Any],
|
|
939
|
+
removed_by_table: dict[str, _TableRemovalInfo],
|
|
940
|
+
affected_by_table: dict[str, set[str]],
|
|
941
|
+
) -> None:
|
|
942
|
+
"""Delete fields and collect affected UIDs for erase_field_for_all."""
|
|
943
|
+
for tbl, field_name in self._erase_field_for_all_batch:
|
|
944
|
+
if tbl not in table_groups:
|
|
945
|
+
table_groups[tbl] = self._get_table_group(tbl)
|
|
946
|
+
table_group = table_groups[tbl]
|
|
947
|
+
if table_group is None:
|
|
948
|
+
continue
|
|
949
|
+
|
|
950
|
+
enc_field = self._encode_component(field_name)
|
|
951
|
+
affected = self._erase_field_from_table(table_group, enc_field)
|
|
952
|
+
if affected:
|
|
953
|
+
affected_by_table.setdefault(tbl, set()).update(affected)
|
|
954
|
+
self._ensure_removal_info(removed_by_table, tbl).removed_fields.add(
|
|
955
|
+
enc_field
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
def _clear(self, *, table: str | None = None) -> None:
|
|
959
|
+
if self._readonly:
|
|
960
|
+
raise OSError(_READONLY_ERROR)
|
|
961
|
+
|
|
962
|
+
with self._write_lock:
|
|
963
|
+
root = self._get_root()
|
|
964
|
+
if table is None:
|
|
965
|
+
self._clear_all_tables(root)
|
|
966
|
+
else:
|
|
967
|
+
self._clear_single_table(root, table)
|
|
968
|
+
|
|
969
|
+
def _clear_all_tables(self, root: Any) -> None:
|
|
970
|
+
"""Clear all tables from root."""
|
|
971
|
+
for key in tuple(root.keys()):
|
|
972
|
+
del root[key]
|
|
973
|
+
self._index_cache_objs.clear()
|
|
974
|
+
self._index_cache_fields.clear()
|
|
975
|
+
self._index_dirty.clear()
|
|
976
|
+
|
|
977
|
+
def _clear_single_table(self, root: Any, table: str) -> None:
|
|
978
|
+
"""Clear and recreate a single table."""
|
|
979
|
+
if table in root:
|
|
980
|
+
del root[table]
|
|
981
|
+
table_group = root.require_group(table)
|
|
982
|
+
idx_group = table_group.require_group(ZARR_INDEX_GROUP)
|
|
983
|
+
self._write_index_set(idx_group, ZARR_INDEX_OBJS, set())
|
|
984
|
+
self._write_index_set(idx_group, ZARR_INDEX_FIELDS, set())
|
|
985
|
+
self._set_index_cache(table, ZARR_INDEX_OBJS, frozenset())
|
|
986
|
+
self._set_index_cache(table, ZARR_INDEX_FIELDS, frozenset())
|
|
987
|
+
self._index_dirty[table] = False
|
|
988
|
+
|
|
989
|
+
def __del__(self) -> None:
|
|
990
|
+
"""Clean up resources on deletion."""
|
|
991
|
+
with contextlib.suppress(Exception):
|
|
992
|
+
self.close()
|