Skip to content

Commit b771d6b

Browse files
authored
Merge pull request #124 from mxlint/bugfix/memory-usage
limited concurrency and better memory footprint
2 parents 0661128 + a094af8 commit b771d6b

15 files changed

Lines changed: 893 additions & 276 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ lint:
8282
jsonFile: ""
8383
ignoreNoqa: false
8484
noCache: false
85+
concurrency: 4
86+
regoTrace: false
8587
skip:
8688
example/doc:
8789
- rule: "001_002"
@@ -102,6 +104,8 @@ Notes:
102104
- `rules.rulesets` are synchronized into `rules.path` before linting.
103105
- `lint.skip` supports skipping by document path (relative to `modelsource`) and rule number.
104106
- `lint.noCache` disables lint result cache when set to `true`.
107+
- `lint.concurrency` limits how many rules are evaluated in parallel. Lower values reduce peak memory usage for large models.
108+
- `lint.regoTrace` enables OPA tracing for Rego rules. Keep it `false` for normal runs to reduce memory overhead.
105109

106110
---
107111

default.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ lint:
66
xunitReport: ""
77
jsonFile: ""
88
ignoreNoqa: false
9+
noCache: false
10+
concurrency: 4
11+
regoTrace: false
912
skip: {}
1013
cache:
1114
directory: .mendix-cache/mxlint

lint/cache.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"crypto/sha256"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"os"
89
"path/filepath"
910
"strings"
@@ -71,12 +72,18 @@ func getCachePath(cacheKey CacheKey) (string, error) {
7172

7273
// computeFileHash computes SHA256 hash of a file's contents
7374
func computeFileHash(filePath string) (string, error) {
74-
content, err := os.ReadFile(filePath)
75+
file, err := os.Open(filePath)
7576
if err != nil {
7677
return "", err
7778
}
78-
hash := sha256.Sum256(content)
79-
return fmt.Sprintf("%x", hash), nil
79+
defer file.Close()
80+
81+
hasher := sha256.New()
82+
if _, err := io.Copy(hasher, file); err != nil {
83+
return "", err
84+
}
85+
86+
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
8087
}
8188

8289
// createCacheKey creates a cache key from rule and input file paths
@@ -226,4 +233,3 @@ func GetCacheStats() (int, int64, error) {
226233

227234
return fileCount, totalSize, err
228235
}
229-

lint/config.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ type ConfigLintSpec struct {
3838
XunitReport string `yaml:"xunitReport"`
3939
JSONFile string `yaml:"jsonFile"`
4040
IgnoreNoqa *bool `yaml:"ignoreNoqa"`
41+
NoCache *bool `yaml:"noCache"`
42+
Concurrency *int `yaml:"concurrency"`
43+
RegoTrace *bool `yaml:"regoTrace"`
4144
Skip map[string][]ConfigSkipRule `yaml:"skip"`
4245
}
4346

@@ -293,11 +296,14 @@ func mergeConfig(base *Config, overlay *Config) {
293296
if overlay.Lint.IgnoreNoqa != nil {
294297
base.Lint.IgnoreNoqa = overlay.Lint.IgnoreNoqa
295298
}
296-
if strings.TrimSpace(overlay.Cache.Directory) != "" {
297-
base.Cache.Directory = strings.TrimSpace(overlay.Cache.Directory)
299+
if overlay.Lint.NoCache != nil {
300+
base.Lint.NoCache = overlay.Lint.NoCache
298301
}
299-
if overlay.Cache.Enable != nil {
300-
base.Cache.Enable = overlay.Cache.Enable
302+
if overlay.Lint.Concurrency != nil {
303+
base.Lint.Concurrency = overlay.Lint.Concurrency
304+
}
305+
if overlay.Lint.RegoTrace != nil {
306+
base.Lint.RegoTrace = overlay.Lint.RegoTrace
301307
}
302308

303309
if overlay.Serve.Port != nil {

lint/config_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,27 @@ func TestLoadMergedConfig_NormalizesSkipMapKeys(t *testing.T) {
326326
t.Fatalf("unexpected unnormalized skip key present: %#v", cfg.Lint.Skip)
327327
}
328328
}
329+
330+
func TestLoadMergedConfig_LintConcurrencyAndTrace(t *testing.T) {
331+
projectDir := t.TempDir()
332+
setDefaultConfigForTest(t, "")
333+
projectConfig := `lint:
334+
concurrency: 2
335+
regoTrace: true
336+
`
337+
if err := os.WriteFile(filepath.Join(projectDir, "mxlint.yaml"), []byte(projectConfig), 0644); err != nil {
338+
t.Fatalf("failed to write project config: %v", err)
339+
}
340+
341+
cfg, err := LoadMergedConfig(projectDir)
342+
if err != nil {
343+
t.Fatalf("LoadMergedConfig returned error: %v", err)
344+
}
345+
346+
if cfg.Lint.Concurrency == nil || *cfg.Lint.Concurrency != 2 {
347+
t.Fatalf("expected lint.concurrency=2, got %#v", cfg.Lint.Concurrency)
348+
}
349+
if cfg.Lint.RegoTrace == nil || *cfg.Lint.RegoTrace != true {
350+
t.Fatalf("expected lint.regoTrace=true, got %#v", cfg.Lint.RegoTrace)
351+
}
352+
}

lint/lint.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,16 @@ func EvalAllWithResults(rulesPath string, modelSourcePath string, xunitReport st
4848
// Create a mutex to safely print testsuites
4949
var printMutex sync.Mutex
5050

51+
maxConcurrency := effectiveLintConcurrency(len(rules))
52+
sem := make(chan struct{}, maxConcurrency)
53+
5154
// Launch goroutines to evaluate rules in parallel
5255
for i, rule := range rules {
56+
sem <- struct{}{}
5357
wg.Add(1)
5458
go func(index int, r Rule) {
5559
defer wg.Done()
60+
defer func() { <-sem }()
5661

5762
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache)
5863
if err != nil {
@@ -156,11 +161,16 @@ func EvalAll(rulesPath string, modelSourcePath string, xunitReport string, jsonF
156161
// Create a mutex to safely print testsuites
157162
var printMutex sync.Mutex
158163

164+
maxConcurrency := effectiveLintConcurrency(len(rules))
165+
sem := make(chan struct{}, maxConcurrency)
166+
159167
// Launch goroutines to evaluate rules in parallel
160168
for i, rule := range rules {
169+
sem <- struct{}{}
161170
wg.Add(1)
162171
go func(index int, r Rule) {
163172
defer wg.Done()
173+
defer func() { <-sem }()
164174

165175
testsuite, err := evalTestsuite(r, modelSourcePath, ignoreNoqa, useCache)
166176
if err != nil {

lint/lint_rego.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,15 @@ func evalTestcase_Rego(rulePath string, queryString string, inputFilePath string
5151
ctx := context.Background()
5252

5353
startTime := time.Now()
54-
r := rego.New(
54+
regoOptions := []func(*rego.Rego){
5555
rego.Query(queryString),
5656
rego.Module(rulePath, regoContent),
5757
rego.Input(data),
58-
rego.Trace(true),
59-
)
58+
}
59+
if regoTraceEnabled() {
60+
regoOptions = append(regoOptions, rego.Trace(true))
61+
}
62+
r := rego.New(regoOptions...)
6063

6164
rs, err := r.Eval(ctx)
6265
if err != nil {

lint/options.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package lint
2+
3+
import "runtime"
4+
5+
const defaultMaxLintConcurrency = 4
6+
7+
func effectiveLintConcurrency(ruleCount int) int {
8+
if ruleCount <= 0 {
9+
return 1
10+
}
11+
12+
cfg := getConfig()
13+
if cfg != nil && cfg.Lint.Concurrency != nil && *cfg.Lint.Concurrency > 0 {
14+
if *cfg.Lint.Concurrency > ruleCount {
15+
return ruleCount
16+
}
17+
return *cfg.Lint.Concurrency
18+
}
19+
20+
auto := runtime.GOMAXPROCS(0)
21+
if auto < 1 {
22+
auto = 1
23+
}
24+
if auto > defaultMaxLintConcurrency {
25+
auto = defaultMaxLintConcurrency
26+
}
27+
if auto > ruleCount {
28+
auto = ruleCount
29+
}
30+
return auto
31+
}
32+
33+
func regoTraceEnabled() bool {
34+
cfg := getConfig()
35+
return cfg != nil && cfg.Lint.RegoTrace != nil && *cfg.Lint.RegoTrace
36+
}

lint/options_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package lint
2+
3+
import "testing"
4+
5+
func intPtr(v int) *int {
6+
return &v
7+
}
8+
9+
func boolPtr(v bool) *bool {
10+
return &v
11+
}
12+
13+
func TestEffectiveLintConcurrency_DefaultIsBounded(t *testing.T) {
14+
SetConfig(&Config{})
15+
t.Cleanup(func() {
16+
SetConfig(&Config{})
17+
})
18+
19+
value := effectiveLintConcurrency(100)
20+
if value < 1 || value > defaultMaxLintConcurrency {
21+
t.Fatalf("expected default concurrency within [1,%d], got %d", defaultMaxLintConcurrency, value)
22+
}
23+
}
24+
25+
func TestEffectiveLintConcurrency_UsesConfigWhenProvided(t *testing.T) {
26+
SetConfig(&Config{
27+
Lint: ConfigLintSpec{
28+
Concurrency: intPtr(2),
29+
},
30+
})
31+
t.Cleanup(func() {
32+
SetConfig(&Config{})
33+
})
34+
35+
value := effectiveLintConcurrency(10)
36+
if value != 2 {
37+
t.Fatalf("expected configured concurrency 2, got %d", value)
38+
}
39+
}
40+
41+
func TestEffectiveLintConcurrency_CapsToRuleCount(t *testing.T) {
42+
SetConfig(&Config{
43+
Lint: ConfigLintSpec{
44+
Concurrency: intPtr(8),
45+
},
46+
})
47+
t.Cleanup(func() {
48+
SetConfig(&Config{})
49+
})
50+
51+
value := effectiveLintConcurrency(3)
52+
if value != 3 {
53+
t.Fatalf("expected concurrency capped to rule count 3, got %d", value)
54+
}
55+
}
56+
57+
func TestRegoTraceEnabled(t *testing.T) {
58+
SetConfig(&Config{
59+
Lint: ConfigLintSpec{
60+
RegoTrace: boolPtr(true),
61+
},
62+
})
63+
t.Cleanup(func() {
64+
SetConfig(&Config{})
65+
})
66+
67+
if !regoTraceEnabled() {
68+
t.Fatal("expected regoTraceEnabled to return true")
69+
}
70+
}

0 commit comments

Comments
 (0)