kvgit 0.4.1__tar.gz → 0.4.3__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.1 → kvgit-0.4.3}/PKG-INFO +4 -2
- {kvgit-0.4.1 → kvgit-0.4.3}/README.md +3 -1
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/__init__.py +2 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/_codec.py +29 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/_open.py +14 -1
- kvgit-0.4.3/kvgit/cache.py +369 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/errors.py +22 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/refs.py +36 -14
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/repo.py +68 -15
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/base.py +207 -44
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/kv.py +263 -81
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/worktree.py +50 -15
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/PKG-INFO +4 -2
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/SOURCES.txt +4 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/pyproject.toml +1 -1
- kvgit-0.4.3/tests/test_cache.py +272 -0
- kvgit-0.4.3/tests/test_commit_on_moved.py +190 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_repo.py +286 -0
- kvgit-0.4.3/tests/test_value_cache.py +222 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_worktree.py +29 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/LICENSE +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/base.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/content_types.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/encoding.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/hamt.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/base.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/composite.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/disk.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/memory.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/kv/postgres.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/merges.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/namespaced.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/py.typed +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/setup.cfg +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_content_types.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_delete_branches.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_hamt.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_merge_policy.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_merges.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_namespaced.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_open.py +0 -0
- {kvgit-0.4.1 → kvgit-0.4.3}/tests/test_storage_v4.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kvgit
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Versioned key-value store with git-like commit, branch, and merge semantics.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License: MIT
|
|
@@ -144,7 +144,9 @@ with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
|
|
|
144
144
|
| `git gc [--prune=now]` | `repo.gc([min_age=0])` |
|
|
145
145
|
|
|
146
146
|
Where it differs: there is no index; several worktrees can hold one
|
|
147
|
-
branch, and their commits race and merge rather than being refused
|
|
147
|
+
branch, and their commits race and merge rather than being refused
|
|
148
|
+
(`commit(on_moved="raise")` refuses instead, raising
|
|
149
|
+
`BranchMovedError`, for data whose keys aren't independent); a
|
|
148
150
|
conflicted merge raises `MergeConflict` and changes nothing (unless a
|
|
149
151
|
merge function such as `text_merge` writes markers into the value);
|
|
150
152
|
commit hashes are not git's, and there are no remotes.
|
|
@@ -90,7 +90,9 @@ with Repo(Disk("/tmp/kvgit-demo")) as on_disk:
|
|
|
90
90
|
| `git gc [--prune=now]` | `repo.gc([min_age=0])` |
|
|
91
91
|
|
|
92
92
|
Where it differs: there is no index; several worktrees can hold one
|
|
93
|
-
branch, and their commits race and merge rather than being refused
|
|
93
|
+
branch, and their commits race and merge rather than being refused
|
|
94
|
+
(`commit(on_moved="raise")` refuses instead, raising
|
|
95
|
+
`BranchMovedError`, for data whose keys aren't independent); a
|
|
94
96
|
conflicted merge raises `MergeConflict` and changes nothing (unless a
|
|
95
97
|
merge function such as `text_merge` writes markers into the value);
|
|
96
98
|
commit hashes are not git's, and there are no remotes.
|
|
@@ -4,6 +4,7 @@ from ._open import open
|
|
|
4
4
|
from .content_types import MergeFn, counter, last_writer_wins, text_merge
|
|
5
5
|
from .errors import (
|
|
6
6
|
BranchExistsError,
|
|
7
|
+
BranchMovedError,
|
|
7
8
|
ConcurrencyError,
|
|
8
9
|
CorruptHeadError,
|
|
9
10
|
GcBusy,
|
|
@@ -40,6 +41,7 @@ from .worktree import Status, Worktree
|
|
|
40
41
|
__all__ = [
|
|
41
42
|
"ROOT_COMMIT",
|
|
42
43
|
"BranchExistsError",
|
|
44
|
+
"BranchMovedError",
|
|
43
45
|
"Branches",
|
|
44
46
|
"CantMark",
|
|
45
47
|
"Commit",
|
|
@@ -61,6 +61,27 @@ class _ChunkReader:
|
|
|
61
61
|
return None
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
class _CountingReader:
|
|
65
|
+
"""A chunk reader that tallies the bytes of the chunks it returns."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, reader: _ChunkReader) -> None:
|
|
68
|
+
self._reader = reader
|
|
69
|
+
self.nbytes = 0
|
|
70
|
+
|
|
71
|
+
def get(self, ref: str) -> bytes:
|
|
72
|
+
raw = self._reader.get(ref)
|
|
73
|
+
self.nbytes += len(raw)
|
|
74
|
+
return raw
|
|
75
|
+
|
|
76
|
+
def get_many(self, refs):
|
|
77
|
+
found = self._reader.get_many(refs)
|
|
78
|
+
self.nbytes += sum(len(v) for v in found.values())
|
|
79
|
+
return found
|
|
80
|
+
|
|
81
|
+
def prefetch(self, refs) -> None:
|
|
82
|
+
self._reader.prefetch(refs)
|
|
83
|
+
|
|
84
|
+
|
|
64
85
|
def _is_chunk_aware(fn) -> bool:
|
|
65
86
|
"""Whether an encoder/decoder takes a required sink/reader argument.
|
|
66
87
|
|
|
@@ -148,6 +169,14 @@ class Codec:
|
|
|
148
169
|
return self.decoder(raw, self._reader)
|
|
149
170
|
return self.decoder(raw)
|
|
150
171
|
|
|
172
|
+
def decode_sized(self, raw: bytes) -> tuple[Any, int]:
|
|
173
|
+
"""A value, and the bytes it was decoded from: its blob plus any
|
|
174
|
+
chunks a chunked decoder read, which the value usually keeps."""
|
|
175
|
+
if self.decoder_chunked:
|
|
176
|
+
reader = _CountingReader(self._reader) # type: ignore[arg-type]
|
|
177
|
+
return self.decoder(raw, reader), len(raw) + reader.nbytes
|
|
178
|
+
return self.decoder(raw), len(raw)
|
|
179
|
+
|
|
151
180
|
def encode_merged(self, value: Any) -> bytes:
|
|
152
181
|
"""Encode a value a merge function produced.
|
|
153
182
|
|
|
@@ -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, DEFAULT_VALUE_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,8 @@ 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,
|
|
44
|
+
value_cache_bytes: int = DEFAULT_VALUE_CACHE_BYTES,
|
|
42
45
|
) -> Worktree:
|
|
43
46
|
"""Open (or create) a store, and a worktree on ``branch`` of it.
|
|
44
47
|
|
|
@@ -57,6 +60,16 @@ def open(
|
|
|
57
60
|
db_name: The IndexedDB database name, for ``kind="indexeddb"``.
|
|
58
61
|
branch: The branch to check out (default ``"main"``).
|
|
59
62
|
codec: How values are stored; see :class:`Repo`.
|
|
63
|
+
cache_bytes: Memory for what the store never changes; see
|
|
64
|
+
:class:`Repo`.
|
|
65
|
+
value_cache_bytes: Memory for the values the worktree reads;
|
|
66
|
+
see :class:`Repo`.
|
|
60
67
|
"""
|
|
61
68
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
62
|
-
|
|
69
|
+
repo = Repo(
|
|
70
|
+
backend,
|
|
71
|
+
codec=codec,
|
|
72
|
+
cache_bytes=cache_bytes,
|
|
73
|
+
value_cache_bytes=value_cache_bytes,
|
|
74
|
+
)
|
|
75
|
+
return repo.worktree(branch, create=True)
|
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
A worktree keeps a second, separate memory: the decoded values it has
|
|
24
|
+
read at its head (:class:`ValueCache`). That one holds blobs, so it is
|
|
25
|
+
per worktree, emptied whenever the head moves, and bounded in bytes.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import threading
|
|
31
|
+
from collections import OrderedDict
|
|
32
|
+
from collections.abc import Iterable, Mapping
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .kv.base import KVStore
|
|
36
|
+
from .versioned.keyset import Keyset
|
|
37
|
+
from .versioned.kv import (
|
|
38
|
+
COMMIT_TIME,
|
|
39
|
+
GC_LEASE_KEY,
|
|
40
|
+
INFO_KEY,
|
|
41
|
+
PARENT_COMMIT,
|
|
42
|
+
PARENT_GENS,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
DEFAULT_CACHE_BYTES = 32 * 1024 * 1024
|
|
46
|
+
"""The default budget: tens of thousands of tree nodes."""
|
|
47
|
+
|
|
48
|
+
DEFAULT_VALUE_CACHE_BYTES = 32 * 1024 * 1024
|
|
49
|
+
"""The default budget for each worktree's decoded values."""
|
|
50
|
+
|
|
51
|
+
_CACHEABLE_PREFIXES = (
|
|
52
|
+
Keyset.DEFAULT_PREFIX,
|
|
53
|
+
PARENT_COMMIT.replace("%s", ""),
|
|
54
|
+
PARENT_GENS.replace("%s", ""),
|
|
55
|
+
COMMIT_TIME.replace("%s", ""),
|
|
56
|
+
INFO_KEY.replace("%s", ""),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# What an entry costs beyond its bytes: the key, the dict slot, the
|
|
60
|
+
# object headers. An estimate, so the budget bounds memory rather than
|
|
61
|
+
# just payload.
|
|
62
|
+
_ENTRY_OVERHEAD = 96
|
|
63
|
+
|
|
64
|
+
_UNSEEN: Any = object()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cacheable(key: str) -> bool:
|
|
68
|
+
"""Whether ``key`` is one the store never rewrites."""
|
|
69
|
+
return key.startswith(_CACHEABLE_PREFIXES)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ContentCache:
|
|
73
|
+
"""Least-recently-used memory of write-once keys, bounded in bytes.
|
|
74
|
+
|
|
75
|
+
Shared by every worktree and snapshot of one :class:`~kvgit.Repo`,
|
|
76
|
+
and safe to use from several threads.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, max_bytes: int = DEFAULT_CACHE_BYTES) -> None:
|
|
80
|
+
if max_bytes <= 0:
|
|
81
|
+
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
|
82
|
+
self.max_bytes = max_bytes
|
|
83
|
+
self._entries: OrderedDict[str, bytes] = OrderedDict()
|
|
84
|
+
self._bytes = 0
|
|
85
|
+
self._lock = threading.Lock()
|
|
86
|
+
self._lease: Any = _UNSEEN
|
|
87
|
+
self.hits = 0
|
|
88
|
+
"""Reads answered from memory."""
|
|
89
|
+
self.misses = 0
|
|
90
|
+
"""Reads of cacheable keys that went to the store."""
|
|
91
|
+
self.clears = 0
|
|
92
|
+
"""How often a sweep emptied the cache."""
|
|
93
|
+
|
|
94
|
+
def __repr__(self) -> str:
|
|
95
|
+
return (
|
|
96
|
+
f"ContentCache({len(self._entries)} entries, "
|
|
97
|
+
f"{self._bytes}/{self.max_bytes} bytes)"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __len__(self) -> int:
|
|
101
|
+
return len(self._entries)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def size_bytes(self) -> int:
|
|
105
|
+
"""What the entries held now cost, by the budget's estimate."""
|
|
106
|
+
return self._bytes
|
|
107
|
+
|
|
108
|
+
def clear(self) -> None:
|
|
109
|
+
with self._lock:
|
|
110
|
+
self._clear()
|
|
111
|
+
|
|
112
|
+
def _clear(self) -> None:
|
|
113
|
+
self._entries.clear()
|
|
114
|
+
self._bytes = 0
|
|
115
|
+
|
|
116
|
+
def lookup(self, keys: Iterable[str]) -> dict[str, bytes]:
|
|
117
|
+
"""The cached values of those ``keys`` held here."""
|
|
118
|
+
found: dict[str, bytes] = {}
|
|
119
|
+
with self._lock:
|
|
120
|
+
for key in keys:
|
|
121
|
+
value = self._entries.get(key)
|
|
122
|
+
if value is not None:
|
|
123
|
+
self._entries.move_to_end(key)
|
|
124
|
+
found[key] = value
|
|
125
|
+
return found
|
|
126
|
+
|
|
127
|
+
def count(self, hits: int, misses: int) -> None:
|
|
128
|
+
with self._lock:
|
|
129
|
+
self.hits += hits
|
|
130
|
+
self.misses += misses
|
|
131
|
+
|
|
132
|
+
def remember(self, items: Mapping[str, bytes]) -> None:
|
|
133
|
+
"""Hold these values, evicting the least recently used to fit."""
|
|
134
|
+
with self._lock:
|
|
135
|
+
for key, value in items.items():
|
|
136
|
+
if not isinstance(value, bytes | bytearray | memoryview):
|
|
137
|
+
continue
|
|
138
|
+
cost = len(value) + len(key) + _ENTRY_OVERHEAD
|
|
139
|
+
if cost > self.max_bytes:
|
|
140
|
+
continue
|
|
141
|
+
old = self._entries.pop(key, None)
|
|
142
|
+
if old is not None:
|
|
143
|
+
self._bytes -= len(old) + len(key) + _ENTRY_OVERHEAD
|
|
144
|
+
self._entries[key] = bytes(value)
|
|
145
|
+
self._bytes += cost
|
|
146
|
+
while self._bytes > self.max_bytes and self._entries:
|
|
147
|
+
key, value = self._entries.popitem(last=False)
|
|
148
|
+
self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
|
|
149
|
+
|
|
150
|
+
def forget(self, keys: Iterable[str]) -> None:
|
|
151
|
+
with self._lock:
|
|
152
|
+
for key in keys:
|
|
153
|
+
value = self._entries.pop(key, None)
|
|
154
|
+
if value is not None:
|
|
155
|
+
self._bytes -= len(value) + len(key) + _ENTRY_OVERHEAD
|
|
156
|
+
|
|
157
|
+
def saw_lease(self, record: bytes | None) -> None:
|
|
158
|
+
"""Note the GC lease record a read found; a different one than
|
|
159
|
+
last time means a sweep has run, and what it removed may be
|
|
160
|
+
held here."""
|
|
161
|
+
with self._lock:
|
|
162
|
+
if self._lease is not _UNSEEN and record != self._lease:
|
|
163
|
+
self._clear()
|
|
164
|
+
self.clears += 1
|
|
165
|
+
self._lease = record
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class CachedStore(KVStore):
|
|
169
|
+
"""A backend seen through a :class:`ContentCache`.
|
|
170
|
+
|
|
171
|
+
Reads of write-once keys are answered from the cache where it can
|
|
172
|
+
and remembered where it cannot; everything else passes through
|
|
173
|
+
untouched. Successful writes of write-once keys are remembered too,
|
|
174
|
+
so a commit's next read of its own tree is a hit, and removals
|
|
175
|
+
evict. Anything else the backend offers (``close``, ``drop``, ...)
|
|
176
|
+
is reached through this object as it would be on the backend.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, backend: KVStore, cache: ContentCache) -> None:
|
|
180
|
+
self.backend = backend
|
|
181
|
+
self.cache = cache
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
return f"CachedStore({self.backend!r})"
|
|
185
|
+
|
|
186
|
+
def __getattr__(self, name: str) -> Any:
|
|
187
|
+
if name.startswith("__") or name in ("backend", "cache"):
|
|
188
|
+
raise AttributeError(name)
|
|
189
|
+
return getattr(self.backend, name)
|
|
190
|
+
|
|
191
|
+
# -- reads -----------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def get(self, key: str) -> bytes | None:
|
|
194
|
+
if cacheable(key):
|
|
195
|
+
held = self.cache.lookup((key,))
|
|
196
|
+
if key in held:
|
|
197
|
+
self.cache.count(1, 0)
|
|
198
|
+
return held[key]
|
|
199
|
+
value = self.backend.get(key)
|
|
200
|
+
self.cache.count(0, 1)
|
|
201
|
+
if value is not None:
|
|
202
|
+
self.cache.remember({key: value})
|
|
203
|
+
return value
|
|
204
|
+
value = self.backend.get(key)
|
|
205
|
+
if key == GC_LEASE_KEY:
|
|
206
|
+
self.cache.saw_lease(value)
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
def get_many(self, *args) -> Mapping[str, bytes]:
|
|
210
|
+
keys = list(dict.fromkeys(self._normalize_keys(args)))
|
|
211
|
+
wanted = [k for k in keys if cacheable(k)]
|
|
212
|
+
if GC_LEASE_KEY in keys:
|
|
213
|
+
# A read of the lease may reveal a sweep, and nothing the
|
|
214
|
+
# cache held before it may be answered beside it: the whole
|
|
215
|
+
# call goes to the store, and the record is noted before
|
|
216
|
+
# anything it brought back is remembered.
|
|
217
|
+
held: dict[str, bytes] = {}
|
|
218
|
+
found = dict(self.backend.get_many(keys))
|
|
219
|
+
self.cache.saw_lease(found.get(GC_LEASE_KEY))
|
|
220
|
+
else:
|
|
221
|
+
held = self.cache.lookup(wanted) if wanted else {}
|
|
222
|
+
rest = [k for k in keys if k not in held]
|
|
223
|
+
found = dict(self.backend.get_many(rest)) if rest else {}
|
|
224
|
+
fetched = {k: v for k, v in found.items() if cacheable(k)}
|
|
225
|
+
if wanted:
|
|
226
|
+
self.cache.count(len(held), len(wanted) - len(held))
|
|
227
|
+
if fetched:
|
|
228
|
+
self.cache.remember(fetched)
|
|
229
|
+
found.update(held)
|
|
230
|
+
return found
|
|
231
|
+
|
|
232
|
+
def items(self) -> Iterable[tuple[str, bytes]]:
|
|
233
|
+
return self.backend.items()
|
|
234
|
+
|
|
235
|
+
def keys(self, prefix: str = "") -> Iterable[str]:
|
|
236
|
+
return self.backend.keys(prefix)
|
|
237
|
+
|
|
238
|
+
def __contains__(self, key: str) -> bool:
|
|
239
|
+
if cacheable(key) and self.cache.lookup((key,)):
|
|
240
|
+
return True
|
|
241
|
+
return key in self.backend
|
|
242
|
+
|
|
243
|
+
# -- writes ----------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def set(self, key: str, value: bytes) -> None:
|
|
246
|
+
self.backend.set(key, value)
|
|
247
|
+
self._wrote({key: value}, ())
|
|
248
|
+
|
|
249
|
+
def set_many(
|
|
250
|
+
self, items: Mapping[str, bytes] | None = None, /, **kwargs: bytes
|
|
251
|
+
) -> None:
|
|
252
|
+
items = dict(self._normalize_items(items, kwargs))
|
|
253
|
+
self.backend.set_many(items)
|
|
254
|
+
self._wrote(items, ())
|
|
255
|
+
|
|
256
|
+
def remove(self, key: str) -> None:
|
|
257
|
+
self.backend.remove(key)
|
|
258
|
+
self._wrote({}, (key,))
|
|
259
|
+
|
|
260
|
+
def remove_many(self, *args) -> None:
|
|
261
|
+
keys = list(self._normalize_keys(args))
|
|
262
|
+
self.backend.remove_many(keys)
|
|
263
|
+
self._wrote({}, keys)
|
|
264
|
+
|
|
265
|
+
def cas_many(
|
|
266
|
+
self,
|
|
267
|
+
expected: Mapping[str, bytes | None],
|
|
268
|
+
writes: Mapping[str, bytes],
|
|
269
|
+
removes: Iterable[str] = (),
|
|
270
|
+
) -> bool:
|
|
271
|
+
removes = list(removes)
|
|
272
|
+
if not self.backend.cas_many(expected, writes, removes):
|
|
273
|
+
return False
|
|
274
|
+
self._wrote(writes, removes)
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def clear(self) -> None:
|
|
278
|
+
self.backend.clear()
|
|
279
|
+
self.cache.clear()
|
|
280
|
+
|
|
281
|
+
def _wrote(self, writes: Mapping[str, bytes], removes: Iterable[str]) -> None:
|
|
282
|
+
gone = [k for k in removes if cacheable(k)]
|
|
283
|
+
if gone:
|
|
284
|
+
self.cache.forget(gone)
|
|
285
|
+
landed = {k: v for k, v in writes.items() if cacheable(k)}
|
|
286
|
+
if landed:
|
|
287
|
+
self.cache.remember(landed)
|
|
288
|
+
if GC_LEASE_KEY in writes:
|
|
289
|
+
# This process's own sweep taking or releasing the lease.
|
|
290
|
+
self.cache.saw_lease(writes[GC_LEASE_KEY])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class ValueCache:
|
|
294
|
+
"""A worktree's decoded values of committed keys, least recently
|
|
295
|
+
used first out, bounded in bytes.
|
|
296
|
+
|
|
297
|
+
It holds only what the worktree's head already stores, so anything
|
|
298
|
+
it lets go is read again from the store; pending writes are kept by
|
|
299
|
+
the worktree itself and never pass through here. A value's cost is
|
|
300
|
+
the bytes it was decoded from (its blob, plus any chunks a chunked
|
|
301
|
+
codec read), and a value costing more than a quarter of the budget
|
|
302
|
+
is not held at all, so one large read cannot push out everything
|
|
303
|
+
else and the largest values, the ones a caller may want only a few
|
|
304
|
+
bytes of, are not kept alive by the cache. A budget of ``0`` holds
|
|
305
|
+
nothing.
|
|
306
|
+
|
|
307
|
+
Safe to use from several threads.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def __init__(self, max_bytes: int = DEFAULT_VALUE_CACHE_BYTES) -> None:
|
|
311
|
+
if max_bytes < 0:
|
|
312
|
+
raise ValueError(f"max_bytes must not be negative, got {max_bytes}")
|
|
313
|
+
self.max_bytes = max_bytes
|
|
314
|
+
self._entries: OrderedDict[str, tuple[Any, int]] = OrderedDict()
|
|
315
|
+
self._bytes = 0
|
|
316
|
+
self._lock = threading.Lock()
|
|
317
|
+
|
|
318
|
+
def __repr__(self) -> str:
|
|
319
|
+
return (
|
|
320
|
+
f"ValueCache({len(self._entries)} entries, "
|
|
321
|
+
f"{self._bytes}/{self.max_bytes} bytes)"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def __len__(self) -> int:
|
|
325
|
+
return len(self._entries)
|
|
326
|
+
|
|
327
|
+
def __contains__(self, key: object) -> bool:
|
|
328
|
+
return key in self._entries
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def size_bytes(self) -> int:
|
|
332
|
+
"""What the values held now cost, by the budget's estimate."""
|
|
333
|
+
return self._bytes
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def max_value_bytes(self) -> int:
|
|
337
|
+
"""The largest cost of a value that is held."""
|
|
338
|
+
return self.max_bytes // 4
|
|
339
|
+
|
|
340
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
341
|
+
"""The value held for ``key``, marked as just used; ``default``
|
|
342
|
+
if none is."""
|
|
343
|
+
with self._lock:
|
|
344
|
+
entry = self._entries.get(key)
|
|
345
|
+
if entry is None:
|
|
346
|
+
return default
|
|
347
|
+
self._entries.move_to_end(key)
|
|
348
|
+
return entry[0]
|
|
349
|
+
|
|
350
|
+
def remember(self, key: str, value: Any, nbytes: int) -> None:
|
|
351
|
+
"""Hold ``value``, decoded from ``nbytes`` bytes, evicting the
|
|
352
|
+
least recently used to fit."""
|
|
353
|
+
cost = nbytes + len(key) + _ENTRY_OVERHEAD
|
|
354
|
+
if cost > self.max_value_bytes:
|
|
355
|
+
return
|
|
356
|
+
with self._lock:
|
|
357
|
+
old = self._entries.pop(key, None)
|
|
358
|
+
if old is not None:
|
|
359
|
+
self._bytes -= old[1]
|
|
360
|
+
self._entries[key] = (value, cost)
|
|
361
|
+
self._bytes += cost
|
|
362
|
+
while self._bytes > self.max_bytes:
|
|
363
|
+
_, (_, evicted) = self._entries.popitem(last=False)
|
|
364
|
+
self._bytes -= evicted
|
|
365
|
+
|
|
366
|
+
def clear(self) -> None:
|
|
367
|
+
with self._lock:
|
|
368
|
+
self._entries.clear()
|
|
369
|
+
self._bytes = 0
|
|
@@ -18,6 +18,28 @@ class ConcurrencyError(KvgitError):
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class BranchMovedError(ConcurrencyError):
|
|
22
|
+
"""Raised by a commit made with ``on_moved="raise"`` that found its
|
|
23
|
+
branch moved: the branch's head is no longer the commit the handle
|
|
24
|
+
is based on. Nothing was published and pending changes are kept;
|
|
25
|
+
refresh and redo the work, or give up.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
branch: The branch.
|
|
29
|
+
base: The commit the handle is based on, which the branch no
|
|
30
|
+
longer names.
|
|
31
|
+
head: The branch's head as the commit found it.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, branch: str, base: str, head: str) -> None:
|
|
35
|
+
self.branch = branch
|
|
36
|
+
self.base = base
|
|
37
|
+
self.head = head
|
|
38
|
+
super().__init__(
|
|
39
|
+
f"Branch '{branch}' moved from {base} to {head}; nothing was committed"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
21
43
|
class MergeConflict(KvgitError):
|
|
22
44
|
"""Raised when a three-way merge encounters unresolvable conflicts.
|
|
23
45
|
|
|
@@ -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:
|
|
@@ -72,10 +77,21 @@ class Branches(Mapping[str, str]):
|
|
|
72
77
|
``gc``; worktrees still holding it read from their heads and raise
|
|
73
78
|
:class:`UnknownBranchError` on commit.
|
|
74
79
|
|
|
80
|
+
The removal is conditional on the head this call read: if another
|
|
81
|
+
writer moves the branch, or deletes and recreates it, in between,
|
|
82
|
+
nothing is removed and the call raises rather than retrying, so a
|
|
83
|
+
newer head is never deleted on the strength of an older one. A
|
|
84
|
+
branch made again at the same commit is indistinguishable from
|
|
85
|
+
the one read, and is deleted, as with git's
|
|
86
|
+
``update-ref -d <ref> <old-oid>``.
|
|
87
|
+
|
|
75
88
|
Raises:
|
|
76
89
|
UnknownBranchError: if there is no such branch.
|
|
90
|
+
ConcurrencyError: if the branch changed while being deleted.
|
|
91
|
+
ValueError: if ``name`` is empty or in the reserved tag
|
|
92
|
+
namespace.
|
|
77
93
|
"""
|
|
78
|
-
_kv.delete_branch(self._repo.
|
|
94
|
+
_kv.delete_branch(self._repo._store, name)
|
|
79
95
|
|
|
80
96
|
|
|
81
97
|
class Tags(Mapping[str, str]):
|
|
@@ -95,7 +111,7 @@ class Tags(Mapping[str, str]):
|
|
|
95
111
|
return f"Tags({list(self)!r})"
|
|
96
112
|
|
|
97
113
|
def __getitem__(self, name: str) -> str:
|
|
98
|
-
commit = _kv._resolve_tag(self._repo.
|
|
114
|
+
commit = _kv._resolve_tag(self._repo._store, name)
|
|
99
115
|
if commit is None:
|
|
100
116
|
raise UnknownTagError(f"Tag '{name}' does not exist")
|
|
101
117
|
return commit
|
|
@@ -103,14 +119,14 @@ class Tags(Mapping[str, str]):
|
|
|
103
119
|
def __contains__(self, name: object) -> bool:
|
|
104
120
|
return (
|
|
105
121
|
isinstance(name, str)
|
|
106
|
-
and _kv._resolve_tag(self._repo.
|
|
122
|
+
and _kv._resolve_tag(self._repo._store, name) is not None
|
|
107
123
|
)
|
|
108
124
|
|
|
109
125
|
def __iter__(self) -> Iterator[str]:
|
|
110
|
-
return iter(_kv.tags(self._repo.
|
|
126
|
+
return iter(_kv.tags(self._repo._store))
|
|
111
127
|
|
|
112
128
|
def __len__(self) -> int:
|
|
113
|
-
return len(_kv.tags(self._repo.
|
|
129
|
+
return len(_kv.tags(self._repo._store))
|
|
114
130
|
|
|
115
131
|
def items(self) -> ItemsView[str, str]:
|
|
116
132
|
"""Every tag and its commit: a live view, like the mapping, that
|
|
@@ -125,16 +141,22 @@ class Tags(Mapping[str, str]):
|
|
|
125
141
|
TagExistsError: if the name is taken.
|
|
126
142
|
UnknownCommitError: if ``commit`` is not in the store.
|
|
127
143
|
"""
|
|
128
|
-
_kv.create_tag(self._repo.
|
|
144
|
+
_kv.create_tag(self._repo._store, name, commit, info)
|
|
129
145
|
|
|
130
146
|
def delete(self, name: str) -> None:
|
|
131
147
|
"""Delete a tag. A commit it alone kept alive becomes collectable
|
|
132
148
|
at the next ``gc``.
|
|
133
149
|
|
|
150
|
+
The removal is conditional on the head and info record this call
|
|
151
|
+
read: a tag deleted and made again by another writer in between,
|
|
152
|
+
even at the same commit, keeps its new commit and info, and the
|
|
153
|
+
call raises rather than retrying.
|
|
154
|
+
|
|
134
155
|
Raises:
|
|
135
156
|
UnknownTagError: if there is no such tag.
|
|
157
|
+
ConcurrencyError: if the tag changed while being deleted.
|
|
136
158
|
"""
|
|
137
|
-
_kv.delete_tag(self._repo.
|
|
159
|
+
_kv.delete_tag(self._repo._store, name)
|
|
138
160
|
|
|
139
161
|
def info(self, name: str) -> TagInfo:
|
|
140
162
|
"""A tag's commit, creation time, info, and whether its commit is
|
|
@@ -143,7 +165,7 @@ class Tags(Mapping[str, str]):
|
|
|
143
165
|
Raises:
|
|
144
166
|
UnknownTagError: if there is no such tag.
|
|
145
167
|
"""
|
|
146
|
-
found = _kv.tag_info(self._repo.
|
|
168
|
+
found = _kv.tag_info(self._repo._store, name)
|
|
147
169
|
if found is None:
|
|
148
170
|
raise UnknownTagError(f"Tag '{name}' does not exist")
|
|
149
171
|
return found
|