Files
geo/docs/ai-brand-monitoring-priority-dispatch-design-v1.1.md
T

1404 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 不再直接 cancelPhase 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 3ingest 分片串行 + 多租户公平调度 + 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 | 2000029999 | 100 | 25000 |
| publish | high | 1500019999 | 90 | 18000 |
| publish | normal | 1000014999 | 80 | 12000 |
| monitor | highcollect-now | 50005999 | 70 | 5000 |
| monitor | normal_boostedaged | 10004999 | 50 | 1000~4999 |
| monitor | normal | 100999 | 40 | 100 |
| monitor | retry | 1099 | 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** 生效,不含 publishpublish 有独立通道)
- 单平台并发跨 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 派发新 normalhigh 任务仍然等待
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 pushPhase 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-leasePhase 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 延迟 < 200msclient 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-nowoutbox 模式,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(单条 UPDATEWHERE 带 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 runningTypeScript
文件:`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 通道策略(**修订 2SSE 在 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 | 新增 | 新增 | 修订对照表 |