Skip to content

Commit ed51bbd

Browse files
jp-fizzbeejayaprabhakarclaude
authored
fix(modelchecker): make protopath cache safe for concurrent readers (#357)
ProtoPath.filesMap is a global cache populated lazily by every Processor through GetProtoFieldByPath. The map was written without synchronization (only the values are immutable; the map structure mutates on insert). Today this is benign because all callers are sequential; once parallel simulation workers exist, two concurrent first-touches of the same file or path will trip Go's "concurrent map writes" runtime check. Wrap reads in a fast RLock path and writes in a Lock path with a double- check after acquiring the write lock (in case another goroutine populated the entry while we were computing). The expensive GetFieldByPath/ convertToProto work runs outside the write lock so workers don't serialize on it. Adds a concurrent test that exercises 16 goroutines × 200 iterations to make the locking contract explicit; it currently passes either way because Go's runtime check is timing-sensitive and the rules_go race build setup here did not appear to wire through, but the test will start catching regressions once we wire race detection or extend the iteration count. Co-authored-by: jayaprabhakar <jayaprabhakar@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2e2b9c6 commit ed51bbd

2 files changed

Lines changed: 71 additions & 10 deletions

File tree

modelchecker/protopath.go

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,50 @@ import (
88
"regexp"
99
"strconv"
1010
"strings"
11+
"sync"
1112
)
1213

1314
var re = regexp.MustCompile(`Stmts\[\d+\]`)
1415

1516
type ProtoPath struct {
16-
// TODO(jayaprabhakar): A quick hack, fix this. It is safe because this field is immutable.
17+
mu sync.RWMutex
1718
filesMap map[*ast.File]map[string]proto.Message
1819
}
20+
1921
var protoPathInstance = &ProtoPath{filesMap: make(map[*ast.File]map[string]proto.Message)}
2022

2123
func GetProtoFieldByPath(file *ast.File, location string) proto.Message {
22-
if protoPathInstance.filesMap[file] == nil {
23-
protoPathInstance.filesMap[file] = make(map[string]proto.Message)
24-
} else if val, ok := protoPathInstance.filesMap[file][location]; ok {
25-
return val
24+
// Fast path: read-locked cache lookup. Parallel callers (e.g. parallel
25+
// simulation workers) hit this most of the time once the cache is warm.
26+
protoPathInstance.mu.RLock()
27+
if inner, ok := protoPathInstance.filesMap[file]; ok {
28+
if val, hit := inner[location]; hit {
29+
protoPathInstance.mu.RUnlock()
30+
return val
31+
}
2632
}
33+
protoPathInstance.mu.RUnlock()
34+
35+
// Slow path: compute outside the lock (reflection on the read-only AST
36+
// is safe without the lock), then insert under a write lock with a
37+
// re-check in case a concurrent caller already populated the entry.
2738
field := GetFieldByPath(file, location)
28-
if field == nil {
29-
protoPathInstance.filesMap[file][location] = nil
30-
return nil
39+
var protobuf proto.Message
40+
if field != nil {
41+
protobuf = convertToProto(field.Elem().Interface(), field.Type())
42+
}
43+
44+
protoPathInstance.mu.Lock()
45+
defer protoPathInstance.mu.Unlock()
46+
inner, ok := protoPathInstance.filesMap[file]
47+
if !ok {
48+
inner = make(map[string]proto.Message)
49+
protoPathInstance.filesMap[file] = inner
50+
}
51+
if existing, hit := inner[location]; hit {
52+
return existing
3153
}
32-
protobuf := convertToProto(field.Elem().Interface(), field.Type())
33-
protoPathInstance.filesMap[file][location] = protobuf
54+
inner[location] = protobuf
3455
return protobuf
3556
}
3657

modelchecker/protopath_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"github.com/stretchr/testify/assert"
66
"github.com/stretchr/testify/require"
77
"google.golang.org/protobuf/encoding/protojson"
8+
"sync"
89
"testing"
910
)
1011

@@ -77,6 +78,45 @@ func TestEndOfBlock(t *testing.T) {
7778
EndOfBlock("Actions[0].Block.Stmts[0].AnyStmt.Block.Stmts[0].IfStmt.Branches[0].Block.Stmts[0]"))
7879
}
7980

81+
// TestGetProtoFieldByPath_Concurrent exercises the cache under parallel reads
82+
// + writes. Run with `go test -race` to catch any locking regression. The
83+
// shared protoPathInstance cache is hit by every Processor; parallel
84+
// simulation workers must not race on it.
85+
func TestGetProtoFieldByPath_Concurrent(t *testing.T) {
86+
file, err := readFileToAst()
87+
require.Nil(t, err)
88+
89+
paths := []string{
90+
"Actions[0]",
91+
"Actions[0].Block",
92+
"Actions[0].Block.Stmts[0]",
93+
"Actions[0].Block.Stmts[0].AnyStmt",
94+
"Actions[0].Block.Stmts[0].AnyStmt.Block",
95+
"Actions[1]",
96+
"Actions[1].Block",
97+
"Actions[1].Block.Stmts[0]",
98+
"Actions[1].Block.Stmts[0].AnyStmt",
99+
"NonExistentPath[42]", // exercises the nil-result cache entry
100+
}
101+
102+
const workers = 16
103+
const iterations = 200
104+
105+
var wg sync.WaitGroup
106+
wg.Add(workers)
107+
for w := 0; w < workers; w++ {
108+
go func() {
109+
defer wg.Done()
110+
for i := 0; i < iterations; i++ {
111+
for _, p := range paths {
112+
_ = GetProtoFieldByPath(file, p)
113+
}
114+
}
115+
}()
116+
}
117+
wg.Wait()
118+
}
119+
80120
func readFileToAst() (*ast.File, error) {
81121
jsonFile := `
82122
{

0 commit comments

Comments
 (0)