Files
yunke/yunke-server/app/sync/watermarks.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

155 lines
5.3 KiB
Python

"""SyncState CRUD — resumable sync watermarks.
The ``SyncState`` table holds one cursor per ``(entity_type, channel, scope_id)``
triple (unique index ``ix_sync_state_scope``). The orchestrator reads a cursor
before a sync run to decide the upstream query window, and advances it after a
successful run so a crash + re-run does not re-pull the same rows.
Two cursors live on one row:
* ``last_version`` — opaque source update stamp (string). Used by entities that
expose a ``version > watermark`` style incremental query.
* ``last_timestamp`` — ms-epoch int. Used by the message sync (time-window
incremental: query ``[last_timestamp, now]``).
``last_synced_at`` stamps every successful advance (the dashboard surfaces
"last synced" per entity); ``updated_at`` is the ORM-level row mtime.
The helpers here are intentionally lightweight (select-then-update ORM style,
not Core upsert). ``SyncState`` is a tiny bookkeeping table — one row per
entity/channel/scope — so the extra round-trip is irrelevant and the partial-
update semantics (advance one cursor without clobbering the other with NULL)
are clearer this way than fighting a Core ``ON CONFLICT`` to be conditional.
"""
from __future__ import annotations
import datetime as dt
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import SyncState
__all__ = [
"get_last_timestamp",
"get_last_version",
"get_watermark",
"set_watermark",
]
async def get_watermark(
session: AsyncSession,
entity_type: str,
*,
channel: str | None = None,
scope_id: str | None = None,
) -> SyncState | None:
"""Return the ``SyncState`` row for ``(entity_type, channel, scope_id)``.
``channel`` / ``scope_id`` are optional filters: ``None`` matches the row
whose column IS NULL (the canonical "no sub-scope" cursor for an entity).
Returns ``None`` when no such row exists (first sync of a brand-new scope).
"""
stmt = sa.select(SyncState).where(SyncState.entity_type == entity_type)
if channel is not None:
stmt = stmt.where(SyncState.channel == channel)
else:
stmt = stmt.where(SyncState.channel.is_(None))
if scope_id is not None:
stmt = stmt.where(SyncState.scope_id == scope_id)
else:
stmt = stmt.where(SyncState.scope_id.is_(None))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_last_timestamp(
session: AsyncSession,
entity_type: str,
*,
channel: str | None = None,
scope_id: str | None = None,
default: int = 0,
) -> int:
"""Convenience: return the stored ``last_timestamp`` or ``default``.
``default`` (``0``) is returned when no row exists yet OR the row exists but
has no timestamp cursor set. A ``0`` start window means "pull everything
upstream" — the correct behavior for a first sync.
"""
row = await get_watermark(
session, entity_type, channel=channel, scope_id=scope_id
)
if row is None or row.last_timestamp is None:
return default
return int(row.last_timestamp)
async def get_last_version(
session: AsyncSession,
entity_type: str,
*,
channel: str | None = None,
scope_id: str | None = None,
) -> str | None:
"""Return the stored ``last_version`` cursor, or ``None`` if not set.
Distinct from :func:`get_last_timestamp`'s ``default=0`` contract: a version
cursor is a string and has no natural "zero" — ``None`` means "no version
cursor yet" and the caller decides what that implies (typically: full
re-scrape).
"""
row = await get_watermark(
session, entity_type, channel=channel, scope_id=scope_id
)
if row is None:
return None
return row.last_version
async def set_watermark(
session: AsyncSession,
entity_type: str,
*,
channel: str | None = None,
scope_id: str | None = None,
last_version: str | None = None,
last_timestamp: int | None = None,
) -> None:
"""Upsert the ``SyncState`` cursor row for ``(entity_type, channel, scope_id)``.
* If no row exists, inserts a fresh one carrying the supplied cursors and
``last_synced_at = now``.
* If a row exists, advances **only** the cursors the caller provided
(non-``None``); a ``None`` argument is treated as "do not touch", so a
timestamp-only advance does NOT wipe a previously-set version cursor
(and vice versa). ``last_synced_at`` is always refreshed to now.
The row is flushed (not committed) so callers own the transaction; a
failing batch can still roll the watermark advance back.
"""
existing = await get_watermark(
session, entity_type, channel=channel, scope_id=scope_id
)
now = dt.datetime.now(dt.UTC)
if existing is None:
session.add(
SyncState(
entity_type=entity_type,
channel=channel,
scope_id=scope_id,
last_version=last_version,
last_timestamp=last_timestamp,
last_synced_at=now,
)
)
else:
if last_version is not None:
existing.last_version = last_version
if last_timestamp is not None:
existing.last_timestamp = last_timestamp
existing.last_synced_at = now
await session.flush()