Skip to content

Commit 6851c7e

Browse files
yxxheroCopilot
andauthored
fix: reduce memory consumption by eliminating redundant string copies (#965)
* fix: reduce memory consumption by eliminating redundant string copies Changes manifest.Parse and parseContent to accept []byte instead of string, avoiding a full copy of the entire manifest when converting from the raw helm output bytes. Raw byte slices are released after parsing to allow GC reclamation before diff computation begins. Also adds a content length ratio check in rename detection to skip obviously different resources before computing expensive full diffs. Closes #915 Signed-off-by: yxxhero <aiopsclub@163.com> * fix: remove ineffectual nil assignments flagged by linter The releaseManifest and installManifest nil assignments were flagged by ineffassign since the variables are not read afterward. The main memory optimization comes from the []byte signature change. Signed-off-by: yxxhero <aiopsclub@163.com> * fix: address PR review comments for reduce memory consumption - Replace append copy in Parse with io.MultiReader to avoid manifest copy - Extract length ratio constants for named constants Add unit tests for length ratio heuristic - Restructure cmd flows to parse into locals before diff release raw bytes earlier Signed-off-by: yxxhero <aiopsclub@163.com> * fix: address all PR review comments - Nil out raw manifest byte slices after parsing in upgrade/release/rollback/revision to allow GC reclamation before diff computation - Fix unkeyed Options struct literals in diff_test.go (4 locations) to use keyed field names for resilience against struct changes - Skip length-ratio filter for Secrets in contentSearch() since Secret raw content lengths before redaction/decoding are unreliable rename predictors - Extract empty-content check before the Secret kind branch (DRY) Agent-Logs-Url: https://github.com/databus23/helm-diff/sessions/c2d2d0b3-3840-48a8-89f0-4b6913c83973 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> * fix: suppress ineffassign warnings on GC-hint nil assignments Add //nolint:ineffassign comments on the nil assignments that are intentionally written to allow GC to reclaim raw manifest bytes before the diff computation begins. The ineffassign linter correctly identifies these as never-read-after-assignment, but the intent is a deliberate GC hint for long-running diff operations. Agent-Logs-Url: https://github.com/databus23/helm-diff/sessions/aa2df3f5-57bb-4939-ac45-3f3612deac60 Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com> --------- Signed-off-by: yxxhero <aiopsclub@163.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 645eb44 commit 6851c7e

8 files changed

Lines changed: 221 additions & 57 deletions

File tree

cmd/release.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,14 @@ func (d *release) differentiateHelm3() error {
109109
}
110110

111111
if releaseChart1 == releaseChart2 {
112+
oldSpecs := manifest.Parse(releaseResponse1, namespace1, d.normalizeManifests, excludes...)
113+
newSpecs := manifest.Parse(releaseResponse2, namespace2, d.normalizeManifests, excludes...)
114+
releaseResponse1 = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
115+
releaseResponse2 = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
116+
112117
seenAnyChanges := diff.Releases(
113-
manifest.Parse(string(releaseResponse1), namespace1, d.normalizeManifests, excludes...),
114-
manifest.Parse(string(releaseResponse2), namespace2, d.normalizeManifests, excludes...),
118+
oldSpecs,
119+
newSpecs,
115120
&d.Options,
116121
os.Stdout)
117122

cmd/revision.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,14 @@ func (d *revision) differentiateHelm3() error {
9999
return err
100100
}
101101

102+
oldSpecs := manifest.Parse(revisionResponse, namespace, d.normalizeManifests, excludes...)
103+
newSpecs := manifest.Parse(releaseResponse, namespace, d.normalizeManifests, excludes...)
104+
revisionResponse = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
105+
releaseResponse = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
106+
102107
diff.Manifests(
103-
manifest.Parse(string(revisionResponse), namespace, d.normalizeManifests, excludes...),
104-
manifest.Parse(string(releaseResponse), namespace, d.normalizeManifests, excludes...),
108+
oldSpecs,
109+
newSpecs,
105110
&d.Options,
106111
os.Stdout)
107112

@@ -122,9 +127,14 @@ func (d *revision) differentiateHelm3() error {
122127
return err
123128
}
124129

130+
oldSpecs := manifest.Parse(revisionResponse1, namespace, d.normalizeManifests, excludes...)
131+
newSpecs := manifest.Parse(revisionResponse2, namespace, d.normalizeManifests, excludes...)
132+
revisionResponse1 = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
133+
revisionResponse2 = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
134+
125135
seenAnyChanges := diff.Manifests(
126-
manifest.Parse(string(revisionResponse1), namespace, d.normalizeManifests, excludes...),
127-
manifest.Parse(string(revisionResponse2), namespace, d.normalizeManifests, excludes...),
136+
oldSpecs,
137+
newSpecs,
128138
&d.Options,
129139
os.Stdout)
130140

cmd/rollback.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,14 @@ func (d *rollback) backcastHelm3() error {
9090
}
9191

9292
// create a diff between the current manifest and the version of the manifest that a user is intended to rollback
93+
oldSpecs := manifest.Parse(releaseResponse, namespace, d.normalizeManifests, excludes...)
94+
newSpecs := manifest.Parse(revisionResponse, namespace, d.normalizeManifests, excludes...)
95+
releaseResponse = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
96+
revisionResponse = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
97+
9398
seenAnyChanges := diff.Manifests(
94-
manifest.Parse(string(releaseResponse), namespace, d.normalizeManifests, excludes...),
95-
manifest.Parse(string(revisionResponse), namespace, d.normalizeManifests, excludes...),
99+
oldSpecs,
100+
newSpecs,
96101
&d.Options,
97102
os.Stdout)
98103

cmd/upgrade.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,11 +327,12 @@ func (d *diffCmd) runHelm3() error {
327327
releaseManifest = append(releaseManifest, hooks...)
328328
}
329329
if d.includeTests {
330-
currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests)
330+
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests)
331331
} else {
332-
currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
332+
currentSpecs = manifest.Parse(releaseManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
333333
}
334334
}
335+
releaseManifest = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
335336

