fix(parity): dry-run delete protections, delete-delay actual-removal budget, --info name2
- -n/--delete sends the same protected/size-skipped/scope as a real run, so the read-only would-delete walk no longer over-reports (row -> caveat) - --delete-delay charges --max-delete on actual removals and recursively re-scans a refilled deferred directory at commit; independent deferred cap - --info=name emits the leading ./ root line and name2 'is uptodate' lines - differential tests promoted from residual pins to rsync parity assertions
This commit is contained in:
@@ -1,19 +1,16 @@
|
||||
"""Differential coverage for ``--delete-delay`` + ``--max-delete`` with a
|
||||
refilled deferred directory.
|
||||
|
||||
FastSync snapshots a directory's extras at plan time (``defer_add``) and charges
|
||||
``--max-delete`` then, and its deferred commit only removes the snapshot path, so
|
||||
a directory refilled before the commit survives ``ENOTEMPTY``. rsync computes
|
||||
the deferred deletions during the transfer, charges ``--max-delete`` on actual
|
||||
removals, and recursively removes a queued directory -- so content created after
|
||||
the plan inside an extra directory is removed too.
|
||||
FastSync snapshots a directory's extras at plan time (``defer_add``) but charges
|
||||
``--max-delete`` only when a path is actually removed, and its deferred commit
|
||||
re-scans a queued directory and removes content created after the plan -- the
|
||||
same rules as rsync. These tests run both tools on the same fixture and assert
|
||||
both sides remove the late content (recursively) and bound the deletion with
|
||||
``--max-delete`` identically.
|
||||
|
||||
These tests run both tools on the same fixture and pin the shared budget bound
|
||||
(the later extra survives, both exit 25) plus the documented residual (the
|
||||
refilled directory's late content survives under FastSync, not rsync). They are
|
||||
not part of the fast PR gate because the rsync side needs a wide real-time
|
||||
injection window (a throttled transfer), while the FastSync side uses the
|
||||
existing byte-deterministic slicing proxy.
|
||||
They are not part of the fast PR gate because the rsync side needs a wide
|
||||
real-time injection window (a throttled transfer), while the FastSync side uses
|
||||
the existing byte-deterministic slicing proxy.
|
||||
|
||||
The refilled directory sits at the transfer ROOT, whose delete plan is always
|
||||
processed before any subdirectory's, so the budget is deterministically charged
|
||||
@@ -101,7 +98,7 @@ def _rsync(args, timeout=120):
|
||||
|
||||
|
||||
class TestDeleteDelayRefilledDirVsRsync:
|
||||
"""The shared budget bound and the documented recursive-removal residual."""
|
||||
"""Both tools charge --max-delete on actual removals and recurse."""
|
||||
|
||||
def _fastsync_refilled(self, tag, max_delete=None):
|
||||
"""Run FastSync with the refill injected deterministically by the proxy
|
||||
@@ -125,18 +122,18 @@ class TestDeleteDelayRefilledDirVsRsync:
|
||||
return result, received, late
|
||||
|
||||
@requires_rsync
|
||||
def test_max_delete_budget_bound_matches_and_residual_pinned(self):
|
||||
# --- FastSync: budget charged at snapshot; late content preserved ---
|
||||
def test_max_delete_budget_bound_matches(self):
|
||||
# --- FastSync: the one actual removal is the late file; dirs survive ---
|
||||
result, received, late = self._fastsync_refilled("budget_fs", max_delete=1)
|
||||
assert result.returncode == 25, (result.stderr or result.stdout)[:300]
|
||||
assert _deleted_count(result.stdout) == 0, result.stdout
|
||||
assert os.path.exists(late), "FastSync removed the late content of a snapshotted dir"
|
||||
assert _deleted_count(result.stdout) == 1, result.stdout
|
||||
assert not os.path.exists(late), "FastSync kept the late content of a queued dir"
|
||||
assert os.path.isdir(os.path.join(received, "xdir"))
|
||||
assert os.path.isdir(os.path.join(received, "b", "ydir")), (
|
||||
"FastSync did not charge the plan-time budget: b/ydir was removed"
|
||||
"FastSync did not bound the deletion with --max-delete=1"
|
||||
)
|
||||
|
||||
# --- rsync: budget charged on actual removals; dirs removed recursively ---
|
||||
# --- rsync: same budget rule and recursive removal ---
|
||||
source, rsync_dst = _seed_rsync("budget_rsync")
|
||||
|
||||
def inject():
|
||||
@@ -151,26 +148,23 @@ class TestDeleteDelayRefilledDirVsRsync:
|
||||
)
|
||||
t.join()
|
||||
assert rsync_result.returncode == 25, rsync_result.stderr
|
||||
# rsync removes the late content (recursive deferred removal); FastSync
|
||||
# keeps it and charges the snapshot directive instead.
|
||||
assert _deleted_count(rsync_result.stdout) == _deleted_count(result.stdout)
|
||||
assert not os.path.exists(os.path.join(rsync_dst, "xdir", "new.txt")), (
|
||||
"rsync kept late content inside a queued directory"
|
||||
)
|
||||
# The shared observable: the later extra survives in both tools under
|
||||
# --max-delete=1, and both report the capped run with exit 25.
|
||||
assert os.path.isdir(os.path.join(rsync_dst, "b", "ydir")), (
|
||||
"rsync did not bound the deletion with --max-delete=1"
|
||||
)
|
||||
assert os.path.isdir(os.path.join(received, "b", "ydir"))
|
||||
|
||||
@requires_rsync
|
||||
def test_refilled_extra_dir_recursive_removal_residual(self):
|
||||
"""Without --max-delete the residual is a plain tree difference: rsync
|
||||
removes the refilled extra directory (and its late content), FastSync
|
||||
leaves the snapshot path in place on ENOTEMPTY."""
|
||||
def test_refilled_extra_dir_recursive_removal_matches(self):
|
||||
"""Without --max-delete both tools remove the refilled extra directory
|
||||
(and its late content)."""
|
||||
result, received, late = self._fastsync_refilled("recur_fs")
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
assert os.path.exists(late), "FastSync removed the refilled directory's late content"
|
||||
assert not os.path.exists(late), "FastSync kept the refilled directory's late content"
|
||||
assert not os.path.isdir(os.path.join(received, "xdir"))
|
||||
|
||||
source, rsync_dst = _seed_rsync("recur_rsync")
|
||||
|
||||
|
||||
@@ -320,10 +320,10 @@ class TestDeleteTimingFailure:
|
||||
class TestDeleteDelayDeletedCount:
|
||||
"""The reported deleted count must reflect entries actually removed."""
|
||||
|
||||
def test_refilled_deferred_dir_is_not_counted(self):
|
||||
def test_refilled_deferred_dir_is_recursively_removed_and_counted(self):
|
||||
"""A directory snapshotted into a --delete-delay plan that is refilled
|
||||
before the commit survives ENOTEMPTY and must NOT inflate "Number of
|
||||
deleted files" (regression for delete_plan.c counting at snapshot)."""
|
||||
before the commit is re-scanned and removed recursively (rsync parity):
|
||||
the late file and the directory are both counted as deleted."""
|
||||
source = os.path.join(TEST_DATA_DIR, "ddc_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "ddc_dst")
|
||||
clean_dir(source)
|
||||
@@ -347,12 +347,13 @@ class TestDeleteDelayDeletedCount:
|
||||
proxy.finish()
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
||||
assert proxy.hook_called.is_set(), "hook never fired"
|
||||
assert os.path.exists(os.path.join(extra_dir, "new.txt")), "late file vanished"
|
||||
assert not os.path.exists(os.path.join(extra_dir, "new.txt")), "late file survived"
|
||||
assert not os.path.isdir(extra_dir), "refilled extra dir survived"
|
||||
deleted = None
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("Number of deleted files:"):
|
||||
deleted = int(line.split(":", 1)[1].split()[0])
|
||||
assert deleted == 0, (deleted, result.stdout)
|
||||
assert deleted == 2, (deleted, result.stdout)
|
||||
|
||||
|
||||
class TestDeleteDelayMaxDeleteParity:
|
||||
@@ -468,10 +469,9 @@ class TestDeleteAfterThreadsKeepSet:
|
||||
)
|
||||
|
||||
class TestDeleteDelayMaxDeleteRefilledDir:
|
||||
"""--delete-delay charges the --max-delete budget at plan/snapshot time, so a
|
||||
refilled snapshotted directory that survives ENOTEMPTY still spends its slot
|
||||
and a later extra is skipped, while the reported count stays at actual
|
||||
removals.
|
||||
"""--delete-delay charges the --max-delete budget on ACTUAL removals: the
|
||||
refilled directory's late content is removed first (consuming the one slot),
|
||||
so the directory itself and a later extra are skipped, matching rsync.
|
||||
|
||||
The refilled directory is at the destination ROOT (its plan is always sent
|
||||
first) and the skipped extra is under a separate source directory, so the
|
||||
@@ -479,7 +479,7 @@ class TestDeleteDelayMaxDeleteRefilledDir:
|
||||
order. The refill is injected through the byte-barrier proxy so it is
|
||||
causally after the plan frame."""
|
||||
|
||||
def test_budget_charged_at_plan_time(self):
|
||||
def test_budget_charged_on_actual_removal(self):
|
||||
source = os.path.join(TEST_DATA_DIR, "ddmb_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "ddmb_dst")
|
||||
clean_dir(source)
|
||||
@@ -504,9 +504,9 @@ class TestDeleteDelayMaxDeleteRefilledDir:
|
||||
proxy.finish()
|
||||
assert result.returncode == 25, (result.stderr or result.stdout)[:400]
|
||||
assert proxy.hook_called.is_set(), "hook never fired"
|
||||
# The refilled directory still consumes the plan-time budget, so the
|
||||
# later extra is skipped...
|
||||
assert os.path.exists(os.path.join(refilled_dir, "new.txt")), "late file vanished"
|
||||
assert os.path.isdir(later_dir), "later extra was not skipped by the plan-time budget"
|
||||
# ...while the reported count reflects only actual removals (none here).
|
||||
assert _deleted_count(result.stdout) == 0, result.stdout
|
||||
# The late content consumes the single budget slot; the refilled
|
||||
# directory itself and the later extra are skipped.
|
||||
assert not os.path.exists(os.path.join(refilled_dir, "new.txt")), "late file survived"
|
||||
assert os.path.isdir(later_dir), "later extra was not skipped by the budget"
|
||||
# The one actual removal is reported.
|
||||
assert _deleted_count(result.stdout) == 1, result.stdout
|
||||
|
||||
@@ -584,53 +584,53 @@ class TestRemoteDryRun:
|
||||
assert _snapshot_tree(received) == before, f"{flags} mutated the destination"
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed")
|
||||
def test_dry_run_delete_lines_over_report_residual(self):
|
||||
"""Documented residual (RSYNC_COMPAT.md `-n/--dry-run` row): FastSync's
|
||||
dry-run would-delete report includes the file that is merely being
|
||||
updated (derived from the receiver's STATUS_STATS extras) and, unlike
|
||||
rsync, also reports an excluded-but-protected extra. rsync `-n -i
|
||||
--delete` lists only genuine extras. Pins the residual that keeps the
|
||||
row Divergent."""
|
||||
def test_dry_run_delete_lines_match_rsync(self):
|
||||
"""`-n --delete` lists exactly the destination extras rsync would remove.
|
||||
|
||||
Covers the three cases that a real run protects: the file being updated
|
||||
(in the keep set), a filter-excluded source entry (protected prefix), and
|
||||
a --max-size-pruned source entry (always-protected prefix). Only the
|
||||
genuine destination-only extras may appear. Residual: a destination-only
|
||||
entry matching an exclude pattern is still removed (FastSync derives
|
||||
delete protection from the source scan, not a receiver filter engine);
|
||||
that divergence is pinned by TestOptionParity.
|
||||
"""
|
||||
source = os.path.join(TEST_DATA_DIR, "dryrep_src")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "dryrep_rdst")
|
||||
fdst = os.path.join(TEST_DATA_DIR, "dryrep_fdst")
|
||||
clean_dir(source)
|
||||
clean_dir(rdst)
|
||||
clean_dir(fdst)
|
||||
with open(os.path.join(source, "a.txt"), "wb") as fh:
|
||||
fh.write(b"new content\n")
|
||||
for name, data in (("a.txt", b"new content\n"), ("keep.log", b"log\n"),
|
||||
("big.bin", b"B" * 2000)):
|
||||
with open(os.path.join(source, name), "wb") as fh:
|
||||
fh.write(data)
|
||||
os.utime(os.path.join(source, "a.txt"), (1_700_000_000, 1_700_000_000))
|
||||
for root in (rdst, fdst):
|
||||
with open(os.path.join(root, "a.txt"), "wb") as fh:
|
||||
fh.write(b"old\n")
|
||||
for name, data in (("extra.log", b"log\n"), ("extra.txt", b"extra\n")):
|
||||
received = get_dest_received_dir(fdst, source)
|
||||
os.makedirs(received, exist_ok=True)
|
||||
for root in (rdst, received):
|
||||
for name, data in (("a.txt", b"old\n"), ("keep.log", b"log\n"),
|
||||
("big.bin", b"B" * 2000), ("extra.txt", b"extra\n")):
|
||||
with open(os.path.join(root, name), "wb") as fh:
|
||||
fh.write(data)
|
||||
for p in (os.path.join(root, "a.txt"), os.path.join(root, "extra.log"),
|
||||
os.path.join(root, "extra.txt")):
|
||||
os.utime(p, (1_500_000_000, 1_500_000_000))
|
||||
os.utime(os.path.join(root, name), (1_500_000_000, 1_500_000_000))
|
||||
|
||||
flags = ["-a", "-n", "-i", "--delete", "--exclude=*.log", "--max-size=1000"]
|
||||
r = subprocess.run(["rsync", "-an", "-i", "--delete", "--exclude=*.log",
|
||||
source + "/", rdst + "/"],
|
||||
"--max-size=1000", source + "/", rdst + "/"],
|
||||
capture_output=True, text=True,
|
||||
env=dict(os.environ, LC_ALL="C"))
|
||||
assert r.returncode == 0, r.stderr
|
||||
rsync_del = {l.split(None, 1)[1] for l in r.stdout.splitlines()
|
||||
if l.startswith("*deleting")}
|
||||
assert rsync_del == {"extra.txt"}, f"unexpected rsync deleting set: {rsync_del}"
|
||||
rsync_del = sorted(l for l in r.stdout.splitlines() if l.startswith("*deleting"))
|
||||
assert rsync_del == ["*deleting extra.txt"], f"unexpected rsync set: {rsync_del}"
|
||||
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, fdst,
|
||||
flags=["-a", "-n", "-i", "--delete", "--exclude=*.log"],
|
||||
port=server.port)
|
||||
result, _ = run_client(source, fdst, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
||||
fs_del = {l.split(None, 1)[1] for l in (result.stdout or "").splitlines()
|
||||
if l.startswith("*deleting")}
|
||||
# Documented over-report: the transferred/updated file and the excluded
|
||||
# extra appear in FastSync's would-delete set.
|
||||
assert "a.txt" in fs_del, "residual changed: FastSync no longer over-reports the update"
|
||||
assert "extra.log" in fs_del, "residual changed: FastSync no longer reports excluded extra"
|
||||
fs_del = sorted(l for l in (result.stdout or "").splitlines()
|
||||
if l.startswith("*deleting"))
|
||||
assert fs_del == rsync_del, f"rsync={rsync_del}\nfastsync={fs_del}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_remote_dry_run_quiet_is_silent(self, shared_server):
|
||||
|
||||
@@ -164,6 +164,64 @@ class TestInfoParity:
|
||||
f"rsync={entries(rsync_result.stdout)} fastsync={entries(result.stdout)}"
|
||||
)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_name_root_line_matches_rsync(self, shared_server):
|
||||
"""A fresh destination: rsync prints `created directory`, then the
|
||||
transfer-root `./` name line before the entries; FastSync must emit the
|
||||
same `./` line."""
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_root_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_root_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_root_rdst")
|
||||
clean_dir(source)
|
||||
_write(os.path.join(source, "f.bin"), b"payload\n")
|
||||
clean_dir(dest)
|
||||
shutil.rmtree(rdst, ignore_errors=True)
|
||||
rsync_result = _rsync(["-a", "--info=name", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
result, _ = run_client(source, dest, flags=["-a", "--info=name"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
|
||||
def names(text):
|
||||
return [l for l in text.splitlines()
|
||||
if l and not l.startswith("created directory")
|
||||
and not (l.endswith("/") and l != "./")]
|
||||
|
||||
assert names(rsync_result.stdout) == ["./", "f.bin"], names(rsync_result.stdout)
|
||||
assert names(result.stdout) == ["./", "f.bin"], names(result.stdout)
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_name2_uptodate_matches_rsync(self, shared_server):
|
||||
"""--info=name2 prints `NAME is uptodate` for entries the receiver
|
||||
already has, matching rsync byte-for-byte."""
|
||||
source = os.path.join(TEST_DATA_DIR, "inf_up_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "inf_up_dst")
|
||||
rdst = os.path.join(TEST_DATA_DIR, "inf_up_rdst")
|
||||
clean_dir(source)
|
||||
os.makedirs(os.path.join(source, "sub"))
|
||||
_write(os.path.join(source, "a.txt"), b"a\n")
|
||||
_write(os.path.join(source, "sub", "b.txt"), b"b\n")
|
||||
clean_dir(rdst)
|
||||
assert _rsync(["-a", source + "/", rdst + "/"]).returncode == 0
|
||||
rsync_result = _rsync(["-a", "--info=name2", source + "/", rdst + "/"])
|
||||
assert rsync_result.returncode == 0, rsync_result.stderr
|
||||
|
||||
clean_dir(dest)
|
||||
seed, _ = run_client(source, dest, flags=["-a", "--incremental"],
|
||||
port=shared_server.port)
|
||||
assert seed.returncode == 0, (seed.stderr or seed.stdout)[:200]
|
||||
result, _ = run_client(source, dest, flags=["-a", "--incremental", "--info=name2"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, (result.stderr or result.stdout)[:200]
|
||||
rsync_lines = sorted(l for l in rsync_result.stdout.splitlines()
|
||||
if l.endswith("is uptodate"))
|
||||
fast_lines = sorted(l for l in result.stdout.splitlines()
|
||||
if l.endswith("is uptodate"))
|
||||
assert fast_lines == rsync_lines, (rsync_lines, fast_lines)
|
||||
assert fast_lines == ["a.txt is uptodate", "sub/b.txt is uptodate"], fast_lines
|
||||
|
||||
@requires_rsync
|
||||
@pytest.mark.ci
|
||||
def test_info_nonreg_matches_rsync(self, shared_server):
|
||||
|
||||
@@ -1393,6 +1393,29 @@ static void test_parse_args_debug_info_levels() {
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_STATS);
|
||||
config_delete(cfg);
|
||||
|
||||
/* --info=name level 2 enables the "is uptodate" marker; a later level-1 or
|
||||
level-0 token clears it again. */
|
||||
cfg = config_create();
|
||||
char* name2_argv[] = {"fastsync", "--info=name2", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, name2_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_NAME | LOG_INFO_NAME_UPTODATE);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* name1_argv[] = {"fastsync", "--info=name2,name1", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, name1_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, LOG_INFO_NAME);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* name0_argv[] = {"fastsync", "--info=name2,name0", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, name0_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->info_level, 0);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* bad_argv[] = {"fastsync", "--debug=123", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
|
||||
+28
-29
@@ -23,10 +23,9 @@ static void send_plan_frame(int fd, const char* dir) {
|
||||
}
|
||||
|
||||
/* --delete-delay: a directory snapshotted into the plan that is refilled before
|
||||
* the commit must NOT be counted as deleted once its unlink fails ENOTEMPTY.
|
||||
* Regression for delete_plan.c counting at snapshot (defer_add) instead of at
|
||||
* the actual removal. */
|
||||
static void test_delete_delay_refilled_dir_not_counted(void) {
|
||||
* the commit is re-scanned and removed recursively (rsync parity). Regression
|
||||
* for the old single-unlink ENOTEMPTY path that left the directory behind. */
|
||||
static void test_delete_delay_refilled_dir_removed_recursively(void) {
|
||||
char root[] = "/tmp/fastsync_dp_refill_XXXXXX";
|
||||
EXPECT_TRUE(mkdtemp(root) != NULL);
|
||||
char extra[1024];
|
||||
@@ -57,16 +56,14 @@ static void test_delete_delay_refilled_dir_not_counted(void) {
|
||||
close(fd);
|
||||
|
||||
EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_OK);
|
||||
/* ENOTEMPTY: the directory survives, so it must not be reported as deleted. */
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
/* The late content and the directory itself are both removed. */
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 2);
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(lstat(extra, &st), 0);
|
||||
EXPECT_TRUE(lstat(extra, &st) != 0);
|
||||
|
||||
delete_plan_session_destroy(session);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
unlink(refill);
|
||||
rmdir(extra);
|
||||
rmdir(root);
|
||||
config_delete(config);
|
||||
}
|
||||
@@ -106,8 +103,9 @@ static void test_delete_delay_removed_file_counted(void) {
|
||||
config_delete(config);
|
||||
}
|
||||
|
||||
/* --max-delete still bounds the deferred plan; the actual (removed) count must
|
||||
* not exceed the limit even though more extras existed. */
|
||||
/* --max-delete is charged on actual removals, not at plan/snapshot time: after
|
||||
* receiving the plans the budget is untouched, and only the commit removes up to
|
||||
* the limit. */
|
||||
static void test_delete_delay_max_delete_bounds_actual(void) {
|
||||
char root[] = "/tmp/fastsync_dp_max_XXXXXX";
|
||||
EXPECT_TRUE(mkdtemp(root) != NULL);
|
||||
@@ -133,8 +131,11 @@ static void test_delete_delay_max_delete_bounds_actual(void) {
|
||||
EXPECT_NOT_NULL(session);
|
||||
send_plan_frame(p[1], ".");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
EXPECT_TRUE(delete_plan_session_limit_reached(session));
|
||||
/* Nothing removed yet, so the budget is not consumed at snapshot time. */
|
||||
EXPECT_FALSE(delete_plan_session_limit_reached(session));
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_LIMIT_REACHED);
|
||||
EXPECT_TRUE(delete_plan_session_limit_reached(session));
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1);
|
||||
|
||||
delete_plan_session_destroy(session);
|
||||
@@ -149,13 +150,12 @@ static void test_delete_delay_max_delete_bounds_actual(void) {
|
||||
config_delete(config);
|
||||
}
|
||||
|
||||
/* --max-delete is charged at plan/snapshot time, not at actual removal: a
|
||||
* deferred entry that survives ENOTEMPTY still consumes its budget slot, so a
|
||||
* later directory's extra is skipped even though nothing was actually removed.
|
||||
* The reported count stays 0 (actual removals) while the run is partial. The
|
||||
* two plans are sent as separate frames for "a" then "b", so the ordering that
|
||||
* decides which entry gets the budget is deterministic (unlike readdir order). */
|
||||
static void test_delete_delay_refilled_dir_charges_budget_at_plan(void) {
|
||||
/* --max-delete is charged on ACTUAL removals: the refilled directory's late
|
||||
* content is removed first (consuming the single budget slot), so the directory
|
||||
* itself and the later extra are skipped, matching rsync. The two plans are
|
||||
* sent as separate frames for "a" then "b", so the ordering that decides which
|
||||
* entry gets the budget is deterministic (unlike readdir order). */
|
||||
static void test_delete_delay_actual_removal_charges_budget(void) {
|
||||
char root[] = "/tmp/fastsync_dp_planbudget_XXXXXX";
|
||||
EXPECT_TRUE(mkdtemp(root) != NULL);
|
||||
char adir[1024], bdir[1024], xdir[1024], ydir[1024];
|
||||
@@ -180,18 +180,16 @@ static void test_delete_delay_refilled_dir_charges_budget_at_plan(void) {
|
||||
|
||||
DeletePlanSession* session = delete_plan_session_create(config);
|
||||
EXPECT_NOT_NULL(session);
|
||||
/* Plan "a" first: its empty extra dir snapshots and charges the budget. */
|
||||
/* Both plan snapshots are taken; neither consumes budget yet. */
|
||||
send_plan_frame(p[1], "a");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
EXPECT_FALSE(delete_plan_session_limit_reached(session));
|
||||
/* Plan "b": the budget is already spent at snapshot time, so b/y is skipped
|
||||
even though a/x has not (and will not) be removed. */
|
||||
send_plan_frame(p[1], "b");
|
||||
EXPECT_EQ_INT(delete_plan_session_receive(session, config, p[0]), 0);
|
||||
EXPECT_TRUE(delete_plan_session_limit_reached(session));
|
||||
EXPECT_FALSE(delete_plan_session_limit_reached(session));
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
|
||||
/* Refill a/x so its deferred rmdir fails ENOTEMPTY. */
|
||||
/* Refill a/x after its plan: the recursive commit must remove this content. */
|
||||
char refill[1200];
|
||||
snprintf(refill, sizeof(refill), "%s/new.txt", xdir);
|
||||
int fd = open(refill, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
@@ -199,16 +197,17 @@ static void test_delete_delay_refilled_dir_charges_budget_at_plan(void) {
|
||||
close(fd);
|
||||
|
||||
EXPECT_EQ_INT(delete_plan_session_commit(session, config), DELETE_COMMIT_LIMIT_REACHED);
|
||||
/* Nothing was actually removed, and the plan-time budget still stopped b/y. */
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 0);
|
||||
/* The one budget slot removed the late content; the two directories survive. */
|
||||
EXPECT_EQ_INT((int)delete_plan_session_deleted(session), 1);
|
||||
EXPECT_TRUE(delete_plan_session_limit_reached(session));
|
||||
struct stat st;
|
||||
EXPECT_TRUE(lstat(refill, &st) != 0);
|
||||
EXPECT_EQ_INT(lstat(xdir, &st), 0);
|
||||
EXPECT_EQ_INT(lstat(ydir, &st), 0);
|
||||
|
||||
delete_plan_session_destroy(session);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
unlink(refill);
|
||||
rmdir(xdir);
|
||||
rmdir(ydir);
|
||||
rmdir(adir);
|
||||
@@ -218,8 +217,8 @@ static void test_delete_delay_refilled_dir_charges_budget_at_plan(void) {
|
||||
}
|
||||
|
||||
void test_delete_plan(void) {
|
||||
test_delete_delay_refilled_dir_not_counted();
|
||||
test_delete_delay_refilled_dir_removed_recursively();
|
||||
test_delete_delay_removed_file_counted();
|
||||
test_delete_delay_max_delete_bounds_actual();
|
||||
test_delete_delay_refilled_dir_charges_budget_at_plan();
|
||||
test_delete_delay_actual_removal_charges_budget();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user