Files
knowledge-governance/internal/platform/postgres/library_dead_letters.go
T
姚章浩 c2402947a9
build / build (push) Successful in 1m26s
feat(library): 死信按精确指纹解除,保留上游记录
上游 task_dead_letters 没有解决标记,purge/DELETE 会销毁"失败过"的唯一证据。
治理侧新增解除记录(迁移 073):绑定实时行逐字节指纹、重放引用、源侧观测摘要与
操作人;行被改过、重放后又出现新死信、源侧未结清都不命中,封存照旧阻断。
指纹先压紧空白再摘要,避免 PG to_jsonb::text 与 json.Marshal 的空白差异。

试过:go test ./internal/{librarydeadletter,libraryagent,httpapi,releaseadapter/v071,
platform/postgres},make verify 15 项全绿。
2026-10-29 06:55:13 +08:00

150 lines
6.4 KiB
Go

package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
// 死信解除只写治理库:上游 task_dead_letters 一个字节都不动。为什么这样:
// 上游没有"已解决"标记,purge/DELETE 会销毁失败证据;治理侧保留审计并绑定
// 实时行指纹,行变了就不会再命中,阻断自动恢复。
// RecordDeadLetterRelease 幂等写入一条解除记录。同实例同指纹重复提交返回已存记录,
// 但内容不一致(重放引用/观测摘要不同)必须报冲突,不能悄悄覆盖审计。
func (s *Store) RecordDeadLetterRelease(ctx context.Context, release governance.LibraryDeadLetterRelease) (governance.LibraryDeadLetterRelease, bool, error) {
if release.ID == "" || release.Instance == "" || release.Fingerprint == "" || release.Actor == "" {
return governance.LibraryDeadLetterRelease{}, false, errors.New("dead letter release requires identity, fingerprint and actor")
}
observation := release.Observation
if len(observation) == 0 {
observation = json.RawMessage(`{}`)
}
var id string
err := s.db.QueryRow(ctx, `
INSERT INTO library_dead_letter_releases (id, instance, tenant_id, kb_id, dead_letter_id, fingerprint,
task_type, scope, scope_id, related_id, replay_queue, replay_task_id, replayed_at, replay_evidence,
observation_digest, observation, note, actor, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
ON CONFLICT (instance, fingerprint) DO NOTHING
RETURNING id`,
release.ID, release.Instance, release.TenantID, release.KnowledgeBaseID, release.DeadLetterID,
release.Fingerprint, release.TaskType, release.Scope, release.ScopeID, release.RelatedID,
release.ReplayQueue, release.ReplayTaskID, release.ReplayedAt.UTC(), release.ReplayEvidence,
release.ObservationDigest, observation, release.Note, release.Actor, release.CreatedAt.UTC()).Scan(&id)
if err == nil {
stored, loadErr := s.DeadLetterRelease(ctx, id)
return stored, false, loadErr
}
if !errors.Is(err, pgx.ErrNoRows) {
return governance.LibraryDeadLetterRelease{}, false, fmt.Errorf("record dead letter release: %w", err)
}
existing, loadErr := s.deadLetterReleaseByFingerprint(ctx, release.Instance, release.Fingerprint)
if loadErr != nil {
return governance.LibraryDeadLetterRelease{}, false, loadErr
}
if existing.ObservationDigest != release.ObservationDigest || existing.ReplayTaskID != release.ReplayTaskID ||
!existing.ReplayedAt.Equal(release.ReplayedAt.UTC()) {
return existing, false, governance.ErrLibraryJobIdempotencyConflict
}
return existing, true, nil
}
func (s *Store) deadLetterReleaseByFingerprint(ctx context.Context, instance, fingerprint string) (governance.LibraryDeadLetterRelease, error) {
var id string
if err := s.db.QueryRow(ctx, `SELECT id FROM library_dead_letter_releases WHERE instance=$1 AND fingerprint=$2`,
instance, fingerprint).Scan(&id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return governance.LibraryDeadLetterRelease{}, errors.New("dead letter release not found")
}
return governance.LibraryDeadLetterRelease{}, fmt.Errorf("load dead letter release: %w", err)
}
return s.DeadLetterRelease(ctx, id)
}
func (s *Store) DeadLetterRelease(ctx context.Context, id string) (governance.LibraryDeadLetterRelease, error) {
var release governance.LibraryDeadLetterRelease
var observation []byte
err := s.db.QueryRow(ctx, `
SELECT id, instance, tenant_id, kb_id, dead_letter_id, fingerprint, task_type, scope, scope_id, related_id,
replay_queue, replay_task_id, replayed_at, replay_evidence, observation_digest, observation, note, actor, created_at
FROM library_dead_letter_releases WHERE id=$1`, id).Scan(&release.ID, &release.Instance, &release.TenantID,
&release.KnowledgeBaseID, &release.DeadLetterID, &release.Fingerprint, &release.TaskType, &release.Scope,
&release.ScopeID, &release.RelatedID, &release.ReplayQueue, &release.ReplayTaskID, &release.ReplayedAt,
&release.ReplayEvidence, &release.ObservationDigest, &observation, &release.Note, &release.Actor, &release.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return governance.LibraryDeadLetterRelease{}, err
}
return governance.LibraryDeadLetterRelease{}, fmt.Errorf("load dead letter release: %w", err)
}
release.Observation = json.RawMessage(observation)
return release, nil
}
// DeadLetterReleases 按实例倒序列出解除记录(审计视图)。
func (s *Store) DeadLetterReleases(ctx context.Context, instance string, limit int) ([]governance.LibraryDeadLetterRelease, error) {
if instance == "" {
return nil, errors.New("dead letter releases require an instance")
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(ctx, `
SELECT id FROM library_dead_letter_releases WHERE instance=$1 ORDER BY created_at DESC LIMIT $2`, instance, limit)
if err != nil {
return nil, fmt.Errorf("list dead letter releases: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("list dead letter releases: %w", err)
}
ids = append(ids, id)
}
if rows.Err() != nil {
return nil, fmt.Errorf("list dead letter releases: %w", rows.Err())
}
result := make([]governance.LibraryDeadLetterRelease, 0, len(ids))
for _, id := range ids {
release, loadErr := s.DeadLetterRelease(ctx, id)
if loadErr != nil {
return nil, loadErr
}
result = append(result, release)
}
return result, nil
}
// DeadLetterReleaseFingerprints 是封存执行侧需要的唯一投影:只给指纹,不给证据。
// worker 把它原样传给 agent,CLI 再对实时行逐字节复核。
func (s *Store) DeadLetterReleaseFingerprints(ctx context.Context, instance string) ([]string, error) {
if instance == "" {
return nil, errors.New("dead letter release fingerprints require an instance")
}
rows, err := s.db.Query(ctx, `SELECT fingerprint FROM library_dead_letter_releases WHERE instance=$1 ORDER BY created_at`, instance)
if err != nil {
return nil, fmt.Errorf("list dead letter release fingerprints: %w", err)
}
defer rows.Close()
result := []string{}
for rows.Next() {
var fingerprint string
if err := rows.Scan(&fingerprint); err != nil {
return nil, fmt.Errorf("list dead letter release fingerprints: %w", err)
}
result = append(result, fingerprint)
}
return result, rows.Err()
}
var _ = time.Time{}