Files
bj_power/bj_power_mes/docs/superpowers/plans/2026-05-08-db-ssot-event-loop.md
T

44 KiB
Raw Blame History

数据库 SSOT 单线程事件循环重构 — 实现计划

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 移除所有 Redis 运行时依赖(RedisBus、RedisStateManager、RedisToolLocker、WriteThroughHelper、ZPopLoop),新增基于 channel 的 ProductionEventLoop 作为唯一生产状态写路径,DB 用 entgo 原子条件更新。

Architecture: 新增 internal/eventloop/ 包(EventLoopMessage、ProductionEventLoop、DBState、hardware worker)。HTTP handler/SignalWatcher/恢复流程只投递消息到 event loop;硬件 worker 执行长耗时动作后回投结果。现有 scheduler 三层 Generator/Filter/Policy 保留并适配 DB 快照输入。

Tech Stack: Go 1.23.4go-zero v1.8.3ent ORM v0.14.5PostgreSQL,无 Redis。


文件结构规划

新建文件

文件 职责
internal/eventloop/types.go EventLoopMessage、CorrelationID、ReplyChan
internal/eventloop/loop.go ProductionEventLoopchannel 消费、消息分发、调度触发
internal/eventloop/dbstate.go DBStateent 原子条件更新封装
internal/eventloop/dbstate_test.go DBState 集成测试
internal/eventloop/worker.go HardwareWorker 接口 + robot worker 适配
internal/eventloop/loop_test.go EventLoop 单元测试
internal/eventloop/recovery.go 启动恢复:扫描 DB、校验一致性、创建 manual_action
internal/eventloop/recovery_test.go 恢复逻辑测试

修改文件

