feat(protocol): destination-state itemize for dirs/symlinks; --stats deleted breakdown

This commit is contained in:
2026-09-23 21:52:40 +02:00
parent cb2979fdf1
commit 29f8be161c
20 changed files with 761 additions and 99 deletions
@@ -17,6 +17,7 @@ Run locally::
python3 -m pytest tests/integration/test_differential_parity.py -n 4 --dist=load -m parity
"""
import os
import re
import shutil
import sys
import warnings
@@ -666,6 +667,157 @@ def test_added_and_deleted_between_runs(parity_server_factory):
_run_and_check(case_id, result)
def _seed_dest_tree(src, root):
"""Copy `src`'s tree into `root` (the transfer mirror), preserving symlinks
and directory mtimes, so a second differential run starts from an existing
destination exactly like a seeded rsync run."""
os.makedirs(root, exist_ok=True)
for dirpath, dirnames, filenames in os.walk(src):
rel = os.path.relpath(dirpath, src)
for name in dirnames:
s = os.path.join(dirpath, name)
d = os.path.join(root, rel, name) if rel != "." else os.path.join(root, name)
if os.path.islink(s):
continue
os.makedirs(d, exist_ok=True)
for name in filenames:
s = os.path.join(dirpath, name)
d = os.path.join(root, rel, name) if rel != "." else os.path.join(root, name)
os.makedirs(os.path.dirname(d), exist_ok=True)
if os.path.islink(s):
if os.path.lexists(d):
os.remove(d)
os.symlink(os.readlink(s), d)
else:
shutil.copy2(s, d)
if rel != ".":
os.utime(os.path.join(root, rel), None)
os.utime(root, None)
# A full rsync itemize code (11 columns) followed by the name. H._ITEMIZE_RE
# only matches created (`+`) entries, so the changed-attribute codes this test
# asserts need their own matcher.
_ITEMIZE_LINE_RE = re.compile(r"^[<>ch.*][fdLDS].{9} ")
def _itemize_dir_link_lines(text):
"""The itemize lines for directory and symlink entries, excluding the
transfer-root `./` line (FastSync emits it unconditionally; a documented
residual)."""
out = []
for line in (text or "").splitlines():
line = line.rstrip()
if not line or not _ITEMIZE_LINE_RE.match(line):
continue
name = line.rsplit(" ", 1)[-1]
if name == "./":
continue
if name.endswith("/") or " -> " in line:
out.append(line)
return sorted(out)
@requires_rsync
@parity
def test_itemize_rerun_dirs_symlinks_matches_rsync(parity_server_factory):
"""#314: a re-run reports directory/symlink destination state like rsync.
On an unchanged tree FastSync emits no per-directory `cd+++++++++` (or
symlink) lines, and after a changed directory mtime / symlink target it
renders rsync's `.d..t......` / `cLc........` instead of `cd`/`cL`."""
case_id = "itemize_rerun_dirs_symlinks"
src = os.path.join(TEST_DATA_DIR, "parity_itemds_src")
rdst = os.path.join(TEST_DATA_DIR, "parity_itemds_rdst")
fdst = os.path.join(TEST_DATA_DIR, "parity_itemds_fdst")
clean_dir(src)
_mk(os.path.join(src, "sub", "b.txt"), b"nested\n")
os.makedirs(os.path.join(src, "emptydir"), exist_ok=True)
os.symlink("a.txt", os.path.join(src, "link"))
_mk(os.path.join(src, "a.txt"), b"top\n")
clean_dir(rdst)
clean_dir(fdst)
server = parity_server_factory(SUPER)
rroot = rdst
froot = get_dest_received_dir(fdst, src)
_seed_dest_tree(src, rroot)
_seed_dest_tree(src, froot)
# Unchanged re-run: no directory or symlink itemize lines from either tool.
rs = H.run_rsync(src, rdst, ["-a", "-i"])
fs, _ = H.run_fastsync(src, fdst, ["-a", "-i", "--incremental"], server.port)
assert rs.returncode == 0, rs.stderr
assert fs.returncode == 0, fs.stderr
assert _itemize_dir_link_lines(rs.stdout) == []
fast_unchanged = _itemize_dir_link_lines(fs.stdout)
assert fast_unchanged == [], f"unchanged re-run itemized dirs/links: {fast_unchanged}"
# Change the directory mtime and the symlink target, then re-run.
_pin(os.path.join(src, "sub"), _OLD_MTIME)
os.remove(os.path.join(src, "link"))
os.symlink("b.txt", os.path.join(src, "link"))
rs = H.run_rsync(src, rdst, ["-a", "-i"])
fs, _ = H.run_fastsync(src, fdst, ["-a", "-i", "--incremental"], server.port)
assert rs.returncode == 0, rs.stderr
assert fs.returncode == 0, fs.stderr
expected = _itemize_dir_link_lines(rs.stdout)
actual = _itemize_dir_link_lines(fs.stdout)
assert actual == expected, f"rsync={rs.stdout!r} fastsync={fs.stdout!r}"
assert any(line.endswith(" sub/") and line.startswith(".d..t") for line in actual), actual
assert any(line.startswith("cLc") and " -> b.txt" in line for line in actual), actual
def _deleted_breakdown_line(text):
for line in (text or "").splitlines():
if line.startswith("Number of deleted files:"):
return " ".join(line.split())
return ""
@requires_rsync
@parity
def test_stats_deleted_breakdown_matches_rsync(parity_server_factory):
"""#316: `--stats` renders rsync's per-type `Number of deleted files`
breakdown for removed regular files, directories, symlinks and a special."""
case_id = "stats_deleted_breakdown"
src = os.path.join(TEST_DATA_DIR, "parity_delbd_src")
rdst = os.path.join(TEST_DATA_DIR, "parity_delbd_rdst")
fdst = os.path.join(TEST_DATA_DIR, "parity_delbd_fdst")
clean_dir(src)
_mk(os.path.join(src, "keep.txt"), b"keep\n")
server = parity_server_factory(DELETE)
def seed(_src, rroot, froot):
for root in (rroot, froot):
_mk(os.path.join(root, "extra1.txt"), b"e1\n", _OLD_MTIME)
_mk(os.path.join(root, "extradir", "inside.txt"), b"e2\n", _OLD_MTIME)
os.makedirs(os.path.join(root, "extradir"), exist_ok=True)
link = os.path.join(root, "extralink")
if not os.path.lexists(link):
os.symlink("keep.txt", link)
fifo = os.path.join(root, "extrafifo")
if not os.path.exists(fifo):
os.mkfifo(fifo)
def extra(_src, _rroot, _froot, rs, fs):
rs_line = _deleted_breakdown_line(rs.stdout)
fs_line = _deleted_breakdown_line(fs.stdout)
if not rs_line:
return ["rsync printed no deleted-files line"]
if rs_line != fs_line:
return [f"deleted breakdown rsync={rs_line!r} fastsync={fs_line!r}"]
if "reg:" not in rs_line or "dir:" not in rs_line or \
"link:" not in rs_line or "special:" not in rs_line:
return [f"breakdown missing a category: {rs_line!r}"]
return []
result = H.run_differential(
src, rdst, fdst, ["-a", "--delete", "--stats"],
["-a", "--delete", "--stats", "--incremental"], server,
seed=seed, extra_check=extra)
_run_and_check(case_id, result, ref="--stats deleted per-type breakdown")
@requires_rsync
@parity
def test_one_file_system(parity_server_factory):
+10
View File
@@ -64,6 +64,7 @@ static void test_dest_state_roundtrip() {
memset(&out, 0, sizeof(out));
out.known = true;
out.existed = true;
out.target_matches = true;
out.size = 123456789ULL;
out.mtime_sec = 1700000000;
out.mtime_nsec = 123456789;
@@ -76,6 +77,7 @@ static void test_dest_state_roundtrip() {
EXPECT_TRUE(format_dest_state_receive(fds[1], &in));
EXPECT_TRUE(in.known);
EXPECT_TRUE(in.existed);
EXPECT_TRUE(in.target_matches);
EXPECT_TRUE(in.size == out.size);
EXPECT_TRUE(in.mtime_sec == out.mtime_sec);
EXPECT_TRUE(in.mtime_nsec == out.mtime_nsec);
@@ -103,6 +105,10 @@ static void test_stats_roundtrip() {
out.created_dir = 4;
out.created_link = 2;
out.created_special = 1;
out.deleted_reg = 9;
out.deleted_dir = 6;
out.deleted_link = 3;
out.deleted_special = 2;
ReceiverStats in;
memset(&in, 0, sizeof(in));
EXPECT_TRUE(format_stats_send(fds[0], &out));
@@ -115,6 +121,10 @@ static void test_stats_roundtrip() {
EXPECT_TRUE(in.created_dir == out.created_dir);
EXPECT_TRUE(in.created_link == out.created_link);
EXPECT_TRUE(in.created_special == out.created_special);
EXPECT_TRUE(in.deleted_reg == out.deleted_reg);
EXPECT_TRUE(in.deleted_dir == out.deleted_dir);
EXPECT_TRUE(in.deleted_link == out.deleted_link);
EXPECT_TRUE(in.deleted_special == out.deleted_special);
close(fds[0]);
close(fds[1]);
}
+2 -1
View File
@@ -153,7 +153,8 @@ static void test_receiver_deleted_paths_gated_by_report_deletes() {
PipelineContextReceiver* ctx_info = pipeline_context_receiver_create(info, q_info, -1, NULL);
EXPECT_NOT_NULL(ctx_info);
EXPECT_NOT_NULL(ctx_info->deleted_paths);
receiver_record_deleted_path(ctx_info->deleted_paths, "d/old_extra");
ReceiverDeleteContext delctx = {NULL, ctx_info->deleted_paths};
receiver_record_deleted_path(&delctx, "d/old_extra", DELETE_ENTRY_REG);
EXPECT_EQ_INT(ctx_info->deleted_paths->size, 1);
EXPECT_EQ_STR((const char*)ctx_info->deleted_paths->items[0], "d/old_extra");
pipeline_context_receiver_destroy(ctx_info);