feat(synccdc): 新增 MongoDB 变更流适配器

- 建立 CDC 适配器注册、探测与安全游标契约
- 支持 MongoDB change stream、恢复令牌和批量读取
- 覆盖权限探测、取消关闭与流位置恢复测试
This commit is contained in:
Syngnat
2026-08-08 19:06:36 +08:00
parent c91a1555a4
commit e40cdbc016
9 changed files with 2267 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
package synccdc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"GoNavi-Wails/internal/connection"
)
type Position struct {
Adapter string `json:"adapter"`
Opaque json.RawMessage `json:"opaque"`
}
type Barrier struct {
Position Position `json:"position"`
SnapshotToken json.RawMessage `json:"snapshotToken,omitempty"`
}
type ObjectRef struct {
Database string `json:"database,omitempty"`
Schema string `json:"schema,omitempty"`
Name string `json:"name"`
}
type Event struct {
Object ObjectRef `json:"object"`
Operation string `json:"operation"`
Key map[string]interface{} `json:"key,omitempty"`
Before map[string]interface{} `json:"before,omitempty"`
After map[string]interface{} `json:"after,omitempty"`
CommitTime time.Time `json:"commitTime"`
SourceTxID string `json:"sourceTxId,omitempty"`
}
type Transaction struct {
Events []Event `json:"events"`
Position Position `json:"position"`
}
type Request struct {
Config connection.ConnectionConfig `json:"-"`
Objects []ObjectRef `json:"objects"`
Database string `json:"database,omitempty"`
Schema string `json:"schema,omitempty"`
}
type Capability struct {
Adapter string `json:"adapter"`
SourceType string `json:"sourceType"`
Supported bool `json:"supported"`
Ready bool `json:"ready"`
Reason string `json:"reason,omitempty"`
RequiredSettings []string `json:"requiredSettings,omitempty"`
SupportsInitialSnapshot bool `json:"supportsInitialSnapshot"`
SupportsSchemaEvents bool `json:"supportsSchemaEvents"`
PreservesSourceTransactions bool `json:"preservesSourceTransactions"`
RequiresCausalSnapshotReads bool `json:"requiresCausalSnapshotReads"`
SnapshotSemantics string `json:"snapshotSemantics,omitempty"`
DeliverySemantics string `json:"deliverySemantics,omitempty"`
AcknowledgementSemantics string `json:"acknowledgementSemantics,omitempty"`
}
type Stream interface {
Next(context.Context) (Transaction, error)
Acknowledge(context.Context, Position) error
Close() error
}
type Adapter interface {
Name() string
SourceTypes() []string
Probe(context.Context, connection.ConnectionConfig) (Capability, error)
BeginSnapshot(context.Context, Request) (Barrier, error)
Open(context.Context, Request, Position) (Stream, error)
}
var ErrAdapterNotRegistered = errors.New("CDC adapter is not registered in this build")
func ValidatePosition(position Position, adapterName string) error {
if strings.TrimSpace(position.Adapter) == "" {
return errors.New("CDC position adapter is required")
}
if !strings.EqualFold(strings.TrimSpace(position.Adapter), strings.TrimSpace(adapterName)) {
return fmt.Errorf("CDC position adapter %q does not match %q", position.Adapter, adapterName)
}
if len(position.Opaque) == 0 || !json.Valid(position.Opaque) {
return errors.New("CDC position payload must be valid JSON")
}
return nil
}

View File

@@ -0,0 +1,383 @@
package synccdc
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
const (
mongoSnapshotSemantics = "startAtOperationTime at-least-once barrier; complete handoff requires every snapshot read to be causally constrained at or after this operationTime, which this adapter cannot enforce by itself; without that constraint initial snapshot is unsupported, reads are not pinned to one cluster timestamp, and duplicate events are possible"
mongoDeliverySemantics = "ordered per MongoDB change stream cursor with resumable at-least-once delivery; each returned transaction contains one change event; stream termination including filtered drop/invalidate returns a re-snapshot-required error"
mongoAckSemantics = "local delivered-position validation only; MongoDB change streams have no server-side acknowledgement"
)
var mongoRequiredSettings = []string{
"MongoDB 4.2+ replica set or sharded cluster; standalone servers are unsupported",
"database-scope changeStream/find privileges for Probe, plus find/changeStream access to every selected namespace",
"majority read concern and oplog retention covering the full snapshot duration",
"a resolved direct MongoDB endpoint; SSH, proxy, and HTTP tunnel routing must be established before CDC",
"cluster-scope changeStream privilege when selected namespaces span multiple databases",
"snapshot reader support for causal reads at or after the returned operationTime when initialSnapshot is enabled",
}
type MongoDBAdapter struct {
connector mongoConnector
}
type mongoSnapshotToken struct {
Version int `json:"version"`
Strategy string `json:"strategy"`
ScopeHash string `json:"scopeHash"`
OperationTime mongoOperationTime `json:"operationTime"`
Semantics string `json:"semantics"`
}
func NewMongoDBAdapter() *MongoDBAdapter {
return &MongoDBAdapter{connector: realMongoConnector{}}
}
func newMongoDBAdapterWithConnector(connector mongoConnector) *MongoDBAdapter {
return &MongoDBAdapter{connector: connector}
}
func (a *MongoDBAdapter) Name() string {
return mongoDBAdapterName
}
func (a *MongoDBAdapter) SourceTypes() []string {
return []string{"mongodb", "mongodb-v1"}
}
func (a *MongoDBAdapter) Probe(ctx context.Context, config connection.ConnectionConfig) (Capability, error) {
ctx = mongoContext(ctx)
capability := mongoCapability(config.Type)
if normalizeSourceType(config.Type) != "mongodb" {
capability.Supported = false
capability.Reason = fmt.Sprintf("source type %q is not handled by the MongoDB CDC adapter", strings.TrimSpace(config.Type))
return capability, nil
}
if err := validateMongoNetworkRoute(config); err != nil {
capability.Reason = err.Error()
return capability, nil
}
database := mongoConfigDatabase(config)
if database == "" {
capability.Reason = "MongoDB CDC requires a source database for its privilege and change-stream probe"
return capability, nil
}
if a == nil || a.connector == nil {
return capability, errors.New("MongoDB CDC connector is not configured")
}
conn, err := a.connector.Connect(ctx, config)
if err != nil {
if ctxErr := contextError(ctx, err); ctxErr != nil {
return capability, ctxErr
}
capability.Reason = classifyMongoProbeError(err)
return capability, nil
}
defer disconnectMongoConnection(conn)
topology, err := conn.Inspect(ctx)
if err != nil {
if ctxErr := contextError(ctx, err); ctxErr != nil {
return capability, ctxErr
}
capability.Reason = classifyMongoProbeError(err)
return capability, nil
}
if reason := validateMongoTopology(topology); reason != "" {
capability.Reason = reason
return capability, nil
}
if err := conn.ProbeChangeStream(ctx, database, bson.Timestamp{}); err != nil {
if ctxErr := contextError(ctx, err); ctxErr != nil {
return capability, ctxErr
}
capability.Reason = classifyMongoProbeError(err)
return capability, nil
}
capability.Ready = true
capability.Reason = mongoSnapshotSemantics
return capability, nil
}
func (a *MongoDBAdapter) BeginSnapshot(ctx context.Context, request Request) (Barrier, error) {
ctx = mongoContext(ctx)
namespaces, scopeHash, err := validateMongoRequest(request)
if err != nil {
return Barrier{}, err
}
if a == nil || a.connector == nil {
return Barrier{}, errors.New("MongoDB CDC connector is not configured")
}
conn, err := a.connector.Connect(ctx, request.Config)
if err != nil {
return Barrier{}, err
}
defer disconnectMongoConnection(conn)
if err := ensureMongoTopology(ctx, conn); err != nil {
return Barrier{}, err
}
operationTime, err := conn.SnapshotOperationTime(ctx, namespaces[0])
if err != nil {
return Barrier{}, err
}
position, err := mongoOperationTimePosition(scopeHash, operationTime)
if err != nil {
return Barrier{}, err
}
snapshotToken, err := json.Marshal(mongoSnapshotToken{
Version: mongoPositionVersion,
Strategy: "startAtOperationTime",
ScopeHash: scopeHash,
OperationTime: mongoOperationTime{
Seconds: operationTime.T,
Increment: operationTime.I,
},
Semantics: mongoSnapshotSemantics,
})
if err != nil {
return Barrier{}, fmt.Errorf("encode MongoDB CDC snapshot token: %w", err)
}
return Barrier{Position: position, SnapshotToken: snapshotToken}, nil
}
func (a *MongoDBAdapter) Open(ctx context.Context, request Request, position Position) (Stream, error) {
ctx = mongoContext(ctx)
namespaces, scopeHash, err := validateMongoRequest(request)
if err != nil {
return nil, err
}
payload, resumeToken, operationTime, err := decodeMongoPosition(position)
if err != nil {
return nil, err
}
if payload.ScopeHash != scopeHash {
return nil, errors.New("MongoDB CDC position belongs to a different source or namespace selection")
}
if a == nil || a.connector == nil {
return nil, errors.New("MongoDB CDC connector is not configured")
}
conn, err := a.connector.Connect(ctx, request.Config)
if err != nil {
return nil, err
}
if err := ensureMongoTopology(ctx, conn); err != nil {
disconnectMongoConnection(conn)
return nil, err
}
cursor, err := conn.OpenChangeStream(ctx, namespaces, mongoWatchStart{
ResumeToken: resumeToken,
OperationTime: operationTime,
})
if err != nil {
disconnectMongoConnection(conn)
return nil, fmt.Errorf("open MongoDB change stream: %w", err)
}
return newMongoDBStream(conn, cursor, namespaces, scopeHash), nil
}
func mongoCapability(sourceType string) Capability {
normalizedSource := normalizeSourceType(sourceType)
if normalizedSource == "" {
normalizedSource = "mongodb"
}
return Capability{
Adapter: mongoDBAdapterName,
SourceType: normalizedSource,
Supported: true,
Ready: false,
RequiredSettings: append([]string(nil), mongoRequiredSettings...),
SupportsInitialSnapshot: true,
SupportsSchemaEvents: false,
PreservesSourceTransactions: false,
RequiresCausalSnapshotReads: true,
SnapshotSemantics: mongoSnapshotSemantics,
DeliverySemantics: mongoDeliverySemantics,
AcknowledgementSemantics: mongoAckSemantics,
}
}
func validateMongoRequest(request Request) ([]mongoNamespace, string, error) {
if normalizeSourceType(request.Config.Type) != "mongodb" {
return nil, "", fmt.Errorf("MongoDB CDC source type must be mongodb or mongodb-v1, got %q", strings.TrimSpace(request.Config.Type))
}
if err := validateMongoNetworkRoute(request.Config); err != nil {
return nil, "", err
}
if len(request.Objects) == 0 {
return nil, "", errors.New("MongoDB CDC requires at least one selected collection")
}
fallbackDatabase := firstNonEmpty(request.Database, request.Schema, mongoConfigDatabase(request.Config))
seen := make(map[string]struct{}, len(request.Objects))
namespaces := make([]mongoNamespace, 0, len(request.Objects))
for _, object := range request.Objects {
database := firstNonEmpty(object.Database, object.Schema, fallbackDatabase)
collection := strings.TrimSpace(object.Name)
if database == "" {
return nil, "", fmt.Errorf("MongoDB CDC collection %q has no database namespace", collection)
}
if collection == "" {
return nil, "", errors.New("MongoDB CDC collection name is required")
}
if strings.ContainsRune(database, '\x00') || strings.ContainsRune(collection, '\x00') {
return nil, "", errors.New("MongoDB CDC namespaces must not contain NUL characters")
}
if strings.HasPrefix(collection, "system.") {
return nil, "", fmt.Errorf("MongoDB CDC does not support system collection %s.%s", database, collection)
}
key := database + "\x00" + collection
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
namespaces = append(namespaces, mongoNamespace{Database: database, Collection: collection})
}
sort.Slice(namespaces, func(i, j int) bool {
if namespaces[i].Database == namespaces[j].Database {
return namespaces[i].Collection < namespaces[j].Collection
}
return namespaces[i].Database < namespaces[j].Database
})
return namespaces, mongoScopeHash(request.Config, namespaces), nil
}
func mongoScopeHash(config connection.ConnectionConfig, namespaces []mongoNamespace) string {
parts := []string{
"v1",
normalizeSourceType(config.Type),
strings.TrimSpace(config.ID),
strings.TrimSpace(config.ReplicaSet),
mongoSafeEndpoint(config),
}
for _, namespace := range namespaces {
parts = append(parts, namespace.Database+"."+namespace.Collection)
}
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return hex.EncodeToString(sum[:])
}
func mongoSafeEndpoint(config connection.ConnectionConfig) string {
if rawURI := strings.TrimSpace(config.URI); rawURI != "" {
if parsed, err := url.Parse(rawURI); err == nil {
parsed.User = nil
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.ToLower(parsed.String())
}
}
return strings.ToLower(strings.Join(normalizedMongoHosts(config), ",") + "/" + strings.TrimSpace(config.Database))
}
func mongoConfigDatabase(config connection.ConnectionConfig) string {
if database := strings.TrimSpace(config.Database); database != "" {
return database
}
if rawURI := strings.TrimSpace(config.URI); rawURI != "" {
if parsed, err := url.Parse(rawURI); err == nil {
if database, err := url.PathUnescape(strings.Trim(parsed.EscapedPath(), "/")); err == nil {
return strings.TrimSpace(database)
}
}
}
return ""
}
func ensureMongoTopology(ctx context.Context, conn mongoConnection) error {
topology, err := conn.Inspect(ctx)
if err != nil {
return err
}
if reason := validateMongoTopology(topology); reason != "" {
return errors.New(reason)
}
return nil
}
func validateMongoTopology(topology mongoTopology) string {
if strings.TrimSpace(topology.ReplicaSet) == "" && !topology.Sharded {
return "MongoDB CDC is unavailable on standalone servers; configure a replica set or sharded cluster"
}
if topology.MaxWireVersion < mongoMinimumBarrierWireVersion {
return fmt.Sprintf("MongoDB CDC requires MongoDB 4.2+ (maxWireVersion >= %d), server reported %d", mongoMinimumBarrierWireVersion, topology.MaxWireVersion)
}
return ""
}
func classifyMongoProbeError(err error) string {
if err == nil {
return ""
}
var commandError mongo.CommandError
if errors.As(err, &commandError) {
switch commandError.Code {
case 13:
return "MongoDB CDC probe was not authorized; grant changeStream and find privileges for the source database and selected collections"
case 286:
return "MongoDB change-stream history is no longer available; increase oplog retention and create a new snapshot barrier"
case 40573, 40615:
return "MongoDB change streams require a replica set or sharded cluster; standalone servers are unsupported"
}
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "not authorized"), strings.Contains(message, "unauthorized"):
return "MongoDB CDC probe was not authorized; grant changeStream and find privileges for the source database and selected collections"
case strings.Contains(message, "only supported on replica sets"), strings.Contains(message, "$changestream") && strings.Contains(message, "standalone"):
return "MongoDB change streams require a replica set or sharded cluster; standalone servers are unsupported"
case strings.Contains(message, "operationtime"):
return "MongoDB did not provide an operationTime; enable majority read concern on a MongoDB 4.0+ replica set or sharded cluster"
default:
return "MongoDB CDC probe failed; verify the endpoint, TLS, credentials, replica-set settings, and source privileges"
}
}
func contextError(ctx context.Context, err error) error {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return nil
}
func disconnectMongoConnection(conn mongoConnection) {
if conn == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = conn.Disconnect(ctx)
cancel()
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func mongoContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
var _ Adapter = (*MongoDBAdapter)(nil)

View File

@@ -0,0 +1,367 @@
package synccdc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
type fakeMongoConnector struct {
connection *fakeMongoConnection
err error
}
func (f *fakeMongoConnector) Connect(context.Context, connection.ConnectionConfig) (mongoConnection, error) {
if f.err != nil {
return nil, f.err
}
return f.connection, nil
}
type fakeMongoConnection struct {
mu sync.Mutex
topology mongoTopology
inspectErr error
operationTime bson.Timestamp
operationErr error
probeErr error
cursor mongoCursor
openErr error
openNamespaces []mongoNamespace
openStart mongoWatchStart
disconnects int
}
func (f *fakeMongoConnection) Inspect(context.Context) (mongoTopology, error) {
return f.topology, f.inspectErr
}
func (f *fakeMongoConnection) SnapshotOperationTime(context.Context, mongoNamespace) (bson.Timestamp, error) {
return f.operationTime, f.operationErr
}
func (f *fakeMongoConnection) ProbeChangeStream(context.Context, string, bson.Timestamp) error {
return f.probeErr
}
func (f *fakeMongoConnection) OpenChangeStream(_ context.Context, namespaces []mongoNamespace, start mongoWatchStart) (mongoCursor, error) {
f.mu.Lock()
f.openNamespaces = append([]mongoNamespace(nil), namespaces...)
f.openStart = start
f.mu.Unlock()
if f.openErr != nil {
return nil, f.openErr
}
return f.cursor, nil
}
func (f *fakeMongoConnection) Disconnect(context.Context) error {
f.mu.Lock()
f.disconnects++
f.mu.Unlock()
return nil
}
type fakeMongoCursor struct {
mu sync.Mutex
events []bson.Raw
tokens []bson.Raw
index int
err error
closed int
nextStarted chan struct{}
block bool
}
func (f *fakeMongoCursor) Next(ctx context.Context) bool {
if f.block {
if f.nextStarted != nil {
select {
case f.nextStarted <- struct{}{}:
default:
}
}
<-ctx.Done()
f.mu.Lock()
f.err = ctx.Err()
f.mu.Unlock()
return false
}
f.mu.Lock()
defer f.mu.Unlock()
return f.index < len(f.events)
}
func (f *fakeMongoCursor) Decode(value any) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.index >= len(f.events) {
return errors.New("no fake MongoDB event")
}
err := bson.Unmarshal(f.events[f.index], value)
f.index++
return err
}
func (f *fakeMongoCursor) ResumeToken() bson.Raw {
f.mu.Lock()
defer f.mu.Unlock()
if f.index == 0 || f.index > len(f.tokens) {
return nil
}
return f.tokens[f.index-1]
}
func (f *fakeMongoCursor) Err() error {
f.mu.Lock()
defer f.mu.Unlock()
return f.err
}
func (f *fakeMongoCursor) Close(context.Context) error {
f.mu.Lock()
f.closed++
f.mu.Unlock()
return nil
}
func TestMongoPositionRoundTripPreservesRawResumeToken(t *testing.T) {
token := mustMongoRaw(t, bson.D{{Key: "_data", Value: "8268AABB"}, {Key: "nested", Value: bson.D{{Key: "n", Value: int64(42)}}}})
position, err := mongoResumeTokenPosition("scope-1", token)
if err != nil {
t.Fatalf("encode position: %v", err)
}
payload, decodedToken, operationTime, err := decodeMongoPosition(position)
if err != nil {
t.Fatalf("decode position: %v", err)
}
if payload.ScopeHash != "scope-1" || operationTime != nil {
t.Fatalf("unexpected decoded payload: %+v, operationTime=%v", payload, operationTime)
}
if !bytes.Equal(decodedToken, token) {
t.Fatalf("resume token changed: got %v want %v", decodedToken, token)
}
position.Opaque = json.RawMessage(`{"version":1,"scopeHash":"scope-1","resumeFormat":"bson-base64-v1","resumeTokenBson":"not@base64"}`)
if _, _, _, err := decodeMongoPosition(position); err == nil {
t.Fatal("malformed base64 resume token must be rejected")
}
validPosition, err := mongoResumeTokenPosition("scope-1", token)
if err != nil {
t.Fatalf("re-encode position: %v", err)
}
validPosition.Opaque = append(validPosition.Opaque, []byte(` {}`)...)
if _, _, _, err := decodeMongoPosition(validPosition); err == nil {
t.Fatal("trailing JSON must be rejected")
}
}
func TestMongoBeginSnapshotUsesOperationTimeBarrier(t *testing.T) {
fakeConnection := &fakeMongoConnection{
topology: mongoTopology{ReplicaSet: "rs0", MaxWireVersion: 17},
operationTime: bson.Timestamp{T: 1_725_000_000, I: 7},
}
adapter := newMongoDBAdapterWithConnector(&fakeMongoConnector{connection: fakeConnection})
request := mongoTestRequest()
barrier, err := adapter.BeginSnapshot(context.Background(), request)
if err != nil {
t.Fatalf("begin snapshot: %v", err)
}
payload, token, operationTime, err := decodeMongoPosition(barrier.Position)
if err != nil {
t.Fatalf("decode barrier position: %v", err)
}
if len(token) != 0 || operationTime == nil || *operationTime != fakeConnection.operationTime {
t.Fatalf("unexpected barrier: payload=%+v token=%v operationTime=%v", payload, token, operationTime)
}
var snapshot mongoSnapshotToken
if err := json.Unmarshal(barrier.SnapshotToken, &snapshot); err != nil {
t.Fatalf("decode snapshot token: %v", err)
}
if snapshot.Strategy != "startAtOperationTime" || !strings.Contains(snapshot.Semantics, "at-least-once") {
t.Fatalf("snapshot semantics not explicit: %+v", snapshot)
}
}
func TestMongoOpenUsesResumeTokenAndSelectedNamespaces(t *testing.T) {
resumeToken := mustMongoRaw(t, bson.D{{Key: "_data", Value: "resume-1"}})
fakeConnection := &fakeMongoConnection{
topology: mongoTopology{ReplicaSet: "rs0", MaxWireVersion: 17},
cursor: &fakeMongoCursor{},
}
adapter := newMongoDBAdapterWithConnector(&fakeMongoConnector{connection: fakeConnection})
request := mongoTestRequest()
namespaces, scopeHash, err := validateMongoRequest(request)
if err != nil {
t.Fatalf("validate request: %v", err)
}
position, err := mongoResumeTokenPosition(scopeHash, resumeToken)
if err != nil {
t.Fatalf("encode resume position: %v", err)
}
stream, err := adapter.Open(context.Background(), request, position)
if err != nil {
t.Fatalf("open stream: %v", err)
}
defer stream.Close()
if len(fakeConnection.openStart.ResumeToken) == 0 || fakeConnection.openStart.OperationTime != nil {
t.Fatalf("resume start was not used: %+v", fakeConnection.openStart)
}
if len(fakeConnection.openNamespaces) != len(namespaces) {
t.Fatalf("namespaces = %+v, want %+v", fakeConnection.openNamespaces, namespaces)
}
}
func TestMongoProbeClassifiesTopologyPrivilegeAndReadyState(t *testing.T) {
tests := []struct {
name string
connection *fakeMongoConnection
ready bool
reason string
}{
{
name: "standalone",
connection: &fakeMongoConnection{topology: mongoTopology{MaxWireVersion: 17}},
reason: "standalone",
},
{
name: "not authorized",
connection: &fakeMongoConnection{
topology: mongoTopology{ReplicaSet: "rs0", MaxWireVersion: 17},
operationTime: bson.Timestamp{T: 100, I: 1},
probeErr: mongo.CommandError{Code: 13, Message: "not authorized"},
},
reason: "changeStream and find",
},
{
name: "ready",
connection: &fakeMongoConnection{
topology: mongoTopology{ReplicaSet: "rs0", MaxWireVersion: 17},
operationTime: bson.Timestamp{T: 100, I: 1},
},
ready: true,
reason: "at-least-once",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := newMongoDBAdapterWithConnector(&fakeMongoConnector{connection: test.connection})
capability, err := adapter.Probe(context.Background(), mongoTestRequest().Config)
if err != nil {
t.Fatalf("probe: %v", err)
}
if capability.Ready != test.ready {
t.Fatalf("ready=%v, want %v; reason=%q", capability.Ready, test.ready, capability.Reason)
}
if !strings.Contains(capability.Reason, test.reason) {
t.Fatalf("reason %q does not contain %q", capability.Reason, test.reason)
}
if capability.PreservesSourceTransactions {
t.Fatal("adapter must not claim transaction grouping")
}
if !capability.RequiresCausalSnapshotReads || !strings.Contains(capability.SnapshotSemantics, "cannot enforce") {
t.Fatalf("snapshot consistency condition is not explicit: %+v", capability)
}
if !strings.Contains(capability.AcknowledgementSemantics, "no server-side") {
t.Fatalf("acknowledgement semantics are misleading: %q", capability.AcknowledgementSemantics)
}
})
}
}
func TestMongoRequestRejectsEmptyObjectsAndUnresolvedRoutes(t *testing.T) {
request := mongoTestRequest()
request.Objects = nil
if _, _, err := validateMongoRequest(request); err == nil {
t.Fatal("empty object selection must be rejected")
}
request = mongoTestRequest()
request.Config.UseSSH = true
if _, _, err := validateMongoRequest(request); err == nil || !strings.Contains(err.Error(), "resolved direct endpoint") {
t.Fatalf("unresolved SSH route error = %v", err)
}
request = mongoTestRequest()
request.Config.UseProxy = true
if _, _, err := validateMongoRequest(request); err == nil || !strings.Contains(err.Error(), "resolved direct endpoint") {
t.Fatalf("unresolved proxy route error = %v", err)
}
}
func TestMongoConnectionURIAndScopeNeverPersistCredentials(t *testing.T) {
config := connection.ConnectionConfig{
Type: "mongodb",
URI: "mongodb://secret-user:secret-password@mongo.example:27017/app?replicaSet=rs0",
Database: "app",
ReplicaSet: "rs0",
}
uri, err := mongoConnectionURI(config)
if err != nil {
t.Fatalf("build URI: %v", err)
}
if !strings.Contains(uri, "secret-password") {
t.Fatal("driver URI unexpectedly discarded runtime credentials")
}
safeEndpoint := mongoSafeEndpoint(config)
if strings.Contains(safeEndpoint, "secret-user") || strings.Contains(safeEndpoint, "secret-password") {
t.Fatalf("safe endpoint leaked credentials: %q", safeEndpoint)
}
request := mongoTestRequest()
request.Config = config
_, scopeHash, err := validateMongoRequest(request)
if err != nil {
t.Fatalf("validate request: %v", err)
}
if strings.Contains(scopeHash, "secret") || len(scopeHash) != 64 {
t.Fatalf("scope hash is not an opaque SHA-256 value: %q", scopeHash)
}
}
func TestMongoAuthAttemptsPreferPrimaryThenReplicaCredentials(t *testing.T) {
config := connection.ConnectionConfig{
User: "primary-user",
Password: "primary-password",
MongoReplicaUser: "cdc-user",
MongoReplicaPassword: "cdc-password",
}
attempts := mongoAuthAttempts(config)
if len(attempts) != 2 || attempts[0].User != "primary-user" || attempts[1].User != "cdc-user" {
t.Fatalf("unexpected auth attempts: %+v", attempts)
}
if attempts[1].MongoReplicaPassword != "" {
t.Fatal("replica attempt must not recursively retain alternate credentials")
}
}
func mongoTestRequest() Request {
return Request{
Config: connection.ConnectionConfig{
ID: "source-1",
Type: "mongodb-v1",
Host: "mongo.internal",
Port: 27017,
Database: "app",
ReplicaSet: "rs0",
},
Objects: []ObjectRef{
{Database: "app", Name: "orders"},
{Database: "app", Name: "customers"},
},
}
}
func mustMongoRaw(t *testing.T, value any) bson.Raw {
t.Helper()
raw, err := bson.Marshal(value)
if err != nil {
t.Fatalf("marshal BSON: %v", err)
}
return raw
}

