feat: add schema fingerprint and Z10d alter; refresh sync coop docs
Ship ensure ADD COLUMN and fingerprint API for Yuheng reconcile, and rewrite §0.2–0.3 cooperation checklist in the联调意见. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -37,8 +37,8 @@ func EnsureTableFromRow(ctx context.Context, db *sql.DB, driver Driver, table, p
|
||||
return EnsureTableFromColumns(ctx, db, driver, table, pkCol, cols)
|
||||
}
|
||||
|
||||
// EnsureTableFromColumns 表不存在时按列名建空表(全部 TEXT,指定 PK)。已存在则幂等跳过。
|
||||
// 用于空表结构同步:本机有空表 → 线上也建同名空表(无需 outbox 行)。
|
||||
// EnsureTableFromColumns 按列名建空表(全部 TEXT,指定 PK)。
|
||||
// 已存在时补齐缺失列(ADD COLUMN),解决「先按 _row_id 建表、后 push 注入 id」导致的缺列 503。
|
||||
func EnsureTableFromColumns(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string, columns []string) error {
|
||||
table = strings.TrimSpace(table)
|
||||
pkCol = strings.TrimSpace(pkCol)
|
||||
@@ -55,27 +55,57 @@ func EnsureTableFromColumns(ctx context.Context, db *sql.DB, driver Driver, tabl
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
want := make([]string, 0, len(columns)+1)
|
||||
seen := map[string]struct{}{}
|
||||
defs := make([]string, 0, len(columns)+1)
|
||||
defs = append(defs, fmt.Sprintf("%s TEXT PRIMARY KEY", quoteIdent(driver, pkCol)))
|
||||
seen[pkCol] = struct{}{}
|
||||
for _, c := range columns {
|
||||
add := func(c string) {
|
||||
c = strings.TrimSpace(c)
|
||||
if c == "" {
|
||||
continue
|
||||
return
|
||||
}
|
||||
if _, ok := seen[c]; ok {
|
||||
continue
|
||||
return
|
||||
}
|
||||
seen[c] = struct{}{}
|
||||
defs = append(defs, fmt.Sprintf("%s TEXT", quoteIdent(driver, c)))
|
||||
want = append(want, c)
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
add(pkCol)
|
||||
for _, c := range columns {
|
||||
add(c)
|
||||
}
|
||||
if len(want) == 0 {
|
||||
return fmt.Errorf("columns required")
|
||||
}
|
||||
if exists {
|
||||
have, err := listColumns(ctx, db, driver, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
haveSet := map[string]struct{}{}
|
||||
for _, c := range have {
|
||||
haveSet[c] = struct{}{}
|
||||
}
|
||||
for _, c := range want {
|
||||
if _, ok := haveSet[c]; ok {
|
||||
continue
|
||||
}
|
||||
ddl := fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s TEXT`, quoteIdent(driver, table), quoteIdent(driver, c))
|
||||
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
||||
// SQLite 无 IF NOT EXISTS;并发下可能已存在
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defs := make([]string, 0, len(want))
|
||||
defs = append(defs, fmt.Sprintf("%s TEXT PRIMARY KEY", quoteIdent(driver, pkCol)))
|
||||
for _, c := range want {
|
||||
if c == pkCol {
|
||||
continue
|
||||
}
|
||||
defs = append(defs, fmt.Sprintf("%s TEXT", quoteIdent(driver, c)))
|
||||
}
|
||||
ddl := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (%s)`, quoteIdent(driver, table), strings.Join(defs, ", "))
|
||||
_, err = db.ExecContext(ctx, ddl)
|
||||
return err
|
||||
|
||||
199
platform/internal/dbsync/fingerprint.go
Normal file
199
platform/internal/dbsync/fingerprint.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package dbsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FingerprintTable 单表指纹(与宇恒 sync_fingerprint 算法对齐)。
|
||||
type FingerprintTable struct {
|
||||
Name string `json:"name"`
|
||||
PKColumn string `json:"pk_column"`
|
||||
RowCount int64 `json:"row_count"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// FingerprintRequest agent 拉线上表指纹。
|
||||
type FingerprintRequest struct {
|
||||
OnlineDBID string `json:"online_db_id"`
|
||||
Tables []string `json:"tables,omitempty"`
|
||||
}
|
||||
|
||||
// FingerprintResult 批量指纹。
|
||||
type FingerprintResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Tables []FingerprintTable `json:"tables"`
|
||||
}
|
||||
|
||||
// FingerprintRemoteTables 计算线上 A 业务表 row_count + content_hash。
|
||||
// 哈希:sha256( table+"|"+pk+"|" + Σ json.dumps(row, sort_keys=True)+"\n" ) 取前 32 hex。
|
||||
func FingerprintRemoteTables(ctx context.Context, ch *Channel, req FingerprintRequest) (*FingerprintResult, error) {
|
||||
if ch == nil {
|
||||
return nil, fmt.Errorf("channel is nil")
|
||||
}
|
||||
db, err := AcquireRemote(ch.Remote.Driver, ch.Remote.DSN)
|
||||
if err != nil {
|
||||
return nil, wrapOpenRemote(err)
|
||||
}
|
||||
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
|
||||
return nil, Retryablef("ensure meta: %v", err)
|
||||
}
|
||||
names, err := ListTables(ctx, db, ch.Remote.Driver)
|
||||
if err != nil {
|
||||
return nil, Retryablef("list tables: %v", err)
|
||||
}
|
||||
want := map[string]struct{}{}
|
||||
for _, t := range req.Tables {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
want[t] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := &FingerprintResult{OK: true, Tables: make([]FingerprintTable, 0, len(names))}
|
||||
for _, name := range names {
|
||||
if strings.HasPrefix(name, "_ajz_") {
|
||||
continue
|
||||
}
|
||||
if len(want) > 0 {
|
||||
if _, ok := want[name]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !tableInChannel(ch, name) {
|
||||
continue
|
||||
}
|
||||
fp, ferr := fingerprintOneTable(ctx, db, ch, name)
|
||||
if ferr != nil {
|
||||
fp.Error = ferr.Error()
|
||||
out.OK = false
|
||||
}
|
||||
out.Tables = append(out.Tables, fp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fingerprintOneTable(ctx context.Context, db *sql.DB, ch *Channel, table string) (FingerprintTable, error) {
|
||||
cols, err := listColumns(ctx, db, ch.Remote.Driver, table)
|
||||
if err != nil {
|
||||
return FingerprintTable{Name: table}, err
|
||||
}
|
||||
pkCol := "id"
|
||||
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
|
||||
pkCol = ch.PKColumns[table]
|
||||
}
|
||||
if !containsStr(cols, pkCol) {
|
||||
for _, c := range []string{"id", "__row_id", "_row_id"} {
|
||||
if containsStr(cols, c) {
|
||||
pkCol = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if !containsStr(cols, pkCol) && len(cols) > 0 {
|
||||
pkCol = cols[0]
|
||||
}
|
||||
}
|
||||
n, err := countRows(ctx, db, ch.Remote.Driver, table)
|
||||
if err != nil {
|
||||
return FingerprintTable{Name: table, PKColumn: pkCol, Columns: cols}, err
|
||||
}
|
||||
hash, err := contentHashOrdered(ctx, db, ch.Remote.Driver, table, pkCol)
|
||||
if err != nil {
|
||||
return FingerprintTable{Name: table, PKColumn: pkCol, RowCount: n, Columns: cols}, err
|
||||
}
|
||||
return FingerprintTable{
|
||||
Name: table,
|
||||
PKColumn: pkCol,
|
||||
RowCount: n,
|
||||
ContentHash: hash,
|
||||
Columns: cols,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func containsStr(ss []string, x string) bool {
|
||||
for _, s := range ss {
|
||||
if s == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contentHashOrdered(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string) (string, error) {
|
||||
q := fmt.Sprintf(`SELECT * FROM %s ORDER BY %s`, quoteIdent(driver, table), quoteIdent(driver, pkCol))
|
||||
rows, err := db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
colNames, err := rows.Columns()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
h := sha256.New()
|
||||
_, _ = h.Write([]byte(table + "|" + pkCol + "|"))
|
||||
ptrs := make([]any, len(colNames))
|
||||
vals := make([]any, len(colNames))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
for rows.Next() {
|
||||
for i := range vals {
|
||||
vals[i] = nil
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := make(map[string]any, len(colNames))
|
||||
for i, c := range colNames {
|
||||
m[c] = normalizeSQLValue(vals[i])
|
||||
}
|
||||
blob, err := stableJSON(m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, _ = h.Write(blob)
|
||||
_, _ = h.Write([]byte("\n"))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
if len(sum) > 32 {
|
||||
sum = sum[:32]
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// stableJSON 按 key 排序,贴近 Python json.dumps(..., sort_keys=True, default=str)。
|
||||
func stableJSON(m map[string]any) ([]byte, error) {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
kb, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vb, err := json.Marshal(m[k])
|
||||
if err != nil {
|
||||
// fallback string
|
||||
vb, err = json.Marshal(fmt.Sprint(m[k]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
parts = append(parts, string(kb)+":"+string(vb))
|
||||
}
|
||||
return []byte("{" + strings.Join(parts, ",") + "}"), nil
|
||||
}
|
||||
Reference in New Issue
Block a user