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.
Files changed (55) hide show
  1. {kvgit-0.4.0 → kvgit-0.4.2}/PKG-INFO +1 -1
  2. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/_open.py +7 -1
  3. kvgit-0.4.2/kvgit/cache.py +283 -0
  4. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/hamt.py +21 -3
  5. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/refs.py +19 -14
  6. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/repo.py +91 -26
  7. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/keyset.py +5 -0
  8. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/kv.py +99 -20
  9. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/PKG-INFO +1 -1
  10. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/SOURCES.txt +2 -0
  11. {kvgit-0.4.0 → kvgit-0.4.2}/pyproject.toml +1 -1
  12. kvgit-0.4.2/tests/test_cache.py +272 -0
  13. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_hamt.py +18 -0
  14. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_repo.py +217 -0
  15. {kvgit-0.4.0 → kvgit-0.4.2}/LICENSE +0 -0
  16. {kvgit-0.4.0 → kvgit-0.4.2}/README.md +0 -0
  17. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/__init__.py +0 -0
  18. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/_codec.py +0 -0
  19. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/__init__.py +0 -0
  20. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/_hash.py +0 -0
  21. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/base.py +0 -0
  22. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/numpy.py +0 -0
  23. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/pandas.py +0 -0
  24. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/codecs/pickler.py +0 -0
  25. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/content_types.py +0 -0
  26. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/encoding.py +0 -0
  27. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/errors.py +0 -0
  28. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/__init__.py +0 -0
  29. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/base.py +0 -0
  30. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/composite.py +0 -0
  31. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/disk.py +0 -0
  32. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/indexeddb.py +0 -0
  33. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/memory.py +0 -0
  34. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/kv/postgres.py +0 -0
  35. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/merges.py +0 -0
  36. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/namespaced.py +0 -0
  37. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/py.typed +0 -0
  38. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/__init__.py +0 -0
  39. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/base.py +0 -0
  40. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/helpers.py +0 -0
  41. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/merge.py +0 -0
  42. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/versioned/protocol.py +0 -0
  43. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit/worktree.py +0 -0
  44. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/dependency_links.txt +0 -0
  45. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/requires.txt +0 -0
  46. {kvgit-0.4.0 → kvgit-0.4.2}/kvgit.egg-info/top_level.txt +0 -0
  47. {kvgit-0.4.0 → kvgit-0.4.2}/setup.cfg +0 -0
  48. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_content_types.py +0 -0
  49. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_delete_branches.py +0 -0
  50. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_merge_policy.py +0 -0
  51. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_merges.py +0 -0
  52. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_namespaced.py +0 -0
  53. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_open.py +0 -0
  54. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_storage_v4.py +0 -0
  55. {kvgit-0.4.0 → kvgit-0.4.2}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kvgit
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
5
5
  Author: ashenfad
6
6
  License: MIT
@@ -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(branch, create=True)
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 for an update in progress, keyed like
112
- # ``pending``. Filled only on the private view ``updated`` works
113
- # through, so a shared ``Hamt`` never has two updates writing it.
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.store
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.store, name)
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.store))
58
+ return iter(VersionedKV.branches(self._repo._store))
54
59
 
55
60
  def __len__(self) -> int:
56
- return len(VersionedKV.branches(self._repo.store))
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.store, name, target)
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.store, name)
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.store, name)
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.store, name) is not None
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.store))
115
+ return iter(_kv.tags(self._repo._store))
111
116
 
112
117
  def __len__(self) -> int:
113
- return len(_kv.tags(self._repo.store))
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.store, name, commit, info)
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.store, name)
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.store, name)
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._store = backend
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._store).__name__})"
122
+ return f"Repo({type(self._backend).__name__})"
104
123
 
105
124
  @property
106
125
  def store(self) -> KVStore:
107
- """The backend."""
108
- return self._store
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._store, "close", None)
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
- return Snapshot(self, self._resolve_ref(commit=commit, branch=branch, tag=tag))
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[branch]
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.get(COMMIT_ROOT % target) is None:
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
- self._pointers = self._repo._pointers(self.commit)
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 = index.get(key)
342
- if pointer is not None:
343
- wanted.setdefault(pointer, []).append(key)
344
- found = self._repo.store.get_many(wanted.keys())
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 key in self._index()
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)