kvgit 0.1.10__tar.gz → 0.2.0__tar.gz
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.
- {kvgit-0.1.10 → kvgit-0.2.0}/PKG-INFO +1 -1
- kvgit-0.2.0/kvgit/encoding.py +32 -0
- kvgit-0.2.0/kvgit/hamt.py +657 -0
- kvgit-0.2.0/kvgit/kv/base.py +131 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/composite.py +17 -10
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/disk.py +30 -11
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/indexeddb.py +26 -19
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/memory.py +15 -7
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/store.py +1 -1
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/base.py +14 -5
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/gp.py +14 -9
- kvgit-0.2.0/kvgit/versioned/keyset.py +234 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/kv.py +279 -208
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/SOURCES.txt +3 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/pyproject.toml +1 -1
- kvgit-0.2.0/tests/test_hamt.py +781 -0
- kvgit-0.2.0/tests/test_store_factory.py +99 -0
- kvgit-0.1.10/kvgit/encoding.py +0 -33
- kvgit-0.1.10/kvgit/kv/base.py +0 -62
- kvgit-0.1.10/tests/test_store_factory.py +0 -47
- {kvgit-0.1.10 → kvgit-0.2.0}/LICENSE +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/README.md +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/__init__.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/content_types.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/errors.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/namespaced.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/py.typed +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/staged.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/setup.cfg +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/tests/test_content_types.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/tests/test_namespaced.py +0 -0
- {kvgit-0.1.10 → kvgit-0.2.0}/tests/test_staged.py +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""JSON byte helpers used by the versioned storage layer.
|
|
2
|
+
|
|
3
|
+
Thin wrappers around ``json.dumps``/``json.loads`` that fix the byte
|
|
4
|
+
encoding to a deterministic compact form. Centralized here so that
|
|
5
|
+
the on-disk wire format is defined in exactly one place — anyone
|
|
6
|
+
who needs to construct or inspect kvgit storage bytes (including
|
|
7
|
+
tests and tooling) goes through these.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dumps(obj) -> bytes:
|
|
14
|
+
"""Serialize a JSON-safe Python object to bytes (compact, deterministic)."""
|
|
15
|
+
return json.dumps(obj, separators=(",", ":")).encode()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def loads(raw: bytes):
|
|
19
|
+
"""Deserialize JSON bytes to a Python object."""
|
|
20
|
+
return json.loads(raw)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def safe_loads(raw: bytes):
|
|
24
|
+
"""Like ``loads`` but returns None on any decode/parse error.
|
|
25
|
+
|
|
26
|
+
Useful when reading values from a store that may contain garbage
|
|
27
|
+
(corruption, partial writes, version skew).
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
return json.loads(raw)
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""Content-addressable Hash Array Mapped Trie (HAMT).
|
|
2
|
+
|
|
3
|
+
A persistent ``str -> bytes`` map laid out in a ``KVStore`` so that
|
|
4
|
+
unchanged subtrees are shared across versions by hash equality.
|
|
5
|
+
|
|
6
|
+
Each node is JSON-serialized and stored under its SHA-256 hash. A
|
|
7
|
+
HAMT is identified by its root node hash; mutations produce a new
|
|
8
|
+
root and a set of new node bytes that the caller persists (atomically,
|
|
9
|
+
if desired) by writing them to the underlying store.
|
|
10
|
+
|
|
11
|
+
Layering: this module knows nothing about kvgit's commit semantics.
|
|
12
|
+
It is a generic content-addressable map. See ``kvgit/versioned/keyset.py``
|
|
13
|
+
for the kvgit-specific wrapper that adds blob/meta entry semantics.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
from .kv.base import KVStore
|
|
23
|
+
|
|
24
|
+
# SHA-256 hex digest length. Each nibble is consumed once as the trie
|
|
25
|
+
# is descended, so this also bounds the maximum trie depth.
|
|
26
|
+
_HASH_LEN = 64
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _node_bytes(node: dict) -> bytes:
|
|
30
|
+
"""Serialize a node deterministically."""
|
|
31
|
+
return json.dumps(node, sort_keys=True, separators=(",", ":")).encode()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hash_bytes(b: bytes) -> str:
|
|
35
|
+
return hashlib.sha256(b).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _key_hash(key: str) -> str:
|
|
39
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _encode_value(value: bytes) -> str:
|
|
43
|
+
return base64.b64encode(value).decode("ascii")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _decode_value(s: str) -> bytes:
|
|
47
|
+
return base64.b64decode(s.encode("ascii"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Canonical empty leaf. Computed once at module load. The empty HAMT
|
|
51
|
+
# is represented by this hash; the node itself is never written to the
|
|
52
|
+
# store. Reads short-circuit on EMPTY_HASH; writes materialize a fresh
|
|
53
|
+
# leaf when needed.
|
|
54
|
+
_EMPTY_LEAF = {"items": {}, "kind": "leaf"}
|
|
55
|
+
_EMPTY_LEAF_BYTES = _node_bytes(_EMPTY_LEAF)
|
|
56
|
+
EMPTY_HASH: str = _hash_bytes(_EMPTY_LEAF_BYTES)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HamtDiff(NamedTuple):
|
|
60
|
+
"""Structural diff between two HAMT roots."""
|
|
61
|
+
|
|
62
|
+
added: dict[str, bytes]
|
|
63
|
+
removed: dict[str, bytes]
|
|
64
|
+
modified: dict[str, tuple[bytes, bytes]] # key -> (old, new)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Hamt:
|
|
68
|
+
"""Immutable, content-addressable HAMT view over a ``KVStore``.
|
|
69
|
+
|
|
70
|
+
Mutating methods (``updated``) return a new ``Hamt`` whose
|
|
71
|
+
``pending`` dict contains any new node bytes not yet flushed to
|
|
72
|
+
the store. Reads on the new view resolve through ``pending``
|
|
73
|
+
first, falling back to the store. Use ``flush()`` or ``commit()``
|
|
74
|
+
to persist, or merge ``pending`` into a larger write batch.
|
|
75
|
+
|
|
76
|
+
Two HAMTs with the same logical contents and the same
|
|
77
|
+
``bucket_max`` will have the same root hash, regardless of how
|
|
78
|
+
they were constructed. This invariant is what enables structural
|
|
79
|
+
sharing across versions.
|
|
80
|
+
|
|
81
|
+
The ``bucket_max`` parameter controls how many entries fit in a
|
|
82
|
+
leaf before it splits into a branch. Larger buckets mean fewer
|
|
83
|
+
nodes but larger leaves; smaller buckets mean more nodes with
|
|
84
|
+
finer-grained sharing. Note: a HAMT built with one ``bucket_max``
|
|
85
|
+
will hash differently from the same logical contents built with
|
|
86
|
+
another ``bucket_max``.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
store: KVStore
|
|
90
|
+
root: str
|
|
91
|
+
prefix: str
|
|
92
|
+
bucket_max: int
|
|
93
|
+
pending: dict[str, bytes] # prefixed key -> node bytes
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
store: KVStore,
|
|
98
|
+
root: str = EMPTY_HASH,
|
|
99
|
+
*,
|
|
100
|
+
prefix: str = "hamt:",
|
|
101
|
+
bucket_max: int = 8,
|
|
102
|
+
pending: dict[str, bytes] | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
if bucket_max < 1:
|
|
105
|
+
raise ValueError(f"bucket_max must be >= 1, got {bucket_max}")
|
|
106
|
+
self.store = store
|
|
107
|
+
self.root = root
|
|
108
|
+
self.prefix = prefix
|
|
109
|
+
self.bucket_max = bucket_max
|
|
110
|
+
self.pending = pending if pending is not None else {}
|
|
111
|
+
|
|
112
|
+
# ---- internal helpers ----
|
|
113
|
+
|
|
114
|
+
def _load(
|
|
115
|
+
self, node_hash: str, pending: dict[str, bytes] | None = None
|
|
116
|
+
) -> dict | None:
|
|
117
|
+
"""Load a node by hash. Checks the supplied pending dict first
|
|
118
|
+
(used during in-progress batch updates), then ``self.pending``,
|
|
119
|
+
then the store."""
|
|
120
|
+
if node_hash == EMPTY_HASH:
|
|
121
|
+
return {"items": {}, "kind": "leaf"}
|
|
122
|
+
prefixed = self.prefix + node_hash
|
|
123
|
+
if pending is not None and prefixed in pending:
|
|
124
|
+
return json.loads(pending[prefixed])
|
|
125
|
+
if prefixed in self.pending:
|
|
126
|
+
return json.loads(self.pending[prefixed])
|
|
127
|
+
raw = self.store.get(prefixed)
|
|
128
|
+
if raw is None:
|
|
129
|
+
return None
|
|
130
|
+
return json.loads(raw)
|
|
131
|
+
|
|
132
|
+
def _store_leaf(
|
|
133
|
+
self, encoded_items: Mapping[str, str], pending: dict[str, bytes]
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Materialize a leaf with the given (already-encoded) items."""
|
|
136
|
+
node = {"items": dict(encoded_items), "kind": "leaf"}
|
|
137
|
+
b = _node_bytes(node)
|
|
138
|
+
h = _hash_bytes(b)
|
|
139
|
+
pending[self.prefix + h] = b
|
|
140
|
+
return h
|
|
141
|
+
|
|
142
|
+
def _store_branch(
|
|
143
|
+
self, children: Mapping[str, str], pending: dict[str, bytes]
|
|
144
|
+
) -> str:
|
|
145
|
+
"""Materialize a branch with the given child hashes."""
|
|
146
|
+
node = {"children": dict(children), "kind": "branch"}
|
|
147
|
+
b = _node_bytes(node)
|
|
148
|
+
h = _hash_bytes(b)
|
|
149
|
+
pending[self.prefix + h] = b
|
|
150
|
+
return h
|
|
151
|
+
|
|
152
|
+
# ---- reads ----
|
|
153
|
+
|
|
154
|
+
def get(self, key: str) -> bytes | None:
|
|
155
|
+
"""Look up a key. Returns None if absent."""
|
|
156
|
+
if self.root == EMPTY_HASH:
|
|
157
|
+
return None
|
|
158
|
+
kh = _key_hash(key)
|
|
159
|
+
node_hash = self.root
|
|
160
|
+
depth = 0
|
|
161
|
+
while True:
|
|
162
|
+
node = self._load(node_hash)
|
|
163
|
+
if node is None:
|
|
164
|
+
return None
|
|
165
|
+
if node["kind"] == "leaf":
|
|
166
|
+
encoded = node["items"].get(key)
|
|
167
|
+
if encoded is None:
|
|
168
|
+
return None
|
|
169
|
+
return _decode_value(encoded)
|
|
170
|
+
chunk = kh[depth]
|
|
171
|
+
if chunk not in node["children"]:
|
|
172
|
+
return None
|
|
173
|
+
node_hash = node["children"][chunk]
|
|
174
|
+
depth += 1
|
|
175
|
+
|
|
176
|
+
def __contains__(self, key: str) -> bool:
|
|
177
|
+
return self.get(key) is not None
|
|
178
|
+
|
|
179
|
+
def items(self) -> Iterator[tuple[str, bytes]]:
|
|
180
|
+
"""Iterate over all (key, value) pairs lazily.
|
|
181
|
+
|
|
182
|
+
One store read per visited node. Use ``materialize()`` if
|
|
183
|
+
you want the whole map and the underlying store has
|
|
184
|
+
non-trivial per-call latency.
|
|
185
|
+
"""
|
|
186
|
+
if self.root == EMPTY_HASH:
|
|
187
|
+
return
|
|
188
|
+
yield from self._items_from(self.root)
|
|
189
|
+
|
|
190
|
+
def _items_from(self, node_hash: str) -> Iterator[tuple[str, bytes]]:
|
|
191
|
+
node = self._load(node_hash)
|
|
192
|
+
if node is None:
|
|
193
|
+
return
|
|
194
|
+
if node["kind"] == "leaf":
|
|
195
|
+
for k, v in node["items"].items():
|
|
196
|
+
yield k, _decode_value(v)
|
|
197
|
+
else:
|
|
198
|
+
for child_hash in node["children"].values():
|
|
199
|
+
yield from self._items_from(child_hash)
|
|
200
|
+
|
|
201
|
+
def materialize(self) -> dict[str, bytes]:
|
|
202
|
+
"""Walk the entire HAMT and return its contents as a dict.
|
|
203
|
+
|
|
204
|
+
Uses batched store reads — one ``get_many`` call per tree
|
|
205
|
+
level — so the cost is roughly O(log_branching N) round-trips
|
|
206
|
+
instead of one per node. For backends with non-trivial
|
|
207
|
+
per-call latency (Redis, IndexedDB) this is dramatically
|
|
208
|
+
faster than draining ``items()``.
|
|
209
|
+
|
|
210
|
+
For local backends (Memory, Disk) the speedup over ``items()``
|
|
211
|
+
is small because there's no per-call latency to amortize.
|
|
212
|
+
Use ``items()`` when you want laziness (e.g. to break out
|
|
213
|
+
early); use ``materialize()`` when you know you want the
|
|
214
|
+
whole map.
|
|
215
|
+
|
|
216
|
+
Equivalent to ``walk()[0]``.
|
|
217
|
+
"""
|
|
218
|
+
return self.walk()[0]
|
|
219
|
+
|
|
220
|
+
def walk(self) -> tuple[dict[str, bytes], set[str]]:
|
|
221
|
+
"""Walk the entire HAMT, returning (items, node_hashes).
|
|
222
|
+
|
|
223
|
+
Single batched BFS that collects both the key→value entries
|
|
224
|
+
and the set of every visited node hash. Equivalent to
|
|
225
|
+
calling ``materialize()`` and ``reachable_nodes()`` separately
|
|
226
|
+
but in one tree traversal — used by GC mark phases that want
|
|
227
|
+
both, like ``clean_orphans``.
|
|
228
|
+
|
|
229
|
+
Same batching properties as ``materialize()``: one
|
|
230
|
+
``get_many`` call per tree level, O(log_branching N)
|
|
231
|
+
round-trips total.
|
|
232
|
+
"""
|
|
233
|
+
if self.root == EMPTY_HASH:
|
|
234
|
+
return {}, set()
|
|
235
|
+
|
|
236
|
+
items: dict[str, bytes] = {}
|
|
237
|
+
nodes: set[str] = set()
|
|
238
|
+
current_level: list[str] = [self.root]
|
|
239
|
+
|
|
240
|
+
while current_level:
|
|
241
|
+
# Partition this level: nodes already in pending vs
|
|
242
|
+
# nodes that need to be fetched from the store.
|
|
243
|
+
cached_nodes: dict[str, dict] = {}
|
|
244
|
+
to_fetch: list[str] = []
|
|
245
|
+
for node_hash in current_level:
|
|
246
|
+
if node_hash == EMPTY_HASH:
|
|
247
|
+
continue
|
|
248
|
+
prefixed = self.prefix + node_hash
|
|
249
|
+
if prefixed in self.pending:
|
|
250
|
+
cached_nodes[node_hash] = json.loads(self.pending[prefixed])
|
|
251
|
+
else:
|
|
252
|
+
to_fetch.append(prefixed)
|
|
253
|
+
|
|
254
|
+
# Single batched fetch for everything at this level.
|
|
255
|
+
fetched: Mapping[str, bytes] = (
|
|
256
|
+
self.store.get_many(to_fetch) if to_fetch else {}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Walk the level: leaves contribute entries, branches
|
|
260
|
+
# contribute the next level's node hashes. Track every
|
|
261
|
+
# node hash we successfully load.
|
|
262
|
+
next_level: list[str] = []
|
|
263
|
+
for node_hash in current_level:
|
|
264
|
+
if node_hash == EMPTY_HASH:
|
|
265
|
+
continue
|
|
266
|
+
if node_hash in cached_nodes:
|
|
267
|
+
node = cached_nodes[node_hash]
|
|
268
|
+
else:
|
|
269
|
+
raw = fetched.get(self.prefix + node_hash)
|
|
270
|
+
if raw is None:
|
|
271
|
+
continue # missing — skip rather than crash
|
|
272
|
+
node = json.loads(raw)
|
|
273
|
+
|
|
274
|
+
nodes.add(node_hash)
|
|
275
|
+
|
|
276
|
+
if node["kind"] == "leaf":
|
|
277
|
+
for k, v in node["items"].items():
|
|
278
|
+
items[k] = _decode_value(v)
|
|
279
|
+
else: # branch
|
|
280
|
+
next_level.extend(node["children"].values())
|
|
281
|
+
|
|
282
|
+
current_level = next_level
|
|
283
|
+
|
|
284
|
+
return items, nodes
|
|
285
|
+
|
|
286
|
+
def keys(self) -> Iterator[str]:
|
|
287
|
+
for k, _ in self.items():
|
|
288
|
+
yield k
|
|
289
|
+
|
|
290
|
+
def values(self) -> Iterator[bytes]:
|
|
291
|
+
for _, v in self.items():
|
|
292
|
+
yield v
|
|
293
|
+
|
|
294
|
+
def __iter__(self) -> Iterator[str]:
|
|
295
|
+
return self.keys()
|
|
296
|
+
|
|
297
|
+
def __len__(self) -> int:
|
|
298
|
+
"""Total entry count. O(N) — walks the tree."""
|
|
299
|
+
return sum(1 for _ in self.items())
|
|
300
|
+
|
|
301
|
+
# ---- writes ----
|
|
302
|
+
|
|
303
|
+
def updated(
|
|
304
|
+
self,
|
|
305
|
+
updates: Mapping[str, bytes] | None = None,
|
|
306
|
+
removals: Iterable[str] = (),
|
|
307
|
+
) -> tuple["Hamt", dict[str, bytes]]:
|
|
308
|
+
"""Apply updates and removals.
|
|
309
|
+
|
|
310
|
+
Returns ``(new_hamt, pending_writes)`` where ``pending_writes``
|
|
311
|
+
is a dict of prefixed-key -> node-bytes ready to merge into a
|
|
312
|
+
store write batch. The returned ``new_hamt.pending`` is the
|
|
313
|
+
same dict, so reads on the new view work before flushing.
|
|
314
|
+
"""
|
|
315
|
+
pending = dict(self.pending)
|
|
316
|
+
current_root = self.root
|
|
317
|
+
|
|
318
|
+
for key, value in (updates or {}).items():
|
|
319
|
+
current_root = self._insert(current_root, key, value, pending)
|
|
320
|
+
for key in removals:
|
|
321
|
+
current_root = self._delete(current_root, key, pending)
|
|
322
|
+
|
|
323
|
+
# Drop any pending node that's no longer reachable from the new root
|
|
324
|
+
# (intermediate nodes that were superseded by later updates).
|
|
325
|
+
reachable_pending = self._filter_pending(current_root, pending)
|
|
326
|
+
|
|
327
|
+
new_hamt = Hamt(
|
|
328
|
+
self.store,
|
|
329
|
+
current_root,
|
|
330
|
+
prefix=self.prefix,
|
|
331
|
+
bucket_max=self.bucket_max,
|
|
332
|
+
pending=reachable_pending,
|
|
333
|
+
)
|
|
334
|
+
return new_hamt, reachable_pending
|
|
335
|
+
|
|
336
|
+
def persist(
|
|
337
|
+
self,
|
|
338
|
+
updates: Mapping[str, bytes] | None = None,
|
|
339
|
+
removals: Iterable[str] = (),
|
|
340
|
+
) -> "Hamt":
|
|
341
|
+
"""Apply updates and write any new nodes to the store immediately.
|
|
342
|
+
|
|
343
|
+
Convenience for callers that don't need to batch writes with
|
|
344
|
+
other store operations. Returns a fresh ``Hamt`` with empty
|
|
345
|
+
pending. Distinct from ``Versioned.commit``: a HAMT has no
|
|
346
|
+
notion of a commit history — this just flushes node bytes.
|
|
347
|
+
"""
|
|
348
|
+
new_hamt, pending = self.updated(updates, removals)
|
|
349
|
+
if pending:
|
|
350
|
+
self.store.set_many(pending)
|
|
351
|
+
return Hamt(
|
|
352
|
+
self.store,
|
|
353
|
+
new_hamt.root,
|
|
354
|
+
prefix=self.prefix,
|
|
355
|
+
bucket_max=self.bucket_max,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def flush(self) -> "Hamt":
|
|
359
|
+
"""Persist any pending node writes. Returns a fresh ``Hamt``."""
|
|
360
|
+
if self.pending:
|
|
361
|
+
self.store.set_many(**self.pending)
|
|
362
|
+
return Hamt(
|
|
363
|
+
self.store,
|
|
364
|
+
self.root,
|
|
365
|
+
prefix=self.prefix,
|
|
366
|
+
bucket_max=self.bucket_max,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# ---- insert ----
|
|
370
|
+
|
|
371
|
+
def _insert(
|
|
372
|
+
self, root_hash: str, key: str, value: bytes, pending: dict[str, bytes]
|
|
373
|
+
) -> str:
|
|
374
|
+
if root_hash == EMPTY_HASH:
|
|
375
|
+
return self._store_leaf({key: _encode_value(value)}, pending)
|
|
376
|
+
kh = _key_hash(key)
|
|
377
|
+
return self._insert_at(root_hash, 0, kh, key, value, pending)
|
|
378
|
+
|
|
379
|
+
def _insert_at(
|
|
380
|
+
self,
|
|
381
|
+
node_hash: str,
|
|
382
|
+
depth: int,
|
|
383
|
+
key_hash: str,
|
|
384
|
+
key: str,
|
|
385
|
+
value: bytes,
|
|
386
|
+
pending: dict[str, bytes],
|
|
387
|
+
) -> str:
|
|
388
|
+
node = self._load(node_hash, pending)
|
|
389
|
+
if node is None:
|
|
390
|
+
# Dangling reference — treat as missing and materialize a leaf.
|
|
391
|
+
return self._store_leaf({key: _encode_value(value)}, pending)
|
|
392
|
+
|
|
393
|
+
if node["kind"] == "leaf":
|
|
394
|
+
encoded = _encode_value(value)
|
|
395
|
+
existing = node["items"].get(key)
|
|
396
|
+
if existing == encoded:
|
|
397
|
+
return node_hash # no-op
|
|
398
|
+
new_items = dict(node["items"])
|
|
399
|
+
new_items[key] = encoded
|
|
400
|
+
if len(new_items) <= self.bucket_max:
|
|
401
|
+
return self._store_leaf(new_items, pending)
|
|
402
|
+
# Overflow: split into a branch.
|
|
403
|
+
return self._split_leaf(new_items, depth, pending)
|
|
404
|
+
|
|
405
|
+
# branch
|
|
406
|
+
chunk = key_hash[depth]
|
|
407
|
+
existing_children = node["children"]
|
|
408
|
+
if chunk in existing_children:
|
|
409
|
+
new_child_hash = self._insert_at(
|
|
410
|
+
existing_children[chunk], depth + 1, key_hash, key, value, pending
|
|
411
|
+
)
|
|
412
|
+
if new_child_hash == existing_children[chunk]:
|
|
413
|
+
return node_hash
|
|
414
|
+
new_children = dict(existing_children)
|
|
415
|
+
new_children[chunk] = new_child_hash
|
|
416
|
+
else:
|
|
417
|
+
new_leaf_hash = self._store_leaf({key: _encode_value(value)}, pending)
|
|
418
|
+
new_children = dict(existing_children)
|
|
419
|
+
new_children[chunk] = new_leaf_hash
|
|
420
|
+
return self._store_branch(new_children, pending)
|
|
421
|
+
|
|
422
|
+
def _split_leaf(
|
|
423
|
+
self,
|
|
424
|
+
encoded_items: Mapping[str, str],
|
|
425
|
+
depth: int,
|
|
426
|
+
pending: dict[str, bytes],
|
|
427
|
+
) -> str:
|
|
428
|
+
"""Convert an overflowing leaf at ``depth`` into a branch."""
|
|
429
|
+
if depth >= _HASH_LEN:
|
|
430
|
+
# Hash exhausted — full SHA-256 collision. Astronomically rare;
|
|
431
|
+
# we just keep them in one (over-sized) leaf to avoid recursing
|
|
432
|
+
# forever.
|
|
433
|
+
return self._store_leaf(encoded_items, pending)
|
|
434
|
+
|
|
435
|
+
groups: dict[str, dict[str, str]] = {}
|
|
436
|
+
for k, v in encoded_items.items():
|
|
437
|
+
nibble = _key_hash(k)[depth]
|
|
438
|
+
groups.setdefault(nibble, {})[k] = v
|
|
439
|
+
|
|
440
|
+
if len(groups) == 1:
|
|
441
|
+
# All entries share the next nibble too — recurse deeper, then
|
|
442
|
+
# wrap in a single-child branch at this depth.
|
|
443
|
+
nibble, group_items = next(iter(groups.items()))
|
|
444
|
+
child_hash = self._split_leaf(group_items, depth + 1, pending)
|
|
445
|
+
return self._store_branch({nibble: child_hash}, pending)
|
|
446
|
+
|
|
447
|
+
children: dict[str, str] = {}
|
|
448
|
+
for nibble, group_items in groups.items():
|
|
449
|
+
if len(group_items) <= self.bucket_max:
|
|
450
|
+
children[nibble] = self._store_leaf(group_items, pending)
|
|
451
|
+
else:
|
|
452
|
+
children[nibble] = self._split_leaf(group_items, depth + 1, pending)
|
|
453
|
+
return self._store_branch(children, pending)
|
|
454
|
+
|
|
455
|
+
# ---- delete ----
|
|
456
|
+
|
|
457
|
+
def _delete(self, root_hash: str, key: str, pending: dict[str, bytes]) -> str:
|
|
458
|
+
if root_hash == EMPTY_HASH:
|
|
459
|
+
return EMPTY_HASH
|
|
460
|
+
kh = _key_hash(key)
|
|
461
|
+
result = self._delete_at(root_hash, 0, kh, key, pending)
|
|
462
|
+
return EMPTY_HASH if result is None else result
|
|
463
|
+
|
|
464
|
+
def _delete_at(
|
|
465
|
+
self,
|
|
466
|
+
node_hash: str,
|
|
467
|
+
depth: int,
|
|
468
|
+
key_hash: str,
|
|
469
|
+
key: str,
|
|
470
|
+
pending: dict[str, bytes],
|
|
471
|
+
) -> str | None:
|
|
472
|
+
"""Delete ``key`` from the subtree. Returns new node hash, or
|
|
473
|
+
None if the subtree is now empty."""
|
|
474
|
+
node = self._load(node_hash, pending)
|
|
475
|
+
if node is None:
|
|
476
|
+
return node_hash
|
|
477
|
+
|
|
478
|
+
if node["kind"] == "leaf":
|
|
479
|
+
if key not in node["items"]:
|
|
480
|
+
return node_hash
|
|
481
|
+
new_items = {k: v for k, v in node["items"].items() if k != key}
|
|
482
|
+
if not new_items:
|
|
483
|
+
return None
|
|
484
|
+
return self._store_leaf(new_items, pending)
|
|
485
|
+
|
|
486
|
+
# branch
|
|
487
|
+
chunk = key_hash[depth]
|
|
488
|
+
existing_children = node["children"]
|
|
489
|
+
if chunk not in existing_children:
|
|
490
|
+
return node_hash
|
|
491
|
+
|
|
492
|
+
new_child_hash = self._delete_at(
|
|
493
|
+
existing_children[chunk], depth + 1, key_hash, key, pending
|
|
494
|
+
)
|
|
495
|
+
if new_child_hash == existing_children[chunk]:
|
|
496
|
+
return node_hash
|
|
497
|
+
|
|
498
|
+
new_children = dict(existing_children)
|
|
499
|
+
if new_child_hash is None:
|
|
500
|
+
del new_children[chunk]
|
|
501
|
+
else:
|
|
502
|
+
new_children[chunk] = new_child_hash
|
|
503
|
+
|
|
504
|
+
if not new_children:
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
# Canonicalization: if all children are leaves and their combined
|
|
508
|
+
# entries fit in a single bucket, collapse the whole branch into
|
|
509
|
+
# one leaf. This preserves the invariant that the same logical
|
|
510
|
+
# contents always produce the same root hash.
|
|
511
|
+
collapsed = self._try_collapse(new_children, pending)
|
|
512
|
+
if collapsed is not None:
|
|
513
|
+
return collapsed
|
|
514
|
+
|
|
515
|
+
return self._store_branch(new_children, pending)
|
|
516
|
+
|
|
517
|
+
def _try_collapse(
|
|
518
|
+
self, children: Mapping[str, str], pending: dict[str, bytes]
|
|
519
|
+
) -> str | None:
|
|
520
|
+
"""If every child is a leaf and the union of their entries fits
|
|
521
|
+
in ``bucket_max``, return the merged leaf hash. Otherwise None."""
|
|
522
|
+
merged: dict[str, str] = {}
|
|
523
|
+
for child_hash in children.values():
|
|
524
|
+
child = self._load(child_hash, pending)
|
|
525
|
+
if child is None or child["kind"] != "leaf":
|
|
526
|
+
return None
|
|
527
|
+
for k, v in child["items"].items():
|
|
528
|
+
if k not in merged:
|
|
529
|
+
merged[k] = v
|
|
530
|
+
if len(merged) > self.bucket_max:
|
|
531
|
+
return None
|
|
532
|
+
return self._store_leaf(merged, pending)
|
|
533
|
+
|
|
534
|
+
# ---- pending management ----
|
|
535
|
+
|
|
536
|
+
def _filter_pending(self, root: str, pending: dict[str, bytes]) -> dict[str, bytes]:
|
|
537
|
+
"""Walk from ``root``, returning only pending entries that are
|
|
538
|
+
actually reachable. Drops orphans created by superseded inserts."""
|
|
539
|
+
if root == EMPTY_HASH:
|
|
540
|
+
return {}
|
|
541
|
+
result: dict[str, bytes] = {}
|
|
542
|
+
queue = [root]
|
|
543
|
+
while queue:
|
|
544
|
+
h = queue.pop()
|
|
545
|
+
prefixed = self.prefix + h
|
|
546
|
+
if prefixed in result or prefixed not in pending:
|
|
547
|
+
# Either already visited or already in the store — done with this branch.
|
|
548
|
+
continue
|
|
549
|
+
node_bytes = pending[prefixed]
|
|
550
|
+
result[prefixed] = node_bytes
|
|
551
|
+
node = json.loads(node_bytes)
|
|
552
|
+
if node["kind"] == "branch":
|
|
553
|
+
queue.extend(node["children"].values())
|
|
554
|
+
return result
|
|
555
|
+
|
|
556
|
+
# ---- structural ops ----
|
|
557
|
+
|
|
558
|
+
def reachable_nodes(self) -> Iterator[str]:
|
|
559
|
+
"""Yield every node hash reachable from this root.
|
|
560
|
+
|
|
561
|
+
Used by GC layers to mark live nodes. Includes pending nodes,
|
|
562
|
+
so this works correctly on a Hamt that hasn't been flushed.
|
|
563
|
+
"""
|
|
564
|
+
if self.root == EMPTY_HASH:
|
|
565
|
+
return
|
|
566
|
+
seen: set[str] = set()
|
|
567
|
+
queue = [self.root]
|
|
568
|
+
while queue:
|
|
569
|
+
h = queue.pop()
|
|
570
|
+
if h in seen:
|
|
571
|
+
continue
|
|
572
|
+
seen.add(h)
|
|
573
|
+
yield h
|
|
574
|
+
node = self._load(h)
|
|
575
|
+
if node is None:
|
|
576
|
+
continue
|
|
577
|
+
if node["kind"] == "branch":
|
|
578
|
+
queue.extend(node["children"].values())
|
|
579
|
+
|
|
580
|
+
def diff(self, other: "Hamt") -> HamtDiff:
|
|
581
|
+
"""Structural diff against ``other``.
|
|
582
|
+
|
|
583
|
+
Cost is O(changes + log N), not O(N), because identical
|
|
584
|
+
subtrees (same node hash) are skipped wholesale. This is the
|
|
585
|
+
primary payoff of structural sharing.
|
|
586
|
+
"""
|
|
587
|
+
added: dict[str, bytes] = {}
|
|
588
|
+
removed: dict[str, bytes] = {}
|
|
589
|
+
modified: dict[str, tuple[bytes, bytes]] = {}
|
|
590
|
+
self._diff_walk(self.root, other.root, other, added, removed, modified)
|
|
591
|
+
return HamtDiff(added=added, removed=removed, modified=modified)
|
|
592
|
+
|
|
593
|
+
def _diff_walk(
|
|
594
|
+
self,
|
|
595
|
+
a_hash: str,
|
|
596
|
+
b_hash: str,
|
|
597
|
+
other: "Hamt",
|
|
598
|
+
added: dict[str, bytes],
|
|
599
|
+
removed: dict[str, bytes],
|
|
600
|
+
modified: dict[str, tuple[bytes, bytes]],
|
|
601
|
+
) -> None:
|
|
602
|
+
if a_hash == b_hash:
|
|
603
|
+
return # identical subtrees — skip entirely
|
|
604
|
+
|
|
605
|
+
if a_hash == EMPTY_HASH:
|
|
606
|
+
for k, v in other._items_from(b_hash):
|
|
607
|
+
added[k] = v
|
|
608
|
+
return
|
|
609
|
+
if b_hash == EMPTY_HASH:
|
|
610
|
+
for k, v in self._items_from(a_hash):
|
|
611
|
+
removed[k] = v
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
a_node = self._load(a_hash)
|
|
615
|
+
b_node = other._load(b_hash)
|
|
616
|
+
if a_node is None or b_node is None:
|
|
617
|
+
# Missing node — fall back to full walk for whichever side is intact.
|
|
618
|
+
if a_node is not None:
|
|
619
|
+
for k, v in self._items_from(a_hash):
|
|
620
|
+
removed[k] = v
|
|
621
|
+
if b_node is not None:
|
|
622
|
+
for k, v in other._items_from(b_hash):
|
|
623
|
+
added[k] = v
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
if a_node["kind"] == "leaf" and b_node["kind"] == "leaf":
|
|
627
|
+
a_items = {k: _decode_value(v) for k, v in a_node["items"].items()}
|
|
628
|
+
b_items = {k: _decode_value(v) for k, v in b_node["items"].items()}
|
|
629
|
+
for k, v in a_items.items():
|
|
630
|
+
if k not in b_items:
|
|
631
|
+
removed[k] = v
|
|
632
|
+
elif b_items[k] != v:
|
|
633
|
+
modified[k] = (v, b_items[k])
|
|
634
|
+
for k, v in b_items.items():
|
|
635
|
+
if k not in a_items:
|
|
636
|
+
added[k] = v
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
if a_node["kind"] == "branch" and b_node["kind"] == "branch":
|
|
640
|
+
chunks = set(a_node["children"]) | set(b_node["children"])
|
|
641
|
+
for chunk in chunks:
|
|
642
|
+
a_child = a_node["children"].get(chunk, EMPTY_HASH)
|
|
643
|
+
b_child = b_node["children"].get(chunk, EMPTY_HASH)
|
|
644
|
+
self._diff_walk(a_child, b_child, other, added, removed, modified)
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
# Mixed kinds (one leaf, one branch). Walk both fully and reconcile.
|
|
648
|
+
a_items = dict(self._items_from(a_hash))
|
|
649
|
+
b_items = dict(other._items_from(b_hash))
|
|
650
|
+
for k, v in a_items.items():
|
|
651
|
+
if k not in b_items:
|
|
652
|
+
removed[k] = v
|
|
653
|
+
elif b_items[k] != v:
|
|
654
|
+
modified[k] = (v, b_items[k])
|
|
655
|
+
for k, v in b_items.items():
|
|
656
|
+
if k not in a_items:
|
|
657
|
+
added[k] = v
|