Files
bj_power/bj_power_mes/docs/superpowers/plans/2026-05-03-redis-ssot-phase3.md
T

14 KiB
Raw Blame History

Phase 3: 工具锁 + 工序超时 + 状态校验 实施计划

For agentic workers: 按步骤执行,每步通过 rtk go build ./... 验证后继续。

Goal: 实现设计文档要求的工具互斥锁、工序级超时自动挂起、PLC-Redis 周期性状态校验三个缺失机制。

Architecture: 三个独立机制:

  1. 工具锁基于 Redis SET NX EX + 调度器约束过滤,确保 Scanner/LaserMarker 互斥
  2. 工序超时在 JobRuntime 中启动 step 级 TTL goroutine,超时自动触发 SuspendJob
  3. PLC-Redis 校验通过后台 Ticker 抽样对比物理/逻辑状态,不一致产生报警

Tech Stack: Go 1.23.4, Redis (go-redis/v9), ent ORM


Chunk 1: 工具锁机制

Task 1: Redis 工具锁

Files:

  • Create: internal/state/tool_lock.go

  • Create: internal/state/tool_lock_test.go

  • Step 1: 创建 internal/state/tool_lock.go

package state

import (
	"context"
	"fmt"
	"time"

	goredis "github.com/redis/go-redis/v9"
)

const (
	ToolLockKeyPrefix = "tool:lock:"
	ToolLockTTL       = 30 * time.Second
)

// ToolLocker 手持工具 Redis 互斥锁接口
type ToolLocker interface {
	Acquire(ctx context.Context, toolType string) (bool, error)
	Release(ctx context.Context, toolType string) error
}

// RedisToolLocker 基于 Redis SET NX EX 的工具锁
type RedisToolLocker struct {
	client *goredis.Client
}

func NewRedisToolLocker(client *goredis.Client) *RedisToolLocker {
	return &RedisToolLocker{client: client}
}

func toolLockKey(toolType string) string {
	return ToolLockKeyPrefix + toolType
}

func (l *RedisToolLocker) Acquire(ctx context.Context, toolType string) (bool, error) {
	ok, err := l.client.SetNX(ctx, toolLockKey(toolType), "1", ToolLockTTL).Result()
	if err != nil {
		return false, fmt.Errorf("tool lock acquire %s: %w", toolType, err)
	}
	return ok, nil
}

func (l *RedisToolLocker) Release(ctx context.Context, toolType string) error {
	if err := l.client.Del(ctx, toolLockKey(toolType)).Err(); err != nil {
		return fmt.Errorf("tool lock release %s: %w", toolType, err)
	}
	return nil
}

// IsLocked 检查工具是否被锁定(供约束过滤使用)
func (l *RedisToolLocker) IsLocked(ctx context.Context, toolType string) (bool, error) {
	val, err := l.client.Exists(ctx, toolLockKey(toolType)).Result()
	if err != nil {
		return false, err
	}
	return val > 0, nil
}

// MemoryToolLocker 内存回退(非 Redis 模式)
type MemoryToolLocker struct {
	locks map[string]bool
}

func NewMemoryToolLocker() *MemoryToolLocker {
	return &MemoryToolLocker{locks: make(map[string]bool)}
}

func (l *MemoryToolLocker) Acquire(_ context.Context, toolType string) (bool, error) {
	if l.locks[toolType] {
		return false, nil
	}
	l.locks[toolType] = true
	return true, nil
}

func (l *MemoryToolLocker) Release(_ context.Context, toolType string) error {
	delete(l.locks, toolType)
	return nil
}

func (l *MemoryToolLocker) IsLocked(_ context.Context, toolType string) (bool, error) {
	return l.locks[toolType], nil
}
  • Step 2: 验证编译

Run: rtk go build ./internal/state

  • Step 3: 创建 internal/state/tool_lock_test.go
package state

import (
	"context"
	"testing"
)

func TestMemoryToolLocker_AcquireRelease(t *testing.T) {
	locker := NewMemoryToolLocker()
	ctx := context.Background()

	ok, err := locker.Acquire(ctx, "SCANNER")
	if err != nil || !ok {
		t.Fatal("first acquire should succeed")
	}

	ok, _ = locker.Acquire(ctx, "SCANNER")
	if ok {
		t.Fatal("second acquire should fail")
	}

	locked, _ := locker.IsLocked(ctx, "SCANNER")
	if !locked {
		t.Fatal("should be locked")
	}

	locker.Release(ctx, "SCANNER")
	locked, _ = locker.IsLocked(ctx, "SCANNER")
	if locked {
		t.Fatal("should be released")
	}
}

func TestMemoryToolLocker_DifferentTools(t *testing.T) {
	locker := NewMemoryToolLocker()
	ctx := context.Background()

	ok1, _ := locker.Acquire(ctx, "SCANNER")
	ok2, _ := locker.Acquire(ctx, "LASER")
	if !ok1 || !ok2 {
		t.Fatal("different tools should not block each other")
	}
}
  • Step 4: 运行测试

Run: rtk go test ./internal/state/ -run TestMemoryToolLocker -v


Task 2: 调度器工具锁约束

Files:

  • Create: internal/scheduler/constraint_tool_lock.go

  • Modify: internal/scheduler/scheduler.go:70-79

  • Step 1: 创建 internal/scheduler/constraint_tool_lock.go

package scheduler

import (
	"context"
	"log/slog"
)

// ToolLockChecker 工具锁检查接口(解耦 Redis 依赖)
type ToolLockChecker interface {
	IsLocked(ctx context.Context, toolType string) (bool, error)
}

// ToolLockConstraint 工具锁约束:过滤掉需要已锁定工具的候选任务
type ToolLockConstraint struct {
	checker ToolLockChecker
}

func NewToolLockConstraint(checker ToolLockChecker) *ToolLockConstraint {
	return &ToolLockConstraint{checker: checker}
}

func (c *ToolLockConstraint) Name() string { return "ToolLock" }

func (c *ToolLockConstraint) Filter(ctx context.Context, tasks []CandidateTask, _ SystemState) []CandidateTask {
	if c.checker == nil {
		return tasks
	}

	var result []CandidateTask
	for _, t := range tasks {
		if t.ToolType == "" {
			result = append(result, t)
			continue
		}
		locked, err := c.checker.IsLocked(ctx, t.ToolType)
		if err != nil {
			slog.Warn("scheduler: tool lock check failed, allowing task", "toolType", t.ToolType, "error", err)
			result = append(result, t)
			continue
		}
		if !locked {
			result = append(result, t)
		}
	}
	return result
}
  • Step 2: 在 scheduler.go DefaultScheduler 中注册约束

修改 DefaultScheduler() 的 filter 列表,在现有约束后面追加 NewToolLockConstraint(nil)nil checker 时约束不生效,通过 SetToolLockChecker 注入)。

  • Step 3: Scheduler 添加 SetToolLockChecker 方法

scheduler.go 中新增:

// SetToolLockChecker 设置工具锁检查器(可选,仅 SSOT 模式下注入)
func (s *Scheduler) SetToolLockChecker(checker ToolLockChecker) {
	s.filterMu.Lock()
	defer s.filterMu.Unlock()
	// 在 filter 链中替换或追加 ToolLockConstraint
	for _, f := range s.filter.(*CompositeFilter).filters {
		if tc, ok := f.(*ToolLockConstraint); ok {
			tc.checker = checker
			return
		}
	}
}

Wait — CompositeFilter doesn't expose filters directly. Simpler approach: add SetToolLockChecker to the ConstraintFilter interface or to CompositeFilter.

Actually, the simplest approach: just add the toolLock field to Scheduler struct and inject at construction time. Let me redesign this.

  • Step 4: 验证编译

Run: rtk go build ./internal/scheduler


Task 3: JobRuntime 工具锁集成(Acquire/Release 时机)

Files:

  • Modify: internal/processor/job_runtime.go

  • Modify: internal/processor/job_processor.go

  • Step 1: 在 ToolTask 执行前后加锁/解锁

job_runtime.gobuildToolActionFunc 中:

  • 执行前调用 toolLocker.Acquire(toolType)
  • 执行后 defer toolLocker.Release(toolType)

ToolLocker 通过 JobRuntimeConfig 注入到 JobRuntime。

  • Step 2: 验证编译

Run: rtk go build ./internal/processor


Task 4: ServiceContext 接线

Files:

  • Modify: internal/svc/service_context.go

  • Step 1: 实例化 ToolLocker 并注入 Scheduler 和 JobRuntimeConfig

service_context.go 中:

  • SSOT 启用时创建 RedisToolLocker,否则 MemoryToolLocker

  • 注入到 scheduler.SetToolLockChecker()

  • 注入到 JobRuntimeConfig.ToolLocker

  • Step 2: 全量编译