文件 变更
internal/config/config.go 删除 EventBus/SSOT 配置结构体,Redis 保留但仅非核心缓存
etc/hougai-api.yaml 删除 EventBus/SSOT 配置项
internal/svc/service_context.go 重写:移除 go-redis 客户端、RedisBus、StateManager、WriteThroughHelper、ZPopLoop,新建 EventLoop+Worker 并注入
internal/processor/job_processor.go 大幅删减:移除 ReadyQueue、ssotWriter、toolLocker、scheduleMu、ZPop 路径,API 方法改为向 event loop 投递 command,保留 jobRuntime map 和相关查询
internal/processor/job_runtime.go 简化:移除 onStatusSync/ssotVersion/terminalCounted/notifyReady/exchangePairer 回调字段,移除 SetOnCompleteFunc/SetOnErrorFunc/syncSSOTStatus/NotifyReady 方法,保留步骤推进和动作构建
internal/processor/dispatcher.go 简化/废弃:不再需要双通道调度器和 replenishing 标志
internal/processor/ready_queue.go 删除
internal/processor/signal_watcher.go 回调改为向 event loop 投递 MachineDone/InspectionResult
internal/processor/replenisher.go 简化:不维护 allocator/mutex,只生成补料动作
internal/processor/temp_slot_allocator.go 删除
internal/processor/recovery_coordinator.go 简化:改为向 event loop 投递 ConfirmRecovery
internal/processor/scheduler_adapter.go 适配:SystemState 从 DB/loop 快照构建
internal/scheduler/scheduler.go 保留
internal/scheduler/constraint_tool_lock.go 删除或简化为 no-op
internal/scheduler/zpop.go 删除
internal/state/redis_manager.go 删除
internal/state/state_write_through.go 删除
internal/state/tool_lock.go 删除 RedisToolLocker,保留 MemoryToolLocker
internal/state/keys.go 删除
internal/state/manager.go 简化:保留 StateManager 接口供测试 mock,删除 ClaimJob/ApplyTaskState 等 SSOT 方法
internal/eventbus/bus.go 删除 RedisBus 实现,保留 Bus 接口 + LocalBus
internal/eventbus/events.go 保留事件类型常量,删除 EntityVersion 字段
internal/recovery/replayer.go 删除(不再重放到 Redis
internal/verify/state_verifier.go 删除(不再对比 Redis 状态)

删除文件

internal/processor/ready_queue.gointernal/processor/temp_slot_allocator.gointernal/processor/ssot_integration_test.gointernal/state/redis_manager.gointernal/state/state_write_through.gointernal/state/keys.gointernal/scheduler/zpop.gointernal/recovery/replayer.gointernal/recovery/replayer_test.gointernal/verify/state_verifier.gointernal/verify/state_verifier_test.go


Chunk 1: 配置清理 + EventLoop 类型定义

Task 1.1: 删除配置结构体中的 EventBus/SSOT

Files:

  • Modify: internal/config/config.go

  • Modify: etc/hougai-api.yaml

  • Step 1: 从 config.go 删除 EventBusConf 和 SSOTConf

// 删除这两段
type EventBusConf struct {
    Enabled    bool   `json:",default=false"`
    StreamName string `json:",default=hougai:events"`
    GroupName  string `json:",default=hougai-workers"`
}

type SSOTConf struct {
    Enabled   bool   `json:",default=false"`
    KeyPrefix string `json:",default=hougai:"`
}

// 删除 Config 中的字段
// EventBus EventBusConf
// SSOT     SSOTConf
  • Step 2: 从 hougai-api.yaml 删除 EventBus 和 SSOT 配置块
# 删除以下行
EventBus:
  Enabled: true
  StreamName: "back_cover:events"
  GroupName: "back_cover-workers"

SSOT:
  Enabled: true
  KeyPrefix: "back_cover:"
  • Step 3: 编译验证
rtk go build ./...

Task 1.2: 创建 EventLoopMessage 类型

Files:

  • Create: internal/eventloop/types.go

  • Step 1: 写 types.go

package eventloop

import "time"

// MessageType 消息类型
type MessageType string

const (
    // Command
    CmdStartOrder         MessageType = "START_ORDER"
    CmdPauseOrder         MessageType = "PAUSE_ORDER"
    CmdResumeOrder        MessageType = "RESUME_ORDER"
    CmdCancelOrder        MessageType = "CANCEL_ORDER"
    CmdSuspendJob         MessageType = "SUSPEND_JOB"
    CmdResumeJob          MessageType = "RESUME_JOB"
    CmdReworkJob          MessageType = "REWORK_JOB"
    CmdConfirmRecovery    MessageType = "CONFIRM_RECOVERY"
    CmdResolveManualAction MessageType = "RESOLVE_MANUAL_ACTION"

    // ExternalEvent
    EvtMachineDone      MessageType = "MACHINE_DONE"
    EvtInspectionResult MessageType = "INSPECTION_RESULT"
    EvtPalletArrived    MessageType = "PALLET_ARRIVED"
    EvtStepTimeout      MessageType = "STEP_TIMEOUT"
    EvtRefillRequested  MessageType = "REFILL_REQUESTED"
    EvtScheduleTick     MessageType = "SCHEDULE_TICK"

    // WorkerResult
    ResRobotActionSucceeded   MessageType = "ROBOT_ACTION_SUCCEEDED"
    ResRobotActionFailed      MessageType = "ROBOT_ACTION_FAILED"
    ResToolActionSucceeded    MessageType = "TOOL_ACTION_SUCCEEDED"
    ResToolActionFailed       MessageType = "TOOL_ACTION_FAILED"
    ResMachineStartSucceeded  MessageType = "MACHINE_START_SUCCEEDED"
    ResMachineStartFailed     MessageType = "MACHINE_START_FAILED"
)

// EventLoopMessage 事件循环消息
type EventLoopMessage struct {
    ID            string
    Type          MessageType
    Payload       map[string]any
    CorrelationID string        // worker result 关联的 task.id
    Reply         chan MessageResult
    CreatedAt     time.Time
}

// MessageResult 消息处理结果(用于同步 reply)
type MessageResult struct {
    Success bool
    Error   string
    Data    map[string]any
}
  • Step 2: 编译验证
rtk go build ./internal/eventloop/...

Task 1.3: 创建 HardwareWorker 接口

Files:

  • Create: internal/eventloop/worker.go

  • Step 1: 写 worker.go(接口定义)

package eventloop

import "context"

// RobotAction 机器人动作请求
type RobotAction struct {
    TaskID       string
    Kind         string // load/unload/exchange/scan/mark/replenish
    JobID        int
    MachineID    int
    SlotNo       int
    TargetSlotNo int
    Params       map[string]any
}

// HardwareWorker 硬件执行器接口
type HardwareWorker interface {
    Execute(ctx context.Context, action RobotAction) error
}

// WorkerResultCallback worker 完成后的回调
type WorkerResultCallback func(msg EventLoopMessage)
  • Step 2: 编译验证
rtk go build ./internal/eventloop/...

Chunk 2: DBState 层(ent 原子条件更新)

Task 2.1: 创建 DBState 领域状态访问层

Files:

  • Create: internal/eventloop/dbstate.go

  • Create: internal/eventloop/dbstate_test.go

  • Step 1: 写 dbstate.go 接口和方法签名

package eventloop

import (
    "context"
    "fmt"
    "strconv"

    "hougai/constants"
    "hougai/ent"
    "hougai/ent/job"
    "hougai/ent/equipment"
    "hougai/ent/equipmentslot"
    "hougai/ent/task"
    "hougai/ent/workorder"
)

// DBState ent 原子条件更新封装,仅由 event loop 使用
type DBState struct {
    client *ent.Client
}

func NewDBState(client *ent.Client) *DBState {
    return &DBState{client: client}
}

// ClaimJobForAction 原子抢占工件:从 ON_BUFFER 转为 IN_HANDLING
// 防御性 WHERE status = expectedStatus,单 event loop 保证不会并发冲突
func (d *DBState) ClaimJobForAction(ctx context.Context, jobID int, expectedStatus constants.JobStatus) error {
    n, err := d.client.Job.Update().
        Where(job.IDEQ(jobID), job.StatusEQ(expectedStatus)).
        SetStatus(constants.JobStatus_InHandling).
        AddVersion(1).
        Save(ctx)
    if err != nil {
        return fmt.Errorf("claim job %d: %w", jobID, err)
    }
    if n == 0 {
        return fmt.Errorf("claim job %d: status not %s", jobID, expectedStatus)
    }
    return nil
}
  • Step 2: 添加更多领域方法
// MoveJobToEquipment 工件上设备:更新 job.position* + equipment_slot.status/currentJobId
func (d *DBState) MoveJobToEquipment(ctx context.Context, jobID, equipmentID, slotNo int) error {
    tx, err := d.client.Tx(ctx)
    if err != nil {
        return fmt.Errorf("move job %d to equipment: begin tx: %w", jobID, err)
    }
    defer tx.Rollback()

    posRef := fmt.Sprintf("%d:%d", equipmentID, slotNo)
    _, err = tx.Job.UpdateOneID(jobID).
        SetPositionType(constants.PositionType_OnEquipment).
        SetPositionRefId(posRef).
        AddVersion(1).
        Save(ctx)
    if err != nil {
        return fmt.Errorf("move job %d: update job: %w", jobID, err)
    }

    _, err = tx.EquipmentSlot.Update().
        Where(
            equipmentslot.EquipmentIdEQ(equipmentID),
            equipmentslot.SlotNoEQ(slotNo),
        ).
        SetStatus(constants.SlotStatus_Occupied).
        SetCurrentJobId(jobID).
        Save(ctx)
    if err != nil {
        return fmt.Errorf("move job %d: update slot: %w", jobID, err)
    }

    return tx.Commit()
}

// MoveJobToBuffer 工件回暂存台
func (d *DBState) MoveJobToBuffer(ctx context.Context, jobID, slotNo int) error {
    _, err := d.client.Job.UpdateOneID(jobID).
        SetPositionType(constants.PositionType_OnBuffer).
        SetPositionRefId(fmt.Sprintf("%d", slotNo)).
        SetTempSlotNo(slotNo).
        AddVersion(1).
        Save(ctx)
    return err
}

// CompleteStep 完成当前步骤,推进到下一步
func (d *DBState) CompleteStep(ctx context.Context, jobID, currentStepIndex, nextStepIndex int, contextPatch map[string]any) error {
    update := d.client.Job.UpdateOneID(jobID).
        SetCurrentStepIndex(nextStepIndex).
        AddVersion(1)
    if contextPatch != nil {
        update = update.SetContext(contextPatch)
    }
    _, err := update.Save(ctx)
    return err
}

// FinishJob 工件终态
func (d *DBState) FinishJob(ctx context.Context, jobID, workOrderID int, terminalStatus constants.JobStatus) error {
    tx, err := d.client.Tx(ctx)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 更新 job 为终态,条件为当前非终态
    _, err = tx.Job.UpdateOneID(jobID).
        Where(job.StatusNotIn(
            constants.JobStatus_Completed,
            constants.JobStatus_Scrapped,
        )).
        SetStatus(terminalStatus).
        ClearTempSlotNo(). // 释放暂存台槽位
        AddVersion(1).
        Save(ctx)
    if err != nil {
        return fmt.Errorf("finish job %d: %w", jobID, err)
    }

    // 条件递增工单计数器
    if terminalStatus == constants.JobStatus_Completed {
        _, _ = tx.WorkOrder.UpdateOneID(workOrderID).AddFinishedNum(1).Save(ctx)
    } else if terminalStatus == constants.JobStatus_Scrapped {
        _, _ = tx.WorkOrder.UpdateOneID(workOrderID).AddFailNum(1).Save(ctx)
    }

    return tx.Commit()
}

// SetEquipmentSlot 更新设备槽位状态
func (d *DBState) SetEquipmentSlot(ctx context.Context, equipmentID, slotNo int, expectedStatus, nextStatus constants.SlotStatus, jobID int) error {
    n, err := d.client.EquipmentSlot.Update().
        Where(
            equipmentslot.EquipmentIdEQ(equipmentID),
            equipmentslot.SlotNoEQ(slotNo),
            equipmentslot.StatusEQ(expectedStatus),
        ).
        SetStatus(nextStatus).
        SetCurrentJobId(jobID).
        Save(ctx)
    if err != nil {
        return fmt.Errorf("set slot %d-%d: %w", equipmentID, slotNo, err)
    }
    if n == 0 {
        return fmt.Errorf("set slot %d-%d: expected %s", equipmentID, slotNo, expectedStatus)
    }
    return nil
}

// GetJobState 读取单个 job 状态
func (d *DBState) GetJobState(ctx context.Context, jobID int) (*ent.Job, error) {
    return d.client.Job.Get(ctx, jobID)
}

// GetActiveJobs 获取所有非终态 job
func (d *DBState) GetActiveJobs(ctx context.Context) ([]*ent.Job, error) {
    return d.client.Job.Query().
        Where(job.StatusNotIn(
            constants.JobStatus_Completed,
            constants.JobStatus_Scrapped,
            constants.JobStatus_Cancelled,
        )).
        All(ctx)
}

// GetTempSlotOccupancy 扫描非终态 job 的 temp_slot_no 得到 1..8 占用位图
func (d *DBState) GetTempSlotOccupancy(ctx context.Context) (uint8, error) {
    jobs, err := d.GetActiveJobs(ctx)
    if err != nil {
        return 0, err
    }
    var bitmap uint8
    for _, j := range jobs {
        if j.TempSlotNo != nil {
            slot := *j.TempSlotNo
            if slot >= 1 && slot <= 8 {
                bitmap |= 1 << (slot - 1)
            }
        }
    }
    return bitmap, nil
}

// parsePositionRef 解析 "equipmentID:slotNo" 格式
func parsePositionRef(ref string) (int, int, error) {
    for i := 0; i < len(ref); i++ {
        if ref[i] == ':' {
            a, err1 := strconv.Atoi(ref[:i])
            b, err2 := strconv.Atoi(ref[i+1:])
            if err1 != nil || err2 != nil {
                return 0, 0, fmt.Errorf("invalid position ref %q", ref)
            }
            return a, b, nil
        }
    }
    return 0, 0, fmt.Errorf("invalid position ref format %q", ref)
}

// RaiseAlarm 创建报警
func (d *DBState) RaiseAlarm(ctx context.Context, alarmCode, message string, level constants.AlarmLevel, equipmentID, jobID int, source string) error {
    _, err := d.client.Alarm.Create().
        SetAlarmCode(alarmCode).
        SetAlarmMessage(message).
        SetLevel(level).
        SetNillableEquipmentId(&equipmentID).
        SetNillableJobId(&jobID).
        SetSource(source).
        Save(ctx)
    return err
}

// CreateManualAction 创建人工恢复动作
func (d *DBState) CreateManualAction(ctx context.Context, actionType constants.ManualActionType, jobID int, description string) error {
    _, err := d.client.ManualAction.Create().
        SetActionType(actionType).
        SetJobId(jobID).
        SetContext(map[string]any{"description": description}).
        SetStatus(constants.ManualActionStatus_Pending).
        Save(ctx)
    return err
}
  • Step 3: 编译验证
rtk go build ./internal/eventloop/...

Chunk 3: ProductionEventLoop 核心

Task 3.1: 创建 event loop 主循环

Files:

  • Create: internal/eventloop/loop.go

  • Step 1: 写 loop.go

package eventloop

import (
    "context"
    "fmt"
    "log/slog"
    "time"

    "hougai/ent"
    "hougai/internal/scheduler"
    "hougai/internal/station"
    "hougai/internal/robot"
)

// ProductionEventLoop 产线事件循环:唯一生产状态写路径
type ProductionEventLoop struct {
    db          *DBState
    sched       *scheduler.Scheduler
    registry    *station.StationRegistry
    robotCtrl   *robot.Controller
    entClient   *ent.Client

    // 消息队列
    msgCh chan EventLoopMessage

    // 硬件 worker
    worker HardwareWorker

    // 运行时快照
    jobRuntimes map[int]*RuntimeSnapshot

    // SSE/事件日志回调
    onStateChanged func(jobID int, status string, positionType string, positionRefID string)
    onAlarmRaised  func(alarmCode, message string, jobID int)

    // 停止
    stopCh chan struct{}
}

// RuntimeSnapshot 工件运行时快照(轻量,只供调度用)
type RuntimeSnapshot struct {
    JobID         int
    WorkOrderID   int
    Status        string
    PositionType  string
    PositionRefID string
    StepIndex     int
    TempSlotNo    int
}

func NewProductionEventLoop(
    entClient *ent.Client,
    sched *scheduler.Scheduler,
    registry *station.StationRegistry,
    robotCtrl *robot.Controller,
    worker HardwareWorker,
) *ProductionEventLoop {
    return &ProductionEventLoop{
        db:          NewDBState(entClient),
        sched:       sched,
        registry:    registry,
        robotCtrl:   robotCtrl,
        entClient:   entClient,
        worker:      worker,
        msgCh:       make(chan EventLoopMessage, 256),
        jobRuntimes: make(map[int]*RuntimeSnapshot),
        stopCh:      make(chan struct{}),
    }
}

// Send 向事件循环投递消息(非阻塞,channel 满时阻塞)
func (l *ProductionEventLoop) Send(msg EventLoopMessage) {
    l.msgCh <- msg
}

// SendSync 投递消息并等待结果(同步 API 使用)
func (l *ProductionEventLoop) SendSync(msg EventLoopMessage, timeout time.Duration) (*MessageResult, error) {
    replyCh := make(chan MessageResult, 1)
    msg.Reply = replyCh
    l.msgCh <- msg
    select {
    case result := <-replyCh:
        return &result, nil
    case <-time.After(timeout):
        return nil, fmt.Errorf("event loop timeout")
    }
}

// Run 启动事件循环(阻塞,在 goroutine 中运行)
func (l *ProductionEventLoop) Run(ctx context.Context) {
    slog.Info("event loop: started")
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case msg := <-l.msgCh:
            l.handleMessage(ctx, msg)
        case <-ticker.C:
            l.trySchedule(ctx)
        case <-l.stopCh:
            slog.Info("event loop: stopped")
            return
        case <-ctx.Done():
            slog.Info("event loop: context done")
            return
        }
    }
}

// Stop 停止事件循环
func (l *ProductionEventLoop) Stop() {
    close(l.stopCh)
}
  • Step 2: 实现消息分发
func (l *ProductionEventLoop) handleMessage(ctx context.Context, msg EventLoopMessage) {
    slog.Debug("event loop: handling message", "type", msg.Type, "id", msg.ID)

    switch msg.Type {
    // Command
    case CmdStartOrder:
        l.handleStartOrder(ctx, msg)
    case CmdPauseOrder:
        l.handlePauseOrder(ctx, msg)
    case CmdResumeOrder:
        l.handleResumeOrder(ctx, msg)
    case CmdCancelOrder:
        l.handleCancelOrder(ctx, msg)
    case CmdSuspendJob:
        l.handleSuspendJob(ctx, msg)
    case CmdResumeJob:
        l.handleResumeJob(ctx, msg)
    case CmdReworkJob:
        l.handleReworkJob(ctx, msg)
    case CmdConfirmRecovery:
        l.handleConfirmRecovery(ctx, msg)
    case CmdResolveManualAction:
        l.handleResolveManualAction(ctx, msg)

    // ExternalEvent
    case EvtMachineDone:
        l.handleMachineDone(ctx, msg)
    case EvtInspectionResult:
        l.handleInspectionResult(ctx, msg)
    case EvtPalletArrived:
        l.handlePalletArrived(ctx, msg)
    case EvtStepTimeout:
        l.handleStepTimeout(ctx, msg)
    case EvtRefillRequested:
        l.handleRefillRequested(ctx, msg)

    // WorkerResult
    case ResRobotActionSucceeded, ResRobotActionFailed,
         ResToolActionSucceeded, ResToolActionFailed,
         ResMachineStartSucceeded, ResMachineStartFailed:
        l.handleWorkerResult(ctx, msg)
    }
}

