Files
knowledge-governance/cmd/worker/library_writer.go
T
姚章浩 a7d643635a
build / build (push) Successful in 1m27s
fix(library): 守卫按行取作业身份、同作业多代次、原作业续作与执行域准入
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 项通过。
2026-10-30 17:20:40 +08:00

144 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}