Files
knowledge-governance/internal/qaopsmirror/sync_run.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

284 lines
10 KiB
Go

package qaopsmirror
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/qaopsquality"
"regexp"
"sort"
"time"
)
type Source interface {
ReadMirrorManifest(context.Context, string) (dobotaiops.MirrorManifest, error)
ReadMirrorRecords(context.Context, string, []string) ([]dobotaiops.MirrorRecord, error)
}
type SourceFactory func(context.Context) (Source, error)
var hashPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
func (s *Store) lease(ctx context.Context, q dobotaiops.RecordQuerier, j jobs.Job, lock bool) (bool, error) {
if j.Type != s.SyncJobType() || j.LeaseOwner == "" {
return false, ErrLease
}
sql := `SELECT payload ? 'mirror_result' FROM jobs WHERE id=$1 AND type=$2 AND state='running' AND lease_owner=$3 AND attempt=$4 AND lease_until>clock_timestamp() AND EXISTS(SELECT 1 FROM operation_requests op WHERE op.operation_type=jobs.type AND op.result->>'job_id'=jobs.id)`
if lock {
sql += " FOR UPDATE"
}
var done bool
if q.QueryRow(ctx, sql, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt).Scan(&done) != nil {
return false, ErrLease
}
return done, nil
}
func (s *Store) progress(ctx context.Context, j jobs.Job, loaded, expected, changed int) error {
p, _ := json.Marshal(map[string]int{"loaded": loaded, "expected": expected, "changed": changed})
tag, e := s.pool.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{mirror_progress}',$5::jsonb),updated_at=now() WHERE id=$1 AND type=$2 AND lease_owner=$3 AND attempt=$4 AND state='running' AND lease_until>clock_timestamp()`, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt, p)
if e != nil || tag.RowsAffected() != 1 {
return ErrLease
}
return nil
}
func (s *Store) recordFailure(j jobs.Job, cause error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
code := "mirror_source_unavailable"
blocked := errors.Is(cause, dobotaiops.ErrNotReadOnly)
if blocked {
code = "mirror_source_authorization_failed"
}
_, _ = s.pool.Exec(ctx, `UPDATE qa_ops_mirror_state SET last_error=$2,blocked=blocked OR $3,next_sync_at=clock_timestamp()+interval '5 minutes' WHERE tenant_key=$1 AND EXISTS(SELECT 1 FROM jobs WHERE id=$4 AND type=$5 AND state='running' AND lease_owner=$6 AND attempt=$7 AND lease_until>clock_timestamp())`, s.tenant, code, blocked, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt)
}
func (s *Store) HandleSync(ctx context.Context, j jobs.Job, factory SourceFactory) (err error) {
if !s.valid() || factory == nil {
return ErrSync
}
done, e := s.lease(ctx, poolQuery{s.pool}, j, false)
if e != nil {
return e
}
if done {
return nil
}
defer func() {
if err != nil && !errors.Is(err, ErrLease) {
s.recordFailure(j, err)
if errors.Is(err, dobotaiops.ErrNotReadOnly) {
err = jobs.Permanent("mirror_source_authorization_failed", ErrSync)
} else {
err = ErrSync
}
}
}()
source, e := factory(ctx)
if e != nil {
return e
}
if closer, ok := source.(interface{ Close() }); ok {
defer closer.Close()
}
known := map[string]string{}
rows, e := s.pool.Query(ctx, `SELECT id,fingerprint FROM qa_ops_mirror_records WHERE tenant_key=$1`, s.tenant)
if e != nil {
return dobotaiops.ErrUnavailable
}
for rows.Next() {
var id, fp string
if rows.Scan(&id, &fp) != nil {
rows.Close()
return dobotaiops.ErrUnavailable
}
known[id] = fp
}
e = rows.Err()
rows.Close()
if e != nil {
return dobotaiops.ErrUnavailable
}
manifest, e := source.ReadMirrorManifest(ctx, s.tenant)
if e != nil {
return e
}
if manifest.AsOf.IsZero() || manifest.AsOf.After(time.Now().Add(5*time.Minute)) || len(manifest.Keys) > dobotaiops.MaxMirrorRecords {
return ErrSync
}
// A sudden completely empty scope is not accepted as authority to erase a populated local history.
if len(manifest.Keys) == 0 && len(known) > 0 {
return ErrSync
}
keys := map[string]string{}
changed := []string{}
ids := make([]string, 0, len(manifest.Keys))
for _, k := range manifest.Keys {
if !validToken(k.ID, 256) || !hashPattern.MatchString(k.Fingerprint) || keys[k.ID] != "" {
return ErrSync
}
keys[k.ID] = k.Fingerprint
ids = append(ids, k.ID)
if known[k.ID] != k.Fingerprint {
changed = append(changed, k.ID)
}
}
sort.Strings(changed)
loaded := len(ids) - len(changed)
if e = s.progress(ctx, j, loaded, len(ids), 0); e != nil {
return e
}
collected := make([]dobotaiops.MirrorRecord, 0, len(changed))
totalBytes := 0
for start := 0; start < len(changed); start += 100 {
if _, e = s.lease(ctx, poolQuery{s.pool}, j, false); e != nil {
return e
}
batch := changed[start:min(start+100, len(changed))]
got, e := source.ReadMirrorRecords(ctx, s.tenant, batch)
if e != nil {
return e
}
if len(got) != len(batch) {
return ErrSync
}
expected := map[string]bool{}
for _, id := range batch {
expected[id] = true
}
for _, row := range got {
if !expected[row.Key.ID] || !hashPattern.MatchString(row.Key.Fingerprint) {
return ErrSync
}
delete(expected, row.Key.ID)
record, e := dobotaiops.DecodeMirrorPayload(string(row.Payload))
if e != nil || record.ID != row.Key.ID || !hashPattern.MatchString(record.QualityInputHash) || record.QualityObservedAt.IsZero() || record.AskedAt.IsZero() || len(row.Payload) > 8<<20 {
return ErrSync
}
totalBytes += len(row.Payload)
if totalBytes > 256<<20 {
return ErrSync
}
// A row can complete between manifest and payload reads. Keep that newer full
// row, never store an old manifest hash against a different current payload.
keys[row.Key.ID] = row.Key.Fingerprint
if record.QualityObservedAt.After(manifest.AsOf) {
manifest.AsOf = record.QualityObservedAt
}
collected = append(collected, row)
}
loaded += len(got)
if e = s.progress(ctx, j, loaded, len(ids), len(collected)); e != nil {
return e
}
}
return s.activate(ctx, j, manifest, ids, known, collected)
}
func (s *Store) activate(ctx context.Context, j jobs.Job, m dobotaiops.MirrorManifest, ids []string, known map[string]string, rows []dobotaiops.MirrorRecord) error {
tx, e := s.pool.Begin(ctx)
if e != nil {
return dobotaiops.ErrUnavailable
}
defer func() { _ = tx.Rollback(context.Background()) }()
if _, e = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, s.SyncJobType()); e != nil {
return dobotaiops.ErrUnavailable
}
done, e := s.lease(ctx, txQuery{tx}, j, true)
if e != nil {
return e
}
if done {
return nil
}
quality := qaopsquality.NewStore(s.pool, s.tenant)
for start := 0; start < len(rows); start += 100 {
batch := rows[start:min(start+100, len(rows))]
records := make([]dobotaiops.Record, 0, len(batch))
for _, row := range batch {
rec, e := dobotaiops.DecodeMirrorPayload(string(row.Payload))
if e != nil {
return ErrSync
}
records = append(records, rec)
identity, _ := json.Marshal(rec.Identity)
if _, e = tx.Exec(ctx, `INSERT INTO qa_ops_mirror_records(tenant_key,id,fingerprint,asked_at,question,status,identity,quality_hash,payload) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT(tenant_key,id) DO UPDATE SET fingerprint=EXCLUDED.fingerprint,asked_at=EXCLUDED.asked_at,question=EXCLUDED.question,status=EXCLUDED.status,identity=EXCLUDED.identity,quality_hash=EXCLUDED.quality_hash,payload=EXCLUDED.payload`, s.tenant, rec.ID, row.Key.Fingerprint, rec.AskedAt, rec.Question, rec.Status, identity, rec.QualityInputHash, row.Payload); e != nil {
return dobotaiops.ErrUnavailable
}
}
if e = quality.ObserveRecordsInTx(ctx, tx, records); e != nil {
return e
}
}
deleted, e := tx.Exec(ctx, `DELETE FROM qa_ops_mirror_records WHERE tenant_key=$1 AND NOT(id=ANY($2::text[]))`, s.tenant, ids)
if e != nil {
return dobotaiops.ErrUnavailable
}
changed := len(rows) > 0 || deleted.RowsAffected() > 0
var revision int64
var wasReady bool
if tx.QueryRow(ctx, `SELECT revision,ready FROM qa_ops_mirror_state WHERE tenant_key=$1 FOR UPDATE`, s.tenant).Scan(&revision, &wasReady) != nil {
return dobotaiops.ErrUnavailable
}
if changed || !wasReady {
revision++
}
safety, _ := json.Marshal(m.Safety)
if _, e = tx.Exec(ctx, `UPDATE qa_ops_mirror_state SET ready=true,blocked=false,revision=$2,record_count=$3,source_observed_at=$4,synced_at=clock_timestamp(),next_sync_at=clock_timestamp()+interval '5 minutes',last_error='',source_safety=$5 WHERE tenant_key=$1`, s.tenant, revision, len(ids), m.AsOf, safety); e != nil {
return dobotaiops.ErrUnavailable
}
result, _ := json.Marshal(map[string]any{"records": len(ids), "changed": len(rows), "deleted": deleted.RowsAffected(), "revision": revision})
tag, e := tx.Exec(ctx, `UPDATE jobs SET payload=jsonb_set(payload,'{mirror_result}',$5::jsonb),updated_at=now() WHERE id=$1 AND type=$2 AND lease_owner=$3 AND attempt=$4 AND state='running' AND lease_until>clock_timestamp()`, j.ID, s.SyncJobType(), j.LeaseOwner, j.Attempt, result)
if e != nil || tag.RowsAffected() != 1 {
return ErrLease
}
if tx.Commit(ctx) != nil {
return dobotaiops.ErrUnavailable
}
return nil
}
func (s *Store) GetSyncStatus(ctx context.Context) (Status, error) {
if !s.valid() {
return Status{}, dobotaiops.ErrUnavailable
}
state, e := s.readState(ctx, poolQuery{s.pool})
if e != nil {
return state, e
}
if s.pool.QueryRow(ctx, workerReadySQL, s.SyncJobType()).Scan(&state.WorkerReady) != nil {
return Status{}, dobotaiops.ErrUnavailable
}
var j SyncJob
var payload []byte
var expired bool
var attempt, maxAttempts int
e = s.pool.QueryRow(ctx, `SELECT id,state,payload,COALESCE(last_error_code,''),COALESCE(state='running' AND lease_until<clock_timestamp(),false),attempt,max_attempts FROM jobs WHERE type=$1 ORDER BY created_at DESC,id DESC LIMIT 1`, s.SyncJobType()).Scan(&j.ID, &j.State, &payload, &j.ErrorCode, &expired, &attempt, &maxAttempts)
if errors.Is(e, pgx.ErrNoRows) {
return state, nil
}
if e != nil {
return Status{}, dobotaiops.ErrUnavailable
}
var data struct {
Progress struct {
Loaded int `json:"loaded"`
Expected int `json:"expected"`
Changed int `json:"changed"`
} `json:"mirror_progress"`
}
if json.Unmarshal(payload, &data) != nil {
return Status{}, dobotaiops.ErrUnavailable
}
j.Loaded = data.Progress.Loaded
j.Expected = data.Progress.Expected
j.Changed = data.Progress.Changed
if expired {
j.State = "retry"
if attempt >= maxAttempts {
j.State = "failed"
}
j.ErrorCode = "mirror_sync_interrupted"
}
state.Job = &j
return state, nil
}