View File

@@ -0,0 +1,452 @@
package synccdc
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
"sort"
"strconv"
"strings"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/tlsconfig"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readconcern"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)
const (
mongoDefaultPort = 27017
mongoDefaultConnectTimeout = 30 * time.Second
mongoMinimumBarrierWireVersion int32 = 8 // mongo-driver/v2 supports MongoDB 4.2+.
)
type mongoNamespace struct {
Database string
Collection string
}
type mongoTopology struct {
ReplicaSet string
Sharded bool
MaxWireVersion int32
}
type mongoWatchStart struct {
ResumeToken bson.Raw
OperationTime *bson.Timestamp
}
type mongoCursor interface {
Next(context.Context) bool
Decode(any) error
ResumeToken() bson.Raw
Err() error
Close(context.Context) error
}
type mongoConnection interface {
Inspect(context.Context) (mongoTopology, error)
SnapshotOperationTime(context.Context, mongoNamespace) (bson.Timestamp, error)
ProbeChangeStream(context.Context, string, bson.Timestamp) error
OpenChangeStream(context.Context, []mongoNamespace, mongoWatchStart) (mongoCursor, error)
Disconnect(context.Context) error
}
type mongoConnector interface {
Connect(context.Context, connection.ConnectionConfig) (mongoConnection, error)
}
type realMongoConnector struct{}
type realMongoConnection struct {
client *mongo.Client
}
type mongoHelloResponse struct {
SetName string `bson:"setName"`
Message string `bson:"msg"`
MaxWireVersion int32 `bson:"maxWireVersion"`
}
func (realMongoConnector) Connect(ctx context.Context, config connection.ConnectionConfig) (mongoConnection, error) {
var lastErr error
for _, attempt := range mongoAuthAttempts(config) {
clientOptions, err := mongoClientOptions(attempt)
if err != nil {
return nil, err
}
client, err := mongo.Connect(clientOptions)
if err != nil {
lastErr = fmt.Errorf("connect to MongoDB for CDC: %w", err)
continue
}
if err := client.Ping(ctx, readpref.Primary()); err != nil {
disconnectCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
_ = client.Disconnect(disconnectCtx)
cancel()
lastErr = fmt.Errorf("ping MongoDB for CDC: %w", err)
if ctxErr := contextError(ctx, err); ctxErr != nil {
return nil, ctxErr
}
continue
}
return &realMongoConnection{client: client}, nil
}
if lastErr == nil {
lastErr = errors.New("MongoDB CDC has no usable authentication configuration")
}
return nil, lastErr
}
func (c *realMongoConnection) Inspect(ctx context.Context) (mongoTopology, error) {
if c == nil || c.client == nil {
return mongoTopology{}, errors.New("MongoDB CDC connection is closed")
}
var result mongoHelloResponse
if err := c.client.Database("admin").RunCommand(ctx, bson.D{{Key: "hello", Value: 1}}).Decode(&result); err != nil {
var commandError mongo.CommandError
if !errors.As(err, &commandError) || commandError.Code != 59 {
return mongoTopology{}, fmt.Errorf("inspect MongoDB topology: %w", err)
}
if err := c.client.Database("admin").RunCommand(ctx, bson.D{{Key: "isMaster", Value: 1}}).Decode(&result); err != nil {
return mongoTopology{}, fmt.Errorf("inspect MongoDB topology: %w", err)
}
}
return mongoTopology{
ReplicaSet: strings.TrimSpace(result.SetName),
Sharded: strings.EqualFold(strings.TrimSpace(result.Message), "isdbgrid"),
MaxWireVersion: result.MaxWireVersion,
}, nil
}
func (c *realMongoConnection) SnapshotOperationTime(ctx context.Context, namespace mongoNamespace) (bson.Timestamp, error) {
if c == nil || c.client == nil {
return bson.Timestamp{}, errors.New("MongoDB CDC connection is closed")
}
var operationTime bson.Timestamp
err := c.client.UseSession(ctx, func(sessionContext context.Context) error {
result := c.client.Database(namespace.Database).Collection(namespace.Collection).FindOne(
sessionContext,
bson.D{},
options.FindOne().SetProjection(bson.D{{Key: "_id", Value: 1}}),
)
if err := result.Err(); err != nil && !errors.Is(err, mongo.ErrNoDocuments) {
return fmt.Errorf("establish majority snapshot barrier for %s.%s: %w", namespace.Database, namespace.Collection, err)
}
session := mongo.SessionFromContext(sessionContext)
if session == nil || session.OperationTime() == nil {
return errors.New("MongoDB did not return an operationTime for the snapshot barrier")
}
operationTime = *session.OperationTime()
return nil
})
if err != nil {
return bson.Timestamp{}, err
}
if operationTime.IsZero() {
return bson.Timestamp{}, errors.New("MongoDB returned an empty operationTime for the snapshot barrier")
}
return operationTime, nil
}
func (c *realMongoConnection) ProbeChangeStream(ctx context.Context, database string, operationTime bson.Timestamp) error {
if c == nil || c.client == nil {
return errors.New("MongoDB CDC connection is closed")
}
pipeline := mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{
{Key: "operationType", Value: "__gonavi_cdc_probe__"},
}}}}
streamOptions := options.ChangeStream().
SetFullDocument(options.UpdateLookup).
SetMaxAwaitTime(250 * time.Millisecond)
if !operationTime.IsZero() {
streamOptions.SetStartAtOperationTime(&operationTime)
}
stream, err := c.client.Database(database).Watch(ctx, pipeline, streamOptions)
if err != nil {
return err
}
return stream.Close(ctx)
}
func (c *realMongoConnection) OpenChangeStream(ctx context.Context, namespaces []mongoNamespace, start mongoWatchStart) (mongoCursor, error) {
if c == nil || c.client == nil {
return nil, errors.New("MongoDB CDC connection is closed")
}
pipeline := mongoNamespacePipeline(namespaces)
streamOptions := options.ChangeStream().
SetFullDocument(options.UpdateLookup).
SetMaxAwaitTime(time.Second)
if len(start.ResumeToken) > 0 {
streamOptions.SetResumeAfter(start.ResumeToken)
} else if start.OperationTime != nil {
streamOptions.SetStartAtOperationTime(start.OperationTime)
} else {
return nil, errors.New("MongoDB CDC change stream start position is required")
}
if len(namespaces) == 1 {
namespace := namespaces[0]
return c.client.Database(namespace.Database).Collection(namespace.Collection).Watch(ctx, pipeline, streamOptions)
}
if database, ok := singleMongoDatabase(namespaces); ok {
return c.client.Database(database).Watch(ctx, pipeline, streamOptions)
}
return c.client.Watch(ctx, pipeline, streamOptions)
}
func (c *realMongoConnection) Disconnect(ctx context.Context) error {
if c == nil || c.client == nil {
return nil
}
client := c.client
c.client = nil
return client.Disconnect(ctx)
}
func mongoNamespacePipeline(namespaces []mongoNamespace) mongo.Pipeline {
operationFilter := bson.D{{Key: "$in", Value: bson.A{"insert", "replace", "update", "delete"}}}
match := bson.D{{Key: "operationType", Value: operationFilter}}
if len(namespaces) == 1 {
match = append(match,
bson.E{Key: "ns.db", Value: namespaces[0].Database},
bson.E{Key: "ns.coll", Value: namespaces[0].Collection},
)
} else if database, ok := singleMongoDatabase(namespaces); ok {
collections := make(bson.A, 0, len(namespaces))
for _, namespace := range namespaces {
collections = append(collections, namespace.Collection)
}
match = append(match,
bson.E{Key: "ns.db", Value: database},
bson.E{Key: "ns.coll", Value: bson.D{{Key: "$in", Value: collections}}},
)
} else {
alternatives := make(bson.A, 0, len(namespaces))
for _, namespace := range namespaces {
alternatives = append(alternatives, bson.D{
{Key: "ns.db", Value: namespace.Database},
{Key: "ns.coll", Value: namespace.Collection},
})
}
match = append(match, bson.E{Key: "$or", Value: alternatives})
}
return mongo.Pipeline{bson.D{{Key: "$match", Value: match}}}
}
func singleMongoDatabase(namespaces []mongoNamespace) (string, bool) {
if len(namespaces) == 0 {
return "", false
}
database := namespaces[0].Database
for _, namespace := range namespaces[1:] {
if namespace.Database != database {
return "", false
}
}
return database, true
}
func mongoClientOptions(config connection.ConnectionConfig) (*options.ClientOptions, error) {
if err := validateMongoNetworkRoute(config); err != nil {
return nil, err
}
uri, err := mongoConnectionURI(config)
if err != nil {
return nil, err
}
clientOptions := options.Client().ApplyURI(uri).
SetReadConcern(readconcern.Majority()).
SetReadPreference(readpref.Primary())
timeout := time.Duration(config.Timeout) * time.Second
if timeout <= 0 {
timeout = mongoDefaultConnectTimeout
}
clientOptions.SetConnectTimeout(timeout).SetServerSelectionTimeout(timeout)
username := strings.TrimSpace(config.User)
password := config.Password
mechanism := strings.TrimSpace(config.MongoAuthMechanism)
if username != "" && !strings.EqualFold(mechanism, "NONE") {
authSource := strings.TrimSpace(config.AuthSource)
if authSource == "" {
authSource = "admin"
}
clientOptions.SetAuth(options.Credential{
AuthMechanism: mechanism,
AuthSource: authSource,
Username: username,
Password: password,
PasswordSet: true,
})
}
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
clientOptions.SetReplicaSet(replicaSet)
}
tlsConfig, err := mongoCDCClientTLSConfig(config)
if err != nil {
return nil, err
}
if tlsConfig != nil {
clientOptions.SetTLSConfig(tlsConfig)
}
return clientOptions, nil
}
func mongoAuthAttempts(config connection.ConnectionConfig) []connection.ConnectionConfig {
replicaUser := strings.TrimSpace(config.MongoReplicaUser)
primaryUser := strings.TrimSpace(config.User)
if replicaUser == "" || (replicaUser == primaryUser && config.MongoReplicaPassword == config.Password) {
return []connection.ConnectionConfig{config}
}
replicaAttempt := config
replicaAttempt.User = replicaUser
replicaAttempt.Password = config.MongoReplicaPassword
replicaAttempt.MongoReplicaUser = ""
replicaAttempt.MongoReplicaPassword = ""
if primaryUser == "" {
return []connection.ConnectionConfig{replicaAttempt}
}
return []connection.ConnectionConfig{config, replicaAttempt}
}
func validateMongoNetworkRoute(config connection.ConnectionConfig) error {
if config.UseSSH {
return errors.New("MongoDB CDC requires a resolved direct endpoint; unresolved SSH tunnelling is not supported")
}
if config.UseProxy {
return errors.New("MongoDB CDC requires a resolved direct endpoint; unresolved proxy routing is not supported")
}
if config.UseHTTPTunnel {
return errors.New("MongoDB CDC requires a resolved direct endpoint; unresolved HTTP tunnelling is not supported")
}
return nil
}
func mongoConnectionURI(config connection.ConnectionConfig) (string, error) {
rawURI := strings.TrimSpace(config.URI)
if rawURI != "" {
parsed, err := url.Parse(rawURI)
if err != nil {
return "", errors.New("MongoDB CDC URI is invalid")
}
if parsed.Scheme != "mongodb" && parsed.Scheme != "mongodb+srv" {
return "", fmt.Errorf("MongoDB CDC URI scheme must be mongodb or mongodb+srv, got %q", parsed.Scheme)
}
if strings.TrimSpace(parsed.Host) == "" {
return "", errors.New("MongoDB CDC URI host is required")
}
if err := mergeMongoConnectionParams(parsed.Query(), config.ConnectionParams, parsed); err != nil {
return "", err
}
return parsed.String(), nil
}
hosts := normalizedMongoHosts(config)
if len(hosts) == 0 {
return "", errors.New("MongoDB CDC host is required")
}
if config.MongoSRV && len(hosts) != 1 {
return "", errors.New("MongoDB SRV CDC configuration requires exactly one host")
}
scheme := "mongodb"
if config.MongoSRV {
scheme = "mongodb+srv"
}
database := strings.TrimSpace(config.Database)
path := "/" + database
parsed := &url.URL{Scheme: scheme, Host: strings.Join(hosts, ","), Path: path}
params := parsed.Query()
if replicaSet := strings.TrimSpace(config.ReplicaSet); replicaSet != "" {
params.Set("replicaSet", replicaSet)
}
if authSource := strings.TrimSpace(config.AuthSource); authSource != "" {
params.Set("authSource", authSource)
}
if mechanism := strings.TrimSpace(config.MongoAuthMechanism); mechanism != "" && !strings.EqualFold(mechanism, "NONE") {
params.Set("authMechanism", mechanism)
}
if err := mergeMongoConnectionParams(params, config.ConnectionParams, parsed); err != nil {
return "", err
}
return parsed.String(), nil
}
func mergeMongoConnectionParams(params url.Values, raw string, target *url.URL) error {
raw = strings.TrimPrefix(strings.TrimSpace(raw), "?")
raw = strings.ReplaceAll(raw, ";", "&")
if raw != "" {
parsed, err := url.ParseQuery(raw)
if err != nil {
return errors.New("MongoDB CDC connection parameters are invalid")
}
for key, values := range parsed {
params.Del(key)
for _, value := range values {
params.Add(key, value)
}
}
}
target.RawQuery = params.Encode()
return nil
}
func normalizedMongoHosts(config connection.ConnectionConfig) []string {
rawHosts := append([]string(nil), config.Hosts...)
if len(rawHosts) == 0 && strings.TrimSpace(config.Host) != "" {
rawHosts = []string{config.Host}
}
seen := make(map[string]struct{}, len(rawHosts))
hosts := make([]string, 0, len(rawHosts))
for _, rawHost := range rawHosts {
host := strings.TrimSpace(rawHost)
if host == "" {
continue
}
if config.MongoSRV {
host = strings.TrimSuffix(host, ".")
} else if _, _, err := net.SplitHostPort(host); err != nil {
port := config.Port
if port <= 0 {
port = mongoDefaultPort
}
host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(port))
}
if _, exists := seen[host]; exists {
continue
}
seen[host] = struct{}{}
hosts = append(hosts, host)
}
sort.Strings(hosts)
return hosts
}
func mongoCDCClientTLSConfig(config connection.ConnectionConfig) (*tls.Config, error) {
if !config.UseSSL {
return nil, nil
}
mode := strings.ToLower(strings.TrimSpace(config.SSLMode))
insecure := mode == "" || mode == "preferred" || mode == "prefer" || mode == "skip-verify" || mode == "skipverify" || mode == "insecure"
if mode == "disable" || mode == "disabled" || mode == "off" || mode == "false" || mode == "none" {
return nil, nil
}
tlsConfig, err := tlsconfig.BuildClientConfig(tlsconfig.ClientConfigOptions{
Enabled: true,
InsecureSkipVerify: insecure,
CAPath: config.SSLCAPath,
CertPath: config.SSLCertPath,
KeyPath: config.SSLKeyPath,
})
if err != nil {
return nil, fmt.Errorf("configure MongoDB CDC TLS: %w", err)
}
return tlsConfig, nil
}

