repository: store more than one object per pack (#9808)
Some checks are pending
Lint / lint (push) Waiting to run
CI / lint (push) Waiting to run
CI / security (push) Waiting to run
CI / asan_ubsan (push) Blocked by required conditions
CI / native_tests (push) Blocked by required conditions
CI / vm_tests (NetBSD, false, netbsd, 10.1) (push) Blocked by required conditions
CI / vm_tests (OmniOS, false, omnios, r151056) (push) Blocked by required conditions
CI / vm_tests (OpenBSD, false, openbsd, 7.8) (push) Blocked by required conditions
CI / vm_tests (borg-freebsd-14-x86_64-gh, FreeBSD, true, freebsd, 14.3) (push) Blocked by required conditions
CI / windows_tests (push) Blocked by required conditions
CodeQL / Analyze (push) Waiting to run

* repository: store more than one object per pack

Raise PackWriter max_count to 2 and flush buffered packs before the archives pointer is written; also flush after debug put-obj and in the affected tests.

* archive: flush buffered packs in Archives.create() so no caller writes the pointer over buffered objects
This commit is contained in:
Mrityunjay Raj 2026-06-26 01:03:09 +05:30 committed by GitHub
parent e57e22d174
commit 62f2e8b05f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 49 additions and 3 deletions

View file

@ -281,6 +281,7 @@ class DebugMixIn:
raise CommandError(f"object id {hex_id} is invalid [{str(err)}].")
repository.put(id, data)
repository.flush() # no cache wraps this command, so flush the buffered pack before close()
print("object %s put." % hex_id)
@with_repository(manifest=False, exclusive=True)

View file

@ -329,6 +329,8 @@ class Archives:
if isinstance(ts, datetime):
ts = ts.isoformat(timespec="microseconds")
assert isinstance(ts, str)
# flush buffered packs first: the pointer must not reference objects still buffered at N>1.
self.repository.flush()
# we only create a directory entry, its name points to the archive item:
self.repository.store_store(f"archives/{bin_to_hex(id)}", b"")

View file

@ -507,7 +507,7 @@ class Repository:
if lock:
self.lock = Lock(self.store, exclusive, timeout=lock_wait).acquire()
self._chunks = None
self._pack_writer = PackWriter(self.store, max_count=1, repository=self)
self._pack_writer = PackWriter(self.store, max_count=2, repository=self)
self.opened = True
@property

View file

@ -308,6 +308,7 @@ def test_manifest_rebuild_corrupted_chunk(archivers, request):
# framing rather than the authenticated payload, which made the repair flaky on Windows.
corrupted_chunk = corrupt(chunk, len(chunk) // 2)
repository.put(archive.id, corrupted_chunk)
repository.flush() # N>1: make the put durable before close()/the check below
cmd(archiver, "check", exit_code=1)
output = cmd(archiver, "check", "-v", "--repair", exit_code=0)
assert "archive2" in output
@ -366,6 +367,7 @@ def test_spoofed_archive(archivers, request):
ro_type=ROBJ_FILE_STREAM, # a real archive is stored with ROBJ_ARCHIVE_META
),
)
repository.flush() # N>1: make the put durable before close()/the check below
cmd(archiver, "check", exit_code=1)
cmd(archiver, "check", "--repair", "--debug", exit_code=0)
output = cmd(archiver, "repo-list")
@ -384,6 +386,7 @@ def test_extra_chunks(archivers, request):
key = b"01234567890123456789012345678901"
chunk = fchunk(b"xxxx", chunk_id=key)
repository.put(key, chunk)
repository.flush() # N>1: make the put durable before close()/the check below
cmd(archiver, "check", "-v", exit_code=0) # check does not deal with orphans anymore

View file

@ -20,7 +20,9 @@ class TestAdHocWithFilesCache:
self.repository_location = os.path.join(str(tmpdir), "repository")
with Repository(self.repository_location, exclusive=True, create=True) as repository:
repository.put(H(1), b"1234")
repository.flush() # N>1: the lone put would stay buffered; make it durable
yield repository
repository.flush() # flush anything a test buffered via the cache before close()
@pytest.fixture
def key(self, repository, monkeypatch):

View file

