Skip to content

Commit b5374ec

Browse files
authored
Refactor how diagnostics are pushed through incremental; add queries.AST (#404)
The `NonFatal` functionality of the incremental package has proven to be a poor abstraction. What I essentially wanted was a way to have queries generate diagnostics, which I could then deduplicate and collect in the end. Collecting diagnostics from dependencies is incorrect, because A -> B, A -> C, B -> D, C -> D would mean diagnostics for D contain the diagnostics from A twice. The rough idea was to instead stash reports in the `NonFatal` area, which required the somewhat unnatural-feeling `report.AsError` type. But this is unnecessary ceremony: all that `NonFatal` will ever be used for is for stashing reports, so the incremental framework should Just Do That. It's not a general library, after all. This PR replaces `Task.NonFatal` with `Task.Report`, which is a report included with each task. When `Run` completes, it collects the set of all queries that were computed (possibly from cache) and merges their reports, and sorts it to eliminate non-determinism. It is not immediately clear to me if having tasks return `(v T, fatal error)` is still useful. Perhaps `(v T, ok bool)` may be more appropriate, since that error should be logged as a diagnostic and will probably just get thrown away.
1 parent 1618dd6 commit b5374ec

File tree

11 files changed

+134
-116
lines changed

11 files changed

+134
-116
lines changed

experimental/incremental/executor.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@ import (
2323
"sync/atomic"
2424

2525
"golang.org/x/sync/semaphore"
26+
27+
"github.com/bufbuild/protocompile/experimental/report"
2628
)
2729

2830
// Executor is a caching executor for incremental queries.
2931
//
3032
// See [New], [Run], and [Invalidate].
3133
type Executor struct {
32-
dirty sync.RWMutex
34+
reportOptions report.Options
35+
dirty sync.RWMutex
3336

3437
// TODO: Evaluate alternatives. sync.Map is pretty bad at having predictable
3538
// performance, and we may want to add eviction to keep memoization costs
@@ -64,6 +67,12 @@ func WithParallelism(n int64) ExecutorOption {
6467
return func(e *Executor) { e.sema = semaphore.NewWeighted(n) }
6568
}
6669

70+
// WithReportOptions sets the report options for reports generated by this
71+
// executor.
72+
func WithReportOptions(options report.Options) ExecutorOption {
73+
return func(e *Executor) { e.reportOptions = options }
74+
}
75+
6776
// Keys returns a snapshot of the keys of which queries are present (and
6877
// memoized) in an Executor.
6978
//
@@ -100,7 +109,7 @@ var runExecutorKey byte
100109
//
101110
// Note: this function really wants to be a method of [Executor], but it isn't
102111
// because it's generic.
103-
func Run[T any](ctx context.Context, e *Executor, queries ...Query[T]) (results []Result[T], expired error) {
112+
func Run[T any](ctx context.Context, e *Executor, queries ...Query[T]) ([]Result[T], *report.Report, error) {
104113
e.dirty.RLock()
105114
defer e.dirty.RUnlock()
106115

@@ -119,7 +128,7 @@ func Run[T any](ctx context.Context, e *Executor, queries ...Query[T]) (results
119128
// Need to acquire a hold on the global semaphore to represent the root
120129
// task we're about to execute.
121130
if e.sema.Acquire(ctx, 1) != nil {
122-
return nil, context.Cause(ctx)
131+
return nil, nil, context.Cause(ctx)
123132
}
124133
defer e.sema.Release(1)
125134

@@ -132,22 +141,32 @@ func Run[T any](ctx context.Context, e *Executor, queries ...Query[T]) (results
132141
runID: generation,
133142
}
134143

135-
results, expired = Resolve(root, queries...)
144+
results, expired := Resolve(root, queries...)
136145
if expired != nil {
137-
return nil, expired
146+
return nil, nil, expired
138147
}
139148

140-
// Now, for each result, we need to walk their dependencies and collect
141-
// their dependencies' non-fatal errors.
142-
for i, query := range queries {
149+
// Record all diagnostics generates by the queries.
150+
report := &report.Report{Options: e.reportOptions}
151+
dedup := make(map[*task]struct{})
152+
record := func(t *task) {
153+
if _, ok := dedup[t]; ok {
154+
return
155+
}
156+
157+
dedup[t] = struct{}{}
158+
report.Diagnostics = append(report.Diagnostics, t.report.Diagnostics...)
159+
}
160+
for _, query := range queries {
143161
task := e.getTask(query.Key())
162+
record(task) // NOTE: task.deps does not contain task.
144163
for dep := range task.deps {
145-
r := &results[i]
146-
r.NonFatal = append(r.NonFatal, dep.result.Load().NonFatal...)
164+
record(dep)
147165
}
148166
}
167+
report.Sort()
149168

150-
return results, nil
169+
return results, report, nil
151170
}
152171

153172
// Evict marks query keys as invalid, requiring those queries, and their
@@ -204,6 +223,6 @@ func (e *Executor) getTask(key any) *task {
204223
return t.(*task) //nolint:errcheck
205224
}
206225

207-
t, _ := e.tasks.LoadOrStore(key, new(task))
226+
t, _ := e.tasks.LoadOrStore(key, &task{report: report.Report{Options: e.reportOptions}})
208227
return t.(*task) //nolint:errcheck
209228
}

experimental/incremental/executor_test.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func (i ParseInt) Execute(t incremental.Task) (int, error) {
5959

6060
v, err := strconv.Atoi(i.Input)
6161
if err != nil {
62-
t.NonFatal(err)
62+
t.Report().Errorf("%s", err)
6363
}
6464
if v < 0 {
6565
return 0, fmt.Errorf("negative value: %v", v)
@@ -129,10 +129,10 @@ func TestSum(t *testing.T) {
129129
incremental.WithParallelism(4),
130130
)
131131

132-
result, err := incremental.Run(ctx, exec, Sum{"1,2,2,3,4"})
132+
result, report, err := incremental.Run(ctx, exec, Sum{"1,2,2,3,4"})
133133
require.NoError(t, err)
134134
assert.Equal(12, result[0].Value)
135-
assert.Empty(result[0].NonFatal)
135+
assert.Empty(report.Diagnostics)
136136
assert.Equal([]string{
137137
`incremental_test.ParseInt{Input:"1"}`,
138138
`incremental_test.ParseInt{Input:"2"}`,
@@ -142,10 +142,10 @@ func TestSum(t *testing.T) {
142142
`incremental_test.Sum{Input:"1,2,2,3,4"}`,
143143
}, exec.Keys())
144144

145-
result, err = incremental.Run(ctx, exec, Sum{"1,2,2,oops,4"})
145+
result, report, err = incremental.Run(ctx, exec, Sum{"1,2,2,oops,4"})
146146
require.NoError(t, err)
147147
assert.Equal(9, result[0].Value)
148-
assert.Len(result[0].NonFatal, 1)
148+
assert.Len(report.Diagnostics, 1)
149149
assert.Equal([]string{
150150
`incremental_test.ParseInt{Input:"1"}`,
151151
`incremental_test.ParseInt{Input:"2"}`,
@@ -166,10 +166,10 @@ func TestSum(t *testing.T) {
166166
`incremental_test.Root{}`,
167167
}, exec.Keys())
168168

169-
result, err = incremental.Run(ctx, exec, Sum{"1,2,2,3,4"})
169+
result, report, err = incremental.Run(ctx, exec, Sum{"1,2,2,3,4"})
170170
require.NoError(t, err)
171171
assert.Equal(12, result[0].Value)
172-
assert.Empty(result[0].NonFatal)
172+
assert.Empty(report.Diagnostics)
173173
assert.Equal([]string{
174174
`incremental_test.ParseInt{Input:"1"}`,
175175
`incremental_test.ParseInt{Input:"2"}`,
@@ -190,7 +190,7 @@ func TestFatal(t *testing.T) {
190190
incremental.WithParallelism(4),
191191
)
192192

193-
result, err := incremental.Run(ctx, exec, Sum{"1,2,-3,-4"})
193+
result, _, err := incremental.Run(ctx, exec, Sum{"1,2,-3,-4"})
194194
require.NoError(t, err)
195195
// NOTE: This error is deterministic, because it's chosen by Sum.Execute.
196196
assert.Equal("negative value: -3", result[0].Fatal.Error())
@@ -213,7 +213,7 @@ func TestCyclic(t *testing.T) {
213213
incremental.WithParallelism(4),
214214
)
215215

216-
result, err := incremental.Run(ctx, exec, Cyclic{Mod: 5, Step: 3})
216+
result, _, err := incremental.Run(ctx, exec, Cyclic{Mod: 5, Step: 3})
217217
require.NoError(t, err)
218218
assert.Equal(
219219
`cycle detected: `+
@@ -250,7 +250,7 @@ func TestUnchanged(t *testing.T) {
250250

251251
for i := 0; i < runs; i++ {
252252
exec.Evict(ParseInt{"42"})
253-
results, _ := incremental.Run(ctx, exec, queries...)
253+
results, _, _ := incremental.Run(ctx, exec, queries...)
254254
for j, r := range results[1:] {
255255
// All calls after an eviction should return true for Changed.
256256
assert.True(r.Changed, "%d", j)
@@ -270,7 +270,7 @@ func TestUnchanged(t *testing.T) {
270270
barrier.Wait() // Ensure all goroutines start together.
271271
defer wg.Done()
272272

273-
results, _ := incremental.Run(ctx, exec, queries...)
273+
results, _, _ := incremental.Run(ctx, exec, queries...)
274274
for j, r := range results {
275275
// We don't know who the winning g that gets to do the
276276
// computation will be be, so just require that all of the
@@ -289,7 +289,7 @@ func TestUnchanged(t *testing.T) {
289289
// Exactly one of the gs should have seen a change.
290290
assert.Equal(int32(1), changed.Load())
291291

292-
results, _ = incremental.Run(ctx, exec, queries...)
292+
results, _, _ = incremental.Run(ctx, exec, queries...)
293293
for j, r := range results[1:] {
294294
// All calls after computation should return false for Changed.
295295
assert.False(r.Changed, "%d", j)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright 2020-2025 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package queries
16+
17+
import (
18+
"github.com/bufbuild/protocompile/experimental/ast"
19+
"github.com/bufbuild/protocompile/experimental/incremental"
20+
"github.com/bufbuild/protocompile/experimental/parser"
21+
"github.com/bufbuild/protocompile/experimental/source"
22+
)
23+
24+
// AST is an [incremental.Query] for the contents of a file as provided
25+
// by a [source.Opener].
26+
//
27+
// AST queries with different Openers are considered distinct.
28+
type AST struct {
29+
source.Opener // Must be comparable.
30+
Path string
31+
}
32+
33+
var _ incremental.Query[ast.File] = AST{}
34+
35+
// Key implements [incremental.Query].
36+
//
37+
// The key for a Contents query is the query itself. This means that a single
38+
// [incremental.Executor] can host Contents queries for multiple Openers. It
39+
// also means that the Openers must all be comparable. As the [Opener]
40+
// documentation states, implementations should take a pointer receiver so that
41+
// comparison uses object identity.
42+
func (a AST) Key() any {
43+
return a
44+
}
45+
46+
// Execute implements [incremental.Query].
47+
func (a AST) Execute(t incremental.Task) (ast.File, error) {
48+
t.Report().Options.Stage += stageAST
49+
50+
r, err := incremental.Resolve(t, File(a))
51+
if err != nil {
52+
return ast.File{}, err
53+
}
54+
if r[0].Fatal != nil {
55+
return ast.File{}, r[0].Fatal
56+
}
57+
58+
file, _ := parser.Parse(r[0].Value, t.Report())
59+
return file, nil
60+
}

experimental/incremental/queries/file.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,21 @@ var _ incremental.Query[*report.File] = File{}
3838
// also means that the Openers must all be comparable. As the [Opener]
3939
// documentation states, implementations should take a pointer receiver so that
4040
// comparison uses object identity.
41-
func (t File) Key() any {
42-
return t
41+
func (f File) Key() any {
42+
return f
4343
}
4444

4545
// Execute implements [incremental.Query].
46-
func (t File) Execute(incremental.Task) (*report.File, error) {
47-
text, err := t.Open(t.Path)
46+
func (f File) Execute(t incremental.Task) (*report.File, error) {
47+
t.Report().Options.Stage += stageFile
48+
49+
text, err := f.Open(f.Path)
4850
if err != nil {
49-
r := newReport(stageFile)
50-
r.Report.Error(&report.ErrInFile{Err: err, Path: t.Path})
51-
return nil, r
51+
t.Report().Errorf("%v", err).Apply(
52+
report.InFile(f.Path),
53+
)
54+
return nil, err
5255
}
5356

54-
return report.NewFile(t.Path, text), nil
57+
return report.NewFile(f.Path, text), nil
5558
}

experimental/incremental/queries/doc.go renamed to experimental/incremental/queries/queries.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@
1616
// of Protocompile. It is separate from package incremental itself because it is
1717
// Protocompile-specific.
1818
package queries
19+
20+
// Values for [report.Report].SortOrder, which determine how diagnostics
21+
// generated across parts of the compiler are sorted.
22+
const (
23+
stageFile int = iota * 10
24+
stageAST
25+
)

experimental/incremental/queries/report.go

Lines changed: 0 additions & 30 deletions
This file was deleted.

experimental/incremental/task.go

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"sync"
2222
"sync/atomic"
2323

24+
"github.com/bufbuild/protocompile/experimental/report"
2425
"github.com/bufbuild/protocompile/internal/iter"
2526
)
2627

@@ -50,14 +51,10 @@ func (t *Task) Context() context.Context {
5051
return t.ctx
5152
}
5253

53-
// Error adds errors to the current query, which will be propagated to all
54-
// queries which depend on it.
55-
//
56-
// This will not cause the query to fail; instead, [Query].Execute should
57-
// return false for the ok value to signal failure.
58-
func (t *Task) NonFatal(errs ...error) {
54+
// Report returns the diagnostic report for this task.
55+
func (t *Task) Report() *report.Report {
5956
t.checkDone()
60-
t.result.NonFatal = append(t.result.NonFatal, errs...)
57+
return &t.task.report
6158
}
6259

6360
// Resolve executes a set of queries in parallel. Each query is run on its own
@@ -111,7 +108,6 @@ func Resolve[T any](caller Task, queries ...Query[T]) (results []Result[T], expi
111108
results[i].Value = r.Value.(T) //nolint:errcheck
112109
}
113110

114-
results[i].NonFatal = r.NonFatal
115111
results[i].Fatal = r.Fatal
116112
results[i].Changed = r.runID == caller.runID
117113
}
@@ -182,15 +178,15 @@ type task struct {
182178
// If this task has not been started yet, this is nil.
183179
// Otherwise, if it is complete, result.done will be closed.
184180
result atomic.Pointer[result]
181+
report report.Report
185182
}
186183

187184
// Result is the Result of executing a query on an [Executor], either via
188185
// [Run] or [Resolve].
189186
type Result[T any] struct {
190187
Value T // Value is unspecified if Fatal is non-nil.
191188

192-
NonFatal []error
193-
Fatal error
189+
Fatal error
194190

195191
// Set if this result has possibly changed since the last time [Run] call in
196192
// which this query was computed.

experimental/parser/lex_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func TestLexer(t *testing.T) {
4343
corpus.Run(t, func(t *testing.T, path, text string, outputs []string) {
4444
text = unescapeTestCase(text)
4545

46-
errs := &report.Report{Tracing: 10}
46+
errs := &report.Report{Options: report.Options{Tracing: 10}}
4747
ctx := ast.NewContext(report.NewFile(path, text))
4848
lex(ctx, errs)
4949

experimental/parser/parse_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func TestParse(t *testing.T) {
4242
}
4343

4444
corpus.Run(t, func(t *testing.T, path, text string, outputs []string) {
45-
errs := &report.Report{Tracing: 10}
45+
errs := &report.Report{Options: report.Options{Tracing: 10}}
4646
file, _ := Parse(report.NewFile(path, text), errs)
4747

4848
errs.Sort()

0 commit comments

Comments
 (0)