kvgit 0.3.3__tar.gz → 0.3.4__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.3.3 → kvgit-0.3.4}/PKG-INFO +1 -1
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/store.py +24 -2
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/kv.py +208 -29
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.3.3 → kvgit-0.3.4}/pyproject.toml +1 -1
- {kvgit-0.3.3 → kvgit-0.3.4}/LICENSE +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/README.md +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/__init__.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/__init__.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/_hash.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/base.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/numpy.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/pandas.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/codecs/pickler.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/content_types.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/encoding.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/errors.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/hamt.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/base.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/composite.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/disk.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/indexeddb.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/kv/memory.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/namespaced.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/py.typed +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/staged.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/__init__.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/base.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/keyset.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/SOURCES.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/setup.cfg +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_content_types.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_delete_branches.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_hamt.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_namespaced.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_staged.py +0 -0
- {kvgit-0.3.3 → kvgit-0.3.4}/tests/test_store_factory.py +0 -0
|
@@ -7,7 +7,13 @@ from typing import Any, Literal
|
|
|
7
7
|
from .kv.base import KVStore
|
|
8
8
|
from .kv.memory import Memory
|
|
9
9
|
from .staged import Staged
|
|
10
|
-
from .versioned.kv import
|
|
10
|
+
from .versioned.kv import (
|
|
11
|
+
BRANCH_HEAD,
|
|
12
|
+
BRANCH_HEAD_PREV,
|
|
13
|
+
CorruptHeadRecoverer,
|
|
14
|
+
VersionedKV,
|
|
15
|
+
clean_orphans,
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
def _make_backend(
|
|
@@ -46,6 +52,7 @@ def store(
|
|
|
46
52
|
encoder: Callable[..., bytes] = pickle.dumps,
|
|
47
53
|
decoder: Callable[..., Any] = pickle.loads,
|
|
48
54
|
codecs: str | None = None,
|
|
55
|
+
recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
|
|
49
56
|
) -> Staged:
|
|
50
57
|
"""Create a Staged store with sensible defaults.
|
|
51
58
|
|
|
@@ -62,6 +69,13 @@ def store(
|
|
|
62
69
|
``"scientific"`` — numpy/pandas chunked codecs (requires
|
|
63
70
|
numpy; install with ``pip install kvgit[scientific]``).
|
|
64
71
|
Mutually exclusive with explicit ``encoder`` / ``decoder``.
|
|
72
|
+
recover_from_corrupt_head: Optional last-resort recovery for a
|
|
73
|
+
branch whose HEAD is unresolvable *and* whose prev-HEAD
|
|
74
|
+
backup is gone. Off by default: with both gone the store
|
|
75
|
+
does not know what the branch pointed at, so the honest
|
|
76
|
+
answer is to fail rather than guess. Pass
|
|
77
|
+
``kvgit.versioned.kv.recover_by_commit_scan`` to opt into
|
|
78
|
+
the scan, having read what it can return.
|
|
65
79
|
|
|
66
80
|
Returns:
|
|
67
81
|
A ``Staged`` store instance.
|
|
@@ -85,7 +99,15 @@ def store(
|
|
|
85
99
|
|
|
86
100
|
backend = _make_backend(kind, path=path, db_name=db_name)
|
|
87
101
|
|
|
88
|
-
return Staged(
|
|
102
|
+
return Staged(
|
|
103
|
+
VersionedKV(
|
|
104
|
+
backend,
|
|
105
|
+
branch=branch,
|
|
106
|
+
recover_from_corrupt_head=recover_from_corrupt_head,
|
|
107
|
+
),
|
|
108
|
+
encoder=encoder,
|
|
109
|
+
decoder=decoder,
|
|
110
|
+
)
|
|
89
111
|
|
|
90
112
|
|
|
91
113
|
def delete_branches(
|
|
@@ -53,6 +53,7 @@ import hashlib
|
|
|
53
53
|
import json
|
|
54
54
|
import logging
|
|
55
55
|
import time
|
|
56
|
+
from collections.abc import Callable
|
|
56
57
|
|
|
57
58
|
from ..encoding import dumps, loads, safe_loads
|
|
58
59
|
from ..hamt import EMPTY_HASH
|
|
@@ -151,8 +152,30 @@ def _load_root(store: KVStore, commit_hash: str) -> str | None:
|
|
|
151
152
|
return val if isinstance(val, str) else None
|
|
152
153
|
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
CorruptHeadRecoverer = Callable[[KVStore, str], "str | None"]
|
|
156
|
+
"""Last-resort recovery for a HEAD that is present but unresolvable.
|
|
157
|
+
|
|
158
|
+
Called with the store and the branch name; returns a commit hash to
|
|
159
|
+
treat as that branch's HEAD, or ``None`` if it cannot say. Mirrors the
|
|
160
|
+
TypeScript port's ``CorruptHeadRecoverer`` so the two implementations
|
|
161
|
+
read the same.
|
|
162
|
+
|
|
163
|
+
There is **no default**. When HEAD is unresolvable and the prev-HEAD
|
|
164
|
+
backup is missing or equally broken, the information needed is not in
|
|
165
|
+
the store, so no implementation can be correct — only lucky. kvgit
|
|
166
|
+
reports ``None`` and leaves the guess to a caller who has decided the
|
|
167
|
+
trade is worth it. :func:`recover_by_commit_scan` is the implementation
|
|
168
|
+
to hand in if that caller is you.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_head(
|
|
173
|
+
store: KVStore,
|
|
174
|
+
branch: str,
|
|
175
|
+
*,
|
|
176
|
+
recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
|
|
177
|
+
) -> str | None:
|
|
178
|
+
"""Resolve a branch HEAD, falling back to prev HEAD then an injected recoverer.
|
|
156
179
|
|
|
157
180
|
**Never writes.** Every read path in the library goes through here,
|
|
158
181
|
so healing the damage in place would make an ordinary ``get`` a
|
|
@@ -163,10 +186,15 @@ def _resolve_head(store: KVStore, branch: str) -> str | None:
|
|
|
163
186
|
HEAD itself as part of the CAS that has to move it anyway.
|
|
164
187
|
|
|
165
188
|
The cost of not persisting is paid per read on a damaged store: two
|
|
166
|
-
extra ``get`` calls for the prev-HEAD tier,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
189
|
+
extra ``get`` calls for the prev-HEAD tier, plus whatever the
|
|
190
|
+
injected recoverer costs below it. A store sitting on a corrupt HEAD
|
|
191
|
+
has a bigger problem than read latency.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
recover_from_corrupt_head: Optional third tier, fired only when
|
|
195
|
+
HEAD exists, is unusable, and the backup did not save it.
|
|
196
|
+
Unset — the default — means such a branch resolves to None.
|
|
197
|
+
See :data:`CorruptHeadRecoverer`.
|
|
170
198
|
|
|
171
199
|
Returns a valid commit hash, or None if unrecoverable.
|
|
172
200
|
"""
|
|
@@ -180,8 +208,21 @@ def _resolve_head(store: KVStore, branch: str) -> str | None:
|
|
|
180
208
|
):
|
|
181
209
|
return commit_hash
|
|
182
210
|
|
|
183
|
-
# 2.
|
|
184
|
-
|
|
211
|
+
# 2. HEAD is present but unusable — try the backup.
|
|
212
|
+
#
|
|
213
|
+
# Only reached when HEAD exists. An absent HEAD does not mean
|
|
214
|
+
# damage, it means the branch is gone — ``delete_branch`` removes
|
|
215
|
+
# the key — and a backup that outlives its branch must not bring
|
|
216
|
+
# the branch back. One can outlive it: a writer descheduled between
|
|
217
|
+
# its CAS and its backup write, resuming after a concurrent delete,
|
|
218
|
+
# recreates only the backup.
|
|
219
|
+
#
|
|
220
|
+
# Nothing legitimate needs the ungated form: ``_cas_head`` writes
|
|
221
|
+
# the backup only after a successful CAS, so HEAD exists whenever
|
|
222
|
+
# the backup means anything.
|
|
223
|
+
prev_bytes = (
|
|
224
|
+
store.get(BRANCH_HEAD_PREV % branch) if head_bytes is not None else None
|
|
225
|
+
)
|
|
185
226
|
if prev_bytes is not None:
|
|
186
227
|
commit_hash = safe_loads(prev_bytes)
|
|
187
228
|
if (
|
|
@@ -193,23 +234,75 @@ def _resolve_head(store: KVStore, branch: str) -> str | None:
|
|
|
193
234
|
)
|
|
194
235
|
return commit_hash
|
|
195
236
|
|
|
196
|
-
# 3. HEAD existed
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
237
|
+
# 3. HEAD existed, is corrupt, and the backup did not save it. The
|
|
238
|
+
# store no longer holds the answer, so there is nothing left to
|
|
239
|
+
# read — only to guess. Guessing is the caller's call, not ours.
|
|
240
|
+
if recover_from_corrupt_head is not None and head_bytes is not None:
|
|
241
|
+
commit_hash = recover_from_corrupt_head(store, branch)
|
|
242
|
+
# A recoverer is caller-supplied, so its answer is checked the
|
|
243
|
+
# same way anything else read out of the store is: a string
|
|
244
|
+
# naming a commit whose root is present. This function promises
|
|
245
|
+
# a valid commit or None, and an unchecked answer is worse than
|
|
246
|
+
# no answer — ``repair_head`` makes it durable, replacing
|
|
247
|
+
# obviously-corrupt HEAD bytes with a plausible hash naming
|
|
248
|
+
# nothing, which is harder to diagnose than the damage it
|
|
249
|
+
# replaced.
|
|
250
|
+
if (
|
|
251
|
+
isinstance(commit_hash, str)
|
|
252
|
+
and store.get(COMMIT_ROOT % commit_hash) is not None
|
|
253
|
+
):
|
|
200
254
|
logger.warning(
|
|
201
|
-
"Branch '%s': HEAD corrupt, recovered via
|
|
255
|
+
"Branch '%s': HEAD corrupt, recovered via injected recoverer",
|
|
256
|
+
branch,
|
|
202
257
|
)
|
|
203
258
|
return commit_hash
|
|
259
|
+
if commit_hash is not None:
|
|
260
|
+
logger.warning(
|
|
261
|
+
"Branch '%s': recoverer returned %r, which is not a commit in "
|
|
262
|
+
"this store; treating the branch as unrecoverable",
|
|
263
|
+
branch,
|
|
264
|
+
commit_hash,
|
|
265
|
+
)
|
|
204
266
|
|
|
205
267
|
return None
|
|
206
268
|
|
|
207
269
|
|
|
208
|
-
def
|
|
209
|
-
"""
|
|
270
|
+
def recover_by_commit_scan(store: KVStore, branch: str) -> str | None:
|
|
271
|
+
"""Guess a corrupt branch's HEAD by scanning every commit. Opt-in.
|
|
272
|
+
|
|
273
|
+
Finds all valid commits, excludes those reachable from healthy
|
|
274
|
+
branches, and returns the most recent remaining tip (by
|
|
275
|
+
``__commit_time__``). A :data:`CorruptHeadRecoverer`, so it is
|
|
276
|
+
passed in rather than reached for::
|
|
277
|
+
|
|
278
|
+
from kvgit.versioned.kv import recover_by_commit_scan
|
|
279
|
+
|
|
280
|
+
v = VersionedKV(store, recover_from_corrupt_head=recover_by_commit_scan)
|
|
281
|
+
|
|
282
|
+
kvgit's default through v0.3.3, and **not** the default any more.
|
|
283
|
+
The name says what it does rather than what it is for, because what
|
|
284
|
+
it is for is the part that cannot be guaranteed: this is a heuristic
|
|
285
|
+
over a store that has already lost the answer.
|
|
210
286
|
|
|
211
|
-
|
|
212
|
-
|
|
287
|
+
Two things to weigh before wiring it in.
|
|
288
|
+
|
|
289
|
+
**It can serve another branch's deleted data.** "Not claimed by a
|
|
290
|
+
healthy branch" is the only signal it has for whose commit a commit
|
|
291
|
+
is, and a deleted branch's commits are unclaimed by definition until
|
|
292
|
+
:func:`clean_orphans` collects them. Delete a branch, damage an
|
|
293
|
+
unrelated branch's HEAD, lose its backup, and this returns the
|
|
294
|
+
deleted branch's tip — grafting onto the survivor a lineage it never
|
|
295
|
+
had, behind a ``logger.warning``. No race, no concurrency, no
|
|
296
|
+
unusual store required.
|
|
297
|
+
|
|
298
|
+
**It is O(store).** Every ``__commit_root__`` and every branch
|
|
299
|
+
ancestry, walked per unresolved read until someone calls
|
|
300
|
+
:func:`repair_head`.
|
|
301
|
+
|
|
302
|
+
It is worth it when losing the branch outright is worse than
|
|
303
|
+
recovering it to a plausible commit — a single-branch store, or one
|
|
304
|
+
where branches are never deleted, has neither hazard in play. That
|
|
305
|
+
judgement belongs to whoever owns the data.
|
|
213
306
|
"""
|
|
214
307
|
root_prefix = COMMIT_ROOT.replace("%s", "")
|
|
215
308
|
all_commits: dict[str, float] = {}
|
|
@@ -321,7 +414,12 @@ def _heal_head(store: KVStore, branch: str, recovered: bytes) -> bool:
|
|
|
321
414
|
return True
|
|
322
415
|
|
|
323
416
|
|
|
324
|
-
def repair_head(
|
|
417
|
+
def repair_head(
|
|
418
|
+
store: KVStore,
|
|
419
|
+
branch: str = "main",
|
|
420
|
+
*,
|
|
421
|
+
recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
|
|
422
|
+
) -> str | None:
|
|
325
423
|
"""Persist a recovered HEAD for a damaged branch.
|
|
326
424
|
|
|
327
425
|
Read paths recover a corrupt ``__branch_head__`` in memory and leave
|
|
@@ -343,16 +441,28 @@ def repair_head(store: KVStore, branch: str = "main") -> str | None:
|
|
|
343
441
|
commit than HEAD actually holds. The branch is re-resolved instead,
|
|
344
442
|
so the answer describes the store rather than the attempt.
|
|
345
443
|
|
|
444
|
+
Args:
|
|
445
|
+
recover_from_corrupt_head: Optional last-resort recovery, used
|
|
446
|
+
only for a HEAD that is present, unusable, and has no usable
|
|
447
|
+
backup. Unset — the default — means such a branch is
|
|
448
|
+
reported unrecoverable rather than guessed at. See
|
|
449
|
+
:data:`CorruptHeadRecoverer` and
|
|
450
|
+
:func:`recover_by_commit_scan`.
|
|
451
|
+
|
|
346
452
|
Returns:
|
|
347
453
|
The commit HEAD now names, or None if the branch does not exist
|
|
348
454
|
or nothing recoverable was found.
|
|
349
455
|
"""
|
|
350
|
-
commit_hash = _resolve_head(
|
|
456
|
+
commit_hash = _resolve_head(
|
|
457
|
+
store, branch, recover_from_corrupt_head=recover_from_corrupt_head
|
|
458
|
+
)
|
|
351
459
|
if commit_hash is None:
|
|
352
460
|
return None
|
|
353
461
|
if _heal_head(store, branch, dumps(commit_hash)):
|
|
354
462
|
return commit_hash
|
|
355
|
-
return _resolve_head(
|
|
463
|
+
return _resolve_head(
|
|
464
|
+
store, branch, recover_from_corrupt_head=recover_from_corrupt_head
|
|
465
|
+
)
|
|
356
466
|
|
|
357
467
|
|
|
358
468
|
def clean_orphans(store: KVStore, min_age: float = 3600) -> int:
|
|
@@ -473,6 +583,19 @@ def _sweep(store: KVStore, min_age: float, *, deep: bool) -> int:
|
|
|
473
583
|
if not (isinstance(key, str) and key.startswith(branch_prefix)):
|
|
474
584
|
continue
|
|
475
585
|
branch_name = key[len(branch_prefix) :]
|
|
586
|
+
# No ``recover_from_corrupt_head`` here, deliberately, even when
|
|
587
|
+
# the caller has one wired into their handles. GC must not
|
|
588
|
+
# decide reachability from a guess. A wrong answer from a
|
|
589
|
+
# last-resort recoverer marks the wrong commits live — real
|
|
590
|
+
# garbage survives forever, and a guessed tip gets walked as
|
|
591
|
+
# though it were this branch's own history, so another branch's
|
|
592
|
+
# ancestry can be pinned into this one's mark set. The sweep
|
|
593
|
+
# should see only what the store actually claims: a branch whose
|
|
594
|
+
# HEAD resolves is marked from its real HEAD, and one whose HEAD
|
|
595
|
+
# does not resolve marks nothing and keeps its commits as young
|
|
596
|
+
# orphans until ``min_age`` and an explicit ``repair_head``
|
|
597
|
+
# settle what it points at. The inconsistency with the read
|
|
598
|
+
# paths is the point, not an oversight.
|
|
476
599
|
branch_head = _resolve_head(store, branch_name)
|
|
477
600
|
if branch_head is None:
|
|
478
601
|
continue
|
|
@@ -610,6 +733,13 @@ class VersionedKV(VersionedBase):
|
|
|
610
733
|
- ``commit()`` to atomically write changes and advance HEAD
|
|
611
734
|
- ``refresh()`` to reload from HEAD
|
|
612
735
|
- ``checkout()`` / ``history()`` for navigating commits
|
|
736
|
+
|
|
737
|
+
``recover_from_corrupt_head`` is the optional last-resort tier of
|
|
738
|
+
HEAD resolution, for a HEAD that is present, unusable, and has no
|
|
739
|
+
usable backup. Unset by default, which makes such a branch
|
|
740
|
+
unrecoverable rather than guessed at; pass
|
|
741
|
+
:func:`recover_by_commit_scan` to restore kvgit's pre-0.3.4
|
|
742
|
+
behaviour. See :data:`CorruptHeadRecoverer`.
|
|
613
743
|
"""
|
|
614
744
|
|
|
615
745
|
def __init__(
|
|
@@ -618,15 +748,23 @@ class VersionedKV(VersionedBase):
|
|
|
618
748
|
*,
|
|
619
749
|
commit_hash: str | None = None,
|
|
620
750
|
branch: str = "main",
|
|
751
|
+
recover_from_corrupt_head: CorruptHeadRecoverer | None = None,
|
|
621
752
|
) -> None:
|
|
622
753
|
if store is None:
|
|
623
754
|
store = Memory()
|
|
624
755
|
self.store = store
|
|
756
|
+
# Applies to every resolve this handle makes — opening, reading
|
|
757
|
+
# HEAD, refreshing, switching, peeking, repairing — and is
|
|
758
|
+
# inherited by the handles ``checkout`` and ``create_branch``
|
|
759
|
+
# hand back, so a caller opts in once rather than per call.
|
|
760
|
+
self._recover_from_corrupt_head = recover_from_corrupt_head
|
|
625
761
|
|
|
626
762
|
_check_storage_version(store)
|
|
627
763
|
|
|
628
764
|
if commit_hash is None:
|
|
629
|
-
commit_hash = _resolve_head(
|
|
765
|
+
commit_hash = _resolve_head(
|
|
766
|
+
store, branch, recover_from_corrupt_head=recover_from_corrupt_head
|
|
767
|
+
)
|
|
630
768
|
if commit_hash is None and store.get(BRANCH_HEAD % branch) is not None:
|
|
631
769
|
raise ValueError(f"Branch '{branch}' HEAD is corrupt and unrecoverable")
|
|
632
770
|
if commit_hash is None:
|
|
@@ -639,6 +777,11 @@ class VersionedKV(VersionedBase):
|
|
|
639
777
|
BRANCH_HEAD % branch: dumps(commit_hash),
|
|
640
778
|
}
|
|
641
779
|
store.set_many(initial)
|
|
780
|
+
# Same reasoning as ``create_branch``: this name had no HEAD
|
|
781
|
+
# a moment ago, so it has no previous HEAD either, and a
|
|
782
|
+
# backup that outlived a delete must not become reachable
|
|
783
|
+
# again through the anchor we just installed.
|
|
784
|
+
store.remove(BRANCH_HEAD_PREV % branch)
|
|
642
785
|
|
|
643
786
|
if not isinstance(commit_hash, str):
|
|
644
787
|
raise TypeError(
|
|
@@ -672,7 +815,11 @@ class VersionedKV(VersionedBase):
|
|
|
672
815
|
@property
|
|
673
816
|
def latest_head(self) -> str | None:
|
|
674
817
|
"""Read HEAD directly from the KV store (reflects other writers)."""
|
|
675
|
-
return _resolve_head(
|
|
818
|
+
return _resolve_head(
|
|
819
|
+
self.store,
|
|
820
|
+
self._branch,
|
|
821
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
822
|
+
)
|
|
676
823
|
|
|
677
824
|
# -- Read operations --
|
|
678
825
|
|
|
@@ -908,8 +1055,8 @@ class VersionedKV(VersionedBase):
|
|
|
908
1055
|
never before. Written first, it lands whether or not the CAS
|
|
909
1056
|
does, so a writer that loses the race still leaves its own stale
|
|
910
1057
|
``expected`` as the branch's recovery target — clobbering the
|
|
911
|
-
winner's backup, and, when ``expected`` came from
|
|
912
|
-
|
|
1058
|
+
winner's backup, and, when ``expected`` came from an injected
|
|
1059
|
+
recoverer, naming a commit that was never HEAD at all. Writing it
|
|
913
1060
|
afterwards makes it always a value ``__branch_head__`` really
|
|
914
1061
|
held.
|
|
915
1062
|
|
|
@@ -1013,7 +1160,11 @@ class VersionedKV(VersionedBase):
|
|
|
1013
1160
|
|
|
1014
1161
|
def refresh(self) -> None:
|
|
1015
1162
|
"""Reload state from HEAD."""
|
|
1016
|
-
commit_hash = _resolve_head(
|
|
1163
|
+
commit_hash = _resolve_head(
|
|
1164
|
+
self.store,
|
|
1165
|
+
self._branch,
|
|
1166
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
1167
|
+
)
|
|
1017
1168
|
if commit_hash is None:
|
|
1018
1169
|
raise ValueError(f"No HEAD commit found for branch {self._branch}")
|
|
1019
1170
|
self._load_commit(commit_hash, update_base=True)
|
|
@@ -1028,6 +1179,7 @@ class VersionedKV(VersionedBase):
|
|
|
1028
1179
|
self.store,
|
|
1029
1180
|
commit_hash=commit_hash,
|
|
1030
1181
|
branch=branch or self._branch,
|
|
1182
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
1031
1183
|
)
|
|
1032
1184
|
|
|
1033
1185
|
def create_branch(self, name: str, *, at: str | None = None) -> "VersionedKV":
|
|
@@ -1041,7 +1193,22 @@ class VersionedKV(VersionedBase):
|
|
|
1041
1193
|
raise ValueError(f"Commit '{at}' does not exist")
|
|
1042
1194
|
if not self.store.cas(branch_key, dumps(target), expected=None):
|
|
1043
1195
|
raise ValueError(f"Branch '{name}' already exists")
|
|
1044
|
-
|
|
1196
|
+
# A branch that has just been created has no previous HEAD, so any
|
|
1197
|
+
# backup under this name is stale by definition. One can outlive a
|
|
1198
|
+
# ``delete_branch`` — a writer descheduled between its CAS and its
|
|
1199
|
+
# backup write, resuming after the delete — and while the name is
|
|
1200
|
+
# unclaimed head resolution ignores it, but re-installing an anchor
|
|
1201
|
+
# would make it reachable again: corrupt this branch's fresh HEAD
|
|
1202
|
+
# before its first successful CAS and the prev-HEAD tier would serve
|
|
1203
|
+
# the *deleted* branch's tip. Dropped after the CAS, so a losing
|
|
1204
|
+
# attempt cannot take out the existing branch's real backup.
|
|
1205
|
+
self.store.remove(BRANCH_HEAD_PREV % name)
|
|
1206
|
+
return VersionedKV(
|
|
1207
|
+
self.store,
|
|
1208
|
+
commit_hash=target,
|
|
1209
|
+
branch=name,
|
|
1210
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
1211
|
+
)
|
|
1045
1212
|
|
|
1046
1213
|
def delete_branch(self, name: str) -> None:
|
|
1047
1214
|
"""Delete a branch and clean up orphaned commits."""
|
|
@@ -1060,7 +1227,11 @@ class VersionedKV(VersionedBase):
|
|
|
1060
1227
|
|
|
1061
1228
|
def switch_branch(self, name: str) -> None:
|
|
1062
1229
|
"""Switch this instance to a different branch in-place."""
|
|
1063
|
-
commit_hash = _resolve_head(
|
|
1230
|
+
commit_hash = _resolve_head(
|
|
1231
|
+
self.store,
|
|
1232
|
+
name,
|
|
1233
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
1234
|
+
)
|
|
1064
1235
|
if commit_hash is None:
|
|
1065
1236
|
if self.store.get(BRANCH_HEAD % name) is not None:
|
|
1066
1237
|
raise ValueError(f"Branch '{name}' HEAD is corrupt and unrecoverable")
|
|
@@ -1070,7 +1241,11 @@ class VersionedKV(VersionedBase):
|
|
|
1070
1241
|
|
|
1071
1242
|
def peek(self, key: str, *, branch: str) -> bytes | None:
|
|
1072
1243
|
"""Read a key from another branch's HEAD without switching."""
|
|
1073
|
-
commit_hash = _resolve_head(
|
|
1244
|
+
commit_hash = _resolve_head(
|
|
1245
|
+
self.store,
|
|
1246
|
+
branch,
|
|
1247
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
1248
|
+
)
|
|
1074
1249
|
if commit_hash is None:
|
|
1075
1250
|
return None
|
|
1076
1251
|
root = _load_root(self.store, commit_hash)
|
|
@@ -1133,7 +1308,11 @@ class VersionedKV(VersionedBase):
|
|
|
1133
1308
|
The commit HEAD now names, or None if nothing was
|
|
1134
1309
|
recoverable.
|
|
1135
1310
|
"""
|
|
1136
|
-
return repair_head(
|
|
1311
|
+
return repair_head(
|
|
1312
|
+
self.store,
|
|
1313
|
+
self._branch,
|
|
1314
|
+
recover_from_corrupt_head=self._recover_from_corrupt_head,
|
|
1315
|
+
)
|
|
1137
1316
|
|
|
1138
1317
|
# -- Orphan cleanup --
|
|
1139
1318
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
description = "Versioned key-value store with git-like commit, branch, and merge semantics."
|
|
9
9
|
authors = [{ name = "ashenfad" }]
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|