Files
knowledge-governance/internal/qaopsmirror/sync_run_integration_test.go
T
姚章浩 3e7fcce0b1
build / build (push) Successful in 1m11s
feat(qa-ops): 持久本地问答副本与安全答案预览
新增迁移50与每5分钟/手动持久同步,API只读本地;同步失败保留、权限阻断、租约与质量失效原子激活。
保留全量稳定分页/固定列,已存质量批量读取,元数据不反复解压长答案;详情安全Markdown与原文。

已运行整仓 make verify、源与副本隔离PG -race、真实只读源及隔离浏览器。
实际同步2525条;固定窗口640条本地中位1.46秒,对照直接源33.10秒。
停worker、缺源凭据、重启API后仍可查询;未更新8088,未修改生产原问答。
2026-10-18 19:10:47 +08:00

144 lines
5.5 KiB
Go

package qaopsmirror
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/jobs"
pg "knowledge-governance/internal/platform/postgres"
"testing"
"time"
)
type fixtureSource struct {
rows []dobotaiops.MirrorRecord
calls int
payloadCalls int
err error
payloadError error
failAfter int
hook func()
}
func (f *fixtureSource) ReadMirrorManifest(context.Context, string) (dobotaiops.MirrorManifest, error) {
f.calls++
m := dobotaiops.MirrorManifest{AsOf: time.Now().UTC(), Safety: dobotaiops.SourceSafety{SSL: true, TablesChecked: 5, ConnectionLimit: 1}}
for _, r := range f.rows {
m.Keys = append(m.Keys, r.Key)
}
return m, f.err
}
func (f *fixtureSource) ReadMirrorRecords(_ context.Context, _ string, ids []string) ([]dobotaiops.MirrorRecord, error) {
f.payloadCalls++
if f.payloadError != nil && f.payloadCalls >= f.failAfter {
return nil, f.payloadError
}
out := []dobotaiops.MirrorRecord{}
for _, id := range ids {
for _, r := range f.rows {
if r.Key.ID == id {
out = append(out, r)
}
}
}
if f.hook != nil {
f.hook()
}
return out, f.err
}
func fixtureEntry(id, answer string) dobotaiops.MirrorRecord {
qh := sha256.Sum256([]byte(id + answer))
fp := sha256.Sum256([]byte("source:" + id + answer))
now := time.Now().UTC()
p, _ := json.Marshal(map[string]any{"id": id, "status": "succeeded", "question": "fixture question", "answer": answer, "asked_at": now.Add(-time.Hour), "time_source": "turn.created_at", "asker": "", "trigger_person": "", "department": "", "region": "", "references": []any{}, "media": []any{}, "source_coverage": map[string]string{"verdict": "covered", "reason_code": "", "evaluator_version": "v5"}, "feedback": map[string]any{"asker_value": "", "up": 0, "down": 0}, "_identity": map[string]string{"actor_id": "a", "staff_id": "s", "union_id": ""}, "_quality_hash": hex.EncodeToString(qh[:]), "_quality_observed_at": now})
return dobotaiops.MirrorRecord{Key: dobotaiops.MirrorKey{ID: id, Fingerprint: hex.EncodeToString(fp[:])}, Payload: p}
}
func claimMirrorJob(t *testing.T, s *Store, q *pg.Store, key string) *jobs.Job {
t.Helper()
ctx := context.Background()
if _, e := q.Claim(ctx, "fixture-sync-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil {
t.Fatal(e)
}
_, _, e := s.EnqueueSync(ctx, key, "fixture-admin")
if e != nil {
t.Fatal(e)
}
j, e := q.Claim(ctx, "fixture-sync-worker", []string{s.SyncJobType()}, time.Now(), time.Minute)
if e != nil || j == nil {
t.Fatal("missing durable sync job", e)
}
return j
}
func TestLocalMirrorPeriodicSyncIsDurableAndDueOnly(t *testing.T) {
p := mirrorFixture(t)
s := NewStore(p, "fixture-periodic")
q := pg.NewStore(p)
ctx := context.Background()
if _, e := q.Claim(ctx, "fixture-periodic-worker", []string{s.SyncJobType()}, time.Now(), time.Minute); e != nil {
t.Fatal(e)
}
if enqueued, e := s.EnqueueDue(ctx); e != nil || !enqueued {
t.Fatal("first automatic sync was not scheduled", e)
}
if _, e := s.EnqueueDue(ctx); e != nil {
t.Fatal(e)
}
var count int
if p.QueryRow(ctx, `SELECT count(*) FROM jobs WHERE type=$1`, s.SyncJobType()).Scan(&count) != nil || count != 1 {
t.Fatal("scheduler duplicated active job")
}
job, e := q.Claim(ctx, "fixture-periodic-worker", []string{s.SyncJobType()}, time.Now(), time.Minute)
if e != nil || job == nil {
t.Fatal(e)
}
source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("auto-one", "automatic")}}
if e = s.HandleSync(ctx, *job, func(context.Context) (Source, error) { return source, nil }); e != nil {
t.Fatal(e)
}
if e = q.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil {
t.Fatal(e)
}
if enqueued, e := s.EnqueueDue(ctx); e != nil || enqueued {
t.Fatal("automatic sync ignored the persisted five-minute interval", e)
}
if _, e = p.Exec(ctx, `UPDATE qa_ops_mirror_state SET next_sync_at=now()-interval '1 second' WHERE tenant_key=$1`, s.tenant); e != nil {
t.Fatal(e)
}
if enqueued, e := s.EnqueueDue(ctx); e != nil || !enqueued {
t.Fatal("due sync did not resume", e)
}
}
func TestLocalMirrorSyncActivatesAndCheckpoints(t *testing.T) {
p := mirrorFixture(t)
s := NewStore(p, "fixture-sync")
q := pg.NewStore(p)
ctx := context.Background()
job := claimMirrorJob(t, s, q, "fixture-first-sync")
source := &fixtureSource{rows: []dobotaiops.MirrorRecord{fixtureEntry("one", "# first answer")}}
factory := func(context.Context) (Source, error) { return source, nil }
if e := s.HandleSync(ctx, *job, factory); e != nil {
t.Fatal("first full sync did not activate", e)
}
state, e := s.GetSyncStatus(ctx)
if e != nil || !state.Ready || state.Records != 1 || state.Revision != 1 || state.LastSuccessAt == nil || state.NextSyncAt == nil || state.NextSyncAt.Sub(*state.LastSuccessAt) < Interval-time.Second {
t.Fatal("incomplete local sync state", e, state)
}
record, e := s.GetRecord(ctx, s.tenant, "one")
if e != nil || record.Answer != "# first answer" {
t.Fatal("committed local answer missing", e)
}
var inputs int
if p.QueryRow(ctx, `SELECT count(*) FROM qa_ops_quality_inputs WHERE tenant_key=$1 AND payload IS NOT NULL`, s.tenant).Scan(&inputs) != nil || inputs != 1 {
t.Fatal("quality evidence did not commit with mirror")
}
calls := source.calls
if e = s.HandleSync(ctx, *job, factory); e != nil || source.calls != calls {
t.Fatal("restart after commit fetched source twice", e)
}
if e = q.Complete(ctx, job.ID, job.LeaseOwner, time.Now()); e != nil {
t.Fatal(e)
}
}