271 lines
11 KiB
Markdown
271 lines
11 KiB
Markdown
# 数据库 SSOT 单线程事件循环重构设计
|
||||
|
|
|
|||
|
|
## 背景
|
|||
|
|
|
|||
|
|
当前后端同时包含 PostgreSQL、Redis Streams EventBus、RedisStateManager、RedisToolLocker、ZPopLoop、Dispatcher、ReadyQueue 和多个运行时锁。目标是重构为数据库唯一真相源:单进程单实例部署下,所有生产状态写入由单线程事件循环串行处理,Redis 完全移除,数据库 ORM 使用 entgo。
|
|||
|
|
|
|||
|
|
## 目标
|
|||
|
|
|
|||
|
|
- PostgreSQL 作为唯一运行时真相源。
|
|||
|
|
- 移除 Redis 运行时依赖,包括 RedisBus、RedisStateManager、RedisToolLocker、WriteThroughHelper、ZPopLoop、SSOT 配置路径。
|
|||
|
|
- 所有 job、equipment_slot、task、work_order、manual_action、alarm、event_log 状态变更通过单线程 event loop 串行执行。
|
|||
|
|
- 机器人、PLC、扫码、打标等长耗时动作不阻塞 event loop,由 worker 执行后回投结果。
|
|||
|
|
- 保留并改造现有 scheduler 三层 Generator/Filter/Policy,使其基于 DB/loop 快照调度。
|
|||
|
|
- 重启恢复直接以 PostgreSQL 当前状态为准,采用保守人工确认策略。
|
|||
|
|
|
|||
|
|
## 非目标
|
|||
|
|
|
|||
|
|
- 不支持多后端实例并发调度写入。
|
|||
|
|
- 不引入分布式锁、租约、选主或 Redis 替代品。
|
|||
|
|
- 不把暂存台建成独立槽位表。
|
|||
|
|
- 不让 HTTP 请求等待真实硬件动作完成。
|
|||
|
|
|
|||
|
|
## 总体架构
|
|||
|
|
|
|||
|
|
新增 `ProductionEventLoop` 作为产线运行核心。HTTP logic、SignalWatcher、恢复流程、人工操作和定时器只向 event loop 投递消息,不直接修改生产状态。
|
|||
|
|
|
|||
|
|
组件边界:
|
|||
|
|
|
|||
|
|
- HTTP handler / logic:提交 command。控制类命令短超时等待结果;长流程通过 SSE 或查询接口观察。
|
|||
|
|
- SignalWatcher:轮询 PLC 后投递 `MachineDone`、`InspectionResult` 等事件到 event loop。
|
|||
|
|
- ProductionEventLoop:唯一状态写路径。串行处理 command、硬件事件、worker result、调度 tick、补料触发。
|
|||
|
|
- Hardware Worker:执行机器人、PLC、扫码、打标等长耗时动作,不修改 DB;完成或失败后回投结果。
|
|||
|
|
- DBState:event loop 内部使用的 ent 状态访问层,封装条件更新和事务。
|
|||
|
|
- PostgreSQL:唯一真相源。
|
|||
|
|
- SSE/EventLog:由 event loop 在状态变更后发布/写入。
|
|||
|
|
|
|||
|
|
## 事件与命令模型
|
|||
|
|
|
|||
|
|
event loop 接收统一的 `EventLoopMessage`:
|
|||
|
|
|
|||
|
|
- `ID`:事件日志和去重标识。
|
|||
|
|
- `Type`:消息类型。
|
|||
|
|
- `Payload`:业务数据。
|
|||
|
|
- `CorrelationID`:动作关联标识。对 worker result 必须等于对应 `task.id` 或 event loop 生成的 action ID。
|
|||
|
|
- `Reply`:可选,用于 API 同步等待。
|
|||
|
|
- `CreatedAt`:创建时间。
|
|||
|
|
|
|||
|
|
消息分为三类。
|
|||
|
|
|
|||
|
|
### Command
|
|||
|
|
|
|||
|
|
来自 API、恢复流程或人工操作:
|
|||
|
|
|
|||
|
|
- `StartOrder`
|
|||
|
|
- `PauseOrder`
|
|||
|
|
- `ResumeOrder`
|
|||
|
|
- `CancelOrder`
|
|||
|
|
- `SuspendJob`
|
|||
|
|
- `ResumeJob`
|
|||
|
|
- `ReworkJob`
|
|||
|
|
- `ConfirmRecovery`
|
|||
|
|
- `ResolveManualAction`
|
|||
|
|
|
|||
|
|
### ExternalEvent
|
|||
|
|
|
|||
|
|
来自硬件轮询或系统定时器:
|
|||
|
|
|
|||
|
|
- `MachineDone`
|
|||
|
|
- `InspectionResult`
|
|||
|
|
- `PalletArrived`
|
|||
|
|
- `StepTimeout`
|
|||
|
|
- `RefillRequested`
|
|||
|
|
- `ScheduleTick`
|
|||
|
|
|
|||
|
|
### WorkerResult
|
|||
|
|
|
|||
|
|
来自硬件 worker:
|
|||
|
|
|
|||
|
|
- `RobotActionSucceeded`
|
|||
|
|
- `RobotActionFailed`
|
|||
|
|
- `ToolActionSucceeded`
|
|||
|
|
- `ToolActionFailed`
|
|||
|
|
- `MachineStartSucceeded`
|
|||
|
|
- `MachineStartFailed`
|
|||
|
|
|
|||
|
|
每条消息处理流程固定为:读取必要 DB 状态,校验当前状态,执行 ent 条件更新或事务,写事件日志和 SSE,再触发调度评估。
|
|||
|
|
|
|||
|
|
Worker result 必须按 `CorrelationID` 关联到 `task.id`。event loop 只接受当前仍处于 `RUNNING` 状态的 task result;如果 task 已完成、失败、取消,或 job 已进入终态,则把 result 记录为 stale 事件并忽略状态推进。这样暂停、取消、恢复后迟到的硬件结果不会污染 DB 状态。
|
|||
|
|
|
|||
|
|
## API 返回语义
|
|||
|
|
|
|||
|
|
采用混合模式:
|
|||
|
|
|
|||
|
|
- `PauseOrder`、`ResumeOrder`、`CancelOrder`、`SuspendJob`、`ResumeJob`、`ReworkJob`:投递 command 后短超时等待执行结果。
|
|||
|
|
- `StartOrder`:等待 DB 初始化和首轮调度完成后返回;真实生产动作通过 SSE/查询观察。
|
|||
|
|
- 硬件动作不阻塞 HTTP 请求。
|
|||
|
|
- 如果 API 等待超时,前端以 SSE/查询结果为准。
|
|||
|
|
|
|||
|
|
## DBState 与 ent 原子状态更新
|
|||
|
|
|
|||
|
|
新增 DB 状态访问层,只给 event loop 使用。它不暴露通用 CRUD,而暴露领域状态迁移方法:
|
|||
|
|
|
|||
|
|
- `ClaimJobForAction(ctx, jobID, expectedStatus, nextStatus, position)`
|
|||
|
|
- `MoveJobToEquipment(ctx, jobID, fromPosition, equipmentID, slotNo)`
|
|||
|
|
- `MoveJobToBuffer(ctx, jobID, slotNo)`
|
|||
|
|
- `CompleteStep(ctx, jobID, expectedStepIndex, nextStepIndex, contextPatch)`
|
|||
|
|
- `FinishJob(ctx, jobID, terminalStatus)`
|
|||
|
|
- `SetEquipmentSlot(ctx, equipmentID, slotNo, expectedState, nextState, jobID)`
|
|||
|
|
- `CreateTask(ctx, ...)`
|
|||
|
|
- `StartTask(ctx, ...)`
|
|||
|
|
- `FinishTask(ctx, ...)`
|
|||
|
|
- `FailTask(ctx, ...)`
|
|||
|
|
- `AppendEventLog(ctx, message)` 或由 event loop 统一写。
|
|||
|
|
- `RaiseAlarm(ctx, alarm)` / `ResolveAlarm(ctx, alarmID)`
|
|||
|
|
|
|||
|
|
实现优先使用 ent `Update().Where(...).Save/Exec`。需要 `RETURNING` 或多表条件更新时,可在 DBState 内部使用 ent 事务和原生 SQL,但 SQL 不散落到 processor 业务代码。
|
|||
|
|
|
|||
|
|
原则:
|
|||
|
|
|
|||
|
|
- 不使用 Redis version。
|
|||
|
|
- `job.version` 保留为事件版本号,每次状态更新递增。
|
|||
|
|
- 不使用悲观锁,也不依赖乐观锁重试。
|
|||
|
|
- SQL `WHERE status/position/step` 是防御性条件,主要一致性由单 event loop 串行化保证。
|
|||
|
|
- 跨表变更使用事务。
|
|||
|
|
- 终态计数用 DB 条件保证只计一次,不能依赖运行时 `terminalCounted`。
|
|||
|
|
|
|||
|
|
## 槽位状态设计
|
|||
|
|
|
|||
|
|
采用混合模型。
|
|||
|
|
|
|||
|
|
### 暂存台
|
|||
|
|
|
|||
|
|
暂存台不建独立槽位表,也不使用 Redis bitmap。以 `job.temp_slot_no` 作为占用记录:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
job.position_type = ON_BUFFER
|
|||
|
|
job.position_ref_id = temp_slot_no
|
|||
|
|
job.temp_slot_no = 1..8
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
规则:
|
|||
|
|
|
|||
|
|
- event loop 查询非终态且 `temp_slot_no IS NOT NULL` 的 job,计算 1..8 空位。
|
|||
|
|
- 补料分配槽位时设置 `temp_slot_no`、`position_type=ON_BUFFER`、`position_ref_id=slotNo`。
|
|||
|
|
- 工件上设备时保留 `temp_slot_no` 不变,只改当前位置。
|
|||
|
|
- 下料回原暂存位时使用原 `temp_slot_no`。
|
|||
|
|
- 完成或报废时清空 `temp_slot_no`。
|
|||
|
|
- 恢复时若发现重复槽位或越界槽位,相关 job 挂起并创建人工恢复动作。
|
|||
|
|
|
|||
|
|
### 真实设备槽位
|
|||
|
|
|
|||
|
|
真实设备槽位使用 `equipment_slot` 表,同时 job 记录当前位置:
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
job.position_type = ON_EQUIPMENT
|
|||
|
|
job.position_ref_id = equipment_id:slot_no
|
|||
|
|
|
|||
|
|
equipment_slot.status = EMPTY / OCCUPIED / DONE
|
|||
|
|
equipment_slot.currentJobId = job.id
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
职责划分:
|
|||
|
|
|
|||
|
|
- `job.position_type/position_ref_id/temp_slot_no` 回答“工件在哪里”。
|
|||
|
|
- `equipment_slot.status/currentJobId` 回答“设备槽位当前物理/工艺状态是什么”。
|
|||
|
|
- 槽位状态沿用现有枚举 `EMPTY / OCCUPIED / DONE`。设备故障不扩展到槽位枚举,记录在 `equipment.status=FAULT`、`alarm` 和 `manual_action`。
|
|||
|
|
- 两者必须由 event loop 在同一事务内更新。
|
|||
|
|
- 恢复时发现不一致则挂起并人工确认。
|
|||
|
|
|
|||
|
|
## 设备完成信号语义
|
|||
|
|
|
|||
|
|
`MachineDone` payload 至少包含 `machineID`,可选包含 `slotNo`、`jobID`、`pass`。
|
|||
|
|
|
|||
|
|
处理规则:
|
|||
|
|
|
|||
|
|
- 批量设备:以 `equipment.batch=true` 为准,event loop 将该设备所有 `OCCUPIED` 槽位改为 `DONE`,并把对应 job 状态改为 `WAITING_UNLOAD`。
|
|||
|
|
- 非批量单槽设备:忽略缺失的 `slotNo`,唯一槽位从 `OCCUPIED` 改为 `DONE`。
|
|||
|
|
- 非批量多槽设备:优先使用 payload 中的 `slotNo`;如果缺失或小于等于 1,则按现有规则选择最早进入的 `OCCUPIED` 槽位作为 FIFO 完成槽位。
|
|||
|
|
- 如果 payload 中包含 `jobID`,必须与目标槽位 `currentJobId` 一致;不一致时不推进状态,创建报警和人工恢复动作。
|
|||
|
|
- 检测设备的 `InspectionResult` 同样进入 event loop,由 loop 更新 job context、决定通过/报废/人工判定。
|
|||
|
|
|
|||
|
|
## 调度数据流
|
|||
|
|
|
|||
|
|
调度触发来源:
|
|||
|
|
|
|||
|
|
- 工单启动。
|
|||
|
|
- 工件步骤完成。
|
|||
|
|
- 设备完成信号。
|
|||
|
|
- 暂存台槽位释放。
|
|||
|
|
- 人工恢复。
|
|||
|
|
- 周期性 `ScheduleTick`。
|
|||
|
|
|
|||
|
|
调度流程:
|
|||
|
|
|
|||
|
|
1. event loop 从 DB 和 loop 内存快照构建 `SystemState`。
|
|||
|
|
2. 复用 scheduler 的 Generator/Filter/Policy 选出候选动作。
|
|||
|
|
3. event loop 对候选动作执行 DB 条件迁移,并创建/启动 task。
|
|||
|
|
4. event loop 创建硬件动作请求,发送给单 robot worker。
|
|||
|
|
5. worker 执行动作。
|
|||
|
|
6. worker 回投成功或失败。
|
|||
|
|
7. event loop 推进步骤,更新 job、equipment_slot、task、work_order,写 event_log,发送 SSE,再触发下一轮调度。
|
|||
|
|
|
|||
|
|
## 补料
|
|||
|
|
|
|||
|
|
补料不再依赖 `TempSlotAllocator` bitmap 或 Redis。event loop 扫描 `job.temp_slot_no` 得到暂存台占用:
|
|||
|
|
|
|||
|
|
- 启动时补满 8 个。
|
|||
|
|
- 后续空位大于等于 4 时补 4 个。
|
|||
|
|
- 补料动作由 robot worker 串行执行。
|
|||
|
|
- 补料分配槽位和 job 状态更新都在 event loop 内完成。
|
|||
|
|
- 不再需要 `Dispatcher.replenishing` 原子标志。
|
|||
|
|
|
|||
|
|
`Replenisher` 可保留为“选择接驳台 job、生成补料动作”的辅助,不再维护真相状态。
|
|||
|
|
|
|||
|
|
## 工具互斥
|
|||
|
|
|
|||
|
|
由于只有一个 robot worker,扫码和打标天然串行:
|
|||
|
|
|
|||
|
|
- 删除 RedisToolLocker 运行时依赖。
|
|||
|
|
- `MemoryToolLocker` 可临时作为兼容 no-op,但最终不参与核心调度。
|
|||
|
|
- `ToolLockConstraint` 从默认 scheduler 约束中移除。
|
|||
|
|
|
|||
|
|
## 恢复策略
|
|||
|
|
|
|||
|
|
采用保守人工确认。
|
|||
|
|
|
|||
|
|
启动时:
|
|||
|
|
|
|||
|
|
1. 加载所有非终态 work_order、job、equipment_slot、task。
|
|||
|
|
2. 根据 `job.temp_slot_no` 重建暂存台占用。
|
|||
|
|
3. 查询 PLC/设备实际状态。
|
|||
|
|
4. 执行一致性校验:
|
|||
|
|
- job 在设备上,但 `equipment_slot.currentJobId/status` 不匹配。
|
|||
|
|
- 多个非终态 job 使用同一 `temp_slot_no`。
|
|||
|
|
- `temp_slot_no` 越界。
|
|||
|
|
- DB 显示设备运行,但 PLC 无对应状态。
|
|||
|
|
5. 不一致项写 manual_action,状态改为 `SUSPENDED` 或 `PAUSED`。
|
|||
|
|
6. 人工确认后通过 `ConfirmRecovery` command 回到 event loop,再恢复调度。
|
|||
|
|
|
|||
|
|
不再把 event_log 重放到 Redis。
|
|||
|
|
|
|||
|
|
## 错误处理
|
|||
|
|
|
|||
|
|
- 硬件 worker 失败:回投失败结果,event loop 标记 task failed,job 进入 `SUSPENDED` 或 `ERROR`,并创建报警/人工动作。
|
|||
|
|
- DB 条件更新失败:event loop 重新读取 DB 快照,决定跳过、重新调度或挂起,不做盲目重试。
|
|||
|
|
- API command 超时:返回超时或处理中,前端以 SSE/查询为准。
|
|||
|
|
- Step timeout:只投递 `StepTimeout` 消息到 event loop,不在 timer goroutine 中直接改 DB。
|
|||
|
|
- 终态计数:DB 事务内根据 job 旧状态条件保证只计一次。
|
|||
|
|
|
|||
|
|
## 测试策略
|
|||
|
|
|
|||
|
|
首版测试重点是新核心的回归安全网:
|
|||
|
|
|
|||
|
|
1. EventLoop 单元测试:命令顺序、同步 reply、超时、worker result 推进。
|
|||
|
|
2. DBState ent 集成测试:条件更新、跨表事务、终态计数防重复。
|
|||
|
|
3. Scheduler integration:从 DB 快照生成候选动作,覆盖卸料优先、换料优先、补料规则。
|
|||
|
|
4. Recovery 测试:重复 temp slot、设备槽位/job 不一致、PLC 不一致时创建 manual action。
|
|||
|
|
5. Mock E2E:覆盖完整工单流程。
|
|||
|
|
|
|||
|
|
Windows 上保持现有限制:不要求 `-race`,部分硬件相关测试仍可隔离。
|
|||
|
|
|
|||
|
|
## 验收标准
|
|||
|
|
|
|||
|
|
- 核心生产流程不依赖 Redis。
|
|||
|
|
- 配置不需要 `Redis`、`EventBus`、`SSOT` 才能启动核心流程。
|
|||
|
|
- 所有运行时状态可从 PostgreSQL 恢复。
|
|||
|
|
- 生产状态写入路径只在 event loop。
|
|||
|
|
- 硬件动作不阻塞 event loop。
|
|||
|
|
- 暂存台占用只由 `job.temp_slot_no` 表示。
|
|||
|
|
- 真实设备槽位由 `equipment_slot` 表表示,并与 job 位置在事务中保持一致。
|