Files
yunke/yunke-server/app/sync/__init__.py
T
浅念 9cbd796975 feat: incremental sync orchestrator + watermarks
Task 2.7 — the coordination layer tying scrapers + repos together with
resumable, incremental sync.

app/sync/watermarks.py — SyncState CRUD keyed on (entity_type, channel,
scope_id): get_watermark / get_last_timestamp / get_last_version /
set_watermark (upsert). A partial advance (timestamp-only) does not clobber
the other cursor with NULL.

app/sync/orchestrator.py — the sync brain:
* sync_wechat_messages: time-window incremental. Reads last_timestamp for
  (message, wechat, "<wechat_id>:<friend_id>"), queries [last_ts, now] via
  scrape_wechat_messages, upserts each, best-effort downloads media, advances
  the watermark to max(timestamp). A re-run with no new messages queries an
  empty window and fetches nothing — the watermark advanced past.
* sync_{departments,wechat_accounts,wechat_friends,wechat_groups,
  wechat_voice_calls}: full re-scrape + idempotent upsert (these list APIs
  have no since-param; 'incremental' = re-scrape all, rely on PK idempotency).
  Watermark records last_synced_at only.
* sync_all_wechat: coordinator running departments -> accounts -> friends ->
  groups -> voice_calls, then per-conversation messages for an explicit
  (wechat_id, friend_id) list. Auto-discovery deferred.

Each run logs a SyncRun row (started -> success/failed, counts, error).
Failure policy: a scraper that raises is caught, recorded as failed, and a
failed SyncResult is returned — the orchestrator does not re-raise, so one
broken entity does not abort the batch. Media-download failures are
best-effort. The orchestrator never commits; callers own the transaction.

Tests (TDD red -> green; in-memory SQLite; scrapers/repos monkeypatched):
- watermarks: set/get, upsert (same key twice -> 1 row), partial-advance
  preserves the other cursor, defaults when absent.
- orchestrator messages: first run upserts 2 + advances watermark to T2;
  second run scrape called with start==T2 and upserts 0 (incremental proof);
  media failure does not abort; SyncRun success recorded.
- orchestrator friends: 3 records upserted, SyncRun success, watermark
  last_synced_at set.
- failure policy: scraper raises -> SyncRun failed + failed result, no raise
  (both friends and message paths).
- sync_all_wechat: 5 entities + 2 message runs in order, all success.

Suite: 149 -> 163 passing. ruff clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:23:45 +08:00

49 lines
1.4 KiB
Python

"""Incremental-sync coordination layer (Task 2.7).
This package ties the scrapers (``app.scraper.*``) and the persistence layer
(``app.storage.repos``) together with **resumable, incremental** sync via the
``SyncState`` watermark table and the ``SyncRun`` audit log.
Public surface:
* :mod:`app.sync.watermarks` — SyncState CRUD (per-entity / per-channel /
per-scope cursor get/set + convenience accessors).
* :mod:`app.sync.orchestrator` — the sync brain. One entrypoint per entity
(``sync_wechat_messages`` / ``sync_wechat_friends`` / ...) plus a coordinator
(:func:`app.sync.orchestrator.sync_all_wechat`). Each run is logged as a
``SyncRun`` row (started → success/failed) so the dashboard / ops view can
report progress and errors.
"""
from app.sync.orchestrator import (
SyncResult,
sync_all_wechat,
sync_departments,
sync_wechat_accounts,
sync_wechat_friends,
sync_wechat_groups,
sync_wechat_messages,
sync_wechat_voice_calls,
)
from app.sync.watermarks import (
get_last_timestamp,
get_last_version,
get_watermark,
set_watermark,
)
__all__ = [
"SyncResult",
"get_last_timestamp",
"get_last_version",
"get_watermark",
"set_watermark",
"sync_all_wechat",
"sync_departments",
"sync_wechat_accounts",
"sync_wechat_friends",
"sync_wechat_groups",
"sync_wechat_messages",
"sync_wechat_voice_calls",
]