Skip to content

Commit 9e88752

Browse files
authored
parser: fix persistent cache staleness and semanticdb_index order dependence (#162)
Two related correctness fixes for -scala_gazelle_cache_file: 1. Cached rules no longer enter the symbol scope at startup. Previously readScalaRuleCacheFile loaded every cached rule's symbols before the walk, so rules deleted from the tree kept providing symbols, kept their semanticdb_index membership, and never aged out of the cache file. The cache now seeds the memo only (SeedScalaRule); a rule's symbols load on its first ParseScalaRule hit, in walk order like a fresh parse, and ScalaRules() returns only rules seen this run so deletions age out of a persistent cache. 2. semanticdb_index membership now traverses Symbol.Conflicts. TrieScope.Put is first-wins per symbol name, so which label holds a duplicated name depends on symbol insertion order; membership computed from scope winners differed between walk-order runs and sorted cache preloads. Including conflict losers makes the generated deps order-independent.
1 parent 97053af commit 9e88752

7 files changed

Lines changed: 291 additions & 7 deletions

File tree

language/scala/cache.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ func (sl *scalaLang) readScalaRuleCacheFile() error {
3434
if err != nil {
3535
return err
3636
}
37-
if err := sl.parser.LoadScalaRule(from, rule); err != nil {
38-
return err
39-
}
37+
sl.parser.SeedScalaRule(from, rule)
4038
}
4139

4240
if debugCache {

pkg/parser/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ go_test(
3939
srcs = [
4040
"assets_test.go",
4141
"exec_test.go",
42+
"memo_parser_test.go",
4243
"scalameta_parser_test.go",
4344
],
4445
data = glob(["testdata/**/*"]),
@@ -47,6 +48,7 @@ go_test(
4748
"//build/stack/gazelle/scala/parse",
4849
"//pkg/bazel",
4950
"//pkg/collections",
51+
"@bazel_gazelle//label",
5052
"@bazel_gazelle//testtools",
5153
"@com_github_google_go_cmp//cmp",
5254
"@com_github_google_go_cmp//cmp/cmpopts",

pkg/parser/memo_parser.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ const debugMemoParser = false
1919
type MemoParser struct {
2020
next Parser
2121
rules map[label.Label]*sppb.Rule
22+
used map[label.Label]bool
2223
}
2324

2425
func NewMemoParser(next Parser) *MemoParser {
2526
return &MemoParser{
2627
next: next,
2728
rules: make(map[label.Label]*sppb.Rule),
29+
used: make(map[label.Label]bool),
2830
}
2931
}
3032

@@ -53,6 +55,15 @@ func (p *MemoParser) ParseScalaRule(kind string, from label.Label, dir string, s
5355
if debugMemoParser {
5456
log.Printf("rule cache hit: %s", from)
5557
}
58+
if !p.used[from] {
59+
p.used[from] = true
60+
// Seeded rules carry no symbols so that rules deleted from the
61+
// tree cannot pollute the resolution scope; load symbols on first
62+
// use, in walk order like a fresh parse.
63+
if err := p.next.LoadScalaRule(from, rule); err != nil {
64+
return nil, err
65+
}
66+
}
5667
return rule, nil
5768
}
5869
if debugMemoParser {
@@ -71,6 +82,7 @@ func (p *MemoParser) ParseScalaRule(kind string, from label.Label, dir string, s
7182
}
7283
rule.Sha256 = sha256
7384
p.rules[from] = rule
85+
p.used[from] = true
7486

7587
if debugMemoParser {
7688
log.Printf("rule cache save: %s (%s)", from, sha256)
@@ -82,14 +94,26 @@ func (p *MemoParser) ParseScalaRule(kind string, from label.Label, dir string, s
8294
// LoadScalaRule loads the given state.
8395
func (p *MemoParser) LoadScalaRule(from label.Label, rule *sppb.Rule) error {
8496
p.rules[from] = rule
97+
p.used[from] = true
8598
return p.next.LoadScalaRule(from, rule)
8699
}
87100

88-
// ScalaRules returns a list of all scala rules sorted by label
101+
// SeedScalaRule primes the memo with a cached rule without loading its
102+
// symbols into scope. Symbols load on the first ParseScalaRule hit, so
103+
// cached rules that no longer exist in the tree never contribute symbols.
104+
func (p *MemoParser) SeedScalaRule(from label.Label, rule *sppb.Rule) {
105+
p.rules[from] = rule
106+
}
107+
108+
// ScalaRules returns the rules used this run (freshly parsed or cache-hit)
109+
// sorted by label. Seeded-but-unused rules are dropped so that deleted
110+
// rules age out of a persistent cache file.
89111
func (p *MemoParser) ScalaRules() []*sppb.Rule {
90112
rules := make([]*sppb.Rule, 0, len(p.rules))
91-
for _, rule := range p.rules {
92-
rules = append(rules, rule)
113+
for from, rule := range p.rules {
114+
if p.used[from] {
115+
rules = append(rules, rule)
116+
}
93117
}
94118
SortRules(rules)
95119
return rules

pkg/parser/memo_parser_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package parser
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/bazelbuild/bazel-gazelle/label"
9+
"github.com/google/go-cmp/cmp"
10+
11+
sppb "github.com/stackb/scala-gazelle/build/stack/gazelle/scala/parse"
12+
)
13+
14+
// fakeParser records LoadScalaRule and ParseScalaRule calls.
15+
type fakeParser struct {
16+
loadCalls []string
17+
parseCalls []string
18+
}
19+
20+
func (f *fakeParser) LoadScalaRule(from label.Label, rule *sppb.Rule) error {
21+
f.loadCalls = append(f.loadCalls, from.String())
22+
return nil
23+
}
24+
25+
func (f *fakeParser) ParseScalaRule(kind string, from label.Label, dir string, srcs ...string) (*sppb.Rule, error) {
26+
f.parseCalls = append(f.parseCalls, from.String())
27+
files := make([]*sppb.File, len(srcs))
28+
for i, src := range srcs {
29+
files[i] = &sppb.File{Filename: src}
30+
}
31+
return &sppb.Rule{
32+
Label: from.String(),
33+
Kind: kind,
34+
Files: files,
35+
}, nil
36+
}
37+
38+
// writeSrc writes a fake scala source file and returns its dir.
39+
func writeSrc(t *testing.T, dir, name, content string) {
40+
t.Helper()
41+
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
42+
t.Fatal(err)
43+
}
44+
}
45+
46+
// parseOnce runs a fresh MemoParser over the given srcs and returns the
47+
// memoized rule (carrying the correct Sha256 for those files on disk).
48+
func parseOnce(t *testing.T, from label.Label, dir string, srcs ...string) *sppb.Rule {
49+
t.Helper()
50+
parser := NewMemoParser(&fakeParser{})
51+
if _, err := parser.ParseScalaRule("scala_library", from, dir, srcs...); err != nil {
52+
t.Fatal(err)
53+
}
54+
rules := parser.ScalaRules()
55+
if len(rules) != 1 {
56+
t.Fatalf("want 1 memoized rule, got %d", len(rules))
57+
}
58+
return rules[0]
59+
}
60+
61+
func TestMemoParserSeedDoesNotLoadSymbols(t *testing.T) {
62+
dir := t.TempDir()
63+
writeSrc(t, dir, "a.scala", "class A")
64+
from := label.New("", "pkg", "a")
65+
cached := parseOnce(t, from, dir, "a.scala")
66+
67+
next := &fakeParser{}
68+
parser := NewMemoParser(next)
69+
parser.SeedScalaRule(from, cached)
70+
71+
if len(next.loadCalls) != 0 {
72+
t.Fatalf("seeding must not load symbols, got LoadScalaRule calls: %v", next.loadCalls)
73+
}
74+
}
75+
76+
func TestMemoParserHitLoadsSymbolsExactlyOnce(t *testing.T) {
77+
dir := t.TempDir()
78+
writeSrc(t, dir, "a.scala", "class A")
79+
from := label.New("", "pkg", "a")
80+
cached := parseOnce(t, from, dir, "a.scala")
81+
82+
next := &fakeParser{}
83+
parser := NewMemoParser(next)
84+
parser.SeedScalaRule(from, cached)
85+
86+
for i := 0; i < 2; i++ {
87+
got, err := parser.ParseScalaRule("scala_library", from, dir, "a.scala")
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
if diff := cmp.Diff(cached.Label, got.Label); diff != "" {
92+
t.Fatalf("rule label (-want +got):\n%s", diff)
93+
}
94+
}
95+
96+
if len(next.parseCalls) != 0 {
97+
t.Fatalf("sha256 match must not re-parse, got ParseScalaRule calls: %v", next.parseCalls)
98+
}
99+
if diff := cmp.Diff([]string{"//pkg:a"}, next.loadCalls); diff != "" {
100+
t.Fatalf("symbols must load exactly once, on first hit (-want +got):\n%s", diff)
101+
}
102+
}
103+
104+
func TestMemoParserStaleSeedReparses(t *testing.T) {
105+
dir := t.TempDir()
106+
writeSrc(t, dir, "a.scala", "class A")
107+
from := label.New("", "pkg", "a")
108+
cached := parseOnce(t, from, dir, "a.scala")
109+
110+
// file content changes after the rule was cached
111+
writeSrc(t, dir, "a.scala", "class A { def b = 1 }")
112+
113+
next := &fakeParser{}
114+
parser := NewMemoParser(next)
115+
parser.SeedScalaRule(from, cached)
116+
117+
if _, err := parser.ParseScalaRule("scala_library", from, dir, "a.scala"); err != nil {
118+
t.Fatal(err)
119+
}
120+
121+
if diff := cmp.Diff([]string{"//pkg:a"}, next.parseCalls); diff != "" {
122+
t.Fatalf("sha256 mismatch must re-parse (-want +got):\n%s", diff)
123+
}
124+
}
125+
126+
func TestMemoParserScalaRulesOmitsUnusedSeeds(t *testing.T) {
127+
dir := t.TempDir()
128+
writeSrc(t, dir, "a.scala", "class A")
129+
live := label.New("", "pkg", "live")
130+
deleted := label.New("", "pkg", "deleted")
131+
cached := parseOnce(t, live, dir, "a.scala")
132+
133+
next := &fakeParser{}
134+
parser := NewMemoParser(next)
135+
parser.SeedScalaRule(live, cached)
136+
// a rule that was cached on a previous run but no longer exists in the
137+
// tree: it is seeded but never visited by the walk
138+
parser.SeedScalaRule(deleted, &sppb.Rule{Label: deleted.String(), Kind: "scala_library"})
139+
140+
if _, err := parser.ParseScalaRule("scala_library", live, dir, "a.scala"); err != nil {
141+
t.Fatal(err)
142+
}
143+
144+
var got []string
145+
for _, r := range parser.ScalaRules() {
146+
got = append(got, r.Label)
147+
}
148+
if diff := cmp.Diff([]string{"//pkg:live"}, got); diff != "" {
149+
t.Fatalf("ScalaRules must omit seeded-but-unused rules (-want +got):\n%s", diff)
150+
}
151+
}
152+
153+
func TestMemoParserLoadScalaRuleMarksUsed(t *testing.T) {
154+
from := label.New("", "pkg", "a")
155+
next := &fakeParser{}
156+
parser := NewMemoParser(next)
157+
158+
if err := parser.LoadScalaRule(from, &sppb.Rule{Label: from.String(), Kind: "scala_library"}); err != nil {
159+
t.Fatal(err)
160+
}
161+
162+
if diff := cmp.Diff([]string{"//pkg:a"}, next.loadCalls); diff != "" {
163+
t.Fatalf("LoadScalaRule must delegate (-want +got):\n%s", diff)
164+
}
165+
if len(parser.ScalaRules()) != 1 {
166+
t.Fatal("explicitly loaded rules must be retained by ScalaRules")
167+
}
168+
}

pkg/semanticdb/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ go_library(
2929
go_test(
3030
name = "semanticdb_test",
3131
srcs = [
32+
"semanticdb_index_test.go",
3233
"semanticdb_test.go",
3334
"visitor_test.go",
3435
],
@@ -37,7 +38,11 @@ go_test(
3738
deps = [
3839
"//build/stack/gazelle/scala/parse",
3940
"//pkg/protobuf",
41+
"//pkg/resolver",
42+
"//pkg/scalarule",
4043
"//scala/meta/semanticdb",
44+
"@bazel_gazelle//label",
45+
"@bazel_gazelle//rule",
4146
"@com_github_google_go_cmp//cmp",
4247
"@com_github_google_go_cmp//cmp/cmpopts",
4348
],

pkg/semanticdb/semanticdb_index.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,22 @@ func (s *semanticdbIndexRule) Resolve(rctx *scalarule.ResolveContext, importsRaw
111111
kinds[kind] = true
112112
}
113113

114+
// Collect symbols by label, including conflict losers: TrieScope.Put is
115+
// first-wins, so which label holds a duplicated symbol name depends on
116+
// symbol insertion order (package walk order vs. sorted cache preload).
117+
// Index membership must be order-independent, so traverse Conflicts too.
114118
symbols := make(map[label.Label]*resolver.Symbol)
119+
var addSymbol func(sym *resolver.Symbol)
120+
addSymbol = func(sym *resolver.Symbol) {
121+
if _, ok := symbols[sym.Label]; !ok {
122+
symbols[sym.Label] = sym
123+
}
124+
for _, conflict := range sym.Conflicts {
125+
addSymbol(conflict)
126+
}
127+
}
115128
for _, sym := range GetGlobalScope().GetSymbols("") {
116-
symbols[sym.Label] = sym
129+
addSymbol(sym)
117130
}
118131

119132
deps := make([]string, 0, len(symbols))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package semanticdb
2+
3+
import (
4+
"testing"
5+
6+
"github.com/bazelbuild/bazel-gazelle/label"
7+
"github.com/bazelbuild/bazel-gazelle/rule"
8+
"github.com/google/go-cmp/cmp"
9+
10+
sppb "github.com/stackb/scala-gazelle/build/stack/gazelle/scala/parse"
11+
"github.com/stackb/scala-gazelle/pkg/resolver"
12+
"github.com/stackb/scala-gazelle/pkg/scalarule"
13+
)
14+
15+
// TestSemanticdbIndexRuleResolveDeps asserts that index membership is
16+
// independent of symbol insertion order: TrieScope.Put is first-wins for a
17+
// given symbol name, so a label whose every symbol name is already claimed
18+
// by another label only appears in the scope as a Conflict. Membership must
19+
// include conflict losers, otherwise the generated deps differ between runs
20+
// that insert symbols in package-walk order and runs that preload them from
21+
// a cache in sorted order.
22+
func TestSemanticdbIndexRuleResolveDeps(t *testing.T) {
23+
for name, tc := range map[string]struct {
24+
kinds []string
25+
symbols []*resolver.Symbol
26+
want []string
27+
}{
28+
"conflict loser is a member": {
29+
kinds: []string{"scala_library"},
30+
symbols: []*resolver.Symbol{
31+
resolver.NewSymbol(sppb.ImportType_CLASS, "com.foo.Dup", "scala_library", label.New("", "a", "a")),
32+
resolver.NewSymbol(sppb.ImportType_CLASS, "com.foo.Dup", "scala_library", label.New("", "b", "b")),
33+
},
34+
want: []string{"//a:a_semanticdb", "//b:b_semanticdb"},
35+
},
36+
"kind filter still applies to conflict losers": {
37+
kinds: []string{"scala_library"},
38+
symbols: []*resolver.Symbol{
39+
resolver.NewSymbol(sppb.ImportType_CLASS, "com.foo.Dup", "scala_library", label.New("", "a", "a")),
40+
resolver.NewSymbol(sppb.ImportType_CLASS, "com.foo.Dup", "scala_binary", label.New("", "b", "b")),
41+
},
42+
want: []string{"//a:a_semanticdb"},
43+
},
44+
"external labels are excluded": {
45+
kinds: []string{"scala_library"},
46+
symbols: []*resolver.Symbol{
47+
resolver.NewSymbol(sppb.ImportType_CLASS, "com.foo.Dup", "scala_library", label.New("", "a", "a")),
48+
resolver.NewSymbol(sppb.ImportType_CLASS, "com.foo.Dup", "scala_library", label.New("maven", "", "jar")),
49+
},
50+
want: []string{"//a:a_semanticdb"},
51+
},
52+
} {
53+
t.Run(name, func(t *testing.T) {
54+
scope := resolver.NewTrieScope()
55+
for _, sym := range tc.symbols {
56+
if err := scope.PutSymbol(sym); err != nil {
57+
t.Fatal(err)
58+
}
59+
}
60+
SetGlobalScope(scope)
61+
62+
r := rule.NewRule(SemanticdbIndexRuleKind, "semanticdb_index")
63+
r.SetAttr("kinds", tc.kinds)
64+
65+
provider := NewSemanticdbIndexRuleProvider(SemanticdbIndexRuleLoad, SemanticdbIndexRuleKind)
66+
ruleProvider := provider.ResolveRule(nil, nil, r)
67+
ruleProvider.Resolve(&scalarule.ResolveContext{Rule: r}, nil)
68+
69+
if diff := cmp.Diff(tc.want, r.AttrStrings("deps")); diff != "" {
70+
t.Fatalf("deps (-want +got):\n%s", diff)
71+
}
72+
})
73+
}
74+
}

0 commit comments

Comments
 (0)