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
130 changes: 123 additions & 7 deletions testing/go/regression/tool/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,25 @@ import (
"github.com/dolthub/doltgresql/utils"
)

// CompareRowsOrdered compares the two rows, enforcing that the order matches between the two rows.
func CompareRowsOrdered(aRowDesc, bRowDesc *pgproto3.RowDescription, aRows, bRows []*pgproto3.DataRow) error {
// CompareRowsOrdered compares the two rows, enforcing that the order matches between the two rows. The aRowDesc and
// aRows are always the recorded Postgres responses, while bRowDesc and bRows are the Doltgres responses. User-object
// OIDs in `oid`-typed columns are compared through the given OIDMap (see OIDMap for details), and any new mappings
// are learned when the comparison succeeds.
func CompareRowsOrdered(oidMap *OIDMap, aRowDesc, bRowDesc *pgproto3.RowDescription, aRows, bRows []*pgproto3.DataRow) error {
if len(aRows) != len(bRows) {
return errors.Errorf("expected a row count of %d but received %d", len(aRows), len(bRows))
}
aReadRows := ReadRows(aRowDesc, aRows)
bReadRows := ReadRows(bRowDesc, bRows)
oidCols := oidColumns(aRowDesc)
oidReplacements := make(map[uint32]uint32)
for rowIdx := range aReadRows {
if len(aReadRows[rowIdx]) != len(bReadRows[rowIdx]) {
return errors.Errorf("expected a row column count of %d but received %d",
len(aReadRows[rowIdx]), len(bReadRows[rowIdx]))
}
for colIdx := range aReadRows[rowIdx] {
if aReadRows[rowIdx][colIdx] != bReadRows[rowIdx][colIdx] {
if !cellsMatch(aReadRows[rowIdx][colIdx], bReadRows[rowIdx][colIdx], oidCols[colIdx], oidMap, oidReplacements) {
if len(aReadRows)+len(bReadRows) < 8 {
return errors.Errorf("row sets differ:\n%s", rowsToErrorString(aReadRows, bReadRows))
} else {
Expand All @@ -54,21 +59,132 @@ func CompareRowsOrdered(aRowDesc, bRowDesc *pgproto3.RowDescription, aRows, bRow
}
}
}
if oidMap != nil {
oidMap.PutAll(oidReplacements)
}
return nil
}

// oidColumns returns, for each column in the recorded row description, whether the column is of the `oid` type.
func oidColumns(rowDesc *pgproto3.RowDescription) []bool {
if rowDesc == nil {
return nil
}
cols := make([]bool, len(rowDesc.Fields))
for i, field := range rowDesc.Fields {
cols[i] = field.DataTypeOID == pgtype.OIDOID
}
return cols
}

// cellsMatch returns whether the recorded cell matches the replayed cell. Cells in `oid`-typed columns that hold
// user-object OIDs are matched through the OIDMap: a previously learned mapping must agree, while a brand new pair is
// tentatively accepted and added to candidates (the caller commits candidates to the map only if the entire
// comparison succeeds, which keeps OID relationships consistent within a result set).
func cellsMatch(aVal, bVal interface{}, isOIDCol bool, oidMap *OIDMap, oidReplacements map[uint32]uint32) bool {
if aVal == bVal {
return true
}
if !isOIDCol || oidMap == nil {
return false
}
aOID, aOK := cellToOID(aVal)
bOID, bOK := cellToOID(bVal)
if !aOK || !bOK || aOID < minUserOID || bOID == 0 {
return false
}
if mapped, ok := oidReplacements[aOID]; ok {
return mapped == bOID
}
// Unknown pairing (or the object was dropped and recreated on one side): tentatively accept it. OID values are
// implementation-specific, so requiring consistency within the result set is the strongest meaningful check.
oidReplacements[aOID] = bOID
return true
}

// CompareRowsUnordered compares the two rows. Order is not enforced, however if there are any duplicate rows, then it
// is expected that the duplicate counts match.
func CompareRowsUnordered(aRowDesc, bRowDesc *pgproto3.RowDescription, aRows, bRows []*pgproto3.DataRow) error {
// is expected that the duplicate counts match. As with CompareRowsOrdered, the a-side is the recorded Postgres
// response and the b-side is the Doltgres response, with user-object OIDs matched through the given OIDMap.
func CompareRowsUnordered(oidMap *OIDMap, aRowDesc, bRowDesc *pgproto3.RowDescription, aRows, bRows []*pgproto3.DataRow) error {
if len(aRows) != len(bRows) {
return errors.Errorf("expected a row count of %d but received %d", len(aRows), len(bRows))
}
aReadRows := ReadRows(aRowDesc, aRows)
bReadRows := ReadRows(bRowDesc, bRows)
oidCols := oidColumns(aRowDesc)
hasOIDCols := false
for _, isOID := range oidCols {
hasOIDCols = hasOIDCols || isOID
}
// Translate already-learned OID mappings in the recorded rows so that the multiset comparison sees replay OIDs.
if oidMap != nil && hasOIDCols {
for _, row := range aReadRows {
for colIdx := range row {
if !oidCols[colIdx] {
continue
}
if oid, ok := cellToOID(row[colIdx]); ok && oid >= minUserOID {
if mapped, ok := oidMap.Get(oid); ok {
row[colIdx] = mapped
}
}
}
}
}
err := compareRowsMultiset(aReadRows, bReadRows)
if err != nil && oidMap != nil && hasOIDCols && len(aReadRows) <= 2000 {
// The multiset comparison may have failed only because the result contains OIDs we haven't learned yet, so
// attempt a matching that is allowed to learn new mappings. The original error is kept if that fails too.
if compareRowsUnorderedWithOidReplacement(oidMap, oidCols, aReadRows, bReadRows) {
return nil
}
}
return err
}

// compareRowsUnorderedWithOidReplacement greedily matches each recorded row to a replayed row under OID-lenient equality,
// accumulating tentative OID mappings as it goes. Returns whether a complete matching was found, in which case the
// tentative mappings are committed to the map.
func compareRowsUnorderedWithOidReplacement(oidMap *OIDMap, oidCols []bool, aReadRows, bReadRows []sql.Row) bool {
oidReplacements := make(map[uint32]uint32)
used := make([]bool, len(bReadRows))
for _, aRow := range aReadRows {
matched := false
BRows:
for bIdx, bRow := range bReadRows {
if used[bIdx] || len(aRow) != len(bRow) {
continue
}
// Trial-match against a copy so that a failed row match doesn't pollute the accumulated candidates
trial := make(map[uint32]uint32, len(oidReplacements))
for k, v := range oidReplacements {
trial[k] = v
}
for colIdx := range aRow {
if !cellsMatch(aRow[colIdx], bRow[colIdx], oidCols[colIdx], oidMap, trial) {
continue BRows
}
}
oidReplacements = trial
used[bIdx] = true
matched = true
break
}
if !matched {
return false
}
}
oidMap.PutAll(oidReplacements)
return true
}

// compareRowsMultiset compares the two sets of decoded rows as multisets, using each row's string form as its
// identity.
func compareRowsMultiset(aReadRows, bReadRows []sql.Row) error {
// It's possible that two different rows can hash to the same result, but we're not concerned with that.
// The same row will always output the same hash, and that's the only property that we really care about.
aMap := make(map[string]int)
bMap := make(map[string]int)
aReadRows := ReadRows(aRowDesc, aRows)
bReadRows := ReadRows(bRowDesc, bRows)
for rowIdx := range aReadRows {
// Column counts should always match, so this is a sanity check
if len(aReadRows[rowIdx]) != len(bReadRows[rowIdx]) {
Expand Down
100 changes: 100 additions & 0 deletions testing/go/regression/tool/dump_trackers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright 2026 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"fmt"
"os"
"sort"
"strings"
"testing"
)

// TestDumpTrackers is a debugging tool rather than a unit test: it converts a binary .trackers file (produced by
// TestRegressionTests) into a human-readable text report, containing overall pass/fail counts, per-file pass rates
// sorted by failure count, and every failed or partially-successful query along with its expected and received
// errors. The text form is convenient for grepping and for bulk failure analysis (e.g. categorizing failures by
// error message to find common root causes).
//
// It only runs when the DUMP_TRACKERS environment variable is set, and asserts nothing. Usage:
//
// DUMP_TRACKERS=/path/to/report.txt go test -run TestDumpTrackers
//
// By default it reads out/results.trackers (the file the regression run writes); set DUMP_TRACKERS_IN to dump a
// different trackers file, such as a saved baseline or one downloaded from a CI artifact.
func TestDumpTrackers(t *testing.T) {
outPath, ok := os.LookupEnv("DUMP_TRACKERS")
if !ok {
t.Skip()
}
inPath := os.Getenv("DUMP_TRACKERS_IN")
if inPath == "" {
inPath = "out/results.trackers"
}
trackers, err := regressionFolder.ReadReplayTrackers(inPath)
if err != nil {
t.Fatal(err)
}
sb := &strings.Builder{}
totalSuccess, totalFail, totalPartial := uint32(0), uint32(0), uint32(0)
type fileStat struct {
file string
success, fail uint32
}
stats := make([]fileStat, 0, len(trackers))
for _, tr := range trackers {
totalSuccess += tr.Success
totalFail += tr.Failed
totalPartial += tr.PartialSuccess
stats = append(stats, fileStat{tr.File, tr.Success, tr.Failed})
}
total := totalSuccess + totalFail
fmt.Fprintf(sb, "TOTAL: %d SUCCESS: %d (%.2f%%) FAIL: %d (%.2f%%) PARTIAL: %d\n\n",
total, totalSuccess, float64(totalSuccess)/float64(total)*100,
totalFail, float64(totalFail)/float64(total)*100, totalPartial)
sort.Slice(stats, func(i, j int) bool { return stats[i].fail > stats[j].fail })
fmt.Fprintf(sb, "PER-FILE (sorted by failures):\n")
for _, s := range stats {
ft := s.success + s.fail
if ft == 0 {
continue
}
fmt.Fprintf(sb, "%-40s total=%-5d success=%-5d fail=%-5d (%.1f%% pass)\n",
s.file, ft, s.success, s.fail, float64(s.success)/float64(ft)*100)
}
sb.WriteString("\n==================== FAILURE DETAILS ====================\n")
for _, tr := range trackers {
if len(tr.FailPartialItems) == 0 {
continue
}
fmt.Fprintf(sb, "\n########## FILE: %s (fail=%d partial=%d) ##########\n", tr.File, tr.Failed, tr.PartialSuccess)
for _, item := range tr.FailPartialItems {
fmt.Fprintf(sb, "---\nQUERY: %s\n", item.Query)
if item.ExpectedError != "" {
fmt.Fprintf(sb, "EXPECTED ERROR: %s\n", item.ExpectedError)
}
if item.UnexpectedError != "" {
fmt.Fprintf(sb, "RECEIVED ERROR: %s\n", item.UnexpectedError)
}
for _, p := range item.PartialSuccess {
fmt.Fprintf(sb, "PARTIAL: %s\n", p)
}
}
}
if err := os.WriteFile(outPath, []byte(sb.String()), 0644); err != nil {
t.Fatal(err)
}
fmt.Println("wrote", outPath)
}
Loading
Loading