From c735163cd7f05430fbc979ee7eacc1099028972d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:05:50 +0000 Subject: [PATCH 1/2] fix(workspace): settle gathers on first read instead of inside the edit Co-Authored-By: jason.han --- .../lazy-workspace-regather.fixed.md | 1 + .../workspace/model/lazy_regather_test.go | 40 +++++++++++++ internal/workspace/model/refindex.go | 1 + internal/workspace/model/workspace.go | 58 ++++++++++++++----- 4 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 changes/unreleased/lazy-workspace-regather.fixed.md create mode 100644 internal/workspace/model/lazy_regather_test.go diff --git a/changes/unreleased/lazy-workspace-regather.fixed.md b/changes/unreleased/lazy-workspace-regather.fixed.md new file mode 100644 index 000000000..86d73ced2 --- /dev/null +++ b/changes/unreleased/lazy-workspace-regather.fixed.md @@ -0,0 +1 @@ +- Editing a document no longer recomputes workspace-wide gathers inside the edit; they are recomputed on the first diagnostics or query after it, so an edit itself is as cheap as before those gathers existed. diff --git a/internal/workspace/model/lazy_regather_test.go b/internal/workspace/model/lazy_regather_test.go new file mode 100644 index 000000000..9b727947a --- /dev/null +++ b/internal/workspace/model/lazy_regather_test.go @@ -0,0 +1,40 @@ +package model + +import ( + "reflect" + "testing" +) + +// TestWorkspacePendingGathersSettleOnRead: an edit queues its gathers instead of +// replaying them, so several edits coalesce into one settle the next read runs — +// and what that read reports is what a fresh analysis of the same text says. +func TestWorkspacePendingGathersSettleOnRead(t *testing.T) { + const notDerived = "oosem-requirement-not-derived" + sat := []byte("package S { private import OOSEM::*; #systemRequirement requirement sys; }") + + ws := NewWorkspace() + ws.Open("hub.sysml", []byte("package M { private import OOSEM::*; #stakeholderNeed requirement need; }"), 1) + ws.Open("sat.sysml", sat, 1) + ws.Diagnostics("sat.sysml") + + // Two edits with no read between them queue together: the hub drops its + // stakeholder need for a mission requirement, the satellite's verdict + // follows it when the next diagnostics settle the pending regathers. + ws.Update("hub.sysml", []byte("package M { private import OOSEM::*; #missionRequirement requirement mission; }"), 2) + ws.Update("sat.sysml", []byte("package S { private import OOSEM::*; #systemRequirement requirement sys; requirement extra; }"), 2) + gotHub := codesOf(ws.Diagnostics("hub.sysml")) + gotSat := codesOf(ws.Diagnostics("sat.sysml")) + if gotSat[notDerived] != 1 { + t.Fatalf("sat.sysml under a mission requirement added while pending: %v, want one %s", gotSat, notDerived) + } + + fresh := NewWorkspace() + fresh.Open("hub.sysml", ws.Document("hub.sysml").Content, 1) + fresh.Open("sat.sysml", ws.Document("sat.sysml").Content, 1) + if want := codesOf(fresh.Diagnostics("hub.sysml")); !reflect.DeepEqual(gotHub, want) { + t.Errorf("hub.sysml: incremental %v, fresh %v", gotHub, want) + } + if want := codesOf(fresh.Diagnostics("sat.sysml")); !reflect.DeepEqual(gotSat, want) { + t.Errorf("sat.sysml: incremental %v, fresh %v", gotSat, want) + } +} diff --git a/internal/workspace/model/refindex.go b/internal/workspace/model/refindex.go index e93f9a8a0..1204fccc8 100644 --- a/internal/workspace/model/refindex.go +++ b/internal/workspace/model/refindex.go @@ -79,6 +79,7 @@ func (x *refIndex) add(doc *Document, ref resolve.Reference, part int, element, // document, in document then position order, building the table of each // document a change has dropped. Caller holds the write lock. func (w *Workspace) referencesLocked(key symbols.ElementKey) []refEntry { + w.settleGathersLocked() if w.refs == nil { w.refs = newRefIndex() } diff --git a/internal/workspace/model/workspace.go b/internal/workspace/model/workspace.go index 9c799864e..10e7d3299 100644 --- a/internal/workspace/model/workspace.go +++ b/internal/workspace/model/workspace.go @@ -49,6 +49,11 @@ type Workspace struct { resolver *resolve.Resolver model *semantics.Model gathers *passes.Gathers + // regatherPending queues the documents whose gathers an invalidate dropped, + // replayed on the next read (see settleGathersLocked); settling bars its + // re-entry, a settle itself calling back through contextLocked. + regatherPending map[string]bool + settling bool // analysis is the options every document of this workspace is analyzed under, // so one session asks one question of all its files. analysis passes.Options @@ -350,20 +355,35 @@ func (w *Workspace) invalidateLocked(name string) { dropped := w.resolver.Invalidate(ch) delete(w.diagCache, name) w.refs.drop(name) - // The gathers the drop took go again, and what they now say differently - // drops the judgments that read it, until nothing more moves. - regather := ch.Docs - for { - for _, doc := range dropped { - delete(w.diagCache, doc) - w.refs.drop(doc) - if gathered, ok := resolve.GatheredDoc(doc); ok { - regather[gathered] = true - } - } - if len(regather) == 0 { - return + // The gathers the drop took are replayed by the next read that needs them + // (settleGathersLocked), not inside the edit. + if w.regatherPending == nil { + w.regatherPending = map[string]bool{} + } + for doc := range ch.Docs { + w.regatherPending[doc] = true + } + for _, doc := range dropped { + delete(w.diagCache, doc) + w.refs.drop(doc) + if gathered, ok := resolve.GatheredDoc(doc); ok { + w.regatherPending[gathered] = true } + } +} + +// settleGathersLocked runs the regather cascade the invalidations queued: what +// a regathered union now says differently drops the judgments that read it, +// until nothing more moves. Caller holds the write lock. +func (w *Workspace) settleGathersLocked() { + if w.settling || w.resolver == nil || len(w.regatherPending) == 0 { + return + } + w.settling = true + defer func() { w.settling = false }() + regather := w.regatherPending + w.regatherPending = nil + for len(regather) > 0 { changed := w.gathers.Regather(w.contextLocked(), regather) if len(changed) == 0 { return @@ -372,8 +392,15 @@ func (w *Workspace) invalidateLocked(name string) { for _, n := range changed { names[n] = true } - dropped = w.resolver.Invalidate(symbols.Changes{Names: names}) + dropped := w.resolver.Invalidate(symbols.Changes{Names: names}) regather = map[string]bool{} + for _, doc := range dropped { + delete(w.diagCache, doc) + w.refs.drop(doc) + if gathered, ok := resolve.GatheredDoc(doc); ok { + regather[gathered] = true + } + } } } @@ -390,6 +417,7 @@ func (w *Workspace) contextLocked() *passes.Context { func (w *Workspace) invalidateAllLocked() { w.diagCache = map[string][]diag.Diagnostic{} w.refs = nil + w.regatherPending = nil w.generation++ if w.resolver != nil { w.resolver.InvalidateAll() @@ -425,6 +453,7 @@ func (w *Workspace) AnalyzedContent(name string) ([]byte, []diag.Diagnostic, boo // diagnosticsLocked analyzes doc, caching the result. Caller holds the lock. func (w *Workspace) diagnosticsLocked(name string, doc *Document) []diag.Diagnostic { + w.settleGathersLocked() if cached, ok := w.diagCache[name]; ok { return cached } @@ -540,6 +569,7 @@ func (w *Workspace) semanticsLocked() (*resolve.Resolver, *semantics.Model) { resolver.Track() w.resolver, w.model, w.gathers = resolver, sem, passes.NewGathers() } + w.settleGathersLocked() return w.resolver, w.model } From 0628da802e13348b8cb5c6816868220390c34916 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:06:51 +0000 Subject: [PATCH 2/2] docs(workspace): shorten the regather queue comment Co-Authored-By: jason.han --- internal/workspace/model/workspace.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/workspace/model/workspace.go b/internal/workspace/model/workspace.go index 10e7d3299..467dabb9b 100644 --- a/internal/workspace/model/workspace.go +++ b/internal/workspace/model/workspace.go @@ -50,8 +50,7 @@ type Workspace struct { model *semantics.Model gathers *passes.Gathers // regatherPending queues the documents whose gathers an invalidate dropped, - // replayed on the next read (see settleGathersLocked); settling bars its - // re-entry, a settle itself calling back through contextLocked. + // replayed on the next read; settling bars a settle re-entering itself. regatherPending map[string]bool settling bool // analysis is the options every document of this workspace is analyzed under,