Run: rtk go build ./...


Chunk 2: 工序超时机制

Task 5: StepTimeoutWatcher — 工序级超时监控

Files:

  • Create: internal/processor/step_timeout.go

  • Create: internal/processor/step_timeout_test.go

  • Step 1: 创建 internal/processor/step_timeout.go

package processor

import (
	"context"
	"log/slog"
	"sync"
	"time"
)

// StepTimeoutWatcher 监控工序级超时。
// 每个正在执行的 step 启动一个 timer,超时后回调 onTimeout。
type StepTimeoutWatcher struct {
	mu        sync.Mutex
	timers    map[string]*time.Timer // key: "jobID:stepIndex"
	onTimeout func(jobID int, stepIndex int)
}

func NewStepTimeoutWatcher(onTimeout func(jobID int, stepIndex int)) *StepTimeoutWatcher {
	return &StepTimeoutWatcher{
		timers:    make(map[string]*time.Timer),
		onTimeout: onTimeout,
	}
}

func (w *StepTimeoutWatcher) Watch(jobID, stepIndex, timeoutSec int) {
	if timeoutSec <= 0 {
		return
	}
	w.mu.Lock()
	defer w.mu.Unlock()

	key := stepTimeoutKey(jobID, stepIndex)
	if t, ok := w.timers[key]; ok {
		t.Stop()
	}
	w.timers[key] = time.AfterFunc(time.Duration(timeoutSec)*time.Second, func() {
		w.mu.Lock()
		delete(w.timers, key)
		w.mu.Unlock()
		if w.onTimeout != nil {
			w.onTimeout(jobID, stepIndex)
		}
	})
}

func (w *StepTimeoutWatcher) Cancel(jobID, stepIndex int) {
	w.mu.Lock()
	defer w.mu.Unlock()

	key := stepTimeoutKey(jobID, stepIndex)
	if t, ok := w.timers[key]; ok {
		t.Stop()
		delete(w.timers, key)
	}
}

func (w *StepTimeoutWatcher) CancelAll(jobID int) {
	w.mu.Lock()
	defer w.mu.Unlock()

	prefix := stepTimeoutKey(jobID, -1)
	for key, t := range w.timers {
		if len(key) >= len(prefix) && key[:len(prefix)] == prefix[:len(prefix)-1] {
			t.Stop()
			delete(w.timers, key)
		}
	}
}

func stepTimeoutKey(jobID, stepIndex int) string {
	return fmt.Sprintf("%d:%d", jobID, stepIndex)
}
  • Step 2: 验证编译

Run: rtk go build ./internal/processor

  • Step 3: 创建 internal/processor/step_timeout_test.go

测试 Watch/Cancel/CancelAll 基本行为。

  • Step 4: 运行测试

Run: rtk go test ./internal/processor/ -run TestStepTimeout -v


Task 6: 将 StepTimeoutWatcher 接入 JobRuntime

Files:

  • Modify: internal/processor/job_runtime.go

  • Modify: internal/processor/job_processor.go

  • Step 1: JobRuntime 在每个 step 开始时 Watch

job_runtime.goadvanceToStep 方法中,根据当前 step 的 StepTimeout 值:

  • > 0: 调用 watcher.Watch(jobID, stepIndex, stepTimeout)

  • = 0: 不监控(无限等待)

  • Step 2: step 完成时 Cancel

在 step 的 onComplete 回调或下一个 step 开始时,调用 watcher.Cancel(jobID, prevStepIndex)

  • Step 3: 超时回调 → SuspendJob

JobProcessor 中实现回调:

func (jp *JobProcessor) handleStepTimeout(jobID, stepIndex int) {
    slog.Warn("step timeout, suspending job", "jobId", jobID, "stepIndex", stepIndex)
    if err := jp.SuspendJob(context.Background(), jobID, "step_timeout"); err != nil {
        slog.Error("step timeout suspend failed", "jobId", jobID, "error", err)
    }
}
  • Step 4: 验证编译

Run: rtk go build ./internal/processor


Chunk 3: PLC-Redis 状态校验

Task 7: 周期性校验器

Files:

  • Create: internal/verify/state_verifier.go

  • Create: internal/verify/state_verifier_test.go

  • Step 1: 创建 internal/verify/state_verifier.go