View File

@@ -0,0 +1,136 @@
package synccdc
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
mongoDBAdapterName = "mongodb-change-stream"
mongoPositionVersion = 1
mongoResumeTokenFormat = "bson-base64-v1"
mongoMaxPositionBytes = 2 << 20
mongoMaxResumeTokenBytes = 1 << 20
)
type mongoOperationTime struct {
Seconds uint32 `json:"seconds"`
Increment uint32 `json:"increment"`
}
type mongoPositionPayload struct {
Version int `json:"version"`
ScopeHash string `json:"scopeHash"`
ResumeTokenBSON string `json:"resumeTokenBson,omitempty"`
ResumeFormat string `json:"resumeFormat,omitempty"`
OperationTime *mongoOperationTime `json:"operationTime,omitempty"`
}
func mongoOperationTimePosition(scopeHash string, timestamp bson.Timestamp) (Position, error) {
if timestamp.IsZero() {
return Position{}, errors.New("MongoDB CDC operation time is empty")
}
return marshalMongoPosition(mongoPositionPayload{
Version: mongoPositionVersion,
ScopeHash: strings.TrimSpace(scopeHash),
OperationTime: &mongoOperationTime{
Seconds: timestamp.T,
Increment: timestamp.I,
},
})
}
func mongoResumeTokenPosition(scopeHash string, token bson.Raw) (Position, error) {
if len(token) == 0 {
return Position{}, errors.New("MongoDB CDC resume token is empty")
}
if err := token.Validate(); err != nil {
return Position{}, fmt.Errorf("MongoDB CDC resume token is invalid BSON: %w", err)
}
return marshalMongoPosition(mongoPositionPayload{
Version: mongoPositionVersion,
ScopeHash: strings.TrimSpace(scopeHash),
ResumeTokenBSON: base64.RawURLEncoding.EncodeToString(token),
ResumeFormat: mongoResumeTokenFormat,
})
}
func marshalMongoPosition(payload mongoPositionPayload) (Position, error) {
if strings.TrimSpace(payload.ScopeHash) == "" {
return Position{}, errors.New("MongoDB CDC position scope is required")
}
opaque, err := json.Marshal(payload)
if err != nil {
return Position{}, fmt.Errorf("encode MongoDB CDC position: %w", err)
}
return Position{Adapter: mongoDBAdapterName, Opaque: opaque}, nil
}
func decodeMongoPosition(position Position) (mongoPositionPayload, bson.Raw, *bson.Timestamp, error) {
if err := ValidatePosition(position, mongoDBAdapterName); err != nil {
return mongoPositionPayload{}, nil, nil, err
}
if len(position.Opaque) > mongoMaxPositionBytes {
return mongoPositionPayload{}, nil, nil, errors.New("MongoDB CDC position exceeds the supported size")
}
decoder := json.NewDecoder(bytes.NewReader(position.Opaque))
decoder.DisallowUnknownFields()
var payload mongoPositionPayload
if err := decoder.Decode(&payload); err != nil {
return mongoPositionPayload{}, nil, nil, fmt.Errorf("decode MongoDB CDC position: %w", err)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return mongoPositionPayload{}, nil, nil, errors.New("MongoDB CDC position must contain exactly one JSON object")
}
if payload.Version != mongoPositionVersion {
return mongoPositionPayload{}, nil, nil, fmt.Errorf("unsupported MongoDB CDC position version %d", payload.Version)
}
if strings.TrimSpace(payload.ScopeHash) == "" {
return mongoPositionPayload{}, nil, nil, errors.New("MongoDB CDC position scope is required")
}
hasToken := strings.TrimSpace(payload.ResumeTokenBSON) != ""
hasOperationTime := payload.OperationTime != nil
if hasToken == hasOperationTime {
return mongoPositionPayload{}, nil, nil, errors.New("MongoDB CDC position must contain exactly one resume token or operation time")
}
if hasToken {
if payload.ResumeFormat != mongoResumeTokenFormat {
return mongoPositionPayload{}, nil, nil, fmt.Errorf("unsupported MongoDB CDC resume token format %q", payload.ResumeFormat)
}
raw, err := base64.RawURLEncoding.DecodeString(payload.ResumeTokenBSON)
if err != nil {
return mongoPositionPayload{}, nil, nil, fmt.Errorf("decode MongoDB CDC resume token: %w", err)
}
if len(raw) > mongoMaxResumeTokenBytes {
return mongoPositionPayload{}, nil, nil, errors.New("MongoDB CDC resume token exceeds the supported size")
}
token := bson.Raw(raw)
if err := token.Validate(); err != nil {
return mongoPositionPayload{}, nil, nil, fmt.Errorf("MongoDB CDC resume token is invalid BSON: %w", err)
}
return payload, token, nil, nil
}
timestamp := bson.Timestamp{T: payload.OperationTime.Seconds, I: payload.OperationTime.Increment}
if timestamp.IsZero() {
return mongoPositionPayload{}, nil, nil, errors.New("MongoDB CDC operation time is empty")
}
return payload, nil, &timestamp, nil
}
func mongoPositionIdentity(position Position) (string, error) {
payload, token, operationTime, err := decodeMongoPosition(position)
if err != nil {
return "", err
}
if len(token) > 0 {
return payload.ScopeHash + ":token:" + base64.RawURLEncoding.EncodeToString(token), nil
}
return fmt.Sprintf("%s:time:%d:%d", payload.ScopeHash, operationTime.T, operationTime.I), nil
}

