# 数据库 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 位置在事务中保持一致。