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.
Files changed (193) hide show
  1. diffract/__init__.py +52 -0
  2. diffract/configs/fast_speed_without_disk.ini +48 -0
  3. diffract/configs/hybrid.ini +74 -0
  4. diffract/configs/sqlite.ini +62 -0
  5. diffract/containers.py +405 -0
  6. diffract/core/__init__.py +23 -0
  7. diffract/core/cache/__init__.py +24 -0
  8. diffract/core/cache/containers.py +93 -0
  9. diffract/core/cache/interface.py +117 -0
  10. diffract/core/cache/redis_manager.py +464 -0
  11. diffract/core/cache/simple_manager.py +478 -0
  12. diffract/core/compute/__init__.py +46 -0
  13. diffract/core/compute/config.py +48 -0
  14. diffract/core/compute/containers.py +81 -0
  15. diffract/core/compute/decorator.py +125 -0
  16. diffract/core/compute/exceptions.py +31 -0
  17. diffract/core/compute/execution/__init__.py +14 -0
  18. diffract/core/compute/execution/_types.py +70 -0
  19. diffract/core/compute/execution/aggregation.py +74 -0
  20. diffract/core/compute/execution/aggregation_runner.py +362 -0
  21. diffract/core/compute/execution/enums.py +38 -0
  22. diffract/core/compute/execution/executor.py +125 -0
  23. diffract/core/compute/execution/parameter_runner.py +125 -0
  24. diffract/core/compute/execution/restrictions.py +61 -0
  25. diffract/core/compute/execution/strategy.py +118 -0
  26. diffract/core/compute/execution/utils.py +112 -0
  27. diffract/core/compute/extensions/__init__.py +0 -0
  28. diffract/core/compute/extensions/power_law.py +1051 -0
  29. diffract/core/compute/extensions/rmt.py +150 -0
  30. diffract/core/compute/extensions/utils.py +36 -0
  31. diffract/core/compute/field_signature.py +183 -0
  32. diffract/core/compute/kernels/__init__.py +48 -0
  33. diffract/core/compute/kernels/alignment.py +106 -0
  34. diffract/core/compute/kernels/heavy_tailed.py +394 -0
  35. diffract/core/compute/kernels/marchenko_pastur.py +99 -0
  36. diffract/core/compute/kernels/mat_decomposition.py +101 -0
  37. diffract/core/compute/kernels/mat_properties.py +53 -0
  38. diffract/core/compute/kernels/model_quality.py +27 -0
  39. diffract/core/compute/kernels/norms.py +88 -0
  40. diffract/core/compute/kernels/ranks.py +35 -0
  41. diffract/core/compute/kernels/tracy_widom.py +36 -0
  42. diffract/core/compute/metadata.py +67 -0
  43. diffract/core/compute/registry.py +485 -0
  44. diffract/core/constants.py +147 -0
  45. diffract/core/data/__init__.py +32 -0
  46. diffract/core/data/interface.py +304 -0
  47. diffract/core/data/metadata/__init__.py +15 -0
  48. diffract/core/data/metadata/containers.py +60 -0
  49. diffract/core/data/metadata/interface.py +208 -0
  50. diffract/core/data/metadata/sqlite_index.py +604 -0
  51. diffract/core/data/nn/__init__.py +43 -0
  52. diffract/core/data/nn/aggregates/__init__.py +35 -0
  53. diffract/core/data/nn/aggregates/metadata.py +96 -0
  54. diffract/core/data/nn/aggregates/proxy.py +57 -0
  55. diffract/core/data/nn/aggregates/repository.py +87 -0
  56. diffract/core/data/nn/aggregates/view.py +307 -0
  57. diffract/core/data/nn/containers.py +86 -0
  58. diffract/core/data/nn/extractors/__init__.py +49 -0
  59. diffract/core/data/nn/extractors/base.py +300 -0
  60. diffract/core/data/nn/extractors/factory.py +234 -0
  61. diffract/core/data/nn/extractors/flax.py +112 -0
  62. diffract/core/data/nn/extractors/handlers/__init__.py +39 -0
  63. diffract/core/data/nn/extractors/handlers/base.py +110 -0
  64. diffract/core/data/nn/extractors/handlers/flax_handlers.py +71 -0
  65. diffract/core/data/nn/extractors/handlers/numpy_handlers.py +63 -0
  66. diffract/core/data/nn/extractors/handlers/onnx_handlers.py +67 -0
  67. diffract/core/data/nn/extractors/handlers/tensorflow_handlers.py +82 -0
  68. diffract/core/data/nn/extractors/handlers/torch_handlers.py +124 -0
  69. diffract/core/data/nn/extractors/interface.py +56 -0
  70. diffract/core/data/nn/extractors/numpy.py +94 -0
  71. diffract/core/data/nn/extractors/onnx.py +108 -0
  72. diffract/core/data/nn/extractors/tensorflow.py +99 -0
  73. diffract/core/data/nn/extractors/torch.py +204 -0
  74. diffract/core/data/nn/params/__init__.py +27 -0
  75. diffract/core/data/nn/params/interface.py +402 -0
  76. diffract/core/data/nn/params/metadata.py +110 -0
  77. diffract/core/data/nn/params/proxy.py +93 -0
  78. diffract/core/data/nn/params/repository.py +45 -0
  79. diffract/core/data/nn/params/schema.py +82 -0
  80. diffract/core/data/nn/params/view.py +393 -0
  81. diffract/core/data/proxy.py +246 -0
  82. diffract/core/data/repository.py +227 -0
  83. diffract/core/data/utils.py +33 -0
  84. diffract/core/data/view.py +389 -0
  85. diffract/core/export/__init__.py +27 -0
  86. diffract/core/export/containers.py +47 -0
  87. diffract/core/export/exporters.py +191 -0
  88. diffract/core/export/formatters/__init__.py +23 -0
  89. diffract/core/export/formatters/base.py +68 -0
  90. diffract/core/export/formatters/dict_formatter.py +38 -0
  91. diffract/core/export/formatters/json_formatter.py +58 -0
  92. diffract/core/export/formatters/list_formatter.py +41 -0
  93. diffract/core/export/formatters/pandas_formatter.py +86 -0
  94. diffract/core/export/formatters/polars_formatter.py +86 -0
  95. diffract/core/export/formatters/registry.py +84 -0
  96. diffract/core/export/interface.py +105 -0
  97. diffract/core/parallel/__init__.py +21 -0
  98. diffract/core/parallel/containers.py +57 -0
  99. diffract/core/parallel/execution.py +91 -0
  100. diffract/core/parallel/runtime.py +91 -0
  101. diffract/core/storage/__init__.py +35 -0
  102. diffract/core/storage/base_manager.py +330 -0
  103. diffract/core/storage/containers.py +122 -0
  104. diffract/core/storage/hdf5_manager.py +856 -0
  105. diffract/core/storage/hybrid_manager.py +218 -0
  106. diffract/core/storage/interface.py +222 -0
  107. diffract/core/storage/metadata.py +89 -0
  108. diffract/core/storage/ram_manager.py +159 -0
  109. diffract/core/storage/sqlite_manager.py +1062 -0
  110. diffract/core/storage/zarr_manager.py +992 -0
  111. diffract/core/utils/__init__.py +45 -0
  112. diffract/core/utils/build.py +46 -0
  113. diffract/core/utils/exceptions.py +11 -0
  114. diffract/core/utils/hashing.py +90 -0
  115. diffract/core/utils/imports.py +292 -0
  116. diffract/core/utils/math.py +15 -0
  117. diffract/py.typed +0 -0
  118. diffract/session/__init__.py +24 -0
  119. diffract/session/errors.py +17 -0
  120. diffract/session/field_cache.py +216 -0
  121. diffract/session/namespaces/__init__.py +15 -0
  122. diffract/session/namespaces/compute/__init__.py +219 -0
  123. diffract/session/namespaces/models/__init__.py +282 -0
  124. diffract/session/namespaces/models/parameters/__init__.py +133 -0
  125. diffract/session/namespaces/models/parameters/meta_patcher.py +167 -0
  126. diffract/session/namespaces/results/__init__.py +378 -0
  127. diffract/session/namespaces/results/eraser.py +129 -0
  128. diffract/session/namespaces/results/injester.py +249 -0
  129. diffract/session/namespaces/utils/__init__.py +141 -0
  130. diffract/session/namespaces/utils/merger.py +295 -0
  131. diffract/session/namespaces/viz/__init__.py +91 -0
  132. diffract/session/namespaces/viz/_utils.py +14 -0
  133. diffract/session/namespaces/viz/box.py +207 -0
  134. diffract/session/namespaces/viz/grid.py +160 -0
  135. diffract/session/namespaces/viz/heatmap.py +164 -0
  136. diffract/session/namespaces/viz/scatter.py +202 -0
  137. diffract/session/namespaces/viz/sparkline.py +270 -0
  138. diffract/session/namespaces/viz/violin.py +212 -0
  139. diffract/session/session.py +260 -0
  140. diffract/session/utils.py +81 -0
  141. diffract/viz/__init__.py +42 -0
  142. diffract/viz/data/__init__.py +34 -0
  143. diffract/viz/data/detection.py +57 -0
  144. diffract/viz/data/extraction.py +117 -0
  145. diffract/viz/data/filtering.py +57 -0
  146. diffract/viz/data/ordering.py +129 -0
  147. diffract/viz/data/provider.py +99 -0
  148. diffract/viz/data/types.py +98 -0
  149. diffract/viz/plots/__init__.py +37 -0
  150. diffract/viz/plots/base/__init__.py +20 -0
  151. diffract/viz/plots/base/axis.py +219 -0
  152. diffract/viz/plots/base/coloraxis.py +133 -0
  153. diffract/viz/plots/base/configurator.py +18 -0
  154. diffract/viz/plots/base/jitter.py +368 -0
  155. diffract/viz/plots/base/line.py +197 -0
  156. diffract/viz/plots/base/marker.py +278 -0
  157. diffract/viz/plots/base/overlay.py +14 -0
  158. diffract/viz/plots/base/plot.py +110 -0
  159. diffract/viz/plots/base/update.py +50 -0
  160. diffract/viz/plots/boxplot.py +283 -0
  161. diffract/viz/plots/cluster.py +374 -0
  162. diffract/viz/plots/heatmap.py +168 -0
  163. diffract/viz/plots/scatter.py +233 -0
  164. diffract/viz/plots/sparkline.py +405 -0
  165. diffract/viz/plots/subplots/__init__.py +32 -0
  166. diffract/viz/plots/subplots/coloraxis.py +339 -0
  167. diffract/viz/plots/subplots/factory.py +713 -0
  168. diffract/viz/plots/subplots/grid.py +214 -0
  169. diffract/viz/plots/subplots/layout.py +370 -0
  170. diffract/viz/plots/subplots/spec.py +39 -0
  171. diffract/viz/plots/violin.py +298 -0
  172. diffract/viz/renderer.py +513 -0
  173. diffract/viz/styling/__init__.py +78 -0
  174. diffract/viz/styling/palettes/__init__.py +20 -0
  175. diffract/viz/styling/palettes/bundle.py +16 -0
  176. diffract/viz/styling/palettes/color.py +83 -0
  177. diffract/viz/styling/palettes/dashes.py +27 -0
  178. diffract/viz/styling/palettes/symbols.py +42 -0
  179. diffract/viz/styling/resolvers/__init__.py +11 -0
  180. diffract/viz/styling/resolvers/categorical.py +68 -0
  181. diffract/viz/styling/resolvers/color.py +77 -0
  182. diffract/viz/styling/resolvers/numeric.py +108 -0
  183. diffract/viz/styling/sources.py +27 -0
  184. diffract/viz/styling/theme/__init__.py +29 -0
  185. diffract/viz/styling/theme/applier.py +114 -0
  186. diffract/viz/styling/theme/components.py +66 -0
  187. diffract/viz/styling/theme/presets.py +58 -0
  188. diffract/viz/styling/theme/theme.py +36 -0
  189. diffract_core-0.2.1.dist-info/METADATA +530 -0
  190. diffract_core-0.2.1.dist-info/RECORD +193 -0
  191. diffract_core-0.2.1.dist-info/WHEEL +4 -0
  192. diffract_core-0.2.1.dist-info/licenses/LICENSE +201 -0
  193. 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()