Skip to content

perf(workspace): don't re-run the analyser on every code action request - #929

Open
psteinroe wants to merge 3 commits into
mainfrom
perf/cache-code-action-lints
Open

psteinroe wants to merge 3 commits into
mainfrom
perf/cache-code-action-lints

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #913, which changes the same parts of Document and WorkspaceServer.

Editors request code actions on every cursor move, and each request ran the whole analysis of the file: every lint and typecheck rule, over every statement. Right after an edit, the diagnostics had already run the same analysis. On a file with 25,000 statements that took seconds per cursor move.

What changed

  • analyse_document returns a DocumentAnalysis struct instead of a tuple. The analysis that the editor asks for (every rule category, no only/skip rule selection) is kept on the Document, through analyse_document_cached. pull_file_diagnostics and the code actions use it, so after the diagnostics of an edit the code actions find it ready. Requests with a rule selection, such as check --only or fix_file (which analyses its own intermediate documents), are not cached.
  • The cached analysis is dropped when the content changes (Document::update_content). It is not used when the settings changed, tracked by a version counter that is bumped while the settings are write-locked, or when the database snapshot changed (compared by Arc identity, which also covers a database that becomes reachable).
  • The code actions go through the cached diagnostics by reference and only look at those under the cursor. LinterDiagnostic and its advices derive Clone, so that pull_file_diagnostics can convert diagnostics out of the shared analysis. RuleCategories derives PartialEq to recognise the "all categories" request.
  • The LSP handler now honours context.only. Kinds are matched hierarchically, so quickfix includes quickfix.pgls.banDropTable, and source.fixAll doesn't get the quick fixes or the Execute Statement command. The filter runs on the final list, so actions added later (refactors, fix-all) go through it without changes here.

EXPLAIN and plpgsql_check diagnostics are not part of the cached analysis and are not affected.

Timings

Release build on a shared, loaded machine, measured with the probe from the release analysis. The "before" row is #892 alone, which stops code actions from walking the whole file to find the statement at the cursor; on main without it, that walk alone took about 0.9 s on the 20,000-line file.

5,000 lines (6,250 statements) 20,000 lines (25,000 statements)
code actions before (best of 3) 777–955 ms 3,557–4,556 ms
diagnostics after an edit (unchanged) 1,782 ms 7,114 ms
first code action request after the diagnostics 5 ms 13 ms
further code action requests < 0.1 ms < 0.1 ms

Tests

  • code_actions_reuse_the_analysis: the code actions use the analysis that the diagnostics cached (the same Arc), an edit drops it, and turning a rule off in the settings removes its actions.
  • test_lint_fix_code_actions (LSP): with only: [quickfix] only the fix and the suppression come back, and with only: [source.fixAll] nothing does.
  • requested_kinds_are_hierarchical: the kind matching, including "", a prefix that isn't a whole segment, and the empty kind of the commands.

Risks

Follow-ups: The memory of the cached analysis is bounded in #951.

ParseIterator::next recursed once for every statement the filter skipped,
so hover, code actions and completions overflowed the 2 MiB stack of the
tokio workers on files with a few thousand statements and aborted the
server. It is a loop now.

Hover and code actions only look at the top-level statement at the cursor
and its SQL function body instead of filtering every statement of the file.
…nce for hover

The parse and tree-sitter caches of a document held 1,000 statements, so
on bigger files every walk over the document parsed every statement again.
They now keep the statements of the document and drop the ones that are
gone when the content changes.

Hover walked the statements before the cursor twice: once to resolve the
references, and once more in snapshot_before, which also deep-cloned
every AST. It now builds the snapshot from the catalog of its own walk,
skips it when the hovered references are all to objects the file didn't
change, and caches it by the text before the statement.
@psteinroe
psteinroe force-pushed the perf/cache-code-action-lints branch from e5c9257 to 6c8ab2e Compare October 24, 2026 07:32
@psteinroe
psteinroe changed the base branch from main to perf/document-statement-caches October 24, 2026 07:32
Editors request code actions on every cursor move, and each request ran
the whole analysis of the file again. The analysis that the diagnostics
and the code actions ask for (every rule category, no rule selection) is
now kept on the document until its content, the settings or the database
snapshot change, and both share it.

The LSP handler also filters the actions by the kinds in context.only.

@psteinroe psteinroe left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too messy

/// The migration sections that are not linted.
skipped_sections: Vec<TextRange>,
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be more encapsulated

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant