Commit Graph

15 Commits

Author SHA1 Message Date
Misaka_Company
e9f012eab5 feat(sync): add data consistency compare (count + ID-set) 2026-07-15 14:03:21 +08:00
Misaka_Company
5ca0715801 ♻️ refactor(sync): extract shared target-table resolution 2026-07-15 14:03:19 +08:00
Misaka_Company
706b9c33db 📝 docs: expand README with architecture, full-sync guide, and FAQ
- Add architecture section describing Capture/Apply/Cleanup pipeline
- Document service vs one-shot full-sync commands
- Add full-sync usage examples (--db/--table/--clear-change-log)
- Add FAQ for -1102 lock contention and pyodbc/pydantic ABI mismatch
2026-07-15 12:14:08 +08:00
Misaka_Company
9c3cc38a44 fix(sync): retry cleanup on Access -1102 lock contention
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×.
2026-07-15 09:42:17 +08:00
Misaka_Company
a227aed0cb feat(sync): add one-shot full sync (TRUNCATE + bulk INSERT)
- New `sync.fullsync` CLI: `python -m sync.fullsync config.yaml [--db F] [--table T] [--clear-change-log]`
- AccessReader: list_user_tables / read_all_rows / read_all_log_ids
- SqlWriter: table_exists / truncate_target (TRUNCATE w/ DELETE fallback) / bulk_insert (SET IDENTITY_INSERT + chunked fast_executemany)
- Reuses FileMapping exclude/include rules (exclude beats include, same as capture)
- Preserves Access IDs via IDENTITY_INSERT; target schemas have no FKs so TRUNCATE is safe
- TableChangeLog NOT cleared by default (opt-in --clear-change-log)
- tests/test_fullsync.py covers resolve_tables exclude/include precedence
2026-07-14 18:06:39 +08:00
Misaka_Company
ea48de290f 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.
2026-07-14 16:32:47 +08:00
Misaka_Company
b1b118463d fix(sql): prevent stale Delete from outranking newer Insert in usp_SyncApply
Route both upsert and delete branches off a single ranked CTE (rn=1 per RecordID over all pending ops ordered by SourceLogID DESC). The previous design used two independent ranked CTEs, which let a stale Delete outrank a newer Insert for the same RecordID and silently drop the row. Also gitignore .claude/ and .workbuddy/ runtime dirs.
2026-07-14 15:32:03 +08:00
Misaka_Company
f85d0a71cc chore: ignore config.pilot.yaml 2026-07-14 12:54:17 +08:00
Misaka_Company
3a3d3d0292 feat: cleanup phase and main service loop with error isolation
Wires capture -> apply -> cleanup into cycle(cfg): per-file capture and
cleanup each wrapped in try/except + log.exception so one file's failure
does not abort the cycle; apply failure does not block cleanup; writer is
always closed in finally. run(cfg) loops cycle with sleep; main() loads
config from argv. logging_setup uses RotatingFileHandler 10MBx5 + console.

Unit tests cover all three error-isolation branches via mocks (no real
end-to-end smoke; integration deferred to Task 9 pilot).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 12:39:22 +08:00
Misaka_Company
2ae913b51a feat: capture orchestration with include/exclude and delete-downgrade
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 12:34:10 +08:00
Misaka_Company
399534e7af feat: sql writer with dedup insert and apply call
Adds SqlWriter: a pyodbc-backed writer that dedup-inserts into
dbo.SyncQueue (IF NOT EXISTS guarded by UX_SyncQueue_Dedup), invokes
dbo.usp_SyncApply, and reports applied SourceLogIDs.

Connection is opened with autocommit=True per the controller revision:
usp_SyncApply manages its own transaction internally (BEGIN/ROLLBACK),
and an outer pyodbc transaction would conflict on ROLLBACK (SQL error
266). The dedup IF NOT EXISTS...INSERT is a single atomic statement.

Integration test self-cleans via SourceFile='sqlw_test.accdb' marker;
conn_str comes from the gitignored config.yaml (no hardcoded creds).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 12:29:39 +08:00
Misaka_Company
1f7191421a feat: access reader and value serialization
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>
2026-07-14 12:22:48 +08:00
Misaka_Company
65939f5e85 feat: SyncQueue table and set-based apply stored procedure
- sql/01_sync_queue.sql: idempotent DDL for dbo.SyncQueue (PK + unique
  dedup index + pending lookup index), safe to re-run.
- sql/02_sync_apply.sql: dbo.usp_SyncApply (@MaxRetries INT=5). Per
  distinct (TargetSchema,TargetTable) it builds column projections from
  sys.columns (excludes ID key/computed/identity/rowversion) and runs a
  dynamic-SQL MERGE (last-write-wins via ROW_NUMBER over SourceLogID DESC)
  for Insert/Update plus a DELETE for the last op = Delete.
  SET IDENTITY_INSERT ON preserves Access PKs.
- tests/conftest.py: sql_conn fixture reads conn_str from gitignored
  config.yaml via load_config; skipped without RUN_INTEGRATION=1.
- tests/test_apply_proc.py: integration test covering IDENTITY-preserving
  INSERT, last-write-wins UPDATE, BIT conversion, and DELETE; cleans up.

Deviation from the brief's procedure (root-cause fix, design preserved):
every JSON path key is quoted ('$."col"') so non-ASCII column names
(e.g. Chinese 名字/数量) parse correctly. Without quoting, JSON_VALUE
raises "JSON path format is not correct" on Chinese columns, which is the
real target schema for this Access->SQL Server sync.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 12:12:21 +08:00
Misaka_Company
7c6cb10b91 feat: config model and yaml loader
Add Pydantic config models (SqlServerConfig, AccessConfig, RuntimeConfig,
FileMapping, SyncConfig) and a YAML loader (load_config). FileMapping provides
source_path() and target_table() helpers; number-typed YAML keys/values (e.g.
root: 2026) are coerced to str via coerce_numbers_to_str. Includes
config.example.yaml template (config.yaml with real credentials stays
gitignored) and pyproject.toml pytest config (pythonpath=src).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 11:53:20 +08:00
Misaka_Company
fb025bb061 chore: project scaffold for access-datamacro sync 2026-07-14 11:12:34 +08:00