kvgit 0.2.0__tar.gz → 0.2.2__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.2.0 → kvgit-0.2.2}/PKG-INFO +1 -2
- {kvgit-0.2.0 → kvgit-0.2.2}/README.md +0 -1
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/hamt.py +20 -5
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/indexeddb.py +18 -1
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/staged.py +36 -11
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/keyset.py +6 -3
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/kv.py +9 -3
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/PKG-INFO +1 -2
- {kvgit-0.2.0 → kvgit-0.2.2}/pyproject.toml +1 -1
- {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_hamt.py +113 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_staged.py +111 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/LICENSE +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/__init__.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/content_types.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/encoding.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/errors.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/base.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/composite.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/disk.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/kv/memory.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/namespaced.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/py.typed +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/store.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/base.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/gp.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/setup.cfg +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_content_types.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_namespaced.py +0 -0
- {kvgit-0.2.0 → kvgit-0.2.2}/tests/test_store_factory.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -49,7 +49,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
49
49
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
50
50
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
51
51
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
52
|
-
| **Eviction** | High/low water rebase drops least-recently-used keys automatically |
|
|
53
52
|
| **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
54
53
|
|
|
55
54
|
## Install
|
|
@@ -8,7 +8,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
8
8
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
9
9
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
10
10
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
11
|
-
| **Eviction** | High/low water rebase drops least-recently-used keys automatically |
|
|
12
11
|
| **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
13
12
|
|
|
14
13
|
## Install
|
|
@@ -217,7 +217,9 @@ class Hamt:
|
|
|
217
217
|
"""
|
|
218
218
|
return self.walk()[0]
|
|
219
219
|
|
|
220
|
-
def walk(
|
|
220
|
+
def walk(
|
|
221
|
+
self, skip_nodes: set[str] | None = None
|
|
222
|
+
) -> tuple[dict[str, bytes], set[str]]:
|
|
221
223
|
"""Walk the entire HAMT, returning (items, node_hashes).
|
|
222
224
|
|
|
223
225
|
Single batched BFS that collects both the key→value entries
|
|
@@ -229,8 +231,19 @@ class Hamt:
|
|
|
229
231
|
Same batching properties as ``materialize()``: one
|
|
230
232
|
``get_many`` call per tree level, O(log_branching N)
|
|
231
233
|
round-trips total.
|
|
234
|
+
|
|
235
|
+
``skip_nodes`` is an optional set of node hashes to treat as
|
|
236
|
+
already-visited. Skipped subtrees are not fetched, not
|
|
237
|
+
recursed into, and not included in the returned ``nodes``
|
|
238
|
+
set. Items beneath skipped subtrees are also omitted from
|
|
239
|
+
the returned ``items`` dict. Pass a cumulative seen-set
|
|
240
|
+
across multiple ``walk()`` calls (e.g. across the commits
|
|
241
|
+
of a branch's history) to share work where the underlying
|
|
242
|
+
HAMTs share structure — turns N-walks-over-shared-tree from
|
|
243
|
+
O(N · subtree) into O(unique nodes).
|
|
232
244
|
"""
|
|
233
|
-
if
|
|
245
|
+
skip = skip_nodes if skip_nodes is not None else set()
|
|
246
|
+
if self.root == EMPTY_HASH or self.root in skip:
|
|
234
247
|
return {}, set()
|
|
235
248
|
|
|
236
249
|
items: dict[str, bytes] = {}
|
|
@@ -239,11 +252,13 @@ class Hamt:
|
|
|
239
252
|
|
|
240
253
|
while current_level:
|
|
241
254
|
# Partition this level: nodes already in pending vs
|
|
242
|
-
# nodes that need to be fetched from the store.
|
|
255
|
+
# nodes that need to be fetched from the store. Drop
|
|
256
|
+
# anything in skip_nodes — those subtrees have already
|
|
257
|
+
# been visited by a prior walk.
|
|
243
258
|
cached_nodes: dict[str, dict] = {}
|
|
244
259
|
to_fetch: list[str] = []
|
|
245
260
|
for node_hash in current_level:
|
|
246
|
-
if node_hash == EMPTY_HASH:
|
|
261
|
+
if node_hash == EMPTY_HASH or node_hash in skip:
|
|
247
262
|
continue
|
|
248
263
|
prefixed = self.prefix + node_hash
|
|
249
264
|
if prefixed in self.pending:
|
|
@@ -261,7 +276,7 @@ class Hamt:
|
|
|
261
276
|
# node hash we successfully load.
|
|
262
277
|
next_level: list[str] = []
|
|
263
278
|
for node_hash in current_level:
|
|
264
|
-
if node_hash == EMPTY_HASH:
|
|
279
|
+
if node_hash == EMPTY_HASH or node_hash in skip:
|
|
265
280
|
continue
|
|
266
281
|
if node_hash in cached_nodes:
|
|
267
282
|
node = cached_nodes[node_hash]
|
|
@@ -63,7 +63,14 @@ def _promise(executor):
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
async def _idb_open(db_name: str, store_name: str):
|
|
66
|
-
"""Open (or create) an IndexedDB database, returning the IDBDatabase.
|
|
66
|
+
"""Open (or create) an IndexedDB database, returning the IDBDatabase.
|
|
67
|
+
|
|
68
|
+
Rejects with a clear error when ``onblocked`` fires — a zombie
|
|
69
|
+
connection from another tab / window (or transient browser-side
|
|
70
|
+
state) can refuse the open indefinitely. Without an explicit
|
|
71
|
+
handler the promise would never resolve and ``run_sync`` would
|
|
72
|
+
hang forever.
|
|
73
|
+
"""
|
|
67
74
|
|
|
68
75
|
def _executor(resolve, reject):
|
|
69
76
|
request = indexedDB.open(db_name, 1)
|
|
@@ -79,9 +86,19 @@ async def _idb_open(db_name: str, store_name: str):
|
|
|
79
86
|
def on_error(event):
|
|
80
87
|
reject(event.target.error)
|
|
81
88
|
|
|
89
|
+
def on_blocked(event):
|
|
90
|
+
reject(
|
|
91
|
+
Exception(
|
|
92
|
+
f"IndexedDB open of {db_name!r} is blocked. Close other "
|
|
93
|
+
f"tabs / windows holding the database open and reload, "
|
|
94
|
+
f"or restart the browser."
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
82
98
|
request.onupgradeneeded = on_upgrade
|
|
83
99
|
request.onsuccess = on_success
|
|
84
100
|
request.onerror = on_error
|
|
101
|
+
request.onblocked = on_blocked
|
|
85
102
|
|
|
86
103
|
return await _promise(_executor)
|
|
87
104
|
|
|
@@ -140,6 +140,7 @@ class Staged(MutableMapping[str, Any]):
|
|
|
140
140
|
def commit(
|
|
141
141
|
self,
|
|
142
142
|
*,
|
|
143
|
+
keys: set[str] | None = None,
|
|
143
144
|
on_conflict: str = "raise",
|
|
144
145
|
merge_fns: dict[str, MergeFn] | None = None,
|
|
145
146
|
default_merge: MergeFn | None = None,
|
|
@@ -148,20 +149,36 @@ class Staged(MutableMapping[str, Any]):
|
|
|
148
149
|
"""Flush staged changes to the underlying Versioned store.
|
|
149
150
|
|
|
150
151
|
Encodes staged values to bytes, wraps merge functions, and
|
|
151
|
-
calls ``Versioned.commit()``. On success, clears the
|
|
152
|
-
buffer.
|
|
152
|
+
calls ``Versioned.commit()``. On success, clears the committed
|
|
153
|
+
entries from the staging buffer.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
keys: If provided, only commit these specific keys.
|
|
157
|
+
Keys not in ``_updates`` or ``_removals`` are silently
|
|
158
|
+
ignored. Uncommitted keys remain staged for a future
|
|
159
|
+
commit. When ``None`` (default), all staged changes
|
|
160
|
+
are committed.
|
|
153
161
|
|
|
154
162
|
Returns:
|
|
155
163
|
A MergeResult (truthy when committed).
|
|
156
164
|
"""
|
|
157
|
-
# Encode staged updates to bytes
|
|
165
|
+
# Encode staged updates to bytes — scoped to keys if provided
|
|
158
166
|
encoded_updates: dict[str, bytes] | None = None
|
|
159
|
-
if
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
167
|
+
if keys is not None:
|
|
168
|
+
# Iterate the (typically small) keys set, not the full _updates dict
|
|
169
|
+
matched_updates = {k: self._updates[k] for k in keys if k in self._updates}
|
|
170
|
+
if matched_updates:
|
|
171
|
+
encoded_updates = {
|
|
172
|
+
k: self._encoder(v) for k, v in matched_updates.items()
|
|
173
|
+
}
|
|
174
|
+
# .intersection() accepts any iterable, not just sets
|
|
175
|
+
removals = self._removals.intersection(keys) or None
|
|
176
|
+
else:
|
|
177
|
+
if self._updates:
|
|
178
|
+
encoded_updates = {
|
|
179
|
+
key: self._encoder(value) for key, value in self._updates.items()
|
|
180
|
+
}
|
|
181
|
+
removals = self._removals if self._removals else None
|
|
165
182
|
|
|
166
183
|
# Build effective merge fns and wrap to bytes-level
|
|
167
184
|
effective_fns = dict(self._merge_fns)
|
|
@@ -188,8 +205,16 @@ class Staged(MutableMapping[str, Any]):
|
|
|
188
205
|
info=info,
|
|
189
206
|
)
|
|
190
207
|
if result.merged:
|
|
191
|
-
|
|
192
|
-
|
|
208
|
+
if keys is not None:
|
|
209
|
+
# Only clear the committed keys from staging
|
|
210
|
+
for k in keys:
|
|
211
|
+
self._updates.pop(k, None)
|
|
212
|
+
self._removals.discard(k)
|
|
213
|
+
else:
|
|
214
|
+
self._updates.clear()
|
|
215
|
+
self._removals.clear()
|
|
216
|
+
# Always clear the full read cache — HEAD moved, so cached
|
|
217
|
+
# values from other keys may be stale after a merge.
|
|
193
218
|
self._cache.clear()
|
|
194
219
|
return result
|
|
195
220
|
|
|
@@ -146,14 +146,17 @@ class Keyset:
|
|
|
146
146
|
"""
|
|
147
147
|
return {k: decode_entry(v) for k, v in self._hamt.materialize().items()}
|
|
148
148
|
|
|
149
|
-
def walk(
|
|
149
|
+
def walk(
|
|
150
|
+
self, skip_nodes: set[str] | None = None
|
|
151
|
+
) -> tuple[dict[str, KeysetEntry], set[str]]:
|
|
150
152
|
"""Single batched walk returning (entries, hamt_node_hashes).
|
|
151
153
|
|
|
152
154
|
Equivalent to ``materialize()`` plus collecting every HAMT
|
|
153
155
|
node hash, in one tree traversal. Used by GC mark phases
|
|
154
|
-
that need both — see ``Hamt.walk
|
|
156
|
+
that need both — see ``Hamt.walk`` (including the
|
|
157
|
+
``skip_nodes`` cumulative seen-set semantics).
|
|
155
158
|
"""
|
|
156
|
-
raw_items, nodes = self._hamt.walk()
|
|
159
|
+
raw_items, nodes = self._hamt.walk(skip_nodes=skip_nodes)
|
|
157
160
|
return {k: decode_entry(v) for k, v in raw_items.items()}, nodes
|
|
158
161
|
|
|
159
162
|
def keys(self) -> Iterator[str]:
|
|
@@ -735,11 +735,17 @@ class VersionedKV(VersionedBase):
|
|
|
735
735
|
if root is None:
|
|
736
736
|
continue
|
|
737
737
|
# Single batched walk per commit collects both the
|
|
738
|
-
# blob references and the HAMT node hashes.
|
|
739
|
-
|
|
738
|
+
# blob references and the HAMT node hashes. Pass the
|
|
739
|
+
# cumulative ``reachable_nodes`` as ``skip_nodes`` so
|
|
740
|
+
# subtrees already seen on a prior commit / branch are
|
|
741
|
+
# not re-fetched — structural sharing means the blobs
|
|
742
|
+
# under those subtrees are also already accounted for.
|
|
743
|
+
entries, new_nodes = Keyset(self.store, root=root).walk(
|
|
744
|
+
skip_nodes=reachable_nodes
|
|
745
|
+
)
|
|
740
746
|
for entry in entries.values():
|
|
741
747
|
reachable_blobs.add(entry.blob)
|
|
742
|
-
reachable_nodes.update(
|
|
748
|
+
reachable_nodes.update(new_nodes)
|
|
743
749
|
|
|
744
750
|
# Sweep phase: find orphaned commits via __commit_root__ scan.
|
|
745
751
|
cutoff_time = time.time() - min_age
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -49,7 +49,6 @@ Git-style versioning for your data. Commits, branches, and merges -- backed by a
|
|
|
49
49
|
| **Commits** | Immutable, content-addressable snapshots with rollback |
|
|
50
50
|
| **Branches** | Cheap forks with CAS-based optimistic concurrency |
|
|
51
51
|
| **Three-way merge** | Auto-merges non-overlapping changes; pluggable merge fns for conflicts |
|
|
52
|
-
| **Eviction** | High/low water rebase drops least-recently-used keys automatically |
|
|
53
52
|
| **Pluggable backends** | In-memory, disk (diskcache), git (GitPython), IndexedDB (Pyodide/browser), or bring your own `KVStore` |
|
|
54
53
|
|
|
55
54
|
## Install
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "Versioned key-value store with git-like commit, branch, and merge semantics."
|
|
9
9
|
authors = [{ name = "ashenfad" }]
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -779,3 +779,116 @@ def test_materialize_is_walk_dot_zero():
|
|
|
779
779
|
items = {f"k{i:03d}": f"v{i}".encode() for i in range(50)}
|
|
780
780
|
h = Hamt(_store(), bucket_max=4).persist(items)
|
|
781
781
|
assert h.materialize() == h.walk()[0]
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
# ---- walk(skip_nodes=...) cumulative seen-set ----
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def test_walk_skip_nodes_root_short_circuits():
|
|
788
|
+
"""Skipping the root yields nothing — entire tree pruned."""
|
|
789
|
+
items = {f"k{i:03d}": f"v{i}".encode() for i in range(20)}
|
|
790
|
+
h = Hamt(_store(), bucket_max=4).persist(items)
|
|
791
|
+
walked_items, walked_nodes = h.walk(skip_nodes={h.root})
|
|
792
|
+
assert walked_items == {}
|
|
793
|
+
assert walked_nodes == set()
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def test_walk_skip_nodes_excludes_skipped_from_returned_set():
|
|
797
|
+
"""Returned ``nodes`` excludes anything in ``skip_nodes``."""
|
|
798
|
+
items = {f"k{i:03d}": f"v{i}".encode() for i in range(60)}
|
|
799
|
+
h = Hamt(_store(), bucket_max=4).persist(items)
|
|
800
|
+
full_nodes = h.walk()[1]
|
|
801
|
+
# Pick an arbitrary non-root node to skip.
|
|
802
|
+
interior = next(iter(full_nodes - {h.root}))
|
|
803
|
+
_, walked_nodes = h.walk(skip_nodes={interior})
|
|
804
|
+
assert interior not in walked_nodes
|
|
805
|
+
# Other nodes are still visited (root at minimum).
|
|
806
|
+
assert h.root in walked_nodes
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def test_walk_skip_nodes_skips_subtree_items():
|
|
810
|
+
"""Items beneath a skipped subtree are not returned."""
|
|
811
|
+
items = {f"k{i:03d}": f"v{i}".encode() for i in range(80)}
|
|
812
|
+
h = Hamt(_store(), bucket_max=4).persist(items)
|
|
813
|
+
|
|
814
|
+
# Find a non-root branch node and the items reachable beneath it,
|
|
815
|
+
# then verify those items are absent when we skip that node.
|
|
816
|
+
full_items, full_nodes = h.walk()
|
|
817
|
+
interior_candidates = full_nodes - {h.root}
|
|
818
|
+
# Walk only the subtree rooted at the candidate to see what's under it.
|
|
819
|
+
for candidate in interior_candidates:
|
|
820
|
+
sub_items = Hamt(h.store, root=candidate, bucket_max=4).walk()[0]
|
|
821
|
+
if sub_items and len(sub_items) < len(full_items):
|
|
822
|
+
walked_items, _ = h.walk(skip_nodes={candidate})
|
|
823
|
+
for k in sub_items:
|
|
824
|
+
assert k not in walked_items
|
|
825
|
+
# And the items not in that subtree are still present.
|
|
826
|
+
for k in full_items:
|
|
827
|
+
if k not in sub_items:
|
|
828
|
+
assert walked_items[k] == full_items[k]
|
|
829
|
+
return
|
|
830
|
+
pytest.fail("expected to find at least one non-root subtree to skip")
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def test_walk_skip_nodes_does_not_fetch_skipped():
|
|
834
|
+
"""Skipped nodes should not be fetched from the store at all."""
|
|
835
|
+
store = _CountingMemory()
|
|
836
|
+
items = {f"k{i:04d}": f"v{i}".encode() for i in range(200)}
|
|
837
|
+
h = Hamt(store, bucket_max=4).persist(items)
|
|
838
|
+
full_nodes = h.walk()[1]
|
|
839
|
+
|
|
840
|
+
# Skip every node — the only fetches should be ones forced by
|
|
841
|
+
# the level-batched fetch *before* we filter (we filter first
|
|
842
|
+
# in the implementation, so even those should be zero).
|
|
843
|
+
store.reset_counts()
|
|
844
|
+
walked_items, walked_nodes = h.walk(skip_nodes=full_nodes)
|
|
845
|
+
assert walked_items == {}
|
|
846
|
+
assert walked_nodes == set()
|
|
847
|
+
assert store.get_calls == 0
|
|
848
|
+
assert store.get_many_calls == 0, (
|
|
849
|
+
f"expected zero fetches when skipping the whole tree, "
|
|
850
|
+
f"got {store.get_many_calls}"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def test_walk_skip_nodes_cumulative_across_shared_subtree():
|
|
855
|
+
"""Two HAMTs sharing structure: walking the second with the
|
|
856
|
+
first's nodes as skip_nodes should fetch only what's new."""
|
|
857
|
+
base_items = {f"k{i:04d}": f"v{i}".encode() for i in range(120)}
|
|
858
|
+
store = _CountingMemory()
|
|
859
|
+
h1 = Hamt(store, bucket_max=4).persist(base_items)
|
|
860
|
+
|
|
861
|
+
# h2 shares the bulk of h1's structure — single-key delta.
|
|
862
|
+
h2 = h1.persist({"new-key": b"new-value"})
|
|
863
|
+
assert h2.root != h1.root
|
|
864
|
+
|
|
865
|
+
# First walk seeds the seen-set.
|
|
866
|
+
seen: set[str] = set()
|
|
867
|
+
items1, nodes1 = h1.walk(skip_nodes=seen)
|
|
868
|
+
seen |= nodes1
|
|
869
|
+
|
|
870
|
+
# Second walk: only the path from h2.root down to the changed
|
|
871
|
+
# leaf should be visited; the rest of the tree is in ``seen``.
|
|
872
|
+
store.reset_counts()
|
|
873
|
+
items2, nodes2 = h2.walk(skip_nodes=seen)
|
|
874
|
+
# New items: just the added key (and only items along visited
|
|
875
|
+
# paths — which by definition contain the new key).
|
|
876
|
+
assert "new-key" in items2
|
|
877
|
+
# Visited node count should be tiny relative to the full tree.
|
|
878
|
+
assert len(nodes2) < len(nodes1) // 4, (
|
|
879
|
+
f"expected shared-structure walk to revisit << {len(nodes1) // 4} "
|
|
880
|
+
f"nodes; got {len(nodes2)}"
|
|
881
|
+
)
|
|
882
|
+
# And no overlap between the two walks' returned node sets.
|
|
883
|
+
assert nodes1.isdisjoint(nodes2)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def test_walk_skip_nodes_none_matches_no_arg():
|
|
887
|
+
"""Passing skip_nodes=None is identical to omitting it."""
|
|
888
|
+
items = {f"k{i:03d}": f"v{i}".encode() for i in range(40)}
|
|
889
|
+
h = Hamt(_store(), bucket_max=4).persist(items)
|
|
890
|
+
a_items, a_nodes = h.walk()
|
|
891
|
+
b_items, b_nodes = h.walk(skip_nodes=None)
|
|
892
|
+
c_items, c_nodes = h.walk(skip_nodes=set())
|
|
893
|
+
assert a_items == b_items == c_items
|
|
894
|
+
assert a_nodes == b_nodes == c_nodes
|
|
@@ -173,6 +173,117 @@ class TestStagedCommit:
|
|
|
173
173
|
assert s.versioned.commit_info() == {"author": "test"}
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
class TestStagedPartialCommit:
|
|
177
|
+
"""Tests for commit(keys=...) partial commit support."""
|
|
178
|
+
|
|
179
|
+
def test_partial_commit_only_flushes_specified_keys(self):
|
|
180
|
+
store = Memory()
|
|
181
|
+
v = Versioned(store)
|
|
182
|
+
s = Staged(v)
|
|
183
|
+
s["a"] = 1
|
|
184
|
+
s["b"] = 2
|
|
185
|
+
result = s.commit(keys={"a"})
|
|
186
|
+
assert result.merged
|
|
187
|
+
|
|
188
|
+
# "a" is committed and no longer staged
|
|
189
|
+
assert not s.is_staged("a")
|
|
190
|
+
# "b" is still staged
|
|
191
|
+
assert s.is_staged("b")
|
|
192
|
+
assert s["b"] == 2
|
|
193
|
+
|
|
194
|
+
def test_partial_commit_persists_committed_keys(self):
|
|
195
|
+
store = Memory()
|
|
196
|
+
v = Versioned(store)
|
|
197
|
+
s = Staged(v)
|
|
198
|
+
s["a"] = 1
|
|
199
|
+
s["b"] = 2
|
|
200
|
+
s.commit(keys={"a"})
|
|
201
|
+
|
|
202
|
+
# Verify "a" is persisted (visible from a fresh Staged)
|
|
203
|
+
s2 = Staged(Versioned(store))
|
|
204
|
+
assert s2.get("a") == 1
|
|
205
|
+
# "b" is NOT persisted (still only in s's staging buffer)
|
|
206
|
+
assert s2.get("b") is None
|
|
207
|
+
|
|
208
|
+
def test_partial_commit_with_info(self):
|
|
209
|
+
s = Staged(Versioned())
|
|
210
|
+
s["a"] = 1
|
|
211
|
+
s["b"] = 2
|
|
212
|
+
result = s.commit(keys={"a"}, info={"message": "just a"})
|
|
213
|
+
assert result.merged
|
|
214
|
+
assert s.versioned.commit_info()["message"] == "just a"
|
|
215
|
+
|
|
216
|
+
def test_partial_commit_ignores_unknown_keys(self):
|
|
217
|
+
s = Staged(Versioned())
|
|
218
|
+
s["a"] = 1
|
|
219
|
+
# "ghost" is not staged — should be silently ignored
|
|
220
|
+
result = s.commit(keys={"a", "ghost"})
|
|
221
|
+
assert result.merged
|
|
222
|
+
assert not s.is_staged("a")
|
|
223
|
+
|
|
224
|
+
def test_partial_commit_with_removals(self):
|
|
225
|
+
s = Staged(Versioned())
|
|
226
|
+
s["a"] = 1
|
|
227
|
+
s["b"] = 2
|
|
228
|
+
s.commit()
|
|
229
|
+
|
|
230
|
+
del s["a"]
|
|
231
|
+
s["c"] = 3
|
|
232
|
+
# Partial commit: only the removal of "a"
|
|
233
|
+
s.commit(keys={"a"})
|
|
234
|
+
assert s.get("a") is None # deleted
|
|
235
|
+
assert s.is_staged("c") # still pending
|
|
236
|
+
|
|
237
|
+
def test_partial_commit_clears_full_cache(self):
|
|
238
|
+
"""Regression test: after a partial commit, the entire read cache
|
|
239
|
+
must be cleared because HEAD moved. Cached values from non-committed
|
|
240
|
+
keys could be stale if a concurrent writer modified them.
|
|
241
|
+
"""
|
|
242
|
+
store = Memory()
|
|
243
|
+
v = Versioned(store)
|
|
244
|
+
s = Staged(v)
|
|
245
|
+
|
|
246
|
+
s["x"] = "original_x"
|
|
247
|
+
s["y"] = "original_y"
|
|
248
|
+
s.commit()
|
|
249
|
+
|
|
250
|
+
# Read "y" to populate the cache
|
|
251
|
+
assert s["y"] == "original_y"
|
|
252
|
+
|
|
253
|
+
# Simulate a concurrent writer updating "y" on the same branch
|
|
254
|
+
v2 = Versioned(store)
|
|
255
|
+
v2.commit({"y": s._encoder("updated_by_other")})
|
|
256
|
+
|
|
257
|
+
# Partial commit of "x" — HEAD moves to include the concurrent write
|
|
258
|
+
s["x"] = "new_x"
|
|
259
|
+
s.commit(keys={"x"})
|
|
260
|
+
|
|
261
|
+
# After partial commit, "y" should reflect the concurrent update,
|
|
262
|
+
# NOT the stale cached value. Before the fix, only "x" was evicted
|
|
263
|
+
# from cache, leaving "y" stale as "original_y".
|
|
264
|
+
s.refresh()
|
|
265
|
+
assert s["y"] == "updated_by_other"
|
|
266
|
+
|
|
267
|
+
def test_keys_accepts_non_set_iterable(self):
|
|
268
|
+
"""keys parameter should accept any iterable, not just sets."""
|
|
269
|
+
s = Staged(Versioned())
|
|
270
|
+
s["a"] = 1
|
|
271
|
+
s["b"] = 2
|
|
272
|
+
# Pass a list instead of a set
|
|
273
|
+
result = s.commit(keys=["a"])
|
|
274
|
+
assert result.merged
|
|
275
|
+
assert not s.is_staged("a")
|
|
276
|
+
assert s.is_staged("b")
|
|
277
|
+
|
|
278
|
+
def test_full_commit_unchanged_when_keys_is_none(self):
|
|
279
|
+
"""Without keys, commit flushes everything (backwards compatible)."""
|
|
280
|
+
s = Staged(Versioned())
|
|
281
|
+
s["a"] = 1
|
|
282
|
+
s["b"] = 2
|
|
283
|
+
s.commit()
|
|
284
|
+
assert not s.has_changes
|
|
285
|
+
|
|
286
|
+
|
|
176
287
|
class TestStagedReset:
|
|
177
288
|
def test_reset_clears_staging(self):
|
|
178
289
|
s = Staged(Versioned())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|