fix(sync): track cleanup state to stop re-deleting log rows and bound SyncQueue

- access_reader.delete_log_ids returns the actual rows deleted (was None).

- sql_writer.mark_cleaned flips applied queue rows to 'cleaned' (sets CleanedAt) after their Access log rows are physically removed, so the same IDs are never deleted twice.

- sql_writer.purge_cleaned removes 'cleaned' rows older than a retention window (default 24h) so SyncQueue stops growing without bound.

- cleanup.cleanup_file marks rows cleaned after a successful delete and returns the real delete count, so the service log reports honest 'cleaned N' instead of a constant.

- service.cycle calls purge_cleaned once per pass; config adds cleaned_retention_hours (default 24).

- sql/01_sync_queue.sql adds CleanedAt column + IX_SyncQueue_Cleaned idempotently.

- tests: unit coverage for mark_cleaned/purge_cleaned/delete_log_ids return count; assert cycle purges each pass.
This commit is contained in:
Misaka_Company
2026-07-14 16:32:47 +08:00
parent b1b118463d
commit ea48de290f
8 changed files with 164 additions and 9 deletions

View File

@@ -0,0 +1,67 @@
"""Unit tests for the cleanup bookkeeping added to SqlWriter / AccessReader.
These mock pyodbc so no database is required. They pin the contract that
``mark_cleaned`` / ``purge_cleaned`` issue the right statements, and that
``delete_log_ids`` returns the actual number of rows deleted (not ``None``).
"""
from unittest.mock import MagicMock, patch
from sync.sql_writer import SqlWriter
from sync.access_reader import AccessReader
def _writer():
"""Build a SqlWriter whose pyodbc connection is a MagicMock."""
with patch("sync.sql_writer.pyodbc.connect") as mconnect:
conn = MagicMock()
mconnect.return_value = conn
w = SqlWriter("conn_str", "dbo.SyncQueue")
return w, conn
def test_mark_cleaned_updates_only_applied_rows_for_file():
w, conn = _writer()
cur = conn.cursor.return_value
w.mark_cleaned("a.accdb", [1, 2, 3])
assert cur.execute.call_count == 1
sql = cur.execute.call_args.args[0]
assert "UPDATE dbo.SyncQueue SET Status='cleaned'" in sql
assert "CleanedAt=sysdatetime()" in sql
assert "SourceFile=?" in sql
assert "Status='applied'" in sql
# first param is the file, the rest are the SourceLogIDs
assert cur.execute.call_args.args[1] == "a.accdb"
assert list(cur.execute.call_args.args[2:]) == [1, 2, 3]
def test_mark_cleaned_no_ids_is_noop():
w, conn = _writer()
cur = conn.cursor.return_value
w.mark_cleaned("a.accdb", [])
cur.execute.assert_not_called()
def test_purge_cleaned_deletes_old_cleaned_rows():
w, conn = _writer()
cur = conn.cursor.return_value
cur.rowcount = 42
n = w.purge_cleaned(24)
assert n == 42
sql = cur.execute.call_args.args[0]
assert "DELETE FROM dbo.SyncQueue" in sql
assert "Status='cleaned'" in sql
assert "CleanedAt < DATEADD(hour, -?, GETDATE())" in sql
assert cur.execute.call_args.args[1] == 24
def test_delete_log_ids_returns_rowcount_and_chunks():
r = AccessReader("x.accdb", "drv")
fake_conn = MagicMock()
fake_cur = MagicMock()
fake_cur.rowcount = 7 # per-chunk affected rows
fake_conn.cursor.return_value = fake_cur
with patch.object(r, "_connect", return_value=fake_conn):
# 5 ids, batch size 2 -> 3 chunks, 3 DELETEs, total 21 rows
n = r.delete_log_ids([1, 2, 3, 4, 5], 2, 3)
assert n == 21
assert fake_cur.execute.call_count == 3

View File

@@ -47,6 +47,7 @@ def test_cycle_continues_on_capture_error_and_still_applies_and_cleans():
assert mcapture.call_count == 2 # both files attempted (bad swallowed)
writer_inst.call_apply.assert_called_once() # apply still called
assert mcleanup.call_count == 2 # cleanup attempted for both files
writer_inst.purge_cleaned.assert_called_once() # cleanup bookkeeping
writer_inst.close.assert_called() # writer closed in finally