View File

@@ -0,0 +1,367 @@
package synccdc
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"sync"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
ErrMongoDBStreamClosed = errors.New("MongoDB CDC stream is closed")
ErrMongoDBResnapshotRequired = errors.New("MongoDB CDC stream ended and requires a new snapshot")
)
const mongoMaxBufferedEventsPerDelivery = 256
type mongoBufferedCursor interface {
TryNext(context.Context) bool
RemainingBatchLength() int
}
type mongoChangeNamespace struct {
Database string `bson:"db"`
Collection string `bson:"coll"`
}
type mongoChangeEnvelope struct {
OperationType string `bson:"operationType"`
Namespace mongoChangeNamespace `bson:"ns"`
DocumentKey bson.Raw `bson:"documentKey"`
FullDocument bson.Raw `bson:"fullDocument"`
ClusterTime bson.Timestamp `bson:"clusterTime"`
WallTime time.Time `bson:"wallTime"`
SessionID bson.Raw `bson:"lsid"`
TxnNumber *int64 `bson:"txnNumber"`
}
type mongoDBStream struct {
connection mongoConnection
cursor mongoCursor
scopeHash string
allowed map[string]struct{}
lifetimeCtx context.Context
cancel context.CancelFunc
opMu sync.Mutex
stateMu sync.Mutex
closed bool
lastDeliveredIdentity string
lastAcknowledgedIdentity string
closeOnce sync.Once
closeErr error
}
func newMongoDBStream(connection mongoConnection, cursor mongoCursor, namespaces []mongoNamespace, scopeHash string) *mongoDBStream {
lifetimeCtx, cancel := context.WithCancel(context.Background())
allowed := make(map[string]struct{}, len(namespaces))
for _, namespace := range namespaces {
allowed[mongoNamespaceKey(namespace.Database, namespace.Collection)] = struct{}{}
}
return &mongoDBStream{
connection: connection,
cursor: cursor,
scopeHash: scopeHash,
allowed: allowed,
lifetimeCtx: lifetimeCtx,
cancel: cancel,
}
}
func (s *mongoDBStream) Next(ctx context.Context) (Transaction, error) {
if s == nil {
return Transaction{}, ErrMongoDBStreamClosed
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return Transaction{}, err
}
s.stateMu.Lock()
closed := s.closed
s.stateMu.Unlock()
if closed {
return Transaction{}, ErrMongoDBStreamClosed
}
nextCtx, cancel := context.WithCancel(ctx)
stop := context.AfterFunc(s.lifetimeCtx, cancel)
defer func() {
stop()
cancel()
}()
s.opMu.Lock()
defer s.opMu.Unlock()
s.stateMu.Lock()
closed = s.closed
s.stateMu.Unlock()
if closed {
return Transaction{}, ErrMongoDBStreamClosed
}
if !s.cursor.Next(nextCtx) {
cursorErr := s.cursor.Err()
if err := ctx.Err(); err != nil {
return Transaction{}, err
}
if s.lifetimeCtx.Err() != nil {
return Transaction{}, ErrMongoDBStreamClosed
}
if cursorErr != nil {
return Transaction{}, fmt.Errorf("read MongoDB change stream: %w", cursorErr)
}
return Transaction{}, ErrMongoDBResnapshotRequired
}
event, position, identity, err := s.decodeCurrentEvent()
if err != nil {
return Transaction{}, err
}
events := []Event{event}
if bufferedCursor, ok := s.cursor.(mongoBufferedCursor); ok {
for len(events) < mongoMaxBufferedEventsPerDelivery && bufferedCursor.RemainingBatchLength() > 0 && bufferedCursor.TryNext(nextCtx) {
bufferedEvent, bufferedPosition, bufferedIdentity, decodeErr := s.decodeCurrentEvent()
if decodeErr != nil {
return Transaction{}, decodeErr
}
events = append(events, bufferedEvent)
position = bufferedPosition
identity = bufferedIdentity
}
if err := ctx.Err(); err != nil {
return Transaction{}, err
}
if s.lifetimeCtx.Err() != nil {
return Transaction{}, ErrMongoDBStreamClosed
}
if cursorErr := s.cursor.Err(); cursorErr != nil {
return Transaction{}, fmt.Errorf("read MongoDB buffered change stream events: %w", cursorErr)
}
}
s.stateMu.Lock()
s.lastDeliveredIdentity = identity
s.stateMu.Unlock()
return Transaction{Events: events, Position: position}, nil
}
func (s *mongoDBStream) decodeCurrentEvent() (Event, Position, string, error) {
var envelope mongoChangeEnvelope
if err := s.cursor.Decode(&envelope); err != nil {
return Event{}, Position{}, "", fmt.Errorf("decode MongoDB change stream event: %w", err)
}
event, err := mapMongoChangeEvent(envelope, s.allowed)
if err != nil {
return Event{}, Position{}, "", err
}
position, err := mongoResumeTokenPosition(s.scopeHash, append(bson.Raw(nil), s.cursor.ResumeToken()...))
if err != nil {
return Event{}, Position{}, "", err
}
identity, err := mongoPositionIdentity(position)
if err != nil {
return Event{}, Position{}, "", err
}
return event, position, identity, nil
}
func (s *mongoDBStream) Acknowledge(ctx context.Context, position Position) error {
if s == nil {
return ErrMongoDBStreamClosed
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
payload, _, _, err := decodeMongoPosition(position)
if err != nil {
return err
}
if payload.ScopeHash != s.scopeHash {
return errors.New("MongoDB CDC acknowledgement belongs to a different source or namespace selection")
}
identity, err := mongoPositionIdentity(position)
if err != nil {
return err
}
s.stateMu.Lock()
defer s.stateMu.Unlock()
if s.closed {
return ErrMongoDBStreamClosed
}
if identity == s.lastAcknowledgedIdentity && identity == s.lastDeliveredIdentity && identity != "" {
return nil
}
if identity == "" || identity != s.lastDeliveredIdentity {
return errors.New("MongoDB CDC can only acknowledge the most recently delivered position")
}
// MongoDB exposes no server acknowledgement for change streams. Recording
// this identity only validates the caller's durable local checkpoint.
s.lastAcknowledgedIdentity = identity
return nil
}
func (s *mongoDBStream) Close() error {
if s == nil {
return nil
}
s.closeOnce.Do(func() {
s.stateMu.Lock()
s.closed = true
s.stateMu.Unlock()
s.cancel()
s.opMu.Lock()
defer s.opMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var cursorErr error
if s.cursor != nil {
cursorErr = s.cursor.Close(ctx)
}
var disconnectErr error
if s.connection != nil {
disconnectErr = s.connection.Disconnect(ctx)
}
s.closeErr = errors.Join(cursorErr, disconnectErr)
})
return s.closeErr
}
func mapMongoChangeEvent(envelope mongoChangeEnvelope, allowed map[string]struct{}) (Event, error) {
if _, ok := allowed[mongoNamespaceKey(envelope.Namespace.Database, envelope.Namespace.Collection)]; !ok {
return Event{}, fmt.Errorf("MongoDB change stream returned out-of-scope namespace %s.%s", envelope.Namespace.Database, envelope.Namespace.Collection)
}
operation := envelope.OperationType
switch operation {
case "insert", "replace", "update", "delete":
default:
return Event{}, fmt.Errorf("unsupported MongoDB change-stream operation %q", operation)
}
key, err := mongoRawDocumentToJSONMap(envelope.DocumentKey)
if err != nil {
return Event{}, fmt.Errorf("decode MongoDB change-stream document key: %w", err)
}
if len(key) == 0 {
return Event{}, fmt.Errorf("MongoDB %s event has no document key", operation)
}
var after map[string]interface{}
if operation != "delete" {
after, err = mongoRawDocumentToJSONMap(envelope.FullDocument)
if err != nil {
return Event{}, fmt.Errorf("decode MongoDB %s fullDocument: %w", operation, err)
}
if len(after) == 0 && operation != "update" {
return Event{}, fmt.Errorf("MongoDB %s event has no fullDocument; updateLookup could not produce an apply-safe row", operation)
}
}
commitTime := envelope.WallTime.UTC()
if !envelope.ClusterTime.IsZero() {
commitTime = time.Unix(int64(envelope.ClusterTime.T), 0).UTC()
}
if commitTime.IsZero() {
return Event{}, errors.New("MongoDB change-stream event has no commit time")
}
return Event{
Object: ObjectRef{
Database: envelope.Namespace.Database,
Name: envelope.Namespace.Collection,
},
Operation: operation,
Key: key,
After: after,
CommitTime: commitTime,
SourceTxID: mongoSourceTransactionID(envelope.SessionID, envelope.TxnNumber),
}, nil
}
func mongoRawDocumentToJSONMap(raw bson.Raw) (map[string]interface{}, error) {
if len(raw) == 0 {
return nil, nil
}
if err := raw.Validate(); err != nil {
return nil, err
}
var document bson.M
if err := bson.Unmarshal(raw, &document); err != nil {
return nil, err
}
converted, ok := mongoBSONValueToJSON(document).(map[string]interface{})
if !ok {
return nil, errors.New("MongoDB BSON document did not decode to an object")
}
return converted, nil
}
func mongoBSONValueToJSON(value interface{}) interface{} {
switch typed := value.(type) {
case bson.M:
result := make(map[string]interface{}, len(typed))
for key, item := range typed {
result[key] = mongoBSONValueToJSON(item)
}
return result
case bson.D:
result := make(map[string]interface{}, len(typed))
for _, item := range typed {
result[item.Key] = mongoBSONValueToJSON(item.Value)
}
return result
case bson.A:
result := make([]interface{}, len(typed))
for index, item := range typed {
result[index] = mongoBSONValueToJSON(item)
}
return result
case []interface{}:
result := make([]interface{}, len(typed))
for index, item := range typed {
result[index] = mongoBSONValueToJSON(item)
}
return result
case bson.ObjectID, bson.DateTime, bson.Decimal128, bson.Binary, bson.Regex,
bson.Timestamp, bson.MaxKey, bson.MinKey, bson.Undefined, int32, int64, []byte, time.Time:
if converted, ok := mongoExtendedJSONValue(typed); ok {
return converted
}
return typed
default:
return value
}
}
func mongoExtendedJSONValue(value interface{}) (interface{}, bool) {
payload, err := bson.MarshalExtJSON(bson.M{"v": value}, true, false)
if err != nil {
return nil, false
}
var wrapped map[string]interface{}
if err := json.Unmarshal(payload, &wrapped); err != nil {
return nil, false
}
converted, ok := wrapped["v"]
return converted, ok
}
func mongoSourceTransactionID(sessionID bson.Raw, transactionNumber *int64) string {
if len(sessionID) == 0 || transactionNumber == nil {
return ""
}
sum := sha256.Sum256(append(append([]byte(nil), sessionID...), []byte(strconv.FormatInt(*transactionNumber, 10))...))
return "mongo-tx-" + hex.EncodeToString(sum[:12])
}
func mongoNamespaceKey(database, collection string) string {
return database + "\x00" + collection
}
var _ Stream = (*mongoDBStream)(nil)

View File

@@ -0,0 +1,276 @@
package synccdc
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type bufferedFakeMongoCursor struct {
*fakeMongoCursor
}
func (cursor *bufferedFakeMongoCursor) TryNext(ctx context.Context) bool {
return cursor.fakeMongoCursor.Next(ctx)
}
func (cursor *bufferedFakeMongoCursor) RemainingBatchLength() int {
cursor.mu.Lock()
defer cursor.mu.Unlock()
return len(cursor.events) - cursor.index
}
func TestMongoStreamMapsInsertReplaceUpdateAndDelete(t *testing.T) {
objectID := bson.NewObjectID()
sessionID := mustMongoRaw(t, bson.D{{Key: "id", Value: bson.Binary{Subtype: 4, Data: []byte("0123456789abcdef")}}})
txnNumber := int64(9)
operations := []string{"insert", "replace", "update", "delete"}
events := make([]bson.Raw, 0, len(operations))
tokens := make([]bson.Raw, 0, len(operations))
for index, operation := range operations {
document := bson.D{
{Key: "_id", Value: bson.D{{Key: "_data", Value: operation}}},
{Key: "operationType", Value: operation},
{Key: "ns", Value: bson.D{{Key: "db", Value: "app"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: objectID}}},
{Key: "clusterTime", Value: bson.Timestamp{T: uint32(1_725_000_000 + index), I: 1}},
{Key: "lsid", Value: sessionID},
{Key: "txnNumber", Value: txnNumber},
}
if operation != "delete" {
document = append(document, bson.E{Key: "fullDocument", Value: bson.D{
{Key: "_id", Value: objectID},
{Key: "amount", Value: int64(9_007_199_254_740_993)},
{Key: "ratio", Value: 1.25},
}})
}
events = append(events, mustMongoRaw(t, document))
tokens = append(tokens, mustMongoRaw(t, bson.D{{Key: "_data", Value: "token-" + operation}}))
}
cursor := &fakeMongoCursor{events: events, tokens: tokens}
connection := &fakeMongoConnection{cursor: cursor}
stream := newMongoDBStream(connection, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
var previousPosition Position
for _, expectedOperation := range operations {
transaction, err := stream.Next(context.Background())
if err != nil {
t.Fatalf("next %s: %v", expectedOperation, err)
}
if len(transaction.Events) != 1 || transaction.Events[0].Operation != expectedOperation {
t.Fatalf("unexpected transaction: %+v", transaction)
}
event := transaction.Events[0]
if event.Object.Database != "app" || event.Object.Name != "orders" {
t.Fatalf("unexpected namespace: %+v", event.Object)
}
if event.Key["_id"] == nil {
t.Fatalf("document key was not preserved: %+v", event.Key)
}
objectIDJSON, ok := event.Key["_id"].(map[string]interface{})
if !ok || objectIDJSON["$oid"] != objectID.Hex() {
t.Fatalf("ObjectID was not preserved as canonical Extended JSON: %+v", event.Key["_id"])
}
if expectedOperation == "delete" {
if event.After != nil {
t.Fatalf("delete after image = %+v", event.After)
}
} else {
amountJSON, ok := event.After["amount"].(map[string]interface{})
if !ok || amountJSON["$numberLong"] != "9007199254740993" {
t.Fatalf("64-bit integer was not preserved as canonical Extended JSON: %+v", event.After)
}
if event.After["ratio"] != float64(1.25) {
t.Fatalf("ordinary double must match snapshot conversion semantics: %+v", event.After)
}
}
if !strings.HasPrefix(event.SourceTxID, "mongo-tx-") {
t.Fatalf("source transaction identity missing: %q", event.SourceTxID)
}
if previousPosition.Adapter != "" {
if err := stream.Acknowledge(context.Background(), previousPosition); err == nil {
t.Fatalf("stale acknowledgement before %s must be rejected", expectedOperation)
}
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("acknowledge %s: %v", expectedOperation, err)
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("idempotent acknowledge %s: %v", expectedOperation, err)
}
previousPosition = transaction.Position
}
}
func TestMongoStreamDrainsBufferedEventsAndAcknowledgesFinalPosition(t *testing.T) {
events := make([]bson.Raw, 0, 3)
tokens := make([]bson.Raw, 0, 3)
for index := 1; index <= 3; index++ {
events = append(events, mustMongoRaw(t, bson.D{
{Key: "operationType", Value: "insert"},
{Key: "ns", Value: bson.D{{Key: "db", Value: "app"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: index}}},
{Key: "fullDocument", Value: bson.D{{Key: "_id", Value: index}, {Key: "value", Value: index}}},
{Key: "clusterTime", Value: bson.Timestamp{T: uint32(100 + index), I: 1}},
}))
tokens = append(tokens, mustMongoRaw(t, bson.D{{Key: "_data", Value: fmt.Sprintf("token-%d", index)}}))
}
cursor := &bufferedFakeMongoCursor{fakeMongoCursor: &fakeMongoCursor{events: events, tokens: tokens}}
stream := newMongoDBStream(&fakeMongoConnection{cursor: cursor}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
transaction, err := stream.Next(context.Background())
if err != nil {
t.Fatalf("next buffered transaction: %v", err)
}
if len(transaction.Events) != 3 {
t.Fatalf("buffered events = %d, want 3", len(transaction.Events))
}
for index, event := range transaction.Events {
encodedID, ok := event.Key["_id"].(map[string]interface{})
if !ok || encodedID["$numberInt"] != fmt.Sprint(index+1) {
t.Fatalf("event %d key = %#v", index, event.Key)
}
}
intermediate, err := mongoResumeTokenPosition("scope-1", tokens[0])
if err != nil {
t.Fatalf("build intermediate position: %v", err)
}
if err := stream.Acknowledge(context.Background(), intermediate); err == nil {
t.Fatal("buffered delivery accepted a non-final position")
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("acknowledge final buffered position: %v", err)
}
expectedPosition, err := mongoResumeTokenPosition("scope-1", tokens[2])
if err != nil {
t.Fatalf("build final position: %v", err)
}
expectedIdentity, _ := mongoPositionIdentity(expectedPosition)
actualIdentity, _ := mongoPositionIdentity(transaction.Position)
if actualIdentity != expectedIdentity {
t.Fatalf("transaction position = %s, want final %s", actualIdentity, expectedIdentity)
}
}
func TestMongoStreamRejectsOutOfScopeEvent(t *testing.T) {
tests := []struct {
name string
raw bson.Raw
error string
}{
{
name: "out of scope",
raw: mustMongoRaw(t, bson.D{
{Key: "operationType", Value: "delete"},
{Key: "ns", Value: bson.D{{Key: "db", Value: "other"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: 1}}},
{Key: "clusterTime", Value: bson.Timestamp{T: 100, I: 1}},
}),
error: "out-of-scope",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cursor := &fakeMongoCursor{
events: []bson.Raw{test.raw},
tokens: []bson.Raw{mustMongoRaw(t, bson.D{{Key: "_data", Value: "token"}})},
}
stream := newMongoDBStream(&fakeMongoConnection{}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
if _, err := stream.Next(context.Background()); err == nil || !strings.Contains(err.Error(), test.error) {
t.Fatalf("error = %v, want substring %q", err, test.error)
}
})
}
}
func TestMongoStreamDeliversUpdateLookupNullWithCheckpoint(t *testing.T) {
cursor := &fakeMongoCursor{
events: []bson.Raw{mustMongoRaw(t, bson.D{
{Key: "operationType", Value: "update"},
{Key: "ns", Value: bson.D{{Key: "db", Value: "app"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: 1}}},
{Key: "clusterTime", Value: bson.Timestamp{T: 100, I: 1}},
})},
tokens: []bson.Raw{mustMongoRaw(t, bson.D{{Key: "_data", Value: "update-without-full-document"}})},
}
stream := newMongoDBStream(&fakeMongoConnection{}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
transaction, err := stream.Next(context.Background())
if err != nil {
t.Fatalf("deliver updateLookup-null event: %v", err)
}
if len(transaction.Events) != 1 || transaction.Events[0].Operation != "update" || transaction.Events[0].After != nil {
t.Fatalf("unexpected tombstone event: %+v", transaction)
}
if transaction.Position.Adapter == "" {
t.Fatal("updateLookup-null event must carry a resumable checkpoint")
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("acknowledge updateLookup-null checkpoint: %v", err)
}
}
func TestMongoStreamNextIsCancelableAndCloseIsIdempotent(t *testing.T) {
cursor := &fakeMongoCursor{block: true, nextStarted: make(chan struct{}, 1)}
connection := &fakeMongoConnection{}
stream := newMongoDBStream(connection, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
result := make(chan error, 1)
go func() {
_, err := stream.Next(context.Background())
result <- err
}()
select {
case <-cursor.nextStarted:
case <-time.After(time.Second):
t.Fatal("Next did not reach the change-stream cursor")
}
if err := stream.Close(); err != nil {
t.Fatalf("close stream: %v", err)
}
select {
case err := <-result:
if !errors.Is(err, ErrMongoDBStreamClosed) {
t.Fatalf("Next error after Close = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close did not cancel blocked Next")
}
if err := stream.Close(); err != nil {
t.Fatalf("second close: %v", err)
}
cursor.mu.Lock()
closeCount := cursor.closed
cursor.mu.Unlock()
connection.mu.Lock()
disconnectCount := connection.disconnects
connection.mu.Unlock()
if closeCount != 1 || disconnectCount != 1 {
t.Fatalf("close count=%d disconnect count=%d", closeCount, disconnectCount)
}
}
func TestMongoStreamCallerCancellationAndAcknowledgementValidation(t *testing.T) {
cursor := &fakeMongoCursor{block: true}
stream := newMongoDBStream(&fakeMongoConnection{}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := stream.Next(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled Next error = %v", err)
}
foreign, err := mongoOperationTimePosition("other-scope", bson.Timestamp{T: 1, I: 1})
if err != nil {
t.Fatalf("build foreign position: %v", err)
}
if err := stream.Acknowledge(context.Background(), foreign); err == nil || !strings.Contains(err.Error(), "different source") {
t.Fatalf("foreign acknowledgement error = %v", err)
}
}

View File

@@ -0,0 +1,119 @@
package synccdc
import (
"fmt"
"sort"
"strings"
"sync"
)
type Registry struct {
mu sync.RWMutex
byName map[string]Adapter
bySource map[string]string
}
func NewRegistry() *Registry {
registry := &Registry{
byName: make(map[string]Adapter),
bySource: make(map[string]string),
}
if err := registry.Register(NewMongoDBAdapter()); err != nil {
panic(fmt.Sprintf("register built-in MongoDB CDC adapter: %v", err))
}
return registry
}
func (r *Registry) Register(adapter Adapter) error {
if r == nil {
return fmt.Errorf("CDC registry is nil")
}
if adapter == nil {
return fmt.Errorf("CDC adapter is nil")
}
name := normalize(adapter.Name())
if name == "" {
return fmt.Errorf("CDC adapter name is required")
}
sources := adapter.SourceTypes()
if len(sources) == 0 {
return fmt.Errorf("CDC adapter %s has no source types", name)
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.byName[name]; exists {
return fmt.Errorf("CDC adapter %s is already registered", name)
}
for _, source := range sources {
normalizedSource := normalizeSourceType(source)
if normalizedSource == "" {
return fmt.Errorf("CDC adapter %s has an empty source type", name)
}
if existing, exists := r.bySource[normalizedSource]; exists {
return fmt.Errorf("CDC source type %s is already handled by %s", normalizedSource, existing)
}
}
r.byName[name] = adapter
for _, source := range sources {
r.bySource[normalizeSourceType(source)] = name
}
return nil
}
func (r *Registry) Get(name string) (Adapter, error) {
if r == nil {
return nil, ErrAdapterNotRegistered
}
r.mu.RLock()
adapter := r.byName[normalize(name)]
r.mu.RUnlock()
if adapter == nil {
return nil, fmt.Errorf("%w: %s", ErrAdapterNotRegistered, strings.TrimSpace(name))
}
return adapter, nil
}
func (r *Registry) ResolveSource(sourceType string) (Adapter, error) {
if r == nil {
return nil, ErrAdapterNotRegistered
}
r.mu.RLock()
name := r.bySource[normalizeSourceType(sourceType)]
adapter := r.byName[name]
r.mu.RUnlock()
if adapter == nil {
return nil, fmt.Errorf("%w: source type %s", ErrAdapterNotRegistered, strings.TrimSpace(sourceType))
}
return adapter, nil
}
func (r *Registry) Names() []string {
if r == nil {
return []string{}
}
r.mu.RLock()
names := make([]string, 0, len(r.byName))
for name := range r.byName {
names = append(names, name)
}
r.mu.RUnlock()
sort.Strings(names)
return names
}
func normalize(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeSourceType(value string) string {
switch normalize(value) {
case "postgresql", "kingbase", "highgo", "vastbase", "opengauss", "gaussdb":
return "postgres"
case "mariadb", "oceanbase", "goldendb":
return "mysql"
case "mongo", "mongodb-v1", "mongodbv1":
return "mongodb"
default:
return normalize(value)
}
}

View File

@@ -0,0 +1,72 @@
package synccdc
import (
"context"
"encoding/json"
"errors"
"testing"
"GoNavi-Wails/internal/connection"
)
type testAdapter struct {
name string
sources []string
}
func (a testAdapter) Name() string { return a.name }
func (a testAdapter) SourceTypes() []string { return a.sources }
func (a testAdapter) Probe(context.Context, connection.ConnectionConfig) (Capability, error) {
return Capability{Adapter: a.name, Supported: true, Ready: true}, nil
}
func (a testAdapter) BeginSnapshot(context.Context, Request) (Barrier, error) {
return Barrier{}, nil
}
func (a testAdapter) Open(context.Context, Request, Position) (Stream, error) { return nil, nil }
func TestRegistryNormalizesSourceFamiliesAndRejectsDuplicates(t *testing.T) {
registry := NewRegistry()
if err := registry.Register(testAdapter{name: "postgres-logical", sources: []string{"postgres"}}); err != nil {
t.Fatalf("register adapter: %v", err)
}
adapter, err := registry.ResolveSource("openGauss")
if err != nil {
t.Fatalf("resolve postgres family: %v", err)
}
if adapter.Name() != "postgres-logical" {
t.Fatalf("resolved adapter = %q", adapter.Name())
}
if err := registry.Register(testAdapter{name: "other", sources: []string{"postgresql"}}); err == nil {
t.Fatal("duplicate normalized source type must be rejected")
}
if _, err := registry.ResolveSource("oracle"); !errors.Is(err, ErrAdapterNotRegistered) {
t.Fatalf("missing adapter error = %v", err)
}
}
func TestRegistryIncludesBuiltInMongoDBChangeStreamAdapter(t *testing.T) {
registry := NewRegistry()
for _, sourceType := range []string{"mongodb", "mongodb-v1", "mongo"} {
adapter, err := registry.ResolveSource(sourceType)
if err != nil {
t.Fatalf("resolve %s: %v", sourceType, err)
}
if adapter.Name() != mongoDBAdapterName {
t.Fatalf("resolve %s = %q", sourceType, adapter.Name())
}
}
}
func TestValidatePositionBindsOpaqueOffsetToAdapter(t *testing.T) {
valid := Position{Adapter: "mysql-binlog", Opaque: json.RawMessage(`{"file":"mysql.000001","position":4}`)}
if err := ValidatePosition(valid, "mysql-binlog"); err != nil {
t.Fatalf("valid position rejected: %v", err)
}
if err := ValidatePosition(valid, "postgres-logical"); err == nil {
t.Fatal("cross-adapter position must be rejected")
}
valid.Opaque = json.RawMessage(`not-json`)
if err := ValidatePosition(valid, "mysql-binlog"); err == nil {
t.Fatal("invalid opaque position must be rejected")
}
}