Skip to content

Commit the solve behind a type query on an unopened file - #4698

Open
samwgoldman wants to merge 1 commit into
tsp-connection-modelfrom
tsp-query-retention
Open

Commit the solve behind a type query on an unopened file#4698
samwgoldman wants to merge 1 commit into
tsp-connection-modelfrom
tsp-query-retention

Conversation

@samwgoldman

@samwgoldman samwgoldman commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary:
TSP supports requests on files which have not yet been opened. In this case, we
would create a throw-away transaction that solved the module to
Step::Solutions with Require::Everything, ran the request handler, and
returned the result. Solving the module for every request is extremely
expensive, but all that work is thrown away between requests. Furthermore, TSP
is fairly chatty, sending hundreds (sometimes thousands) of getComputedType
requests.

This change offers a simple but effective solution: when handling a request for
a non-open file, we create a "possibly committable" transaction to raise the
require level for that handle. We are either able to (1) get a committable
transaction which updates the committed state, so future requests share the
earlier solve work, or (2) we get a non-committable transaction, but the
TransactionManager maintains a saved state, so that multiple requests can
still share solve work even when a background recheck is happening. This is also
how the Pyrefly LSP works.

To test this change I captured and replayed a Pylance session which had 43,811
getComputedType requests (89% of them through the extra connection). Request
costs compared to parent, in ms:

  request          target    n      | before p50   p90    total | after p50   p90   total
  getComputedType  non-open  22294  |     16.886  57.82  667.79s |    0.067  0.114   3.43s
  getComputedType  open      21517  |      0.046   0.09    1.25s |    0.059  0.074   1.34s
  getSnapshot      -           191  |      0.069   7.18    1.14s |    0.068  6.632   1.05s
  resolveImport    -           994  |      0.130   1.38    0.58s |    0.087  1.229   0.42s
                                        total 670.95s               total 6.41s

Both runs also carried an unrelated demand fix that is not part of this stack,
worth roughly 0.2s of the "after" total.

Test Plan:
Answers are unchanged. Response payloads carry allocation-order identifiers
that differ between runs, so responses are compared ignoring digits: 43822 of
43873 (99.88%) match. All 51 that differ are -32802 snapshot-outdated
responses, never a differing type. Two runs of the same binary differ
structurally in 78 responses, so the gap between the two binaries is within a
single binary's run-to-run variation.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot added size/l and removed size/l labels Aug 28, 2026
@github-actions

This comment has been minimized.

@kinto0 kinto0 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

great change, thank you!!

would love to see tests on the following:

  1. Repeated unopened-file query reuse
    Query the same unopened file twice and verify the second query performs no solve work. This is the primary purpose of the commit.

  2. Deferred didChange visibility
    Arrange didChange(v2) → type query → later mutation, causing validation of v2 to be deferred. Verify the query returns the type from v2 rather than stale shared state.

  3. Non-committable fallback
    Hold the recheck lock, issue a type query, and verify its solved transaction is saved. Release the lock and verify the next query reuses and commits it.

  4. Intervening resolveImport
    After saving a query transaction, call resolveImport, then issue another query. This would expose the transaction-loss issue from my review.

  5. Invalidation after caching
    Query an unopened file, change it, advance the snapshot, and verify the next query recomputes rather than returning the cached old type.

  6. Explicit queue-draining case
    Use a type conversion known to lazily enqueue additional work and verify the transaction commits successfully. Existing type-shape tests may exercise this accidentally, but none assert the invariant.

  7. Other unopened query kinds
    Only getComputedType tests unopened files. getDeclaredType and getExpectedType now use the same new path but lack equivalent coverage.

/// which is a little longer but avoids resolving a handle against state we
/// then have to re-acquire.
///
/// A file the client never opened is solved at `Require::Everything`, the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this docstring could be a lot less verbose

// Bring in-memory content up to date. `didChange` skips this step when
// another mutation is already queued, so the overlay can be behind
// `open_files` even though the queue has drained.
transaction.set_memory(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is the same as just running validate_in_memory_for_transaction, right? can we reuse that?

Comment thread pyrefly/lib/tsp/server.rs
Ok(true)
}
TSPRequests::ResolveImportRequest { params, .. } => {
self.handle_resolve_import(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we need to update this too if we want to be able to save the transactions from this call. It looks like it was missed because it already passed ide_transaction_manager.

@github-actions github-actions Bot added size/l and removed size/l labels Aug 28, 2026
@github-actions

This comment has been minimized.

@github-actions github-actions Bot added size/l and removed size/l labels Aug 28, 2026
@github-actions

This comment has been minimized.

Summary:
TSP supports requests on files which have not yet been opened. In this case, we
would create a throw-away transaction that solved the module to
`Step::Solutions` with `Require::Everything`, ran the request handler, and
returned the result. Solving the module for every request is extremely
expensive, but all that work is thrown away between requests. Furthermore, TSP
is fairly chatty, sending hundreds (sometimes thousands) of `getComputedType`
requests.

This change offers a simple but effective solution: when handling a request for
a non-open file, we create a "possibly committable" transaction to raise the
require level for that handle. We are either able to (1) get a committable
transaction which updates the committed state, so future requests share the
earlier solve work, or (2) we get a non-committable transaction, but the
`TransactionManager` maintains a saved state, so that multiple requests can
still share solve work even when a background recheck is happening. This is also
how the Pyrefly LSP works.

To test this change I captured and replayed a Pylance session which had 43,811
`getComputedType` requests (89% of them through the extra connection). Request
costs compared to parent, in ms:

```
  request          target    n      | before p50   p90    total | after p50   p90   total
  getComputedType  non-open  22294  |     16.886  57.82  667.79s |    0.067  0.114   3.43s
  getComputedType  open      21517  |      0.046   0.09    1.25s |    0.059  0.074   1.34s
  getSnapshot      -           191  |      0.069   7.18    1.14s |    0.068  6.632   1.05s
  resolveImport    -           994  |      0.130   1.38    0.58s |    0.087  1.229   0.42s
                                        total 670.95s               total 6.41s
```

Both runs also carried an unrelated `demand` fix that is not part of this stack,
worth roughly 0.2s of the "after" total.

Test Plan:
Answers are unchanged. Response payloads carry allocation-order identifiers
that differ between runs, so responses are compared ignoring digits: 43822 of
43873 (99.88%) match. All 51 that differ are `-32802` snapshot-outdated
responses, never a differing type. Two runs of the same binary differ
structurally in 78 responses, so the gap between the two binaries is within a
single binary's run-to-run variation.
@github-actions github-actions Bot added size/l and removed size/l labels Aug 28, 2026
@github-actions

Copy link
Copy Markdown

According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants