Skip to content

Commit fbed672

Browse files
authored
perf: add regex cache, sync.Once templates, and atomic fail-fast (#231)
* perf(changelog): add thread-safe regex cache and sync.Once template init - Cache compiled regexes with RWMutex to avoid recompilation. - Use sync.Once for contributor template initialization to eliminate race conditions. * perf(workspace): use atomic.Bool for fail-fast and fix slice pre-allocation Replace mutex-based fail-fast with atomic.Bool and CompareAndSwap.
1 parent 4023e46 commit fbed672

File tree

4 files changed

+65
-38
lines changed

4 files changed

+65
-38
lines changed

internal/plugins/changeloggenerator/generator.go

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"path/filepath"
88
"sort"
99
"strings"
10+
"sync"
1011
"text/template"
1112

1213
"github.com/indaco/sley/internal/core"
@@ -19,9 +20,13 @@ type Generator struct {
1920
remote *RemoteInfo
2021
formatter Formatter
2122

22-
// Template caches to avoid re-parsing on every contributor entry.
23+
// Template caches with thread-safe initialization via sync.Once.
2324
cachedContribTmpl *template.Template
25+
contribTmplOnce sync.Once
26+
contribTmplErr error
2427
cachedNewContribTmpl *template.Template
28+
newContribTmplOnce sync.Once
29+
newContribTmplErr error
2530
}
2631

2732
// NewGenerator creates a new changelog generator.
@@ -202,18 +207,15 @@ func (g *Generator) writeContributorEntry(sb *strings.Builder, contrib Contribut
202207
format = "- [@{{.Username}}](https://{{.Host}}/{{.Username}})"
203208
}
204209

205-
// Parse and execute template (use cached template if available)
206-
tmpl := g.cachedContribTmpl
207-
if tmpl == nil {
208-
var parseErr error
209-
tmpl, parseErr = template.New("contributor").Parse(format)
210-
if parseErr != nil {
211-
// Fallback on template error
212-
fmt.Fprintf(sb, "- [@%s](https://%s/%s)\n", contrib.Username, host, contrib.Username)
213-
return
214-
}
215-
g.cachedContribTmpl = tmpl
210+
// Parse template once (thread-safe)
211+
g.contribTmplOnce.Do(func() {
212+
g.cachedContribTmpl, g.contribTmplErr = template.New("contributor").Parse(format)
213+
})
214+
if g.contribTmplErr != nil {
215+
fmt.Fprintf(sb, "- [@%s](https://%s/%s)\n", contrib.Username, host, contrib.Username)
216+
return
216217
}
218+
tmpl := g.cachedContribTmpl
217219

218220
data := contributorTemplateData{
219221
Name: contrib.Name,
@@ -276,18 +278,15 @@ func (g *Generator) writeNewContributorEntry(sb *strings.Builder, nc *NewContrib
276278
format = g.getDefaultNewContributorFormat(remote)
277279
}
278280

279-
// Parse and execute template (use cached template if available)
280-
tmpl := g.cachedNewContribTmpl
281-
if tmpl == nil {
282-
var parseErr error
283-
tmpl, parseErr = template.New("newContributor").Parse(format)
284-
if parseErr != nil {
285-
// Fallback on template error
286-
g.writeNewContributorFallback(sb, nc, remote)
287-
return
288-
}
289-
g.cachedNewContribTmpl = tmpl
281+
// Parse template once (thread-safe)
282+
g.newContribTmplOnce.Do(func() {
283+
g.cachedNewContribTmpl, g.newContribTmplErr = template.New("newContributor").Parse(format)
284+
})
285+
if g.newContribTmplErr != nil {
286+
g.writeNewContributorFallback(sb, nc, remote)
287+
return
290288
}
289+
tmpl := g.cachedNewContribTmpl
291290

292291
data := newContributorTemplateData{
293292
Name: nc.Name,

internal/plugins/changeloggenerator/parser.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package changeloggenerator
33
import (
44
"regexp"
55
"strings"
6+
"sync"
67
)
78

89
// ParsedCommit represents a fully parsed conventional commit.
@@ -24,6 +25,36 @@ var (
2425
prNumberRe = regexp.MustCompile(`\(?#(\d+)\)?`)
2526
)
2627

28+
// regexCache provides thread-safe caching of compiled regular expressions
29+
// to avoid recompilation of the same pattern (e.g., exclude patterns, group patterns).
30+
var regexCache = struct {
31+
mu sync.RWMutex
32+
cache map[string]*regexp.Regexp
33+
}{
34+
cache: make(map[string]*regexp.Regexp),
35+
}
36+
37+
// getCompiledRegex returns a cached compiled regex, compiling and caching it on first use.
38+
func getCompiledRegex(pattern string) (*regexp.Regexp, error) {
39+
regexCache.mu.RLock()
40+
re, ok := regexCache.cache[pattern]
41+
regexCache.mu.RUnlock()
42+
if ok {
43+
return re, nil
44+
}
45+
46+
re, err := regexp.Compile(pattern)
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
regexCache.mu.Lock()
52+
regexCache.cache[pattern] = re
53+
regexCache.mu.Unlock()
54+
55+
return re, nil
56+
}
57+
2758
// ParseConventionalCommit parses a commit message into its components.
2859
// Returns nil if the commit doesn't follow conventional commit format.
2960
func ParseConventionalCommit(commit *CommitInfo) *ParsedCommit {
@@ -70,10 +101,10 @@ func FilterCommits(commits []*ParsedCommit, excludePatterns []string) []*ParsedC
70101
return commits
71102
}
72103

73-
// Compile patterns
104+
// Compile patterns (cached)
74105
patterns := make([]*regexp.Regexp, 0, len(excludePatterns))
75106
for _, p := range excludePatterns {
76-
re, err := regexp.Compile(p)
107+
re, err := getCompiledRegex(p)
77108
if err != nil {
78109
continue // Skip invalid patterns
79110
}
@@ -132,7 +163,7 @@ type compiledGroup struct {
132163
func compileGroupPatterns(groups []GroupConfig) []compiledGroup {
133164
compiled := make([]compiledGroup, 0, len(groups))
134165
for i, g := range groups {
135-
re, err := regexp.Compile(g.Pattern)
166+
re, err := getCompiledRegex(g.Pattern)
136167
if err != nil {
137168
continue
138169
}

internal/workspace/detector.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func (d *Detector) scanDirectory(ctx context.Context, dir string, depth int, roo
173173
return nil, nil
174174
}
175175

176-
modules := make([]*Module, 0, len(entries)/2)
176+
modules := make([]*Module, 0, len(entries))
177177

178178
for _, entry := range entries {
179179
if err := ctx.Err(); err != nil {

internal/workspace/executor.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"sync"
7+
"sync/atomic"
78
"time"
89
)
910

@@ -127,6 +128,7 @@ func (e *Executor) runParallel(ctx context.Context, modules []*Module, op Operat
127128
var wg sync.WaitGroup
128129
var mu sync.Mutex
129130
var firstError error
131+
var failed atomic.Bool
130132

131133
// Create a context that we can cancel if fail-fast is triggered
132134
execCtx, cancel := context.WithCancel(ctx)
@@ -138,25 +140,20 @@ func (e *Executor) runParallel(ctx context.Context, modules []*Module, op Operat
138140
go func(idx int, module *Module) {
139141
defer wg.Done()
140142

141-
// Check if we should skip due to fail-fast
142-
if e.failFast {
143-
mu.Lock()
144-
if firstError != nil {
145-
mu.Unlock()
146-
return
147-
}
148-
mu.Unlock()
143+
// Check if we should skip due to fail-fast (lock-free)
144+
if e.failFast && failed.Load() {
145+
return
149146
}
150147

151148
result := e.executeOperation(execCtx, module, op)
152149

153-
mu.Lock()
154150
results[idx] = result
155-
if e.failFast && !result.Success && firstError == nil {
151+
if e.failFast && !result.Success && failed.CompareAndSwap(false, true) {
152+
mu.Lock()
156153
firstError = result.Error
154+
mu.Unlock()
157155
cancel() // Cancel all other operations
158156
}
159-
mu.Unlock()
160157
}(i, mod)
161158
}
162159

0 commit comments

Comments
 (0)