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

@@ -87,15 +87,18 @@ class AccessReader:
def delete_log_ids(
self, ids: list[int], batch_size: int, retries: int
) -> None:
) -> int:
"""Delete the given log-row IDs in chunks, retrying on lock contention.
A no-op when ``ids`` is empty (never raises). Retries with linear
backoff because the live client may briefly hold a page lock on
``TableChangeLog``.
Returns the total number of rows actually deleted (sum of per-chunk
``cursor.rowcount``), so the caller can report honest counts. A no-op
(returns 0) when ``ids`` is empty. Retries with linear backoff because
the live client may briefly hold a page lock on ``TableChangeLog``.
Raises on final lock failure.
"""
if not ids:
return
return 0
total = 0
cur = self._connect().cursor()
for i in range(0, len(ids), batch_size):
chunk = ids[i:i + batch_size]
@@ -106,12 +109,14 @@ class AccessReader:
f"DELETE FROM TableChangeLog WHERE ID IN ({placeholders})",
*chunk,
)
total += cur.rowcount
break
except pyodbc.OperationalError:
if attempt < retries - 1:
time.sleep(0.2 * (attempt + 1))
else:
raise
return total
def close(self):
if self._conn: