1404 lines
63 KiB
Markdown
1404 lines
63 KiB
Markdown
|
|
# AI 品牌监测优先级调度与立即采集抢占方案 V1.1
|
|||
|
|
|
|||
|
|
## 1. 文档信息
|
|||
|
|
|
|||
|
|
| 项目 | 内容 |
|
|||
|
|
| --- | --- |
|
|||
|
|
| 文档名称 | AI 品牌监测优先级调度与立即采集抢占方案 V1.1 |
|
|||
|
|
| 文档版本 | V1.1 |
|
|||
|
|
| 文档状态 | 修订完成,待二次评审 |
|
|||
|
|
| 创建日期 | 2026-04-20 |
|
|||
|
|
| 修订日期 | 2026-04-20 |
|
|||
|
|
| 上一版本 | `docs/ai-brand-monitoring-priority-dispatch-design-v1.md`(V1.0) |
|
|||
|
|
| 适用范围 | `monitoring` 采集链路、`scheduler`、`tenant-api`、`desktop client` |
|
|||
|
|
| 关联文档 | `docs/ai-brand-monitoring-tech-design-v5.md` |
|
|||
|
|
| 关联文档 | `docs/geo-platform-ops-admin-tech-architecture-v1.md` |
|
|||
|
|
| 当前基线 | `desktop client (pull-lease) + monitoring_collect_tasks + RabbitMQ result ingest + projection rebuild` |
|
|||
|
|
|
|||
|
|
## 2. 修订记录(V1.0 → V1.1)
|
|||
|
|
|
|||
|
|
本版本针对 V1.0 架构评审中的 5 项 Critical 与 7 项 Warning 做全面修订:
|
|||
|
|
|
|||
|
|
| 编号 | 原问题 | 修订章节 | 修订摘要 |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| C1 | `status` 与 `execution_status` 二选一决策留白 | §7.1 | 明确不新增 `execution_status`;Phase 1 继续复用现有 `status`,Phase 2 执行权威迁至 `desktop_tasks` |
|
|||
|
|
| C2 | `interrupt_generation` 语义未定义,存在抢占竞态 | §10.2 | 新增代际规范:归属、递增规则、事件携带、回写比对 |
|
|||
|
|
| C3 | Phase 1→2 迁移期双写一致性空白 | §16.2 | 引入 `execution_owner` 路由字段 + 灰度 + 回滚 + reconcile |
|
|||
|
|
| C4 | 抢占 15s/60s 硬编码无经验基线 | §10.5 | 改为 per-platform 可配置参数,附测量落地流程 |
|
|||
|
|
| C5 | dispatch 预算四维耦合关系未定义 | §9.2 | 新增决策树、拒绝处置矩阵、high lane 绕过规则 |
|
|||
|
|
| W1 | `target_client_id` 设备漂移场景未覆盖 | §10.6 | 新增绑定时机、选择规则、漂移 4 场景处置、TTL |
|
|||
|
|
| W2 | aging 简单加分会集体失效 | §9.4 | 改为百分位触发 + 对数衰减 |
|
|||
|
|
| W3 | publish/monitor 共表 priority 值域未规范 | §7.3 | 新增跨 kind priority 值域表 + lane_weight 排序 |
|
|||
|
|
| W4 | 伪代码 16.1 单大事务 + 外部副作用 | §17.1 | 改为 outbox 模式,短事务 + 独立 pump |
|
|||
|
|
| W5 | Phase 1 抢占语义与双层模型矛盾 | §16.1 | 收敛 Phase 1 范围:只抢占 queued/leased,不抢 running |
|
|||
|
|
| W6 | desktop pull→push 过渡期通道空窗 | §14.3 §16.1 | **Phase 1 即启用 WebSocket 通道,SSE `/api/desktop/events` Phase 1 完成后立即下线**(修订 2:用户明确要求早期拿掉 SSE) |
|
|||
|
|
| W7 | 幂等键与现有表约束未对齐 | §12.3 | 对齐 `question_monitor_runs`、`projection_rebuild_queue` 现有键 |
|
|||
|
|
| I1 | §6.1 当前态描述偏旧 | §6.1 | 更新 `monitor.projection.rebuild` 已存在 |
|
|||
|
|
| I2 | `ready` 与 `pending` 混用 | 全文 | 统一为 `pending` |
|
|||
|
|
| I3 | `collection_mode` 字段语义在插件下线后歧义 | §13.1 | 保留用于日志审计,文档明确说明 |
|
|||
|
|
| I4 | 观测指标不足 | §15.1 | 补充 `preempt_induced_retry`、`safe_point_interval`、`dispatch_reject_by_reason` 等 |
|
|||
|
|
|
|||
|
|
## 3. 背景与问题
|
|||
|
|
|
|||
|
|
当前监测采集已经具备以下基础能力:
|
|||
|
|
|
|||
|
|
1. `collect-now` 已存在入口,能够把当天任务重置回可执行状态。
|
|||
|
|
2. desktop client 已具备任务租约、续租、回写结果、使用 WS 推送等基础设施(monitor 仍以 pull-lease 为主,其实是无法正常使用的,不用太在意;publish 已使用 WS push(可用,体验非常好);旧 SSE `/api/desktop/events` **Phase 1 即下线**,所有事件统一走 WebSocket)。
|
|||
|
|
3. 结果上报已走 `API -> RabbitMQ -> ingest worker -> Monitoring PG` 的异步写链路,`monitor.projection.rebuild` worker 与 `MonitoringProjectionRebuildQueue` 已存在并完成聚合投影合并。
|
|||
|
|
|
|||
|
|
但当前实现仍存在几个关键缺口:
|
|||
|
|
|
|||
|
|
1. `collect-now` 只能重置 `pending / expired / failed / completed / skipped`,不会抢占已经 `leased / running` 的定时任务。
|
|||
|
|
2. monitoring 任务当前仍以"按 `planned_at` 顺序从 DB 拉取"作为主调度信号,缺少显式优先级和抢占语义。
|
|||
|
|
3. monitoring 尚未像 publish 一样迁移到 `RabbitMQ -> tenant-api -> desktop dispatch websocket` 的低延迟推送链路。
|
|||
|
|
4. 定时任务如果按分钟大批量扫到期后直接下发,容易把 desktop client、结果回写链路和数据库同时打满。
|
|||
|
|
5. 结果入库虽然已异步化,但"原始事实写入"的分片串行尚未落地,高峰期仍有打挂库风险。
|
|||
|
|
|
|||
|
|
本方案要解决的核心问题是:
|
|||
|
|
|
|||
|
|
1. 每日定时采集如何稳态运行,不形成瞬时巨量计算洪峰。
|
|||
|
|
2. `collect-now` 如何保证明显高于定时任务的体验优先级。
|
|||
|
|
3. 当定时任务正在执行时,如何安全让路给 `collect-now`。
|
|||
|
|
4. 结果如何在不牺牲吞吐的前提下做到局部有序、幂等落库。
|
|||
|
|
|
|||
|
|
## 4. 目标与非目标
|
|||
|
|
|
|||
|
|
### 4.1 目标
|
|||
|
|
|
|||
|
|
1. `collect-now` 从点击到 desktop client 开始执行的链路尽量走低延迟通道。
|
|||
|
|
2. `collect-now` 对同一 client 上的定时监测任务具备抢占权。
|
|||
|
|
3. 定时调度采用"分批 claim + 按预算 dispatch",而不是"扫到即全量下发"。
|
|||
|
|
4. 数据库只承担真相存储与有界写入,不承担大规模实时排队压力。
|
|||
|
|
5. 结果入库按业务分片串行,局部有序,不要求全局单线程。
|
|||
|
|
6. 全链路具备幂等、回放、超时回收、死信与可观测性。
|
|||
|
|
|
|||
|
|
### 4.2 非目标
|
|||
|
|
|
|||
|
|
1. 不要求一次 `collect-now` 抢占整个租户所有后台任务;默认只抢占目标 client 上的定时监测任务。
|
|||
|
|
2. 不要求依赖 RabbitMQ 单队列优先级特性解决全部排序问题(没顺序要求)。
|
|||
|
|
3. 不要求 desktop client 直接连接 RabbitMQ(不可以直接连接 RabbitMQ)。
|
|||
|
|
4. 不在本方案内重做全部监测业务口径,仅聚焦任务控制面与执行链路。
|
|||
|
|
|
|||
|
|
## 5. 核心结论
|
|||
|
|
|
|||
|
|
### 5.1 任务真相源
|
|||
|
|
|
|||
|
|
推荐采用"双层任务模型"(Phase 2 全量引入):
|
|||
|
|
|
|||
|
|
1. `monitoring_collect_tasks` 继续作为"业务任务真相源",表示某品牌、某问题、某平台、某业务日是否需要采集。
|
|||
|
|
2. `desktop_tasks(kind=monitor)` 作为"执行任务控制面",承接优先级、抢占、租约、取消、推送与恢复。
|
|||
|
|
|
|||
|
|
换句话说:
|
|||
|
|
|
|||
|
|
- `monitoring_collect_tasks` 负责"要不要做"
|
|||
|
|
- `desktop_tasks(kind=monitor)` 负责"怎么派发给哪台 desktop 做"
|
|||
|
|
|
|||
|
|
这样可以复用现有 publish 的成熟能力,而不是让 monitoring 永久维持一套独立调度机制。
|
|||
|
|
|
|||
|
|
**过渡期说明**:Phase 1 不引入 `desktop_tasks(kind=monitor)`,执行态从 `monitoring_collect_tasks` 的 `lease_expires_at / leased_to_executor` 推导;Phase 2 按 tenant 灰度迁移,详见 §16.2。
|
|||
|
|
|
|||
|
|
### 5.2 RabbitMQ 的角色
|
|||
|
|
|
|||
|
|
RabbitMQ 只负责运输、削峰、解耦和异步化,不作为唯一真相源。
|
|||
|
|
|
|||
|
|
推荐边界:
|
|||
|
|
|
|||
|
|
1. 下行:`DB claim -> RabbitMQ dispatch event -> tenant-api dispatch hub -> desktop websocket`
|
|||
|
|
2. 上行:`desktop callback -> tenant-api receive -> RabbitMQ result ingest -> worker -> Monitoring PG`
|
|||
|
|
|
|||
|
|
不推荐 desktop client 直连 RabbitMQ,原因:
|
|||
|
|
|
|||
|
|
1. 证书与凭证分发复杂。
|
|||
|
|
2. 网络拓扑和私有化部署适配更难。
|
|||
|
|
3. 权限收敛、审计、灰度、风控和回放都不如经由 API 网关统一处理稳定。
|
|||
|
|
|
|||
|
|
### 5.3 优先级策略
|
|||
|
|
|
|||
|
|
推荐采用"多队列 + 服务端调度预算":
|
|||
|
|
|
|||
|
|
1. `high`:`collect-now`
|
|||
|
|
2. `normal`:每日定时
|
|||
|
|
3. `retry`:失败重试、过期回收
|
|||
|
|
|
|||
|
|
不要依赖 RabbitMQ 单队列 `x-max-priority` 解决一切问题。大厂常见做法是多队列分层,再由业务侧决定是否暂停 `normal` lane。
|
|||
|
|
|
|||
|
|
### 5.4 抢占策略
|
|||
|
|
|
|||
|
|
抢占采用"协作式抢占",不做粗暴 kill:
|
|||
|
|
|
|||
|
|
1. `queued` 的执行任务:直接取消或降级回 `normal` 队列。
|
|||
|
|
2. `leased` 但尚未开始浏览器交互:不在服务端盲目清租约,而是下发 `task_control`;desktop 只在本地单线程调度循环中、且 `executionPhase in (PREPARING, NAVIGATING)` 时才主动 cancel lease 并回队(见 §14.2 / §14.4)。
|
|||
|
|
3. `running` 的执行任务(**Phase 2 起支持**):下发 `interrupt_requested`,desktop client 在安全点主动中断并回写 `aborted(preempted)`。
|
|||
|
|
4. 如果 `interrupt_soft_timeout` 内无法安全中断,则禁止新的 `normal` 任务继续进入该 client,待当前任务结束后立即执行 `collect-now`。
|
|||
|
|
|
|||
|
|
本文中“尚未真正开始执行”严格等价于 desktop 本地 `executionPhase in (PREPARING, NAVIGATING)`;自 `AUTHENTICATING` 起一律视为“已开始”,Phase 1 不再直接 cancel,Phase 2 改为在安全点处理中断。
|
|||
|
|
|
|||
|
|
### 5.5 数据库保护策略
|
|||
|
|
|
|||
|
|
数据库保护依赖四层手段:
|
|||
|
|
|
|||
|
|
1. 调度限流:到期任务先 claim,不立刻全量 dispatch。
|
|||
|
|
2. 写链路拆分:原始事实写入与聚合重算分开(已落地)。
|
|||
|
|
3. 分片串行:按 `(tenant_id, brand_id, business_date)` 做结果分片。
|
|||
|
|
4. 局部重算:只重算受影响的品牌/日期快照,不做全局全表聚合(已落地)。
|
|||
|
|
|
|||
|
|
## 6. 当前态与目标态
|
|||
|
|
|
|||
|
|
### 6.1 当前态(V1.1 更正)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
schedule/collect-now API (status reset only)
|
|||
|
|
-> monitoring_collect_tasks (status=pending)
|
|||
|
|
-> desktop client 定时 pull lease
|
|||
|
|
-> desktop 执行
|
|||
|
|
-> API callback
|
|||
|
|
-> RabbitMQ monitor.result.ingest (已存在)
|
|||
|
|
-> ingest worker (已存在)
|
|||
|
|
-> Monitoring PG raw facts
|
|||
|
|
-> RabbitMQ monitor.projection.rebuild (已存在)
|
|||
|
|
-> projection rebuild worker (已存在,带 coalesce)
|
|||
|
|
-> monitoring_brand_daily / monitoring_brand_platform_daily
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
特点(实际代码核实):
|
|||
|
|
|
|||
|
|
1. 上行结果链路合理,projection rebuild 已带 coalesce。
|
|||
|
|
2. 下行仍偏 DB 轮询,无 push 推送。
|
|||
|
|
3. `collect-now` 目前只做"重置可执行状态",不做优先级提升也不做抢占(§`server/internal/tenant/app/monitoring_service.go` 中 `CollectNow` 方法)。
|
|||
|
|
4. SSE `/api/desktop/events` 存在且仍在使用,**Phase 1 即下线**,所有事件统一走 WebSocket `desktop.task.dispatch`。
|
|||
|
|
5. `desktop_tasks` 表已存在并支持 `kind=publish \| monitor`,但 monitor kind 目前**未被任何调度逻辑使用**。
|
|||
|
|
|
|||
|
|
### 6.2 推荐目标态
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
schedule scanner / collect-now API
|
|||
|
|
-> monitoring_collect_tasks (业务真相,带 lane/priority/target_client_id)
|
|||
|
|
-> monitor dispatch coordinator (按预算 claim execution slots)
|
|||
|
|
-> desktop_tasks(kind=monitor) (执行控制面)
|
|||
|
|
-> RabbitMQ desktop.task.dispatch (复用 publish 通道)
|
|||
|
|
-> tenant-api dispatch hub
|
|||
|
|
-> desktop websocket
|
|||
|
|
-> desktop lease / execute / extend / cancel / complete
|
|||
|
|
-> monitor result receive API
|
|||
|
|
-> RabbitMQ monitor.result.ingest (分片串行消费)
|
|||
|
|
-> ingest worker
|
|||
|
|
-> Monitoring PG raw facts
|
|||
|
|
-> RabbitMQ monitor.projection.rebuild (已有 coalesce)
|
|||
|
|
-> projection worker
|
|||
|
|
-> monitoring_brand_daily / monitoring_brand_platform_daily
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 渐进落地原则
|
|||
|
|
|
|||
|
|
推荐分三阶段,详见 §16:
|
|||
|
|
|
|||
|
|
1. Phase 1:业务任务表加优先级字段 + collect-now 高优提升 + queued/leased 抢占(不抢 running)+ 直接复用 WebSocket 下发 `task_available / task_control`,SSE 同步下线。
|
|||
|
|
2. Phase 2:引入 `desktop_tasks(kind=monitor)` + 收敛执行控制面 + 支持 running 任务抢占。
|
|||
|
|
3. Phase 3:ingest 分片串行 + 多租户公平调度 + aging + 自动降速。
|
|||
|
|
|
|||
|
|
## 7. 任务模型设计
|
|||
|
|
|
|||
|
|
### 7.1 业务任务:`monitoring_collect_tasks`(Phase 1 新增字段)
|
|||
|
|
|
|||
|
|
**架构决策(C1)**:**不新增** `execution_status` 字段,但必须明确“谁是唯一执行真相源”。Phase 1 沿用现有 `status` 承载 legacy 执行流转;Phase 2 当 `execution_owner='desktop_tasks'` 时,`desktop_tasks.status` 成为**唯一执行真相**,`monitoring_collect_tasks.status` 只保留业务终态,不再镜像中间执行态,也不再作为新路径的租约判断依据。
|
|||
|
|
|
|||
|
|
`status` 兼容值保持与当前实现一致:
|
|||
|
|
|
|||
|
|
| 值 | 语义 |
|
|||
|
|
| --- | --- |
|
|||
|
|
| `pending` | 待执行或待再次派发 |
|
|||
|
|
| `leased` | 已被 desktop 领取租约(legacy 路径) |
|
|||
|
|
| `received` | desktop 已回调结果,等待 ingest worker 最终落库 |
|
|||
|
|
| `completed` | 今天已完成 |
|
|||
|
|
| `failed` | 今天尝试但失败(达到最大重试) |
|
|||
|
|
| `skipped` | 今天跳过(配额/关闭/排除) |
|
|||
|
|
| `expired` | 今天到期未完成 |
|
|||
|
|
|
|||
|
|
约束说明:
|
|||
|
|
|
|||
|
|
1. `execution_owner='legacy'` 时,`LeaseTasks / ResumeTasks / expire / result callback` 继续依赖本表 `status`,保持与现网代码一致。
|
|||
|
|
2. `execution_owner='desktop_tasks'` 时,执行归属、抢占、续租、取消与中间执行态一律以 `desktop_tasks.status` 为准;`monitoring_collect_tasks.status` 只允许保留 `pending / completed / failed / skipped / expired` 五类业务态,**禁止**写入 `leased / received`。
|
|||
|
|
3. Phase 2 迁移期不得同时让两张表都成为“执行真相源”,否则会出现双状态竞争。
|
|||
|
|
4. Phase 2 灰度仅影响新生成任务;已 in-flight 的 `legacy` 存量任务继续沿用旧写法直至自然结束,不做半程切换。
|
|||
|
|
|
|||
|
|
**Phase 2 状态写入矩阵(强制约束)**:
|
|||
|
|
|
|||
|
|
| 场景 | `execution_owner='legacy'` | `execution_owner='desktop_tasks'` |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| dispatch / lease 中 | `monitoring_collect_tasks.status` 可写 `leased` | `monitoring_collect_tasks.status` 保持 `pending`;执行细节写 `desktop_tasks.status` |
|
|||
|
|
| desktop 回调结果已接收 | `monitoring_collect_tasks.status` 可写 `received` | `monitoring_collect_tasks.status` 仍保持 `pending`;原始结果照常入 MQ,等待最终终态 |
|
|||
|
|
| 最终成功 | `monitoring_collect_tasks.status = completed` | `monitoring_collect_tasks.status = completed` |
|
|||
|
|
| 最终失败 / 跳过 / 过期 | `failed / skipped / expired` | `failed / skipped / expired` |
|
|||
|
|
|
|||
|
|
**下游兼容策略**:
|
|||
|
|
|
|||
|
|
| 下游 | 读取策略 |
|
|||
|
|
| --- | --- |
|
|||
|
|
| UI | `monitoring_collect_tasks.status` 仅用于“是否已完成/是否终态”判断;当前执行细节读取 active `desktop_tasks.status` |
|
|||
|
|
| 报表 | 以 `monitoring_collect_tasks` 为业务主表;若需要执行维度(排队、执行中、重试中),显式 join `desktop_tasks` |
|
|||
|
|
| 审计 | 业务审计读 `monitoring_collect_tasks`;执行与 attempt 细节读 `desktop_tasks` / `desktop_task_attempts` |
|
|||
|
|
| reconcile | `execution_owner='desktop_tasks'` 时只对账“是否存在 active desktop_task / 是否收敛为正确终态”,不再比较 `leased / received` 之类中间态 |
|
|||
|
|
|
|||
|
|
**术语约定**:
|
|||
|
|
|
|||
|
|
1. 业务任务层使用 `completed`,表示“该业务日已完成采集”。
|
|||
|
|
2. 执行任务层与 `monitoring_collect_requests` 使用 `succeeded`,表示“本次执行 / 本次请求成功结束”。
|
|||
|
|
3. 两套命名是刻意区分业务语义与执行语义,不是 typo。
|
|||
|
|
|
|||
|
|
新增字段:
|
|||
|
|
|
|||
|
|
| 字段 | 类型 | 说明 |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| `trigger_source` | varchar(20) | `automatic` / `manual`(已存在,保留) |
|
|||
|
|
| `dispatch_priority` | int | 服务端调度排序值,见 §7.3 值域规范 |
|
|||
|
|
| `dispatch_lane` | varchar(20) | `high` / `normal` / `normal_boosted` / `retry` |
|
|||
|
|
| `target_client_id` | uuid nullable | 绑定的 desktop client,见 §10.6 |
|
|||
|
|
| `dispatch_after` | timestamptz | 不早于该时间派发 |
|
|||
|
|
| `interrupt_generation` | int | 抢占代际,详见 §10.2 |
|
|||
|
|
| `superseded_by_request_id` | uuid nullable | 被哪次手动请求覆盖 |
|
|||
|
|
| `last_dispatched_at` | timestamptz nullable | 最近一次派发时间 |
|
|||
|
|
| `dispatch_attempts` | int | 派发次数 |
|
|||
|
|
| `ingest_shard_key` | varchar(120) | 结果入库分片键,见 §12.2 |
|
|||
|
|
| `execution_owner` | varchar(20) | `legacy` / `desktop_tasks`,Phase 1→2 过渡路由字段,见 §16.2 |
|
|||
|
|
|
|||
|
|
说明:
|
|||
|
|
|
|||
|
|
1. `dispatch_priority` 不直接等同于 RabbitMQ 优先级,而是服务端调度排序依据。
|
|||
|
|
2. `target_client_id` 在 `collect-now` 场景必须明确绑定到当前用户在线 client;普通定时任务可为 NULL。
|
|||
|
|
3. `execution_owner` 默认 `legacy`,Phase 2 灰度切换为 `desktop_tasks`。
|
|||
|
|
|
|||
|
|
### 7.2 执行任务:`desktop_tasks(kind=monitor)`(Phase 2 新增字段)
|
|||
|
|
|
|||
|
|
建议扩展已有表,增加以下字段:
|
|||
|
|
|
|||
|
|
| 字段 | 类型 | 说明 |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| `priority` | int | 执行优先级,见 §7.3 值域 |
|
|||
|
|
| `lane` | varchar(20) | `high` / `normal` / `normal_boosted` / `retry` |
|
|||
|
|
| `lane_weight` | int | 由 lane 衍生,排序用(见 §7.3) |
|
|||
|
|
| `source` | varchar(20) | `collect_now` / `daily_schedule` / `retry_recovery` |
|
|||
|
|
| `scheduler_group_key` | varchar(120) | 问题并发互斥键 |
|
|||
|
|
| `monitor_task_id` | bigint nullable | 关联 `monitoring_collect_tasks.id` |
|
|||
|
|
| `supersedes_task_id` | uuid nullable | 抢占时被替代的旧执行任务 |
|
|||
|
|
| `control_flags` | jsonb | 如 `{"interrupt_requested": true, "interrupt_generation": 7}` |
|
|||
|
|
| `interrupt_generation` | int | 与业务任务同步,权威副本 |
|
|||
|
|
| `started_at` | timestamptz nullable | 真正开始执行时间 |
|
|||
|
|
| `interrupted_at` | timestamptz nullable | 被请求中断时间 |
|
|||
|
|
| `interrupt_reason` | varchar(64) | `collect_now_preempt` 等 |
|
|||
|
|
| `enqueued_at` | timestamptz | 入队时间(aging 用) |
|
|||
|
|
|
|||
|
|
**新增唯一约束**(防止双写):
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE UNIQUE INDEX uq_desktop_tasks_monitor_active
|
|||
|
|
ON desktop_tasks (monitor_task_id)
|
|||
|
|
WHERE kind = 'monitor' AND status IN ('queued', 'in_progress', 'unknown');
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
补充说明:
|
|||
|
|
|
|||
|
|
1. `unknown` 视为“未决活跃态”,在 reconcile 前不得为同一 `monitor_task_id` 再创建新的 active monitor 执行任务。
|
|||
|
|
2. 只有当 `unknown` 被人工或自动对账收敛为 `retry / failed / succeeded / aborted` 后,才允许重新派发。
|
|||
|
|
|
|||
|
|
### 7.3 priority 值域规范(W3)
|
|||
|
|
|
|||
|
|
为避免 monitor 与 publish 共表时优先级错配,严格划分值域:
|
|||
|
|
|
|||
|
|
| kind | lane | priority 范围 | lane_weight | 典型值 |
|
|||
|
|
| --- | --- | --- | --- | --- |
|
|||
|
|
| publish | urgent | 20000–29999 | 100 | 25000 |
|
|||
|
|
| publish | high | 15000–19999 | 90 | 18000 |
|
|||
|
|
| publish | normal | 10000–14999 | 80 | 12000 |
|
|||
|
|
| monitor | high(collect-now) | 5000–5999 | 70 | 5000 |
|
|||
|
|
| monitor | normal_boosted(aged) | 1000–4999 | 50 | 1000~4999 |
|
|||
|
|
| monitor | normal | 100–999 | 40 | 100 |
|
|||
|
|
| monitor | retry | 10–99 | 20 | 50 |
|
|||
|
|
|
|||
|
|
**统一排序规则**:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
ORDER BY lane_weight DESC, priority DESC, enqueued_at ASC
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
保证"优先级跨 lane 不能越级"——monitor 的 high (5000) 永远不会压过 publish 的 normal (10000),publish 始终拥有最高话语权。
|
|||
|
|
|
|||
|
|
### 7.4 手动请求表 `monitoring_collect_requests`
|
|||
|
|
|
|||
|
|
| 字段 | 类型 | 说明 |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| `id` | uuid | 请求 ID |
|
|||
|
|
| `tenant_id` | bigint | 租户 |
|
|||
|
|
| `workspace_id` | bigint | 工作空间 |
|
|||
|
|
| `brand_id` | bigint | 品牌 |
|
|||
|
|
| `keyword_id` | bigint nullable | 可选关键词 |
|
|||
|
|
| `requested_by_user_id` | bigint | 发起人 |
|
|||
|
|
| `target_client_id` | uuid nullable | 本次绑定 client(可能解绑) |
|
|||
|
|
| `scope_hash` | varchar(64) | 幂等用,见 §10.6 |
|
|||
|
|
| `request_scope` | jsonb | 选择了哪些问题/平台 |
|
|||
|
|
| `status` | varchar(30) | `accepted` / `dispatching` / `running` / `partial` / `succeeded` / `failed` / `timed_out` / `superseded` |
|
|||
|
|
| `requested_task_count` | int | 目标任务数 |
|
|||
|
|
| `dispatched_task_count` | int | 已派发任务数 |
|
|||
|
|
| `completed_task_count` | int | 已完成任务数 |
|
|||
|
|
| `first_dispatched_at` | timestamptz nullable | 第一条派发时间 |
|
|||
|
|
| `first_result_at` | timestamptz nullable | 第一条结果时间 |
|
|||
|
|
| `ttl_expires_at` | timestamptz | TTL,默认创建后 5 分钟 |
|
|||
|
|
|
|||
|
|
作用:
|
|||
|
|
|
|||
|
|
1. 给 UI 返回"这次立即采集是否真的在推进"。
|
|||
|
|
2. 支撑重复点击幂等。
|
|||
|
|
3. 支撑超时告警与链路追踪。
|
|||
|
|
|
|||
|
|
## 8. 状态机设计
|
|||
|
|
|
|||
|
|
### 8.1 业务任务状态机
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
pending
|
|||
|
|
-> (dispatch) -> (desktop lease+run+callback) -> completed
|
|||
|
|
|
|||
|
|
pending
|
|||
|
|
-> (dispatch+lease timeout) -> expired -> retry -> pending
|
|||
|
|
|
|||
|
|
pending (被抢占场景,Phase 2)
|
|||
|
|
-> (promote to high lane) -> (interrupt old execution) -> (new execution completed) -> completed
|
|||
|
|
|
|||
|
|
pending
|
|||
|
|
-> failed (超过 max_retry)
|
|||
|
|
|
|||
|
|
pending
|
|||
|
|
-> skipped (配额/规则排除)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
约束:
|
|||
|
|
|
|||
|
|
1. 同一业务日同一问题同一平台只保留一条业务任务,不复制第二套任务。
|
|||
|
|
2. `collect-now` 只提升优先级、刷新调度窗口、触发抢占,不再复制业务任务。
|
|||
|
|
3. 最终结果事实仍归并到同一业务任务。
|
|||
|
|
4. 当 `execution_owner='desktop_tasks'` 时,业务任务在执行全过程保持 `pending`,直到最终终态才跳到 `completed / failed / skipped / expired`;`leased / received` 不再写入业务表。
|
|||
|
|
|
|||
|
|
### 8.2 执行任务状态机(Phase 2 起)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
queued
|
|||
|
|
-> in_progress
|
|||
|
|
-> succeeded
|
|||
|
|
|
|||
|
|
queued
|
|||
|
|
-> aborted (Phase 1/2 均支持:直接取消)
|
|||
|
|
|
|||
|
|
in_progress
|
|||
|
|
-> (task_control: interrupt_requested with gen) -> (desktop safe point) -> aborted (Phase 2)
|
|||
|
|
|
|||
|
|
in_progress
|
|||
|
|
-> failed | unknown | succeeded
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
补充约束:
|
|||
|
|
|
|||
|
|
1. `unknown` 在 reconcile 前视为活跃执行态,与 `queued / in_progress` 一样阻止同一 `monitor_task_id` 生成新的 active monitor 执行任务。
|
|||
|
|
2. `unknown` 不是可长期堆积的常态,必须通过 `ReconcileDesktopTask` 收敛回 `queued / failed / succeeded / aborted`。
|
|||
|
|
|
|||
|
|
## 9. 调度与优先级设计
|
|||
|
|
|
|||
|
|
### 9.1 定时调度策略
|
|||
|
|
|
|||
|
|
`scheduler` 不直接把所有到期任务一股脑发出,而是分成三步:
|
|||
|
|
|
|||
|
|
1. 扫描:找出 `next_run_at <= now()` 的计划任务。
|
|||
|
|
2. claim:把到期任务转成 `monitoring_collect_tasks`(设置 `dispatch_lane='normal'`、`dispatch_priority=100`、`execution_owner` 按灰度规则)。
|
|||
|
|
3. dispatch:由 `monitor dispatch coordinator` 按预算从 `monitoring_collect_tasks` 中选择一小批执行任务下发。
|
|||
|
|
|
|||
|
|
### 9.2 dispatch 预算决策树(C5)
|
|||
|
|
|
|||
|
|
当 dispatch coordinator 决定是否派发一条任务时,按下列顺序串行检查,**任一失败则当前 task 跳过**:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
dispatch_allowed(task) iff
|
|||
|
|
1. cluster.minute_budget.remaining > 0
|
|||
|
|
2. tenant(task.tenant_id).minute_budget.remaining > 0
|
|||
|
|
3. client(task.target_client_id).concurrent_monitor < client.max_concurrent
|
|||
|
|
4. client(task.target_client_id).queue_depth < client.max_queue_depth
|
|||
|
|
5. platform(task.ai_platform_id).concurrent < platform.max_concurrent
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**拒绝处置矩阵**:
|
|||
|
|
|
|||
|
|
| 拒绝维度 | 处置 |
|
|||
|
|
| --- | --- |
|
|||
|
|
| budget 类(1、2) | 暂留 ready 队列,下一分钟预算窗口重试 |
|
|||
|
|
| concurrent 类(3、5) | 暂留 ready 队列,等待 `completion` 事件驱动重试 |
|
|||
|
|
| queue depth 类(4) | 选择次优 client(`target_client_id=NULL` 的任务);若指定 client 饱和且 `target_client_id` 非空,暂留 |
|
|||
|
|
|
|||
|
|
**预算值建议**(可配置,热更新):
|
|||
|
|
|
|||
|
|
| 维度 | 初始值 | 熔断阈值 | 说明 |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| 集群每分钟派发上限 | 300 | 500 | 保护 ingest 链路 |
|
|||
|
|
| 单租户每分钟上限 | 30 | 60 | 公平性 |
|
|||
|
|
| 单 client monitor 并发 | 2 | 4 | 浏览器资源 |
|
|||
|
|
| 单 client 待执行队列深度 | 20 | 40 | 避免堆积失真 |
|
|||
|
|
| 单平台并发(跨 client 聚合) | 1 | 2 | 登录态/风控保护 |
|
|||
|
|
|
|||
|
|
**high lane 绕过规则**:
|
|||
|
|
|
|||
|
|
- `dispatch_lane='high'` 的任务**绕过** 2、4 两项(租户预算、队列深度)
|
|||
|
|
- 仍受 1、3、5 约束(集群预算、client 浏览器并发、平台风控)
|
|||
|
|
- 保障 collect-now 体验同时不压垮基础设施
|
|||
|
|
|
|||
|
|
**耦合说明**:
|
|||
|
|
|
|||
|
|
- 集群预算 > 所有租户预算之和,预防极端峰值
|
|||
|
|
- 单 client 并发仅对 **monitor kind** 生效,不含 publish(publish 有独立通道)
|
|||
|
|
- 单平台并发跨 client 聚合计数(例如 OpenAI 在 client A 并发 1 + client B 并发 0 = 平台并发 1)
|
|||
|
|
|
|||
|
|
### 9.3 lane 调度规则
|
|||
|
|
|
|||
|
|
推荐调度顺序:
|
|||
|
|
|
|||
|
|
1. `high` 非空时,优先耗尽 `high`
|
|||
|
|
2. `high` 非空时,暂停向**同一 client** 派发新的 `normal`(其他 client 上的 normal 不受影响)
|
|||
|
|
3. 全局仍保留 10%~20% 的 `normal` 背景吞吐,防止定时任务永久饿死
|
|||
|
|
4. `retry` 只在 `high` 与 `normal` 都低水位时进入
|
|||
|
|
|
|||
|
|
### 9.4 aging 提升(百分位 + 对数衰减,W2)
|
|||
|
|
|
|||
|
|
简单阶梯加分在批量积压时会**集体失效**(所有 normal 都 +50 等于没有 aging)。改为百分位触发式 aging:
|
|||
|
|
|
|||
|
|
**触发条件**:每 30s 扫描一次 `normal` lane ready 列表:
|
|||
|
|
|
|||
|
|
1. 按 `enqueued_at` 升序排序
|
|||
|
|
2. 选出等待时间最长的 **top 10%** 任务
|
|||
|
|
3. 对这批任务执行 `priority += agingBoost(wait_time)`,`dispatch_lane = 'normal_boosted'`
|
|||
|
|
|
|||
|
|
**boost 函数**:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func agingBoost(waitMinutes float64) int {
|
|||
|
|
// 对数衰减:10min +50, 30min +85, 60min +115, 120min +141
|
|||
|
|
return int(50 * math.Log2(1 + waitMinutes/5))
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**上限保护**:
|
|||
|
|
|
|||
|
|
- 单任务 aging 累计不超过 `+900`(保证 `normal_boosted` priority 永远 `< 5000`,不跨入 high 区)
|
|||
|
|
- 若 aging 后 priority 达到 `normal_boosted` 上限,进入 `normal_boosted` lane 并停止继续 aging
|
|||
|
|
- 观测指标 `monitor_normal_starvation_age_p99` 暴露 normal 最长等待时间
|
|||
|
|
|
|||
|
|
## 10. collect-now 抢占设计
|
|||
|
|
|
|||
|
|
### 10.1 抢占范围
|
|||
|
|
|
|||
|
|
默认只抢占以下任务:
|
|||
|
|
|
|||
|
|
1. 同一 `target_client_id`
|
|||
|
|
2. `kind=monitor`
|
|||
|
|
3. `source=daily_schedule`
|
|||
|
|
4. `status in (queued, in_progress)`(Phase 1 仅 `queued`;Phase 2 起含 `in_progress`)
|
|||
|
|
|
|||
|
|
不抢占:
|
|||
|
|
|
|||
|
|
1. publish 任务
|
|||
|
|
2. 其他用户或其他 client 的 monitor 任务
|
|||
|
|
3. 已接近完成且无法安全中断的任务
|
|||
|
|
|
|||
|
|
### 10.2 interrupt_generation 规范(C2)
|
|||
|
|
|
|||
|
|
**定义**:`interrupt_generation` 归属 `desktop_tasks`(Phase 2)或 `monitoring_collect_tasks`(Phase 1),每次 collect-now 针对该 client 原子递增。
|
|||
|
|
|
|||
|
|
**递增操作**(单次 UPDATE):
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- Phase 2 示例
|
|||
|
|
UPDATE desktop_tasks
|
|||
|
|
SET interrupt_generation = interrupt_generation + 1
|
|||
|
|
WHERE target_client_id = $1
|
|||
|
|
AND kind = 'monitor'
|
|||
|
|
AND status IN ('queued', 'in_progress')
|
|||
|
|
RETURNING id, interrupt_generation;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**下发约束**:
|
|||
|
|
|
|||
|
|
1. `task_control` 事件必须携带 `generation`
|
|||
|
|
2. `task_available`(high lane)事件必须携带 `generation`
|
|||
|
|
3. dispatch coordinator 发出 `abort` 指令时必须带当次 generation
|
|||
|
|
|
|||
|
|
**desktop 执行规则**:
|
|||
|
|
|
|||
|
|
1. 本地维护 per-task `last_seen_generation`
|
|||
|
|
2. 仅执行 `event.generation >= local.last_seen_generation` 的指令
|
|||
|
|
3. 收到指令后更新 `last_seen_generation`
|
|||
|
|
|
|||
|
|
**回写约束**:
|
|||
|
|
|
|||
|
|
desktop 回写 `aborted(preempted)` 时必须带产生 abort 的 generation;服务端比对:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
if callback.generation < currentTask.interrupt_generation {
|
|||
|
|
// 旧 abort 指令迟到,忽略本次回写并下发新的 abort
|
|||
|
|
log.Warn("stale abort callback", callback.generation, currentTask.interrupt_generation)
|
|||
|
|
publishInterruptSignal(currentTask, currentTask.interrupt_generation)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**双击场景保护**:
|
|||
|
|
|
|||
|
|
用户双击 collect-now 产生两次请求时:
|
|||
|
|
|
|||
|
|
- 第一次递增 gen 从 N → N+1
|
|||
|
|
- 第二次递增 gen 从 N+1 → N+2
|
|||
|
|
- desktop 只会执行 gen=N+2 的指令(覆盖 N+1),第一次 abort 自然失效
|
|||
|
|
- 两次 collect_request 通过 `superseded_by_request_id` 形成引用链,第一次 request 标记为 `superseded`
|
|||
|
|
|
|||
|
|
### 10.3 抢占流程
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
用户点击 collect-now
|
|||
|
|
-> 幂等检查(scope_hash + 5min TTL)
|
|||
|
|
-> 短事务:create collect_request, increment gen, promote business tasks to high, write outbox
|
|||
|
|
-> 提交事务
|
|||
|
|
-> outbox pump 投递 dispatch signal (monitor.high.{client_id}) + interrupt signal (task_control)
|
|||
|
|
-> tenant-api dispatch hub 收到后 WebSocket 推送给 desktop
|
|||
|
|
-> desktop 在安全点中断 running 任务(Phase 2)或在下次 pull 时发现租约失效(Phase 1)
|
|||
|
|
-> desktop 回写 aborted(preempted) with generation
|
|||
|
|
-> desktop 立即领取 high lane monitor 任务
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 10.4 安全点(Phase 2 起)
|
|||
|
|
|
|||
|
|
desktop client 必须只在安全点响应抢占:
|
|||
|
|
|
|||
|
|
1. 打开平台页面前
|
|||
|
|
2. 输入问题前
|
|||
|
|
3. 提交请求前
|
|||
|
|
4. 收到原始回答后、结构化解析前
|
|||
|
|
5. 单次问题执行结束后
|
|||
|
|
|
|||
|
|
不要在以下阶段强杀:
|
|||
|
|
|
|||
|
|
1. 平台登录态关键跳转中
|
|||
|
|
2. 人机验证流程中
|
|||
|
|
3. 页面提交后等待平台响应但浏览器状态不确定时
|
|||
|
|
|
|||
|
|
**测量**:每个安全点埋点 `safe_point_interval_ms`,用于 §10.5 动态调参。
|
|||
|
|
|
|||
|
|
### 10.5 安全中断超时策略(参数化,C4)
|
|||
|
|
|
|||
|
|
**参数化原则**:硬编码的 15s/60s 无法适应不同平台的响应差异,改为 per-platform 配置:
|
|||
|
|
|
|||
|
|
| 平台 | 安全点 P95 | `interrupt_soft_timeout` | `interrupt_hard_timeout` |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| OpenAI 快答 | 8s | 15s | 45s |
|
|||
|
|
| OpenAI 深度推理 | 40s | 60s | 180s |
|
|||
|
|
| Gemini | 20s | 30s | 90s |
|
|||
|
|
| Perplexity | 30s | 45s | 120s |
|
|||
|
|
| 其他 | 动态测量 | max(P95 × 1.5, 15s) | soft × 3 |
|
|||
|
|
|
|||
|
|
**分级策略**:
|
|||
|
|
|
|||
|
|
1. `soft_timeout` 内未中断:禁止向该 client 派发新 normal,high 任务仍然等待
|
|||
|
|
2. `soft_timeout` 到 `hard_timeout`:允许当前任务自然结束,结束后立即领 high
|
|||
|
|
3. 超过 `hard_timeout`:触发告警 + 自动 reconcile(强制 cancel 租约、high 任务转派其他 client 或降级 web 端提示)
|
|||
|
|
|
|||
|
|
**测量落地**:
|
|||
|
|
|
|||
|
|
- 每个安全点埋点 `safe_point_interval_ms`
|
|||
|
|
- 每周基于 P95 自动更新各平台 `interrupt_soft_timeout` 参数
|
|||
|
|
- 参数热更新,无需重启服务
|
|||
|
|
|
|||
|
|
### 10.6 target_client_id 绑定策略(W1)
|
|||
|
|
|
|||
|
|
**绑定时机**:collect-now 接受请求时立刻选择 target client。
|
|||
|
|
|
|||
|
|
**选择规则**(按优先级):
|
|||
|
|
|
|||
|
|
1. 请求体指定 `target_client_id` 且该 client **在线 + 健康** → 使用指定 client
|
|||
|
|
2. 未指定或指定不可用 → 在当前用户 `tenant_id / workspace_id` 下的在线 client 中按下列权重选:
|
|||
|
|
- 最近心跳 < 30s(必要条件)
|
|||
|
|
- monitor 并发余量最大
|
|||
|
|
- 最近 1 分钟 abort 率最低
|
|||
|
|
- 任一平台登录态健康
|
|||
|
|
|
|||
|
|
**漂移处理矩阵**:
|
|||
|
|
|
|||
|
|
| 场景 | 策略 |
|
|||
|
|
| --- | --- |
|
|||
|
|
| 用户多设备同时在线 | 按上述权重选一个,不广播;`collect_request.target_client_id` 记录选择结果 |
|
|||
|
|
| 绑定后 client 掉线(30s 无心跳) | 解绑:业务任务 `target_client_id = NULL`,`lane='high'` 不变,任一在线 client 可领 |
|
|||
|
|
| 用户换设备登录 | 收到新 client 心跳后,正在执行的 collect_request 不迁移;新提交的 collect-now 绑定新 client |
|
|||
|
|
| 所有 client 掉线 | collect_request 保持 `accepted`,TTL 5 分钟;5 分钟内有 client 上线则重新 dispatch;超时转 `timed_out` |
|
|||
|
|
|
|||
|
|
**失败边界**:
|
|||
|
|
|
|||
|
|
- collect_request `ttl_expires_at` = 创建时间 + 5 分钟
|
|||
|
|
- `accepted` 状态下无任何 dispatch 动作超过 30s 触发预警
|
|||
|
|
- 同一 `(user_id, brand_id, scope_hash)` 5 分钟内重复 collect-now 返回上一次的 `request_id`(幂等复用)
|
|||
|
|
|
|||
|
|
## 11. RabbitMQ 拓扑设计
|
|||
|
|
|
|||
|
|
### 11.1 下行 dispatch
|
|||
|
|
|
|||
|
|
复用现有 `desktop.task.dispatch` topic exchange,建议约定路由键:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
monitor.high.{client_id}
|
|||
|
|
monitor.normal.{client_id}
|
|||
|
|
monitor.retry.{client_id}
|
|||
|
|
publish.{client_id} (已存在)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
说明:
|
|||
|
|
|
|||
|
|
1. tenant-api 实例通过临时队列消费所有 `monitor.*` 与 `publish.*`(现有绑定 `publish.*`,Phase 2 新增 `monitor.*`)
|
|||
|
|
2. dispatch hub 再按 `target_client_id` 过滤并推给对应 websocket
|
|||
|
|
3. desktop client 不需要感知 RabbitMQ,只消费 websocket 事件即可
|
|||
|
|
|
|||
|
|
### 11.2 上行结果
|
|||
|
|
|
|||
|
|
保留现有结果 ingest 链路(已落地):
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
monitor.result.ingest
|
|||
|
|
monitor.result.ingest.dlq
|
|||
|
|
monitor.projection.rebuild (已存在并带 coalesce)
|
|||
|
|
monitor.projection.rebuild.dlq
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Phase 3 增强:
|
|||
|
|
|
|||
|
|
1. `monitor.result.ingest` 按 `ingest_shard_key` 分片串行消费(见 §12.2)
|
|||
|
|
2. projection worker 已支持 coalesce,同一个 `(tenant_id, brand_id, business_date)` 只保留一条重算请求
|
|||
|
|
|
|||
|
|
### 11.3 多队列而非单队列 priority
|
|||
|
|
|
|||
|
|
推荐:
|
|||
|
|
|
|||
|
|
1. `monitor.dispatch.high`
|
|||
|
|
2. `monitor.dispatch.normal`
|
|||
|
|
3. `monitor.dispatch.retry`
|
|||
|
|
|
|||
|
|
不推荐仅依赖单队列 `x-max-priority`。原因:
|
|||
|
|
|
|||
|
|
1. 单队列 priority 在消息堆积很深时观测和治理不清晰
|
|||
|
|
2. 多队列更容易做 pause、drain、限流和运维看板
|
|||
|
|
3. 更符合 lane 化流量治理方式
|
|||
|
|
|
|||
|
|
## 12. 结果回写与有序落库
|
|||
|
|
|
|||
|
|
### 12.1 写链路拆分
|
|||
|
|
|
|||
|
|
写链路分成两层(现状已落地):
|
|||
|
|
|
|||
|
|
1. 原始事实层:`question_monitor_runs`、`question_monitor_parse_results`、`monitoring_citation_facts`
|
|||
|
|
2. 聚合投影层:`monitoring_brand_daily`、`monitoring_brand_platform_daily`
|
|||
|
|
|
|||
|
|
### 12.2 分片串行(Phase 3 落地)
|
|||
|
|
|
|||
|
|
不建议全局单线程顺序落库。
|
|||
|
|
|
|||
|
|
**主选分片键**(唯一):
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
shard_key = hash(tenant_id, brand_id, business_date) % N
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
选择理由:与现有 `MonitoringProjectionRebuildQueue` coalesce 键对齐,避免同一投影维度上出现 ingest 与 projection 错位。**去掉** V1 中的备选 `(tenant_id, question_id, business_date)`,统一一个维度。
|
|||
|
|
|
|||
|
|
规则:
|
|||
|
|
|
|||
|
|
1. 同 shard 内串行消费,保证局部有序
|
|||
|
|
2. 不同 shard 并行消费,保证总体吞吐
|
|||
|
|
3. 原始事实 upsert 幂等即可,不追求全局顺序
|
|||
|
|
4. 聚合快照按 `(tenant_id, brand_id, business_date)` 做局部重算与覆盖写
|
|||
|
|
|
|||
|
|
### 12.3 幂等键体系(与现有表对齐,W7)
|
|||
|
|
|
|||
|
|
| 幂等维度 | 键 | 对应现有约束 |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| 业务任务幂等 | `(tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date)` | `monitoring_collect_tasks` 现有唯一索引 |
|
|||
|
|
| 执行任务幂等 | `(monitor_task_id)` WHERE status IN (queued,in_progress,unknown) | `desktop_tasks` Phase 2 新增唯一索引(见 §7.2) |
|
|||
|
|
| 原始 run 入库幂等 | `(tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date)` | `question_monitor_runs.uk_monitor_run_idempotent` |
|
|||
|
|
| parse 结果入库幂等 | `(run_id, citation_fingerprint)` | `question_monitor_parse_results` / `monitoring_citation_facts` 现有约束 |
|
|||
|
|
| projection 重算幂等 | `(tenant_id, brand_id, business_date)` | `MonitoringProjectionRebuildQueue` 现有 coalesce 键(未含 collector_type) |
|
|||
|
|
|
|||
|
|
**注意**:
|
|||
|
|
|
|||
|
|
1. projection 重算 coalesce 键统一为 `(tenant_id, brand_id, business_date)`,V1 文档中提及的 `collector_type` 维度**不纳入**,与现有实现对齐。
|
|||
|
|
2. `question_monitor_runs` 当前没有 `task_id / attempt_id` 字段;attempt 级诊断信息仍在 `desktop_task_attempts`。如果后续要做 attempt 级 monitoring run 回放,需要单独迁移 `question_monitor_runs` 结构,不能假设现状已支持。
|
|||
|
|
3. `question_monitor_runs` 与业务任务共用同一业务键,语义是“同一业务日同一问题同一平台只保留一条最新有效 run”,而不是保留 attempt 级历史。
|
|||
|
|
4. `question_monitor_runs` 的 upsert 语义必须固定为“成功结果优先、最后一次有效结果生效”。列名以下仅为示意,实际实现按真实 schema 对应:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
INSERT INTO question_monitor_runs (...)
|
|||
|
|
VALUES (...)
|
|||
|
|
ON CONFLICT (tenant_id, brand_id, question_id, ai_platform_id, collector_type, run_mode, business_date)
|
|||
|
|
DO UPDATE SET
|
|||
|
|
result = EXCLUDED.result,
|
|||
|
|
status = EXCLUDED.status,
|
|||
|
|
updated_at = now()
|
|||
|
|
WHERE question_monitor_runs.status = 'failed'
|
|||
|
|
OR EXCLUDED.status = 'succeeded';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
5. 上述语义等价于:
|
|||
|
|
- 新 `succeeded` 可覆盖旧 `failed`,也可覆盖旧 `succeeded`(最后一次成功结果生效)
|
|||
|
|
- 新 `failed` 只能覆盖旧 `failed`,不得覆盖已存在的 `succeeded`
|
|||
|
|
- 失败 attempt 的诊断链路保留在 `desktop_task_attempts`,不在 `question_monitor_runs` 中展开
|
|||
|
|
|
|||
|
|
### 12.4 落库保护
|
|||
|
|
|
|||
|
|
当出现以下任一情况时,自动触发写链路降速:
|
|||
|
|
|
|||
|
|
1. ingest queue lag 超过阈值(默认 30s)
|
|||
|
|
2. projection queue lag 超过阈值(默认 60s)
|
|||
|
|
3. Monitoring PG 写入延迟明显升高(P95 > 500ms)
|
|||
|
|
4. 事务冲突率升高(> 5%)
|
|||
|
|
|
|||
|
|
降速策略:
|
|||
|
|
|
|||
|
|
1. 暂停 `normal` dispatch
|
|||
|
|
2. 保留 `high` lane
|
|||
|
|
3. 将 projection rebuild 改为批次合并
|
|||
|
|
|
|||
|
|
## 13. API 与协议设计
|
|||
|
|
|
|||
|
|
### 13.1 collect-now
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
POST /api/tenant/monitoring/brands/{brand_id}/collect-now
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
"keyword_id": 123,
|
|||
|
|
"preempt": true,
|
|||
|
|
"wait_for_first_dispatch": false,
|
|||
|
|
"target_client_id": "optional-uuid"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
返回建议:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"request_id": "uuid",
|
|||
|
|
"collection_mode": "desktop",
|
|||
|
|
"target_client_id": "uuid",
|
|||
|
|
"affected_task_count": 12,
|
|||
|
|
"promoted_task_count": 12,
|
|||
|
|
"aborted_queued_count": 4,
|
|||
|
|
"interrupt_requested_count": 2,
|
|||
|
|
"interrupt_generation": 7,
|
|||
|
|
"ttl_expires_at": "2026-04-20T10:05:00Z",
|
|||
|
|
"message": "已提升为高优先级并请求抢占当前定时采集任务"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**`collection_mode` 字段说明**:浏览器插件硬切换下线后,该字段恒为 `"desktop"`,保留仅用于日志审计与未来多端扩展。前端可忽略此字段的分支逻辑。
|
|||
|
|
|
|||
|
|
### 13.2 desktop dispatch event
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"type": "task_available",
|
|||
|
|
"kind": "monitor",
|
|||
|
|
"task_id": "uuid",
|
|||
|
|
"target_client_id": "uuid",
|
|||
|
|
"priority": 5000,
|
|||
|
|
"lane": "high",
|
|||
|
|
"status": "queued",
|
|||
|
|
"scheduler_group_key": "qid:123",
|
|||
|
|
"business_date": "2026-04-20",
|
|||
|
|
"interrupt_generation": 7,
|
|||
|
|
"updated_at": "2026-04-20T10:00:00Z"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 13.3 desktop control event
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"type": "task_control",
|
|||
|
|
"kind": "monitor",
|
|||
|
|
"task_id": "uuid",
|
|||
|
|
"target_client_id": "uuid",
|
|||
|
|
"control": "interrupt_requested",
|
|||
|
|
"reason": "collect_now_preempt",
|
|||
|
|
"interrupt_generation": 7,
|
|||
|
|
"replacement_task_id": "uuid",
|
|||
|
|
"updated_at": "2026-04-20T10:00:05Z"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 13.4 desktop 中断确认
|
|||
|
|
|
|||
|
|
优先复用现有 cancel 能力:
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
POST /api/desktop/tasks/{task_id}/cancel
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
"lease_token": "xxx",
|
|||
|
|
"reason": "collect_now_preempt",
|
|||
|
|
"interrupt_generation": 7
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
若后续希望保留更细致的中断原因,可新增:
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
POST /api/desktop/tasks/{task_id}/interrupt-ack
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 13.5 立即采集请求查询
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
GET /api/tenant/monitoring/collect-requests/{request_id}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
用于前端轮询:
|
|||
|
|
|
|||
|
|
1. 是否已经抢占成功
|
|||
|
|
2. 是否已开始执行
|
|||
|
|
3. 是否已出现第一条结果
|
|||
|
|
4. 当前状态与倒计时 TTL
|
|||
|
|
|
|||
|
|
## 14. desktop client 设计要求
|
|||
|
|
|
|||
|
|
### 14.1 调度优先级
|
|||
|
|
|
|||
|
|
desktop client 本地调度规则:
|
|||
|
|
|
|||
|
|
1. `publish` 继续保持最高优先级,不被 monitoring 抢占。
|
|||
|
|
2. `monitor.high` 高于 `monitor.normal_boosted` 高于 `monitor.normal` 高于 `monitor.retry`
|
|||
|
|
3. 收到 `interrupt_requested` 时,正在运行的 `monitor.normal` 必须让路给 `monitor.high`(Phase 2 起)
|
|||
|
|
4. Phase 1 desktop 仍以 pull 为主;Phase 2 迁至 push + lease
|
|||
|
|
|
|||
|
|
### 14.2 本地队列
|
|||
|
|
|
|||
|
|
本地 monitor scheduler 需要新增:
|
|||
|
|
|
|||
|
|
1. `lane`
|
|||
|
|
2. `priority`
|
|||
|
|
3. `interruptRequested`
|
|||
|
|
4. `interruptDeadline`
|
|||
|
|
5. `interruptGeneration`(与服务端同步的代际号)
|
|||
|
|
6. `executionPhase`(精确定义“是否已开始执行”,见下表)
|
|||
|
|
|
|||
|
|
`executionPhase` 建议统一枚举:
|
|||
|
|
|
|||
|
|
| 值 | 说明 | Phase 1 active lease 行为 | Phase 2 行为 |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| `PREPARING` | 浏览器 context / page 尚未初始化完成 | 可在下一次串行检查时 cancel lease | 可立即进入中断处理 |
|
|||
|
|
| `NAVIGATING` | 正在打开 AI 平台页面 | 可在下一次串行检查时 cancel lease | 可立即进入中断处理 |
|
|||
|
|
| `AUTHENTICATING` | 登录态校验 / 验证流程 | 视为已开始,不再直接 cancel | 等待下一个安全点 |
|
|||
|
|
| `INPUTTING` | 输入问题 | 视为已开始,不再直接 cancel | 等待下一个安全点 |
|
|||
|
|
| `SUBMITTING` | 提交请求 | 视为已开始,不再直接 cancel | 等待下一个安全点 |
|
|||
|
|
| `WAITING` | 等待 AI 回答 | 视为已开始,不再直接 cancel | 等待下一个安全点 |
|
|||
|
|
| `PARSING` | 结构化解析 | 视为已开始,不再直接 cancel | 等待下一个安全点 |
|
|||
|
|
| `POSTING` | 回写结果 | 视为已开始,不再直接 cancel | 等待下一个安全点 |
|
|||
|
|
|
|||
|
|
本文中“尚未真正开始执行”严格定义为 `PREPARING | NAVIGATING`;自 `AUTHENTICATING` 起统一视为“已开始”。
|
|||
|
|
|
|||
|
|
状态推进与 `interruptRequested / interruptGeneration` 的更新,必须运行在同一个 desktop scheduler 单线程 loop 或同一把互斥锁内,不能分散在不同线程各自修改。
|
|||
|
|
|
|||
|
|
本地排序建议:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
lane_weight DESC
|
|||
|
|
priority DESC
|
|||
|
|
availableAt ASC
|
|||
|
|
enqueuedAt ASC
|
|||
|
|
updatedAt ASC
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 14.3 Phase 1 / Phase 2 通道策略(SSE 早期下线)
|
|||
|
|
|
|||
|
|
| 阶段 | `task_available` 下发通道 | `task_control` 下发通道 | SSE 状态 |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| Phase 1 | **WebSocket** (`desktop.task.dispatch`) 复用 publish 通道 | **WebSocket** 同通道 | **Phase 1 完成即下线** |
|
|||
|
|
| Phase 2 | WebSocket(新增 running 抢占事件) | WebSocket | 已移除 |
|
|||
|
|
| Phase 3 | WebSocket | WebSocket | 已移除 |
|
|||
|
|
|
|||
|
|
**Phase 1 直接启用 WebSocket 的前提**(代码核实):
|
|||
|
|
|
|||
|
|
1. `desktop.task.dispatch` exchange 已存在并在 publish 场景生产使用
|
|||
|
|
2. `PublishDesktopDispatch` 发布端已存在,Phase 1 扩展支持 `monitor.*` 路由键
|
|||
|
|
3. tenant-api dispatch hub 已支持 WebSocket push,Phase 1 新增订阅 `monitor.*` binding key
|
|||
|
|
4. desktop client WebSocket 连接已存在(publish 场景),Phase 1 新增 `kind=monitor` 的事件 handler
|
|||
|
|
|
|||
|
|
**Phase 1 WebSocket 事件扩展**:
|
|||
|
|
|
|||
|
|
1. `task_available` 事件新增 `kind=monitor` 分支(见 §13.2)
|
|||
|
|
2. 新增 `task_control` 事件(见 §13.3),承载抢占指令
|
|||
|
|
3. Phase 1 desktop 在 `task_available` 到来时仍以 pull-lease 确认任务归属(WebSocket 仅作为 dispatch 信号通知,lease 协议保持 pull 兼容),Phase 2 起改为 push-lease
|
|||
|
|
|
|||
|
|
**SSE 下线收尾**(Phase 1 上线前置工作):
|
|||
|
|
|
|||
|
|
1. 代码层面:`server/internal/tenant/transport/desktop_events_handler.go` 移除路由注册
|
|||
|
|
2. 代码层面:desktop-client 移除 `SseClient` 类、`api-client.ts` 相关 `sseConnected` 状态、`runtime-controller.ts` 的 SSE 启动调用
|
|||
|
|
3. 路由层面:`/api/desktop/events` 返回 410 Gone 一个版本周期后彻底移除
|
|||
|
|
4. 观测层面:SSE 连接数指标下线,改看 WebSocket 连接数
|
|||
|
|
|
|||
|
|
### 14.4 中断实现与串行化规则(Phase 1 active lease / Phase 2 running 通用)
|
|||
|
|
|
|||
|
|
desktop client 需要:
|
|||
|
|
|
|||
|
|
1. 监听 `task_control` 后,必须把处理投递到与主执行循环同一个 scheduler loop(或持有同一把互斥锁)的串行上下文中。
|
|||
|
|
2. 在该串行上下文内先比对 `interrupt_generation`,再原子写入 `interruptRequested=true / interruptGeneration / interruptReason`。
|
|||
|
|
3. `task_control` 处理线程**禁止**直接调用 cancel API;真正的 cancel / abort 只能由主执行循环在下一个检查点统一发出。
|
|||
|
|
4. Phase 1 中,若串行检查时 `executionPhase in (PREPARING, NAVIGATING)`,则发送 `cancel lease`;若已进入 `AUTHENTICATING` 及之后阶段,则忽略本次中断并等待自然完成。
|
|||
|
|
5. Phase 2 中,若任务已经开始执行,则在下一个安全点处理 `interruptRequested`,回写 `aborted(preempted)` 并携带 `interrupt_generation`。
|
|||
|
|
6. 中断完成后立刻触发下一轮 `pumpExecutionLoop()`,优先消费 `monitor.high`。
|
|||
|
|
|
|||
|
|
## 15. 可观测性与风控
|
|||
|
|
|
|||
|
|
### 15.1 关键指标(含新增)
|
|||
|
|
|
|||
|
|
至少监控以下指标:
|
|||
|
|
|
|||
|
|
**现有指标**:
|
|||
|
|
|
|||
|
|
1. `collect_now_accept_count`
|
|||
|
|
2. `collect_now_first_dispatch_latency_ms`
|
|||
|
|
3. `collect_now_first_result_latency_ms`
|
|||
|
|
4. `monitor_dispatch_high_backlog`
|
|||
|
|
5. `monitor_dispatch_normal_backlog`
|
|||
|
|
6. `monitor_result_ingest_lag_ms`
|
|||
|
|
7. `monitor_projection_rebuild_lag_ms`
|
|||
|
|
8. `monitor_preempt_request_count`
|
|||
|
|
9. `monitor_preempt_success_count`
|
|||
|
|
10. `monitor_preempt_timeout_count`
|
|||
|
|
11. `desktop_client_monitor_queue_depth`
|
|||
|
|
12. `monitoring_pg_write_qps`
|
|||
|
|
|
|||
|
|
**V1.1 新增指标**:
|
|||
|
|
|
|||
|
|
1. `collect_now_preempt_induced_retry_count` — 抢占导致定时任务额外重试次数
|
|||
|
|
2. `monitor_normal_starvation_age_p99` — normal lane 最长等待时间
|
|||
|
|
3. `safe_point_interval_ms_p95` — 各平台安全点间隔(per platform 维度)
|
|||
|
|
4. `monitor_dispatch_reject_by_reason_count` — 按拒绝维度分桶(budget/concurrent/queue_depth)
|
|||
|
|
5. `interrupt_generation_mismatch_count` — 旧 generation 被忽略次数(异常监控)
|
|||
|
|
6. `collect_request_ttl_expired_count` — TTL 内未完成派发的请求数
|
|||
|
|
7. `execution_owner_legacy_count` / `execution_owner_desktop_tasks_count` — Phase 2 灰度进度
|
|||
|
|
8. `phase2_reconcile_fixed_count` — reconcile 任务修复数
|
|||
|
|
9. `monitor_dispatch_high_oldest_age_ms` — `high` lane 中最老未派发任务年龄
|
|||
|
|
10. `tenant_monitor_normal_backlog` — 按 tenant 分桶的 `normal` backlog 深度
|
|||
|
|
|
|||
|
|
### 15.2 告警
|
|||
|
|
|
|||
|
|
| 告警 | 指标 | 聚合方式 | 窗口 | 阈值 | 持续时间 |
|
|||
|
|
| --- | --- | --- | --- | --- | --- |
|
|||
|
|
| `high` lane 积压 | `monitor_dispatch_high_oldest_age_ms` | `max` | 1 分钟 | `> 60000ms` | 连续 2 个窗口 |
|
|||
|
|
| `collect-now` 首条结果过慢 | `collect_now_first_result_latency_ms` | `P99` | 5 分钟 | `> 180000ms` | 连续 2 个窗口 |
|
|||
|
|
| projection lag 过高 | `monitor_projection_rebuild_lag_ms` | `max` | 5 分钟 | `> 300000ms` | 连续 2 个窗口 |
|
|||
|
|
| 单 client 抢占超时过多 | `increase(monitor_preempt_timeout_count{client_id})` | `sum by client_id` | 1 小时 | `>= 3` | 立即告警 |
|
|||
|
|
| 单 tenant 的 `normal` backlog 无下降 | `tenant_monitor_normal_backlog{tenant_id}` | `min > 100` 且 `last-first >= 0` | 30 分钟 | 同时满足两条件 | 连续 1 个窗口 |
|
|||
|
|
| generation mismatch 突增 | `increase(interrupt_generation_mismatch_count)` | `sum` | 10 分钟 | `>= 10` | 连续 2 个窗口 |
|
|||
|
|
| Phase 2 reconcile 持续不一致 | `phase2_reconcile_fixed_count` | 每次 reconcile run | 15 分钟 | 连续 3 次 run `> 0` | 立即告警 |
|
|||
|
|
|
|||
|
|
说明:
|
|||
|
|
|
|||
|
|
1. 本节中的 queue lag / backlog age 一律按“最老未处理项的入队年龄”定义,而不是平均值。
|
|||
|
|
2. `collect_request_ttl_expired_count` 继续作为单请求超时兜底告警,不与 P99 聚合告警互相替代。
|
|||
|
|
|
|||
|
|
### 15.3 风控与公平性
|
|||
|
|
|
|||
|
|
1. 单用户 `collect-now` 基于 `(user_id, brand_id, scope_hash)` 的 5 分钟幂等窗口(取代 V1 的 10 秒简单去重)
|
|||
|
|
2. 单租户同时最多 1~2 个活跃 `collect-now request`
|
|||
|
|
3. 对异常频繁的手动采集做限流(每用户每小时 20 次)
|
|||
|
|
4. 同平台登录态异常时暂停该平台 lane
|
|||
|
|
|
|||
|
|
## 16. 分阶段落地方案
|
|||
|
|
|
|||
|
|
### 16.1 Phase 1:最小改造 + WebSocket 直上 + SSE 下线(W5 + W6)
|
|||
|
|
|
|||
|
|
**范围收敛**:Phase 1 **不做 running 任务的运行时抢占**(运行时抢占放 Phase 2),但通道层面**一步到位直上 WebSocket**,不保留 SSE 作为过渡。
|
|||
|
|
|
|||
|
|
**Phase 1 实施项清单**:
|
|||
|
|
|
|||
|
|
1. **DB migration**:`monitoring_collect_tasks` 加字段(`dispatch_priority / dispatch_lane / target_client_id / interrupt_generation / execution_owner / superseded_by_request_id / dispatch_after / last_dispatched_at / dispatch_attempts / ingest_shard_key`);新增 `monitoring_collect_requests` 表
|
|||
|
|
2. **lease 排序改造**:monitoring lease 查询从 `ORDER BY planned_at` 改为 `ORDER BY CASE dispatch_lane WHEN 'high' THEN 70 WHEN 'normal_boosted' THEN 50 WHEN 'normal' THEN 40 WHEN 'retry' THEN 20 ELSE 0 END DESC, dispatch_priority DESC, dispatch_after ASC NULLS FIRST, planned_at ASC`。
|
|||
|
|
3. **collect-now handler**:幂等检查 + gen 递增 + 任务提升 + 取消 queued normal / 对 active lease 写入抢占控制信号 + 创建 collect_request(见 §17.1 outbox 模式)
|
|||
|
|
4. **RabbitMQ 生产端扩展**:`PublishDesktopDispatch` 支持 `monitor.high.{client_id} / monitor.normal.{client_id} / monitor.retry.{client_id}` 路由键
|
|||
|
|
5. **tenant-api dispatch hub 订阅扩展**:binding key 新增 `monitor.*`,与现有 `publish.*` 并列
|
|||
|
|
6. **WebSocket push 下发**:dispatch hub 把 `task_available(kind=monitor)` 与 `task_control` 事件推给匹配 `target_client_id` 的 desktop WebSocket
|
|||
|
|
7. **desktop client 改造**:
|
|||
|
|
- WebSocket handler 增加 `kind=monitor` 分支
|
|||
|
|
- 收到 `task_available(monitor)` 时触发一次 pull-lease(Phase 1 保持 lease 协议不变,仅把"定时 pull"改为"信号驱动 pull")
|
|||
|
|
- 收到 `task_control(interrupt_requested)` 时:先在本地 scheduler 串行上下文内仅标记 `interruptRequested=true`;若目标任务处于本地队列(queued)则丢弃;若在下一次串行检查时 `executionPhase in (PREPARING, NAVIGATING)` 则主动 `cancel lease`;自 `AUTHENTICATING` 起视为已开始,Phase 1 忽略中断并等待自然完成
|
|||
|
|
- 比对 `interrupt_generation`,忽略过期指令
|
|||
|
|
8. **SSE 下线**(Phase 1 一并完成):
|
|||
|
|
- 移除 `server/internal/tenant/transport/desktop_events_handler.go` 路由注册
|
|||
|
|
- 移除 desktop-client 的 `SseClient`、`api-client.ts` 中 `sseConnected` 状态、`runtime-controller.ts` 的 SSE 启动
|
|||
|
|
- 旧端点返回 410 Gone 一个发布周期后删除
|
|||
|
|
9. **指标埋点**:collect_now 全链路延迟、gen_mismatch、dispatch_reject、WebSocket 连接数
|
|||
|
|
|
|||
|
|
**Phase 1 的 active lease 处置**:
|
|||
|
|
|
|||
|
|
legacy 路径下,服务端无法仅凭 `monitoring_collect_tasks` 精确区分“已 lease 但尚未真正开始执行”和“已在浏览器内运行”。因此 Phase 1 统一采用保守策略:
|
|||
|
|
|
|||
|
|
1. 服务端只直接取消 queued 的 normal 任务。
|
|||
|
|
2. 对已有 active lease 的 normal 任务,不在服务端盲目清空 `lease_token_hash`,只下发 `task_control` 提示 desktop 自判。
|
|||
|
|
3. desktop 先在本地调度锁内原子标记 `interruptRequested`;仅当下一次串行检查仍处于 `PREPARING / NAVIGATING` 时,才主动 `cancel lease` 并让路给 high。
|
|||
|
|
4. 若串行检查时已进入 `AUTHENTICATING` 及之后阶段,则 Phase 1 忽略本次中断请求,待自然完成;期间 coordinator 暂停向该 client 派发新的 normal。
|
|||
|
|
|
|||
|
|
**Phase 1 的 collect-now 优先级保障**:
|
|||
|
|
|
|||
|
|
1. 提升目标业务任务为 high lane 后,立即取消 queued 的 normal 任务,并向 active lease 的 normal 任务发送 `task_control`
|
|||
|
|
2. 通过 WebSocket 立刻推 `task_available(high)` 给目标 client
|
|||
|
|
3. desktop 收到信号后立即 pull-lease high 任务;若当前无 active lease,通常可直接开始执行
|
|||
|
|
4. 无 active lease 的典型场景:WebSocket push 延迟 < 200ms,client pull-lease < 500ms,总体 collect-now → 开始执行 < 2s;若遇到已 running 任务,则首响时间取决于当前任务尾延迟
|
|||
|
|
|
|||
|
|
**Phase 1 核心诉求与边界**:
|
|||
|
|
|
|||
|
|
1. 解决 95% 场景:用户点击 collect-now 后 2s 内开始执行
|
|||
|
|
2. 不解决 5% 场景:client 已在 running 且 AI 平台响应很慢(30s+),此场景 UI 提示"正在等上一条任务完成"
|
|||
|
|
|
|||
|
|
**优点**:
|
|||
|
|
|
|||
|
|
1. 通道层面一步到位,无 SSE/WS 双通道并存的复杂度
|
|||
|
|
2. 解决"立即采集让路"核心诉求,延迟进入 2s 量级
|
|||
|
|
3. 不引入双层模型,无大规模迁移成本
|
|||
|
|
|
|||
|
|
**缺点**:
|
|||
|
|
|
|||
|
|
1. monitoring 与 publish 仍是两套执行控制面(Phase 2 收敛)
|
|||
|
|
2. 无法抢占 running 任务(业务方可接受,Phase 2 补齐)
|
|||
|
|
3. Phase 1 工作量略增(多了 WebSocket 通道对接和 SSE 下线),估时 1.5~2 周
|
|||
|
|
|
|||
|
|
### 16.2 Phase 2:执行控制面收敛 + 迁移期一致性(C3)
|
|||
|
|
|
|||
|
|
**新增内容**(通道层面在 Phase 1 已就位,Phase 2 只做控制面收敛):
|
|||
|
|
|
|||
|
|
1. 引入 `desktop_tasks(kind=monitor)` 全字段(见 §7.2),作为执行态权威表
|
|||
|
|
2. `monitoring_collect_tasks.execution_owner` 字段落地,按 tenant 灰度
|
|||
|
|
3. Phase 1 的"信号驱动 pull-lease"升级为"push-lease":dispatch hub push `task_available` 时直接携带 lease_token
|
|||
|
|
4. 真正的 `running` 任务抢占能力(基于 `desktop_tasks.control_flags` + 安全点协议)
|
|||
|
|
5. desktop client 监听 `task_control` 事件,安全点响应中断并回写 `aborted(preempted)`
|
|||
|
|
|
|||
|
|
#### 16.2.1 执行源路由字段
|
|||
|
|
|
|||
|
|
`monitoring_collect_tasks.execution_owner` varchar(20):
|
|||
|
|
|
|||
|
|
- `legacy` — 仍在业务任务表上做 lease(Phase 1 路径)
|
|||
|
|
- `desktop_tasks` — 已迁移到执行任务表(Phase 2 路径)
|
|||
|
|
|
|||
|
|
dispatch coordinator 按该字段路由:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
switch task.ExecutionOwner {
|
|||
|
|
case "legacy":
|
|||
|
|
leaseOnMonitoringCollectTasks(task)
|
|||
|
|
case "desktop_tasks":
|
|||
|
|
dispatchViaDesktopTasks(task)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 16.2.2 灰度策略
|
|||
|
|
|
|||
|
|
按 `tenant_id % 100 < rollout_percentage` 灰度。Phase 2 上线时:
|
|||
|
|
|
|||
|
|
| 日期(D 表示 Phase 2 上线日) | `rollout_percentage` | 预期观测 |
|
|||
|
|
| --- | --- | --- |
|
|||
|
|
| D1 | 5% | 抢占成功率、ingest lag、reconcile 产出 |
|
|||
|
|
| D3 | 25% | 扩大样本,观察多租户并发表现 |
|
|||
|
|
| D7 | 50% | 压测高峰是否平稳 |
|
|||
|
|
| D14 | 100% | 全量 |
|
|||
|
|
|
|||
|
|
灰度切换时:
|
|||
|
|
|
|||
|
|
- 仅对**新生成**的业务任务按 tenant 落 `execution_owner`
|
|||
|
|
- **不迁移**已 in-flight 的 legacy 任务,等自然结束
|
|||
|
|
- dispatch coordinator 同时消费两条路径,直到 legacy in-flight 清零
|
|||
|
|
|
|||
|
|
#### 16.2.3 回滚预案
|
|||
|
|
|
|||
|
|
若 Phase 2 发现严重问题:
|
|||
|
|
|
|||
|
|
1. 立即将 rollout 回调至 0%(新任务全部走 legacy)
|
|||
|
|
2. 已迁移到 `desktop_tasks` 的 in-flight 任务**不强制回迁**,等自然结束(或 `lease_expires_at` 回收)
|
|||
|
|
3. 若必须强制回迁,执行脚本:
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- 强制回迁仅在紧急故障场景使用
|
|||
|
|
BEGIN;
|
|||
|
|
-- Step 1: cancel 所有 monitor kind 的 in-flight desktop_tasks
|
|||
|
|
UPDATE desktop_tasks
|
|||
|
|
SET status = 'aborted', interrupt_reason = 'phase2_emergency_rollback'
|
|||
|
|
WHERE kind = 'monitor' AND status IN ('queued', 'in_progress', 'unknown');
|
|||
|
|
|
|||
|
|
-- Step 2: 业务任务回流 legacy
|
|||
|
|
UPDATE monitoring_collect_tasks
|
|||
|
|
SET execution_owner = 'legacy',
|
|||
|
|
status = 'pending',
|
|||
|
|
lease_token_hash = NULL,
|
|||
|
|
leased_to_executor = NULL,
|
|||
|
|
leased_at = NULL,
|
|||
|
|
lease_expires_at = NULL
|
|||
|
|
WHERE execution_owner = 'desktop_tasks'
|
|||
|
|
AND status IN ('pending');
|
|||
|
|
COMMIT;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 16.2.4 双写一致性保护
|
|||
|
|
|
|||
|
|
**绝对禁止**:
|
|||
|
|
|
|||
|
|
- 同一 `monitoring_collect_tasks.id` 同时存在两条 active `desktop_tasks(kind=monitor)` → 通过 §7.2 的 UNIQUE 约束保证
|
|||
|
|
- `legacy` 路径在业务任务标记 `execution_owner='desktop_tasks'` 后继续 lease → 通过 coordinator 启动前检查 + 定期 reconcile 保证
|
|||
|
|
|
|||
|
|
**Reconcile 任务**(每 5 分钟运行):
|
|||
|
|
|
|||
|
|
1. 扫描 `execution_owner='legacy' AND leased_to_executor IS NOT NULL AND lease_expires_at < now()` 的僵尸租约 → 清理
|
|||
|
|
2. 扫描 `execution_owner='desktop_tasks' AND status='pending'` 且无对应 active desktop_task 的业务任务 → 重新 dispatch
|
|||
|
|
3. 扫描 `execution_owner='desktop_tasks'` 但 `desktop_tasks` 表无对应记录的异常业务任务 → 记录告警、人工介入
|
|||
|
|
|
|||
|
|
### 16.3 Phase 3:治理增强
|
|||
|
|
|
|||
|
|
目标:面向规模化与稳定性。
|
|||
|
|
|
|||
|
|
实施项:
|
|||
|
|
|
|||
|
|
1. result ingest 按 `ingest_shard_key` 分片串行消费(见 §12.2)
|
|||
|
|
2. projection rebuild coalesce 已有,Phase 3 补充按租户分片
|
|||
|
|
3. per-tenant fair scheduling
|
|||
|
|
4. aging 提升按 §9.4 完全落地
|
|||
|
|
5. 运维看板与自动降速
|
|||
|
|
|
|||
|
|
(SSE 相关移除已在 Phase 1 完成,无需 Phase 3 处理)
|
|||
|
|
|
|||
|
|
## 17. 推荐伪代码
|
|||
|
|
|
|||
|
|
### 17.1 collect-now(outbox 模式,W4)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func CollectNow(ctx context.Context, req CollectNowRequest) (*CollectNowResponse, error) {
|
|||
|
|
// 阶段 1:幂等复用(无事务)
|
|||
|
|
scopeHash := hashScope(req)
|
|||
|
|
if existing := findActiveRequest(ctx, req.UserID, req.BrandID, scopeHash); existing != nil {
|
|||
|
|
return buildResponseFromExisting(existing), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 阶段 2:选择 target_client(见 §10.6)
|
|||
|
|
targetClientID, err := selectTargetClient(ctx, req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
requestID := uuid.New()
|
|||
|
|
var effects *CollectNowEffects
|
|||
|
|
|
|||
|
|
// 阶段 3:短事务,仅写 DB 和 outbox,不做外部副作用
|
|||
|
|
err = withTx(ctx, func(tx Tx) error {
|
|||
|
|
createCollectRequest(tx, CollectRequest{
|
|||
|
|
ID: requestID,
|
|||
|
|
UserID: req.UserID,
|
|||
|
|
BrandID: req.BrandID,
|
|||
|
|
ScopeHash: scopeHash,
|
|||
|
|
TargetClientID: targetClientID,
|
|||
|
|
Status: "accepted",
|
|||
|
|
TTLExpiresAt: time.Now().Add(5 * time.Minute),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 原子递增 generation
|
|||
|
|
gen := incrementInterruptGeneration(tx, targetClientID)
|
|||
|
|
|
|||
|
|
// 批量提升业务任务为 high lane(单条 UPDATE,WHERE 带 scope 条件)
|
|||
|
|
effects = promoteToHighLane(tx, req.Scope, targetClientID, requestID, gen)
|
|||
|
|
|
|||
|
|
// 仅统计本次真正从 queued normal -> canceled 的任务数;不含已被旧请求取消或非 normal 任务
|
|||
|
|
effects.AbortedQueuedCount = abortQueuedNormal(tx, targetClientID, gen)
|
|||
|
|
// 仅统计本次 newly-marked interrupt_requested 的 active lease 数;不含已存在更高/相同 generation 的任务
|
|||
|
|
effects.InterruptRequestedCount = markInterruptRequested(tx, targetClientID, gen, requestID)
|
|||
|
|
|
|||
|
|
// 写 outbox
|
|||
|
|
writeOutbox(tx, OutboxEvent{
|
|||
|
|
Kind: "monitor.dispatch.high",
|
|||
|
|
Payload: DispatchPayload{ClientID: targetClientID, Generation: gen},
|
|||
|
|
CreatedAt: time.Now(),
|
|||
|
|
})
|
|||
|
|
writeOutbox(tx, OutboxEvent{
|
|||
|
|
Kind: "monitor.interrupt",
|
|||
|
|
Payload: InterruptPayload{ClientID: targetClientID, TaskIDs: effects.RunningTaskIDs, Generation: gen, RequestID: requestID},
|
|||
|
|
CreatedAt: time.Now(),
|
|||
|
|
})
|
|||
|
|
return nil
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 阶段 4:外部副作用由独立 outbox pump goroutine 处理(非阻塞)
|
|||
|
|
return &CollectNowResponse{
|
|||
|
|
RequestID: requestID,
|
|||
|
|
CollectionMode: "desktop",
|
|||
|
|
TargetClientID: targetClientID,
|
|||
|
|
AffectedTaskCount: effects.TotalCount,
|
|||
|
|
PromotedTaskCount: effects.PromotedCount,
|
|||
|
|
AbortedQueuedCount: effects.AbortedQueuedCount,
|
|||
|
|
InterruptRequestedCount: effects.InterruptRequestedCount,
|
|||
|
|
InterruptGeneration: effects.Generation,
|
|||
|
|
TTLExpiresAt: time.Now().Add(5 * time.Minute),
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Outbox pump(独立 goroutine)
|
|||
|
|
func OutboxPump(ctx context.Context) {
|
|||
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|||
|
|
defer ticker.Stop()
|
|||
|
|
for {
|
|||
|
|
select {
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return
|
|||
|
|
case <-ticker.C:
|
|||
|
|
events := fetchUndeliveredOutbox(ctx, 100)
|
|||
|
|
for _, e := range events {
|
|||
|
|
switch e.Kind {
|
|||
|
|
case "monitor.dispatch.high":
|
|||
|
|
publishDispatchSignal(e.Payload)
|
|||
|
|
case "monitor.interrupt":
|
|||
|
|
publishInterruptSignal(e.Payload)
|
|||
|
|
}
|
|||
|
|
markOutboxDelivered(ctx, e.ID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**CollectNowResponse 字段语义**:
|
|||
|
|
|
|||
|
|
1. `aborted_queued_count` = 本次 `collect-now` 实际取消的 queued normal 任务数,不含此前已取消或已失活任务。
|
|||
|
|
2. `interrupt_requested_count` = 本次新写入 `task_control / interruptRequested` 的 active lease 任务数,不含已被更高或相同 generation 覆盖的任务。
|
|||
|
|
|
|||
|
|
### 17.2 dispatch coordinator
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func DispatchLoop(ctx context.Context) {
|
|||
|
|
for {
|
|||
|
|
// 先 drain high lane
|
|||
|
|
if highLaneBacklog() > 0 {
|
|||
|
|
drainHighLane()
|
|||
|
|
keepNormalBudget(0.2) // 保留 20% normal 背景吞吐
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查降速信号
|
|||
|
|
if dbLagTooHigh() || ingestLagTooHigh() {
|
|||
|
|
pauseNormalLane()
|
|||
|
|
sleep(5 * time.Second)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 按预算 dispatch normal
|
|||
|
|
dispatchByBudget("normal")
|
|||
|
|
sleep(1 * time.Second)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func dispatchByBudget(lane string) {
|
|||
|
|
candidates := fetchReadyTasks(lane, 100)
|
|||
|
|
for _, task := range candidates {
|
|||
|
|
reason, ok := checkDispatchBudget(task)
|
|||
|
|
if !ok {
|
|||
|
|
metrics.Inc("monitor_dispatch_reject_by_reason_count", reason)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
doDispatch(task)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 17.3 desktop client 抢占串行化(Phase 1 active lease / Phase 2 running,TypeScript)
|
|||
|
|
|
|||
|
|
文件:`apps/desktop-client/src/scheduler/monitor-scheduler.ts`
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
function onTaskControl(event: TaskControlEvent) {
|
|||
|
|
scheduler.enqueue(() => {
|
|||
|
|
const active = state.activeExecutions.get(event.task_id)
|
|||
|
|
if (!active) return
|
|||
|
|
if (active.kind !== "monitor") return
|
|||
|
|
|
|||
|
|
// 代际比对必须和状态写入在同一个串行上下文内
|
|||
|
|
if (event.interrupt_generation < active.lastSeenGeneration) {
|
|||
|
|
metrics.inc("interrupt_generation_mismatch_count")
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
active.lastSeenGeneration = event.interrupt_generation
|
|||
|
|
active.interruptRequested = true
|
|||
|
|
active.interruptReason = event.reason
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function canPhase1Cancel(phase: ExecutionPhase) {
|
|||
|
|
return phase === "PREPARING" || phase === "NAVIGATING"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function maybeHandleInterrupt(taskId: string) {
|
|||
|
|
const active = state.activeExecutions.get(taskId)
|
|||
|
|
if (!active?.interruptRequested) return
|
|||
|
|
|
|||
|
|
// Phase 1 不允许 control handler 直接 cancel,只能在主执行循环串行检查后决定
|
|||
|
|
if (runtime.phase === 1 && !canPhase1Cancel(active.executionPhase)) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await cancelDesktopTask(taskId, {
|
|||
|
|
lease_token: currentLeaseToken(taskId),
|
|||
|
|
reason: "collect_now_preempt",
|
|||
|
|
interrupt_generation: active.lastSeenGeneration,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 记录安全点间隔(用于动态调参)
|
|||
|
|
metrics.observe("safe_point_interval_ms", Date.now() - active.lastSafePointAt)
|
|||
|
|
|
|||
|
|
pumpExecutionLoop()
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 18. 最终建议
|
|||
|
|
|
|||
|
|
推荐决策如下:
|
|||
|
|
|
|||
|
|
1. 短期(Phase 1,1.5~2 周):业务任务表加字段 + collect-now 高优提升 + queued/leased 抢占 + **WebSocket 通道直上(monitor.\* 路由键 + dispatch hub 订阅扩展)+ SSE `/api/desktop/events` 同步下线**。不抢 running,收敛业务范围。
|
|||
|
|
2. 中期(Phase 2,4 周):引入 `desktop_tasks(kind=monitor)` + `execution_owner` 灰度迁移 + pull-lease 升级为 push-lease + 支持 running 抢占(安全点协议)。严格遵守灰度节奏(5% → 25% → 50% → 100%)。
|
|||
|
|
3. 长期(Phase 3,持续):ingest 分片串行 + per-tenant fair scheduling + aging + 自动降速。
|
|||
|
|
4. 结果回写坚持 `API ingress -> MQ -> shard serial ingest -> projection rebuild`,不要回退到 desktop 直写 DB。
|
|||
|
|
|
|||
|
|
如果只保留一句话概括本方案,就是:
|
|||
|
|
|
|||
|
|
**业务任务存在 `monitoring_collect_tasks`(Phase 1 兼容承载现有状态流转,Phase 2 仅保留业务终态与调度属性),执行任务逐步收敛到 `desktop_tasks(kind=monitor)`(承载唯一执行态),`collect-now` 通过 high lane + 代际化协作式抢占 + outbox 模式优先插队,结果再经 MQ 分片有序入库;迁移期用 `execution_owner` 字段做双路径路由,灰度上线、reconcile 兜底、随时可回滚。**
|
|||
|
|
|
|||
|
|
## 附录 A. V1.1 修订项与原文档对照速查
|
|||
|
|
|
|||
|
|
| V1.1 章节 | V1 章节 | 修订类型 | 修订要点 |
|
|||
|
|
| --- | --- | --- | --- |
|
|||
|
|
| §2 修订记录 | 新增 | 新增 | V1→V1.1 变更全景 |
|
|||
|
|
| §6.1 当前态 | §5.1 | 更正 | `monitor.projection.rebuild` 实际已存在 |
|
|||
|
|
| §7.1 | §6.1 | 重写 | 不新增 `execution_status`;Phase 1 兼容复用现有 `status`,Phase 2 明确 `desktop_tasks` 为唯一执行真相,并补齐状态写入矩阵 |
|
|||
|
|
| §7.2 | §6.2 | 补充 | 新增 UNIQUE 约束防止双写 |
|
|||
|
|
| §7.3 | 新增 | 新增 | priority 值域规范 + lane_weight |
|
|||
|
|
| §9.2 | §8.2 | 重写 | dispatch 预算决策树 + 拒绝矩阵 |
|
|||
|
|
| §9.4 | §8.4 | 重写 | aging 百分位触发 + 对数衰减 |
|
|||
|
|
| §10.2 | 新增 | 新增 | interrupt_generation 规范 |
|
|||
|
|
| §10.5 | §9.4 | 重写 | 参数化超时,per-platform 配置 |
|
|||
|
|
| §10.6 | 新增 | 新增 | target_client_id 绑定与漂移 |
|
|||
|
|
| §12.2 | §11.2 | 精简 | 去掉 shard_key 备选维度,统一 |
|
|||
|
|
| §12.3 | §11.3 | 对齐 | 与现有表约束映射 + `question_monitor_runs` upsert 语义 |
|
|||
|
|
| §13.1 | §12.1 | 补充 | collection_mode 字段语义 |
|
|||
|
|
| §14.3 | 新增 | 新增 | Phase 1/2/3 通道策略(**修订 2:SSE 在 Phase 1 即下线**) |
|
|||
|
|
| §16.1 Phase 1 通道部分 | §15.1 | 重写 | WebSocket 通道 Phase 1 直上,SSE 同步下线(**修订 2**) |
|
|||
|
|
| §14.2 / §14.4 | 新增 | 新增 | `executionPhase` 定义 + desktop 中断串行化规则 |
|
|||
|
|
| §15.1 | §14.1 | 扩充 | 新增 10 项指标 |
|
|||
|
|
| §15.2 | §14.2 | 重写 | 告警五元组(指标/聚合/窗口/阈值/持续时间) |
|
|||
|
|
| §16.1 | §15.1 | 重写 | Phase 1 范围收敛 + WebSocket 直上 + SSE 下线 |
|
|||
|
|
| §16.2 | §15.2 | 重写 | 迁移期一致性(execution_owner + 灰度 + 回滚 + reconcile) |
|
|||
|
|
| §17.1 | §16.1 | 重写 | outbox 模式 |
|
|||
|
|
| 附录 A | 新增 | 新增 | 修订对照表 |
|