Skip to content

Commit efed836

Browse files
authored
perf: speed up overlay performance ~200-40000x depending on the size of the document (#17)
* chore: add a benchmark * chore: the speedup * chore: bump go version
1 parent 37ca848 commit efed836

File tree

4 files changed

+220
-23
lines changed

4 files changed

+220
-23
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/speakeasy-api/openapi-overlay
22

3-
go 1.22
3+
go 1.24
44

55
require (
66
github.com/speakeasy-api/jsonpath v0.6.0

pkg/overlay/apply.go

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -148,50 +148,43 @@ func (o *Overlay) applyUpdateAction(root *yaml.Node, action Action, warnings *[]
148148
}
149149

150150
nodes := p.Query(root)
151-
prior, err := yaml.Marshal(root)
152-
if err != nil {
153-
return err
154-
}
155151

152+
didMakeChange := false
156153
for _, node := range nodes {
157-
if err := updateNode(node, &action.Update); err != nil {
158-
return err
159-
}
154+
didMakeChange = updateNode(node, &action.Update) || didMakeChange
160155
}
161-
post, err := yaml.Marshal(root)
162-
if err != nil {
163-
return err
164-
}
165-
if warnings != nil && string(prior) == string(post) {
156+
if !didMakeChange {
166157
*warnings = append(*warnings, "does nothing")
167158
}
168159

169160
return nil
170161
}
171162

172-
func updateNode(node *yaml.Node, updateNode *yaml.Node) error {
173-
mergeNode(node, updateNode)
174-
return nil
163+
func updateNode(node *yaml.Node, updateNode *yaml.Node) bool {
164+
return mergeNode(node, updateNode)
175165
}
176166

177-
func mergeNode(node *yaml.Node, merge *yaml.Node) {
167+
func mergeNode(node *yaml.Node, merge *yaml.Node) bool {
178168
if node.Kind != merge.Kind {
179169
*node = *clone(merge)
180-
return
170+
return true
181171
}
182172
switch node.Kind {
183173
default:
174+
isChanged := node.Value != merge.Value
184175
node.Value = merge.Value
176+
return isChanged
185177
case yaml.MappingNode:
186-
mergeMappingNode(node, merge)
178+
return mergeMappingNode(node, merge)
187179
case yaml.SequenceNode:
188-
mergeSequenceNode(node, merge)
180+
return mergeSequenceNode(node, merge)
189181
}
190182
}
191183

192184
// mergeMappingNode will perform a shallow merge of the merge node into the main
193185
// node.
194-
func mergeMappingNode(node *yaml.Node, merge *yaml.Node) {
186+
func mergeMappingNode(node *yaml.Node, merge *yaml.Node) bool {
187+
anyChange := false
195188
NextKey:
196189
for i := 0; i < len(merge.Content); i += 2 {
197190
mergeKey := merge.Content[i].Value
@@ -200,18 +193,21 @@ NextKey:
200193
for j := 0; j < len(node.Content); j += 2 {
201194
nodeKey := node.Content[j].Value
202195
if nodeKey == mergeKey {
203-
mergeNode(node.Content[j+1], mergeValue)
196+
anyChange = mergeNode(node.Content[j+1], mergeValue) || anyChange
204197
continue NextKey
205198
}
206199
}
207200

208201
node.Content = append(node.Content, merge.Content[i], clone(mergeValue))
202+
anyChange = true
209203
}
204+
return anyChange
210205
}
211206

212207
// mergeSequenceNode will append the merge node's content to the original node.
213-
func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) {
208+
func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) bool {
214209
node.Content = append(node.Content, clone(merge).Content...)
210+
return true
215211
}
216212

217213
func clone(node *yaml.Node) *yaml.Node {

pkg/overlay/apply_test.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"bytes"
55
"github.com/speakeasy-api/jsonpath/pkg/jsonpath"
66
"github.com/speakeasy-api/openapi-overlay/pkg/loader"
7+
"github.com/speakeasy-api/openapi-overlay/pkg/overlay"
78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910
"gopkg.in/yaml.v3"
1011
"os"
12+
"strconv"
1113
"testing"
1214
)
1315

@@ -75,6 +77,195 @@ func TestApplyToStrict(t *testing.T) {
7577
assert.Len(t, warnings, 1)
7678
assert.Equal(t, "update action (2 / 2) target=$.info.title: does nothing", warnings[0])
7779
NodeMatchesFile(t, node, "testdata/openapi-strict-onechange.yaml")
80+
81+
node, err = loader.LoadSpecification("testdata/openapi.yaml")
82+
require.NoError(t, err)
83+
84+
o, err = loader.LoadOverlay("testdata/overlay.yaml")
85+
require.NoError(t, err)
86+
87+
err = o.ApplyTo(node)
88+
assert.NoError(t, err)
89+
90+
NodeMatchesFile(t, node, "testdata/openapi-overlayed.yaml")
91+
92+
}
93+
94+
func BenchmarkApplyToStrict(b *testing.B) {
95+
openAPIBytes, err := os.ReadFile("testdata/openapi.yaml")
96+
require.NoError(b, err)
97+
overlayBytes, err := os.ReadFile("testdata/overlay-zero-change.yaml")
98+
require.NoError(b, err)
99+
100+
var specNode yaml.Node
101+
err = yaml.NewDecoder(bytes.NewReader(openAPIBytes)).Decode(&specNode)
102+
require.NoError(b, err)
103+
104+
// Load overlay from bytes
105+
var o overlay.Overlay
106+
err = yaml.NewDecoder(bytes.NewReader(overlayBytes)).Decode(&o)
107+
require.NoError(b, err)
108+
109+
// Apply overlay to spec
110+
for b.Loop() {
111+
_, _ = o.ApplyToStrict(&specNode)
112+
}
113+
}
114+
115+
func BenchmarkApplyToStrictBySize(b *testing.B) {
116+
// Read the base OpenAPI spec
117+
openAPIBytes, err := os.ReadFile("testdata/openapi.yaml")
118+
require.NoError(b, err)
119+
120+
// Read the overlay spec
121+
overlayBytes, err := os.ReadFile("testdata/overlay-zero-change.yaml")
122+
require.NoError(b, err)
123+
124+
// Decode the base spec
125+
var baseSpec yaml.Node
126+
err = yaml.NewDecoder(bytes.NewReader(openAPIBytes)).Decode(&baseSpec)
127+
require.NoError(b, err)
128+
129+
// Find the paths node and a path to duplicate
130+
pathsNode := findPathsNode(&baseSpec)
131+
require.NotNil(b, pathsNode)
132+
133+
// Get the first path item to use as template
134+
var templatePath *yaml.Node
135+
var templateKey string
136+
for i := 0; i < len(pathsNode.Content); i += 2 {
137+
if pathsNode.Content[i].Kind == yaml.ScalarNode && pathsNode.Content[i].Value[0] == '/' {
138+
templateKey = pathsNode.Content[i].Value
139+
templatePath = pathsNode.Content[i+1]
140+
break
141+
}
142+
}
143+
require.NotNil(b, templatePath)
144+
145+
// Target sizes: 2KB, 20KB, 200KB, 2MB, 20MB
146+
targetSizes := []struct {
147+
size int
148+
name string
149+
}{
150+
{2 * 1024, "2KB"},
151+
{20 * 1024, "20KB"},
152+
{200 * 1024, "200KB"},
153+
{2000 * 1024, "2M"},
154+
}
155+
156+
// Calculate the base document size
157+
var baseBuf bytes.Buffer
158+
enc := yaml.NewEncoder(&baseBuf)
159+
err = enc.Encode(&baseSpec)
160+
require.NoError(b, err)
161+
baseSize := baseBuf.Len()
162+
163+
// Calculate the size of a single path item by encoding it
164+
var pathBuf bytes.Buffer
165+
pathEnc := yaml.NewEncoder(&pathBuf)
166+
tempNode := &yaml.Node{
167+
Kind: yaml.MappingNode,
168+
Content: []*yaml.Node{
169+
{Kind: yaml.ScalarNode, Value: templateKey + "-test"},
170+
cloneNode(templatePath),
171+
},
172+
}
173+
err = pathEnc.Encode(tempNode)
174+
require.NoError(b, err)
175+
// Approximate size contribution of one path (accounting for YAML structure)
176+
pathItemSize := pathBuf.Len() - 10 // Subtract some overhead
177+
178+
for _, target := range targetSizes {
179+
b.Run(target.name, func(b *testing.B) {
180+
// Create a copy of the base spec
181+
specCopy := cloneNode(&baseSpec)
182+
pathsNodeCopy := findPathsNode(specCopy)
183+
184+
// Calculate how many paths we need to add
185+
bytesNeeded := target.size - baseSize
186+
pathsToAdd := 0
187+
if bytesNeeded > 0 {
188+
pathsToAdd = bytesNeeded / pathItemSize
189+
// Add a few extra to ensure we exceed the target
190+
pathsToAdd += 5
191+
}
192+
193+
// Add the calculated number of path duplicates
194+
for i := 0; i < pathsToAdd; i++ {
195+
newPathKey := yaml.Node{Kind: yaml.ScalarNode, Value: templateKey + "-duplicate-" + strconv.Itoa(i)}
196+
newPathValue := cloneNode(templatePath)
197+
pathsNodeCopy.Content = append(pathsNodeCopy.Content, &newPathKey, newPathValue)
198+
}
199+
200+
// Verify final size
201+
var finalBuf bytes.Buffer
202+
finalEnc := yaml.NewEncoder(&finalBuf)
203+
err = finalEnc.Encode(specCopy)
204+
require.NoError(b, err)
205+
actualSize := finalBuf.Len()
206+
b.Logf("OpenAPI size: %d bytes (target: %d, paths added: %d)", actualSize, target.size, pathsToAdd)
207+
208+
// Load overlay
209+
var o overlay.Overlay
210+
err = yaml.NewDecoder(bytes.NewReader(overlayBytes)).Decode(&o)
211+
require.NoError(b, err)
212+
213+
specForTest := cloneNode(specCopy)
214+
// Run the benchmark
215+
b.ResetTimer()
216+
for b.Loop() {
217+
_, _ = o.ApplyToStrict(specForTest)
218+
}
219+
})
220+
}
221+
}
222+
223+
// Helper function to find the paths node in the OpenAPI spec
224+
func findPathsNode(node *yaml.Node) *yaml.Node {
225+
if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
226+
node = node.Content[0]
227+
}
228+
229+
if node.Kind != yaml.MappingNode {
230+
return nil
231+
}
232+
233+
for i := 0; i < len(node.Content); i += 2 {
234+
if node.Content[i].Value == "paths" {
235+
return node.Content[i+1]
236+
}
237+
}
238+
return nil
239+
}
240+
241+
// Helper function to deep clone a YAML node
242+
func cloneNode(node *yaml.Node) *yaml.Node {
243+
if node == nil {
244+
return nil
245+
}
246+
247+
clone := &yaml.Node{
248+
Kind: node.Kind,
249+
Style: node.Style,
250+
Tag: node.Tag,
251+
Value: node.Value,
252+
Anchor: node.Anchor,
253+
Alias: node.Alias,
254+
HeadComment: node.HeadComment,
255+
LineComment: node.LineComment,
256+
FootComment: node.FootComment,
257+
Line: node.Line,
258+
Column: node.Column,
259+
}
260+
261+
if node.Content != nil {
262+
clone.Content = make([]*yaml.Node, len(node.Content))
263+
for i, child := range node.Content {
264+
clone.Content[i] = cloneNode(child)
265+
}
266+
}
267+
268+
return clone
78269
}
79270

80271
func TestApplyToOld(t *testing.T) {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
overlay: 1.0.0
2+
x-speakeasy-jsonpath: rfc9535
3+
info:
4+
title: Drinks Overlay
5+
version: 0.0.0
6+
actions:
7+
- target: $.paths["/drink/{name}"].get.summary
8+
update: "Read a drink."
9+
- target: $.paths["/drink/{name}"].get.summary
10+
update: "Get a drink."

0 commit comments

Comments
 (0)