package verify

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

	"hougai/internal/alarm"
	"hougai/internal/plc"
	"hougai/internal/state"
)

// StateVerifier 周期性对比 PLC 物理状态与 Redis 逻辑状态。
// 设计文档 §14:不一致则挂起报警。
type StateVerifier struct {
	stateMgr   state.StateManager
	plcManager *plc.Manager
	alarmSvc   *alarm.Service
	interval   time.Duration
}

func NewStateVerifier(stateMgr state.StateManager, plcManager *plc.Manager, alarmSvc *alarm.Service, interval time.Duration) *StateVerifier {
	if interval <= 0 {
		interval = 30 * time.Second
	}
	return &StateVerifier{
		stateMgr:   stateMgr,
		plcManager: plcManager,
		alarmSvc:   alarmSvc,
		interval:   interval,
	}
}

// Run 启动周期性校验循环,直到 ctx 取消。
func (v *StateVerifier) Run(ctx context.Context) {
	ticker := time.NewTicker(v.interval)
	defer ticker.Stop()

	slog.Info("state verifier: started", "interval", v.interval)
	for {
		select {
		case <-ctx.Done():
			slog.Info("state verifier: stopped")
			return
		case <-ticker.C:
			v.verifyOnce(ctx)
		}
	}
}

func (v *StateVerifier) verifyOnce(ctx context.Context) {
	// 1. 获取暂存台 BitmapRedis 逻辑状态)
	logicalSlots, err := v.stateMgr.GetTempSlotBitmap(ctx)
	if err != nil {
		slog.Warn("state verifier: get logical slots failed", "error", err)
		return
	}

	// 2. 读取 PLC 暂存台传感器(物理状态)
	plc := v.plcManager.GetPlc()
	if plc == nil {
		return
	}
	physicalSlots, err := plc.ReadBufferSlotStates(ctx)
	if err != nil {
		slog.Warn("state verifier: read plc slots failed", "error", err)
		return
	}

	// 3. 对比
	if logicalSlots != physicalSlots {
		slog.Warn("state verifier: mismatch detected",
			"logical", logicalSlots,
			"physical", physicalSlots,
		)
		if v.alarmSvc != nil {
			v.alarmSvc.Raise(ctx,
				"STATE_MISMATCH",
				"PLC 暂存台状态与 Redis 逻辑状态不一致",
				"WARN",
				0, 0, "state_verifier",
			)
		}
	}
}
  • Step 2: 检查 PLC Manager 是否有 ReadBufferSlotStates

如果不存在,在 internal/plc/manager.gointernal/plc/ 中添加桩方法(返回错误),Phase 4 实现。

  • Step 3: 验证编译

Run: rtk go build ./internal/verify


Task 8: ServiceContext 启动校验器

Files:

  • Modify: internal/svc/service_context.go

  • Step 1: 启动 StateVerifier goroutine

在 SSOT 启用时启动:

if c.SSOT.Enabled && alarmSvc != nil {
    verifier := verify.NewStateVerifier(stateMgr, plcManager, alarmSvc, 30*time.Second)
    go verifier.Run(context.Background())
    slog.Info("svc: state verifier started")
}
  • Step 2: 全量编译

Run: rtk go build ./...


Chunk 4: 集成验证

Task 9: 全量编译 + 测试

  • Step 1: 全量编译

Run: rtk go build ./...

  • Step 2: 运行新增测试

Run: rtk go test ./internal/state/... ./internal/scheduler/... ./internal/processor/... ./internal/verify/...

  • Step 3: 运行全量测试

Run: rtk go test ./...


风险点

风险 缓解
工具锁 TTL 过短导致锁提前释放 使用 30s TTL + 执行前续租,或设置足够长的 TTL 覆盖最长工具操作
StepTimeoutWatcher timer 泄漏 CancelAll 在 Job 结束时清理,添加定时巡检
PLC ReadBufferSlotStates 不存在 添加桩方法返回错误,校验器跳过本轮
Scheduler 接口不兼容 ToolLockConstraint 使用 SetToolLockChecker 方法动态注入,保持向后兼容

验证标准

  • rtk go build ./... 通过
  • rtk go test ./... 通过(排除已知失败的 preload/camera/db/robot 包)
  • 工具锁:同一工具类型不可并发获取
  • 工序超时:超时后自动 SuspendJob
  • 状态校验:PLC 不一致时触发 ALARM