Skip to content

Commit 63bcdee

Browse files
committed
add test for backup roundtrip
1 parent f49bf50 commit 63bcdee

3 files changed

Lines changed: 224 additions & 1 deletion

File tree

prime_backup/types/chunk_method.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class ChunkMethod(enum.Enum):
2424
if TYPE_CHECKING:
2525
value: ChunkerDefinition
2626

27+
def ensure_lib(self):
28+
self.value.ensure_lib()
29+
2730
@classmethod
2831
def get_for_file(cls, file_path: PathLike, file_size: int) -> Optional['ChunkMethod']:
2932
"""

prime_backup/types/chunker_definition.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55

66
from typing_extensions import override
77

8-
from prime_backup.types.chunker import Chunker, FastCDCFileChunker, FastCDCStreamChunker, FixedSizeFileChunker, FixedSizeStreamChunker, FastCDCChunkerConfig, FixedAutoFileChunker, PrettyChunk
8+
from prime_backup.types.chunker import Chunker, FastCDCFileChunker, FastCDCStreamChunker, FixedSizeFileChunker, FixedSizeStreamChunker, FastCDCChunkerConfig, FixedAutoFileChunker, PrettyChunk, _get_fastcdc_class
99

1010

1111
class ChunkerDefinition(ABC):
12+
def ensure_lib(self):
13+
pass
14+
1215
@abstractmethod
1316
def create_file_chunker(self, file_path: Path, need_entire_file_hash: bool, *, previous_chunks: Optional[Iterable[PrettyChunk]] = None) -> Chunker:
1417
...
@@ -31,6 +34,10 @@ class FastCDCChunkerDefinition(ChunkerDefinition):
3134
def __post_init__(self):
3235
object.__setattr__(self, '_config', FastCDCChunkerConfig(self.avg_size, self.min_size, self.max_size))
3336

37+
@override
38+
def ensure_lib(self):
39+
_get_fastcdc_class()
40+
3441
@override
3542
def create_file_chunker(self, file_path: Path, need_entire_file_hash: bool, *, previous_chunks: Optional[Iterable[PrettyChunk]] = None) -> Chunker:
3643
return FastCDCFileChunker(self._config, file_path, need_entire_file_hash)

tests/test_backup_roundtrip.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
from pathlib import Path
2+
from typing import Dict, Generator, List, Tuple
3+
4+
import pytest
5+
6+
from prime_backup.action.create_backup_action import CreateBackupAction
7+
from prime_backup.action.export_backup_action_directory import ExportBackupToDirectoryAction
8+
from prime_backup.action.export_backup_action_tar import ExportBackupToTarAction
9+
from prime_backup.action.export_backup_action_zip import ExportBackupToZipAction
10+
from prime_backup.action.import_backup_action import ImportBackupAction
11+
from prime_backup.action.validate_chunks_action import ValidateChunksAction
12+
from prime_backup.action.validate_packs_action import ValidatePacksAction
13+
from prime_backup.compressors import CompressMethod
14+
from prime_backup.config.backup_config import ChunkingRule
15+
from prime_backup.config.config import Config, set_config_instance
16+
from prime_backup.db.access import DbAccess
17+
from prime_backup.db.values import BlobStorageMethod
18+
from prime_backup.types.chunk_method import ChunkMethod
19+
from prime_backup.types.hash_method import HashMethod
20+
from prime_backup.types.operator import Operator
21+
from prime_backup.types.standalone_backup_format import StandaloneBackupFormat
22+
from prime_backup.types.tar_format import TarFormat
23+
24+
25+
def _param_id(value: object) -> str:
26+
if hasattr(value, 'name'):
27+
return str(getattr(value, 'name'))
28+
return 'c{}'.format(value)
29+
30+
31+
@pytest.fixture(autouse=True)
32+
def _restore_config_and_db() -> Generator[None, None, None]:
33+
old_config = Config.get()
34+
try:
35+
yield
36+
finally:
37+
if DbAccess.is_initialized():
38+
DbAccess.shutdown()
39+
set_config_instance(old_config)
40+
41+
42+
def _assert_pack_and_chunk_validate_ok() -> None:
43+
assert ValidatePacksAction().run().bad == 0
44+
assert ValidateChunksAction().run().bad == 0
45+
46+
47+
def _make_chunked_data() -> bytes:
48+
return (
49+
b'a' * (4 * 1024) +
50+
b'b' * (4 * 1024) +
51+
b'c' * (4 * 1024) +
52+
b'd' * (4 * 1024) +
53+
b'tail'
54+
)
55+
56+
57+
def _populate_world(world_path: Path) -> Tuple[Dict[str, bytes], List[str]]:
58+
files = {
59+
'chunked.dat': _make_chunked_data(),
60+
'small.txt': b'hello roundtrip matrix',
61+
'empty.txt': b'',
62+
'config/settings.json': b'{"enabled":true,"level":3}\n',
63+
'logs/2026-06-06.log': b'line 1\nline 2\n',
64+
'nested/deeper/payload.bin': bytes(range(32)),
65+
'region/r.0.0.mca': b'mca-like small payload',
66+
}
67+
dirs = [
68+
'config',
69+
'logs',
70+
'nested',
71+
'nested/deeper',
72+
'region',
73+
'empty_dir',
74+
'empty_dir/child',
75+
]
76+
for rel_dir in dirs:
77+
(world_path / rel_dir).mkdir(parents=True, exist_ok=True)
78+
for rel_path, content in files.items():
79+
path = world_path / rel_path
80+
path.parent.mkdir(parents=True, exist_ok=True)
81+
path.write_bytes(content)
82+
return files, dirs
83+
84+
85+
def __assert_restored_tree(restored_world_path: Path, expected_files: Dict[str, bytes], expected_dirs: List[str]) -> None:
86+
restored_files: Dict[str, bytes] = {
87+
path.relative_to(restored_world_path).as_posix(): path.read_bytes()
88+
for path in restored_world_path.rglob('*')
89+
if path.is_file()
90+
}
91+
restored_dirs: List[str] = [
92+
path.relative_to(restored_world_path).as_posix()
93+
for path in restored_world_path.rglob('*')
94+
if path.is_dir()
95+
]
96+
assert restored_files == expected_files
97+
assert sorted(restored_dirs) == sorted(expected_dirs)
98+
99+
100+
def __skip_if_dependencies_unavailable(
101+
hash_method: HashMethod,
102+
compress_method: CompressMethod,
103+
chunk_method: ChunkMethod,
104+
export_format: StandaloneBackupFormat,
105+
) -> None:
106+
try:
107+
hash_method.value.ensure_lib()
108+
except ImportError as e:
109+
pytest.skip('hash dependency unavailable for {}: {}'.format(hash_method.name, e))
110+
111+
try:
112+
compress_method.value.ensure_lib()
113+
except ImportError as e:
114+
pytest.skip('compress dependency unavailable for {}: {}'.format(compress_method.name, e))
115+
116+
if export_format == StandaloneBackupFormat.tar_zst:
117+
try:
118+
CompressMethod.zstd.value.ensure_lib()
119+
except ImportError as e:
120+
pytest.skip('export dependency unavailable for {}: {}'.format(export_format.name, e))
121+
122+
try:
123+
chunk_method.ensure_lib()
124+
except ImportError as e:
125+
pytest.skip('chunking dependency unavailable for {}: {}'.format(chunk_method.name, e))
126+
127+
128+
def _get_export_extension(export_format: StandaloneBackupFormat) -> str:
129+
if isinstance(export_format.value, TarFormat):
130+
return export_format.value.value.extension
131+
return export_format.value.extension
132+
133+
134+
def _make_config(
135+
storage_root: Path,
136+
server_path: Path,
137+
hash_method: HashMethod,
138+
compress_method: CompressMethod,
139+
chunk_method: ChunkMethod,
140+
concurrency: int,
141+
) -> Config:
142+
config = Config.get_default()
143+
config.concurrency = concurrency
144+
config.storage_root = str(storage_root)
145+
config.backup.source_root = str(server_path)
146+
config.backup.targets = ['world']
147+
config.backup.hash_method = hash_method
148+
config.backup.compress_method = compress_method
149+
config.backup.compress_threshold = 0
150+
config.backup.chunking_enabled = True
151+
config.backup.chunking_rules = [
152+
ChunkingRule(algorithm=chunk_method, file_size_threshold=1, patterns=['**/*.dat']),
153+
]
154+
return config
155+
156+
157+
def __export_backup(backup_id: int, export_path: Path, export_format: StandaloneBackupFormat) -> None:
158+
if isinstance(export_format.value, TarFormat):
159+
ExportBackupToTarAction(backup_id, export_path, export_format.value, create_meta=True).run()
160+
else:
161+
ExportBackupToZipAction(backup_id, export_path, create_meta=True).run()
162+
163+
164+
@pytest.mark.parametrize('hash_method', tuple(HashMethod), ids=_param_id)
165+
@pytest.mark.parametrize('compress_method', tuple(CompressMethod), ids=_param_id)
166+
@pytest.mark.parametrize('chunk_method', tuple(ChunkMethod), ids=_param_id)
167+
@pytest.mark.parametrize('export_format', tuple(StandaloneBackupFormat), ids=_param_id)
168+
@pytest.mark.parametrize('concurrency', (1, 2), ids=_param_id)
169+
def test_backup_roundtrip(
170+
tmp_path: Path,
171+
hash_method: HashMethod,
172+
compress_method: CompressMethod,
173+
chunk_method: ChunkMethod,
174+
export_format: StandaloneBackupFormat,
175+
concurrency: int,
176+
) -> None:
177+
__skip_if_dependencies_unavailable(hash_method, compress_method, chunk_method, export_format)
178+
179+
source_pb_path = tmp_path / 'source_pb'
180+
imported_pb_path = tmp_path / 'imported_pb'
181+
server_path = tmp_path / 'server'
182+
world_path = server_path / 'world'
183+
restored_path = tmp_path / 'restored'
184+
export_path = tmp_path / ('backup' + _get_export_extension(export_format))
185+
186+
world_path.mkdir(parents=True)
187+
expected_files, expected_dirs = _populate_world(world_path)
188+
189+
set_config_instance(_make_config(source_pb_path, server_path, hash_method, compress_method, chunk_method, concurrency))
190+
DbAccess.init(create=True, migrate=False)
191+
try:
192+
backup = CreateBackupAction(Operator.literal('test'), '').run()
193+
_assert_pack_and_chunk_validate_ok()
194+
with DbAccess.open_session() as session:
195+
assert len(session.list_blobs_by_storage_method(BlobStorageMethod.chunked)) > 0
196+
197+
__export_backup(backup.id, export_path, export_format)
198+
assert export_path.is_file()
199+
assert export_path.stat().st_size > 0
200+
finally:
201+
DbAccess.shutdown()
202+
203+
set_config_instance(_make_config(imported_pb_path, server_path, hash_method, compress_method, chunk_method, concurrency))
204+
DbAccess.init(create=True, migrate=False)
205+
try:
206+
imported_backup = ImportBackupAction(export_path, export_format, ensure_meta=True).run()
207+
_assert_pack_and_chunk_validate_ok()
208+
209+
failures = ExportBackupToDirectoryAction(imported_backup.id, restored_path, restore_mode=True).run()
210+
assert len(failures) == 0
211+
__assert_restored_tree(restored_path / 'world', expected_files, expected_dirs)
212+
finally:
213+
DbAccess.shutdown()

0 commit comments

Comments
 (0)