// reply 发送同步响应
func (l *ProductionEventLoop) reply(msg EventLoopMessage, success bool, errMsg string, data map[string]any) {
    if msg.Reply != nil {
        msg.Reply <- MessageResult{Success: success, Error: errMsg, Data: data}
    }
}
  • Step 3: 编译验证
rtk go build ./internal/eventloop/...

Task 3.2: 实现调度触发方法 trySchedule

Files:

  • Modify: internal/eventloop/loop.go (追加)

  • Step 1: 实现 trySchedule

func (l *ProductionEventLoop) trySchedule(ctx context.Context) {
    if l.sched == nil {
        return
    }

    // 从 DB 加载活跃 job 快照
    activeJobs, err := l.db.GetActiveJobs(ctx)
    if err != nil {
        slog.Error("event loop: get active jobs failed", "error", err)
        return
    }
    if len(activeJobs) == 0 {
        return
    }

    // 更新内存快照
    for _, j := range activeJobs {
        ts := j.TempSlotNo
        tsn := 0
        if ts != nil {
            tsn = *ts
        }
        l.jobRuntimes[j.ID] = &RuntimeSnapshot{
            JobID:         j.ID,
            WorkOrderID:   j.WorkOrderId,
            Status:        string(j.Status),
            PositionType:  string(j.PositionType),
            PositionRefID: j.PositionRefId,
            StepIndex:     j.CurrentStepIndex,
            TempSlotNo:    tsn,
        }
    }

    // 构建 JobView + SystemState 并调用 scheduler
    // 这里适配现有 scheduler_adapter.go 的逻辑
    candidates := l.runScheduler(ctx, activeJobs)
    for _, ct := range candidates {
        l.submitCandidate(ctx, ct)
    }
}
  • Step 2: 编译验证
rtk go build ./internal/eventloop/...

