kvgit 0.1.6__tar.gz → 0.1.8__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.1.6 → kvgit-0.1.8}/PKG-INFO +1 -1
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/indexeddb.py +10 -3
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/store.py +1 -29
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/__init__.py +0 -2
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/kv.py +96 -1
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/PKG-INFO +1 -1
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/SOURCES.txt +0 -1
- {kvgit-0.1.6 → kvgit-0.1.8}/pyproject.toml +1 -1
- {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_store_factory.py +0 -6
- kvgit-0.1.6/kvgit/versioned/gc.py +0 -334
- {kvgit-0.1.6 → kvgit-0.1.8}/LICENSE +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/README.md +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/__init__.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/content_types.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/encoding.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/errors.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/__init__.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/base.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/composite.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/disk.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/kv/memory.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/namespaced.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/py.typed +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/staged.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/base.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/gp.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/helpers.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/merge.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit/versioned/protocol.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/dependency_links.txt +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/requires.txt +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/kvgit.egg-info/top_level.txt +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/setup.cfg +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_content_types.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_namespaced.py +0 -0
- {kvgit-0.1.6 → kvgit-0.1.8}/tests/test_staged.py +0 -0
|
@@ -117,10 +117,17 @@ async def _idb_tx_complete(tx):
|
|
|
117
117
|
|
|
118
118
|
|
|
119
119
|
def _to_bytes(js_value) -> bytes | None:
|
|
120
|
-
"""Convert a JS result to bytes, or None if absent.
|
|
120
|
+
"""Convert a JS result to bytes, or None if absent.
|
|
121
|
+
|
|
122
|
+
Uses Uint8Array.to_py() for fast memcpy from JS to WASM memory,
|
|
123
|
+
avoiding the slow element-wise iteration of bytes(js_proxy).
|
|
124
|
+
"""
|
|
121
125
|
if js_value is None or js_value is undefined:
|
|
122
126
|
return None
|
|
123
|
-
|
|
127
|
+
from js import Uint8Array # type: ignore[import-not-found]
|
|
128
|
+
|
|
129
|
+
arr = Uint8Array.new(js_value)
|
|
130
|
+
return arr.to_py().tobytes()
|
|
124
131
|
|
|
125
132
|
|
|
126
133
|
class IndexedDB(KVStore):
|
|
@@ -206,7 +213,7 @@ class IndexedDB(KVStore):
|
|
|
206
213
|
def on_success(event):
|
|
207
214
|
cursor = event.target.result
|
|
208
215
|
if cursor:
|
|
209
|
-
results.append((str(cursor.key),
|
|
216
|
+
results.append((str(cursor.key), _to_bytes(cursor.value)))
|
|
210
217
|
cursor.continue_()
|
|
211
218
|
else:
|
|
212
219
|
resolve(None)
|
|
@@ -6,7 +6,6 @@ from typing import Any, Callable, Literal
|
|
|
6
6
|
from .kv.base import KVStore
|
|
7
7
|
from .kv.memory import Memory
|
|
8
8
|
from .staged import Staged
|
|
9
|
-
from .versioned.gc import GCVersionedKV
|
|
10
9
|
from .versioned.kv import VersionedKV
|
|
11
10
|
from .versioned.protocol import Versioned
|
|
12
11
|
|
|
@@ -19,9 +18,6 @@ def store(
|
|
|
19
18
|
branch: str = "main",
|
|
20
19
|
encoder: Callable[[Any], bytes] = pickle.dumps,
|
|
21
20
|
decoder: Callable[[bytes], Any] = pickle.loads,
|
|
22
|
-
high_water_bytes: int | None = None,
|
|
23
|
-
low_water_bytes: int | None = None,
|
|
24
|
-
is_protected: Callable[[str], bool] | None = None,
|
|
25
21
|
) -> Staged:
|
|
26
22
|
"""Create a Staged store with sensible defaults.
|
|
27
23
|
|
|
@@ -34,14 +30,6 @@ def store(
|
|
|
34
30
|
branch: Branch name (default ``"main"``).
|
|
35
31
|
encoder: Value encoder (default ``pickle.dumps``).
|
|
36
32
|
decoder: Value decoder (default ``pickle.loads``).
|
|
37
|
-
high_water_bytes: Enable GC with this high-water threshold.
|
|
38
|
-
Not supported with ``kind="git"``.
|
|
39
|
-
low_water_bytes: GC low-water threshold (defaults to 80%
|
|
40
|
-
of high_water). Not supported with ``kind="git"``.
|
|
41
|
-
is_protected: Callable that returns True for keys GC should
|
|
42
|
-
never drop. Only used when ``high_water_bytes`` is set.
|
|
43
|
-
Defaults to protecting keys starting with ``__``.
|
|
44
|
-
Not supported with ``kind="git"``.
|
|
45
33
|
|
|
46
34
|
Returns:
|
|
47
35
|
A ``Staged`` store instance.
|
|
@@ -49,12 +37,6 @@ def store(
|
|
|
49
37
|
versioned: Versioned
|
|
50
38
|
|
|
51
39
|
if kind == "git":
|
|
52
|
-
if (
|
|
53
|
-
high_water_bytes is not None
|
|
54
|
-
or low_water_bytes is not None
|
|
55
|
-
or is_protected is not None
|
|
56
|
-
):
|
|
57
|
-
raise ValueError("GC parameters are not supported with kind='git'")
|
|
58
40
|
if path is None:
|
|
59
41
|
raise ValueError("path is required when kind='git'")
|
|
60
42
|
from .versioned.gp import VersionedGP
|
|
@@ -78,16 +60,6 @@ def store(
|
|
|
78
60
|
else:
|
|
79
61
|
raise ValueError(f"Unknown kind: {kind!r}")
|
|
80
62
|
|
|
81
|
-
|
|
82
|
-
gc_kwargs: dict[str, Any] = {
|
|
83
|
-
"branch": branch,
|
|
84
|
-
"high_water_bytes": high_water_bytes,
|
|
85
|
-
"low_water_bytes": low_water_bytes,
|
|
86
|
-
}
|
|
87
|
-
if is_protected is not None:
|
|
88
|
-
gc_kwargs["is_protected"] = is_protected
|
|
89
|
-
versioned = GCVersionedKV(backend, **gc_kwargs)
|
|
90
|
-
else:
|
|
91
|
-
versioned = VersionedKV(backend, branch=branch)
|
|
63
|
+
versioned = VersionedKV(backend, branch=branch)
|
|
92
64
|
|
|
93
65
|
return Staged(versioned, encoder=encoder, decoder=decoder)
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
"""Versioned store implementations."""
|
|
2
2
|
|
|
3
|
-
from .gc import GCVersionedKV
|
|
4
3
|
from .kv import VersionedKV
|
|
5
4
|
from .protocol import BytesMergeFn, DiffResult, MergeResult, Versioned
|
|
6
5
|
|
|
7
6
|
__all__ = [
|
|
8
7
|
"BytesMergeFn",
|
|
9
8
|
"DiffResult",
|
|
10
|
-
"GCVersionedKV",
|
|
11
9
|
"MergeResult",
|
|
12
10
|
"Versioned",
|
|
13
11
|
"VersionedKV",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
|
+
import logging
|
|
5
6
|
import time
|
|
6
7
|
|
|
7
8
|
from ..encoding import MetaEntry, from_bytes, meta_from_bytes, meta_to_bytes, to_bytes
|
|
@@ -397,13 +398,14 @@ class VersionedKV(VersionedBase):
|
|
|
397
398
|
return VersionedKV(self.store, commit_hash=target, branch=name)
|
|
398
399
|
|
|
399
400
|
def delete_branch(self, name: str) -> None:
|
|
400
|
-
"""Delete a branch
|
|
401
|
+
"""Delete a branch and clean up orphaned commits."""
|
|
401
402
|
if name == self._branch:
|
|
402
403
|
raise ValueError("Cannot delete the current branch")
|
|
403
404
|
branch_key = BRANCH_HEAD % name
|
|
404
405
|
if self.store.get(branch_key) is None:
|
|
405
406
|
raise ValueError(f"Branch '{name}' does not exist")
|
|
406
407
|
self.store.remove(branch_key)
|
|
408
|
+
self.clean_orphans(min_age=0)
|
|
407
409
|
|
|
408
410
|
def switch_branch(self, name: str) -> None:
|
|
409
411
|
"""Switch this instance to a different branch in-place."""
|
|
@@ -461,6 +463,99 @@ class VersionedKV(VersionedBase):
|
|
|
461
463
|
return None
|
|
462
464
|
return from_bytes(info_bytes)
|
|
463
465
|
|
|
466
|
+
# -- Orphan cleanup --
|
|
467
|
+
|
|
468
|
+
def clean_orphans(self, min_age: float = 3600) -> int:
|
|
469
|
+
"""Remove orphaned commits unreachable from any branch HEAD.
|
|
470
|
+
|
|
471
|
+
Traces all reachable commits from live branch HEADs, then
|
|
472
|
+
deletes commit metadata and versioned keys for any commit
|
|
473
|
+
not in the reachable set. Only deletes blobs that are not
|
|
474
|
+
referenced by any reachable commit.
|
|
475
|
+
|
|
476
|
+
The ``min_age`` guard (default 1 hour) prevents recently
|
|
477
|
+
created commits from being falsely swept during concurrent
|
|
478
|
+
writes. Use ``min_age=0`` when concurrency is not a concern
|
|
479
|
+
(e.g. single-user browser environments).
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Number of orphaned commits removed.
|
|
483
|
+
"""
|
|
484
|
+
logger = logging.getLogger("kvgit.orphans")
|
|
485
|
+
|
|
486
|
+
# Mark phase: find all reachable commits and their blob keys
|
|
487
|
+
reachable: set[str] = set()
|
|
488
|
+
reachable_blobs: set[str] = set()
|
|
489
|
+
prefix = BRANCH_HEAD.replace("%s", "")
|
|
490
|
+
for key in self.store.keys():
|
|
491
|
+
if isinstance(key, str) and key.startswith(prefix):
|
|
492
|
+
head_bytes = self.store.get(key)
|
|
493
|
+
if head_bytes is None:
|
|
494
|
+
continue
|
|
495
|
+
branch_head = from_bytes(head_bytes)
|
|
496
|
+
for commit in self.history(commit_hash=branch_head, all_parents=True):
|
|
497
|
+
if commit not in reachable:
|
|
498
|
+
reachable.add(commit)
|
|
499
|
+
ks_bytes = self.store.get(COMMIT_KEYSET % commit)
|
|
500
|
+
if ks_bytes:
|
|
501
|
+
try:
|
|
502
|
+
for vk in from_bytes(ks_bytes).values():
|
|
503
|
+
reachable_blobs.add(vk)
|
|
504
|
+
except Exception:
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
# Sweep phase: find orphaned commits by scanning for meta keys
|
|
508
|
+
meta_prefix = META_KEY.replace("%s", "")
|
|
509
|
+
cutoff_time = time.time() - min_age
|
|
510
|
+
orphans: list[str] = []
|
|
511
|
+
|
|
512
|
+
for key in self.store.keys():
|
|
513
|
+
if not isinstance(key, str) or not key.startswith(meta_prefix):
|
|
514
|
+
continue
|
|
515
|
+
commit_hash = key[len(meta_prefix) :]
|
|
516
|
+
if not commit_hash or commit_hash in reachable:
|
|
517
|
+
continue
|
|
518
|
+
# Check age
|
|
519
|
+
meta_bytes = self.store.get(key)
|
|
520
|
+
if meta_bytes is None:
|
|
521
|
+
continue
|
|
522
|
+
try:
|
|
523
|
+
meta = meta_from_bytes(meta_bytes)
|
|
524
|
+
if meta:
|
|
525
|
+
newest = max(e.created_at for e in meta.values())
|
|
526
|
+
if newest < cutoff_time:
|
|
527
|
+
orphans.append(commit_hash)
|
|
528
|
+
else:
|
|
529
|
+
orphans.append(commit_hash)
|
|
530
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
531
|
+
continue
|
|
532
|
+
|
|
533
|
+
# Delete orphaned commits — only remove blobs not used by reachable commits
|
|
534
|
+
for orphan_hash in orphans:
|
|
535
|
+
keyset_bytes = self.store.get(COMMIT_KEYSET % orphan_hash)
|
|
536
|
+
if keyset_bytes:
|
|
537
|
+
try:
|
|
538
|
+
keyset = from_bytes(keyset_bytes)
|
|
539
|
+
orphan_only = [
|
|
540
|
+
vk for vk in keyset.values() if vk not in reachable_blobs
|
|
541
|
+
]
|
|
542
|
+
if orphan_only:
|
|
543
|
+
self.store.remove_many(*orphan_only)
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
self.store.remove_many(
|
|
547
|
+
META_KEY % orphan_hash,
|
|
548
|
+
COMMIT_KEYSET % orphan_hash,
|
|
549
|
+
PARENT_COMMIT % orphan_hash,
|
|
550
|
+
TOTAL_VAR_SIZE_KEY % orphan_hash,
|
|
551
|
+
INFO_KEY % orphan_hash,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
if orphans:
|
|
555
|
+
logger.debug("Cleaned %d orphaned commit(s)", len(orphans))
|
|
556
|
+
|
|
557
|
+
return len(orphans)
|
|
558
|
+
|
|
464
559
|
# -- Internal --
|
|
465
560
|
|
|
466
561
|
def _touch(self, key: str) -> None:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kvgit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
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"
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
5
|
from kvgit import Staged, store
|
|
6
|
-
from kvgit.versioned.gc import GCVersionedKV as GCVersioned
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
class TestStoreFactory:
|
|
@@ -19,11 +18,6 @@ class TestStoreFactory:
|
|
|
19
18
|
with pytest.raises(ValueError, match="path is required"):
|
|
20
19
|
store(kind="disk")
|
|
21
20
|
|
|
22
|
-
def test_gc_versioned(self):
|
|
23
|
-
s = store(high_water_bytes=5000)
|
|
24
|
-
assert isinstance(s, Staged)
|
|
25
|
-
assert isinstance(s.versioned, GCVersioned)
|
|
26
|
-
|
|
27
21
|
def test_branch_parameter(self):
|
|
28
22
|
s = store(branch="dev")
|
|
29
23
|
assert isinstance(s, Staged)
|
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
"""GCVersionedKV: VersionedKV with automatic garbage collection."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import time
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import Callable
|
|
7
|
-
|
|
8
|
-
from ..errors import ConcurrencyError
|
|
9
|
-
from ..kv.base import KVStore
|
|
10
|
-
from ..encoding import MetaEntry, from_bytes, meta_from_bytes, meta_to_bytes, to_bytes
|
|
11
|
-
from .protocol import MergeResult
|
|
12
|
-
from .kv import (
|
|
13
|
-
BRANCH_HEAD,
|
|
14
|
-
COMMIT_KEYSET,
|
|
15
|
-
INFO_KEY,
|
|
16
|
-
META_KEY,
|
|
17
|
-
PARENT_COMMIT,
|
|
18
|
-
TOTAL_VAR_SIZE_KEY,
|
|
19
|
-
VersionedKV,
|
|
20
|
-
content_hash,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def is_system_key(key: str) -> bool:
|
|
25
|
-
"""Check if a key is a system/protected key (starts with ``__``).
|
|
26
|
-
|
|
27
|
-
Handles both direct keys (``"__foo__"``) and namespaced keys
|
|
28
|
-
(``"ns/__foo__"``) by extracting the base key name.
|
|
29
|
-
|
|
30
|
-
This is the default ``is_protected`` policy for ``GCVersionedKV``.
|
|
31
|
-
"""
|
|
32
|
-
base_key = key.split("/")[-1] if "/" in key else key
|
|
33
|
-
return base_key.startswith("__")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@dataclass(frozen=True)
|
|
37
|
-
class RebaseResult:
|
|
38
|
-
"""Result of a rebase/GC operation."""
|
|
39
|
-
|
|
40
|
-
performed: bool
|
|
41
|
-
new_commit: str | None
|
|
42
|
-
dropped_keys: tuple[str, ...]
|
|
43
|
-
kept_keys: tuple[str, ...]
|
|
44
|
-
total_size_before: int
|
|
45
|
-
total_size_after: int
|
|
46
|
-
orphans_cleaned: int = 0
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class GCVersionedKV(VersionedKV):
|
|
50
|
-
"""VersionedKV with built-in garbage collection via rebase.
|
|
51
|
-
|
|
52
|
-
Rebase strategy (high/low water):
|
|
53
|
-
- Track total persisted user-var size from commit metadata.
|
|
54
|
-
- If total <= high_water_bytes: no-op.
|
|
55
|
-
- If total > high_water_bytes: drop coldest user keys (oldest touch,
|
|
56
|
-
then largest) until total <= low_water_bytes (default 80% of high).
|
|
57
|
-
- Protected keys (as determined by ``is_protected``) are always retained.
|
|
58
|
-
- Write a fresh root commit with only retained keys, then delete
|
|
59
|
-
dropped blobs and orphaned commits.
|
|
60
|
-
|
|
61
|
-
Every ``commit()`` auto-runs the high/low check.
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
def __init__(
|
|
65
|
-
self,
|
|
66
|
-
store: KVStore | None = None,
|
|
67
|
-
*,
|
|
68
|
-
commit_hash: str | None = None,
|
|
69
|
-
branch: str = "main",
|
|
70
|
-
high_water_bytes: int,
|
|
71
|
-
low_water_bytes: int | None = None,
|
|
72
|
-
is_protected: Callable[[str], bool] = is_system_key,
|
|
73
|
-
) -> None:
|
|
74
|
-
super().__init__(store, commit_hash=commit_hash, branch=branch)
|
|
75
|
-
if high_water_bytes <= 0:
|
|
76
|
-
raise ValueError("high_water_bytes must be > 0")
|
|
77
|
-
self.high_water = high_water_bytes
|
|
78
|
-
self.low_water = (
|
|
79
|
-
low_water_bytes
|
|
80
|
-
if low_water_bytes is not None
|
|
81
|
-
else int(high_water_bytes * 0.8)
|
|
82
|
-
)
|
|
83
|
-
if self.low_water <= 0 or self.low_water > self.high_water:
|
|
84
|
-
self.low_water = int(high_water_bytes * 0.8)
|
|
85
|
-
self._is_protected = is_protected
|
|
86
|
-
self.last_rebase_result: RebaseResult | None = None
|
|
87
|
-
|
|
88
|
-
def commit(
|
|
89
|
-
self,
|
|
90
|
-
updates: dict[str, bytes] | None = None,
|
|
91
|
-
removals: set[str] | None = None,
|
|
92
|
-
*,
|
|
93
|
-
on_conflict: str = "raise",
|
|
94
|
-
merge_fns=None,
|
|
95
|
-
default_merge=None,
|
|
96
|
-
info: dict | None = None,
|
|
97
|
-
) -> "MergeResult":
|
|
98
|
-
"""Commit changes, then run GC if above high water mark."""
|
|
99
|
-
|
|
100
|
-
result = super().commit(
|
|
101
|
-
updates,
|
|
102
|
-
removals,
|
|
103
|
-
on_conflict=on_conflict,
|
|
104
|
-
merge_fns=merge_fns,
|
|
105
|
-
default_merge=default_merge,
|
|
106
|
-
info=info,
|
|
107
|
-
)
|
|
108
|
-
if result.merged:
|
|
109
|
-
rebase_result = self.maybe_rebase()
|
|
110
|
-
self.last_rebase_result = rebase_result
|
|
111
|
-
return result
|
|
112
|
-
|
|
113
|
-
def maybe_rebase(self) -> RebaseResult:
|
|
114
|
-
"""Run rebase only if total size exceeds high water mark."""
|
|
115
|
-
total = self._load_total_size()
|
|
116
|
-
if total <= self.high_water:
|
|
117
|
-
return RebaseResult(
|
|
118
|
-
performed=False,
|
|
119
|
-
new_commit=None,
|
|
120
|
-
dropped_keys=(),
|
|
121
|
-
kept_keys=tuple(self._commit_keys.keys()),
|
|
122
|
-
total_size_before=total,
|
|
123
|
-
total_size_after=total,
|
|
124
|
-
)
|
|
125
|
-
return self.rebase()
|
|
126
|
-
|
|
127
|
-
def rebase(
|
|
128
|
-
self,
|
|
129
|
-
keep_keys: set[str] | None = None,
|
|
130
|
-
*,
|
|
131
|
-
info: dict | None = None,
|
|
132
|
-
) -> RebaseResult:
|
|
133
|
-
"""Rebase: create a fresh root commit, dropping cold keys."""
|
|
134
|
-
meta = self._meta
|
|
135
|
-
total_before = self._load_total_size(
|
|
136
|
-
default=sum(e.size or 0 for e in meta.values())
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
# Identify protected and user keys
|
|
140
|
-
protected_keys = {
|
|
141
|
-
k: v for k, v in self._commit_keys.items() if self._is_protected(k)
|
|
142
|
-
}
|
|
143
|
-
user_meta = {k: v for k, v in meta.items() if not self._is_protected(k)}
|
|
144
|
-
|
|
145
|
-
retained_keys = set(protected_keys.keys()) | set(user_meta.keys())
|
|
146
|
-
total = sum(e.size or 0 for e in user_meta.values())
|
|
147
|
-
dropped: list[str] = []
|
|
148
|
-
|
|
149
|
-
if keep_keys is not None:
|
|
150
|
-
# Explicit keep set — drop everything not in it (except protected keys)
|
|
151
|
-
for key in list(retained_keys):
|
|
152
|
-
if self._is_protected(key):
|
|
153
|
-
continue
|
|
154
|
-
if key not in keep_keys:
|
|
155
|
-
retained_keys.discard(key)
|
|
156
|
-
dropped.append(key)
|
|
157
|
-
total -= (user_meta.get(key) and user_meta[key].size) or 0
|
|
158
|
-
else:
|
|
159
|
-
# High/low water strategy: drop coldest until under low water
|
|
160
|
-
candidates: list[tuple[str, MetaEntry]] = sorted(
|
|
161
|
-
user_meta.items(),
|
|
162
|
-
key=lambda kv: (kv[1].last_touch, -(kv[1].size or 0)),
|
|
163
|
-
)
|
|
164
|
-
for key, entry in candidates:
|
|
165
|
-
if total <= self.low_water:
|
|
166
|
-
break
|
|
167
|
-
retained_keys.discard(key)
|
|
168
|
-
dropped.append(key)
|
|
169
|
-
total -= entry.size or 0
|
|
170
|
-
|
|
171
|
-
# Build new commit with retained keys
|
|
172
|
-
|
|
173
|
-
# Collect retained data
|
|
174
|
-
new_commit_keys: dict[str, str] = {}
|
|
175
|
-
new_meta: dict[str, MetaEntry] = {}
|
|
176
|
-
retained_data: dict[str, bytes] = {}
|
|
177
|
-
|
|
178
|
-
for key in retained_keys:
|
|
179
|
-
versioned_key = self._commit_keys.get(key)
|
|
180
|
-
if not versioned_key:
|
|
181
|
-
continue
|
|
182
|
-
value = self.store.get(versioned_key)
|
|
183
|
-
if value is None:
|
|
184
|
-
continue
|
|
185
|
-
if key in meta:
|
|
186
|
-
new_meta[key] = meta[key]
|
|
187
|
-
if not self._is_protected(key):
|
|
188
|
-
retained_data[key] = value
|
|
189
|
-
|
|
190
|
-
# Content-addressable hash for the rebase commit (parent=None, fresh root)
|
|
191
|
-
preview_keys: dict[str, str] = {}
|
|
192
|
-
for key in protected_keys:
|
|
193
|
-
preview_keys[key] = protected_keys[key]
|
|
194
|
-
for key in retained_data:
|
|
195
|
-
preview_keys[key] = f"<pending:{key}>"
|
|
196
|
-
new_hash = content_hash((), preview_keys, retained_data, info=info)
|
|
197
|
-
|
|
198
|
-
# Build the write batch
|
|
199
|
-
diffs: dict[str, bytes] = {}
|
|
200
|
-
|
|
201
|
-
# Protected keys — copy blobs with new versioned keys
|
|
202
|
-
for key, old_vk in protected_keys.items():
|
|
203
|
-
value = self.store.get(old_vk)
|
|
204
|
-
if value is None:
|
|
205
|
-
continue
|
|
206
|
-
new_vk = f"{new_hash}:{key}"
|
|
207
|
-
new_commit_keys[key] = new_vk
|
|
208
|
-
diffs[new_vk] = value
|
|
209
|
-
|
|
210
|
-
# Retained user keys
|
|
211
|
-
for key, value in retained_data.items():
|
|
212
|
-
new_vk = f"{new_hash}:{key}"
|
|
213
|
-
new_commit_keys[key] = new_vk
|
|
214
|
-
diffs[new_vk] = value
|
|
215
|
-
|
|
216
|
-
# Commit metadata
|
|
217
|
-
diffs[COMMIT_KEYSET % new_hash] = to_bytes(new_commit_keys)
|
|
218
|
-
diffs[PARENT_COMMIT % new_hash] = to_bytes([])
|
|
219
|
-
diffs[META_KEY % new_hash] = meta_to_bytes(new_meta)
|
|
220
|
-
total_after = sum(e.size or 0 for e in new_meta.values())
|
|
221
|
-
diffs[TOTAL_VAR_SIZE_KEY % new_hash] = to_bytes(total_after)
|
|
222
|
-
if info is not None:
|
|
223
|
-
diffs[INFO_KEY % new_hash] = to_bytes(info)
|
|
224
|
-
|
|
225
|
-
self.store.set_many(**diffs)
|
|
226
|
-
|
|
227
|
-
# CAS HEAD to the new rebase commit
|
|
228
|
-
branch_key = BRANCH_HEAD % self._branch
|
|
229
|
-
expected = to_bytes(self._base_commit)
|
|
230
|
-
if not self.store.cas(branch_key, to_bytes(new_hash), expected=expected):
|
|
231
|
-
raise ConcurrencyError("HEAD changed during rebase.")
|
|
232
|
-
|
|
233
|
-
# Delete dropped blobs
|
|
234
|
-
to_delete = []
|
|
235
|
-
for key in dropped:
|
|
236
|
-
vk = self._commit_keys.get(key)
|
|
237
|
-
if vk:
|
|
238
|
-
to_delete.append(vk)
|
|
239
|
-
if to_delete:
|
|
240
|
-
self.store.remove_many(*to_delete)
|
|
241
|
-
|
|
242
|
-
# Update in-memory state
|
|
243
|
-
self._commit_keys = new_commit_keys
|
|
244
|
-
self._current_commit = new_hash
|
|
245
|
-
self._base_commit = new_hash
|
|
246
|
-
self._meta = new_meta
|
|
247
|
-
|
|
248
|
-
# Clean orphaned commits
|
|
249
|
-
orphans_cleaned = self.clean_orphans()
|
|
250
|
-
|
|
251
|
-
return RebaseResult(
|
|
252
|
-
performed=True,
|
|
253
|
-
new_commit=new_hash,
|
|
254
|
-
dropped_keys=tuple(dropped),
|
|
255
|
-
kept_keys=tuple(retained_keys),
|
|
256
|
-
total_size_before=total_before,
|
|
257
|
-
total_size_after=total_after,
|
|
258
|
-
orphans_cleaned=orphans_cleaned,
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
def clean_orphans(self, min_age: float = 3600) -> int:
|
|
262
|
-
"""Remove orphaned commits unreachable from any branch HEAD.
|
|
263
|
-
|
|
264
|
-
Advisory: this is not atomic. Concurrent writers may create
|
|
265
|
-
commits between the mark and sweep phases. The ``min_age``
|
|
266
|
-
guard (default 1 hour) prevents recently created commits from
|
|
267
|
-
being falsely swept.
|
|
268
|
-
"""
|
|
269
|
-
# Mark phase: find all reachable commits across ALL branches
|
|
270
|
-
reachable: set[str] = set()
|
|
271
|
-
prefix = BRANCH_HEAD.replace("%s", "")
|
|
272
|
-
for key in self.store.keys():
|
|
273
|
-
if isinstance(key, str) and key.startswith(prefix):
|
|
274
|
-
head_bytes = self.store.get(key)
|
|
275
|
-
if head_bytes is None:
|
|
276
|
-
continue
|
|
277
|
-
branch_head = from_bytes(head_bytes)
|
|
278
|
-
for commit in self.history(commit_hash=branch_head, all_parents=True):
|
|
279
|
-
reachable.add(commit)
|
|
280
|
-
|
|
281
|
-
# Sweep phase: find orphaned commits by scanning for meta keys
|
|
282
|
-
meta_prefix = META_KEY.replace("%s", "")
|
|
283
|
-
cutoff_time = time.time() - min_age
|
|
284
|
-
orphans: list[str] = []
|
|
285
|
-
|
|
286
|
-
for key in self.store.keys():
|
|
287
|
-
if not isinstance(key, str) or not key.startswith(meta_prefix):
|
|
288
|
-
continue
|
|
289
|
-
commit_hash = key[len(meta_prefix) :]
|
|
290
|
-
if not commit_hash or commit_hash in reachable:
|
|
291
|
-
continue
|
|
292
|
-
# Check age
|
|
293
|
-
meta_bytes = self.store.get(key)
|
|
294
|
-
if meta_bytes is None:
|
|
295
|
-
continue
|
|
296
|
-
try:
|
|
297
|
-
meta = meta_from_bytes(meta_bytes)
|
|
298
|
-
if meta:
|
|
299
|
-
first_entry = next(iter(meta.values()), None)
|
|
300
|
-
if first_entry and first_entry.created_at < cutoff_time:
|
|
301
|
-
orphans.append(commit_hash)
|
|
302
|
-
except (json.JSONDecodeError, TypeError, KeyError):
|
|
303
|
-
continue
|
|
304
|
-
|
|
305
|
-
# Delete orphaned commits and their data
|
|
306
|
-
for orphan_hash in orphans:
|
|
307
|
-
keyset_bytes = self.store.get(COMMIT_KEYSET % orphan_hash)
|
|
308
|
-
if keyset_bytes:
|
|
309
|
-
try:
|
|
310
|
-
keyset = from_bytes(keyset_bytes)
|
|
311
|
-
blob_keys = list(keyset.values())
|
|
312
|
-
if blob_keys:
|
|
313
|
-
self.store.remove_many(*blob_keys)
|
|
314
|
-
except Exception:
|
|
315
|
-
pass
|
|
316
|
-
self.store.remove_many(
|
|
317
|
-
META_KEY % orphan_hash,
|
|
318
|
-
COMMIT_KEYSET % orphan_hash,
|
|
319
|
-
PARENT_COMMIT % orphan_hash,
|
|
320
|
-
TOTAL_VAR_SIZE_KEY % orphan_hash,
|
|
321
|
-
INFO_KEY % orphan_hash,
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
return len(orphans)
|
|
325
|
-
|
|
326
|
-
def _load_total_size(self, default: int = 0) -> int:
|
|
327
|
-
"""Load the total variable size for the current commit."""
|
|
328
|
-
total_bytes = self.store.get(TOTAL_VAR_SIZE_KEY % self._current_commit)
|
|
329
|
-
if total_bytes is None:
|
|
330
|
-
return default
|
|
331
|
-
try:
|
|
332
|
-
return from_bytes(total_bytes)
|
|
333
|
-
except Exception:
|
|
334
|
-
return default
|
|
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
|