fix: switch to better Myers algorithm implementation

See https://github.com/siderolabs/omni/pull/2311 for the rationale.

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
This commit is contained in:
Andrey Smirnov 2026-02-12 16:28:43 +04:00
parent 0048464be8
commit 35ad0448c9
No known key found for this signature in database
GPG Key ID: 322C6F63F594CE7C
10 changed files with 285 additions and 113 deletions

1
go.mod
View File

@ -313,6 +313,7 @@ require (
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/neticdk/go-stdlib v1.0.0 // indirect
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect
github.com/opencontainers/selinux v1.13.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect

2
go.sum
View File

@ -542,6 +542,8 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/nberlee/go-netstat v0.1.2 h1:wgPV1YOUo+kDFypqiKgfxMtnSs1Wb42c7ahI4qyEUJc=
github.com/nberlee/go-netstat v0.1.2/go.mod h1:GvDCRLsUKMRN1wULkt7tpnDmjSIE6YGf5zeVq+mBO64=
github.com/neticdk/go-stdlib v1.0.0 h1:9QCpoMpO5dBJGHJhumZrHzfJyvpVBd2Gc7ODJujfpXY=
github.com/neticdk/go-stdlib v1.0.0/go.mod h1:rch+DEB6VtR972ZPTY6A5OyLmCrp2YlXP0WGjuDDdcw=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=

View File

@ -321,7 +321,7 @@ func (s *Server) ApplyConfiguration(ctx context.Context, in *machine.ApplyConfig
}
func generateDiff(r runtime.Runtime, provider config.Provider) (string, error) {
documentsDiff, err := configdiff.DiffToString(r.ConfigContainer(), provider)
documentsDiff, err := configdiff.DiffConfigs(r.ConfigContainer(), provider)
if err != nil {
return "", err
}

View File

@ -194,7 +194,7 @@ func (r *Runtime) CanApplyImmediate(cfg config.Provider) error {
}
if !reflect.DeepEqual(currentConfig, newConfig) {
diff, err := configdiff.DiffToString(container.NewV1Alpha1(currentConfig), container.NewV1Alpha1(newConfig))
diff, err := configdiff.DiffConfigs(container.NewV1Alpha1(currentConfig), container.NewV1Alpha1(newConfig))
if err != nil {
return fmt.Errorf("error calculating diff: %w", err)
}

View File

@ -6,23 +6,15 @@
package configdiff
import (
"fmt"
"io"
"strings"
"github.com/fatih/color"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/siderolabs/talos/pkg/machinery/config"
"github.com/siderolabs/talos/pkg/machinery/config/encoder"
"github.com/siderolabs/talos/pkg/machinery/textdiff"
)
// Diff outputs (optionally) colorized diff between two machine configurations.
// DiffConfigs returns a string representation of the diff between two machine configurations.
//
// One of the resources might be nil.
func Diff(w io.Writer, oldCfg, newCfg config.Encoder) error {
func DiffConfigs(oldCfg, newCfg config.Encoder) (string, error) {
var (
oldYaml, newYaml []byte
err error
@ -31,99 +23,16 @@ func Diff(w io.Writer, oldCfg, newCfg config.Encoder) error {
if oldCfg != nil {
oldYaml, err = oldCfg.EncodeBytes(encoder.WithComments(encoder.CommentsDisabled))
if err != nil {
return err
return "", err
}
}
if newCfg != nil {
newYaml, err = newCfg.EncodeBytes(encoder.WithComments(encoder.CommentsDisabled))
if err != nil {
return err
return "", err
}
}
edits := myers.ComputeEdits(span.URIFromPath("a"), string(oldYaml), string(newYaml))
diff := gotextdiff.ToUnified("a", "b", string(oldYaml), edits)
outputDiff(w, diff, true)
return nil
}
// DiffToString returns a string representation of the diff between two machine configurations.
func DiffToString(oldCfg, newCfg config.Encoder) (string, error) {
var sb strings.Builder
err := Diff(&sb, oldCfg, newCfg)
return sb.String(), err
}
//nolint:gocyclo
func outputDiff(w io.Writer, u gotextdiff.Unified, noColor bool) {
if len(u.Hunks) == 0 {
return
}
bold := color.New(color.Bold)
cyan := color.New(color.FgCyan)
red := color.New(color.FgRed)
green := color.New(color.FgGreen)
if noColor {
bold.DisableColor()
cyan.DisableColor()
red.DisableColor()
green.DisableColor()
}
bold.Fprintf(w, "--- %s\n", u.From) //nolint:errcheck
bold.Fprintf(w, "+++ %s\n", u.To) //nolint:errcheck
for _, hunk := range u.Hunks {
fromCount, toCount := 0, 0
for _, l := range hunk.Lines {
switch l.Kind { //nolint:exhaustive
case gotextdiff.Delete:
fromCount++
case gotextdiff.Insert:
toCount++
default:
fromCount++
toCount++
}
}
cyan.Fprintf(w, "@@") //nolint:errcheck
if fromCount > 1 {
cyan.Fprintf(w, " -%d,%d", hunk.FromLine, fromCount) //nolint:errcheck
} else {
cyan.Fprintf(w, " -%d", hunk.FromLine) //nolint:errcheck
}
if toCount > 1 {
cyan.Fprintf(w, " +%d,%d", hunk.ToLine, toCount) //nolint:errcheck
} else {
cyan.Fprintf(w, " +%d", hunk.ToLine) //nolint:errcheck
}
cyan.Fprintf(w, " @@\n") //nolint:errcheck
for _, l := range hunk.Lines {
switch l.Kind { //nolint:exhaustive
case gotextdiff.Delete:
red.Fprintf(w, "-%s", l.Content) //nolint:errcheck
case gotextdiff.Insert:
green.Fprintf(w, "+%s", l.Content) //nolint:errcheck
default:
fmt.Fprintf(w, " %s", l.Content)
}
if !strings.HasSuffix(l.Content, "\n") {
red.Fprintf(w, "\n\\ No newline at end of file\n") //nolint:errcheck
}
}
}
return textdiff.Diff(string(oldYaml), string(newYaml))
}

View File

@ -75,7 +75,7 @@ func TestDiffString(t *testing.T) {
oldCfg := must.Value(container.New(test.oldCfg...))(t)
newCfg := must.Value(container.New(test.newCfg...))(t)
got, err := configdiff.DiffToString(oldCfg, newCfg)
got, err := configdiff.DiffConfigs(oldCfg, newCfg)
require.NoError(t, err)
require.Equal(t, test.want, got)

View File

@ -12,13 +12,12 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/emicklei/dot v1.11.0
github.com/evanphx/json-patch v5.9.11+incompatible
github.com/fatih/color v1.18.0
github.com/ghodss/yaml v1.0.0
github.com/google/cel-go v0.27.0
github.com/hashicorp/go-multierror v1.1.1
github.com/hexops/gotextdiff v1.0.3
github.com/jsimonetti/rtnetlink/v2 v2.2.0
github.com/mdlayher/ethtool v0.5.0
github.com/neticdk/go-stdlib v1.0.0
github.com/opencontainers/runtime-spec v1.3.0
github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25
github.com/ryanuber/go-glob v1.0.0
@ -55,8 +54,6 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.8.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect

View File

@ -34,8 +34,6 @@ github.com/emicklei/dot v1.11.0 h1:zsrhCuFHAJge/aZIC4N4LdHy5tqYu4tWEaUzIwdYj4Y=
github.com/emicklei/dot v1.11.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8=
github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA=
github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
@ -63,8 +61,6 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink/v2 v2.2.0 h1:/KfZ310gOAFrXXol5VwnFEt+ucldD/0dsSRZwpHCP9w=
@ -73,16 +69,14 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/neticdk/go-stdlib v1.0.0 h1:9QCpoMpO5dBJGHJhumZrHzfJyvpVBd2Gc7ODJujfpXY=
github.com/neticdk/go-stdlib v1.0.0/go.mod h1:rch+DEB6VtR972ZPTY6A5OyLmCrp2YlXP0WGjuDDdcw=
github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo=
github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
@ -178,7 +172,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@ -0,0 +1,77 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Package textdiff provides a way to compare two text blobs.
package textdiff
import (
"fmt"
"strings"
"github.com/neticdk/go-stdlib/diff/myers"
)
// MaxLines is the maximum number of lines that the diff function will process before giving up and returning a message instead.
const MaxLines = 75_000
// Diff is a function that computes unified diff between two strings.
//
// The diff is limited to MaxLines lines, and if the diff is larger than that, a message is returned instead of the actual diff.
// This is to prevent the function from consuming too much memory or CPU time when comparing large text blobs.
func Diff(a, b string) (string, error) {
if a == b {
return "", nil
}
prevLines := strings.Count(a, "\n")
newLines := strings.Count(b, "\n")
if prevLines+newLines > MaxLines {
return fmt.Sprintf("@@ -%d,%d +%d,%d @@ diff too large to display\n", 1, prevLines, 1, newLines), nil
}
differ := myers.NewCustomDiffer(
myers.WithUnifiedFormatter(),
myers.WithLinearSpace(true),
// Disable the library's standard-Myers and LCS fallback paths:
// - Standard Myers (< smallInputThreshold) is O((N+M)²) when inputs are asymmetric.
// - LCS (> largeInputThreshold) is O(N*M) for the DP table.
// By setting these to 0 and MaxLines respectively, only Hirschberg's
// O(N+M) linear-space algorithm runs. Our MaxLines guard above ensures
// inputs never exceed largeInputThreshold.
myers.WithSmallInputThreshold(0),
myers.WithLargeInputThreshold(MaxLines),
)
return differ.Diff(a, b)
}
// DiffWithCustomPaths is almost same as Diff, but allows to specify custom paths for the diff header.
func DiffWithCustomPaths(a, b, aPath, bPath string) (string, error) {
diff, err := Diff(a, b)
if err != nil {
return "", err
}
if diff == "" {
return "", nil
}
// patch the diff header to include the manifest path
diff, ok := strings.CutPrefix(diff, "--- a\n+++ b\n")
if !ok {
return "", fmt.Errorf("unexpected diff format")
}
var sb strings.Builder
sb.WriteString("--- ")
sb.WriteString(aPath)
sb.WriteString("\n+++ ")
sb.WriteString(bPath)
sb.WriteString("\n")
sb.WriteString(diff)
return sb.String(), nil
}

View File

@ -0,0 +1,193 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package textdiff_test
import (
"fmt"
"math/rand/v2"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/siderolabs/talos/pkg/machinery/textdiff"
)
func TestDiff(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
a, b string
expected string
}{
{
name: "empty",
a: "",
b: "",
expected: "",
},
{
name: "identical",
a: "line 1\nline 2\nline 3\n",
b: "line 1\nline 2\nline 3\n",
expected: "",
},
{
name: "completely different",
a: "line 1\nline 2\nline 3\n",
b: "line A\nline B\nline C\n",
expected: `--- a
+++ b
@@ -1,3 +1,3 @@
-line 1
-line 2
-line 3
+line A
+line B
+line C
`,
},
{
name: "inserted line",
a: "line 1\nline 3\n",
b: "line 1\nline 2\nline 3\n",
expected: `--- a
+++ b
@@ -1,2 +1,3 @@
line 1
+line 2
line 3
`,
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
diff, err := textdiff.Diff(test.a, test.b)
require.NoError(t, err)
assert.Equal(t, test.expected, diff)
})
}
}
func TestDiffWithCustomPaths(t *testing.T) {
t.Parallel()
diff, err := textdiff.DiffWithCustomPaths("line 1\nline 3\n", "line 1\nline 2\nline 3\n", "fileA.txt", "fileB.txt")
require.NoError(t, err)
assert.Equal(t, `--- fileA.txt
+++ fileB.txt
@@ -1,2 +1,3 @@
line 1
+line 2
line 3
`, diff)
}
func genRandomLines(n int) string {
var sb strings.Builder
for range n {
fmt.Fprintf(&sb, "line %d\n", rand.Int())
}
return sb.String()
}
func TestDiffMemoryBudge(t *testing.T) {
// not parallel, we need to measure memory allocations
linesA := genRandomLines(1000)
linesB := genRandomLines(20000)
linesC := genRandomLines(1000)
for _, test := range []struct {
name string
a, b string
memoryBudget uint64
}{
{
name: "empty",
a: "",
b: "",
memoryBudget: 1024, // 1KB
},
{
name: "large",
a: linesA + linesB,
b: linesB + linesC,
memoryBudget: 3 * 1024 * 1024, // 3MB
},
{
name: "large 2",
a: linesA + linesB,
b: linesA + linesB + linesC,
memoryBudget: 2 * 1024 * 1024, // 2MB
},
{
name: "completely different",
a: linesA,
b: linesC,
memoryBudget: 512 * 1024, // 512KB
},
{
name: "large to empty",
a: linesA + linesB + linesC,
b: "",
memoryBudget: 6 * 1024 * 1024, // 6MB
},
{
name: "empty to large",
a: "",
b: linesA + linesB + linesC,
memoryBudget: 6 * 1024 * 1024, // 6MB
},
} {
t.Run(test.name, func(t *testing.T) {
differ := textdiff.Diff
allocs := MemoryAllocated(func() {
_, err := differ(test.a, test.b)
require.NoError(t, err)
})
require.LessOrEqual(t, allocs, test.memoryBudget, "memory allocations exceeded the budget")
})
}
}
func MemoryAllocated(f func()) uint64 {
defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1))
runtime.GC()
// Measure the starting statistics
var memstats runtime.MemStats
runtime.ReadMemStats(&memstats)
allocs := 0 - memstats.TotalAlloc
f()
// Read the final statistics
runtime.ReadMemStats(&memstats)
allocs += memstats.TotalAlloc
return allocs
}