Task 3.3: 实现核心 handler 方法(MachineDone / InspectionResult / WorkerResult

Files:

  • Modify: internal/eventloop/loop.go(追加)

  • Step 1: 实现 handleMachineDone

func (l *ProductionEventLoop) handleMachineDone(ctx context.Context, msg EventLoopMessage) {
    machineID := intFromPayload(msg.Payload, "machineId")
    if machineID == 0 {
        return
    }
    slotNo := intFromPayload(msg.Payload, "slot")
    jobIDFromPayload := intFromPayload(msg.Payload, "jobId")

    // 查询设备是否批量完成
    equip, err := l.entClient.Equipment.Get(ctx, machineID)
    if err != nil {
        slog.Error("event loop: machine done, equipment not found", "machineId", machineID, "error", err)
        return
    }

    if equip.Batch {
        // 批量完成:所有 OCCUPIED 槽位 → DONE
        slots, _ := l.entClient.EquipmentSlot.Query().
            Where(equipmentslot.EquipmentIdEQ(machineID), equipmentslot.StatusEQ(constants.SlotStatus_Occupied)).
            All(ctx)
        for _, s := range slots {
            l.db.SetEquipmentSlot(ctx, machineID, s.SlotNo, constants.SlotStatus_Occupied, constants.SlotStatus_Done, s.CurrentJobId)
            l.updateJobToWaitingUnload(ctx, s.CurrentJobId)
        }
    } else {
        // 非批量:确定目标槽位
        targetSlot := slotNo
        if targetSlot < 1 {
            // 多槽位非批量设备 FIFO
            slot, _ := l.entClient.EquipmentSlot.Query().
                Where(equipmentslot.EquipmentIdEQ(machineID), equipmentslot.StatusEQ(constants.SlotStatus_Occupied)).
                Order(ent.Asc(equipmentslot.FieldSlotNo)).
                First(ctx)
            if slot != nil {
                targetSlot = slot.SlotNo
            }
        }
        // 交叉校验 jobID
        slot, _ := l.entClient.EquipmentSlot.Query().
            Where(equipmentslot.EquipmentIdEQ(machineID), equipmentslot.SlotNoEQ(targetSlot)).
            First(ctx)
        if slot == nil {
            return
        }
        if jobIDFromPayload > 0 && slot.CurrentJobId != jobIDFromPayload {
            l.db.RaiseAlarm(ctx, "SLOT_JOB_MISMATCH",
                fmt.Sprintf("machine %d slot %d: expected job %d, got %d", machineID, targetSlot, slot.CurrentJobId, jobIDFromPayload),
                constants.AlarmLevel_ERROR, machineID, slot.CurrentJobId, "event-loop")
            return
        }
        l.db.SetEquipmentSlot(ctx, machineID, targetSlot, constants.SlotStatus_Occupied, constants.SlotStatus_Done, slot.CurrentJobId)
        l.updateJobToWaitingUnload(ctx, slot.CurrentJobId)
    }

    l.trySchedule(ctx)
}
  • Step 2: 实现 handleInspectionResult
func (l *ProductionEventLoop) handleInspectionResult(ctx context.Context, msg EventLoopMessage) {
    machineID := intFromPayload(msg.Payload, "machineId")
    jobID := intFromPayload(msg.Payload, "jobId")
    pass, _ := msg.Payload["pass"].(bool)

    job, err := l.entClient.Job.Get(ctx, jobID)
    if err != nil {
        return
    }
    // 写入检测结果到 context
    ctx_ := job.Context
    if ctx_ == nil {
        ctx_ = make(map[string]any)
    }
    ctx_["inspectionPass"] = pass
    _, _ = l.entClient.Job.UpdateOneID(jobID).SetContext(ctx_).Save(ctx)

    if pass {
        // 检测通过:推进步骤
        l.advanceJobStep(ctx, jobID)
    } else {
        // 检测 NG:创建报警
        l.db.RaiseAlarm(ctx, "INSPECTION_FAIL",
            fmt.Sprintf("job %d inspection failed", jobID),
            constants.AlarmLevel_WARN, machineID, jobID, "event-loop")
        l.db.FinishJob(ctx, jobID, job.WorkOrderId, constants.JobStatus_Scrapped)
    }

    l.trySchedule(ctx)
}
  • Step 3: 实现 handleWorkerResult(含 stale result 防护)
func (l *ProductionEventLoop) handleWorkerResult(ctx context.Context, msg EventLoopMessage) {
    taskID := msg.CorrelationID
    if taskID == "" {
        slog.Warn("event loop: worker result without correlation ID")
        return
    }

    // 查询 task 是否仍在 RUNNING
    task, err := l.entClient.Task.Get(ctx, taskID)
    if err != nil || task.Status != constants.TaskStatus_Running {
        slog.Info("event loop: stale worker result ignored", "taskId", taskID, "status", task.Status)
        return
    }

    jobID := intFromPayload(msg.Payload, "jobId")
    success := msg.Type == ResRobotActionSucceeded || msg.Type == ResToolActionSucceeded || msg.Type == ResMachineStartSucceeded

    if success {
        // 标记 task 完成
        l.entClient.Task.UpdateOneID(taskID).
            SetStatus(constants.TaskStatus_Completed).
            SetCompletedAt(time.Now()).
            Save(ctx)
        // 推进 job 步骤
        l.advanceJobStep(ctx, jobID)
    } else {
        // 标记 task 失败
        l.entClient.Task.UpdateOneID(taskID).
            SetStatus(constants.TaskStatus_Failed).
            SetCompletedAt(time.Now()).
            Save(ctx)
        // job 挂起
        l.entClient.Job.UpdateOneID(jobID).
            SetStatus(constants.JobStatus_Suspended).
            SetSuspendedReason("action_failed").
            Save(ctx)
        l.db.RaiseAlarm(ctx, "ACTION_FAILED",
            fmt.Sprintf("job %d task %s failed", jobID, taskID),
            constants.AlarmLevel_ERROR, 0, jobID, "event-loop")
    }

    l.trySchedule(ctx)
}
  • Step 4: 实现 advanceJobStep 和 updateJobToWaitingUnload 辅助方法
func (l *ProductionEventLoop) advanceJobStep(ctx context.Context, jobID int) {
    job, err := l.entClient.Job.Get(ctx, jobID)
    if err != nil {
        return
    }
    l.db.CompleteStep(ctx, jobID, job.CurrentStepIndex, job.CurrentStepIndex+1, nil)
}

func (l *ProductionEventLoop) updateJobToWaitingUnload(ctx context.Context, jobID int) {
    _, _ = l.entClient.Job.UpdateOneID(jobID).
        SetStatus(constants.JobStatus_WaitingUnload).
        AddVersion(1).
        Save(ctx)
}
  • Step 5: 添加 intFromPayload 辅助函数
func intFromPayload(p map[string]any, key string) int {
    switch v := p[key].(type) {
    case int:
        return v
    case float64:
        return int(v)
    }
    return 0
}
  • Step 6: 编译验证
rtk go build ./internal/eventloop/...

Chunk 4: ServiceContext 重构

Task 4.1: 重写 service_context.go

Files:

  • Modify: internal/svc/service_context.go

  • Modify: internal/config/config.go(删除冗余引用)

  • Step 1: 删除 go-redis 依赖和 Redis/SSOT 初始化代码

// 删除
// import goredis "github.com/redis/go-redis/v9"

// 删除 go-redis 客户端初始化
// var goRedisClient *goredis.Client
// if c.EventBus.Enabled || c.SSOT.Enabled { ... }

// 删除 RedisBus 初始化
// bus = eventbus.NewRedisBus(...)

// 删除 RedisStateManager 初始化
// stateMgr = state.NewRedisStateManager(goRedisClient)

// 删除事件重放
// replayer := recovery.NewReplayer(entClient, stateMgr)

// 删除 SSOT 写穿
// writer := state.NewWriteThroughHelper(stateMgr, true)

// 删除 ZPopLoop
// go scheduler.ZPopLoop(...)

// 删除状态校验器
// verifier := verify.NewStateVerifier(stateMgr, ...)

// 删除 RedisToolLocker
// toolLocker := state.NewRedisToolLocker(goRedisClient)
  • Step 2: 新增 EventLoop + HardwareWorker 初始化
// 事件总线:始终使用 LocalBus(单进程模式)
bus := eventbus.NewLocalBus()

// 硬件 worker:封装 robot controller
worker := processor.NewRobotWorker(robotCtrl, scanner, marker)

// 事件循环引擎
var sched *scheduler.Scheduler
sched = scheduler.DefaultScheduler()
sched.SetToolLockChecker(nil) // no tool lock needed in single-worker mode

eventLoop := eventloop.NewProductionEventLoop(
    entClient, sched, registry, robotCtrl, worker,
)
eventLoop.SetOnStateChanged(func(jobID int, status, positionType, positionRefID string) {
    bus.Publish(context.Background(), eventbus.Event{
        ID:   fmt.Sprintf("el-%d-%d", jobID, time.Now().UnixNano()),
        Type: eventbus.EventJobStatusChanged,
        Payload: map[string]interface{}{
            "jobId":        jobID,
            "status":       status,
            "positionType": positionType,
            "positionRefId": positionRefID,
        },
    })
})
eventLoop.SetOnAlarmRaised(func(alarmCode, message string, jobID int) {
    bus.Publish(context.Background(), eventbus.Event{
        Type: eventbus.EventAlarmRaised,
        Payload: map[string]interface{}{
            "alarmCode": alarmCode,
            "message":   message,
            "jobId":     jobID,
        },
    })
})

// SignalWatcher 回调改为投递到 event loop
signalWatcher.OnMachineDone = func(machineID int) {
    eventLoop.Send(eventloop.EventLoopMessage{
        ID:   fmt.Sprintf("sw-%d-%d", machineID, time.Now().UnixNano()),
        Type: eventloop.EvtMachineDone,
        Payload: map[string]any{"machineId": machineID},
    })
}
signalWatcher.OnInspectionResult = func(machineID int, pass bool) {
    eventLoop.Send(eventloop.EventLoopMessage{
        ID:   fmt.Sprintf("sw-insp-%d-%d", machineID, time.Now().UnixNano()),
        Type: eventloop.EvtInspectionResult,
        Payload: map[string]any{"machineId": machineID, "pass": pass},
    })
}

// 启动事件循环(在 bus 之后)
go eventLoop.Run(context.Background())
  • Step 3: ServiceContext 字段更新
type ServiceContext struct {
    Config          config.Config
    EntClient       *ent.Client
    SSEHandler      *sse.Handler
    PLCManager      *plc.Manager
    RobotManager    *robot.Manager
    StationRegistry *station.StationRegistry
    RecipeLoader    *processor.RecipeLoader
    EventLoop       *eventloop.ProductionEventLoop  // 替代 OrderProcessor
    EventBus        eventbus.Bus                      // 仅 LocalBus
    EventLogWriter  *eventlog.EventLogWriter
    SSEBridge       *sse.EventBridge
}
  • Step 4: 添加 SSE/EventLog 回调注册任务

NewProductionEventLoop 中添加额外回调设置方法,并在 service_context 中串联:

// loop.go 追加
func (l *ProductionEventLoop) SetOnStateChanged(fn func(jobID int, status, positionType, positionRefID string)) {
    l.onStateChanged = fn
}

func (l *ProductionEventLoop) SetOnAlarmRaised(fn func(alarmCode, message string, jobID int)) {
    l.onAlarmRaised = fn
}

在 loop 的 handleWorkerResultadvanceJobStepupdateJobToWaitingUnload 等方法末尾调用:

if l.onStateChanged != nil {
    l.onStateChanged(jobID, string(job.Status), string(job.PositionType), job.PositionRefId)
}

在 service_context 中,EventLoop 初始化后注册:

eventLoop.SetOnStateChanged(func(jobID int, status, positionType, positionRefID string) {
    bus.Publish(context.Background(), eventbus.NewJobStatusChangedEvent(
        jobID, 0, status, positionType, positionRefID, 0,
    ))
})
eventLoop.SetOnAlarmRaised(func(alarmCode, message string, jobID int) {
    bus.Publish(context.Background(), eventbus.Event{
        ID:   fmt.Sprintf("alarm-%d-%d", jobID, time.Now().UnixNano()),
        Type: eventbus.EventAlarmRaised,
        Payload: map[string]interface{}{
            "alarmCode": alarmCode,
            "message":   message,
            "jobId":     jobID,
        },
    })
})

同时保留 bus.SubscribeAll 订阅所有事件写入 event_log

bus.SubscribeAll(func(ctx context.Context, evt eventbus.Event) error {
    eventLogWriter.Write(evt)
    return nil
})
  • Step 5: 编译验证
rtk go build ./...

Chunk 5: Processor 精简 + Worker 适配

Task 5.1: 创建 RobotWorker 适配器

Files:

  • Create: internal/processor/robot_worker.go

  • Step 1: 实现 HardwareWorker 接口

package processor

import (
    "context"
    "log/slog"

    "hougai/internal/eventloop"
    "hougai/internal/robot"
    "hougai/internal/station"
)

// RobotWorker 适配 robot.Controller + handheld tools 到 eventloop.HardwareWorker
type RobotWorker struct {
    robotCtrl *robot.Controller
    scanner   station.HandheldTool
    marker    station.HandheldTool
}

func NewRobotWorker(robotCtrl *robot.Controller, scanner, marker station.HandheldTool) *RobotWorker {
    return &RobotWorker{robotCtrl: robotCtrl, scanner: scanner, marker: marker}
}

func (w *RobotWorker) Execute(ctx context.Context, action eventloop.RobotAction) error {
    slog.Info("robot worker: executing", "taskId", action.TaskID, "kind", action.Kind, "jobId", action.JobID)

    switch action.Kind {
    case "load":
        return w.robotCtrl.LoadToMachine(ctx, action.MachineID, action.SlotNo)
    case "unload":
        return w.robotCtrl.UnloadFromMachine(ctx, action.MachineID, action.SlotNo, action.TargetSlotNo)
    case "exchange":
        return w.robotCtrl.ExchangeAtMachine(ctx, action.MachineID, action.SlotNo, action.TargetSlotNo)
    case "replenish":
        return w.robotCtrl.ReplenishFromDock(ctx, action.TargetSlotNo)
    case "scan":
        return w.scanner.Execute(ctx, action.Params)
    case "mark":
        return w.marker.Execute(ctx, action.Params)
    default:
        slog.Warn("robot worker: unknown action kind", "kind", action.Kind)
        return nil
    }
}
  • Step 2: 编译验证
rtk go build ./internal/processor/...

Task 5.2: 精简 job_processor.go

Files:

  • Modify: internal/processor/job_processor.go

  • Step 1: 删除 ssotWriter、toolLocker、readyQueue、scheduleMu 字段

// 删除以下字段
// ssotWriter    *state.WriteThroughHelper
// toolLocker    state.ToolLocker
// readyQueue    *ReadyQueue
// scheduleMu    sync.Mutex
  • Step 2: 删除 SetSSOTWriter、SetToolLocker、SetStateManager 方法

  • Step 3: 删除 startSubsystems 方法(由 event loop 替代)

  • Step 4: 删除 notifyReady、ScheduleAndSubmit、scheduleReadyJobs、scheduleAllActiveJobs、SyncSSOTStatus、ScheduleSingleJob 方法

  • Step 5: 保留 StartOrder、PauseOrder 等方法签名为公开方法,内部改为向 event loop 投递消息

func (jp *JobProcessor) PauseOrder(eventLoop *eventloop.ProductionEventLoop, orderID int) error {
    result, err := eventLoop.SendSync(eventloop.EventLoopMessage{
        ID:   fmt.Sprintf("cmd-pause-order-%d-%d", orderID, time.Now().UnixNano()),
        Type: eventloop.CmdPauseOrder,
        Payload: map[string]any{"orderId": orderID},
    }, 5*time.Second)
    if err != nil {
        return fmt.Errorf("pause order %d: %w", orderID, err)
    }
    if !result.Success {
        return fmt.Errorf("pause order %d: %s", orderID, result.Error)
    }
    return nil
}
  • Step 6: 编译验证
rtk go build ./...

Task 5.3: 精简 job_runtime.go

Files:

  • Modify: internal/processor/job_runtime.go

  • Step 1: 删除 onStatusSync、ssotVersion、terminalCounted、notifyReady 字段

  • Step 2: 删除 syncSSOTStatus 调用(syncStatusToDB 中移除 onStatusSync 分支)

  • Step 3: 保留 buildTask、buildLoadAction、buildUnloadAction 等动作构建方法

  • Step 4: 编译验证

rtk go build ./internal/processor/...

Chunk 6: 恢复 & 启动流程

Task 6.1: 创建恢复模块

Files:

  • Create: internal/eventloop/recovery.go

  • Step 1: 写恢复逻辑

package eventloop

import (
    "context"
    "log/slog"

    "hougai/constants"
    "hougai/ent/workorder"
)

// RecoverOnStartup 启动时执行保守人工确认恢复
// 1. 将所有 IN_PROGRESS 的 work_order 改为 PAUSED
// 2. 将所有非终态 job 改为 SUSPENDED
// 3. 校验 consistencytemp_slot_no 重复/越界、equipment_slot 与 job 位置不匹配)
// 4. 不一致项创建 manual_action
func (d *DBState) RecoverOnStartup(ctx context.Context) (recoveredJobs int, manualActions int, err error) {
    // 将所有 IN_PROGRESS 工单改为 PAUSED
    paused, err := d.client.WorkOrder.Update().
        Where(workorder.StatusEQ(constants.WorkOrderStatus_InProgress)).
        SetStatus(constants.WorkOrderStatus_Paused).
        Save(ctx)
    if err != nil {
        return 0, 0, err
    }
    slog.Info("recovery: paused work orders", "count", paused)

    // 校验 temp_slot_no 一致性
    jobs, err := d.GetActiveJobs(ctx)
    if err != nil {
        return 0, 0, err
    }

    slotUsers := make(map[int][]int) // tempSlotNo -> []jobID
    for _, j := range jobs {
        if j.TempSlotNo != nil {
            slot := *j.TempSlotNo
            if slot < 1 || slot > 8 {
                // 越界槽位
                d.CreateManualAction(ctx, constants.ManualActionType_RecoveryRequired, j.ID,
                    "temp_slot_no out of range")
                manualActions++
                continue
            }
            slotUsers[slot] = append(slotUsers[slot], j.ID)
        }
    }

    for slot, users := range slotUsers {
        if len(users) > 1 {
            slog.Warn("recovery: duplicate temp slot", "slot", slot, "jobs", users)
            for _, jID := range users {
                d.CreateManualAction(ctx, constants.ManualActionType_RecoveryRequired, jID,
                    "duplicate temp_slot_no")
                manualActions++
            }
        }
    }

    // 校验 equipment_slot 与 job 位置一致性
    for _, j := range jobs {
        if j.PositionType == constants.PositionType_OnEquipment {
            equipID, slotNo, slotErr := parsePositionRef(j.PositionRefId)
            if slotErr != nil {
                continue
            }
            slot, slotErr := d.client.EquipmentSlot.Query().
                Where(equipmentslot.EquipmentIdEQ(equipID), equipmentslot.SlotNoEQ(slotNo)).
                First(ctx)
            if slotErr != nil || slot.CurrentJobId != j.ID {
                d.CreateManualAction(ctx, constants.ManualActionType_RecoveryRequired, j.ID,
                    "equipment_slot inconsistent with job.position")
                manualActions++
            }
        }
    }

    return len(jobs), manualActions, nil
}
  • Step 2: 编译验证
rtk go build ./internal/eventloop/...

Task 6.2: 在 service_context.go 中接入恢复流程

  • Step 1: 在 eventLoop.Run() 之前添加恢复调用
// 启动恢复
recovered, manualActions, err := eventloop.NewDBState(entClient).RecoverOnStartup(context.Background())
if err != nil {
    slog.Error("svc: startup recovery failed", "error", err)
} else {
    slog.Info("svc: startup recovery complete", "recoveredJobs", recovered, "manualActions", manualActions)
}

// 启动事件循环
go eventLoop.Run(context.Background())

Chunk 7: 清理死代码

Task 7.1: 删除 Redis 依赖文件

  • Step 1: 删除 state 包中 Redis 相关文件
rm internal/state/redis_manager.go
rm internal/state/state_write_through.go
rm internal/state/keys.go
  • Step 2: 删除 scheduler 中 Redis 相关文件
rm internal/scheduler/zpop.go
  • Step 3: 删除 recovery 中 event_log 重放文件
rm internal/recovery/replayer.go
rm internal/recovery/replayer_test.go
  • Step 4: 删除 verify 中 Redis 校验文件
rm internal/verify/state_verifier.go
rm internal/verify/state_verifier_test.go
  • Step 5: 删除 processor 中已废弃文件
rm internal/processor/ready_queue.go
rm internal/processor/ready_queue_test.go
rm internal/processor/temp_slot_allocator.go
rm internal/processor/temp_slot_allocator_test.go
rm internal/processor/ssot_integration_test.go
  • Step 6: 编译验证
rtk go build ./...

Chunk 8: EventBus 简化

Task 8.1: 删除 RedisBus,保留 LocalBus + Bus 接口

Files:

  • Modify: internal/eventbus/bus.go

  • Delete: internal/eventbus/local.go 中的 redis import(如果有)

  • Step 1: 从 bus.go 删除 RedisBus 实现,仅保留 Bus 接口和 Event 结构体

  • Step 2: 确保 LocalBus 完整可用

  • Step 3: 编译验证

rtk go build ./internal/eventbus/...

Chunk 9: Handler/Logic 适配

Task 9.1: 更新 handler 以使用 EventLoop

Files:

  • Modify: internal/handler/ 下的 handler 文件(通过 api 生成)

由于 handler 是自动生成的,修改应集中在 logic 层:

  • Step 1: 在 logic 文件中将 OrderProcessor 调用替换为 EventLoop.Send/SendSync
// 旧: l.svcCtx.OrderProcessor.StartOrder(ctx, orderID)
// 新: l.svcCtx.EventLoop.Send(eventloop.EventLoopMessage{...})
  • Step 2: 编译验证
rtk go build ./...

Chunk 10: 测试

Task 10.1: DBState 集成测试

Files:

  • Create: internal/eventloop/dbstate_test.go

  • Step 1: 写 ClaimJobForAction 测试

func TestDBState_ClaimJobForAction(t *testing.T) {
    // 使用 ent 内存 SQLite 或测试 PG
    // 创建 job (ON_BUFFER) → ClaimJobForAction → 验证 IN_HANDLING
    // 验证重复 Claim 失败
}
  • Step 2: 写 MoveJobToEquipment/FinishJob 事务测试

  • Step 3: 写 GetTempSlotOccupancy 测试

  • Step 4: 运行测试

rtk go test ./internal/eventloop/... -v

Task 10.2: EventLoop 单元测试

Files:

  • Create: internal/eventloop/loop_test.go

  • Step 1: 写消息处理测试(使用 mock worker

func TestEventLoop_StartOrder(t *testing.T) {
    // 投递 CmdStartOrder → 验证 job 状态变更 → 验证 reply
}
  • Step 2: 写 worker result 关联测试

  • Step 3: 写 stale result 忽略测试

  • Step 4: 运行测试

rtk go test ./internal/eventloop/... -v

Task 10.3: 恢复测试

Files:

  • Create: internal/eventloop/recovery_test.go

  • Step 1: 写 duplicate temp slot 测试

  • Step 2: 写 temp slot 越界测试

  • Step 3: 运行测试

rtk go test ./internal/eventloop/... -v

Task 10.4: 运行全量测试

  • Step 1: 修复编译失败
rtk go build ./...
  • Step 2: 运行测试
rtk go test ./... 2>&1 | head -100
  • Step 3: 修复测试失败

验收检查

  • go build ./... 通过
  • go vet ./... 通过
  • 核心流程不依赖 Redis(搜索 go-redis import 仅出现在非核心文件)
  • 配置不再需要 EventBus.EnabledSSOT.Enabled
  • ProductionEventLoop 是唯一状态写路径
  • Mock E2Ego run cmd/mockrun/main.go 或等价)通过完整工单流程