Skip to content

perf(catalog): build the catalog snapshot in linear time - #983

Open
psteinroe wants to merge 5 commits into
perf/editor-requests-cancellablefrom
perf/catalog-linear-snapshot
Open

psteinroe wants to merge 5 commits into
perf/editor-requests-cancellablefrom
perf/catalog-linear-snapshot

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Catalog::snapshot() builds the Snapshot that hover and completions read: the database snapshot with the file's changes. Its builder was quadratic in the size of the file. For each relation the file defines, it searched all tables, removed that table's columns with a pass over all columns, and looked up each column's type with a pass over all types. Each dropped relation, type and function cost another pass over the objects of its kind, and each file type was checked against all types.

With #982, hover after an edit above the hovered statement was dominated by this. In a 20,000-statement migration file (5,000 tables), one snapshot took 630 ms of the 744 ms per keystroke.

What changed

materialize.rs only. It produces the same objects, ids and order as before on the same platform:

  • The builder indexes the tables, types (by schema and name, and by name) and sequences once the schemas are done. It indexes them again after the relations and the dropped types are applied. Each index keeps the first object of a name, which is what the linear searches found. New types are added to the index as they are pushed, so type_id and define_type still see the types defined before them.
  • Dropped relations, types and functions are collected into sets and removed in one pass per kind. The relations are keys of one map, so a dropped relation never has the name of a defined one. Removing them after the others are defined therefore removes the same objects as removing them in between. The same holds for the functions.
  • define_relation only finds or adds the table. define_columns then takes out the old columns of all defined tables in one pass over the columns, and appends the new ones in the order of the relations, as before.

Test

The first commit adds snapshot_of_a_file_with_many_changes and records it with the old builder. The input file:

  • creates 20 tables with indexes, functions and views;
  • drops, renames, alters or moves each table to another schema;
  • changes, drops and recreates a database table;
  • drops and creates types, functions and sequences, and creates a schema.

The test renders every schema, table, column, type, function, index and sequence of the snapshot as one JSON line. The second commit doesn't change the recorded output.

The file's objects get their ids in the iteration order of the catalog's hash maps. That order differs between x86_64 and aarch64, so the first version of the test failed on the macOS runner. The third commit writes each id as the table or type it stands for, and sorts the lines. The fourth sorts the JSON keys, since cargo test --workspace turns on serde_json's preserve_order. I recorded it again with the old builder, and the new builder gives the same output. On x86_64, the order-sensitive rendering from the first commit was also identical between the old and the new builder. The other pgls_catalog, pgls_hover and pgls_completions tests pass.

Numbers

Release build, on top of #982 (this PR doesn't depend on that stack and is based on main). The scenarios are the ones from #982: a dbmate file of tables, indexes, SQL functions and queries, one keystroke in a name, averages over 20 keystrokes. #982 → #982 with this PR:

per keystroke, hover 5,000 statements 20,000 statements
edit in the CREATE TABLE 3 statements above 58.9 → 28.1 ms 744 → 123 ms
edit near the start, request near the end 102 → 74 ms 892 → 303 ms
edit in the hovered statement (snapshot from the cache, so only noise) 6.1 → 7.6 ms 34 → 44 ms

Inlay hints don't build a snapshot and are unchanged within noise. Completions after an edit above them build one too, but their time is mostly pgls_completions::complete going over the 5,000 file tables of the snapshot, so they stay about the same.

One line per object of the materialized snapshot, in its order, so that a change of how it is built shows any difference in ids, order or fields.
Catalog::snapshot() searched all tables and kept all columns but those of the table for each
relation the file defines, searched all types for each column type and file type, and went
over the snapshot's objects again for each dropped relation, type and function. A file with
5,000 tables took 630 ms per snapshot, which hover builds after each change before the hovered
statement.

The builder now indexes the tables, types and sequences by name once, drops the dropped objects
in one pass per kind, and replaces the columns of all defined tables in one pass. The objects,
their ids and their order are the same, see snapshot_of_a_file_with_many_changes.
The ids of the file's objects follow the iteration order of a hash map, which differs between x86_64 and aarch64. The test writes them as the table or type they stand for and sorts the lines. Recorded again with the builder from before the previous commit, and unchanged with it.
With the features of the whole workspace, serde_json keeps the order of the struct fields instead of sorting the keys.
@psteinroe
psteinroe changed the base branch from main to perf/editor-requests-cancellable October 26, 2026 19:55

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