Skip to content

Add incremental invalidation engine#641

Open
st0012 wants to merge 1 commit intomainfrom
add-incremental-invalidation-pr2
Open

Add incremental invalidation engine#641
st0012 wants to merge 1 commit intomainfrom
add-incremental-invalidation-pr2

Conversation

@st0012
Copy link
Member

@st0012 st0012 commented Mar 4, 2026

Extracted from #638 (incremental invalidation). This PR contains the core invalidation engine; the incremental resolver and without_resolution flag will follow as separate PRs.

Replaces the old remove_definitions_for_document + invalidate_ancestor_chains approach with a targeted invalidation engine. When a file is updated or deleted, the engine traces through the name_dependents reverse index to invalidate only the affected declarations, names, and references — instead of requiring a full graph rebuild.

How it works

update() and delete_document() now run a three-step process:

  1. invalidate — seeds a worklist from old/new definitions and references
  2. remove_document_data — cleans up raw data (defs, refs, names, strings) from maps
  3. extend — merges new content and accumulates pending work items

The worklist processes two kinds of items:

  • Declaration — either removes the declaration (cascading to members, singleton, descendants) or clears its ancestor chain and queues descendants
  • UnresolveName / UnresolveReferences — unresolves names or just their references depending on whether the structural dependency broke or only the ancestor context changed

The name_dependents reverse index (built during indexing in LocalGraph) maps each name to the definitions and references that depend on it, enabling efficient invalidation tracing without scanning the full graph.

Note: the resolver still does clear_declarations + full rebuild. Wiring it to drain pending_work incrementally is a follow-up.

@st0012 st0012 force-pushed the add-incremental-invalidation-pr2 branch from 4a7fc18 to f39ef05 Compare March 4, 2026 23:00
@st0012 st0012 marked this pull request as ready for review March 4, 2026 23:45
@st0012 st0012 requested a review from a team as a code owner March 4, 2026 23:45
@st0012 st0012 force-pushed the add-incremental-invalidation-pr2 branch from f39ef05 to 267785b Compare March 4, 2026 23:50
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be nice to have some indexing tests to ensure that we get the right data structures initially.

For example:

# Bar and CONST don't depend on anything. There are no code changes that
# can modify what they mean other than changing them directly
module Bar; end
CONST = 1

# Foo has no dependencies
module Foo
  # This Bar depends on Foo
  # The Baz depends on both Bar and Foo
  class Bar::Baz
    # CONST depends on Foo and Baz
    CONST
  end
end

Note that the key is ensuring that the chain of dependents can be followed. For example, since Baz already depends on Bar, remembering that CONST depends on Baz is enough. We don't need to tie CONST to Bar. If Bar gets invalidated, it will invalidate Baz and that will in turn invalidate CONST.

/// The three steps must run in this order:
/// 1. `invalidate` -- reads resolved names and declaration state to determine what to invalidate
/// 2. `remove_document_data` -- removes old refs/defs/names/strings from maps
/// 3. `extend` -- merges the new `LocalGraph` into the now-clean graph
Copy link
Member

Choose a reason for hiding this comment

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

No need to do it in this PR, but I think this method now deserves a more descriptive name. Maybe consume_document_changes or ingest_local_graph. It does a whole lot more than just "update".

self.remove_definitions_for_document(&document);
let old_document = self.documents.remove(&uri_id);

self.invalidate(old_document.as_ref(), Some(&other));
Copy link
Member

Choose a reason for hiding this comment

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

It might be worth adding a fast path for the initial indexing on boot (which can trigger no invalidation and no removal of data).

Maybe a boolean flag for skipping invalidation.

Comment on lines +845 to +847
&& declaration.remove_definition(def_id)
{
declaration.clear_diagnostics();
Copy link
Member

Choose a reason for hiding this comment

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

This is doing two data removal operations: removing a definition and clearing diagnostics. Does it make sense to move this to remove_document_data? Or will that result in duplicate work?

Copy link
Member Author

Choose a reason for hiding this comment

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

Moved

Comment on lines +865 to +866
&& let Some(nesting_id) = name_ref.nesting()
&& let Some(NameRef::Resolved(resolved)) = self.names.get(nesting_id)
Copy link
Member

Choose a reason for hiding this comment

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

Can you clarify that document change this is accounting for? I'm having a hard time understanding it.

A constant reference was changed and we're enqueuing invalidation for the reference's nesting declaration.

Copy link
Member Author

@st0012 st0012 Mar 9, 2026

Choose a reason for hiding this comment

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

This is for

class Foo
  include Bar # constant reference, when added we invalidate Foo entirely for now
end

I've added comments for it.


self.declarations.remove(&decl_id);
} else {
// Ancestor-stale mode
Copy link
Member

Choose a reason for hiding this comment

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

What does this mean?

Copy link
Member Author

Choose a reason for hiding this comment

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

Added more comments. Basically, it's triggered in

class Foo
  include Bar # this is added/removed
end

So we simply change ancestors/descendents update in this branch.

}
}

self.declarations.remove(&decl_id);
Copy link
Member

Choose a reason for hiding this comment

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

This is also doing data removal. Maybe this is fine, but I'm calling it out because the method documentation mentions a separation between invalidation and removal.

Copy link
Member Author

Choose a reason for hiding this comment

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

I currently treat declaration removal as invalidation, kinda similar to unresolving a name. We remove (unresolve) the declaration here if we found it has no underlying definitions anymore.

The underlying materials (definitions, constant references, names...etc.) are only removed in remove_document_data.

return;
};

// Remove self from each ancestor's descendant set
Copy link
Member

Choose a reason for hiding this comment

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

I could be misunderstanding and maybe this was an existing bug, but removing self is not enough. The entire ancestor chain of self must be removed from descendants. However, we should absolutely not try to perform this removal because there are module deduping rules that you cannot possibly account for with a removal.

Whenever a declaration gets invalidated, we always need to invalidate the ancestors of all descendants. In both branches of this method.

Copy link
Member Author

Choose a reason for hiding this comment

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

Both paths will update the ancestors. I've updated the document to make it more clear: invalidate_declaration either "remove/rebuild" or "update" a declaration. In both paths we update ancestors.

This also means there are optimization opportunities in both paths we can do later, which I also included in comments.

@st0012 st0012 force-pushed the add-incremental-invalidation-pr2 branch 4 times, most recently from de13a32 to d560ba6 Compare March 9, 2026 23:14
@st0012 st0012 requested a review from vinistock March 9, 2026 23:16
@st0012 st0012 self-assigned this Mar 9, 2026
Introduces a worklist-based invalidation engine that cascades changes
through the graph when documents are updated or deleted. Uses
ChildName/NestedName edges from the name_dependents index to propagate
invalidation with two distinct modes:

- Structural cascade (UnresolveName): declaration removed or scope broken
- Ancestor cascade (UnresolveReferences): ancestor chain changed

Replaces the has_unresolved_dependency runtime check with explicit
invalidation variants determined at queue time.
@st0012 st0012 force-pushed the add-incremental-invalidation-pr2 branch from d560ba6 to 6862eca Compare March 10, 2026 17:24
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.

2 participants