perf(java): keep the configuration snapshot across editor activity

The configuration index was invalidated by every Java element event,
including POST_RECONCILE, and its working-copy fingerprint covered every
open editor. Opening the navigation target, closing a tab, or saving
application.yml was enough to make the next Find Usages or YAML key
navigation rebuild the whole project index.

Invalidation now follows what the index depends on:
- POST_CHANGE deltas only, ignoring working-copy open/close, AST-only
  flags and non-Java resource content on projects, roots and packages
  (configuration files are parsed by the client, metadata files are
  stamp-checked).
- The fingerprint covers working copies with unsaved changes only.

Each snapshot reports a generation so the real-server harness can assert
that opening/closing editors, a full build and a saved edit keep it warm,
while a source added on disk still invalidates it.

Bundle 1.2.1 (installed as java-usages-1.2.1.jar so OSGi does not reuse
the cached 1.2.0), reproducible SHA-256
cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1.
This commit is contained in:
l0ng-ai
2026-11-03 17:37:35 +08:00
parent 1f0b2a43e7
commit 01fd25ab9b
7 changed files with 63 additions and 13 deletions
+7 -4
View File
@@ -57,10 +57,13 @@ ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go t
retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations;
ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST
construction for ordinary Java units, but every accepted association is resolved
semantically. Warm requests reuse the snapshot. Java model/reconcile changes
invalidate it conservatively, including dependent-type changes; open working-copy
content fingerprints also prevent stale answers before reconciliation. This is
**snapshot invalidation**, not a fully incremental Java semantic index.
semantically. Warm requests reuse the snapshot. Java model changes (sources on
disk, classpath, archives) invalidate it conservatively, including dependent-type
changes; a fingerprint of working copies with unsaved changes covers edits before
they are saved. Opening or closing an editor, reconciling, building, and non-Java
resource changes (configuration files are parsed by the client; metadata files are
checked by timestamp) keep it. This is **snapshot invalidation**, not a fully
incremental Java semantic index.
Metadata/JAR timestamps are checked before reuse. The client caches parsed disk
configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and
+1 -1
View File
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.2.0
Bundle-Version: 1.2.1
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
+4 -1
View File
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
## Rebuild and delivery
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
`java-usages-1.2.0.jar`, and added alongside existing initialization bundles.
`java-usages-1.2.1.jar`, and added alongside existing initialization bundles.
Users need no compiler, Maven or Python. Manifest/install version must change
when replacing the bundle because OSGi caches bundles.
@@ -70,6 +70,9 @@ and compare SHA-256. The prior 1.1.1 output was
`1e569939cba22c7db6e2c9b53de2a4c10ccaabe762a51427119a04af3d974ae4`.
Version 1.2.0 adds the configuration command; its reproducible SHA-256 is
`9140f1d2ecf27eadfe66b40a17daa18a08a65927ebef79456ba55d486ce5eeba`.
Version 1.2.1 keeps the configuration snapshot across editor open/close, saves of
unrelated content and non-Java resource changes; its reproducible SHA-256 is
`cbae3e3a0bcf2818b4cc9d98998bd9b81f92e1ae7803dc757134485f9776a4e1`.
```sh
export PATH=/path/to/jdk25/bin:$PATH
Binary file not shown.
@@ -27,9 +27,23 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
private static final String VALUE = "org.springframework.beans.factory.annotation.Value";
private static final String RESOLVER = "org.springframework.core.env.PropertyResolver";
private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024;
private static final AtomicLong REVISION = new AtomicLong();
private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong();
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
static { JavaCore.addElementChangedListener(event -> REVISION.incrementAndGet(), ElementChangedEvent.POST_CHANGE | ElementChangedEvent.POST_RECONCILE); }
/** Unsaved edits are covered by the working-copy digest, so reconciles never
* invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and
* non-Java resources, reported as F_CONTENT on a project, root or package
* (configuration files are parsed by the client; metadata files carry their
* own stamps), do not change the index either. */
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); }
static boolean affects(IJavaElementDelta delta) {
int flags = delta.getFlags() & ~IGNORED_FLAGS;
if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT;
if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true;
for (IJavaElementDelta child : delta.getAffectedChildren()) if (affects(child)) return true;
return false;
}
private record Entry(String name, String handle, Location location, boolean relaxed) {}
private record FileStamp(java.nio.file.attribute.FileTime modified, long size) {}
@@ -38,7 +52,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
catch (java.io.IOException e) { return new FileStamp(null, -1); }
}
private record Index(long revision, String workingCopies, List<Entry> entries, List<String> roots,
Map<Path, FileStamp> files, boolean incomplete, int parsed) {}
Map<Path, FileStamp> files, boolean incomplete, int parsed, long generation) {}
@Override
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
@@ -108,7 +122,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
}
return Map.of("locations", locations, "keys", keys, "roots", index.roots,
"incomplete", index.incomplete || locations.size() >= 2000,
"parsedUnits", index.parsed, "revision", index.revision);
"parsedUnits", index.parsed, "revision", index.revision, "generation", index.generation);
}
private static boolean sameUri(String a, String b) {
@@ -156,9 +170,12 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
for (ICompilationUnit copy : JavaCore.getWorkingCopies(null)) copies.put(copy.getPrimary().getHandleIdentifier(), copy);
for (ICompilationUnit copy : JavaCore.getWorkingCopies(JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()))
copies.put(copy.getPrimary().getHandleIdentifier(), copy);
// Only unsaved contents differ from what the Java model reports for disk,
// so merely opening or closing an editor keeps the snapshot.
var digest = java.security.MessageDigest.getInstance("SHA-256");
for (var entry : copies.entrySet()) {
check(monitor);
if (!entry.getValue().hasUnsavedChanges()) continue;
digest.update(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8));
String source = entry.getValue().getSource();
if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
@@ -209,7 +226,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
// Return project locations as well; the client excludes nested independent modules.
roots.add(project.getProject().getLocationURI().toString());
builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project);
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed);
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet());
CACHE.remove(id);
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
+28 -1
View File
@@ -200,6 +200,33 @@ try:
warm = []
for _ in range(20):
start = time.perf_counter(); query('app.time-out'); warm.append(time.perf_counter() - start)
# The snapshot stays warm across editor activity that cannot change it:
# opening/closing another file, a full build, and an edit once it is saved.
generation = query('app.time-out')['generation']
other = source_dir / 'demo/NotSpring.java'
other_text = other.read_text()
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
assert query('app.time-out')['generation'] == generation, 'opening an editor rebuilt the index'
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
request('java/buildWorkspace', True)
assert query('app.time-out')['generation'] == generation, 'closing an editor or building rebuilt the index'
saved_text = other_text + '// saved\n'
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': other.as_uri(), 'languageId': 'java', 'version': 1, 'text': other_text}}})
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': other.as_uri(), 'version': 2}, 'contentChanges': [{'text': saved_text}]}})
other.write_text(saved_text)
send({'jsonrpc': '2.0', 'method': 'textDocument/didSave', 'params': {'textDocument': {'uri': other.as_uri()}}})
after_save = query('app.time-out')['generation']
assert query('app.time-out')['generation'] == after_save, 'a saved editor keeps rebuilding the index'
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': other.as_uri()}}})
after_close = query('app.time-out')['generation']
assert query('app.time-out')['generation'] == after_close
# A source added on disk still invalidates it.
added = write('demo/Added.java', 'package demo; @org.springframework.boot.context.properties.ConfigurationProperties("added") class Added { int size; }')
send({'jsonrpc': '2.0', 'method': 'workspace/didChangeWatchedFiles', 'params': {'changes': [{'uri': added.as_uri(), 'type': 1}]}})
deadline = time.monotonic() + 30
while not query('added.size')['locations']:
assert time.monotonic() < deadline, 'a new source on disk did not reach the index'
time.sleep(0.2)
# Unsaved changes must replace old keys, and closing must restore disk keys.
changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}')
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})
@@ -211,7 +238,7 @@ try:
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}})
restored = query('app.nested.url')
assert restored['locations'], restored
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000}
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
print(json.dumps(evidence, indent=2))
finally: