主要更新: 1. 数据库持久化功能 - 支持三种策略:仅落库、既落库又存证、仅存证 - 实现 Cursor Worker 异步扫描和存证机制 - 实现 Retry Worker 失败重试机制 - 支持 PostgreSQL、MySQL、SQLite 等多种数据库 - 添加 ClientIP 和 ServerIP 字段(可空,仅落库) 2. 集群并发安全 - 使用 SELECT FOR UPDATE SKIP LOCKED 防止重复处理 - 实现 CAS (Compare-And-Set) 原子状态更新 - 添加 updated_at 字段支持并发控制 3. Cursor 初始化优化 - 自动基于历史数据初始化 cursor - 确保不遗漏任何历史记录 - 修复 UPSERT 逻辑 4. 测试完善 - 添加 E2E 集成测试(含 Pulsar 消费者验证) - 添加 PostgreSQL 集成测试 - 添加 Pulsar 集成测试 - 添加集群并发安全测试 - 添加 Cursor 初始化验证测试 - 补充大量单元测试,提升覆盖率 5. 工具脚本 - 添加数据库迁移脚本 - 添加 Cursor 状态检查工具 - 添加 Cursor 初始化工具 - 添加 Pulsar 消息验证工具 6. 文档清理 - 删除冗余文档,只保留根目录 README 测试结果: - 所有 E2E 测试通过(100%) - 数据库持久化与异步存证流程验证通过 - 集群环境下的并发安全性验证通过 - Cursor 自动初始化和历史数据处理验证通过
364 lines
10 KiB
Go
364 lines
10 KiB
Go
package persistence_test
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
_ "github.com/lib/pq"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"go.yandata.net/iod/iod/go-trustlog/api/adapter"
|
||
"go.yandata.net/iod/iod/go-trustlog/api/logger"
|
||
"go.yandata.net/iod/iod/go-trustlog/api/model"
|
||
"go.yandata.net/iod/iod/go-trustlog/api/persistence"
|
||
)
|
||
|
||
const (
|
||
// PostgreSQL 连接配置
|
||
postgresHost = "localhost"
|
||
postgresPort = 5432
|
||
postgresUser = "postgres"
|
||
postgresPassword = "postgres"
|
||
postgresDatabase = "trustlog"
|
||
|
||
// Pulsar 连接配置
|
||
pulsarURL = "pulsar://localhost:6650"
|
||
pulsarTopic = "trustlog-integration-test"
|
||
)
|
||
|
||
// TestIntegration_PostgreSQL_Basic 测试基本的 PostgreSQL 持久化功能
|
||
func TestIntegration_PostgreSQL_Basic(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping integration test in short mode")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
log := logger.NewNopLogger()
|
||
|
||
// 创建数据库连接
|
||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||
postgresHost, postgresPort, postgresUser, postgresPassword, postgresDatabase)
|
||
|
||
db, err := sql.Open("postgres", dsn)
|
||
require.NoError(t, err, "Failed to connect to PostgreSQL")
|
||
defer db.Close()
|
||
|
||
// 测试连接
|
||
err = db.PingContext(ctx)
|
||
require.NoError(t, err, "Failed to ping PostgreSQL")
|
||
|
||
// 创建持久化客户端(仅落库策略)
|
||
config := persistence.PersistenceClientConfig{
|
||
DBConfig: persistence.DBConfig{
|
||
DSN: dsn,
|
||
DriverName: "postgres",
|
||
MaxOpenConns: 10,
|
||
},
|
||
PersistenceConfig: persistence.PersistenceConfig{
|
||
Strategy: persistence.StrategyDBOnly,
|
||
},
|
||
Logger: log,
|
||
}
|
||
|
||
client, err := persistence.NewPersistenceClient(ctx, config)
|
||
require.NoError(t, err, "Failed to create persistence client")
|
||
defer client.Close()
|
||
|
||
// 创建测试 Operation
|
||
now := time.Now()
|
||
clientIP := "192.168.1.100"
|
||
serverIP := "10.0.0.1"
|
||
|
||
operation := &model.Operation{
|
||
OpID: fmt.Sprintf("test-op-%d", now.Unix()),
|
||
Timestamp: now,
|
||
OpSource: model.OpSourceDOIP,
|
||
OpType: model.OpTypeCreate,
|
||
DoPrefix: "test",
|
||
ClientIP: &clientIP,
|
||
ServerIP: &serverIP,
|
||
}
|
||
|
||
// 存储 Operation
|
||
err = client.OperationPublish(ctx, operation)
|
||
require.NoError(t, err, "Failed to publish operation")
|
||
|
||
// 等待一小段时间确保写入完成
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
// 从数据库查询验证
|
||
var count int
|
||
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM operation WHERE op_id = $1", operation.OpID).Scan(&count)
|
||
require.NoError(t, err, "Failed to query operation")
|
||
assert.Equal(t, 1, count, "Operation should be stored in database")
|
||
|
||
// 验证 IP 字段
|
||
var storedClientIP, storedServerIP sql.NullString
|
||
err = db.QueryRowContext(ctx,
|
||
"SELECT client_ip, server_ip FROM operation WHERE op_id = $1",
|
||
operation.OpID).Scan(&storedClientIP, &storedServerIP)
|
||
require.NoError(t, err, "Failed to query IP fields")
|
||
|
||
assert.True(t, storedClientIP.Valid, "ClientIP should be valid")
|
||
assert.Equal(t, clientIP, storedClientIP.String, "ClientIP should match")
|
||
assert.True(t, storedServerIP.Valid, "ServerIP should be valid")
|
||
assert.Equal(t, serverIP, storedServerIP.String, "ServerIP should match")
|
||
|
||
t.Logf("✅ PostgreSQL basic test passed")
|
||
}
|
||
|
||
// TestIntegration_PostgreSQL_NullableIP 测试可空 IP 字段
|
||
func TestIntegration_PostgreSQL_NullableIP(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping integration test in short mode")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
log := logger.NewNopLogger()
|
||
|
||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||
postgresHost, postgresPort, postgresUser, postgresPassword, postgresDatabase)
|
||
|
||
db, err := sql.Open("postgres", dsn)
|
||
require.NoError(t, err)
|
||
defer db.Close()
|
||
|
||
config := persistence.PersistenceClientConfig{
|
||
DBConfig: persistence.DBConfig{
|
||
DSN: dsn,
|
||
DriverName: "postgres",
|
||
MaxOpenConns: 10,
|
||
},
|
||
PersistenceConfig: persistence.PersistenceConfig{
|
||
Strategy: persistence.StrategyDBOnly,
|
||
},
|
||
}
|
||
|
||
client, err := persistence.NewPersistenceClient(ctx, config)
|
||
require.NoError(t, err)
|
||
defer client.Close()
|
||
|
||
// 创建不带 IP 的 Operation
|
||
now := time.Now()
|
||
operation := &model.Operation{
|
||
OpID: fmt.Sprintf("test-op-noip-%d", now.Unix()),
|
||
Timestamp: now,
|
||
OpSource: model.OpSourceDOIP,
|
||
OpType: model.OpTypeUpdate,
|
||
DoPrefix: "test",
|
||
// ClientIP 和 ServerIP 为 nil
|
||
}
|
||
|
||
err = client.OperationPublish(ctx, operation)
|
||
require.NoError(t, err)
|
||
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
// 验证 IP 字段为 NULL
|
||
var storedClientIP, storedServerIP sql.NullString
|
||
err = db.QueryRowContext(ctx,
|
||
"SELECT client_ip, server_ip FROM operation WHERE op_id = $1",
|
||
operation.OpID).Scan(&storedClientIP, &storedServerIP)
|
||
require.NoError(t, err)
|
||
|
||
assert.False(t, storedClientIP.Valid, "ClientIP should be NULL")
|
||
assert.False(t, storedServerIP.Valid, "ServerIP should be NULL")
|
||
|
||
t.Logf("✅ PostgreSQL nullable IP test passed")
|
||
}
|
||
|
||
// TestIntegration_Pulsar_PostgreSQL 测试 Pulsar + PostgreSQL 集成
|
||
func TestIntegration_Pulsar_PostgreSQL(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping integration test in short mode")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
log := logger.NewNopLogger()
|
||
|
||
// PostgreSQL 配置
|
||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||
postgresHost, postgresPort, postgresUser, postgresPassword, postgresDatabase)
|
||
|
||
db, err := sql.Open("postgres", dsn)
|
||
require.NoError(t, err)
|
||
defer db.Close()
|
||
|
||
err = db.PingContext(ctx)
|
||
require.NoError(t, err)
|
||
|
||
// 创建 Pulsar publisher
|
||
pulsarConfig := adapter.PublisherConfig{
|
||
Logger: log,
|
||
URL: pulsarURL,
|
||
Topic: pulsarTopic,
|
||
ProducerName: "integration-test-producer",
|
||
}
|
||
|
||
publisher, err := adapter.NewPublisher(ctx, pulsarConfig)
|
||
require.NoError(t, err, "Failed to create Pulsar publisher")
|
||
defer publisher.Close()
|
||
|
||
// 创建持久化客户端(DB + Trustlog 策略)
|
||
config := persistence.PersistenceClientConfig{
|
||
DBConfig: persistence.DBConfig{
|
||
DSN: dsn,
|
||
DriverName: "postgres",
|
||
MaxOpenConns: 10,
|
||
},
|
||
PersistenceConfig: persistence.PersistenceConfig{
|
||
Strategy: persistence.StrategyDBAndTrustlog,
|
||
},
|
||
EnableCursorWorker: true,
|
||
CursorWorkerConfig: persistence.CursorWorkerConfig{
|
||
ScanInterval: 5 * time.Second,
|
||
BatchSize: 10,
|
||
MaxRetries: 3,
|
||
RetryInterval: 2 * time.Second,
|
||
InitialBackoff: 1 * time.Second,
|
||
MaxBackoff: 10 * time.Second,
|
||
},
|
||
EnableRetryWorker: true,
|
||
RetryWorkerConfig: persistence.RetryWorkerConfig{
|
||
CheckInterval: 5 * time.Second,
|
||
BatchSize: 10,
|
||
MaxRetries: 5,
|
||
InitialDelay: 1 * time.Minute,
|
||
MaxDelay: 1 * time.Hour,
|
||
BackoffFactor: 2.0,
|
||
},
|
||
}
|
||
|
||
client, err := persistence.NewPersistenceClient(ctx, config)
|
||
require.NoError(t, err)
|
||
defer client.Close()
|
||
|
||
// 设置 publisher
|
||
client.SetPublisher(publisher)
|
||
|
||
// 创建测试 Operation
|
||
now := time.Now()
|
||
clientIP := "172.16.0.100"
|
||
|
||
operation := &model.Operation{
|
||
OpID: fmt.Sprintf("pulsar-test-%d", now.Unix()),
|
||
Timestamp: now,
|
||
OpSource: model.OpSourceDOIP,
|
||
OpType: model.OpTypeCreate,
|
||
DoPrefix: "integration",
|
||
ClientIP: &clientIP,
|
||
}
|
||
|
||
// 发布 Operation(应该同时写入 DB 和 Pulsar)
|
||
err = client.OperationPublish(ctx, operation)
|
||
require.NoError(t, err)
|
||
|
||
// 等待异步处理
|
||
time.Sleep(2 * time.Second)
|
||
|
||
// 验证 DB 中存在记录
|
||
var count int
|
||
err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM operation WHERE op_id = $1", operation.OpID).Scan(&count)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, 1, count, "Operation should be in database")
|
||
|
||
// 验证 trustlog_status 初始为 NOT_TRUSTLOGGED
|
||
var status string
|
||
err = db.QueryRowContext(ctx, "SELECT trustlog_status FROM operation WHERE op_id = $1", operation.OpID).Scan(&status)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "NOT_TRUSTLOGGED", status, "Initial status should be NOT_TRUSTLOGGED")
|
||
|
||
t.Logf("✅ Pulsar + PostgreSQL integration test passed")
|
||
}
|
||
|
||
// TestIntegration_CursorWorker 测试 Cursor Worker 功能
|
||
func TestIntegration_CursorWorker(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("Skipping integration test in short mode")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||
defer cancel()
|
||
|
||
log := logger.NewNopLogger()
|
||
|
||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||
postgresHost, postgresPort, postgresUser, postgresPassword, postgresDatabase)
|
||
|
||
db, err := sql.Open("postgres", dsn)
|
||
require.NoError(t, err)
|
||
defer db.Close()
|
||
|
||
// 创建带 Cursor Worker 的持久化客户端
|
||
config := persistence.PersistenceClientConfig{
|
||
DBConfig: persistence.DBConfig{
|
||
DSN: dsn,
|
||
DriverName: "postgres",
|
||
MaxOpenConns: 10,
|
||
},
|
||
PersistenceConfig: persistence.PersistenceConfig{
|
||
Strategy: persistence.StrategyDBAndTrustlog,
|
||
},
|
||
EnableCursorWorker: true,
|
||
CursorWorkerConfig: persistence.CursorWorkerConfig{
|
||
ScanInterval: 2 * time.Second, // 更频繁的扫描用于测试
|
||
BatchSize: 10,
|
||
MaxRetries: 3,
|
||
RetryInterval: 1 * time.Second,
|
||
InitialBackoff: 500 * time.Millisecond,
|
||
MaxBackoff: 5 * time.Second,
|
||
},
|
||
}
|
||
|
||
client, err := persistence.NewPersistenceClient(ctx, config)
|
||
require.NoError(t, err)
|
||
defer client.Close()
|
||
|
||
// 创建 mock publisher
|
||
pulsarConfig := adapter.PublisherConfig{
|
||
Logger: log,
|
||
URL: pulsarURL,
|
||
Topic: pulsarTopic + "-cursor",
|
||
ProducerName: "cursor-test-producer",
|
||
}
|
||
|
||
publisher, err := adapter.NewPublisher(ctx, pulsarConfig)
|
||
require.NoError(t, err)
|
||
defer publisher.Close()
|
||
|
||
client.SetPublisher(publisher)
|
||
|
||
// 插入测试记录
|
||
now := time.Now()
|
||
operation := &model.Operation{
|
||
OpID: fmt.Sprintf("cursor-test-%d", now.Unix()),
|
||
Timestamp: now,
|
||
OpSource: model.OpSourceDOIP,
|
||
OpType: model.OpTypeCreate,
|
||
DoPrefix: "cursor-test",
|
||
}
|
||
|
||
err = client.OperationPublish(ctx, operation)
|
||
require.NoError(t, err)
|
||
|
||
// 等待 Cursor Worker 处理
|
||
time.Sleep(10 * time.Second)
|
||
|
||
// 验证状态变化(可能已被处理)
|
||
var status string
|
||
err = db.QueryRowContext(ctx, "SELECT trustlog_status FROM operation WHERE op_id = $1", operation.OpID).Scan(&status)
|
||
require.NoError(t, err)
|
||
|
||
t.Logf("Operation status after cursor worker: %s", status)
|
||
// 注意:由于 Pulsar 可能不可用,状态可能仍是 NOT_TRUSTLOGGED 或进入 retry 表
|
||
|
||
t.Logf("✅ Cursor Worker test completed")
|
||
}
|
||
|