soupdb 0.6.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.
- engine/__init__.py +0 -0
- engine/__main__.py +86 -0
- engine/algorithms/__init__.py +0 -0
- engine/algorithms/external_hash.py +435 -0
- engine/algorithms/external_sort.py +307 -0
- engine/algorithms/spatial/__init__.py +44 -0
- engine/algorithms/spatial/bounds.py +40 -0
- engine/algorithms/spatial/geometry.py +109 -0
- engine/algorithms/spatial/knn.py +219 -0
- engine/algorithms/spatial/metrics.py +96 -0
- engine/algorithms/spatial/polygon_query.py +49 -0
- engine/algorithms/spatial/predicates.py +57 -0
- engine/algorithms/spatial/range_query.py +121 -0
- engine/algorithms/spatial/stats.py +51 -0
- engine/algorithms/spatial/traversal.py +42 -0
- engine/algorithms/spatial/validation.py +89 -0
- engine/common/__init__.py +0 -0
- engine/common/catalog.py +885 -0
- engine/common/errors.py +47 -0
- engine/common/page.py +1 -0
- engine/common/ranked.py +87 -0
- engine/common/ranked_catalog.py +88 -0
- engine/common/record.py +181 -0
- engine/common/rid.py +17 -0
- engine/common/schema.py +28 -0
- engine/indexes/__init__.py +0 -0
- engine/indexes/_bplus_page.py +312 -0
- engine/indexes/_hash_page.py +217 -0
- engine/indexes/_key_codec.py +111 -0
- engine/indexes/base.py +78 -0
- engine/indexes/bplus_tree.py +579 -0
- engine/indexes/clustered_bplus.py +98 -0
- engine/indexes/extendible_hash.py +296 -0
- engine/indexes/inverted/__init__.py +41 -0
- engine/indexes/ranked_factory.py +130 -0
- engine/indexes/rtree/__init__.py +9 -0
- engine/indexes/rtree/_codec.py +97 -0
- engine/indexes/rtree/_page_codec.py +140 -0
- engine/indexes/rtree/_page_store.py +208 -0
- engine/indexes/rtree/mbr.py +55 -0
- engine/indexes/rtree/node.py +66 -0
- engine/indexes/rtree/point.py +33 -0
- engine/indexes/rtree/queries.py +170 -0
- engine/indexes/rtree/rtree.py +268 -0
- engine/indexes/vector/__init__.py +51 -0
- engine/multimedia/__init__.py +35 -0
- engine/plugins/__init__.py +26 -0
- engine/plugins/base.py +85 -0
- engine/plugins/capabilities.py +27 -0
- engine/plugins/errors.py +15 -0
- engine/plugins/registry.py +258 -0
- engine/query/__init__.py +20 -0
- engine/query/ast.py +406 -0
- engine/query/errors.py +10 -0
- engine/query/evaluator.py +300 -0
- engine/query/executor.py +307 -0
- engine/query/format_expr.py +97 -0
- engine/query/index_maintenance.py +84 -0
- engine/query/lexer.py +215 -0
- engine/query/operators.py +1054 -0
- engine/query/params.py +34 -0
- engine/query/parser.py +1013 -0
- engine/query/planner.py +906 -0
- engine/query/ranked_binding.py +105 -0
- engine/query/ranked_candidates.py +47 -0
- engine/query/ranked_operators.py +209 -0
- engine/query/ranked_planning.py +193 -0
- engine/query/ranked_vector_planning.py +110 -0
- engine/query/resultset.py +20 -0
- engine/query/spatial_metrics.py +181 -0
- engine/query/tokens.py +36 -0
- engine/query/vector_operators.py +106 -0
- engine/storage/__init__.py +0 -0
- engine/storage/_sequential_page.py +63 -0
- engine/storage/_slotted_page.py +141 -0
- engine/storage/base.py +45 -0
- engine/storage/buffer_manager.py +77 -0
- engine/storage/disk_manager.py +93 -0
- engine/storage/file_manager.py +61 -0
- engine/storage/heap_file.py +150 -0
- engine/storage/io_counters.py +33 -0
- engine/storage/sequential_file.py +351 -0
- engine/transactions/__init__.py +0 -0
- engine/transactions/base.py +67 -0
- engine/transactions/connection_handler.py +208 -0
- engine/transactions/demo.py +130 -0
- engine/transactions/lock_manager.py +44 -0
- engine/transactions/protocol.py +457 -0
- engine/transactions/session.py +403 -0
- engine/transactions/strategies/__init__.py +0 -0
- engine/transactions/strategies/strict_2pl.py +165 -0
- engine/transactions/transaction_manager.py +75 -0
- soupdb-0.6.1.dist-info/METADATA +132 -0
- soupdb-0.6.1.dist-info/RECORD +96 -0
- soupdb-0.6.1.dist-info/WHEEL +4 -0
- soupdb-0.6.1.dist-info/licenses/LICENSE +21 -0
engine/__init__.py
ADDED
|
File without changes
|
engine/__main__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Punto de entrada del gestor: ``python -m engine``.
|
|
2
|
+
|
|
3
|
+
Levanta el servidor TCP que habla el protocolo binario con el driver y con
|
|
4
|
+
SoupChef. Sin esto el motor solo se puede usar escribiendo Python a mano, y el
|
|
5
|
+
contenedor no sirve nada en su puerto.
|
|
6
|
+
|
|
7
|
+
Toda la configuración llega por variables de entorno, que es lo que espera
|
|
8
|
+
``docker-compose.yml``:
|
|
9
|
+
|
|
10
|
+
==================== ========================== ======================
|
|
11
|
+
Variable Valor por defecto Qué controla
|
|
12
|
+
==================== ========================== ======================
|
|
13
|
+
``SOUPDB_DATA`` ``data`` Directorio de la base
|
|
14
|
+
``DRIVER_HOST`` ``0.0.0.0`` Interfaz de escucha
|
|
15
|
+
``DRIVER_PORT`` ``55432`` Puerto
|
|
16
|
+
``PAGE_SIZE`` ``4096`` Tamaño de página
|
|
17
|
+
``BUFFER_POOL_SIZE`` ``1024`` Páginas en el buffer pool
|
|
18
|
+
``MAX_CONNECTIONS`` ``100`` Conexiones simultáneas
|
|
19
|
+
``LOCK_TIMEOUT_MS`` ``5000`` Espera máxima por un lock
|
|
20
|
+
==================== ========================== ======================
|
|
21
|
+
|
|
22
|
+
Escucha en ``0.0.0.0`` y no en ``127.0.0.1`` porque dentro de un contenedor una
|
|
23
|
+
escucha en loopback no es alcanzable desde el host.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import signal
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from types import FrameType
|
|
31
|
+
|
|
32
|
+
from engine.common.catalog import Catalog
|
|
33
|
+
from engine.transactions.connection_handler import ConnectionHandler
|
|
34
|
+
from engine.transactions.transaction_manager import TransactionManager
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _env_int(name: str, default: int) -> int:
|
|
38
|
+
raw = os.environ.get(name)
|
|
39
|
+
if raw is None or raw.strip() == "":
|
|
40
|
+
return default
|
|
41
|
+
try:
|
|
42
|
+
return int(raw)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise SystemExit(f"{name} debe ser un entero, no {raw!r}") from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> None:
|
|
48
|
+
data_dir = Path(os.environ.get("SOUPDB_DATA", "data"))
|
|
49
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
catalog = Catalog(
|
|
52
|
+
data_dir,
|
|
53
|
+
page_size=_env_int("PAGE_SIZE", 4096),
|
|
54
|
+
buffer_capacity=_env_int("BUFFER_POOL_SIZE", 1024),
|
|
55
|
+
)
|
|
56
|
+
handler = ConnectionHandler(
|
|
57
|
+
catalog,
|
|
58
|
+
TransactionManager(),
|
|
59
|
+
host=os.environ.get("DRIVER_HOST", "0.0.0.0"),
|
|
60
|
+
port=_env_int("DRIVER_PORT", 55432),
|
|
61
|
+
max_connections=_env_int("MAX_CONNECTIONS", 100),
|
|
62
|
+
lock_timeout_ms=_env_int("LOCK_TIMEOUT_MS", 5000),
|
|
63
|
+
)
|
|
64
|
+
handler.bind()
|
|
65
|
+
|
|
66
|
+
def _shutdown(_signum: int, _frame: FrameType | None) -> None:
|
|
67
|
+
# Docker manda SIGTERM al parar el contenedor: hay que cerrar los
|
|
68
|
+
# archivos para no dejar la base a medio escribir.
|
|
69
|
+
handler.shutdown()
|
|
70
|
+
catalog.close()
|
|
71
|
+
sys.exit(0)
|
|
72
|
+
|
|
73
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
74
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
75
|
+
|
|
76
|
+
host = os.environ.get("DRIVER_HOST", "0.0.0.0")
|
|
77
|
+
print(f"SoupDB escuchando en {host}:{handler.port}, datos en {data_dir}", flush=True)
|
|
78
|
+
try:
|
|
79
|
+
handler.serve_forever()
|
|
80
|
+
finally:
|
|
81
|
+
handler.shutdown()
|
|
82
|
+
catalog.close()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""External hashing for GROUP BY and hash JOIN."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import struct
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import BinaryIO, TypeVar
|
|
9
|
+
|
|
10
|
+
from engine.common.record import Record
|
|
11
|
+
from engine.indexes._key_codec import encode_key
|
|
12
|
+
from engine.indexes.base import Key
|
|
13
|
+
|
|
14
|
+
Aggregate = TypeVar("Aggregate")
|
|
15
|
+
|
|
16
|
+
_LENGTH_FORMAT = "<I"
|
|
17
|
+
_LENGTH_SIZE = struct.calcsize(_LENGTH_FORMAT)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def external_hash_group_by(
|
|
21
|
+
records: Iterable[Record],
|
|
22
|
+
key_fn: Callable[[Record], Key],
|
|
23
|
+
aggregate_step: Callable[[Aggregate | None, Record], Aggregate],
|
|
24
|
+
*,
|
|
25
|
+
memory_limit_bytes: int,
|
|
26
|
+
partition_count: int = 8,
|
|
27
|
+
max_partition_depth: int = 4,
|
|
28
|
+
temp_dir: str | Path | None = None,
|
|
29
|
+
) -> Iterator[tuple[Key, Aggregate]]:
|
|
30
|
+
"""Group records using disk-backed hash partitioning."""
|
|
31
|
+
_validate_parameters(
|
|
32
|
+
memory_limit_bytes,
|
|
33
|
+
partition_count,
|
|
34
|
+
max_partition_depth,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
with tempfile.TemporaryDirectory(dir=temp_dir) as working_directory:
|
|
38
|
+
workdir = Path(working_directory)
|
|
39
|
+
|
|
40
|
+
partitions = _partition_records(
|
|
41
|
+
records,
|
|
42
|
+
key_fn,
|
|
43
|
+
workdir=workdir,
|
|
44
|
+
prefix="group",
|
|
45
|
+
partition_count=partition_count,
|
|
46
|
+
level=0,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
for partition in partitions:
|
|
50
|
+
yield from _group_partition(
|
|
51
|
+
partition,
|
|
52
|
+
key_fn,
|
|
53
|
+
aggregate_step,
|
|
54
|
+
workdir=workdir,
|
|
55
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
56
|
+
partition_count=partition_count,
|
|
57
|
+
level=1,
|
|
58
|
+
max_partition_depth=max_partition_depth,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def external_hash_join(
|
|
63
|
+
left_records: Iterable[Record],
|
|
64
|
+
right_records: Iterable[Record],
|
|
65
|
+
left_key_fn: Callable[[Record], Key],
|
|
66
|
+
right_key_fn: Callable[[Record], Key],
|
|
67
|
+
*,
|
|
68
|
+
memory_limit_bytes: int,
|
|
69
|
+
partition_count: int = 8,
|
|
70
|
+
max_partition_depth: int = 4,
|
|
71
|
+
temp_dir: str | Path | None = None,
|
|
72
|
+
) -> Iterator[tuple[Record, Record]]:
|
|
73
|
+
"""Perform an equality join using external hash partitioning."""
|
|
74
|
+
_validate_parameters(
|
|
75
|
+
memory_limit_bytes,
|
|
76
|
+
partition_count,
|
|
77
|
+
max_partition_depth,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with tempfile.TemporaryDirectory(dir=temp_dir) as working_directory:
|
|
81
|
+
workdir = Path(working_directory)
|
|
82
|
+
|
|
83
|
+
left_partitions = _partition_records(
|
|
84
|
+
left_records,
|
|
85
|
+
left_key_fn,
|
|
86
|
+
workdir=workdir,
|
|
87
|
+
prefix="join-left",
|
|
88
|
+
partition_count=partition_count,
|
|
89
|
+
level=0,
|
|
90
|
+
)
|
|
91
|
+
right_partitions = _partition_records(
|
|
92
|
+
right_records,
|
|
93
|
+
right_key_fn,
|
|
94
|
+
workdir=workdir,
|
|
95
|
+
prefix="join-right",
|
|
96
|
+
partition_count=partition_count,
|
|
97
|
+
level=0,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
for left_path, right_path in zip(
|
|
101
|
+
left_partitions,
|
|
102
|
+
right_partitions,
|
|
103
|
+
strict=True,
|
|
104
|
+
):
|
|
105
|
+
yield from _join_partition(
|
|
106
|
+
left_path,
|
|
107
|
+
right_path,
|
|
108
|
+
left_key_fn,
|
|
109
|
+
right_key_fn,
|
|
110
|
+
workdir=workdir,
|
|
111
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
112
|
+
partition_count=partition_count,
|
|
113
|
+
level=1,
|
|
114
|
+
max_partition_depth=max_partition_depth,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _group_partition(
|
|
119
|
+
path: Path,
|
|
120
|
+
key_fn: Callable[[Record], Key],
|
|
121
|
+
aggregate_step: Callable[[Aggregate | None, Record], Aggregate],
|
|
122
|
+
*,
|
|
123
|
+
workdir: Path,
|
|
124
|
+
memory_limit_bytes: int,
|
|
125
|
+
partition_count: int,
|
|
126
|
+
level: int,
|
|
127
|
+
max_partition_depth: int,
|
|
128
|
+
) -> Iterator[tuple[Key, Aggregate]]:
|
|
129
|
+
if path.stat().st_size > memory_limit_bytes and level <= max_partition_depth:
|
|
130
|
+
children = _partition_records(
|
|
131
|
+
_read_records(path),
|
|
132
|
+
key_fn,
|
|
133
|
+
workdir=workdir,
|
|
134
|
+
prefix=f"group-{level}-{path.stem}",
|
|
135
|
+
partition_count=partition_count,
|
|
136
|
+
level=level,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for child in children:
|
|
140
|
+
yield from _group_partition(
|
|
141
|
+
child,
|
|
142
|
+
key_fn,
|
|
143
|
+
aggregate_step,
|
|
144
|
+
workdir=workdir,
|
|
145
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
146
|
+
partition_count=partition_count,
|
|
147
|
+
level=level + 1,
|
|
148
|
+
max_partition_depth=max_partition_depth,
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
groups: dict[Key, Aggregate] = {}
|
|
153
|
+
|
|
154
|
+
for record in _read_records(path):
|
|
155
|
+
key = key_fn(record)
|
|
156
|
+
groups[key] = aggregate_step(groups.get(key), record)
|
|
157
|
+
|
|
158
|
+
yield from groups.items()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _join_partition(
|
|
162
|
+
left_path: Path,
|
|
163
|
+
right_path: Path,
|
|
164
|
+
left_key_fn: Callable[[Record], Key],
|
|
165
|
+
right_key_fn: Callable[[Record], Key],
|
|
166
|
+
*,
|
|
167
|
+
workdir: Path,
|
|
168
|
+
memory_limit_bytes: int,
|
|
169
|
+
partition_count: int,
|
|
170
|
+
level: int,
|
|
171
|
+
max_partition_depth: int,
|
|
172
|
+
) -> Iterator[tuple[Record, Record]]:
|
|
173
|
+
if left_path.stat().st_size == 0 or right_path.stat().st_size == 0:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
smaller_size = min(
|
|
177
|
+
left_path.stat().st_size,
|
|
178
|
+
right_path.stat().st_size,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if smaller_size <= memory_limit_bytes:
|
|
182
|
+
yield from _in_memory_join(
|
|
183
|
+
left_path,
|
|
184
|
+
right_path,
|
|
185
|
+
left_key_fn,
|
|
186
|
+
right_key_fn,
|
|
187
|
+
)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if level <= max_partition_depth:
|
|
191
|
+
left_children = _partition_records(
|
|
192
|
+
_read_records(left_path),
|
|
193
|
+
left_key_fn,
|
|
194
|
+
workdir=workdir,
|
|
195
|
+
prefix=f"left-{level}-{left_path.stem}",
|
|
196
|
+
partition_count=partition_count,
|
|
197
|
+
level=level,
|
|
198
|
+
)
|
|
199
|
+
right_children = _partition_records(
|
|
200
|
+
_read_records(right_path),
|
|
201
|
+
right_key_fn,
|
|
202
|
+
workdir=workdir,
|
|
203
|
+
prefix=f"right-{level}-{right_path.stem}",
|
|
204
|
+
partition_count=partition_count,
|
|
205
|
+
level=level,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
for child_left, child_right in zip(
|
|
209
|
+
left_children,
|
|
210
|
+
right_children,
|
|
211
|
+
strict=True,
|
|
212
|
+
):
|
|
213
|
+
yield from _join_partition(
|
|
214
|
+
child_left,
|
|
215
|
+
child_right,
|
|
216
|
+
left_key_fn,
|
|
217
|
+
right_key_fn,
|
|
218
|
+
workdir=workdir,
|
|
219
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
220
|
+
partition_count=partition_count,
|
|
221
|
+
level=level + 1,
|
|
222
|
+
max_partition_depth=max_partition_depth,
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
yield from _block_hash_join(
|
|
227
|
+
left_path,
|
|
228
|
+
right_path,
|
|
229
|
+
left_key_fn,
|
|
230
|
+
right_key_fn,
|
|
231
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _in_memory_join(
|
|
236
|
+
left_path: Path,
|
|
237
|
+
right_path: Path,
|
|
238
|
+
left_key_fn: Callable[[Record], Key],
|
|
239
|
+
right_key_fn: Callable[[Record], Key],
|
|
240
|
+
) -> Iterator[tuple[Record, Record]]:
|
|
241
|
+
build_left = left_path.stat().st_size <= right_path.stat().st_size
|
|
242
|
+
|
|
243
|
+
if build_left:
|
|
244
|
+
build_path = left_path
|
|
245
|
+
probe_path = right_path
|
|
246
|
+
build_key_fn = left_key_fn
|
|
247
|
+
probe_key_fn = right_key_fn
|
|
248
|
+
else:
|
|
249
|
+
build_path = right_path
|
|
250
|
+
probe_path = left_path
|
|
251
|
+
build_key_fn = right_key_fn
|
|
252
|
+
probe_key_fn = left_key_fn
|
|
253
|
+
|
|
254
|
+
table: dict[Key, list[Record]] = {}
|
|
255
|
+
|
|
256
|
+
for record in _read_records(build_path):
|
|
257
|
+
table.setdefault(build_key_fn(record), []).append(record)
|
|
258
|
+
|
|
259
|
+
for probe_record in _read_records(probe_path):
|
|
260
|
+
matches = table.get(probe_key_fn(probe_record), ())
|
|
261
|
+
|
|
262
|
+
for build_record in matches:
|
|
263
|
+
if build_left:
|
|
264
|
+
yield build_record, probe_record
|
|
265
|
+
else:
|
|
266
|
+
yield probe_record, build_record
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _block_hash_join(
|
|
270
|
+
left_path: Path,
|
|
271
|
+
right_path: Path,
|
|
272
|
+
left_key_fn: Callable[[Record], Key],
|
|
273
|
+
right_key_fn: Callable[[Record], Key],
|
|
274
|
+
*,
|
|
275
|
+
memory_limit_bytes: int,
|
|
276
|
+
) -> Iterator[tuple[Record, Record]]:
|
|
277
|
+
build_left = left_path.stat().st_size <= right_path.stat().st_size
|
|
278
|
+
|
|
279
|
+
if build_left:
|
|
280
|
+
build_path = left_path
|
|
281
|
+
probe_path = right_path
|
|
282
|
+
build_key_fn = left_key_fn
|
|
283
|
+
probe_key_fn = right_key_fn
|
|
284
|
+
else:
|
|
285
|
+
build_path = right_path
|
|
286
|
+
probe_path = left_path
|
|
287
|
+
build_key_fn = right_key_fn
|
|
288
|
+
probe_key_fn = left_key_fn
|
|
289
|
+
|
|
290
|
+
for chunk in _record_chunks(
|
|
291
|
+
build_path,
|
|
292
|
+
memory_limit_bytes=memory_limit_bytes,
|
|
293
|
+
):
|
|
294
|
+
table: dict[Key, list[Record]] = {}
|
|
295
|
+
|
|
296
|
+
for record in chunk:
|
|
297
|
+
table.setdefault(build_key_fn(record), []).append(record)
|
|
298
|
+
|
|
299
|
+
for probe_record in _read_records(probe_path):
|
|
300
|
+
matches = table.get(probe_key_fn(probe_record), ())
|
|
301
|
+
|
|
302
|
+
for build_record in matches:
|
|
303
|
+
if build_left:
|
|
304
|
+
yield build_record, probe_record
|
|
305
|
+
else:
|
|
306
|
+
yield probe_record, build_record
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _partition_records(
|
|
310
|
+
records: Iterable[Record],
|
|
311
|
+
key_fn: Callable[[Record], Key],
|
|
312
|
+
*,
|
|
313
|
+
workdir: Path,
|
|
314
|
+
prefix: str,
|
|
315
|
+
partition_count: int,
|
|
316
|
+
level: int,
|
|
317
|
+
) -> list[Path]:
|
|
318
|
+
paths = [
|
|
319
|
+
workdir / f"{prefix}-level-{level}-partition-{index}.bin"
|
|
320
|
+
for index in range(partition_count)
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
files = [path.open("wb") for path in paths]
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
for record in records:
|
|
327
|
+
partition = _partition_index(
|
|
328
|
+
key_fn(record),
|
|
329
|
+
partition_count=partition_count,
|
|
330
|
+
level=level,
|
|
331
|
+
)
|
|
332
|
+
_write_record(files[partition], record)
|
|
333
|
+
finally:
|
|
334
|
+
for file in files:
|
|
335
|
+
file.close()
|
|
336
|
+
|
|
337
|
+
return paths
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _partition_index(
|
|
341
|
+
key: Key,
|
|
342
|
+
*,
|
|
343
|
+
partition_count: int,
|
|
344
|
+
level: int,
|
|
345
|
+
) -> int:
|
|
346
|
+
encoded = encode_key(key)
|
|
347
|
+
salt = level.to_bytes(4, byteorder="little", signed=False)
|
|
348
|
+
digest = hashlib.blake2b(
|
|
349
|
+
encoded + salt,
|
|
350
|
+
digest_size=8,
|
|
351
|
+
).digest()
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
int.from_bytes(
|
|
355
|
+
digest,
|
|
356
|
+
byteorder="little",
|
|
357
|
+
signed=False,
|
|
358
|
+
)
|
|
359
|
+
% partition_count
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _record_chunks(
|
|
364
|
+
path: Path,
|
|
365
|
+
*,
|
|
366
|
+
memory_limit_bytes: int,
|
|
367
|
+
) -> Iterator[list[Record]]:
|
|
368
|
+
chunk: list[Record] = []
|
|
369
|
+
used_bytes = 0
|
|
370
|
+
|
|
371
|
+
for record in _read_records(path):
|
|
372
|
+
size = _serialized_size(record)
|
|
373
|
+
|
|
374
|
+
if chunk and used_bytes + size > memory_limit_bytes:
|
|
375
|
+
yield chunk
|
|
376
|
+
chunk = []
|
|
377
|
+
used_bytes = 0
|
|
378
|
+
|
|
379
|
+
chunk.append(record)
|
|
380
|
+
used_bytes += size
|
|
381
|
+
|
|
382
|
+
if used_bytes >= memory_limit_bytes:
|
|
383
|
+
yield chunk
|
|
384
|
+
chunk = []
|
|
385
|
+
used_bytes = 0
|
|
386
|
+
|
|
387
|
+
if chunk:
|
|
388
|
+
yield chunk
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _write_record(file: BinaryIO, record: Record) -> None:
|
|
392
|
+
data = record.data
|
|
393
|
+
|
|
394
|
+
if len(data) > 0xFFFFFFFF:
|
|
395
|
+
raise ValueError("record is too large for external-hash format")
|
|
396
|
+
|
|
397
|
+
file.write(struct.pack(_LENGTH_FORMAT, len(data)))
|
|
398
|
+
file.write(data)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _read_records(path: Path) -> Iterator[Record]:
|
|
402
|
+
with path.open("rb") as file:
|
|
403
|
+
while True:
|
|
404
|
+
length_data = file.read(_LENGTH_SIZE)
|
|
405
|
+
|
|
406
|
+
if not length_data:
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
if len(length_data) != _LENGTH_SIZE:
|
|
410
|
+
raise ValueError("truncated external-hash record length")
|
|
411
|
+
|
|
412
|
+
length = struct.unpack(_LENGTH_FORMAT, length_data)[0]
|
|
413
|
+
data = file.read(length)
|
|
414
|
+
|
|
415
|
+
if len(data) != length:
|
|
416
|
+
raise ValueError("truncated external-hash record")
|
|
417
|
+
|
|
418
|
+
yield Record(data=data)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _serialized_size(record: Record) -> int:
|
|
422
|
+
return _LENGTH_SIZE + len(record.data)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _validate_parameters(
|
|
426
|
+
memory_limit_bytes: int,
|
|
427
|
+
partition_count: int,
|
|
428
|
+
max_partition_depth: int,
|
|
429
|
+
) -> None:
|
|
430
|
+
if memory_limit_bytes <= 0:
|
|
431
|
+
raise ValueError("memory_limit_bytes must be positive")
|
|
432
|
+
if partition_count < 2:
|
|
433
|
+
raise ValueError("partition_count must be at least 2")
|
|
434
|
+
if max_partition_depth < 0:
|
|
435
|
+
raise ValueError("max_partition_depth must be non-negative")
|