Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ jobs:
- name: Run schema-diff tests
run: go test -count=1 -v ./tests/integration -run 'TestSchemaDiff_'

- name: Run spock-diff comparison unit tests
run: go test -count=1 -v ./internal/consistency/diff -run 'TestCompareSubscriptions'

- name: Run spock-diff tests
run: go test -count=1 -v ./tests/integration -run 'TestSpockDiff_|TestGetSpockNodeAndSubInfo'

- name: Run Merkle tree numeric scale invariance test
run: go test -count=1 -v ./tests/integration -run 'TestMerkleTreeNumericScaleInvariance'

Expand Down
1 change: 1 addition & 0 deletions db/queries/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,7 @@ func GetSpockNodeAndSubInfo(ctx context.Context, db DBQuerier) ([]types.SpockNod
&info.SubName,
&info.SubEnabled,
&info.SubReplicationSets,
&info.SubOriginName,
); err != nil {
return nil, err
}
Expand Down
10 changes: 7 additions & 3 deletions db/queries/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,17 +601,19 @@ var SQLTemplates = Templates{
`)),
SpockNodeAndSubInfo: template.Must(template.New("spockNodeAndSubInfo").Parse(`
SELECT
n.node_id,
n.node_id::bigint,
n.node_name,
n.location,
n.country,
s.sub_id,
s.sub_id::bigint,
s.sub_name,
s.sub_enabled,
s.sub_replication_sets
s.sub_replication_sets,
COALESCE(o.node_name, '') AS sub_origin_name
FROM
spock.node n
LEFT OUTER JOIN spock.subscription s ON s.sub_target = n.node_id
LEFT OUTER JOIN spock.node o ON o.node_id = s.sub_origin
WHERE
s.sub_name IS NOT NULL;
`)),
Expand All @@ -626,6 +628,8 @@ var SQLTemplates = Templates{
relname
FROM
spock.tables
WHERE
set_name IS NOT NULL
ORDER BY
set_name, nspname, relname
) subquery
Expand Down
82 changes: 46 additions & 36 deletions internal/consistency/diff/spock_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,15 @@ func (t *SpockDiffTask) ExecuteTask() (err error) {
sub := types.SpockSubscription{}
if ni.SubName != "" {
sub.SubName = ni.SubName
sub.ProviderNode = ni.SubOriginName
sub.SubEnabled = ni.SubEnabled
sub.ReplicationSets = ni.SubReplicationSets
if ni.SubOriginName == "" {
hint := fmt.Sprintf("Subscription '%s' has an unresolved origin node; its reciprocal peer cannot be determined and it may be reported below as a missing subscription.", sub.SubName)
if !utils.Contains(config.Hints, hint) {
config.Hints = append(config.Hints, hint)
}
}
Comment on lines +350 to +355

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When ni.SubOriginName == "" the code emits a hint saying the subscription is "excluded from comparison" and sets sub.ProviderNode = "". However, config.Subscriptions = append(config.Subscriptions, sub) still runs unconditionally (it's outside the if ni.SubName != "" block). subscriptionsByProvider correctly skips entries with empty ProviderNode, so this node's lookup for the peer returns false, and MissingOnNode1 gets the peer name — a false-positive mismatch. The hint and the diff report are inconsistent: the user is told the subscription is excluded, then immediately shown a mismatch caused by its exclusion. Fix: add continue (skip the append) when SubOriginName == "".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SubscriptionsByProvider already skips empty-ProviderNode entries, so
MissingOnNode1 still gets the peer name regardless of the append; it'd only drop the subscription from
the printed config (and if it's the node's only sub, the config would then print "No subscriptions
found" while the hint names it).

With an unresolved origin we can't tell which peer the subscription belongs to, so the "missing" entry
is unavoidable. I reworded the hint to match the diff instead:

▎ Subscription '' has an unresolved origin node; its reciprocal peer cannot be determined and it
▎ may be reported below as a missing subscription.

if len(ni.SubReplicationSets) == 0 {
hint := fmt.Sprintf("Subscription '%s' has no replication sets.", sub.SubName)
if !utils.Contains(config.Hints, hint) {
Expand Down Expand Up @@ -508,50 +515,53 @@ func compareSubscriptions(c1, c2 SpockNodeConfig) types.SubscriptionDiff {
n1Name := c1.NodeName
n2Name := c2.NodeName

subsOnN1 := make(map[string]types.SpockSubscription)
for _, s := range c1.Subscriptions {
if s.SubName != "" {
subsOnN1[s.SubName] = s
}
}
subsOnN2 := make(map[string]types.SpockSubscription)
for _, s := range c2.Subscriptions {
if s.SubName != "" {
subsOnN2[s.SubName] = s
}
}

// Check for reciprocal subscriptions between n1 and n2
subN1toN2_name := fmt.Sprintf("sub_%s%s", n1Name, n2Name) // Subscription on n2, from n1
subN2toN1_name := fmt.Sprintf("sub_%s%s", n2Name, n1Name) // Subscription on n1, from n2
// A healthy pair requires n1 to subscribe from n2 and n2 from n1. Match on the
// provider node identity, not the subscription name (which users may override).
subsFromOnN1 := subscriptionsByProvider(c1.Subscriptions)
subsFromOnN2 := subscriptionsByProvider(c2.Subscriptions)

s1, s1_exists := subsOnN2[subN1toN2_name]
s2, s2_exists := subsOnN1[subN2toN1_name]
s1, n1SubsFromN2 := subsFromOnN1[n2Name] // subscription on n1 receiving from n2
s2, n2SubsFromN1 := subsFromOnN2[n1Name] // subscription on n2 receiving from n1

if !s1_exists {
diff.MissingOnNode2 = append(diff.MissingOnNode2, subN1toN2_name)
if !n1SubsFromN2 {
diff.MissingOnNode1 = append(diff.MissingOnNode1, n2Name)
}
if !s2_exists {
diff.MissingOnNode1 = append(diff.MissingOnNode1, subN2toN1_name)
if !n2SubsFromN1 {
diff.MissingOnNode2 = append(diff.MissingOnNode2, n1Name)
}

if s1_exists && s2_exists {
sort.Strings(s1.ReplicationSets)
sort.Strings(s2.ReplicationSets)
if n1SubsFromN2 && n2SubsFromN1 {
// Compare order-insensitively without mutating the originals: these slices
// are shared with the SpockConfigs JSON output, which keeps DB order.
sets1 := append([]string(nil), s1.ReplicationSets...)
sets2 := append([]string(nil), s2.ReplicationSets...)
sort.Strings(sets1)
sort.Strings(sets2)

// Compare properties, ignoring the name which is expected to be different.
if s1.SubEnabled != s2.SubEnabled || !reflect.DeepEqual(s1.ReplicationSets, s2.ReplicationSets) {
// Both directions exist; their properties should match (names aside).
if s1.SubEnabled != s2.SubEnabled || !reflect.DeepEqual(sets1, sets2) {
diff.Different = append(diff.Different, types.SubscriptionPair{
Name: fmt.Sprintf("reciprocal subscriptions for %s and %s", n1Name, n2Name),
Node1: s2, // This is sub on n1
Node2: s1, // This is sub on n2
Name: fmt.Sprintf("reciprocal subscriptions between %s and %s", n1Name, n2Name),
Node1: s1, // subscription on n1 (from n2)
Node2: s2, // subscription on n2 (from n1)
})
}
}

return diff
}

// subscriptionsByProvider indexes subscriptions by the node they replicate from.
func subscriptionsByProvider(subs []types.SpockSubscription) map[string]types.SpockSubscription {
byProvider := make(map[string]types.SpockSubscription, len(subs))
for _, s := range subs {
if s.ProviderNode != "" {
byProvider[s.ProviderNode] = s
}
}
return byProvider
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func compareReplicationSets(c1, c2 SpockNodeConfig) types.ReplicationSetDiff {
diff := types.ReplicationSetDiff{}

Expand Down Expand Up @@ -605,17 +615,17 @@ func compareReplicationSets(c1, c2 SpockNodeConfig) types.ReplicationSetDiff {

func printDiffDetails(details types.SpockDiffDetail, node1, node2 string) {
if len(details.Subscriptions.MissingOnNode1) > 0 {
fmt.Printf(" Missing reciprocal subscriptions on %s: %v\n", node1, details.Subscriptions.MissingOnNode1)
fmt.Printf(" %s is missing a subscription receiving from: %v\n", node1, details.Subscriptions.MissingOnNode1)
}
if len(details.Subscriptions.MissingOnNode2) > 0 {
fmt.Printf(" Missing reciprocal subscriptions on %s: %v\n", node2, details.Subscriptions.MissingOnNode2)
fmt.Printf(" %s is missing a subscription receiving from: %v\n", node2, details.Subscriptions.MissingOnNode2)
}
if len(details.Subscriptions.Different) > 0 {
fmt.Println(" Subscriptions with different properties:")
fmt.Println(" Reciprocal subscriptions with different properties:")
for _, d := range details.Subscriptions.Different {
fmt.Printf(" - Mismatch in settings for subscriptions between %s and %s:\n", node1, node2)
fmt.Printf(" - On %s (subscription '%s'): Enabled: %t, Repsets: %v\n", node1, d.Node1.SubName, d.Node1.SubEnabled, d.Node1.ReplicationSets)
fmt.Printf(" - On %s (subscription '%s'): Enabled: %t, Repsets: %v\n", node2, d.Node2.SubName, d.Node2.SubEnabled, d.Node2.ReplicationSets)
fmt.Printf(" - Mismatch in settings for reciprocal subscriptions between %s and %s:\n", node1, node2)
fmt.Printf(" - On %s (subscription '%s', from %s): Enabled: %t, Repsets: %v\n", node1, d.Node1.SubName, d.Node1.ProviderNode, d.Node1.SubEnabled, d.Node1.ReplicationSets)
fmt.Printf(" - On %s (subscription '%s', from %s): Enabled: %t, Repsets: %v\n", node2, d.Node2.SubName, d.Node2.ProviderNode, d.Node2.SubEnabled, d.Node2.ReplicationSets)
}
}

Expand Down
116 changes: 116 additions & 0 deletions internal/consistency/diff/spock_diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// ///////////////////////////////////////////////////////////////////////////
//
// # ACE - Active Consistency Engine
//
// Copyright (C) 2023 - 2026, pgEdge (https://www.pgedge.com/)
//
// This software is released under the PostgreSQL License:
// https://opensource.org/license/postgresql
//
// ///////////////////////////////////////////////////////////////////////////

package diff

import (
"testing"

"github.com/pgedge/ace/pkg/types"
"github.com/stretchr/testify/assert"
)

// spockCfg is a small helper to build a node config for comparison tests.
func spockCfg(nodeName string, subs ...types.SpockSubscription) SpockNodeConfig {
return SpockNodeConfig{NodeName: nodeName, Subscriptions: subs}
}

// Healthy reciprocal pair with matching repsets. Names are deliberately
// arbitrary: matching must rely on the provider node, not a name convention.
func TestCompareSubscriptions_HealthyReciprocalIgnoresNames(t *testing.T) {
n1 := spockCfg("n1", types.SpockSubscription{
SubName: "custom_name_a", ProviderNode: "n2", SubEnabled: true,
ReplicationSets: []string{"default", "default_insert_only"},
})
n2 := spockCfg("n2", types.SpockSubscription{
SubName: "totally_different", ProviderNode: "n1", SubEnabled: true,
ReplicationSets: []string{"default_insert_only", "default"},
})

d := compareSubscriptions(n1, n2)

assert.Empty(t, d.MissingOnNode1, "n1 subscribes from n2, nothing missing")
assert.Empty(t, d.MissingOnNode2, "n2 subscribes from n1, nothing missing")
assert.Empty(t, d.Different, "replication sets match (order-insensitive)")
}

// n1 doesn't subscribe from n2 (one direction missing); n2 does subscribe from n1.
func TestCompareSubscriptions_MissingOneDirection(t *testing.T) {
n1 := spockCfg("n1") // no subscriptions at all
n2 := spockCfg("n2", types.SpockSubscription{
SubName: "s", ProviderNode: "n1", SubEnabled: true,
ReplicationSets: []string{"default"},
})

d := compareSubscriptions(n1, n2)

assert.Equal(t, []string{"n2"}, d.MissingOnNode1,
"n1 should be flagged as not subscribing from n2")
assert.Empty(t, d.MissingOnNode2,
"n2 correctly subscribes from n1")
}

// Both directions exist but replication sets differ: reported under Different.
func TestCompareSubscriptions_DifferentReplicationSets(t *testing.T) {
n1 := spockCfg("n1", types.SpockSubscription{
SubName: "a", ProviderNode: "n2", SubEnabled: true,
ReplicationSets: []string{"default"},
})
n2 := spockCfg("n2", types.SpockSubscription{
SubName: "b", ProviderNode: "n1", SubEnabled: true,
ReplicationSets: []string{"default", "extra"},
})

d := compareSubscriptions(n1, n2)

assert.Empty(t, d.MissingOnNode1)
assert.Empty(t, d.MissingOnNode2)
assert.Len(t, d.Different, 1, "replication set difference should be reported")
}

// compareSubscriptions must not reorder the caller's ReplicationSets: the slices
// are shared with the SpockConfigs section of the written JSON, which should
// preserve database-returned order.
func TestCompareSubscriptions_DoesNotMutateReplicationSets(t *testing.T) {
n1Sets := []string{"default_insert_only", "default"}
n2Sets := []string{"default", "default_insert_only"}
n1 := spockCfg("n1", types.SpockSubscription{
SubName: "a", ProviderNode: "n2", SubEnabled: true, ReplicationSets: n1Sets,
})
n2 := spockCfg("n2", types.SpockSubscription{
SubName: "b", ProviderNode: "n1", SubEnabled: true, ReplicationSets: n2Sets,
})

compareSubscriptions(n1, n2)

assert.Equal(t, []string{"default_insert_only", "default"}, n1Sets,
"n1 replication set order must be preserved")
assert.Equal(t, []string{"default", "default_insert_only"}, n2Sets,
"n2 replication set order must be preserved")
}

// In a 3-node mesh, comparing n1 and n2 ignores their subscriptions with n3.
func TestCompareSubscriptions_IgnoresUnrelatedPeers(t *testing.T) {
n1 := spockCfg("n1",
types.SpockSubscription{SubName: "a", ProviderNode: "n2", SubEnabled: true, ReplicationSets: []string{"default"}},
types.SpockSubscription{SubName: "c", ProviderNode: "n3", SubEnabled: true, ReplicationSets: []string{"default"}},
)
n2 := spockCfg("n2",
types.SpockSubscription{SubName: "b", ProviderNode: "n1", SubEnabled: true, ReplicationSets: []string{"default"}},
types.SpockSubscription{SubName: "d", ProviderNode: "n3", SubEnabled: true, ReplicationSets: []string{"default"}},
)

d := compareSubscriptions(n1, n2)

assert.Empty(t, d.MissingOnNode1)
assert.Empty(t, d.MissingOnNode2)
assert.Empty(t, d.Different)
}
7 changes: 6 additions & 1 deletion pkg/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,11 @@ type NodePairDiff struct {

// SpockSubscription holds information about a spock subscription.
type SpockSubscription struct {
SubName string `json:"sub_name"`
SubName string `json:"sub_name"`
// ProviderNode is the spock node_name this subscription replicates FROM
// (resolved from sub_origin); used to match reciprocal subscriptions by node
// identity instead of by the user-overridable subscription name.
ProviderNode string `json:"provider_node"`
SubEnabled bool `json:"sub_enabled"`
ReplicationSets []string `json:"replication_sets"`
}
Expand Down Expand Up @@ -316,6 +320,7 @@ type SpockNodeAndSubInfo struct {
SubName string `db:"sub_name"`
SubEnabled bool `db:"sub_enabled"`
SubReplicationSets []string `db:"sub_replication_sets"`
SubOriginName string `db:"sub_origin_name"`
}

// SpockRepSetInfo contains information about a replication set.
Expand Down
Loading
Loading