a7d643635a
build / build (push) Successful in 1m27s
R1 维护/发布守卫硬编码 j.id=$1:维护类入口的 $1 是 scope/instance,作业号在 $2, 导致 update/release/ingress 六个入口正常路径全部匹配不到作业。改为按被更新行自己的 owner_job_id 生成条件,并同时要求该代次在 library_attempts 里仍活跃。 R2 认领只改 jobs.attempt_epoch 不改 attempts.epoch,第二次接管即错位;唯一索引 (job_id) 与"每次接管新 attempt 且历史不可变"矛盾。改为同作业多代次:锁作业行与 operation 行、同 owner 幂等续租、活跃未过期者让路、过期/本作业旧代次标 abandoned、 新代次 epoch=max+1,并清同 operation 其它作业行租约。迟到 write/renew/finish 一律 ErrLibraryEpochStale(finish 补了"只能在活跃态进来一次")。 R3 续作不再新建 -resume 作业:PrepareLibraryJobResume 让原业务作业回 pending 并显式 结束上一代,维护窗口/journal/预备份/写集合仍按原作业 id 归属,代次由 worker 起手递增。 R4 执行域准入改为注册 handler 前读事实:目标实况(KB/租户/schema/索引)、旧 release 路由绑定的 KB、未释放维护窗口持有者;未知或重叠即拒绝执行(不做维护动作、不停既有服务)。 同时自纠 5 项设计偏离:current 次序键取意图创建时间(旧意图迟到事实被拒)、首次锚点 改事务 FOR UPDATE + 回读且 NULL≠0、版本事件冲突只做加法且 xmax=0 才算新事实、 恢复作为新 kind=recover 意图推进 current、隔离测试加测试库护栏并清理残留 schema。 测试:真实 PG(kg-pgtest-writeset,一用例一 schema)新增 8 条集成用例覆盖上述正负例; go test ./... 全绿(50 包)、go vet/build rc=0、web tsc + vitest 485 通过、make verify 16 项通过。
144 lines
6.6 KiB
Go
144 lines
6.6 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"time"
|
||
|
||
"knowledge-governance/internal/governance"
|
||
"knowledge-governance/internal/jobs"
|
||
postgresstore "knowledge-governance/internal/platform/postgres"
|
||
)
|
||
|
||
// libraryAttemptWriter 是"这一次执行代次"的写入口。
|
||
//
|
||
// 它嵌入完整 Store,只把**可变**入口换成带 epoch 守卫的实现(方法名相同,所以调用点
|
||
// 一行都不用改)。这样"谁能写业务状态/维护锁"只由这一个类型回答:handler 里拿到的
|
||
// 是 writer 而不是裸 Store,就不可能顺手绕过守卫去写。
|
||
//
|
||
// 为什么必须这样:失租的旧执行器和接管它的新代次用的是**同一个 job_id**。只加一个
|
||
// epoch 列而不让写路径读它,等于什么都没做(这正是 F13 的原始形态)。
|
||
type libraryAttemptWriter struct {
|
||
*postgresstore.Store
|
||
attempt governance.LibraryAttempt
|
||
}
|
||
|
||
// beginLibraryWriter 取得这个作业的执行权,返回带守卫的写入口。
|
||
// owner 用队列作业 ID:每一次投递是一个确定的执行者身份,重复投递是同一个身份(幂等),
|
||
// 不同投递互不覆盖(这正是"第二个 resumer 不能释放第一个的锁"需要的区分)。
|
||
func beginLibraryWriter(ctx context.Context, store *postgresstore.Store, jobID, owner string) (*libraryAttemptWriter, error) {
|
||
job, err := store.LibraryJob(ctx, jobID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读不到业务作业 %s:%w", jobID, err)
|
||
}
|
||
if job.OperationID == "" {
|
||
// 迁移前的历史作业没有 operation:仍然取租约(防止双执行),但没有代次可守卫。
|
||
log.Printf("library job %s has no operation id (pre-identity row): guarded writes fall back to lease only", jobID)
|
||
}
|
||
attempt, err := store.BeginLibraryAttempt(ctx, jobID, owner, 10*time.Minute, time.Now().UTC())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &libraryAttemptWriter{Store: store, attempt: attempt}, nil
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) AdvanceLibraryJob(ctx context.Context, jobID string, state governance.LibraryJobState,
|
||
stage, message, recovery, snapshotID, targetSnapshotID string, now time.Time) error {
|
||
if err := w.checkJob(jobID); err != nil {
|
||
return err
|
||
}
|
||
return w.Store.AdvanceLibraryJobGuarded(ctx, w.attempt, state, stage, message, recovery, snapshotID, targetSnapshotID, now)
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) UpdateLibraryStage(ctx context.Context, jobID, stage, state string,
|
||
total, completed, bytesTotal, bytesDone *int64, detail string, now time.Time) error {
|
||
if err := w.checkJob(jobID); err != nil {
|
||
return err
|
||
}
|
||
return w.Store.UpdateLibraryStageGuarded(ctx, w.attempt, stage, state, total, completed, bytesTotal, bytesDone, detail, now)
|
||
}
|
||
|
||
// RecordLibraryVersion 注入本次代次身份:事件登记因此也受 epoch 守卫。
|
||
func (w *libraryAttemptWriter) RecordLibraryVersion(ctx context.Context, version governance.LibraryVersion) error {
|
||
if version.OperationID == "" {
|
||
version.OperationID = w.attempt.OperationID
|
||
}
|
||
if version.AttemptEpoch == 0 {
|
||
version.AttemptEpoch = w.attempt.Epoch
|
||
}
|
||
version.AttemptOwner = w.attempt.Owner
|
||
return w.Store.RecordLibraryVersion(ctx, version)
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) SetLibraryMaintenanceIngress(ctx context.Context, scope, ownerJobID, state, note string, now time.Time) error {
|
||
return w.Store.SetLibraryMaintenanceIngressGuarded(ctx, scope, ownerJobID, state, note, w.attempt, now)
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) SetInstanceMaintenanceIngress(ctx context.Context, instance, ownerJobID, state, note string, now time.Time) error {
|
||
return w.Store.SetInstanceMaintenanceIngressGuarded(ctx, instance, ownerJobID, state, note, w.attempt, now)
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) UpdateLibraryMaintenanceState(ctx context.Context, scope, ownerJobID, state, note string, now time.Time) error {
|
||
return w.Store.UpdateLibraryMaintenanceStateGuarded(ctx, scope, ownerJobID, state, note, w.attempt, now)
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) UpdateInstanceMaintenanceState(ctx context.Context, instance, ownerJobID, state, note string, now time.Time) error {
|
||
return w.Store.UpdateInstanceMaintenanceStateGuarded(ctx, instance, ownerJobID, state, note, w.attempt, now)
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) ReleaseLibraryMaintenance(ctx context.Context, scope, ownerJobID string, now time.Time) error {
|
||
return w.Store.ReleaseLibraryMaintenanceGuarded(ctx, scope, ownerJobID, w.attempt, now)
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) ReleaseInstanceMaintenance(ctx context.Context, instance, ownerJobID string, now time.Time) error {
|
||
return w.Store.ReleaseInstanceMaintenanceGuarded(ctx, instance, ownerJobID, w.attempt, now)
|
||
}
|
||
|
||
// finish 结束本次代次。失败只记日志:代次结束写不进去不该反过来把已经完成的数据收尾
|
||
// 判成失败(下一次接管会重新开始一个新代次,旧代次的残留状态由 active 唯一索引兜住)。
|
||
func (w *libraryAttemptWriter) finish(ctx context.Context, state governance.LibraryAttemptState, outcome string) {
|
||
if w.attempt.OperationID == "" {
|
||
return
|
||
}
|
||
if err := w.Store.FinishLibraryAttempt(ctx, w.attempt, state, outcome, time.Now().UTC()); err != nil &&
|
||
!errors.Is(err, governance.ErrLibraryEpochStale) {
|
||
log.Printf("library attempt %s#%d finish failed: %v", w.attempt.OperationID, w.attempt.Epoch, err)
|
||
}
|
||
}
|
||
|
||
func (w *libraryAttemptWriter) checkJob(jobID string) error {
|
||
if jobID != w.attempt.JobID {
|
||
return fmt.Errorf("尝试用代次 %s#%d 写另一个作业 %s:拒绝(跨作业写入不是续作)",
|
||
w.attempt.OperationID, w.attempt.Epoch, jobID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 编译期断言:writer 必须同时满足"成功收尾"与"阶段记录"两个存储面。
|
||
var (
|
||
_ libraryMaintenanceStore = (*libraryAttemptWriter)(nil)
|
||
_ libraryStageStore = (*libraryAttemptWriter)(nil)
|
||
)
|
||
|
||
// libraryQueueOwner 构造**每一次投递**的执行者身份:worker + 队列作业 + 队列尝试序号。
|
||
//
|
||
// 不能用稳定的 queueJobID 单独当身份(父的复核项 R2):同一个队列作业被重投时
|
||
// ID 不变,而执行者是新的——那样第二次投递会被误判成"同一次执行重复调用",
|
||
// 直接复用上一代租约而不开新代次。加上 worker 与 attempt 之后:
|
||
//
|
||
// · 同一次投递重复调用 ⇒ 身份相同 ⇒ 幂等复用;
|
||
// · 换 worker 或换队列尝试 ⇒ 身份不同 ⇒ 必须开新代次(旧代次被结束,守卫立即失效)。
|
||
func libraryQueueOwner(workerID string, job jobs.Job, libraryJobID string) string {
|
||
queueID := job.ID
|
||
if queueID == "" {
|
||
queueID = libraryJobID
|
||
}
|
||
worker := workerID
|
||
if worker == "" {
|
||
worker = "worker"
|
||
}
|
||
return fmt.Sprintf("%s#%s#%d", worker, queueID, job.Attempt)
|
||
}
|