2026-10-27 23:42:15 -04:00
|
|
|
|
"""T+ 只读快照同步:批量 staging、幂等去重和失败隔离。"""
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
import logging
|
2026-10-27 23:42:15 -04:00
|
|
|
|
from collections.abc import Iterable
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from itertools import batched
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
from app.core.tplus_lock import sync_lock
|
|
|
|
|
|
from app.models import TPlusSyncBatch, TPlusSyncError
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.services.tplus import progress
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.services.tplus.errors import TRANSIENT_CODES, classify_source_error
|
2026-10-27 23:42:15 -04:00
|
|
|
|
from app.services.tplus.mapping import TPlusMapping, get_mapping
|
|
|
|
|
|
from app.services.tplus.reader import TPlusReader
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.services.tplus.source_heads import observe_rows
|
2026-10-27 23:42:15 -04:00
|
|
|
|
from app.services.tplus.staging import stage_rows
|
|
|
|
|
|
from app.services.tplus.task_service import fail_batch, new_batch_no, recover_running
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-10-27 23:42:15 -04:00
|
|
|
|
|
|
|
|
|
|
class TPlusSyncService:
|
|
|
|
|
|
"""按分页提交原始快照,业务表始终不参与本服务。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, source_session: Session, target_session: Session) -> None:
|
|
|
|
|
|
self.source_session = source_session
|
|
|
|
|
|
self.target_session = target_session
|
|
|
|
|
|
configured = get_settings().t_plus_query_dir
|
|
|
|
|
|
self.reader = TPlusReader(source_session, Path(configured) if configured else None)
|
|
|
|
|
|
|
|
|
|
|
|
def run(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
entity_types: Iterable[str],
|
|
|
|
|
|
watermark: datetime | None = None,
|
|
|
|
|
|
batch_no: str | None = None,
|
|
|
|
|
|
batch_size: int = 500,
|
|
|
|
|
|
) -> TPlusSyncBatch:
|
|
|
|
|
|
"""执行可重复的全量读取,提交每页进度;字段语义未定时拒绝时间增量。"""
|
|
|
|
|
|
entities = tuple(dict.fromkeys(entity_types))
|
|
|
|
|
|
if not entities or not 1 <= batch_size <= 5000:
|
|
|
|
|
|
raise ValueError("至少指定一个实体,batch_size 必须在 1 到 5000 之间")
|
|
|
|
|
|
if watermark is not None:
|
|
|
|
|
|
raise ValueError("增量水位语义尚未确认,当前只支持全量读取和哈希去重")
|
|
|
|
|
|
for entity in entities:
|
|
|
|
|
|
get_mapping(entity)
|
|
|
|
|
|
with sync_lock(self.target_session):
|
|
|
|
|
|
recover_running(self.target_session)
|
|
|
|
|
|
batch = self._start_direct(entities, batch_no)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return self.execute_claimed(batch, batch_size)
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
self.target_session.rollback()
|
|
|
|
|
|
fail_batch(self.target_session, batch, "INTERRUPTED")
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
def _start_direct(self, entities, batch_no):
|
|
|
|
|
|
batch = TPlusSyncBatch(
|
|
|
|
|
|
batch_no=batch_no or new_batch_no(),
|
|
|
|
|
|
sync_mode="FULL",
|
|
|
|
|
|
status="RUNNING",
|
|
|
|
|
|
source_database=get_settings().t_plus_database or None,
|
|
|
|
|
|
started_at=datetime.now(),
|
|
|
|
|
|
source_record_count=0,
|
|
|
|
|
|
staged_record_count=0,
|
|
|
|
|
|
failed_record_count=0,
|
|
|
|
|
|
skipped_record_count=0,
|
|
|
|
|
|
entity_types_json=list(entities),
|
|
|
|
|
|
)
|
|
|
|
|
|
self.target_session.add(batch)
|
|
|
|
|
|
self.target_session.commit()
|
|
|
|
|
|
return batch
|
|
|
|
|
|
|
|
|
|
|
|
def execute_claimed(self, batch: TPlusSyncBatch, batch_size: int = 500):
|
|
|
|
|
|
"""处理已持锁且标记 RUNNING 的任务;仅由直接入口或 worker 调用。"""
|
|
|
|
|
|
if batch.status != "RUNNING" or not batch.entity_types_json:
|
|
|
|
|
|
raise ValueError("任务尚未领取或读取范围不完整")
|
|
|
|
|
|
mappings = [get_mapping(entity) for entity in batch.entity_types_json]
|
2026-11-02 22:20:55 -05:00
|
|
|
|
progress.write(batch, phase="COUNTING")
|
|
|
|
|
|
self.target_session.commit()
|
|
|
|
|
|
try:
|
|
|
|
|
|
totals = self.reader.count_entities(batch.entity_types_json)
|
|
|
|
|
|
if isinstance(totals, dict) and all(isinstance(v, int) and v >= 0 for v in totals.values()) and set(totals) == set(batch.entity_types_json):
|
|
|
|
|
|
progress.write(batch, phase="READING", totals=totals)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
# 总量统计不可用时仍继续读源;页面显示未知总量,不伪造百分比。
|
|
|
|
|
|
self.source_session.rollback()
|
|
|
|
|
|
logger.warning("T+总量统计暂不可用,继续读取(%s)", type(exc).__name__)
|
|
|
|
|
|
self.target_session.commit()
|
2026-11-01 20:42:53 -05:00
|
|
|
|
interrupted = False
|
2026-10-27 23:42:15 -04:00
|
|
|
|
for mapping in mappings:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._read_entity(batch, mapping, batch_size)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.target_session.rollback()
|
2026-11-01 20:42:53 -05:00
|
|
|
|
code = classify_source_error(exc)
|
|
|
|
|
|
self._record_error(batch, mapping, {}, code, type(exc).__name__)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
progress.write(batch, failed=mapping.entity_type)
|
2026-10-27 23:42:15 -04:00
|
|
|
|
self.target_session.commit()
|
2026-11-01 20:42:53 -05:00
|
|
|
|
if code in TRANSIENT_CODES:
|
|
|
|
|
|
interrupted = True
|
|
|
|
|
|
break
|
2026-10-27 23:42:15 -04:00
|
|
|
|
batch.status = "SUCCEEDED"
|
|
|
|
|
|
if batch.failed_record_count:
|
|
|
|
|
|
failed_rows = (
|
|
|
|
|
|
self.target_session.scalar(
|
|
|
|
|
|
select(func.count())
|
|
|
|
|
|
.select_from(TPlusSyncError)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
TPlusSyncError.batch_no == batch.batch_no,
|
|
|
|
|
|
TPlusSyncError.error_code == "STAGE_ERROR",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
or 0
|
|
|
|
|
|
)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
# 中途断连仍是未完成的全量采集;保留已暂存页,重跑由哈希防重。
|
|
|
|
|
|
batch.status = "PARTIAL" if not interrupted and batch.source_record_count > failed_rows else "FAILED"
|
2026-10-27 23:42:15 -04:00
|
|
|
|
batch.error_message = "部分数据未完成,请查询同步错误记录并重跑全量快照"
|
|
|
|
|
|
batch.finished_at = datetime.now()
|
2026-11-02 22:20:55 -05:00
|
|
|
|
progress.write(batch, phase="COMPLETED" if batch.status == "SUCCEEDED" else "FAILED")
|
2026-10-27 23:42:15 -04:00
|
|
|
|
batch.watermark_to = None
|
|
|
|
|
|
self.target_session.commit()
|
|
|
|
|
|
return batch
|
|
|
|
|
|
|
|
|
|
|
|
def _read_entity(self, batch: TPlusSyncBatch, mapping: TPlusMapping, size: int) -> None:
|
|
|
|
|
|
"""批量写入一类数据;每页提交后即使进程中断也可幂等重跑。"""
|
2026-11-02 22:20:55 -05:00
|
|
|
|
progress.write(batch, phase="READING", active=mapping.entity_type)
|
|
|
|
|
|
self.target_session.commit()
|
|
|
|
|
|
read = 0
|
2026-10-27 23:42:15 -04:00
|
|
|
|
source = self.reader.iter_entity(mapping.entity_type, batch_size=size)
|
|
|
|
|
|
for chunk in batched(source, size):
|
|
|
|
|
|
inserted, errors = stage_rows(self.target_session, batch.batch_no, mapping, list(chunk))
|
2026-11-01 20:42:53 -05:00
|
|
|
|
observe_rows(self.target_session, batch, mapping, list(chunk))
|
2026-10-27 23:42:15 -04:00
|
|
|
|
batch.source_record_count += len(chunk)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
read += len(chunk)
|
|
|
|
|
|
progress.write(batch, read=read)
|
2026-10-27 23:42:15 -04:00
|
|
|
|
batch.staged_record_count += inserted
|
|
|
|
|
|
batch.skipped_record_count += len(chunk) - inserted - len(errors)
|
|
|
|
|
|
for row, reason in errors:
|
|
|
|
|
|
self._record_error(batch, mapping, row, "STAGE_ERROR", reason)
|
|
|
|
|
|
self.target_session.commit()
|
2026-11-01 20:42:53 -05:00
|
|
|
|
report = dict(batch.audit_result_json or {})
|
|
|
|
|
|
report["source_head_version"] = 1
|
|
|
|
|
|
report["observed_entities"] = list(
|
|
|
|
|
|
dict.fromkeys([*report.get("observed_entities", []), mapping.entity_type])
|
|
|
|
|
|
)
|
|
|
|
|
|
batch.audit_result_json = report
|
2026-11-02 22:20:55 -05:00
|
|
|
|
progress.write(batch, done=mapping.entity_type)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
self.target_session.commit()
|
2026-10-27 23:42:15 -04:00
|
|
|
|
|
|
|
|
|
|
def _record_error(
|
|
|
|
|
|
self, batch: TPlusSyncBatch, mapping: TPlusMapping, row: dict, code: str, reason: str
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""只记录错误类别和源 ID,避免数据库异常携带个人信息或凭据。"""
|
|
|
|
|
|
self.target_session.add(
|
|
|
|
|
|
TPlusSyncError(
|
|
|
|
|
|
batch_no=batch.batch_no,
|
|
|
|
|
|
entity_type=mapping.entity_type,
|
|
|
|
|
|
source_table=mapping.source_table,
|
|
|
|
|
|
source_id=str(row["id"]) if row.get("id") is not None else None,
|
|
|
|
|
|
source_line_no=str(row["line_no"]) if row.get("line_no") is not None else None,
|
|
|
|
|
|
error_code=code,
|
|
|
|
|
|
error_message=f"源数据处理失败({reason}),请核对连接和字段",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
batch.failed_record_count += 1
|