336337
var newOwnedReleases map[string]diff.OwnershipDiff
337338
if d.takeOwnership {
@@ -347,10 +348,11 @@ func (d *diffCmd) runHelm3() error {
347348

348349
var newSpecs map[string]*manifest.MappingResult
349350
if d.includeTests {
350-
newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests)
351+
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests)
351352
} else {
352-
newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
353+
newSpecs = manifest.Parse(installManifest, d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook)
353354
}
355+
installManifest = nil //nolint:ineffassign // nil to allow GC to reclaim raw bytes before diff computation
354356

355357
seenAnyChanges := diff.ManifestsOwnership(currentSpecs, newSpecs, newOwnedReleases, &d.Options, os.Stdout)
356358

diff/diff.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ func actualChanges(diff []difflib.DiffRecord) int {
181181
return changes
182182
}
183183

184+
const (
185+
renameDetectionMinLengthRatio float32 = 0.1
186+
renameDetectionMaxLengthRatio float32 = 10.0
187+
)
188+
184189
func contentSearch(report *Report, possiblyRemoved []string, oldIndex map[string]*manifest.MappingResult, possiblyAdded []string, newIndex map[string]*manifest.MappingResult, options *Options) ([]string, []string) {
185190
if options.FindRenames <= 0 {
186191
return possiblyRemoved, possiblyAdded
@@ -198,6 +203,21 @@ func contentSearch(report *Report, possiblyRemoved []string, oldIndex map[string
198203
continue
199204
}
200205

206+
oldLen := len(oldContent.Content)
207+
newLen := len(newContent.Content)
208+
if oldLen == 0 || newLen == 0 {
209+
continue
210+
}
211+
// Skip the length-ratio filter for Secrets: their raw content length can
212+
// differ greatly from the post-processed (redacted/decoded) length, so the
213+
// ratio would be an unreliable predictor of content similarity.
214+
if oldContent.Kind != "Secret" {
215+
ratio := float32(oldLen) / float32(newLen)
216+
if ratio < renameDetectionMinLengthRatio || ratio > renameDetectionMaxLengthRatio {
217+
continue
218+
}
219+
}
220+
201221
switch {
202222
case options.ShowSecretsDecoded:
203223
decodeSecrets(oldContent, newContent)
@@ -208,7 +228,7 @@ func contentSearch(report *Report, possiblyRemoved []string, oldIndex map[string
208228
diff := diffMappingResults(oldContent, newContent, options.StripTrailingCR)
209229
delta := actualChanges(diff)
210230
if delta == 0 || len(diff) == 0 {
211-
continue // Should never happen, but better safe than sorry
231+
continue
212232
}
213233
fraction := float32(delta) / float32(len(diff))
214234
if fraction > 0 && fraction < smallestFraction {

diff/diff_test.go

Lines changed: 140 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -617,8 +617,8 @@ spec:
617617
- name: app
618618
image: demo:v2
619619
`
620-
oldIndex := manifest.Parse(oldManifest, "prod", true)
621-
newIndex := manifest.Parse(newManifest, "prod", true)
620+
oldIndex := manifest.Parse([]byte(oldManifest), "prod", true)
621+
newIndex := manifest.Parse([]byte(newManifest), "prod", true)
622622

623623
var buf bytes.Buffer
624624
changed := Manifests(oldIndex, newIndex, opts, &buf)
@@ -656,7 +656,7 @@ metadata:
656656
namespace: ops
657657
spec: {}
658658
`
659-
newIndex := manifest.Parse(newManifest, "ops", true)
659+
newIndex := manifest.Parse([]byte(newManifest), "ops", true)
660660

661661
var buf bytes.Buffer
662662
changed := Manifests(map[string]*manifest.MappingResult{}, newIndex, opts, &buf)
@@ -702,8 +702,8 @@ metadata:
702702
data:
703703
password: Zm9v
704704
`
705-
oldIndex := manifest.Parse(oldManifest, "default", true)
706-
newIndex := manifest.Parse(newManifest, "default", true)
705+
oldIndex := manifest.Parse([]byte(oldManifest), "default", true)
706+
newIndex := manifest.Parse([]byte(newManifest), "default", true)
707707

708708
var buf bytes.Buffer
709709
changed := Manifests(oldIndex, newIndex, opts, &buf)
@@ -822,8 +822,8 @@ metadata:
822822
name: test
823823
namespace: default
824824
`
825-
oldIndex := manifest.Parse(emptyManifest, "default", true)
826-
newIndex := manifest.Parse(validManifest, "default", true)
825+
oldIndex := manifest.Parse([]byte(emptyManifest), "default", true)
826+
newIndex := manifest.Parse([]byte(validManifest), "default", true)
827827

828828
var buf bytes.Buffer
829829
changed := Manifests(oldIndex, newIndex, opts, &buf)
@@ -846,8 +846,8 @@ metadata:
846846
name: test
847847
namespace: default
848848
`
849-
oldIndex := manifest.Parse(nullManifest, "default", true)
850-
newIndex := manifest.Parse(validManifest, "default", true)
849+
oldIndex := manifest.Parse([]byte(nullManifest), "default", true)
850+
newIndex := manifest.Parse([]byte(validManifest), "default", true)
851851

852852
var buf bytes.Buffer
853853
changed := Manifests(oldIndex, newIndex, opts, &buf)
@@ -890,8 +890,8 @@ data:
890890
deeply:
891891
value: new
892892
`
893-
oldIndex := manifest.Parse(oldManifest, "default", true)
894-
newIndex := manifest.Parse(newManifest, "default", true)
893+
oldIndex := manifest.Parse([]byte(oldManifest), "default", true)
894+
newIndex := manifest.Parse([]byte(newManifest), "default", true)
895895

896896
var buf bytes.Buffer
897897
changed := Manifests(oldIndex, newIndex, opts, &buf)
@@ -944,8 +944,8 @@ spec:
944944
- name: KEY2
945945
value: val2
946946
`
947-
oldIndex := manifest.Parse(oldManifest, "prod", true)
948-
newIndex := manifest.Parse(newManifest, "prod", true)
947+
oldIndex := manifest.Parse([]byte(oldManifest), "prod", true)
948+
newIndex := manifest.Parse([]byte(newManifest), "prod", true)
949949

950950
var buf bytes.Buffer
951951
changed := Manifests(oldIndex, newIndex, opts, &buf)
@@ -967,8 +967,8 @@ spec:
967967
emptyManifest1 := ``
968968
emptyManifest2 := ``
969969

970-
oldIndex := manifest.Parse(emptyManifest1, "default", true)
971-
newIndex := manifest.Parse(emptyManifest2, "default", true)
970+
oldIndex := manifest.Parse([]byte(emptyManifest1), "default", true)
971+
newIndex := manifest.Parse([]byte(emptyManifest2), "default", true)
972972

973973
var buf bytes.Buffer
974974
changed := Manifests(oldIndex, newIndex, opts, &buf)
@@ -1593,3 +1593,128 @@ data:
15931593
require.Contains(t, new.Content, "key1: '++++++++ # (6 bytes)'")
15941594
})
15951595
}
1596+
1597+
func TestRenameDetectionLengthRatio(t *testing.T) {
1598+
ansi.DisableColors(true)
1599+
1600+
makeSpec := func(name string, content string) map[string]*manifest.MappingResult {
1601+
return map[string]*manifest.MappingResult{
1602+
name: {
1603+
Name: name,
1604+
Kind: "Deployment",
1605+
Content: content,
1606+
},
1607+
}
1608+
}
1609+
1610+
shortContent := `
1611+
apiVersion: apps/v1
1612+
kind: Deployment
1613+
metadata:
1614+
name: short
1615+
spec:
1616+
replicas: 1
1617+
`
1618+
1619+
shortContentRenamed := `
1620+
apiVersion: apps/v1
1621+
kind: Deployment
1622+
metadata:
1623+
name: short-renamed
1624+
spec:
1625+
replicas: 1
1626+
`
1627+
1628+
longContent := `
1629+
apiVersion: apps/v1
1630+
kind: Deployment
1631+
metadata:
1632+
name: very-long
1633+
spec:
1634+
replicas: 1
1635+
template:
1636+
spec:
1637+
containers:
1638+
- name: app
1639+
image: myapp:v1
1640+
ports:
1641+
- containerPort: 8080
1642+
env:
1643+
- name: VAR1
1644+
value: "hello"
1645+
- name: VAR2
1646+
value: "world"
1647+
- name: VAR3
1648+
value: "foo"
1649+
- name: VAR4
1650+
value: "bar"
1651+
- name: VAR5
1652+
value: "baz"
1653+
resources:
1654+
limits:
1655+
cpu: "1"
1656+
memory: "1Gi"
1657+
`
1658+
1659+
t.Run("similar length detects rename", func(t *testing.T) {
1660+
var buf bytes.Buffer
1661+
opts := &Options{OutputFormat: "diff", OutputContext: 10, ShowSecrets: true, FindRenames: 0.5}
1662+
1663+
oldSpec := makeSpec("default, short, Deployment (apps)", shortContent)
1664+
newSpec := makeSpec("default, short-renamed, Deployment (apps)", shortContentRenamed)
1665+
1666+
changed := Manifests(oldSpec, newSpec, opts, &buf)
1667+
require.True(t, changed)
1668+
require.Contains(t, buf.String(), "default, short, Deployment (apps) has changed")
1669+
})
1670+
1671+
t.Run("very different length skips rename", func(t *testing.T) {
1672+
var buf bytes.Buffer
1673+
opts := &Options{OutputFormat: "diff", OutputContext: 10, ShowSecrets: true, FindRenames: 0.5}
1674+
1675+
oldSpec := makeSpec("default, short, Deployment (apps)", shortContent)
1676+
newSpec := makeSpec("default, very-long, Deployment (apps)", longContent)
1677+
1678+
changed := Manifests(oldSpec, newSpec, opts, &buf)
1679+
require.True(t, changed)
1680+
require.Contains(t, buf.String(), "default, short, Deployment (apps) has been removed")
1681+
require.Contains(t, buf.String(), "default, very-long, Deployment (apps) has been added")
1682+
})
1683+
1684+
t.Run("empty content skipped", func(t *testing.T) {
1685+
var buf bytes.Buffer
1686+
opts := &Options{OutputFormat: "diff", OutputContext: 10, ShowSecrets: true, FindRenames: 0.5}
1687+
1688+
oldSpec := map[string]*manifest.MappingResult{
1689+
"default, empty, Deployment (apps)": {
1690+
Name: "default, empty, Deployment (apps)",
1691+
Kind: "Deployment",
1692+
Content: "",
1693+
},
1694+
}
1695+
newSpec := makeSpec("default, short-renamed, Deployment (apps)", shortContentRenamed)
1696+
1697+
changed := Manifests(oldSpec, newSpec, opts, &buf)
1698+
require.True(t, changed)
1699+
require.Contains(t, buf.String(), "has been added")
1700+
})
1701+
1702+
t.Run("different kind skipped", func(t *testing.T) {
1703+
var buf bytes.Buffer
1704+
opts := &Options{OutputFormat: "diff", OutputContext: 10, ShowSecrets: true, FindRenames: 0.5}
1705+
1706+
oldSpec := map[string]*manifest.MappingResult{
1707+
"default, svc, Service (v1)": {
1708+
Name: "default, svc, Service (v1)",
1709+
Kind: "Service",
1710+
Content: shortContent,
1711+
},
1712+
}
1713+
newSpec := makeSpec("default, svc-renamed, Deployment (apps)", shortContentRenamed)
1714+
1715+
changed := Manifests(oldSpec, newSpec, opts, &buf)
1716+
require.True(t, changed)
1717+
require.Contains(t, buf.String(), "has been removed")
1718+
require.Contains(t, buf.String(), "has been added")
1719+
})
1720+
}

0 commit comments

Comments
 (0)