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,1062 @@
|
|
|
1
|
+
"""SQLite storage manager with optimized multithreaded reading support.
|
|
2
|
+
|
|
3
|
+
This module provides SQLite-based storage with connection pooling for
|
|
4
|
+
concurrent read operations while maintaining serialized writes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import pickle
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
|
16
|
+
from contextlib import closing, contextmanager, suppress
|
|
17
|
+
from io import BytesIO
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from queue import Empty, Full, Queue
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
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
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Generator
|
|
32
|
+
from types import TracebackType
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConnectionPool:
|
|
38
|
+
"""Thread-safe connection pool for read-only SQLite connections.
|
|
39
|
+
|
|
40
|
+
Maintains a pool of read-only connections that can be safely shared
|
|
41
|
+
across multiple threads for concurrent read operations.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
path: str,
|
|
47
|
+
pool_size: int = 8,
|
|
48
|
+
timeout: float = 5.0,
|
|
49
|
+
read_cache_size_kb: int = 32000,
|
|
50
|
+
read_mmap_size: int = 134217728,
|
|
51
|
+
pool_acquire_timeout: float = 1.0,
|
|
52
|
+
max_overflow: int = 2,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize connection pool.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
path: Path to SQLite database file.
|
|
58
|
+
pool_size: Maximum number of connections to maintain.
|
|
59
|
+
timeout: Timeout for database operations in seconds.
|
|
60
|
+
read_cache_size_kb: Cache size in kilobytes for read connections.
|
|
61
|
+
read_mmap_size: Memory-mapped I/O size in bytes for read connections.
|
|
62
|
+
pool_acquire_timeout: Timeout for acquiring connection from pool.
|
|
63
|
+
max_overflow: Maximum overflow multiplier for temporary connections.
|
|
64
|
+
"""
|
|
65
|
+
self._path = path
|
|
66
|
+
self._pool_size = pool_size
|
|
67
|
+
self._timeout = timeout
|
|
68
|
+
self._read_cache_size_kb = read_cache_size_kb
|
|
69
|
+
self._read_mmap_size = read_mmap_size
|
|
70
|
+
self._pool_acquire_timeout = pool_acquire_timeout
|
|
71
|
+
self._max_overflow = max_overflow
|
|
72
|
+
self._pool: Queue[sqlite3.Connection] = Queue(maxsize=pool_size)
|
|
73
|
+
self._created_connections = 0
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
self._initialized = False
|
|
76
|
+
|
|
77
|
+
def _initialize_pool(self) -> None:
|
|
78
|
+
"""Create initial pool of read-only connections."""
|
|
79
|
+
for _ in range(self._pool_size):
|
|
80
|
+
try:
|
|
81
|
+
conn = self._create_connection()
|
|
82
|
+
self._pool.put(conn, block=False)
|
|
83
|
+
except (OSError, sqlite3.Error) as e:
|
|
84
|
+
logger.warning(
|
|
85
|
+
"Failed to pre-create connection: %s",
|
|
86
|
+
format_exception_message(e),
|
|
87
|
+
)
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
def _create_connection(self) -> sqlite3.Connection:
|
|
91
|
+
"""Create a new read-only connection.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Configured read-only SQLite connection.
|
|
95
|
+
"""
|
|
96
|
+
uri = f"file:{self._path}?mode=ro"
|
|
97
|
+
|
|
98
|
+
conn = sqlite3.connect(
|
|
99
|
+
uri,
|
|
100
|
+
check_same_thread=False,
|
|
101
|
+
uri=True,
|
|
102
|
+
timeout=self._timeout,
|
|
103
|
+
isolation_level=None,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
with closing(conn.cursor()) as cur:
|
|
107
|
+
cur.execute("PRAGMA query_only = ON")
|
|
108
|
+
cur.execute("PRAGMA temp_store = MEMORY")
|
|
109
|
+
cur.execute(f"PRAGMA cache_size = -{self._read_cache_size_kb}")
|
|
110
|
+
cur.execute("PRAGMA page_size = 4096")
|
|
111
|
+
cur.execute(f"PRAGMA mmap_size = {self._read_mmap_size}")
|
|
112
|
+
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._created_connections += 1
|
|
115
|
+
|
|
116
|
+
return conn
|
|
117
|
+
|
|
118
|
+
@contextmanager
|
|
119
|
+
def acquire(self) -> Generator[sqlite3.Connection, None, None]:
|
|
120
|
+
"""Acquire a connection from the pool.
|
|
121
|
+
|
|
122
|
+
Yields:
|
|
123
|
+
Read-only SQLite connection.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
TimeoutError: If connection cannot be acquired within timeout.
|
|
127
|
+
"""
|
|
128
|
+
conn = None
|
|
129
|
+
try:
|
|
130
|
+
try:
|
|
131
|
+
conn = self._pool.get(timeout=self._pool_acquire_timeout)
|
|
132
|
+
except Empty:
|
|
133
|
+
with self._lock:
|
|
134
|
+
can_create = (
|
|
135
|
+
self._created_connections < self._pool_size * self._max_overflow
|
|
136
|
+
)
|
|
137
|
+
if can_create:
|
|
138
|
+
conn = self._create_connection()
|
|
139
|
+
else:
|
|
140
|
+
conn = self._pool.get(timeout=self._timeout)
|
|
141
|
+
|
|
142
|
+
if not self._validate_connection(conn):
|
|
143
|
+
conn.close()
|
|
144
|
+
conn = self._create_connection()
|
|
145
|
+
|
|
146
|
+
yield conn
|
|
147
|
+
|
|
148
|
+
finally:
|
|
149
|
+
if conn is not None:
|
|
150
|
+
try:
|
|
151
|
+
self._pool.put(conn, block=False)
|
|
152
|
+
except Full:
|
|
153
|
+
conn.close()
|
|
154
|
+
|
|
155
|
+
def _validate_connection(self, conn: sqlite3.Connection) -> bool:
|
|
156
|
+
"""Check if connection is still valid.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
conn: Connection to validate.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True if connection is valid, False otherwise.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
_ = conn.total_changes
|
|
166
|
+
except sqlite3.Error:
|
|
167
|
+
return False
|
|
168
|
+
else:
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
def connect(self) -> None:
|
|
172
|
+
"""Initialize the connection pool.
|
|
173
|
+
|
|
174
|
+
This method is idempotent - calling it multiple times has no effect
|
|
175
|
+
after the first call.
|
|
176
|
+
"""
|
|
177
|
+
if not self._initialized:
|
|
178
|
+
self._initialize_pool()
|
|
179
|
+
self._initialized = True
|
|
180
|
+
|
|
181
|
+
def close_all(self) -> None:
|
|
182
|
+
"""Close all connections in the pool."""
|
|
183
|
+
while True:
|
|
184
|
+
try:
|
|
185
|
+
conn = self._pool.get(block=False)
|
|
186
|
+
except Empty:
|
|
187
|
+
break
|
|
188
|
+
with suppress(sqlite3.Error, OSError):
|
|
189
|
+
conn.close()
|
|
190
|
+
# Reset initialized flag so connect() can reinitialize the pool
|
|
191
|
+
self._initialized = False
|
|
192
|
+
with self._lock:
|
|
193
|
+
self._created_connections = 0
|
|
194
|
+
|
|
195
|
+
def __del__(self) -> None:
|
|
196
|
+
"""Cleanup on deletion."""
|
|
197
|
+
with suppress(Exception):
|
|
198
|
+
self.close_all()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class SQLiteStorageManager(BaseStorageManager):
|
|
202
|
+
"""SQLite storage manager with optimized concurrent read support.
|
|
203
|
+
|
|
204
|
+
This implementation uses a connection pool for read operations and a
|
|
205
|
+
dedicated write connection with locking for write operations.
|
|
206
|
+
|
|
207
|
+
Read operations (concurrent):
|
|
208
|
+
- get_field, has_field, list_* methods
|
|
209
|
+
- Use read-only connection pool
|
|
210
|
+
- No locking required
|
|
211
|
+
- Safe for parallel execution
|
|
212
|
+
|
|
213
|
+
Write operations (serialized):
|
|
214
|
+
- set_field, erase_*, clear
|
|
215
|
+
- Use dedicated write connection
|
|
216
|
+
- Protected by write lock
|
|
217
|
+
- WAL mode allows concurrent reads during writes
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
path: str,
|
|
223
|
+
*,
|
|
224
|
+
wal_mode: bool = True,
|
|
225
|
+
synchronous: str = "NORMAL",
|
|
226
|
+
cache_size_kb: int = 64000,
|
|
227
|
+
use_json: bool = True,
|
|
228
|
+
readonly: bool = False,
|
|
229
|
+
timeout: float = 5.0,
|
|
230
|
+
read_pool_size: int = 8,
|
|
231
|
+
array_threshold: int = 128 * 1024 * 1024,
|
|
232
|
+
array_dir: str = "sqlite_blobs",
|
|
233
|
+
write_mmap_size: int = 268435456,
|
|
234
|
+
read_cache_size_kb: int = 32000,
|
|
235
|
+
read_mmap_size: int = 134217728,
|
|
236
|
+
pool_acquire_timeout: float = 1.0,
|
|
237
|
+
max_overflow: int = 2,
|
|
238
|
+
blob_write_workers: int = 4,
|
|
239
|
+
**kwargs: Any,
|
|
240
|
+
) -> None:
|
|
241
|
+
"""Initialize SQLite storage manager.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
path: Path to SQLite database file.
|
|
245
|
+
wal_mode: Enable Write-Ahead Logging for better concurrency.
|
|
246
|
+
synchronous: SQLite synchronous mode (NORMAL, FULL, OFF).
|
|
247
|
+
cache_size_kb: Cache size in kilobytes for write connection.
|
|
248
|
+
use_json: Prefer JSON serialization when possible.
|
|
249
|
+
readonly: Open database in read-only mode.
|
|
250
|
+
timeout: Database operation timeout in seconds.
|
|
251
|
+
read_pool_size: Number of read-only connections in pool.
|
|
252
|
+
array_threshold: Threshold in bytes for storing arrays in external files.
|
|
253
|
+
array_dir: Directory name for storing large array files.
|
|
254
|
+
write_mmap_size: Memory-mapped I/O size in bytes for write connection.
|
|
255
|
+
read_cache_size_kb: Cache size in kilobytes for read connections.
|
|
256
|
+
read_mmap_size: Memory-mapped I/O size in bytes for read connections.
|
|
257
|
+
pool_acquire_timeout: Timeout for acquiring connection from pool.
|
|
258
|
+
max_overflow: Maximum overflow multiplier for temporary connections.
|
|
259
|
+
batch_size_limit_bytes: Hard limit for buffer batch size before auto-flush.
|
|
260
|
+
batch_soft_limit_ratio: Ratio of limit to trigger early flush (e.g., 0.9).
|
|
261
|
+
blob_write_workers: Max workers for parallel blob file writes.
|
|
262
|
+
**kwargs: Additional keyword arguments for BaseStorageManager.
|
|
263
|
+
"""
|
|
264
|
+
super().__init__(use_json=use_json, **kwargs)
|
|
265
|
+
|
|
266
|
+
self._path = path
|
|
267
|
+
self._readonly = readonly
|
|
268
|
+
self._write_lock = threading.RLock() # Reentrant for nested contexts
|
|
269
|
+
self._base_dir = Path(path).parent
|
|
270
|
+
self._array_dir = self._base_dir / array_dir
|
|
271
|
+
self._array_threshold = array_threshold
|
|
272
|
+
|
|
273
|
+
self._wal_mode = wal_mode
|
|
274
|
+
self._synchronous = synchronous
|
|
275
|
+
self._cache_size_kb = cache_size_kb
|
|
276
|
+
self._timeout = timeout
|
|
277
|
+
self._write_mmap_size = write_mmap_size
|
|
278
|
+
self._blob_write_workers = max(1, blob_write_workers)
|
|
279
|
+
self._blob_executor: ThreadPoolExecutor | None = None
|
|
280
|
+
|
|
281
|
+
if not readonly:
|
|
282
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
self._array_dir.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
|
|
285
|
+
self._uri = f"file:{path}{'?mode=ro' if readonly else ''}"
|
|
286
|
+
|
|
287
|
+
self._write_conn = None
|
|
288
|
+
self._read_pool: ConnectionPool | None = (
|
|
289
|
+
ConnectionPool(
|
|
290
|
+
path=path,
|
|
291
|
+
pool_size=read_pool_size,
|
|
292
|
+
timeout=timeout,
|
|
293
|
+
read_cache_size_kb=read_cache_size_kb,
|
|
294
|
+
read_mmap_size=read_mmap_size,
|
|
295
|
+
pool_acquire_timeout=pool_acquire_timeout,
|
|
296
|
+
max_overflow=max_overflow,
|
|
297
|
+
)
|
|
298
|
+
if not readonly
|
|
299
|
+
else None
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def _apply_pragmas(self) -> None:
|
|
303
|
+
"""Apply SQLite pragmas to write connection."""
|
|
304
|
+
pragmas = [
|
|
305
|
+
("journal_mode", "WAL") if self._wal_mode and not self._readonly else None,
|
|
306
|
+
("synchronous", self._synchronous),
|
|
307
|
+
("cache_size", -self._cache_size_kb),
|
|
308
|
+
("temp_store", "MEMORY"),
|
|
309
|
+
("page_size", 4096),
|
|
310
|
+
("mmap_size", self._write_mmap_size) if not self._readonly else None,
|
|
311
|
+
]
|
|
312
|
+
with closing(self._write_conn.cursor()) as cur:
|
|
313
|
+
for pragma in pragmas:
|
|
314
|
+
if pragma:
|
|
315
|
+
key, value = pragma
|
|
316
|
+
cur.execute(f"PRAGMA {key}={value}")
|
|
317
|
+
|
|
318
|
+
def _init_schema(self) -> None:
|
|
319
|
+
"""Initialize database schema with table support."""
|
|
320
|
+
with closing(self._write_conn.cursor()) as cur:
|
|
321
|
+
cur.execute(
|
|
322
|
+
"""
|
|
323
|
+
CREATE TABLE IF NOT EXISTS storage (
|
|
324
|
+
table_name TEXT NOT NULL DEFAULT 'default',
|
|
325
|
+
field TEXT NOT NULL,
|
|
326
|
+
obj_uid TEXT NOT NULL,
|
|
327
|
+
value_type TEXT NOT NULL,
|
|
328
|
+
value_data BLOB NOT NULL,
|
|
329
|
+
file_path TEXT,
|
|
330
|
+
value_meta TEXT,
|
|
331
|
+
PRIMARY KEY (table_name, field, obj_uid)
|
|
332
|
+
)
|
|
333
|
+
"""
|
|
334
|
+
)
|
|
335
|
+
cur.execute(
|
|
336
|
+
"CREATE INDEX IF NOT EXISTS idx_table_field "
|
|
337
|
+
"ON storage(table_name, field)"
|
|
338
|
+
)
|
|
339
|
+
cur.execute(
|
|
340
|
+
"CREATE INDEX IF NOT EXISTS idx_table_obj "
|
|
341
|
+
"ON storage(table_name, obj_uid)"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
cur.execute(
|
|
345
|
+
"""
|
|
346
|
+
CREATE TABLE IF NOT EXISTS obj_registry (
|
|
347
|
+
table_name TEXT NOT NULL DEFAULT 'default',
|
|
348
|
+
obj_uid TEXT NOT NULL,
|
|
349
|
+
PRIMARY KEY (table_name, obj_uid)
|
|
350
|
+
) WITHOUT ROWID
|
|
351
|
+
"""
|
|
352
|
+
)
|
|
353
|
+
cur.execute(
|
|
354
|
+
"""
|
|
355
|
+
CREATE TABLE IF NOT EXISTS field_registry (
|
|
356
|
+
table_name TEXT NOT NULL DEFAULT 'default',
|
|
357
|
+
field TEXT NOT NULL,
|
|
358
|
+
PRIMARY KEY (table_name, field)
|
|
359
|
+
) WITHOUT ROWID
|
|
360
|
+
"""
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _serialize(self, value: Any) -> tuple[bytes, str]:
|
|
364
|
+
"""Serialize value to bytes.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
value: Value to serialize.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Tuple of (serialized bytes, type string).
|
|
371
|
+
"""
|
|
372
|
+
if isinstance(value, np.ndarray):
|
|
373
|
+
bio = BytesIO()
|
|
374
|
+
np.save(bio, value, allow_pickle=False)
|
|
375
|
+
return bio.getvalue(), "ndarray"
|
|
376
|
+
if self._use_json:
|
|
377
|
+
with suppress(TypeError, ValueError):
|
|
378
|
+
return json.dumps(value, ensure_ascii=False).encode("utf-8"), "json"
|
|
379
|
+
return pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL), "pickle"
|
|
380
|
+
|
|
381
|
+
def _encode_value_meta(self, value: Any) -> str:
|
|
382
|
+
"""Encode value metadata into json format."""
|
|
383
|
+
meta = infer_value_metadata(value)
|
|
384
|
+
return json.dumps(meta.to_jsonable(), ensure_ascii=False)
|
|
385
|
+
|
|
386
|
+
def _init_blob_executor(self) -> ThreadPoolExecutor:
|
|
387
|
+
"""Lazily initialize blob write executor."""
|
|
388
|
+
if self._blob_executor is None:
|
|
389
|
+
self._blob_executor = ThreadPoolExecutor(
|
|
390
|
+
max_workers=self._blob_write_workers, thread_name_prefix="sqlite-blob"
|
|
391
|
+
)
|
|
392
|
+
return self._blob_executor
|
|
393
|
+
|
|
394
|
+
def _submit_blob_write(self, file_path: str, data: bytes) -> Future[None]:
|
|
395
|
+
"""Submit blob write to executor."""
|
|
396
|
+
executor = self._init_blob_executor()
|
|
397
|
+
|
|
398
|
+
def _write() -> None:
|
|
399
|
+
path = Path(file_path)
|
|
400
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
401
|
+
with path.open("wb") as f:
|
|
402
|
+
f.write(data)
|
|
403
|
+
|
|
404
|
+
return executor.submit(_write)
|
|
405
|
+
|
|
406
|
+
def _wait_for_blob_writes(self, futures: list[Future[None]]) -> None:
|
|
407
|
+
"""Wait for blob writes to complete and surface exceptions."""
|
|
408
|
+
if not futures:
|
|
409
|
+
return
|
|
410
|
+
done, _ = wait(futures)
|
|
411
|
+
for fut in done:
|
|
412
|
+
exc = fut.exception()
|
|
413
|
+
if exc:
|
|
414
|
+
raise exc
|
|
415
|
+
|
|
416
|
+
def _deserialize(self, data: bytes, dtype: str) -> Any:
|
|
417
|
+
"""Deserialize bytes to value.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
data: Serialized bytes.
|
|
421
|
+
dtype: Type string.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Deserialized value.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
ValueError: If dtype is unknown.
|
|
428
|
+
"""
|
|
429
|
+
if dtype == "ndarray":
|
|
430
|
+
return np.load(BytesIO(data), allow_pickle=False)
|
|
431
|
+
if dtype == "json":
|
|
432
|
+
return json.loads(data.decode("utf-8"))
|
|
433
|
+
if dtype == "pickle":
|
|
434
|
+
return pickle.loads(data) # noqa: S301
|
|
435
|
+
msg = f"Unknown type: {dtype}"
|
|
436
|
+
raise ValueError(msg)
|
|
437
|
+
|
|
438
|
+
def _store_large_array(
|
|
439
|
+
self,
|
|
440
|
+
table: str,
|
|
441
|
+
field: str,
|
|
442
|
+
obj_uid: str,
|
|
443
|
+
arr_bytes: bytes,
|
|
444
|
+
futures: list[Future[None]],
|
|
445
|
+
) -> str:
|
|
446
|
+
"""Schedule storing large array to external file."""
|
|
447
|
+
fname = f"{table}_{field}_{obj_uid}_{os.urandom(8).hex()}.npy"
|
|
448
|
+
fpath = self._array_dir / fname
|
|
449
|
+
futures.append(self._submit_blob_write(str(fpath), arr_bytes))
|
|
450
|
+
return str(fpath)
|
|
451
|
+
|
|
452
|
+
def _load_large_array(self, file_path: str) -> np.ndarray:
|
|
453
|
+
"""Load array from external file.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
file_path: Path to array file.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
Loaded array.
|
|
460
|
+
"""
|
|
461
|
+
path = Path(file_path)
|
|
462
|
+
with path.open("rb") as f:
|
|
463
|
+
return np.load(f, allow_pickle=False)
|
|
464
|
+
|
|
465
|
+
def _delete_array_files(self, file_rows: list[tuple[str | None, str]]) -> None:
|
|
466
|
+
"""Delete array files asynchronously in background thread.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
file_rows: List of (file_path, value_type) tuples from database.
|
|
470
|
+
"""
|
|
471
|
+
if not file_rows:
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
file_paths = [
|
|
475
|
+
file_path
|
|
476
|
+
for file_path, dtype in file_rows
|
|
477
|
+
if dtype == "ndarray" and file_path
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
if not file_paths:
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
def _delete_files() -> None:
|
|
484
|
+
for file_path in file_paths:
|
|
485
|
+
with suppress(OSError):
|
|
486
|
+
Path(file_path).unlink()
|
|
487
|
+
|
|
488
|
+
thread = threading.Thread(target=_delete_files, daemon=True)
|
|
489
|
+
thread.start()
|
|
490
|
+
|
|
491
|
+
@contextmanager
|
|
492
|
+
def _transaction(self) -> Generator[None, None, None]:
|
|
493
|
+
"""Context manager for write transactions."""
|
|
494
|
+
acquired = False
|
|
495
|
+
try:
|
|
496
|
+
if self._context_depth == 0:
|
|
497
|
+
self._write_lock.acquire()
|
|
498
|
+
acquired = True
|
|
499
|
+
self._write_conn.execute("BEGIN IMMEDIATE")
|
|
500
|
+
self._context_depth += 1
|
|
501
|
+
yield
|
|
502
|
+
self._context_depth -= 1
|
|
503
|
+
if self._context_depth == 0 and acquired:
|
|
504
|
+
self._write_conn.execute("COMMIT")
|
|
505
|
+
except Exception:
|
|
506
|
+
self._context_depth = max(0, self._context_depth - 1)
|
|
507
|
+
if self._context_depth == 0 and acquired:
|
|
508
|
+
self._write_conn.execute("ROLLBACK")
|
|
509
|
+
raise
|
|
510
|
+
finally:
|
|
511
|
+
if self._context_depth == 0 and acquired:
|
|
512
|
+
self._write_lock.release()
|
|
513
|
+
|
|
514
|
+
def _execute_read(
|
|
515
|
+
self, query: str, params: tuple = (), fetchall: bool = True
|
|
516
|
+
) -> list[tuple]:
|
|
517
|
+
"""Execute read query using connection pool.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
query: SQL query string.
|
|
521
|
+
params: Query parameters.
|
|
522
|
+
fetchall: Whether to fetch all rows.
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
Query results as list of tuples.
|
|
526
|
+
|
|
527
|
+
Raises:
|
|
528
|
+
OSError: On database errors.
|
|
529
|
+
"""
|
|
530
|
+
try:
|
|
531
|
+
if self._write_conn is None:
|
|
532
|
+
self.connect()
|
|
533
|
+
|
|
534
|
+
if self._read_pool is not None:
|
|
535
|
+
with self._read_pool.acquire() as conn, closing(conn.cursor()) as cur:
|
|
536
|
+
if fetchall:
|
|
537
|
+
return cur.execute(query, params).fetchall()
|
|
538
|
+
return cur.execute(query, params).fetchone()
|
|
539
|
+
else:
|
|
540
|
+
with closing(self._write_conn.cursor()) as cur:
|
|
541
|
+
if fetchall:
|
|
542
|
+
return cur.execute(query, params).fetchall()
|
|
543
|
+
return cur.execute(query, params).fetchone()
|
|
544
|
+
except sqlite3.Error as e:
|
|
545
|
+
logger.exception("SQLite read error for query: %s", query)
|
|
546
|
+
msg = f"SQLite error: {format_exception_message(e)}"
|
|
547
|
+
raise OSError(msg) from e
|
|
548
|
+
|
|
549
|
+
def _execute_write(self, query: str, params: tuple | tuple[tuple] = ()) -> None:
|
|
550
|
+
"""Execute write query with transaction and locking.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
query: SQL query string.
|
|
554
|
+
params: Query parameters.
|
|
555
|
+
|
|
556
|
+
Raises:
|
|
557
|
+
OSError: On database errors.
|
|
558
|
+
"""
|
|
559
|
+
try:
|
|
560
|
+
if self._write_conn is None:
|
|
561
|
+
self.connect()
|
|
562
|
+
|
|
563
|
+
with self._transaction(), closing(self._write_conn.cursor()) as cur:
|
|
564
|
+
if params == ():
|
|
565
|
+
cur.execute(query)
|
|
566
|
+
return
|
|
567
|
+
if isinstance(params, (list, tuple)) and len(params) == 0:
|
|
568
|
+
return
|
|
569
|
+
if params and isinstance(params[0], tuple):
|
|
570
|
+
cur.executemany(query, params)
|
|
571
|
+
else:
|
|
572
|
+
cur.execute(query, params)
|
|
573
|
+
|
|
574
|
+
except sqlite3.Error as e:
|
|
575
|
+
logger.exception("SQLite write error for query: %s", query)
|
|
576
|
+
msg = f"SQLite error: {format_exception_message(e)}"
|
|
577
|
+
raise OSError(msg) from e
|
|
578
|
+
|
|
579
|
+
def _has_field(
|
|
580
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
581
|
+
) -> bool:
|
|
582
|
+
"""Check if field exists.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
obj_uid: Object unique identifier.
|
|
586
|
+
field_name: Field name.
|
|
587
|
+
table: Table name for logical data separation.
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
True if field exists, False otherwise.
|
|
591
|
+
"""
|
|
592
|
+
query = (
|
|
593
|
+
"SELECT 1 FROM storage "
|
|
594
|
+
"WHERE table_name = ? AND field = ? AND obj_uid = ? LIMIT 1"
|
|
595
|
+
)
|
|
596
|
+
try:
|
|
597
|
+
row = self._execute_read(
|
|
598
|
+
query, (table, field_name, obj_uid), fetchall=False
|
|
599
|
+
)
|
|
600
|
+
return row is not None # noqa: TRY300
|
|
601
|
+
except OSError:
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
def _get_field(
|
|
605
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
606
|
+
) -> Any:
|
|
607
|
+
"""Retrieve field value.
|
|
608
|
+
|
|
609
|
+
Args:
|
|
610
|
+
obj_uid: Object unique identifier.
|
|
611
|
+
field_name: Field name.
|
|
612
|
+
table: Table name for logical data separation.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
Field value.
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
KeyError: If field not found.
|
|
619
|
+
"""
|
|
620
|
+
row = self._execute_read(
|
|
621
|
+
"SELECT value_type, value_data, file_path FROM storage "
|
|
622
|
+
"WHERE table_name = ? AND field = ? AND obj_uid = ?",
|
|
623
|
+
(table, field_name, obj_uid),
|
|
624
|
+
fetchall=False,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
if row is None:
|
|
628
|
+
msg = f"Field '{field_name}' of '{obj_uid}' not found"
|
|
629
|
+
raise KeyError(msg)
|
|
630
|
+
|
|
631
|
+
dtype, data, file_path = row
|
|
632
|
+
if dtype == "ndarray" and file_path:
|
|
633
|
+
return self._load_large_array(file_path)
|
|
634
|
+
return self._deserialize(data, dtype)
|
|
635
|
+
|
|
636
|
+
def _get_field_metadata(
|
|
637
|
+
self, obj_uid: UID, field_name: str, *, table: str = DEFAULT_TABLE
|
|
638
|
+
) -> dict[str, Any] | None:
|
|
639
|
+
"""Return stored metadata for a field if present."""
|
|
640
|
+
query = (
|
|
641
|
+
"SELECT value_meta FROM storage "
|
|
642
|
+
"WHERE table_name = ? AND field = ? AND obj_uid = ?"
|
|
643
|
+
)
|
|
644
|
+
try:
|
|
645
|
+
row = self._execute_read(
|
|
646
|
+
query, (table, field_name, obj_uid), fetchall=False
|
|
647
|
+
)
|
|
648
|
+
if row is None or row[0] is None:
|
|
649
|
+
return None
|
|
650
|
+
try:
|
|
651
|
+
return json.loads(row[0])
|
|
652
|
+
except (json.JSONDecodeError, TypeError):
|
|
653
|
+
return None
|
|
654
|
+
except OSError:
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
def _list_fields(
|
|
658
|
+
self, obj_uid: UID = None, *, table: str = DEFAULT_TABLE
|
|
659
|
+
) -> list[str]:
|
|
660
|
+
"""List fields.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
obj_uid: Optional object unique identifier. If None, lists all fields.
|
|
664
|
+
table: Table name for logical data separation.
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
List of field names.
|
|
668
|
+
"""
|
|
669
|
+
if obj_uid is None:
|
|
670
|
+
rows = self._execute_read(
|
|
671
|
+
"SELECT field FROM field_registry WHERE table_name = ?", (table,)
|
|
672
|
+
)
|
|
673
|
+
else:
|
|
674
|
+
rows = self._execute_read(
|
|
675
|
+
"SELECT field FROM storage WHERE table_name = ? AND obj_uid = ?",
|
|
676
|
+
(table, obj_uid),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
return [row[0] for row in rows]
|
|
680
|
+
|
|
681
|
+
def _list_objs(self, *, table: str = DEFAULT_TABLE) -> list[str]:
|
|
682
|
+
"""List all objects.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
table: Table name for logical data separation.
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
List of object unique identifiers.
|
|
689
|
+
"""
|
|
690
|
+
rows = self._execute_read(
|
|
691
|
+
"SELECT obj_uid FROM obj_registry WHERE table_name = ?", (table,)
|
|
692
|
+
)
|
|
693
|
+
return [row[0] for row in rows]
|
|
694
|
+
|
|
695
|
+
def _list_objs_has_field(
|
|
696
|
+
self, field_name: str, *, table: str = DEFAULT_TABLE
|
|
697
|
+
) -> list[UID]:
|
|
698
|
+
"""List objects with specific field.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
field_name: Field name.
|
|
702
|
+
table: Table name for logical data separation.
|
|
703
|
+
|
|
704
|
+
Returns:
|
|
705
|
+
List of object unique identifiers.
|
|
706
|
+
"""
|
|
707
|
+
rows = self._execute_read(
|
|
708
|
+
"SELECT obj_uid FROM storage WHERE table_name = ? AND field = ?",
|
|
709
|
+
(table, field_name),
|
|
710
|
+
)
|
|
711
|
+
return [row[0] for row in rows]
|
|
712
|
+
|
|
713
|
+
def _flush_set_field_batch(self) -> None:
|
|
714
|
+
"""Store field values from batch."""
|
|
715
|
+
if not self._set_field_batch:
|
|
716
|
+
return
|
|
717
|
+
|
|
718
|
+
operations = []
|
|
719
|
+
done: list[tuple[str, UID, str]] = []
|
|
720
|
+
blob_futures: list[Future[None]] = []
|
|
721
|
+
new_obj_uids: set[tuple[str, str]] = set()
|
|
722
|
+
new_fields: set[tuple[str, str]] = set()
|
|
723
|
+
|
|
724
|
+
try:
|
|
725
|
+
for (tbl, obj_uid, field_name), value in self._set_field_batch.items():
|
|
726
|
+
data, dtype = self._serialize(value)
|
|
727
|
+
value_meta = self._encode_value_meta(value)
|
|
728
|
+
file_path = None
|
|
729
|
+
|
|
730
|
+
if dtype == "ndarray" and len(data) > self._array_threshold:
|
|
731
|
+
file_path = self._store_large_array(
|
|
732
|
+
tbl, field_name, obj_uid, data, blob_futures
|
|
733
|
+
)
|
|
734
|
+
data = b""
|
|
735
|
+
|
|
736
|
+
operations.append(
|
|
737
|
+
(tbl, field_name, obj_uid, dtype, data, file_path, value_meta)
|
|
738
|
+
)
|
|
739
|
+
done.append((tbl, obj_uid, field_name))
|
|
740
|
+
new_obj_uids.add((tbl, obj_uid))
|
|
741
|
+
new_fields.add((tbl, field_name))
|
|
742
|
+
|
|
743
|
+
self._wait_for_blob_writes(blob_futures)
|
|
744
|
+
self._execute_write(
|
|
745
|
+
"INSERT OR REPLACE INTO storage "
|
|
746
|
+
"(table_name, field, obj_uid, value_type, value_data, "
|
|
747
|
+
"file_path, value_meta) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
748
|
+
operations,
|
|
749
|
+
)
|
|
750
|
+
if new_obj_uids:
|
|
751
|
+
self._execute_write(
|
|
752
|
+
"INSERT OR IGNORE INTO obj_registry "
|
|
753
|
+
"(table_name, obj_uid) VALUES (?, ?)",
|
|
754
|
+
tuple(new_obj_uids),
|
|
755
|
+
)
|
|
756
|
+
if new_fields:
|
|
757
|
+
self._execute_write(
|
|
758
|
+
"INSERT OR IGNORE INTO field_registry "
|
|
759
|
+
"(table_name, field) VALUES (?, ?)",
|
|
760
|
+
tuple(new_fields),
|
|
761
|
+
)
|
|
762
|
+
finally:
|
|
763
|
+
for key in done:
|
|
764
|
+
del self._set_field_batch[key]
|
|
765
|
+
size = self._set_field_sizes.pop(key, 0)
|
|
766
|
+
if size:
|
|
767
|
+
self._pending_set_bytes = max(0, self._pending_set_bytes - size)
|
|
768
|
+
if not self._set_field_sizes:
|
|
769
|
+
self._pending_set_bytes = 0
|
|
770
|
+
|
|
771
|
+
def _flush_erase_field_batch(self) -> None:
|
|
772
|
+
"""Remove fields from batch."""
|
|
773
|
+
if not self._erase_field_batch:
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
try:
|
|
777
|
+
with self._transaction(), closing(self._write_conn.cursor()) as cur:
|
|
778
|
+
tmp_table = "tmp_erase_field"
|
|
779
|
+
cur.execute(
|
|
780
|
+
f"""
|
|
781
|
+
CREATE TEMP TABLE IF NOT EXISTS {tmp_table} (
|
|
782
|
+
table_name TEXT NOT NULL,
|
|
783
|
+
field TEXT NOT NULL,
|
|
784
|
+
obj_uid TEXT NOT NULL
|
|
785
|
+
)
|
|
786
|
+
"""
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
cur.executemany(
|
|
790
|
+
f"INSERT INTO {tmp_table} "
|
|
791
|
+
"(table_name, field, obj_uid) VALUES (?, ?, ?)",
|
|
792
|
+
tuple(
|
|
793
|
+
(tbl, field_name, obj_uid)
|
|
794
|
+
for tbl, obj_uid, field_name in self._erase_field_batch
|
|
795
|
+
),
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
rows = cur.execute(
|
|
799
|
+
f"""
|
|
800
|
+
SELECT file_path, value_type
|
|
801
|
+
FROM storage
|
|
802
|
+
WHERE (table_name, field, obj_uid) IN (
|
|
803
|
+
SELECT table_name, field, obj_uid FROM {tmp_table}
|
|
804
|
+
)
|
|
805
|
+
"""
|
|
806
|
+
).fetchall()
|
|
807
|
+
|
|
808
|
+
cur.execute(
|
|
809
|
+
f"""
|
|
810
|
+
DELETE FROM storage
|
|
811
|
+
WHERE (table_name, field, obj_uid) IN (
|
|
812
|
+
SELECT table_name, field, obj_uid FROM {tmp_table}
|
|
813
|
+
)
|
|
814
|
+
"""
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
cur.execute(
|
|
818
|
+
f"""
|
|
819
|
+
DELETE FROM obj_registry
|
|
820
|
+
WHERE (table_name, obj_uid) IN (
|
|
821
|
+
SELECT table_name, obj_uid FROM {tmp_table}
|
|
822
|
+
)
|
|
823
|
+
AND (table_name, obj_uid) NOT IN (
|
|
824
|
+
SELECT DISTINCT table_name, obj_uid FROM storage
|
|
825
|
+
)
|
|
826
|
+
"""
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
cur.execute(
|
|
830
|
+
f"""
|
|
831
|
+
DELETE FROM field_registry
|
|
832
|
+
WHERE (table_name, field) IN (
|
|
833
|
+
SELECT table_name, field FROM {tmp_table}
|
|
834
|
+
)
|
|
835
|
+
AND (table_name, field) NOT IN (
|
|
836
|
+
SELECT DISTINCT table_name, field FROM storage
|
|
837
|
+
)
|
|
838
|
+
"""
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
cur.execute(f"DROP TABLE IF EXISTS {tmp_table}")
|
|
842
|
+
|
|
843
|
+
self._delete_array_files(rows)
|
|
844
|
+
finally:
|
|
845
|
+
self._erase_field_batch.clear()
|
|
846
|
+
|
|
847
|
+
def _flush_erase_obj_batch(self) -> None:
|
|
848
|
+
"""Remove objects from batch."""
|
|
849
|
+
if not self._erase_obj_batch:
|
|
850
|
+
return
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
with self._transaction(), closing(self._write_conn.cursor()) as cur:
|
|
854
|
+
tmp_table = "tmp_erase_obj"
|
|
855
|
+
cur.execute(
|
|
856
|
+
f"""
|
|
857
|
+
CREATE TEMP TABLE IF NOT EXISTS {tmp_table} (
|
|
858
|
+
table_name TEXT NOT NULL,
|
|
859
|
+
obj_uid TEXT NOT NULL
|
|
860
|
+
)
|
|
861
|
+
"""
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
cur.executemany(
|
|
865
|
+
f"INSERT INTO {tmp_table} (table_name, obj_uid) VALUES (?, ?)",
|
|
866
|
+
tuple(self._erase_obj_batch),
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
rows = cur.execute(
|
|
870
|
+
f"""
|
|
871
|
+
SELECT file_path, value_type
|
|
872
|
+
FROM storage
|
|
873
|
+
WHERE (table_name, obj_uid) IN (
|
|
874
|
+
SELECT table_name, obj_uid FROM {tmp_table}
|
|
875
|
+
)
|
|
876
|
+
"""
|
|
877
|
+
).fetchall()
|
|
878
|
+
|
|
879
|
+
cur.execute(
|
|
880
|
+
f"""
|
|
881
|
+
DELETE FROM storage
|
|
882
|
+
WHERE (table_name, obj_uid) IN (
|
|
883
|
+
SELECT table_name, obj_uid FROM {tmp_table}
|
|
884
|
+
)
|
|
885
|
+
"""
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
cur.execute(
|
|
889
|
+
f"""
|
|
890
|
+
DELETE FROM obj_registry
|
|
891
|
+
WHERE (table_name, obj_uid) IN (
|
|
892
|
+
SELECT table_name, obj_uid FROM {tmp_table}
|
|
893
|
+
)
|
|
894
|
+
"""
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
cur.execute(
|
|
898
|
+
"""
|
|
899
|
+
DELETE FROM field_registry
|
|
900
|
+
WHERE (table_name, field) NOT IN (
|
|
901
|
+
SELECT DISTINCT table_name, field FROM storage
|
|
902
|
+
)
|
|
903
|
+
"""
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
cur.execute(f"DROP TABLE IF EXISTS {tmp_table}")
|
|
907
|
+
|
|
908
|
+
self._delete_array_files(rows)
|
|
909
|
+
finally:
|
|
910
|
+
self._erase_obj_batch.clear()
|
|
911
|
+
|
|
912
|
+
def _flush_erase_field_for_all_batch(self) -> None:
|
|
913
|
+
"""Remove field from all objects in batch."""
|
|
914
|
+
if not self._erase_field_for_all_batch:
|
|
915
|
+
return
|
|
916
|
+
|
|
917
|
+
try:
|
|
918
|
+
with self._transaction(), closing(self._write_conn.cursor()) as cur:
|
|
919
|
+
tmp_table = "tmp_erase_field_for_all"
|
|
920
|
+
cur.execute(
|
|
921
|
+
f"""
|
|
922
|
+
CREATE TEMP TABLE IF NOT EXISTS {tmp_table} (
|
|
923
|
+
table_name TEXT NOT NULL,
|
|
924
|
+
field TEXT NOT NULL
|
|
925
|
+
)
|
|
926
|
+
"""
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
cur.executemany(
|
|
930
|
+
f"INSERT INTO {tmp_table} (table_name, field) VALUES (?, ?)",
|
|
931
|
+
tuple(self._erase_field_for_all_batch),
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
rows = cur.execute(
|
|
935
|
+
f"""
|
|
936
|
+
SELECT file_path, value_type
|
|
937
|
+
FROM storage
|
|
938
|
+
WHERE (table_name, field) IN (
|
|
939
|
+
SELECT table_name, field FROM {tmp_table}
|
|
940
|
+
)
|
|
941
|
+
"""
|
|
942
|
+
).fetchall()
|
|
943
|
+
|
|
944
|
+
cur.execute(
|
|
945
|
+
f"""
|
|
946
|
+
DELETE FROM storage
|
|
947
|
+
WHERE (table_name, field) IN (
|
|
948
|
+
SELECT table_name, field FROM {tmp_table}
|
|
949
|
+
)
|
|
950
|
+
"""
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
cur.execute(
|
|
954
|
+
f"""
|
|
955
|
+
DELETE FROM field_registry
|
|
956
|
+
WHERE (table_name, field) IN (
|
|
957
|
+
SELECT table_name, field FROM {tmp_table}
|
|
958
|
+
)
|
|
959
|
+
"""
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
cur.execute(
|
|
963
|
+
"""
|
|
964
|
+
DELETE FROM obj_registry
|
|
965
|
+
WHERE (table_name, obj_uid) NOT IN (
|
|
966
|
+
SELECT DISTINCT table_name, obj_uid FROM storage
|
|
967
|
+
)
|
|
968
|
+
"""
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
cur.execute(f"DROP TABLE IF EXISTS {tmp_table}")
|
|
972
|
+
|
|
973
|
+
self._delete_array_files(rows)
|
|
974
|
+
finally:
|
|
975
|
+
self._erase_field_for_all_batch.clear()
|
|
976
|
+
|
|
977
|
+
def _clear(self, *, table: str | None = None) -> None:
|
|
978
|
+
"""Clear data from storage.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
table: If provided, clear only this table. If None, clear all data.
|
|
982
|
+
"""
|
|
983
|
+
if table is None:
|
|
984
|
+
rows = self._execute_read("SELECT file_path, value_type FROM storage")
|
|
985
|
+
with self._transaction(), closing(self._write_conn.cursor()) as cur:
|
|
986
|
+
cur.execute("DELETE FROM storage")
|
|
987
|
+
cur.execute("DELETE FROM obj_registry")
|
|
988
|
+
cur.execute("DELETE FROM field_registry")
|
|
989
|
+
else:
|
|
990
|
+
rows = self._execute_read(
|
|
991
|
+
"SELECT file_path, value_type FROM storage WHERE table_name = ?",
|
|
992
|
+
(table,),
|
|
993
|
+
)
|
|
994
|
+
with self._transaction(), closing(self._write_conn.cursor()) as cur:
|
|
995
|
+
cur.execute("DELETE FROM storage WHERE table_name = ?", (table,))
|
|
996
|
+
cur.execute("DELETE FROM obj_registry WHERE table_name = ?", (table,))
|
|
997
|
+
cur.execute("DELETE FROM field_registry WHERE table_name = ?", (table,))
|
|
998
|
+
|
|
999
|
+
self._delete_array_files(rows)
|
|
1000
|
+
|
|
1001
|
+
def connect(self) -> None:
|
|
1002
|
+
"""Connect to database and initialize schema."""
|
|
1003
|
+
self._write_conn = sqlite3.connect(
|
|
1004
|
+
self._uri,
|
|
1005
|
+
check_same_thread=False,
|
|
1006
|
+
uri=True,
|
|
1007
|
+
timeout=self._timeout,
|
|
1008
|
+
isolation_level=None,
|
|
1009
|
+
)
|
|
1010
|
+
self._apply_pragmas()
|
|
1011
|
+
if not self._readonly:
|
|
1012
|
+
self._init_schema()
|
|
1013
|
+
|
|
1014
|
+
if self._read_pool is not None:
|
|
1015
|
+
self._read_pool.connect()
|
|
1016
|
+
|
|
1017
|
+
def close(self) -> None:
|
|
1018
|
+
"""Close all connections."""
|
|
1019
|
+
if self._read_pool is not None:
|
|
1020
|
+
self._read_pool.close_all()
|
|
1021
|
+
if self._write_conn:
|
|
1022
|
+
self._write_conn.close()
|
|
1023
|
+
self._write_conn = None
|
|
1024
|
+
if self._blob_executor is not None:
|
|
1025
|
+
self._blob_executor.shutdown(wait=True)
|
|
1026
|
+
self._blob_executor = None
|
|
1027
|
+
|
|
1028
|
+
def __enter__(self) -> Self:
|
|
1029
|
+
"""Enter batch write context manager with transaction."""
|
|
1030
|
+
if self._context_depth == 0:
|
|
1031
|
+
self._write_lock.acquire()
|
|
1032
|
+
self._write_conn.execute("BEGIN IMMEDIATE")
|
|
1033
|
+
|
|
1034
|
+
return super().__enter__()
|
|
1035
|
+
|
|
1036
|
+
def __exit__(
|
|
1037
|
+
self,
|
|
1038
|
+
exc_type: type[BaseException] | None,
|
|
1039
|
+
exc_val: BaseException | None,
|
|
1040
|
+
exc_tb: TracebackType | None,
|
|
1041
|
+
) -> None:
|
|
1042
|
+
"""Exit batch write context manager."""
|
|
1043
|
+
try:
|
|
1044
|
+
if self._context_depth == 1:
|
|
1045
|
+
if exc_type is None:
|
|
1046
|
+
try:
|
|
1047
|
+
self._perform_batch_operations()
|
|
1048
|
+
self._write_conn.execute("COMMIT")
|
|
1049
|
+
except Exception:
|
|
1050
|
+
self._write_conn.execute("ROLLBACK")
|
|
1051
|
+
raise
|
|
1052
|
+
else:
|
|
1053
|
+
self._write_conn.execute("ROLLBACK")
|
|
1054
|
+
finally:
|
|
1055
|
+
self._context_depth = max(0, self._context_depth - 1)
|
|
1056
|
+
if self._context_depth == 0:
|
|
1057
|
+
self._write_lock.release()
|
|
1058
|
+
|
|
1059
|
+
def __del__(self) -> None:
|
|
1060
|
+
"""Cleanup on deletion."""
|
|
1061
|
+
with suppress(Exception):
|
|
1062
|
+
self.close()
|