ACE ODBC reports lock contention ("无法更新;当前被锁定。(-1102)")
as a generic pyodbc.Error (HY000), NOT pyodbc.OperationalError, so the
old except clause never caught it and gave up after the first collision
— making cleanup_lock_retries:3 a no-op for the 氩弧焊/高写入库 -1102 case.
Now catch pyodbc.Error and retry only on lock messages ("被锁定"/"-1102"),
re-raising other errors immediately instead of retrying them 3×.
- 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.
Add to_jsonable() (Access/pyodbc value -> JSON-safe) with strict TDD
(5 tests: datetime iso, bool preserved before int, decimal->str,
none/numbers, dict round-trip). bool branch precedes int because bool
is a subclass of int in Python.
Add AccessReader: reads TableChangeLog (read_log), reads a full source
row by RecordID via to_jsonable (read_row, warns on >4000-char values
that JSON_VALUE would truncate), and deletes processed log IDs in
chunked, retried batches (delete_log_ids, no-op on empty list).
Connects via the ACE ODBC driver from config.access.driver, shared
autocommit mode to coexist with the live .laccdb client.
Integration test against the real 氩弧焊.accdb (read-only; delete_log_ids
only exercised with []) passes.
Co-Authored-By: Claude <noreply@anthropic.com>