kvgit 0.4.1__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.1 → kvgit-0.4.2}/PKG-INFO +1 -1
  2. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/_open.py +7 -1
  3. kvgit-0.4.2/kvgit/cache.py +283 -0
  4. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/refs.py +19 -14
  5. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/repo.py +52 -15
  6. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/kv.py +99 -20
  7. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/PKG-INFO +1 -1
  8. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/SOURCES.txt +2 -0
  9. {kvgit-0.4.1 → kvgit-0.4.2}/pyproject.toml +1 -1
  10. kvgit-0.4.2/tests/test_cache.py +272 -0
  11. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_repo.py +135 -0
  12. {kvgit-0.4.1 → kvgit-0.4.2}/LICENSE +0 -0
  13. {kvgit-0.4.1 → kvgit-0.4.2}/README.md +0 -0
  14. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/__init__.py +0 -0
  15. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/_codec.py +0 -0
  16. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/__init__.py +0 -0
  17. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/_hash.py +0 -0
  18. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/base.py +0 -0
  19. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/numpy.py +0 -0
  20. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/pandas.py +0 -0
  21. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/codecs/pickler.py +0 -0
  22. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/content_types.py +0 -0
  23. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/encoding.py +0 -0
  24. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/errors.py +0 -0
  25. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/hamt.py +0 -0
  26. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/__init__.py +0 -0
  27. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/base.py +0 -0
  28. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/composite.py +0 -0
  29. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/disk.py +0 -0
  30. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/indexeddb.py +0 -0
  31. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/memory.py +0 -0
  32. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/kv/postgres.py +0 -0
  33. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/merges.py +0 -0
  34. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/namespaced.py +0 -0
  35. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/py.typed +0 -0
  36. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/__init__.py +0 -0
  37. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/base.py +0 -0
  38. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/helpers.py +0 -0
  39. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/keyset.py +0 -0
  40. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/merge.py +0 -0
  41. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/versioned/protocol.py +0 -0
  42. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit/worktree.py +0 -0
  43. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/dependency_links.txt +0 -0
  44. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/requires.txt +0 -0
  45. {kvgit-0.4.1 → kvgit-0.4.2}/kvgit.egg-info/top_level.txt +0 -0
  46. {kvgit-0.4.1 → kvgit-0.4.2}/setup.cfg +0 -0
  47. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_content_types.py +0 -0
  48. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_delete_branches.py +0 -0
  49. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_hamt.py +0 -0
  50. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_merge_policy.py +0 -0
  51. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_merges.py +0 -0
  52. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_namespaced.py +0 -0
  53. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_open.py +0 -0
  54. {kvgit-0.4.1 → kvgit-0.4.2}/tests/test_storage_v4.py +0 -0
  55. {kvgit-0.4.1 → 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.1
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])
@@ -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,7 +333,7 @@ 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
 
@@ -313,9 +345,11 @@ class Snapshot(Mapping[str, Any]):
313
345
  is the same state as stored bytes, whatever the codec.
314
346
  """
315
347
 
316
- def __init__(self, repo: Repo, commit: str) -> None:
348
+ def __init__(self, repo: Repo, commit: str, *, root: str | None = None) -> None:
317
349
  self._repo = repo
318
350
  self.commit = commit
351
+ # The keyset root, when whoever made this snapshot already read it.
352
+ self._root = root
319
353
  self._keyset: Keyset | None = None
320
354
  # The whole keyset as key -> blob pointer, once something iterates
321
355
  # it; until then, reads look keys up in the tree one batch at a
@@ -328,10 +362,13 @@ class Snapshot(Mapping[str, Any]):
328
362
 
329
363
  def _tree(self) -> Keyset:
330
364
  if self._keyset is None:
331
- root_raw = self._repo.store.get(COMMIT_ROOT % self.commit)
332
- if root_raw is None:
333
- raise UnknownCommitError(f"Commit '{self.commit}' does not exist")
334
- self._keyset = Keyset(self._repo.store, root=loads(root_raw))
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)
335
372
  return self._keyset
336
373
 
337
374
  def _index(self) -> dict[str, str]:
@@ -369,7 +406,7 @@ class Snapshot(Mapping[str, Any]):
369
406
  wanted.setdefault(pointer, []).append(key)
370
407
  if not wanted:
371
408
  return {}
372
- found = self._repo.store.get_many(wanted.keys())
409
+ found = self._repo._store.get_many(wanted.keys())
373
410
  return {key: raw for pointer, raw in found.items() for key in wanted[pointer]}
374
411
 
375
412
  def __getitem__(self, key: str) -> Any: