Commit fcc2a9d6 by luoqi

feat(sync): PR1 — partial-unique 并发锁 + run_start baseline cursor

数据正确性 2 件:
1. **并发锁**:sync_logs 加 partial UNIQUE (host_id) WHERE status='running'
   同 host 同时只能 1 个 sync 在跑(存量 / 增量 cron / 手动一律抢同一把锁)
   INSERT 撞 P2002 → 抛 SyncAlreadyRunningError → 调用方 skip
   scheduler 捕获该 error 时 warn 不 error,下次 cron 自然 retry
   CLI 撞锁退出 code=4(区分于 2=真失败 / 3=bootstrap 崩)

2. **cursor=run_start 而非 max(updated_date)**:
   存量跑期间 DW 持续写入(已摄入患者更新 / 未摄入患者新增),
   max(updated_date) cursor 会漏:
     · batch 1 摄入患者 100,T+1h DW 又写患者 100 一笔(updated_date=T+1h)
       max cursor 推到 T+4:25(末批的 max),下次增量 WHERE > T+4:25 → 漏掉这笔
   run_start cursor 保证捞回:
     · 下次 WHERE > T+0 → 全部 T+0~now 的变化都进入增量
     · 同行同 updatedAt → source_event_id 一致 → P2002 path → parser re-run idempotent
     · 同行不同 updatedAt → 不同 source_event_id → 新 tx + 新 fact 版本
   重复读浪费 read 但无害,数据正确性 > 带宽优化

importDirectory 重构:
- runStart 入口冻结
- SyncLog create 提前(在 table load 前),作为锁 acquisition 点
- 整段 work 包 try/finally,finally 统一 finalize syncLog(释放锁 + 写 cursor)
- 之前没有 finally,work throw 时 syncLog 卡在 status='running' = 永久死锁
- 加 SyncAlreadyRunningError 导出类,scheduler / CLI 分别处理

本地验证(docker exec psql):
   1st INSERT running OK
   2nd INSERT running for same host → unique violation
   UPDATE 1st status='success' 后,新 running INSERT OK(锁释放)

stale 锁兜底(进程崩留 status='running'):
  目前需人工清:
    UPDATE sync_logs SET status='aborted' WHERE status='running'
                                            AND started_at < NOW() - INTERVAL '12 hours';
  PR2/PR3 期间会加 cron 看门狗自动清。
parent f19434d7
-- 同 host 同时只能有 1 个 status='running' 的 sync_log
-- 用 partial UNIQUE 索引把"锁"内嵌到 sync_logs 行:
-- - INSERT status='running' 时撞 unique → P2002,调用方 skip(并发拦截)
-- - finalize 时 UPDATE status='success'/'failed'/'partial' → partial 条件不满足 → 锁释放
-- - 进程崩留下 stale running:依赖 cron/手动 UPDATE status='aborted' 清理(留 TTL 兜底,见
-- SyncIncrementalSchedulerService 的看门狗)
--
-- 比独立 sync_locks 表简洁:沿用现有 schema,锁状态跟 sync_log 行同步,无需双写
CREATE UNIQUE INDEX "sync_logs_one_running_per_host"
ON "sync_logs" ("host_id")
WHERE "status" = 'running';
...@@ -1339,5 +1339,8 @@ model SyncLog { ...@@ -1339,5 +1339,8 @@ model SyncLog {
@@index([status]) @@index([status])
/// 单患者 pull 反查:"该患者最近被刷新过几次" /// 单患者 pull 反查:"该患者最近被刷新过几次"
@@index([patientId, startedAt]) @@index([patientId, startedAt])
/// MIGRATION 20260528000000:partial UNIQUE (host_id) WHERE status='running'
/// host 同时只能有 1 running ,作为存量/增量 sync 的并发锁。
/// Prisma 不支持声明式 partial UNIQUE,migration.sql 是单一真理源。
@@map("sync_logs") @@map("sync_logs")
} }
...@@ -11,7 +11,10 @@ ...@@ -11,7 +11,10 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service'; import {
ColdImportService,
SyncAlreadyRunningError,
} from '../modules/sync/cold-import/cold-import.service';
interface CliArgs { interface CliArgs {
dir?: string; dir?: string;
...@@ -100,9 +103,19 @@ async function bootstrap() { ...@@ -100,9 +103,19 @@ async function bootstrap() {
} }
logger.log('─────────────────────────────────────────'); logger.log('─────────────────────────────────────────');
} catch (err) { } catch (err) {
logger.error('Cold import failed:'); if (err instanceof SyncAlreadyRunningError) {
logger.error(err instanceof Error ? err.stack ?? err.message : String(err)); logger.error(`并发拦截:已有其他 sync 在跑同 host:${err.message}`);
process.exitCode = 2; logger.error('如果上次 sync 异常崩了未清理,手动 SQL 清:');
logger.error(
` UPDATE sync_logs SET status='aborted', ended_at=NOW() ` +
`WHERE host_id='<hostId>' AND status='running' AND started_at < NOW() - INTERVAL '12 hours';`,
);
process.exitCode = 4; // 4 = lock conflict(区分于 2 = 真失败 / 3 = bootstrap 崩)
} else {
logger.error('Cold import failed:');
logger.error(err instanceof Error ? err.stack ?? err.message : String(err));
process.exitCode = 2;
}
} finally { } finally {
await app.close(); await app.close();
} }
......
...@@ -2,7 +2,10 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,7 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import * as path from 'path'; import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service'; import {
ColdImportService,
SyncAlreadyRunningError,
} from '../modules/sync/cold-import/cold-import.service';
import { PersonaService } from '../modules/persona/persona.service'; import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service'; import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
...@@ -56,9 +59,16 @@ export class SyncIncrementalSchedulerService { ...@@ -56,9 +59,16 @@ export class SyncIncrementalSchedulerService {
try { try {
await this.runOne(path.join(dataDir, host)); await this.runOne(path.join(dataDir, host));
} catch (err) { } catch (err) {
this.logger.error( if (err instanceof SyncAlreadyRunningError) {
`sync-incremental: host=${host} failed: ${(err as Error).message}`, // 并发拦截 — 已有手动跑或上次 cron 还没完。下次 cron 自然 retry。
); this.logger.warn(
`sync-incremental: host=${host} skip(并发锁拦截):${err.message}`,
);
} else {
this.logger.error(
`sync-incremental: host=${host} failed: ${(err as Error).message}`,
);
}
// 不抛 — 下个 host 继续;cursor 没前进自然下次 catchup // 不抛 — 下个 host 继续;cursor 没前进自然下次 catchup
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment