Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions src/borg/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def build_rest_backend(location):
class PackWriter:
"""Buffers chunks into a pack file and writes to the store when full.

Collects (chunk_id, cdata) pairs in a list and flushes once max_count is
Collects (chunk_id, cdata) pairs in a list and flushes once a limit is
reached. PackWriter maintains the ChunkIndex directly: each add() marks the
chunk as pending (pack_id=UNKNOWN_BYTES32); flush() then assigns the real
pack_id, offset and size to every pending entry once the pack is on disk.
Expand All @@ -116,18 +116,23 @@ class PackWriter:
uses that repository's single, authoritative index (see the chunks property), so
there is never a second copy to keep in sync. Unit tests pass an explicit index.

max_count bounds how many chunks a pack accumulates before flush() writes it.
Raising it produces larger packs without changing this class's interface.
max_count bounds how many chunks a pack holds; max_size bounds its byte size.
flush() fires when either limit is reached. Each limit is disabled by setting it
to None; at least one must be set.
"""

def __init__(self, store, *, max_count=1, chunks=None, repository=None):
def __init__(self, store, *, max_count=3, max_size=None, chunks=None, repository=None):
if repository is None and chunks is None:
raise ValueError("PackWriter requires either a repository or an explicit chunks index")
if max_count is None and max_size is None:
raise ValueError("PackWriter requires at least one of max_count or max_size")
Comment thread
mr-raj12 marked this conversation as resolved.
Outdated
self.store = store
self.max_count = max_count
self.max_count = max_count # None = no count limit
self.max_size = max_size # None = no size limit
self.repository = repository # when set, the one and only index lives there
self._chunks = chunks # explicit index for repository-less use (tests)
self._pieces = [] # list of (chunk_id, cdata)
self._size = 0 # byte size of buffered pieces

@property
def chunks(self):
Expand All @@ -142,17 +147,14 @@ def chunks(self):

def add(self, chunk_id, cdata):
"""Buffer a chunk. Returns flush results if the pack is now full, else None."""
# Mark the chunk as pending (pack_id=UNKNOWN_BYTES32). flush() assigns the real
# pack_id and offset for every piece, so the placeholder offset 0 here is never read:
# get() refuses a pending entry (PackLocationUnknown) before any offset would matter.
# Precondition: callers add only chunks not already stored (the cache dedups via
# seen_chunk() first), so add(chunk_id, 0) never resets a real size on an existing entry.
# This is also what keeps ChunkIndex.add's "v.size == 0 or v.size == size" assertion happy:
# a fresh id has no entry, so the size=0 we pass here is never compared against a real size.
self.chunks.add(chunk_id, 0) # size filled in by cache layer
# Mark the chunk as pending; flush() fills in the pack_id, offset and size.
Comment thread
mr-raj12 marked this conversation as resolved.
Outdated
self.chunks.add(chunk_id, 0)
self.chunks.update_pack_info([(chunk_id, UNKNOWN_BYTES32, 0, len(cdata))])
self._pieces.append((chunk_id, cdata))
if len(self._pieces) >= self.max_count:
self._size += len(cdata)
if (self.max_count is not None and len(self._pieces) >= self.max_count) or (
self.max_size is not None and self._size >= self.max_size
):
return self.flush()
return None

Expand Down Expand Up @@ -201,6 +203,7 @@ def flush(self):
raise
finally:
self._pieces = [] # reset even on failure to prevent re-bundling a failed chunk
self._size = 0
self.chunks.update_pack_info(results) # replace UNKNOWN_BYTES32 with real pack_id
return results

Expand Down Expand Up @@ -507,7 +510,7 @@ def open(self, *, exclusive, lock_wait=None, lock=True):
if lock:
self.lock = Lock(self.store, exclusive, timeout=lock_wait).acquire()
self._chunks = None
self._pack_writer = PackWriter(self.store, max_count=2, repository=self)
self._pack_writer = PackWriter(self.store, repository=self)
self.opened = True

@property
Expand Down
30 changes: 30 additions & 0 deletions src/borg/testsuite/repository_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ def test_list(repo_fixtures, request):
with get_repository_from_fixture(repo_fixtures, request) as repository:
for x in range(100):
repository.put(H(x), fchunk(b"SOMEDATA", chunk_id=H(x))) # unique bytes -> unique pack id
repository.flush() # flush the last partial pack so all 100 objects are listable
repo_list = repository.list()
assert len(repo_list) == 100
first_half = repository.list(limit=50)
Expand Down Expand Up @@ -345,6 +346,35 @@ def test_pack_writer_n2_flush():
assert results[1] == (id2, expected_pack_id, len(data1), len(data2))


def test_pack_writer_flushes_on_max_size():
# max_count is high, so the flush is driven by max_size alone.
store = MockStore()
pw = PackWriter(store, max_count=100, max_size=10, chunks=ChunkIndex())
assert pw.add(b"a" * 32, b"12345") is None
results = pw.add(b"b" * 32, b"67890")
assert results is not None
assert len(results) == 2


def test_pack_writer_max_size_none_is_count_only():
store = MockStore()
pw = PackWriter(store, max_count=2, max_size=None, chunks=ChunkIndex())
assert pw.add(b"a" * 32, b"x" * 10_000) is None
assert pw.add(b"b" * 32, b"y" * 10_000) is not None


def test_pack_writer_max_count_none_is_size_only():
store = MockStore()
pw = PackWriter(store, max_count=None, max_size=10, chunks=ChunkIndex())
assert pw.add(b"a" * 32, b"12345") is None
assert pw.add(b"b" * 32, b"67890") is not None


def test_pack_writer_requires_a_limit():
with pytest.raises(ValueError):
PackWriter(MockStore(), max_count=None, max_size=None, chunks=ChunkIndex())


def test_pack_writer_rolls_back_index_on_failed_store():
# If store.store() fails, flush() must drop the entries add() pre-marked, otherwise the index
# keeps a phantom (indexed but never stored) chunk that seen_chunk() reports as present and a
Expand Down
Loading