3e7fcce0b1
build / build (push) Successful in 1m11s
新增迁移50与每5分钟/手动持久同步,API只读本地;同步失败保留、权限阻断、租约与质量失效原子激活。 保留全量稳定分页/固定列,已存质量批量读取,元数据不反复解压长答案;详情安全Markdown与原文。 已运行整仓 make verify、源与副本隔离PG -race、真实只读源及隔离浏览器。 实际同步2525条;固定窗口640条本地中位1.46秒,对照直接源33.10秒。 停worker、缺源凭据、重启API后仍可查询;未更新8088,未修改生产原问答。
144 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|