@ -5,6 +5,7 @@ from hashlib import sha256
import pytest
from ..helpers import IntegrityError, Location, bin_to_hex
from ..hashindex import ChunkIndex
from ..constants import UNKNOWN_BYTES32
from ..repository import Repository, MAX_DATA_SIZE, rest_serve_command, PackWriter, PackReader
from ..repoobj import RepoObj, OBJ_MAGIC, OBJ_VERSION
from .hashindex_test import H
@ -124,6 +125,7 @@ def test_read_data(repo_fixtures, request):
chunk_complete = hdr + meta + data
chunk_short = hdr + meta
repository.put(H(0), chunk_complete)
repository.flush() # N>1: make the buffered pack durable before get()
assert repository.get(H(0)) == chunk_complete
assert repository.get(H(0), read_data=True) == chunk_complete
assert repository.get(H(0), read_data=False) == chunk_short
@ -132,16 +134,48 @@ def test_read_data(repo_fixtures, request):
def test_consistency(repo_fixtures, request):
with get_repository_from_fixture(repo_fixtures, request) as repository:
repository.put(H(0), fchunk(b"foo"))
repository.flush() # N>1: flush before reading the just-put chunk back
assert pdchunk(repository.get(H(0))) == b"foo"
repository.put(H(0), fchunk(b"foo2"))
repository.flush()
assert pdchunk(repository.get(H(0))) == b"foo2"
repository.put(H(0), fchunk(b"bar"))
repository.flush()
assert pdchunk(repository.get(H(0))) == b"bar"
# delete is a no-op for now (see Repository.delete): the latest put still wins.
repository.delete(H(0))
assert pdchunk(repository.get(H(0))) == b"bar"
def test_multi_object_pack_roundtrip(repo_fixtures, request):
# Two objects fill one pack and must both read back: the second from a non-zero offset, and
# read_data=False returning only its header+meta. The test pins max_count=2 so it does not depend
# on the Repository.open() default; the compact tests override max_count via build_one_pack() too.
meta0, data0 = b"meta0", b"the-first-object"
meta1, data1 = b"m1", b"second"
chunk0 = fchunk(data0, meta=meta0, chunk_id=H(0))
chunk1 = fchunk(data1, meta=meta1, chunk_id=H(1))
with get_repository_from_fixture(repo_fixtures, request) as repository:
repository._pack_writer.max_count = 2 # this test is written for exactly two objects per pack
repository.put(H(0), chunk0)
assert repository.chunks[H(0)].pack_id == UNKNOWN_BYTES32 # buffered: the pack is not full yet
repository.put(H(1), chunk1) # fills the pack, flushing both objects at once
# both objects share one pack, written exactly once, laid out in put() order
pack_id = repository.chunks[H(0)].pack_id
assert pack_id != UNKNOWN_BYTES32
assert repository.chunks[H(1)].pack_id == pack_id
assert [info.name for info in repository.store_list("packs")] == [bin_to_hex(pack_id)]
assert repository.chunks[H(0)].obj_offset == 0
assert repository.chunks[H(1)].obj_offset == len(chunk0) # second object read from a non-zero offset
# full reads return each object's exact bytes, the second one resolved from its non-zero offset
assert repository.get(H(0)) == chunk0
assert repository.get(H(1)) == chunk1
# read_data=False returns header+meta only and stays inside the requested object
hdr_size = RepoObj.obj_header.size
assert repository.get(H(0), read_data=False) == chunk0[: hdr_size + len(meta0)]
assert repository.get(H(1), read_data=False) == chunk1[: hdr_size + len(meta1)]
def build_one_pack(repository, objects):
with repository:
repository._pack_writer.max_count = len(objects) + 1 # prevent per-put flush; one pack on flush()
@ -226,6 +260,7 @@ def test_max_data_size(repo_fixtures, request):
with get_repository_from_fixture(repo_fixtures, request) as repository:
max_data = b"x" * (MAX_DATA_SIZE - RepoObj.obj_header.size)
repository.put(H(0), fchunk(max_data))
repository.flush() # N>1: make the buffered pack durable before get()
assert pdchunk(repository.get(H(0))) == max_data
with pytest.raises(IntegrityError):
repository.put(H(1), fchunk(max_data + b"x"))
@ -399,13 +434,16 @@ def test_get_uses_chunk_index_location(tmp_path):
def test_put_marks_id_in_chunk_index(tmp_path):
# put() immediately updates _chunks: add() marks the id as seen, then update_pack_info
# fills in the real pack location for the current session.
# At N>1, put() marks the id pending (pack_id=UNKNOWN_BYTES32); flush() then fills in the
# real pack location for the current session.
with Repository(str(tmp_path / "repo"), exclusive=True, create=True) as repository:
id1 = H(1)
repository.put(id1, fchunk(b"ZEROS"))
entry = repository._chunks.get(id1)
assert entry is not None
assert entry.pack_id == UNKNOWN_BYTES32 # buffered, not yet flushed
repository.flush()
entry = repository._chunks.get(id1)
assert entry.pack_id == sha256(fchunk(b"ZEROS")).digest()
assert entry.size == 0 # uncompressed size filled in by cache layer