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:
67
tests/test_cleanup_accounting.py
Normal file
67
tests/test_cleanup_accounting.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user