kvgit 0.4.0__tar.gz → 0.4.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.4.0 → kvgit-0.4.2}/PKG-INFO +1 -1
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/_open.py +7 -1
- kvgit-0.4.2/kvgit/cache.py +283 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/hamt.py +21 -3
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/refs.py +19 -14
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/repo.py +91 -26
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/keyset.py +5 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/kv.py +99 -20
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/SOURCES.txt +2 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/pyproject.toml +1 -1
- kvgit-0.4.2/tests/test_cache.py +272 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_hamt.py +18 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_repo.py +217 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/LICENSE +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/README.md +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/_codec.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/base.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/content_types.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/encoding.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/errors.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/base.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/composite.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/disk.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/memory.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/postgres.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/merges.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/namespaced.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/py.typed +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/base.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/worktree.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/setup.cfg +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_content_types.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_delete_branches.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_merge_policy.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_merges.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_namespaced.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_open.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_storage_v4.py +0 -0
- {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_worktree.py +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from typing import Literal
|
|
4
4
|
|
|
5
5
|
from ._codec import CodecSpec
|
|
6
|
+
from .cache import DEFAULT_CACHE_BYTES
|
|
6
7
|
from .kv.base import KVStore
|
|
7
8
|
from .kv.memory import Memory
|
|
8
9
|
from .repo import Repo
|
|
@@ -39,6 +40,7 @@ def open(
|
|
|
39
40
|
db_name: str = "kvgit",
|
|
40
41
|
branch: str = "main",
|
|
41
42
|
codec: CodecSpec = "pickle",
|
|
43
|
+
cache_bytes: int = DEFAULT_CACHE_BYTES,
|
|
42
44
|
) -> Worktree:
|
|
43
45
|
"""Open (or create) a store, and a worktree on ``branch`` of it.
|
|
44
46
|
|
|
@@ -57,6 +59,10 @@ def open(
|
|
|
57
59
|
db_name: The IndexedDB database name, for ``kind="indexeddb"``.
|
|
58
60
|
branch: The branch to check out (default ``"main"``).
|
|
59
61
|
codec: How values are stored; see :class:`Repo`.
|
|
62
|
+
cache_bytes: Memory for what the store never changes; see
|
|
63
|
+
:class:`Repo`.
|
|
60
64
|
"""
|
|
61
65
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
62
|
-
return Repo(backend, codec=codec).worktree(
|
|
66
|
+
return Repo(backend, codec=codec, cache_bytes=cache_bytes).worktree(
|
|
67
|
+
branch, create=True
|
|
68
|
+
)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""A repository's memory of what its store can never change.
|
|
2
|
+
|
|
3
|
+
Tree nodes and a commit's records — its parents, their generations,
|
|
4
|
+
its time and its info — are written once, under keys named by content
|
|
5
|
+
or by commit, and never rewritten. A read of one can be answered from
|
|
6
|
+
memory for as long as the store still holds it, which saves the round
|
|
7
|
+
trip to a networked backend that most reads of them are.
|
|
8
|
+
|
|
9
|
+
What the cache must not outlive is a deletion. Only a sweep deletes
|
|
10
|
+
these keys, and every sweep rewrites the GC lease record, which each
|
|
11
|
+
commit reads before it builds. So the cache remembers the record it
|
|
12
|
+
last saw and empties itself when a read of the lease finds another:
|
|
13
|
+
the sweep of any process on any host is noticed before this process
|
|
14
|
+
builds on what it may have removed. A sweep run through this process's
|
|
15
|
+
own store evicts what it removes as it removes it.
|
|
16
|
+
|
|
17
|
+
A commit's root is never cached. It is the store's answer to whether a
|
|
18
|
+
commit exists — what ``snapshot(commit=)`` and a tag's ``dangling``
|
|
19
|
+
ask — so it is always read, and whatever is reached through a root the
|
|
20
|
+
store still holds is safe to answer from here. Absence is never cached
|
|
21
|
+
either: a key missing now may be written by the next commit.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
from collections import OrderedDict
|
|
28
|
+
from collections.abc import Iterable, Mapping
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from .kv.base import KVStore
|
|
32
|
+
from .versioned.keyset import Keyset
|
|
33
|
+
from .versioned.kv import (
|
|
34
|
+
COMMIT_TIME,
|
|
35
|
+
GC_LEASE_KEY,
|
|
36
|
+
INFO_KEY,
|
|
37
|
+
PARENT_COMMIT,
|
|
38
|
+
PARENT_GENS,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
DEFAULT_CACHE_BYTES = 32 * 1024 * 1024
|
|
42
|
+
"""The default budget: tens of thousands of tree nodes."""
|
|
43
|
+
|
|
44
|
+
_CACHEABLE_PREFIXES = (
|
|
45
|
+
Keyset.DEFAULT_PREFIX,
|
|
46
|
+
PARENT_COMMIT.replace("%s", ""),
|
|
47
|
+
PARENT_GENS.replace("%s", ""),
|
|
48
|
+
COMMIT_TIME.replace("%s", ""),
|
|
49
|
+
INFO_KEY.replace("%s", ""),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# What an entry costs beyond its bytes: the key, the dict slot, the
|
|
53
|
+
# object headers. An estimate, so the budget bounds memory rather than
|
|
54
|
+
# just payload.
|
|
55
|
+
_ENTRY_OVERHEAD = 96
|
|
56
|
+
|
|
57
|
+
_UNSEEN: Any = object()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cacheable(key: str) -> bool:
|
|
61
|
+
"""Whether ``key`` is one the store never rewrites."""
|
|
62
|
+
return key.startswith(_CACHEABLE_PREFIXES)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ContentCache:
|
|
66
|
+
"""Least-recently-used memory of write-once keys, bounded in bytes.
|
|
67
|
+
|
|
68
|
+
Shared by every worktree and snapshot of one :class:`~kvgit.Repo`,
|
|
69
|
+
and safe to use from several threads.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, max_bytes: int = DEFAULT_CACHE_BYTES) -> None:
|
|
73
|
+
if max_bytes <= 0:
|
|
74
|
+
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
|
75
|
+
self.max_bytes = max_bytes
|
|
76
|
+
self._entries: OrderedDict[str, bytes] = OrderedDict()
|
|
77
|
+
self._bytes = 0
|
|
78
|
+
self._lock = threading.Lock()
|
|
79
|
+
self._lease: Any = _UNSEEN
|
|
80
|
+
self.hits = 0
|
|
81
|
+
"""Reads answered from memory."""
|
|
82
|
+
self.misses = 0
|
|
83
|
+
"""Reads of cacheable keys that went to the store."""
|
|
84
|
+
self.clears = 0
|
|
85
|
+
"""How often a sweep emptied the cache."""
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return (
|
|
89
|
+
f"ContentCache({len(self._entries)} entries, "
|
|
90
|
+
f"{self._bytes}/{self.max_bytes} bytes)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def __len__(self) -> int:
|
|
94
|
+
return len(self._entries)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def size_bytes(self) -> int:
|
|
98
|
+
"""What the entries held now cost, by the budget's estimate."""
|
|
99
|
+
return self._bytes
|
|
100
|
+
|
|
101
|
+
def clear(self) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._clear()
|
|
104
|
+
|
|
105
|
+
def _clear(self) -> None:
|
|
106
|
+
self._entries.clear()
|
|
107
|
+
self._bytes = 0
|
|
108
|
+
|
|
109
|
+
def lookup(self, keys: Iterable[str]) -> dict[str, bytes]:
|
|
110
|
+
"""The cached values of those ``keys`` held here."""
|
|
111
|
+
found: dict[str, bytes] = {}
|
|
112
|
+
with self._lock:
|
|
113
|
+
for key in keys:
|
|
114
|
+
value = self._entries.get(key)
|
|
115
|
+
if value is not None:
|
|
116
|
+
self._entries.move_to_end(key)
|
|
117
|
+
found[key] = value
|
|
118
|
+
return found
|
|
119
|
+
|
|
120
|
+
def count(self, hits: int, misses: int) -> None:
|
|
121
|
+
with self._lock:
|
|
122
|
+
self.hits += hits
|
|
123
|
+
self.misses += misses
|
|
124
|
+
|
|
125
|
+
def remember(self, items: Mapping[str, bytes]) -> None:
|
|
126
|
+
"""Hold these values, evicting the least recently used to fit."""
|
|
127
|
+
with self._lock:
|
|
128
|
+
for key, value in items.items():
|
|
129
|
+
if not isinstance(value, bytes | bytearray | memoryview):
|
|
130
|
+
continue
|
|
131
|
+
cost = len(value) + len(key) + _ENTRY_OVERHEAD
|
|
132
|
+
if cost > self.max_bytes:
|
|
133
|
+
continue
|
|
134
|
+
old = self._entries.pop(key, None)
|
|
135
|
+
if old is not None:
|
|
136
|
+
self._bytes -= len(old) + len(key) + _ENTRY_OVERHEAD
|
|
137
|
+
self._entries[key] = bytes(value)
|
|
138
|
+
self._bytes += cost
|
|
139
|
+
while self._bytes > self.max_bytes and self._entries:
|
|
140
|
+
key, value = self._entries.popitem(last=False)
|
|
141
|
+
self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
|
|
142
|
+
|
|
143
|
+
def forget(self, keys: Iterable[str]) -> None:
|
|
144
|
+
with self._lock:
|
|
145
|
+
for key in keys:
|
|
146
|
+
value = self._entries.pop(key, None)
|
|
147
|
+
if value is not None:
|
|
148
|
+
self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
|
|
149
|
+
|
|
150
|
+
def saw_lease(self, record: bytes | None) -> None:
|
|
151
|
+
"""Note the GC lease record a read found; a different one than
|
|
152
|
+
last time means a sweep has run, and what it removed may be
|
|
153
|
+
held here."""
|
|
154
|
+
with self._lock:
|
|
155
|
+
if self._lease is not _UNSEEN and record != self._lease:
|
|
156
|
+
self._clear()
|
|
157
|
+
self.clears += 1
|
|
158
|
+
self._lease = record
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CachedStore(KVStore):
|
|
162
|
+
"""A backend seen through a :class:`ContentCache`.
|
|
163
|
+
|
|
164
|
+
Reads of write-once keys are answered from the cache where it can
|
|
165
|
+
and remembered where it cannot; everything else passes through
|
|
166
|
+
untouched. Successful writes of write-once keys are remembered too,
|
|
167
|
+
so a commit's next read of its own tree is a hit, and removals
|
|
168
|
+
evict. Anything else the backend offers (``close``, ``drop``, ...)
|
|
169
|
+
is reached through this object as it would be on the backend.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, backend: KVStore, cache: ContentCache) -> None:
|
|
173
|
+
self.backend = backend
|
|
174
|
+
self.cache = cache
|
|
175
|
+
|
|
176
|
+
def __repr__(self) -> str:
|
|
177
|
+
return f"CachedStore({self.backend!r})"
|
|
178
|
+
|
|
179
|
+
def __getattr__(self, name: str) -> Any:
|
|
180
|
+
if name.startswith("__") or name in ("backend", "cache"):
|
|
181
|
+
raise AttributeError(name)
|
|
182
|
+
return getattr(self.backend, name)
|
|
183
|
+
|
|
184
|
+
# -- reads -----------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def get(self, key: str) -> bytes | None:
|
|
187
|
+
if cacheable(key):
|
|
188
|
+
held = self.cache.lookup((key,))
|
|
189
|
+
if key in held:
|
|
190
|
+
self.cache.count(1, 0)
|
|
191
|
+
return held[key]
|
|
192
|
+
value = self.backend.get(key)
|
|
193
|
+
self.cache.count(0, 1)
|
|
194
|
+
if value is not None:
|
|
195
|
+
self.cache.remember({key: value})
|
|
196
|
+
return value
|
|
197
|
+
value = self.backend.get(key)
|
|
198
|
+
if key == GC_LEASE_KEY:
|
|
199
|
+
self.cache.saw_lease(value)
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
def get_many(self, *args) -> Mapping[str, bytes]:
|
|
203
|
+
keys = list(dict.fromkeys(self._normalize_keys(args)))
|
|
204
|
+
wanted = [k for k in keys if cacheable(k)]
|
|
205
|
+
if GC_LEASE_KEY in keys:
|
|
206
|
+
# A read of the lease may reveal a sweep, and nothing the
|
|
207
|
+
# cache held before it may be answered beside it: the whole
|
|
208
|
+
# call goes to the store, and the record is noted before
|
|
209
|
+
# anything it brought back is remembered.
|
|
210
|
+
held: dict[str, bytes] = {}
|
|
211
|
+
found = dict(self.backend.get_many(keys))
|
|
212
|
+
self.cache.saw_lease(found.get(GC_LEASE_KEY))
|
|
213
|
+
else:
|
|
214
|
+
held = self.cache.lookup(wanted) if wanted else {}
|
|
215
|
+
rest = [k for k in keys if k not in held]
|
|
216
|
+
found = dict(self.backend.get_many(rest)) if rest else {}
|
|
217
|
+
fetched = {k: v for k, v in found.items() if cacheable(k)}
|
|
218
|
+
if wanted:
|
|
219
|
+
self.cache.count(len(held), len(wanted) - len(held))
|
|
220
|
+
if fetched:
|
|
221
|
+
self.cache.remember(fetched)
|
|
222
|
+
found.update(held)
|
|
223
|
+
return found
|
|
224
|
+
|
|
225
|
+
def items(self) -> Iterable[tuple[str, bytes]]:
|
|
226
|
+
return self.backend.items()
|
|
227
|
+
|
|
228
|
+
def keys(self, prefix: str = "") -> Iterable[str]:
|
|
229
|
+
return self.backend.keys(prefix)
|
|
230
|
+
|
|
231
|
+
def __contains__(self, key: str) -> bool:
|
|
232
|
+
if cacheable(key) and self.cache.lookup((key,)):
|
|
233
|
+
return True
|
|
234
|
+
return key in self.backend
|
|
235
|
+
|
|
236
|
+
# -- writes ----------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def set(self, key: str, value: bytes) -> None:
|
|
239
|
+
self.backend.set(key, value)
|
|
240
|
+
self._wrote({key: value}, ())
|
|
241
|
+
|
|
242
|
+
def set_many(
|
|
243
|
+
self, items: Mapping[str, bytes] | None = None, /, **kwargs: bytes
|
|
244
|
+
) -> None:
|
|
245
|
+
items = dict(self._normalize_items(items, kwargs))
|
|
246
|
+
self.backend.set_many(items)
|
|
247
|
+
self._wrote(items, ())
|
|
248
|
+
|
|
249
|
+
def remove(self, key: str) -> None:
|
|
250
|
+
self.backend.remove(key)
|
|
251
|
+
self._wrote({}, (key,))
|
|
252
|
+
|
|
253
|
+
def remove_many(self, *args) -> None:
|
|
254
|
+
keys = list(self._normalize_keys(args))
|
|
255
|
+
self.backend.remove_many(keys)
|
|
256
|
+
self._wrote({}, keys)
|
|
257
|
+
|
|
258
|
+
def cas_many(
|
|
259
|
+
self,
|
|
260
|
+
expected: Mapping[str, bytes | None],
|
|
261
|
+
writes: Mapping[str, bytes],
|
|
262
|
+
removes: Iterable[str] = (),
|
|
263
|
+
) -> bool:
|
|
264
|
+
removes = list(removes)
|
|
265
|
+
if not self.backend.cas_many(expected, writes, removes):
|
|
266
|
+
return False
|
|
267
|
+
self._wrote(writes, removes)
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
def clear(self) -> None:
|
|
271
|
+
self.backend.clear()
|
|
272
|
+
self.cache.clear()
|
|
273
|
+
|
|
274
|
+
def _wrote(self, writes: Mapping[str, bytes], removes: Iterable[str]) -> None:
|
|
275
|
+
gone = [k for k in removes if cacheable(k)]
|
|
276
|
+
if gone:
|
|
277
|
+
self.cache.forget(gone)
|
|
278
|
+
landed = {k: v for k, v in writes.items() if cacheable(k)}
|
|
279
|
+
if landed:
|
|
280
|
+
self.cache.remember(landed)
|
|
281
|
+
if GC_LEASE_KEY in writes:
|
|
282
|
+
# This process's own sweep taking or releasing the lease.
|
|
283
|
+
self.cache.saw_lease(writes[GC_LEASE_KEY])
|
|
@@ -108,9 +108,10 @@ class Hamt:
|
|
|
108
108
|
self.prefix = prefix
|
|
109
109
|
self.bucket_max = bucket_max
|
|
110
110
|
self.pending = pending if pending is not None else {}
|
|
111
|
-
# Stored nodes already read
|
|
112
|
-
#
|
|
113
|
-
#
|
|
111
|
+
# Stored nodes already read, keyed like ``pending``: by an update
|
|
112
|
+
# in progress, on the private view ``updated`` works through, and
|
|
113
|
+
# by ``get_many``. A node never changes once stored, so nothing
|
|
114
|
+
# here goes stale.
|
|
114
115
|
self._fetched: dict[str, bytes] = {}
|
|
115
116
|
|
|
116
117
|
# ---- internal helpers ----
|
|
@@ -179,6 +180,23 @@ class Hamt:
|
|
|
179
180
|
node_hash = node["children"][chunk]
|
|
180
181
|
depth += 1
|
|
181
182
|
|
|
183
|
+
def get_many(self, keys: Iterable[str]) -> dict[str, bytes]:
|
|
184
|
+
"""The values of the keys that are present, as ``{key: value}``.
|
|
185
|
+
|
|
186
|
+
Reads the paths to all of them level by level, one batched read
|
|
187
|
+
per level, rather than walking the tree once per key. The nodes
|
|
188
|
+
read stay cached on this view, so later lookups through it reuse
|
|
189
|
+
them.
|
|
190
|
+
"""
|
|
191
|
+
keys = list(dict.fromkeys(keys))
|
|
192
|
+
self._prefetch_paths(keys, [])
|
|
193
|
+
found: dict[str, bytes] = {}
|
|
194
|
+
for key in keys:
|
|
195
|
+
value = self.get(key)
|
|
196
|
+
if value is not None:
|
|
197
|
+
found[key] = value
|
|
198
|
+
return found
|
|
199
|
+
|
|
182
200
|
def __contains__(self, key: str) -> bool:
|
|
183
201
|
return self.get(key) is not None
|
|
184
202
|
|
|
@@ -35,10 +35,15 @@ class Branches(Mapping[str, str]):
|
|
|
35
35
|
return f"Branches({list(self)!r})"
|
|
36
36
|
|
|
37
37
|
def __getitem__(self, name: str) -> str:
|
|
38
|
+
return self._resolve(name)
|
|
39
|
+
|
|
40
|
+
def _resolve(self, name: str, roots: dict[str, str] | None = None) -> str:
|
|
41
|
+
"""The branch's head, recording its root in ``roots`` when given
|
|
42
|
+
(see :meth:`Repo._resolve_ref`)."""
|
|
38
43
|
_kv._reject_reserved_branch(name)
|
|
39
|
-
store = self._repo.
|
|
44
|
+
store = self._repo._store
|
|
40
45
|
commit = _kv._resolve_head(
|
|
41
|
-
store, name, recover_from_corrupt_head=self._repo._recover
|
|
46
|
+
store, name, recover_from_corrupt_head=self._repo._recover, roots=roots
|
|
42
47
|
)
|
|
43
48
|
if commit is not None:
|
|
44
49
|
return commit
|
|
@@ -47,13 +52,13 @@ class Branches(Mapping[str, str]):
|
|
|
47
52
|
raise UnknownBranchError(f"Branch '{name}' does not exist")
|
|
48
53
|
|
|
49
54
|
def __contains__(self, name: object) -> bool:
|
|
50
|
-
return isinstance(name, str) and VersionedKV.exists(self._repo.
|
|
55
|
+
return isinstance(name, str) and VersionedKV.exists(self._repo._store, name)
|
|
51
56
|
|
|
52
57
|
def __iter__(self) -> Iterator[str]:
|
|
53
|
-
return iter(VersionedKV.branches(self._repo.
|
|
58
|
+
return iter(VersionedKV.branches(self._repo._store))
|
|
54
59
|
|
|
55
60
|
def __len__(self) -> int:
|
|
56
|
-
return len(VersionedKV.branches(self._repo.
|
|
61
|
+
return len(VersionedKV.branches(self._repo._store))
|
|
57
62
|
|
|
58
63
|
def create(self, name: str, at: str | None = None) -> str:
|
|
59
64
|
"""Create branch ``name`` at commit ``at`` (default: the empty root
|
|
@@ -64,7 +69,7 @@ class Branches(Mapping[str, str]):
|
|
|
64
69
|
UnknownCommitError: if ``at`` is not in the store.
|
|
65
70
|
"""
|
|
66
71
|
target = at or ROOT_COMMIT
|
|
67
|
-
_kv.create_branch(self._repo.
|
|
72
|
+
_kv.create_branch(self._repo._store, name, target)
|
|
68
73
|
return target
|
|
69
74
|
|
|
70
75
|
def delete(self, name: str) -> None:
|
|
@@ -75,7 +80,7 @@ class Branches(Mapping[str, str]):
|
|
|
75
80
|
Raises:
|
|
76
81
|
UnknownBranchError: if there is no such branch.
|
|
77
82
|
"""
|
|
78
|
-
_kv.delete_branch(self._repo.
|
|
83
|
+
_kv.delete_branch(self._repo._store, name)
|
|
79
84
|
|
|
80
85
|
|
|
81
86
|
class Tags(Mapping[str, str]):
|
|
@@ -95,7 +100,7 @@ class Tags(Mapping[str, str]):
|
|
|
95
100
|
return f"Tags({list(self)!r})"
|
|
96
101
|
|
|
97
102
|
def __getitem__(self, name: str) -> str:
|
|
98
|
-
commit = _kv._resolve_tag(self._repo.
|
|
103
|
+
commit = _kv._resolve_tag(self._repo._store, name)
|
|
99
104
|
if commit is None:
|
|
100
105
|
raise UnknownTagError(f"Tag '{name}' does not exist")
|
|
101
106
|
return commit
|
|
@@ -103,14 +108,14 @@ class Tags(Mapping[str, str]):
|
|
|
103
108
|
def __contains__(self, name: object) -> bool:
|
|
104
109
|
return (
|
|
105
110
|
isinstance(name, str)
|
|
106
|
-
and _kv._resolve_tag(self._repo.
|
|
111
|
+
and _kv._resolve_tag(self._repo._store, name) is not None
|
|
107
112
|
)
|
|
108
113
|
|
|
109
114
|
def __iter__(self) -> Iterator[str]:
|
|
110
|
-
return iter(_kv.tags(self._repo.
|
|
115
|
+
return iter(_kv.tags(self._repo._store))
|
|
111
116
|
|
|
112
117
|
def __len__(self) -> int:
|
|
113
|
-
return len(_kv.tags(self._repo.
|
|
118
|
+
return len(_kv.tags(self._repo._store))
|
|
114
119
|
|
|
115
120
|
def items(self) -> ItemsView[str, str]:
|
|
116
121
|
"""Every tag and its commit: a live view, like the mapping, that
|
|
@@ -125,7 +130,7 @@ class Tags(Mapping[str, str]):
|
|
|
125
130
|
TagExistsError: if the name is taken.
|
|
126
131
|
UnknownCommitError: if ``commit`` is not in the store.
|
|
127
132
|
"""
|
|
128
|
-
_kv.create_tag(self._repo.
|
|
133
|
+
_kv.create_tag(self._repo._store, name, commit, info)
|
|
129
134
|
|
|
130
135
|
def delete(self, name: str) -> None:
|
|
131
136
|
"""Delete a tag. A commit it alone kept alive becomes collectable
|
|
@@ -134,7 +139,7 @@ class Tags(Mapping[str, str]):
|
|
|
134
139
|
Raises:
|
|
135
140
|
UnknownTagError: if there is no such tag.
|
|
136
141
|
"""
|
|
137
|
-
_kv.delete_tag(self._repo.
|
|
142
|
+
_kv.delete_tag(self._repo._store, name)
|
|
138
143
|
|
|
139
144
|
def info(self, name: str) -> TagInfo:
|
|
140
145
|
"""A tag's commit, creation time, info, and whether its commit is
|
|
@@ -143,7 +148,7 @@ class Tags(Mapping[str, str]):
|
|
|
143
148
|
Raises:
|
|
144
149
|
UnknownTagError: if there is no such tag.
|
|
145
150
|
"""
|
|
146
|
-
found = _kv.tag_info(self._repo.
|
|
151
|
+
found = _kv.tag_info(self._repo._store, name)
|
|
147
152
|
if found is None:
|
|
148
153
|
raise UnknownTagError(f"Tag '{name}' does not exist")
|
|
149
154
|
return found
|
|
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from ._codec import Codec, CodecSpec
|
|
10
|
+
from .cache import DEFAULT_CACHE_BYTES, CachedStore, ContentCache
|
|
10
11
|
from .content_types import MergeFn
|
|
11
12
|
from .encoding import loads, safe_loads
|
|
12
13
|
from .errors import (
|
|
@@ -69,6 +70,11 @@ class Repo:
|
|
|
69
70
|
default_merge: The merge rule for keys no other rule covers.
|
|
70
71
|
recover_from_corrupt_head: Last-resort recovery for a HEAD that is
|
|
71
72
|
damaged and has no usable backup; see the API reference.
|
|
73
|
+
cache_bytes: Memory for tree nodes and commit records read from
|
|
74
|
+
the store, which never change once written (32 MB by
|
|
75
|
+
default; ``0`` turns the cache off). A sweep by any process
|
|
76
|
+
empties it before this one builds on what the sweep removed;
|
|
77
|
+
see :class:`~kvgit.cache.ContentCache`.
|
|
72
78
|
|
|
73
79
|
Raises:
|
|
74
80
|
StorageVersionError: if the store is stamped with a layout this
|
|
@@ -84,9 +90,22 @@ class Repo:
|
|
|
84
90
|
merge_prefixes: dict[str, MergeRule] | None = None,
|
|
85
91
|
default_merge: MergeRule | None = None,
|
|
86
92
|
recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
|
|
93
|
+
cache_bytes: int = DEFAULT_CACHE_BYTES,
|
|
87
94
|
) -> None:
|
|
88
95
|
_kv._check_storage_version(backend)
|
|
89
|
-
self.
|
|
96
|
+
self._backend = backend
|
|
97
|
+
# 0 turns the cache off; anything else is a budget, and
|
|
98
|
+
# ContentCache refuses one that is negative.
|
|
99
|
+
self.cache: ContentCache | None = (
|
|
100
|
+
ContentCache(cache_bytes) if cache_bytes != 0 else None
|
|
101
|
+
)
|
|
102
|
+
"""What this repository remembers of its store, with its hit and
|
|
103
|
+
miss counts; ``None`` with ``cache_bytes=0``."""
|
|
104
|
+
# Everything the repository reads goes through the cache; the
|
|
105
|
+
# backend itself stays what ``store`` hands out.
|
|
106
|
+
self._store: KVStore = (
|
|
107
|
+
CachedStore(backend, self.cache) if self.cache is not None else backend
|
|
108
|
+
)
|
|
90
109
|
self._codec = Codec(codec, backend)
|
|
91
110
|
self._merge_fns = dict(merge_fns or {})
|
|
92
111
|
self._merge_prefixes = dict(merge_prefixes or {})
|
|
@@ -100,16 +119,22 @@ class Repo:
|
|
|
100
119
|
``delete`` and ``info``."""
|
|
101
120
|
|
|
102
121
|
def __repr__(self) -> str:
|
|
103
|
-
return f"Repo({type(self.
|
|
122
|
+
return f"Repo({type(self._backend).__name__})"
|
|
104
123
|
|
|
105
124
|
@property
|
|
106
125
|
def store(self) -> KVStore:
|
|
107
|
-
"""The backend.
|
|
108
|
-
|
|
126
|
+
"""The backend.
|
|
127
|
+
|
|
128
|
+
Reads made straight through it bypass the repository's cache,
|
|
129
|
+
and so do removals: a key deleted here, rather than by
|
|
130
|
+
:meth:`gc`, may still be answered from memory until the next
|
|
131
|
+
sweep.
|
|
132
|
+
"""
|
|
133
|
+
return self._backend
|
|
109
134
|
|
|
110
135
|
def close(self) -> None:
|
|
111
136
|
"""Close the backend, if it has anything to close."""
|
|
112
|
-
close = getattr(self.
|
|
137
|
+
close = getattr(self._backend, "close", None)
|
|
113
138
|
if callable(close):
|
|
114
139
|
close()
|
|
115
140
|
|
|
@@ -255,7 +280,9 @@ class Repo:
|
|
|
255
280
|
Name exactly one. The snapshot is pinned to the commit resolved
|
|
256
281
|
now: a branch that moves later does not move it.
|
|
257
282
|
"""
|
|
258
|
-
|
|
283
|
+
roots: dict[str, str] = {}
|
|
284
|
+
target = self._resolve_ref(commit=commit, branch=branch, tag=tag, roots=roots)
|
|
285
|
+
return Snapshot(self, target, root=roots.get(target))
|
|
259
286
|
|
|
260
287
|
# -- Maintenance --
|
|
261
288
|
|
|
@@ -287,13 +314,18 @@ class Repo:
|
|
|
287
314
|
commit: str | None = None,
|
|
288
315
|
branch: str | None = None,
|
|
289
316
|
tag: str | None = None,
|
|
317
|
+
roots: dict[str, str] | None = None,
|
|
290
318
|
) -> str:
|
|
291
|
-
"""The commit a ref names; exactly one must be given.
|
|
319
|
+
"""The commit a ref names; exactly one must be given.
|
|
320
|
+
|
|
321
|
+
Checking that the commit exists reads its keyset root; ``roots``,
|
|
322
|
+
when given, keeps it, for a caller about to open that tree.
|
|
323
|
+
"""
|
|
292
324
|
given = [ref for ref in (commit, branch, tag) if ref is not None]
|
|
293
325
|
if len(given) != 1:
|
|
294
326
|
raise ValueError("name exactly one of commit, branch or tag")
|
|
295
327
|
if branch is not None:
|
|
296
|
-
return self.branches
|
|
328
|
+
return self.branches._resolve(branch, roots)
|
|
297
329
|
if tag is not None:
|
|
298
330
|
tagged = _kv._resolve_tag(self._store, tag)
|
|
299
331
|
if tagged is None:
|
|
@@ -301,18 +333,10 @@ class Repo:
|
|
|
301
333
|
target = tagged
|
|
302
334
|
else:
|
|
303
335
|
target = commit # type: ignore[assignment]
|
|
304
|
-
if self._store
|
|
336
|
+
if not _kv._has_root(self._store, target, roots):
|
|
305
337
|
raise UnknownCommitError(f"Commit '{target}' does not exist")
|
|
306
338
|
return target
|
|
307
339
|
|
|
308
|
-
def _pointers(self, commit: str) -> dict[str, str]:
|
|
309
|
-
"""A commit's keyset as key -> blob pointer."""
|
|
310
|
-
root_raw = self._store.get(COMMIT_ROOT % commit)
|
|
311
|
-
if root_raw is None:
|
|
312
|
-
raise UnknownCommitError(f"Commit '{commit}' does not exist")
|
|
313
|
-
entries = Keyset(self._store, root=loads(root_raw)).materialize()
|
|
314
|
-
return {key: entry.blob for key, entry in entries.items()}
|
|
315
|
-
|
|
316
340
|
|
|
317
341
|
class Snapshot(Mapping[str, Any]):
|
|
318
342
|
"""The state of the repository at one commit, read-only.
|
|
@@ -321,27 +345,68 @@ class Snapshot(Mapping[str, Any]):
|
|
|
321
345
|
is the same state as stored bytes, whatever the codec.
|
|
322
346
|
"""
|
|
323
347
|
|
|
324
|
-
def __init__(self, repo: Repo, commit: str) -> None:
|
|
348
|
+
def __init__(self, repo: Repo, commit: str, *, root: str | None = None) -> None:
|
|
325
349
|
self._repo = repo
|
|
326
350
|
self.commit = commit
|
|
351
|
+
# The keyset root, when whoever made this snapshot already read it.
|
|
352
|
+
self._root = root
|
|
353
|
+
self._keyset: Keyset | None = None
|
|
354
|
+
# The whole keyset as key -> blob pointer, once something iterates
|
|
355
|
+
# it; until then, reads look keys up in the tree one batch at a
|
|
356
|
+
# time and remember what they found (None: absent).
|
|
327
357
|
self._pointers: dict[str, str] | None = None
|
|
358
|
+
self._looked_up: dict[str, str | None] = {}
|
|
328
359
|
|
|
329
360
|
def __repr__(self) -> str:
|
|
330
361
|
return f"Snapshot(commit={self.commit[:8]}...)"
|
|
331
362
|
|
|
363
|
+
def _tree(self) -> Keyset:
|
|
364
|
+
if self._keyset is None:
|
|
365
|
+
root = self._root
|
|
366
|
+
if root is None:
|
|
367
|
+
root_raw = self._repo._store.get(COMMIT_ROOT % self.commit)
|
|
368
|
+
if root_raw is None:
|
|
369
|
+
raise UnknownCommitError(f"Commit '{self.commit}' does not exist")
|
|
370
|
+
root = loads(root_raw)
|
|
371
|
+
self._keyset = Keyset(self._repo._store, root=root)
|
|
372
|
+
return self._keyset
|
|
373
|
+
|
|
332
374
|
def _index(self) -> dict[str, str]:
|
|
375
|
+
"""The whole keyset, read once, for iteration and length."""
|
|
333
376
|
if self._pointers is None:
|
|
334
|
-
|
|
377
|
+
entries = self._tree().materialize()
|
|
378
|
+
self._pointers = {key: entry.blob for key, entry in entries.items()}
|
|
335
379
|
return self._pointers
|
|
336
380
|
|
|
381
|
+
def _pointers_of(self, keys: tuple[str, ...]) -> dict[str, str]:
|
|
382
|
+
"""The blob pointers of those keys that exist.
|
|
383
|
+
|
|
384
|
+
Once the whole keyset has been read, from that; before, by looking
|
|
385
|
+
just these keys up in the tree — its depth in batched reads, not
|
|
386
|
+
the whole keyset — so reading a few keys at a commit stays cheap
|
|
387
|
+
however many the commit holds.
|
|
388
|
+
"""
|
|
389
|
+
if self._pointers is not None:
|
|
390
|
+
return {k: self._pointers[k] for k in keys if k in self._pointers}
|
|
391
|
+
unknown = [k for k in dict.fromkeys(keys) if k not in self._looked_up]
|
|
392
|
+
if unknown:
|
|
393
|
+
found = self._tree().get_many(unknown)
|
|
394
|
+
for key in unknown:
|
|
395
|
+
entry = found.get(key)
|
|
396
|
+
self._looked_up[key] = None if entry is None else entry.blob
|
|
397
|
+
return {
|
|
398
|
+
key: pointer
|
|
399
|
+
for key in keys
|
|
400
|
+
if (pointer := self._looked_up.get(key)) is not None
|
|
401
|
+
}
|
|
402
|
+
|
|
337
403
|
def _raw_many(self, keys: tuple[str, ...]) -> dict[str, bytes]:
|
|
338
|
-
index = self._index()
|
|
339
404
|
wanted: dict[str, list[str]] = {}
|
|
340
|
-
for key in keys:
|
|
341
|
-
pointer
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
found = self._repo.
|
|
405
|
+
for key, pointer in self._pointers_of(keys).items():
|
|
406
|
+
wanted.setdefault(pointer, []).append(key)
|
|
407
|
+
if not wanted:
|
|
408
|
+
return {}
|
|
409
|
+
found = self._repo._store.get_many(wanted.keys())
|
|
345
410
|
return {key: raw for pointer, raw in found.items() for key in wanted[pointer]}
|
|
346
411
|
|
|
347
412
|
def __getitem__(self, key: str) -> Any:
|
|
@@ -362,7 +427,7 @@ class Snapshot(Mapping[str, Any]):
|
|
|
362
427
|
return len(self._index())
|
|
363
428
|
|
|
364
429
|
def __contains__(self, key: object) -> bool:
|
|
365
|
-
return isinstance(key, str) and
|
|
430
|
+
return isinstance(key, str) and bool(self._pointers_of((key,)))
|
|
366
431
|
|
|
367
432
|
@property
|
|
368
433
|
def raw(self) -> RawSnapshot:
|
|
@@ -155,6 +155,11 @@ class Keyset:
|
|
|
155
155
|
return None
|
|
156
156
|
return decode_entry(raw)
|
|
157
157
|
|
|
158
|
+
def get_many(self, keys: Iterable[str]) -> dict[str, KeysetEntry]:
|
|
159
|
+
"""The entries of the keys that are present, reading their paths
|
|
160
|
+
in one batched read per tree level; see ``Hamt.get_many``."""
|
|
161
|
+
return {k: decode_entry(v) for k, v in self._hamt.get_many(keys).items()}
|
|
162
|
+
|
|
158
163
|
def get_blob(self, key: str) -> str | None:
|
|
159
164
|
"""Shortcut: just the blob pointer, no meta."""
|
|
160
165
|
entry = self.get(key)
|