Compare commits

...

3 Commits

Author SHA1 Message Date
Ingo Schnabel
c4ff96b5fe Performance Part 2 2026-09-06 10:11:00 +02:00
Ingo Schnabel
9ce92c974c Performance Part 1 2026-09-05 17:39:57 +02:00
Ingo Schnabel
736fb48512 Performance 2026-09-05 10:27:31 +02:00
14 changed files with 954 additions and 110 deletions

View File

@@ -50,6 +50,12 @@ final class RefreshCommand extends AbstractProjectCommand {
+ "Enrichment still runs in full; a changed Natural copycode re-parses everything (its text is inlined at parse time)")
boolean changedOnly;
@Option(names = "--profile",
description = "DIAGNOSTIC: run the enrichment steps under Cypher PROFILE and log the dominant operators of every step "
+ "slower than 5 s, to see where a step spends its time. Costs 10-30% on its own, so this run's absolute "
+ "timings are not comparable to a normal refresh")
boolean profile;
@Override
public Integer call() throws Exception {
if (name != null && !name.isBlank()) {
@@ -76,6 +82,9 @@ final class RefreshCommand extends AbstractProjectCommand {
if (changedOnly) {
path = appendQuery(path, "changedOnly", "true");
}
if (profile) {
path = appendQuery(path, "profile", "true");
}
return printResponse(apiClient().post(path));
}
}

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=265
version=276

View File

@@ -424,7 +424,12 @@ public class AnalysisResource {
+ "Opt-in: enrichment still runs in full (so this cuts parse time only), duplicate detection sees just the "
+ "changed files, user-exit LoC is re-stamped only on re-parsed files, and a changed Natural copycode "
+ "disables the skipping for that run because copycode text is inlined at parse time.")
@QueryParam("changedOnly") @Nullable Boolean changedOnly) {
@QueryParam("changedOnly") @Nullable Boolean changedOnly,
@Parameter(description = "DIAGNOSTIC, not an analysis option: run this refresh's enrichment steps under Cypher PROFILE "
+ "and log the dominant operators of every step slower than 5 s, to attribute a step's time between matching "
+ "and writing. Profiling costs 10-30% on its own, so the run's absolute times are not comparable to a normal "
+ "refresh - the numbers describe the distribution inside a step. Off by default.")
@QueryParam("profile") @Nullable Boolean profile) {
boolean deepIngest = deep != null && deep;
if (paths != null && !paths.isBlank()) {
List<String> requested = Arrays.stream(paths.split(",")).map(String::trim).filter(p -> !p.isEmpty()).toList();
@@ -435,7 +440,8 @@ public class AnalysisResource {
return withResolvedRoot(project, info -> projectIngestService.refreshPaths(info, requested));
}
return withResolvedRoot(project, info ->
projectIngestService.refreshProject(info, deepIngest, Boolean.TRUE.equals(changedOnly)));
projectIngestService.refreshProject(info, deepIngest, Boolean.TRUE.equals(changedOnly),
Boolean.TRUE.equals(profile)));
}
/**

View File

@@ -201,7 +201,14 @@ public class AstIngestService {
* (field-level resolution runs only for {@link EnrichmentLevel#FULL}).
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level) {
return graphRepository.finalizeProject(project, level).replaceWithVoid();
return finalizeProject(project, level, false);
}
/**
* @param profile diagnostic: profile the slow enrichment steps (see {@code GraphRepository}).
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
return graphRepository.finalizeProject(project, level, profile).replaceWithVoid();
}
/**

View File

@@ -375,9 +375,18 @@ public class ProjectIngestService {
* <p>Enrichment is project-wide and still runs in full, so this cuts parse+persist time only.
*/
public IngestSummary refreshProject(ProjectInfo project, boolean deep, boolean changedOnly) throws IOException {
return refreshProject(project, deep, changedOnly, false);
}
/**
* @param profile diagnostic (2026-09-05): profile the slow enrichment steps of this run. Costs
* 10-30% on its own, so it is opt-in per run and never a default.
*/
public IngestSummary refreshProject(ProjectInfo project, boolean deep, boolean changedOnly,
boolean profile) throws IOException {
long startedAt = System.nanoTime();
IngestSummary summary = ingestRoot(project, deep ? EnrichmentLevel.FULL : EnrichmentLevel.CALL_GRAPH,
false, changedOnly);
false, changedOnly, profile);
sweepDeletedFileOrphans(project);
LOG.infof("Refresh finished: project='%s', mode=%s, files=%d, modules=%d, failed=%d, %d s",
project.name(), deep ? "deep" : "call-graph", summary.examinedFiles().size(),
@@ -472,6 +481,11 @@ public class ProjectIngestService {
*/
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse,
boolean changedOnly) throws IOException {
return ingestRoot(project, level, coarse, changedOnly, false);
}
private IngestSummary ingestRoot(ProjectInfo project, EnrichmentLevel level, boolean coarse,
boolean changedOnly, boolean profile) throws IOException {
long startedAt = System.nanoTime();
// Item 129: mark the pass in flight before touching anything. If it never reaches the record
// call at the end — crash, container stop, an aborted deep refresh — the marker stays set and
@@ -573,7 +587,7 @@ public class ProjectIngestService {
if (ingested > 0) {
LOG.infof("Finalizing project '%s' (%d files persisted, %s)", project.name(), ingested,
level.name().toLowerCase(Locale.ROOT));
astIngestService.finalizeProject(project.name(), level).await().indefinitely();
astIngestService.finalizeProject(project.name(), level, profile).await().indefinitely();
// Tag ingest depth: only a field-resolving (FULL) pass marks modules FULL; the fast
// whole-root passes mark CALL_GRAPH (so field queries still hint a deep ingest), without
// downgrading any already-FULL module.

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# info version (referenced below via property expression, not duplicated).
agenticcode.version=265
agenticcode.version=276
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
mp.openapi.extensions.smallrye.info.title=AgenticCode API

View File

@@ -26,6 +26,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*
* <p>Asserts the cause (the planner seeks the index) rather than a runtime, so it fails if the index
* is dropped or the query is rewritten into a shape that cannot use it.
*
* <p>Item 160 extended this to all five per-file reap/sweep statements, which share the shape and the
* index, and fixed the parameters: the test used to pass {@code {sourceFile, ids}}, keys the query has
* never read, so it asserted the plan of a query whose parameters were meaningless. They now carry the
* real shape, {@code {f, os}}.
*/
@QuarkusTest
class StaleFileSweepIndexIT {
@@ -45,23 +50,38 @@ class StaleFileSweepIndexIT {
plan.children().forEach(child -> collectOperators(child, into));
}
/**
* The five per-file statements of the persist path; all seek the same index.
*/
private static Map<String, String> perFileStatements() {
return Map.of(
"sweep-stale-file-nodes", CypherQueries.DELETE_STALE_FILE_NODES,
"sweep-resolved-field-edges", CypherQueries.DELETE_STALE_RESOLVED_FIELD_EDGES,
"reap-table-access-edges", CypherQueries.DELETE_STALE_NATURAL_TABLE_ACCESS_EDGES,
"reap-using-edges", CypherQueries.DELETE_STALE_NATURAL_USING_EDGES,
"reap-call-edges", CypherQueries.DELETE_STALE_NATURAL_CALL_EDGES);
}
@Test
void staleFileSweepSeeksTheProjectSourceFileIndexInsteadOfScanningTheLabel() {
void everyPerFileSweepSeeksTheProjectSourceFileIndexInsteadOfScanningTheLabel() {
try (Session session = driver.session()) {
// An index is only visible to the planner once ONLINE; index creation is asynchronous, so
// without this the assertion would race startup and flake between seek and label scan.
session.run("CALL db.awaitIndexes(120)").consume();
// EXPLAIN plans without executing, so this write query touches nothing.
Plan plan = session.run("EXPLAIN " + CypherQueries.DELETE_STALE_FILE_NODES,
Map.of("project", "any-project",
"files", List.of(Map.of("sourceFile", "ANY.nat", "ids", List.of("some-id")))))
.consume().plan();
perFileStatements().forEach((label, statement) -> {
// EXPLAIN plans without executing, so these write queries touch nothing.
Plan plan = session.run("EXPLAIN " + statement,
Map.of("project", "any-project",
"ingestGen", 1L,
"files", List.of(Map.of("f", "ANY.nat", "os", List.of("")))))
.consume().plan();
List<String> operators = new ArrayList<>();
collectOperators(plan, operators);
assertTrue(operators.stream().anyMatch(op -> op.startsWith("NodeIndexSeek") && op.contains(INDEX)),
"stale-file sweep must seek an " + INDEX + " index, but planned: " + operators);
List<String> operators = new ArrayList<>();
collectOperators(plan, operators);
assertTrue(operators.stream().anyMatch(op -> op.startsWith("NodeIndexSeek") && op.contains(INDEX)),
label + " must seek an " + INDEX + " index, but planned: " + operators);
});
}
}
}

View File

@@ -74,11 +74,23 @@ public final class CypherQueries {
* delete every other includer's nodes (their {@code ingestGen} is one transaction old) together
* with their edges. A module's own nodes carry {@code ownerModule = ""}, so their sweep is
* unchanged.
*
* <p><b>Grouped by file, not one seek per pair (item 160, 2026-09-05).</b> {@code $files} carries
* {@code {f, os: [ownerModule, ...]}} — one entry per source file — instead of one entry per
* {@code (sourceFile, ownerModule)} pair. The index is {@code (project, sourceFile)}, so a
* pair-driven seek returned <em>all</em> of a copycode's nodes once per owner and discarded all but
* that owner's: on {@code upms} 264 copycode files carry 20 343 pairs (mean 77 owners per file,
* {@code YFRAMEC2.cpy} 1 993), so the five reap/sweep statements between them requested 23.3 M rows
* to reach 53 740 nodes. Measured read-only on {@code YFRAMEC2.cpy} with its full owner list:
* 7 944 098 db-hits pair-driven against <b>3 986</b> grouped, same 3 986 nodes matched. The
* {@code IN} test does not re-scan the list per node — Neo4j hashes it — which was the objection
* this shape had to survive. Applies identically to the four edge reaps below.
*/
public static final String DELETE_STALE_FILE_NODES = """
UNWIND $files AS p
MATCH (n:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o})
WHERE n.ingestGen IS NULL OR n.ingestGen <> $ingestGen
MATCH (n:AstNode {project: $project, sourceFile: p.f})
WHERE n.ownerModule IN p.os
AND (n.ingestGen IS NULL OR n.ingestGen <> $ingestGen)
DETACH DELETE n
""";
@@ -88,11 +100,23 @@ public final class CypherQueries {
* On a re-ingest of the owning file we delete its placeholders whose id the fresh parse no longer
* produced, exactly as the file sweep does for real nodes — otherwise a renamed/removed field
* reference would orphan a stale per-module placeholder.
*
* <p><b>One pass, not one per owner (2026-09-05).</b> This used to {@code UNWIND $owners} and seek
* per owner. No index covers {@code ownerModule}, so each seek ran on
* {@code ast_node_project_sourcefile} — and since every placeholder shares {@code sourceFile=""},
* each one returned <em>all</em> 14 551 of the project's placeholders and discarded all but a
* handful. With ~200 owners per batch and 32 batches that is ~6 400 full passes over the same
* bucket, and the persist instrumentation measured this single statement at <b>345 s, 54 % of the
* whole persist phase</b> of an upms deep refresh. Filtering one pass against the owner list
* measured 3x faster (4 770 ms to 1 588 ms for one batch's 200 owners, read-only on the real
* graph), with no new index to maintain on every one of the 940 k node merges. An index on
* {@code (project, ownerModule)} would go further still, but has to earn its write-side cost
* against <em>this</em> shape — measure before adding it.
*/
public static final String DELETE_STALE_PLACEHOLDER_NODES = """
UNWIND $owners AS o
MATCH (n:AstNode {project: $project, sourceFile: "", ownerModule: o})
WHERE (n:VARIABLE OR n:CONSTANT)
MATCH (n:AstNode {project: $project, sourceFile: ""})
WHERE n.ownerModule IN $owners
AND (n:VARIABLE OR n:CONSTANT)
AND (n.ingestGen IS NULL OR n.ingestGen <> $ingestGen)
DETACH DELETE n
""";
@@ -111,8 +135,9 @@ public final class CypherQueries {
*/
public static final String DELETE_STALE_RESOLVED_FIELD_EDGES = """
UNWIND $files AS p
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o})-[r:READS|WRITES]->(fld:AstNode)
WHERE (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT')
MATCH (src:AstNode {project: $project, sourceFile: p.f})-[r:READS|WRITES]->(fld:AstNode)
WHERE src.ownerModule IN p.os
AND (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT')
AND fld.sourceFile <> "" AND fld.sourceFile <> p.f
DELETE r
""";
@@ -132,8 +157,9 @@ public final class CypherQueries {
*/
public static final String DELETE_STALE_NATURAL_TABLE_ACCESS_EDGES = """
UNWIND $files AS p
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:READS|WRITES]->(t:AstNode {project: $project, sourceFile: ""})
WHERE t.type IN ['DB_TABLE', 'WORKFILE']
MATCH (src:AstNode {project: $project, sourceFile: p.f, language: 'natural'})-[r:READS|WRITES]->(t:AstNode {project: $project, sourceFile: ""})
WHERE src.ownerModule IN p.os
AND t.type IN ['DB_TABLE', 'WORKFILE']
DELETE r
""";
@@ -155,7 +181,8 @@ public final class CypherQueries {
*/
public static final String DELETE_STALE_NATURAL_USING_EDGES = """
UNWIND $files AS p
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:INCLUDES]->(d:AstNode {project: $project, type: 'DATA_STRUCTURE'})
MATCH (src:AstNode {project: $project, sourceFile: p.f, language: 'natural'})-[r:INCLUDES]->(d:AstNode {project: $project, type: 'DATA_STRUCTURE'})
WHERE src.ownerModule IN p.os
DELETE r
""";
@@ -213,8 +240,9 @@ public final class CypherQueries {
*/
public static final String DELETE_STALE_NATURAL_CALL_EDGES = """
UNWIND $files AS p
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:CALLS]->(t)
WHERE t.sourceFile = "" OR r.callKind <> 'CALLNAT_DYNAMIC'
MATCH (src:AstNode {project: $project, sourceFile: p.f, language: 'natural'})-[r:CALLS]->(t)
WHERE src.ownerModule IN p.os
AND (t.sourceFile = "" OR r.callKind <> 'CALLNAT_DYNAMIC')
DELETE r
""";
@@ -1235,18 +1263,26 @@ public final class CypherQueries {
MATCH (src:AstNode {project: $project})-[r:CALLS]->(callee:AstNode {type: 'MODULE', project: $project})
WHERE r.args IS NOT NULL AND callee.sourceFile <> "" AND r.calleeMethod IS NULL AND src.sourceFile <> ""
MATCH (cm:AstNode {type: 'MODULE', project: $project, sourceFile: src.sourceFile})
WITH callee, r,
[cm.sourceFile] + [(cm)-[:INCLUDES]->(ci:AstNode) | ci.sourceFile] AS cvfiles,
[callee.sourceFile] + [(callee)-[:INCLUDES]->(pi:AstNode) | pi.sourceFile] AS pvfiles,
split(r.args, ',') AS args
WITH callee, r, cm, split(r.args, ',') AS args
UNWIND range(0, size(args) - 1) AS i
WITH callee, r, i, trim(args[i]) AS argName, cvfiles, pvfiles
UNWIND cvfiles AS cf
WITH callee, r, cm, i, trim(args[i]) AS argName
// The parameter at position i depends on (callee, i) alone, so it is resolved ONCE per
// argument slot instead of once per matched caller variable. The subquery aggregates,
// which is load-bearing: collect() always returns exactly one row, so a callee with no
// parameter at i yields an empty list and the guard below drops the row. A non-aggregating
// subquery would swallow the row instead - same outcome today, wrong mechanism tomorrow.
CALL {
WITH callee, i
UNWIND [callee.sourceFile] + [(callee)-[:INCLUDES]->(pi:AstNode) | pi.sourceFile] AS pf
MATCH (pv:AstNode {project: $project, sourceFile: pf, paramPosition: toString(i)})
RETURN collect(pv) AS pvs
}
WITH callee, r, cm, i, argName, pvs
WHERE size(pvs) > 0
UNWIND [cm.sourceFile] + [(cm)-[:INCLUDES]->(ci:AstNode) | ci.sourceFile] AS cf
MATCH (cv:AstNode {project: $project, sourceFile: cf, name: argName})
WHERE cv.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE']
WITH callee, r, i, cv, pvfiles
UNWIND pvfiles AS pf
MATCH (pv:AstNode {project: $project, sourceFile: pf, paramPosition: toString(i)})
UNWIND pvs AS pv
MERGE (cv)-[:ARG_TO_PARAM {callSite: r.lineNo, position: i}]->(pv)
""";
/**
@@ -1259,18 +1295,23 @@ public final class CypherQueries {
MATCH (cm:AstNode {type: 'MODULE', project: $project, name: mn})
MATCH (src:AstNode {project: $project, sourceFile: cm.sourceFile})-[r:CALLS]->(callee:AstNode {type: 'MODULE', project: $project})
WHERE r.args IS NOT NULL AND callee.sourceFile <> "" AND r.calleeMethod IS NULL
WITH callee, r,
[cm.sourceFile] + [(cm)-[:INCLUDES]->(ci:AstNode) | ci.sourceFile] AS cvfiles,
[callee.sourceFile] + [(callee)-[:INCLUDES]->(pi:AstNode) | pi.sourceFile] AS pvfiles,
split(r.args, ',') AS args
// cm is carried through, unlike the previous shape which dropped it after building the
// file lists: the caller-side list is now built below, after the parameter lookup.
WITH callee, r, cm, split(r.args, ',') AS args
UNWIND range(0, size(args) - 1) AS i
WITH callee, r, i, trim(args[i]) AS argName, cvfiles, pvfiles
UNWIND cvfiles AS cf
WITH callee, r, cm, i, trim(args[i]) AS argName
CALL {
WITH callee, i
UNWIND [callee.sourceFile] + [(callee)-[:INCLUDES]->(pi:AstNode) | pi.sourceFile] AS pf
MATCH (pv:AstNode {project: $project, sourceFile: pf, paramPosition: toString(i)})
RETURN collect(pv) AS pvs
}
WITH callee, r, cm, i, argName, pvs
WHERE size(pvs) > 0
UNWIND [cm.sourceFile] + [(cm)-[:INCLUDES]->(ci:AstNode) | ci.sourceFile] AS cf
MATCH (cv:AstNode {project: $project, sourceFile: cf, name: argName})
WHERE cv.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE']
WITH callee, r, i, cv, pvfiles
UNWIND pvfiles AS pf
MATCH (pv:AstNode {project: $project, sourceFile: pf, paramPosition: toString(i)})
UNWIND pvs AS pv
MERGE (cv)-[:ARG_TO_PARAM {callSite: r.lineNo, position: i}]->(pv)
""";
/**
@@ -3408,19 +3449,19 @@ public final class CypherQueries {
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
queries.put(type, """
// Same INCLUDE-driven order as the whole-root variant above - see there for why.
UNWIND $names AS mn
MATCH (m:AstNode {project: $project, type: 'MODULE', name: mn})-[:CONTAINS]->(ph:AstNode {project: $project, sourceFile: "", type: 'VARIABLE'})
WHERE m.sourceFile <> ""
MATCH (m)-[:INCLUDES]->(s:AstNode {project: $project, type: 'DATA_STRUCTURE'})
WHERE s.sourceFile <> ""
MATCH (s)-[:CONTAINS*1..%d]->(realv:AstNode {project: $project, name: ph.name})
MATCH (m:AstNode {project: $project, type: 'MODULE', name: mn})-[:INCLUDES]->(s:AstNode {project: $project, type: 'DATA_STRUCTURE'})
WHERE m.sourceFile <> "" AND s.sourceFile <> ""
MATCH (s)-[:CONTAINS*1..%d]->(realv:AstNode {project: $project})
WHERE realv.sourceFile <> "" AND realv.type IN ['VARIABLE', 'CONSTANT']
// Item 81: when the reference was qualified by a group name the parser could not see
// as a USING member (e.g. #MAP-T.V-ID, where #MAP-T is a group inside an included
// PDA), keep only a realv nested under a group of that name — so an otherwise
// project-wide-ambiguous leaf (V-ID is in many PDAs) resolves uniquely.
AND (ph.qualifierGroup IS NULL
OR EXISTS { (s)-[:CONTAINS*0..%d]->(:AstNode {project: $project, type: 'DATA_STRUCTURE', name: ph.qualifierGroup})-[:CONTAINS*1..%d]->(realv) })
MATCH (m)-[:CONTAINS]->(ph:AstNode {project: $project, sourceFile: "", type: 'VARIABLE', name: realv.name})
// Item 81: when the reference was qualified by a group name the parser could not see
// as a USING member (e.g. #MAP-T.V-ID, where #MAP-T is a group inside an included
// PDA), keep only a realv nested under a group of that name - so an otherwise
// project-wide-ambiguous leaf (V-ID is in many PDAs) resolves uniquely.
WHERE ph.qualifierGroup IS NULL
OR EXISTS { (s)-[:CONTAINS*0..%d]->(:AstNode {project: $project, type: 'DATA_STRUCTURE', name: ph.qualifierGroup})-[:CONTAINS*1..%d]->(realv) }
WITH m, ph, collect(DISTINCT realv) AS matches
WHERE size(matches) = 1
WITH m, ph, matches[0] AS realv
@@ -3439,18 +3480,29 @@ public final class CypherQueries {
Map<EdgeType, String> queries = new EnumMap<>(EdgeType.class);
for (EdgeType type : RESOLVABLE_FIELD_EDGE_TYPES) {
queries.put(type, """
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS]->(ph:AstNode {project: $project, sourceFile: "", type: 'VARIABLE'})
WHERE m.sourceFile <> ""
MATCH (m)-[:INCLUDES]->(s:AstNode {project: $project, type: 'DATA_STRUCTURE'})
WHERE s.sourceFile <> ""
MATCH (s)-[:CONTAINS*1..%d]->(realv:AstNode {project: $project, name: ph.name})
// Driven by the INCLUDE, not by the placeholder (2026-09-05). The old order expanded
// every included data area's subtree once per placeholder of the including module:
// 380 639 subtree expansions on upms against 41 377 this way, and the profile showed
// 273M database hits producing 43 753 rows. Two consequences of the swap:
// * `ph` is now bound by all four properties of the (project, sourceFile, type,
// name) index, so it is an index seek. A composite index needs every property
// constrained and the old order had no name at that point - that is where the
// factor of 2 comes from, not from a lucky plan.
// * A module with includes but no placeholders now expands for nothing: 1 359 of
// upms's 3 489 including modules, ~11 618 wasted expansions. Deliberate - a
// fraction of the 339 262 expansions saved, and it shrinks precisely when
// placeholders are still unresolved, i.e. when this step has real work to do.
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:INCLUDES]->(s:AstNode {project: $project, type: 'DATA_STRUCTURE'})
WHERE m.sourceFile <> "" AND s.sourceFile <> ""
MATCH (s)-[:CONTAINS*1..%d]->(realv:AstNode {project: $project})
WHERE realv.sourceFile <> "" AND realv.type IN ['VARIABLE', 'CONSTANT']
// Item 81: when the reference was qualified by a group name the parser could not see
// as a USING member (e.g. #MAP-T.V-ID, where #MAP-T is a group inside an included
// PDA), keep only a realv nested under a group of that name — so an otherwise
// project-wide-ambiguous leaf (V-ID is in many PDAs) resolves uniquely.
AND (ph.qualifierGroup IS NULL
OR EXISTS { (s)-[:CONTAINS*0..%d]->(:AstNode {project: $project, type: 'DATA_STRUCTURE', name: ph.qualifierGroup})-[:CONTAINS*1..%d]->(realv) })
MATCH (m)-[:CONTAINS]->(ph:AstNode {project: $project, sourceFile: "", type: 'VARIABLE', name: realv.name})
// Item 81: when the reference was qualified by a group name the parser could not see
// as a USING member (e.g. #MAP-T.V-ID, where #MAP-T is a group inside an included
// PDA), keep only a realv nested under a group of that name - so an otherwise
// project-wide-ambiguous leaf (V-ID is in many PDAs) resolves uniquely.
WHERE ph.qualifierGroup IS NULL
OR EXISTS { (s)-[:CONTAINS*0..%d]->(:AstNode {project: $project, type: 'DATA_STRUCTURE', name: ph.qualifierGroup})-[:CONTAINS*1..%d]->(realv) }
WITH m, ph, collect(DISTINCT realv) AS matches
WHERE size(matches) = 1
WITH m, ph, matches[0] AS realv
@@ -3585,6 +3637,32 @@ public final class CypherQueries {
* module has no matching {@code INCLUDES} edge (e.g. {@code GLOBAL USING}/copycode) are picked
* up by {@link #resolvePlaceholderFieldTargetsByName} afterwards. Run after every ingest,
* alongside {@link #resolvePlaceholderTargets}.
*
* <p>Item 159 — driving order. The {@code INCLUDES} edge still decides <em>which</em> data area
* counts, but it no longer <em>finds</em> {@code real}: the resolution
* {@code (ph, phv) -> real -> realv} runs first, on the 485 placeholder-field pairs of
* {@code upms}, and the reference edges join on afterwards. Before, every one of the ~220 000
* {@code (ph, phv, src, m)} rows re-expanded {@code real}'s field subtree and re-scanned
* {@code m}'s include list; now the subtree is expanded 520 times, once per {@code (real, phv)}
* pair. Measured read-only on the live graph: the whole prefix through {@code realv} costs
* 1.4 s. This is the same hoist as item 157 and matches the shape
* {@link #resolvePlaceholderFieldTargetsByName} already had.
*
* <p>Two clauses are load-bearing and must not be "simplified" away — both were verified with
* {@code EXPLAIN}: the {@code WITH DISTINCT} is a planner barrier (without it the planner
* reverts to the reference-driven order and the hoist is undone), and the {@code INCLUDES} check
* is an {@code EXISTS} predicate rather than a {@code MATCH} (as a {@code MATCH} the planner
* sources {@code m} from {@code real}'s includers and builds an {@code m x src} cartesian).
*
* <p>The order is safe only while a placeholder's {@code DATA_STRUCTURE} name stays selective
* enough that the index seek on {@code (project, type, name)} does not out-fan the include list
* it replaces — on {@code upms} at most 4 real structures share a placeholder name (mean 1.45).
* A corpus where a qualified reference names generator boilerplate duplicated across hundreds of
* files would push the cost back into this seek; the result stays correct either way, since the
* {@code (m)-[:INCLUDES]->(real)} check below still filters.
*
* <p>The {@code _SCOPED} variant keeps the module-driven order on purpose: it starts from
* {@code $names} and processes a handful of modules, where there is nothing to hoist.
*/
public static String resolvePlaceholderFieldTargets(EdgeType type) {
String query = RESOLVE_PLACEHOLDER_FIELD_TARGETS_BY_TYPE.get(type);
@@ -3615,10 +3693,12 @@ public final class CypherQueries {
MATCH (ph:AstNode {project: $project, sourceFile: "", type: 'DATA_STRUCTURE'})
-[:CONTAINS]->(phv:AstNode {project: $project, sourceFile: ""})
WHERE phv.type IN ['VARIABLE', 'CONSTANT']
MATCH (src:AstNode)-[r:%s]->(phv)
MATCH (m:AstNode {project: $project, type: 'MODULE'})-[:CONTAINS*0..1]->(src)
WHERE m.sourceFile <> ""
MATCH (m)-[:INCLUDES]->(real:AstNode {project: $project, type: 'DATA_STRUCTURE', name: ph.name})
// Item 159: resolve (ph, phv) -> realv FIRST, while the row count is still the
// number of placeholder fields; the reference edges join on afterwards. `real`
// comes from the (project, type, name) index instead of from a name scan over
// the module's include list, so the field subtree is expanded once per
// (phv, real) pair rather than once per reference.
MATCH (real:AstNode {project: $project, type: 'DATA_STRUCTURE', name: ph.name})
WHERE real.sourceFile <> ""
MATCH (real)-[:CONTAINS*1..10]->(realv:AstNode {project: $project, name: phv.name})
// Item 80: a qualified reference may name a group, not a leaf, so a DATA_STRUCTURE
@@ -3626,6 +3706,17 @@ public final class CypherQueries {
// Safe here (qualifier-scoped via INCLUDES, no size(matches)=1 guard); the bare-field
// resolvers keep the leaf-only filter so an added group can't make them ambiguous.
WHERE realv.sourceFile <> "" AND realv.type IN ['VARIABLE', 'CONSTANT', 'DATA_STRUCTURE']
// The WITH is a planner barrier, not cosmetics: without it the planner falls back
// to the reference-driven order and re-expands the subtree per row (verified with
// EXPLAIN). DISTINCT only collapses the duplicate INCLUDES edges, which MERGE
// deduplicated anyway; `ph` is dropped but `real` carries its identity.
WITH DISTINCT phv, real, realv
MATCH (phv)<-[r:%s]-(src:AstNode)
MATCH (src)<-[:CONTAINS*0..1]-(m:AstNode {project: $project, type: 'MODULE'})
// EXISTS, not MATCH: as a MATCH the planner sources `m` from `real`'s includers
// and builds an m x src cartesian; as a predicate it stays a bounded check on the
// module that already owns `src`.
WHERE m.sourceFile <> "" AND EXISTS { (m)-[:INCLUDES]->(real) }
MERGE (src)-[r2:%s {%s}]->(realv)
SET r2 += properties(r)
SET r2.lineNo = r.lineNo

View File

@@ -9,6 +9,8 @@ import org.jboss.logging.Logger;
import org.jspecify.annotations.Nullable;
import org.neo4j.driver.*;
import org.neo4j.driver.Record;
import org.neo4j.driver.summary.ProfiledPlan;
import org.neo4j.driver.summary.ResultSummary;
import org.neo4j.driver.summary.SummaryCounters;
import java.util.*;
@@ -1662,7 +1664,14 @@ public class GraphRepository {
public Uni<Void> persist(String project, ParseResult result, boolean reconcile) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
session.executeWriteWithoutResult(tx -> mergeResults(tx, project, List.of(result), reconcile));
// Single-file persist (scoped deep ingest): timed like the batch path, logged at DEBUG
// because a fan-out warm runs it hundreds of times and would drown the log at INFO.
PersistStats stats = new PersistStats();
long start = System.nanoTime();
session.executeWriteWithoutResult(tx -> mergeResults(tx, project, List.of(result), reconcile, stats));
if (LOG.isDebugEnabled()) {
LOG.debugf("%s [%s]", stats.format(1, reconcile, (System.nanoTime() - start) / 1_000_000L), project);
}
}
return result;
}).replaceWithVoid();
@@ -1779,20 +1788,46 @@ public class GraphRepository {
public Uni<Void> persistBatch(String project, List<ParseResult> results, boolean reconcile) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
session.executeWriteWithoutResult(tx -> mergeResults(tx, project, results, reconcile));
PersistStats stats = new PersistStats();
long start = System.nanoTime();
session.executeWriteWithoutResult(tx -> mergeResults(tx, project, results, reconcile, stats));
LOG.infof("%s [%s]", stats.format(results.size(), reconcile,
(System.nanoTime() - start) / 1_000_000L), project);
}
return results;
}).replaceWithVoid();
}
/**
* @param level how far enrichment goes — see {@link EnrichmentLevel}. Field-placeholder
* resolution (the expensive part) runs only for {@link EnrichmentLevel#FULL};
* the other levels leave field placeholders for a later (scoped) deep ingest.
* Only steps at least this slow get a profile block — 8 of upms's 51, not all 51.
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level) {
return runEnrichment(project, enrichmentSteps(level.dataflow(), level.resolveFields(), false),
Map.of("project", project), level.name().toLowerCase(java.util.Locale.ROOT));
private static final int PROFILE_LOG_THRESHOLD_MS = 5_000;
/**
* Logs the operators that dominate one profiled enrichment step, most database hits first.
*
* <p>Answers the question the step timing alone cannot: of the time a step spends, how much is
* <em>finding</em> the rows (expansions, index seeks) versus <em>writing</em> them (MERGE,
* DELETE). The persist instrumentation resolved the same class of question one level up, and
* turned a 460 s unknown into a named 345 s statement.
*/
private static void logProfile(String project, String label, ProfiledPlan plan) {
List<ProfiledPlan> flat = new ArrayList<>();
Deque<ProfiledPlan> stack = new ArrayDeque<>();
stack.push(plan);
while (!stack.isEmpty()) {
ProfiledPlan current = stack.pop();
flat.add(current);
current.children().forEach(stack::push);
}
flat.sort(Comparator.comparingLong(ProfiledPlan::dbHits).reversed());
StringBuilder top = new StringBuilder();
for (ProfiledPlan op : flat.subList(0, Math.min(5, flat.size()))) {
top.append(top.isEmpty() ? "" : " | ")
.append(op.operatorType()).append(' ').append(op.dbHits()).append(" hits, ")
.append(op.records()).append(" rows");
}
LOG.infof("Profile %s %s: %s", project, label, top);
}
/**
@@ -1814,6 +1849,24 @@ public class GraphRepository {
return runEnrichment(project, enrichmentSteps(true, true, true), params, "scoped-deep");
}
/**
* @param level how far enrichment goes — see {@link EnrichmentLevel}. Field-placeholder
* resolution (the expensive part) runs only for {@link EnrichmentLevel#FULL};
* the other levels leave field placeholders for a later (scoped) deep ingest.
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level) {
return finalizeProject(project, level, false);
}
/**
* @param profile diagnostic profiling of the slow steps — see
* {@link #runEnrichment(String, List, Map, String, boolean)}.
*/
public Uni<Void> finalizeProject(String project, EnrichmentLevel level, boolean profile) {
return runEnrichment(project, enrichmentSteps(level.dataflow(), level.resolveFields(), false),
Map.of("project", project), level.name().toLowerCase(java.util.Locale.ROOT), profile);
}
/**
* Runs an ordered list of enrichment steps, each in its own transaction (bounding per-tx
* memory), logging duration/rows/heap per step. {@code params} is passed to every step; steps
@@ -1821,15 +1874,32 @@ public class GraphRepository {
*/
private Uni<Void> runEnrichment(String project, List<EnrichmentStep> steps,
Map<String, Object> params, String mode) {
return runEnrichment(project, steps, params, mode, false);
}
/**
* @param profile diagnostic (2026-09-05): run every step under {@code PROFILE} and log the
* operators that dominate the slow ones. Off by default because profiling costs
* 10-30% on its own — a permanently profiled refresh would be a regression, and the
* numbers it yields describe the <em>distribution</em> inside a step, not a new
* reference for total runtime.
*/
private Uni<Void> runEnrichment(String project, List<EnrichmentStep> steps,
Map<String, Object> params, String mode, boolean profile) {
return Uni.createFrom().item(() -> {
try (Session session = driver.session()) {
LOG.infof("Finalize %s (%s): %d steps", project, mode, steps.size());
LOG.infof("Finalize %s (%s): %d steps%s", project, mode, steps.size(),
profile ? " [PROFILE]" : "");
for (int i = 0; i < steps.size(); i++) {
EnrichmentStep step = steps.get(i);
long start = System.nanoTime();
SummaryCounters c = session.executeWrite(tx ->
tx.run(step.cypher(), params).consume().counters());
String cypher = profile ? "PROFILE " + step.cypher() : step.cypher();
ResultSummary summary = session.executeWrite(tx -> tx.run(cypher, params).consume());
SummaryCounters c = summary.counters();
long ms = (System.nanoTime() - start) / 1_000_000;
if (profile && ms >= PROFILE_LOG_THRESHOLD_MS && summary.hasProfile()) {
logProfile(project, step.label(), summary.profile());
}
Runtime rt = Runtime.getRuntime();
long usedMb = (rt.totalMemory() - rt.freeMemory()) >> 20;
LOG.infof("Finalize %s [%d/%d] %s: %d ms; rels +%d/-%d, nodes +%d/-%d, props %d; JVM heap %d/%d MB",
@@ -2513,7 +2583,8 @@ public class GraphRepository {
* is true, also sweeps each re-parsed file's stale nodes — those the fresh parse no longer
* produces (item 58); pass {@code false} for a coarse Tier-1 scan (subset node set).
*/
private void mergeResults(TransactionContext tx, String project, List<ParseResult> results, boolean reconcile) {
private void mergeResults(TransactionContext tx, String project, List<ParseResult> results, boolean reconcile,
PersistStats stats) {
List<Map<String, @Nullable Object>> namedNodes = new ArrayList<>();
List<Map<String, @Nullable Object>> positionalNodes = new ArrayList<>();
Map<EdgeType, List<Map<String, @Nullable Object>>> edgesByType = new EnumMap<>(EdgeType.class);
@@ -2529,7 +2600,9 @@ public class GraphRepository {
// Real (sourceFile, ownerModule) pairs touched by this parse, for the item-58 stale-node sweep
// and the item-86/106/124 edge reaps below. Item 75-B: the pair, not the file alone — several
// includers' copycode nodes share one sourceFile and only the re-parsed owner's may be swept.
Set<Map<String, Object>> freshFiles = new LinkedHashSet<>();
// Item 160: grouped as file -> owners, not a flat pair set. One seek per file instead of one
// per pair; see DELETE_STALE_FILE_NODES for the measurement.
Map<String, Set<String>> freshOwnersByFile = new LinkedHashMap<>();
// Item 76: owning modules whose placeholders this parse refreshed, for the placeholder sweep.
Set<String> freshPlaceholderOwners = new HashSet<>();
// Different files emit the same placeholder (e.g. a CALLNAT/USING target, sourceFile="")
@@ -2538,6 +2611,10 @@ public class GraphRepository {
// (canonical = first one seen) and remap every edge endpoint onto its nid.
Map<String, Long> canonicalNidByKey = new HashMap<>();
Map<String, Long> nidByParserId = new HashMap<>();
// Item 141 follow-up: the Java half of the batch — ~29k parameter maps per batch on upms — is
// timed too. Measuring only the Cypher would leave a remainder unattributed, which is the very
// thing this instrumentation exists to remove.
stats.run("prepare", () -> {
for (ParseResult result : results) {
String ownerFile = result.nodes().stream()
.filter(n -> n.type() == NodeType.MODULE && !n.sourceFile().isEmpty())
@@ -2556,7 +2633,8 @@ public class GraphRepository {
(positional ? positionalNodes : namedNodes).add(params);
String owner = nodeOwner(node, ownerFile);
if (!node.sourceFile().isEmpty()) {
freshFiles.add(Map.of("f", node.sourceFile(), "o", owner));
freshOwnersByFile.computeIfAbsent(node.sourceFile(), k -> new LinkedHashSet<>())
.add(owner);
} else if (!owner.isEmpty()) {
freshPlaceholderOwners.add(owner);
}
@@ -2581,11 +2659,19 @@ public class GraphRepository {
edgesByType.computeIfAbsent(edge.type(), k -> new ArrayList<>()).add(params);
}
}
});
// Item 160: the parameter shape the five reap/sweep statements below expect — one entry per
// file, carrying that file's owners as a list.
List<Map<String, Object>> freshFiles = freshOwnersByFile.entrySet().stream()
.map(e -> Map.of("f", e.getKey(), "os", List.copyOf(e.getValue())))
.toList();
if (!namedNodes.isEmpty()) {
tx.run(CypherQueries.MERGE_NODES, Map.of("nodes", namedNodes, "ingestGen", ingestGen));
stats.runQuery("merge-nodes", tx, CypherQueries.MERGE_NODES,
Map.of("nodes", namedNodes, "ingestGen", ingestGen));
}
if (!positionalNodes.isEmpty()) {
tx.run(CypherQueries.MERGE_POSITIONAL_NODES, Map.of("nodes", positionalNodes, "ingestGen", ingestGen));
stats.runQuery("merge-positional-nodes", tx, CypherQueries.MERGE_POSITIONAL_NODES,
Map.of("nodes", positionalNodes, "ingestGen", ingestGen));
}
// Item 86: reap a re-parsed Natural file's stale DB_TABLE/WORKFILE access edges *before*
// re-merging the fresh ones, so a statement whose access target changed between parses (e.g.
@@ -2593,39 +2679,40 @@ public class GraphRepository {
// orphan its old edge to a never-swept placeholder. The fresh merge below re-creates the
// current edges; unchanged ones round-trip identically.
if (!freshFiles.isEmpty()) {
tx.run(CypherQueries.DELETE_STALE_NATURAL_TABLE_ACCESS_EDGES,
Map.of("project", project, "files", List.copyOf(freshFiles)));
stats.runQuery("reap-table-access-edges", tx, CypherQueries.DELETE_STALE_NATURAL_TABLE_ACCESS_EDGES,
Map.of("project", project, "files", freshFiles));
// Item 106: same for USING edges, which resolve onto REAL data-area nodes and so are not
// covered by the placeholder-only reap above.
tx.run(CypherQueries.DELETE_STALE_NATURAL_USING_EDGES,
Map.of("project", project, "files", List.copyOf(freshFiles)));
stats.runQuery("reap-using-edges", tx, CypherQueries.DELETE_STALE_NATURAL_USING_EDGES,
Map.of("project", project, "files", freshFiles));
// Item 124: same for CALLS. A parser fix that changes a call's target leaves both endpoints
// alive (the calling subroutine is unchanged; the old target is a never-swept placeholder),
// so the corrected call was only ever added beside the wrong one. Deep re-ingest only —
// two dynamic-call resolvers are skipped in a coarse finalize and could not rebuild what
// this deletes.
if (reconcile) {
tx.run(CypherQueries.DELETE_STALE_NATURAL_CALL_EDGES,
Map.of("project", project, "files", List.copyOf(freshFiles)));
stats.runQuery("reap-call-edges", tx, CypherQueries.DELETE_STALE_NATURAL_CALL_EDGES,
Map.of("project", project, "files", freshFiles));
}
}
for (Map.Entry<EdgeType, List<Map<String, @Nullable Object>>> entry : edgesByType.entrySet()) {
tx.run(CypherQueries.mergeEdgesBatch(entry.getKey()),
stats.countEdgeType();
stats.runQuery("merge-edges", tx, CypherQueries.mergeEdgesBatch(entry.getKey()),
Map.of("edges", entry.getValue(), "ingestGen", ingestGen));
}
// Item 58: after merging the fresh nodes (whose ids are now written), delete each re-parsed
// file's nodes that the fresh parse no longer produced (renamed/removed fields, moved
// statements) so a refresh purges stale nodes instead of leaving them to shadow the new ones.
if (reconcile && !freshFiles.isEmpty()) {
tx.run(CypherQueries.DELETE_STALE_FILE_NODES, Map.of("project", project,
"files", List.copyOf(freshFiles), "ingestGen", ingestGen));
stats.runQuery("sweep-stale-file-nodes", tx, CypherQueries.DELETE_STALE_FILE_NODES,
Map.of("project", project, "files", freshFiles, "ingestGen", ingestGen));
}
// Item 76: sweep per-module field placeholders (sourceFile="") that a re-ingested owner file
// no longer produces. The file sweep above skips sourceFile="" nodes; without this an
// ownerModule-keyed placeholder would orphan on refresh.
if (reconcile && !freshPlaceholderOwners.isEmpty()) {
tx.run(CypherQueries.DELETE_STALE_PLACEHOLDER_NODES, Map.of("project", project,
"owners", List.copyOf(freshPlaceholderOwners), "ingestGen", ingestGen));
stats.runQuery("sweep-stale-placeholders", tx, CypherQueries.DELETE_STALE_PLACEHOLDER_NODES,
Map.of("project", project, "owners", List.copyOf(freshPlaceholderOwners), "ingestGen", ingestGen));
}
if (skippedEdges[0] > 0) {
LOG.warnf("Dropped %d edge(s) whose endpoints this parse did not emit — the call graph "
@@ -2637,8 +2724,73 @@ public class GraphRepository {
// beside the new one. Deep re-ingest only (reconcile) — finalize's field resolution always
// follows and re-links them.
if (reconcile && !freshFiles.isEmpty()) {
tx.run(CypherQueries.DELETE_STALE_RESOLVED_FIELD_EDGES,
Map.of("project", project, "files", List.copyOf(freshFiles)));
stats.runQuery("sweep-resolved-field-edges", tx, CypherQueries.DELETE_STALE_RESOLVED_FIELD_EDGES,
Map.of("project", project, "files", freshFiles));
}
}
/**
* Per-batch timing of the persist path, so the ingest phase is attributable instead of being one
* opaque number. The finalize phase has had per-step timings since day one ({@code runEnrichment});
* persistence had none, and a deep refresh of {@code upms} spends <b>724 s</b> of its 1 256 s here
* against only 18 s of parsing — measured 2026-09-04, which is why this exists.
*
* <p>Statements are timed by <em>consuming</em> each result where they previously streamed
* lazily. The work happens inside the same transaction either way; consuming only decides when
* it is materialised, and buys the {@link SummaryCounters} that say what a step actually wrote.
* The residual {@code commit} in the log line is deliberate: total minus the sum of the labels is
* the transaction commit, which belongs to no statement. Naming it keeps the line honest —
* an unexplained remainder is exactly what this instrumentation exists to remove.
*/
private static final class PersistStats {
private final Map<String, Long> millisByLabel = new LinkedHashMap<>();
private long nodesCreated, nodesDeleted, relsCreated, relsDeleted;
private int edgeTypes;
/**
* Times {@code work} under {@code label}, accumulating repeated labels (e.g. per edge type).
*/
void run(String label, Runnable work) {
long start = System.nanoTime();
work.run();
millisByLabel.merge(label, (System.nanoTime() - start) / 1_000_000L, Long::sum);
}
/**
* Times one Cypher statement, consuming it so the counters and the duration are real.
*/
void runQuery(String label, TransactionContext tx, String cypher, Map<String, Object> params) {
long start = System.nanoTime();
SummaryCounters counters = tx.run(cypher, params).consume().counters();
millisByLabel.merge(label, (System.nanoTime() - start) / 1_000_000L, Long::sum);
nodesCreated += counters.nodesCreated();
nodesDeleted += counters.nodesDeleted();
relsCreated += counters.relationshipsCreated();
relsDeleted += counters.relationshipsDeleted();
}
void countEdgeType() {
edgeTypes++;
}
/**
* @param totalMs the whole transaction including commit, measured by the caller
*/
String format(int files, boolean reconcile, long totalMs) {
StringBuilder line = new StringBuilder();
long attributed = 0;
for (Map.Entry<String, Long> entry : millisByLabel.entrySet()) {
line.append(line.isEmpty() ? "" : ", ").append(entry.getKey()).append(' ').append(entry.getValue());
attributed += entry.getValue();
if ("merge-edges".equals(entry.getKey())) {
line.append(" (").append(edgeTypes).append(" types)");
}
}
line.append(", commit ").append(Math.max(0, totalMs - attributed));
return "Persist %s %d files: total %d ms = %s; nodes +%d/-%d, rels +%d/-%d"
.formatted(reconcile ? "deep" : "coarse", files, totalMs, line, nodesCreated, nodesDeleted,
relsCreated, relsDeleted);
}
}

View File

@@ -13,17 +13,32 @@ services:
# collect rather than commit.
NEO4J_server_memory_heap_max__size: "2G"
NEO4J_server_memory_heap_initial__size: "512M"
# Deliberately raised 512M -> 1G alongside the smaller heap: the store is 2.0 GB, so a
# bigger page cache offsets the tighter heap instead of pushing the load onto disk.
NEO4J_server_memory_pagecache_size: "1G"
# 512M -> 1G -> 3G -> 2G. The store has grown to 2.4 GB (1.2 GB of it range indexes), so 1G
# covered only ~42% of it. 2G covers nearly all of it; the 2.5 GB of transaction logs are
# deliberately not counted, Neo4j does not keep them here. 3G was measured (see below) and then
# traded back down to 2G to leave host RAM for a 4 GB VirtualBox guest that runs alongside — the
# difference between 2G and 3G is expected to be under 1% but was NOT measured.
#
# Measured at 3G, not assumed (item 161): the cache buys ~6% on a deep refresh of upms —
# 552 s -> 519 s, persist 217.9 -> 207.1 s, finalize 316.4 -> 298.9 s. Modest on purpose to record: `directio`
# is false, so Neo4j reads through the OS file cache and the store is cached twice. Block I/O
# across two whole refresh runs was 237 MB read against a 2.4 GB store — there was never disk
# I/O to save. A cache miss costs a read() syscall plus a copy, not a seek, and 6% is what
# removing those is worth. The cost of this setting is 2 GiB of host RAM; whether that trade is
# right depends on the machine, so treat 3G as a measured data point, not a recommendation.
# Community Edition cannot pre-warm the cache: the first refresh after a restart runs cold
# (540 s here) and is not a fair measurement.
NEO4J_server_memory_pagecache_size: "2G"
# Return committed-but-unused heap to the OS while idle instead of sitting on it. Without this
# the JVM held everything it had ever needed: 6.17 GiB an hour after a refresh had finished.
NEO4J_server_jvm_additional: "-XX:G1PeriodicGCInterval=300000"
# Hard ceiling: heap 2G + pagecache 1G + metaspace, direct buffers and GC overhead.
# Raised 4g -> 5g after the verification run peaked at exactly 4096 of 4096 MB — no OOM-kill,
# but zero reserve. An OOM-kill mid-refresh leaves the graph half-updated, which is far worse
# than a spare GB. Still well under the 6.17 GiB measured with no cap at all.
mem_limit: 5g
# Hard ceiling: heap 2G + pagecache 2G + metaspace, direct buffers and GC overhead.
# Raised 4g -> 5g after a verification run peaked at exactly 4096 of 4096 MB — no OOM-kill, but
# zero reserve. An OOM-kill mid-refresh leaves the graph half-updated, which is far worse than a
# spare GB. This follows the page cache: Neo4j's own rule of thumb is heap + page cache + ~1G, so
# 2G of cache means 6g here — 1G of reserve, not generosity. Do not raise the page cache without
# raising this too.
mem_limit: 6g
volumes:
- neo4j-data:/data
healthcheck:

View File

@@ -100,6 +100,66 @@ and folding them into the default result set would move every existing completen
131's lesson). The flip side is the rule to remember — **an empty default search says nothing about
comments.**
## Diagnosing a slow refresh (items 153/155)
Two instrumentation layers, both aimed at the same question — *where does the time go?*
* **Always on:** every persist batch logs one line with its statement breakdown
(`Persist deep 200 files: total 8740 ms = prepare 108, merge-nodes 1630, ..., commit 20`), and every
enrichment step logs duration plus created/deleted rows. The residual `commit` is deliberate: total
minus the labels is the transaction commit, so nothing hides in an unnamed remainder.
* **Opt-in per run:** `POST /refresh?deep=true&profile=true` (`ac refresh --deep --profile`) runs the
enrichment steps under Cypher `PROFILE` and logs the five heaviest operators of every step slower
than 5 s. Diagnostic only — it answers "is this step matching or writing?", which the step timing
alone cannot. Measured overhead on `upms`: none worth reporting (908 s vs 905 s).
What that measured, so nobody re-derives it: an `upms` deep refresh is ~310 s persist and ~600 s
enrichment; the five field-resolution steps are 88 % of the latter and every one of them is dominated
by *matching*, not writing — `resolve-bare-included` spends 273 M database hits to produce 43 753
rows. See roadmap item 156.
## `limit`/`offset` work on some endpoints and are silently ignored on others
**Every endpoint answers completely.** The difference is whether it lets you ask for less. 19 list
endpoints declare no `limit`/`offset` at all, and JAX-RS drops an undeclared query parameter without
a word — so `?limit=2` there is not an error, it simply has no effect:
```
GET /upms/modules?limit=2 -> 3 587 rows
GET /upms/modules/WAGNTX0S/functions?limit=2 -> 22 rows
GET /upms/modules/WAGNTX0S/data-structures?limit=1 -> 16 rows
```
**Ignore `limit`/`offset` (always the full list):**
`/modules` · `/modules/{name}/functions` · `/modules/{name}/functions/overrides` ·
`/modules/{name}/functions/{function}/overrides` · `/modules/{name}/data-structures` ·
`/modules/{name}/columns` · `/modules/{name}/payload` · `/modules/{name}/dispatch-table` ·
`/modules/{name}/sql-statements` · `/data-structures/{name}/fields` · `/db-tables/{name}/columns` ·
`/variables/{name}/reads` · `/variables/{name}/writes` · `/variables/{name}/flow-forward` ·
`/variables/{name}/flow-backward` · `/variables/{name}/field-flow` · `/duplicates` ·
`/dynamic-calls/unresolved` · `/dynamic-calls/overrides`
**Honour them** (14, verified against the method signatures): the search endpoints
(`search/identifier`, `search/value`, `search/annotation`, `search/references`, `search/source`),
`rest-endpoints`, and per module `callers`, `callees`, `context`, `db-accesses`, `graph`, `reaches`,
`workfile-accesses`, `comments`.
`call-tree` is bounded differently again — by `depth`, not by row count — which is the right shape
for a tree but means `limit` does nothing there either.
Which direction the mistake runs matters, so be precise about it: a dropped `limit` means you get
**more** than you asked for, never less. It costs tokens, never correctness — the opposite of item
131's failure, where a silent 50-row cap was read as the complete set. Nothing here can under-report.
The practical consequence is budget, not trust: `/modules` on `upms` is 3 587 rows in one response.
Narrow with the filters those endpoints *do* have (`?kind=`, `?sourceFile=`, `?module=`,
`?extendsName=`) rather than with a `limit` that will be ignored, and prefer `/modules/{name}/digest`
or `/context` when you want an overview rather than an enumeration.
*(Implementing `limit` on those 19 was considered and deliberately not done: the endpoints are honest
as they stand, and a `limit` that ever acquired a default would reintroduce exactly the silent
truncation item 131 removed.)*
## Truncation is now visible on the search endpoints (item 131)
`search/identifier`, `search/value`, `search/annotation`, `search/references` and

View File

@@ -4804,3 +4804,305 @@ reproduced the bug.)*
and per-field inline notes
```
## Persist-Phase: Instrumentierung und der Platzhalter-Sweep — 2026-09-05
- [x] **153. Die Persistenz-Phase war eine einzige undurchsichtige Zahl** (2026-09-05)
Die Finalize-Phase loggt seit jeher pro Schritt (`runEnrichment`: Dauer, erzeugte/gelöschte Knoten
und Kanten). Die Persistenz hatte nichts dergleichen — ein Deep-Refresh von `upms` verbrachte dort
**724 s von 1 256 s**, aufgeteilt auf 32 Batch-Zeilen ohne jede Aufschlüsselung. Eine Hochrechnung
aus der reinen MERGE-Rate erklärte nur ~234 s davon; **~460 s waren nicht zuzuordnen**.
`GraphRepository.mergeResults` führt pro Batch 5–9 Statements in einer Transaktion aus
(Knoten-Merges, bis zu 17 Kanten-Merges je `EdgeType`, fünf Stale-Sweeps), von denen keines
konsumiert wurde — also weder Zeiten noch `SummaryCounters`.
**Gebaut:** `PersistStats` misst jedes Statement (via `.consume()`, was zugleich die Counters
liefert) plus die Java-seitige Vorbereitung, und loggt **eine** aggregierte Zeile pro Batch:
```
Persist deep 200 files: total 8740 ms = prepare 108, merge-nodes 1630, merge-positional-nodes 506,
reap-table-access-edges 310, reap-using-edges 161, reap-call-edges 175, merge-edges 1479 (6 types),
sweep-stale-file-nodes 132, sweep-stale-placeholders 1887, sweep-resolved-field-edges 278, commit 20
```
Bewusst *eine* Zeile statt einer pro Statement: pro Statement wären es ~800 Zeilen je Refresh, die
die 51 Finalize-Zeilen zudecken würden. Der Rest `commit` = Gesamt − Summe der Labels ist Absicht —
ein unbenannter Rest ist genau das, was diese Instrumentierung beseitigen soll. Der Einzeldatei-Pfad
loggt auf DEBUG, weil ein Fan-out-Warm ihn hunderte Male durchläuft.
**Ergebnis der ersten Messung** (upms, deep, 2026-09-05): Parsen 18 s, Persist 639 s, Finalize 567 s.
`sweep-stale-placeholders` **344,9 s = 54 % der Persist-Phase und 28 % des Gesamtlaufs**. Die
Java-Vorbereitung, die ich als möglichen versteckten Kostenblock vermutet hatte: **1,5 s**.
- [x] **154. `DELETE_STALE_PLACEHOLDER_NODES` lief einmal pro Owner statt einmal pro Batch**
(2026-09-05, gefunden durch Item 153)
Der Sweep (Item 76) machte `UNWIND $owners AS o MATCH (n {project, sourceFile: "", ownerModule: o})`.
Kein Index deckt `ownerModule` ab, also lief jeder Seek über `ast_node_project_sourcefile` — und da
**alle** Platzhalter `sourceFile = ""` teilen, lieferte jeder Seek sämtliche 14 551 Platzhalter des
Projekts und verwarf alle bis auf eine Handvoll. Bei ~200 Ownern je Batch × 32 Batches sind das
~6 400 vollständige Durchläufe durch denselben Bucket.
**Fix:** ein Durchlauf, gefiltert gegen die Liste (`WHERE n.ownerModule IN $owners`). Semantisch
identisch (dieselbe Knotenmenge; `IN` dedupliziert doppelte Owner, was bei einem DELETE folgenlos
ist), eine Zeile Cypher, kein neuer Index.
| | vorher | nachher |
|---|---|---|
| `sweep-stale-placeholders` | 344,9 s | **3,6 s** (−99 %) |
| Persist-Phase gesamt | 639 s | **332 s** (−48 %) |
| Deep-Refresh `upms` gesamt | 1 225 s | **956 s** (−22 %) |
**Zwei Irrwege, die die Messung erspart hat** — beide plausibel, beide falsch:
1. Bei `resolve-bare-included` sah der Plan nach „erst expandieren, dann filtern" aus. Der
Index-First-Umbau (Seek auf `(project, name)`, dann Enthaltensein prüfen) war **langsamer** und
lief nach 10 Minuten noch — Feldnamen sind projektweit nicht selektiv genug.
2. Für *diesen* Sweep war der naheliegende Vorschlag ein Index auf `(project, ownerModule)`. Die in
der Validierung gegengemessene Umformulierung war 3× schneller **ohne** Index — und damit ohne
Schreib-Aufschlag auf jeden der 940 k Knoten-Merges. Ein Index bleibt als nächster Hebel offen,
muss sich aber gegen diese Form beweisen.
Auch die Erfolgsprognose war falsch, wenn auch in die günstige Richtung: geschätzt waren ~100 s
Ersparnis (Annahme: das `DETACH DELETE` sei irreduzibel), tatsächlich wurden es ~341 s. Die Kosten
steckten fast vollständig im wiederholten Scannen, nicht im Löschen.
**Nächster Hebel** (gemessen, offen): die Finalize-Phase ist jetzt mit 607 s der größere Posten,
davon `resolve-bare-included` READS+WRITES 331 s und `link-args-to-params` 105 s (0 erzeugte
Kanten). Zusammen 436 s = 72 % der Finalize-Phase.
- [x] **155. Enrichment-Schritte profilierbar (`refresh?profile=true`)** (2026-09-05)
Die Persist-Instrumentierung (Item 153) beantwortete „welches Statement", nicht „wofür innerhalb
eines Statements". Für die fünf teuren Feldschritte war damit offen, ob ihre Zeit im Matching oder
im Schreiben steckt — und ein Lese-Nachbau kann es nicht beantworten, weil er nach dem Refresh nur
die Reste sieht (der Fehlschluss „der Schritt findet nichts" lag genau darin bereit).
`runEnrichment` stellt jedem Schritt optional `PROFILE` voran und loggt für jeden Schritt ab 5 s die
fünf Operatoren mit den meisten db-hits (8 der 51 Schritte statt aller). Opt-in pro Lauf über
`POST /refresh?profile=true` und `ac refresh --profile`, beide ausdrücklich als **DIAGNOSTIC**
gekennzeichnet und im Agent-Guide nur im Performance-Abschnitt geführt, nicht in der
Endpoint-Tabelle: ein Profiling-Schalter ist Werkzeug für Entwickler, nicht für Agenten.
Gemessener Overhead: **keiner** (908 s profiliert gegen 905 s unprofiliert). Meine Warnung vor
10-30 % hat sich nicht bestätigt — die Zahlen sind direkt vergleichbar.
**Ergebnis (upms, deep, 2026-09-05).** Jeder teure Schritt zeigt dasselbe Muster: breit expandieren,
dann fast alles wegwerfen. Kein einziger Schreib-Operator taucht in den Top 5 auf.
| Schritt | Zeit | dominanter Operator | Verhältnis |
|---|---|---|---|
| `resolve-bare-included` READS/WRITES | 2x ~165 s | `Filter` 273 320 729 hits -> 43 753 rows | 1 : 6 246 |
| | | `VarLengthExpand(All)` 127 180 800 hits -> 63 017 519 rows | |
| `link-args-to-params` | 105 s | `NodeIndexSeek` 65 711 041 hits -> 64 653 592 rows, gefiltert auf 18 371 | 1 : 3 518 |
| `resolve-field-placeholder` READS/WRITES | 2x ~50 s | `Filter` 50-52 Mio. hits -> ~220 000 rows | 1 : 229 |
| `resolve-view-alias-*` (3 Schritte) | 3x ~12 s | `Expand(All)` 37-43 Mio. hits | |
Bei `link-args-to-params` ist der einzelne Seek **nicht** das Problem — er liefert 11 geschätzte
Zeilen. Er wird nur millionenfach ausgeführt, weil die Query ein Kreuzprodukt über die
Include-Dateien beider Seiten mal die Argumentliste bildet (`UNWIND cvfiles` x `UNWIND pvfiles`).
Ein Index hilft dort also nicht; die Kandidatenmenge muss kleiner werden.
- [x] **157. `link-args-to-params` suchte den Parameter einmal pro Aufrufer-Variable statt einmal pro
Argument** (2026-09-05, gefunden durch Item 155)
Der Schritt kostete 105 s und erzeugte beim Re-Refresh **null** Kanten; im Profil tauchte kein
einziger Schreib-Operator unter den fünf teuersten auf. Die Fan-out-Messung erklärte es:
| | |
|---|---|
| Call-Sites mit Argumenten | 27 240 |
| Argument-Slots | 91 954 |
| Seeks Aufrufer-Seite (`cv`) | 2 509 135 |
| **Seeks Parameter-Seite (`pv`)** | **28 868 877** |
Der Parameter an Position *i* hängt nur von `(callee, i)` ab, wurde aber innerhalb der
Aufrufer-Schleife gesucht — 28,9 Mio. Seeks für 18 371 Ergebnispaare.
**Fix:** Parameter-Suche in eine aggregierende `CALL`-Subquery je `(callee, i)`, Aufrufer-Seite
danach. In beiden Varianten (`LINK_ARGS_TO_PARAMS` und `..._SCOPED`, letztere im interaktiven
Deep-Ingest-Pfad).
| | vorher | nachher |
|---|---|---|
| Schritt `link-args-to-params` | 105 s | **66,8 s** (-36 %) |
| Deep-Refresh `upms` gesamt | 905 s | **831 s** |
Die read-only Vorabmessung hatte 67,2 s vorhergesagt — die Umsetzung traf sie auf 0,4 s genau. Von
den 74 s Gesamtersparnis sind allerdings nur **38 s dem Schritt zurechenbar**; der Rest liegt in der
Lauf-zu-Lauf-Varianz von ~10 %, die in dieser Messreihe durchgehend zu beobachten war.
**Zwei Details, die die Umformulierung tragen:**
* Die Subquery **aggregiert** (`RETURN collect(pv)`). Sie liefert damit immer genau eine Zeile, bei
fehlendem Parameter eine leere Liste, die der Wächter `size(pvs) > 0` verwirft. Eine
nicht-aggregierende Subquery hätte die Zeile verschluckt — heute dasselbe Ergebnis, aber aus dem
falschen Grund und bei der nächsten Änderung falsch.
* Die scoped Variante ließ `cm` nach dem ersten `WITH` fallen. Ein Copy-Paste der Whole-Root-Fassung
hätte dort die Aufrufer-Dateiliste nicht mehr bilden können — im interaktiven Pfad, wo ein stilles
Null-Ergebnis kaum auffällt.
**Nicht gemacht, mit Begründung:** Auch die Aufrufer-Seite zu deduplizieren. 91 954 Argument-Slots
stehen 50 610 verschiedenen `(Datei, Argumentname)`-Paaren gegenüber — Faktor 1,8 für eine deutlich
kompliziertere Query mit Re-Join. Der Boden der heutigen Struktur liegt bei 56,5 s (Aufrufer-Seite
allein gemessen), der Umbau ist mit 66,8 s zehn Sekunden davon entfernt.
**Bilanz der Performance-Arbeit** (Items 153-157): Deep-Refresh `upms` **1 225 s -> 831 s (-32 %)**,
erreicht durch zwei Cypher-Umformulierungen. Verbleibende Verteilung: Persist ~310 s, Finalize
~520 s, davon `resolve-bare-included` READS+WRITES allein 298 s.
- [x] **158. `resolve-bare-included` expandierte jeden Datenbereich einmal pro Platzhalter**
(2026-09-05, gefunden durch Item 155)
Der teuerste Enrichment-Schritt (147 s + 151 s für READS/WRITES) lief je `(Modul, Platzhalter)` und
expandierte dabei den kompletten Teilbaum **jedes** eingebundenen Datenbereichs bis Tiefe 10. Der
Teilbaum eines Datenbereichs wurde damit einmal pro Platzhalter des einbindenden Moduls neu
durchlaufen. Das Profil zeigte 273 Mio. db-hits für 43 753 Ergebniszeilen und 63 Mio.
Zwischenzeilen aus dem `VarLengthExpand`.
| | |
|---|---|
| Teilbaum-Expansionen vorher | 380 639 |
| nachher (je `(Modul, Include)` einmal) | **41 377** |
**Fix:** Die Query wird vom INCLUDE getrieben statt vom Platzhalter — erst je `(Modul, Include)`
einmal expandieren, dann die Platzhalter über den Namen dazu-matchen. In beiden Varianten
(Whole-Root und scoped).
| | vorher | nachher |
|---|---|---|
| `resolve-bare-included READS` | 147,4 s | **75,8 s** (-49 %) |
| `resolve-bare-included WRITES` | 150,6 s | **76,8 s** (-49 %) |
| Deep-Refresh `upms` gesamt | 831 s | **695 s** |
Erzeugte Kanten identisch (`+23 788` / `+68 936` wie zuvor), Graph unverändert bei 940 592 Knoten
und 2 186 496 Kanten — der Gleichheitsbeleg am echten Korpus, zusätzlich zu 411 grünen ITs
(darunter `GroupQualifiedLeafResolveIT` für den qualifizierten Pfad aus Item 81).
**Warum es doppelt so schnell ist, strukturell und nicht zufällig:** `ph` wird jetzt mit **allen
vier** Eigenschaften des Index `(project, sourceFile, type, name)` gebunden. Ein zusammengesetzter
Index greift nur, wenn jede Eigenschaft gebunden ist — die alte Reihenfolge hatte an dieser Stelle
keinen Namen und konnte den Index deshalb nie nutzen.
**Der Preis, bewusst bezahlt:** Module mit Includes, aber ohne Platzhalter expandieren jetzt
vergeblich — 1 359 der 3 489 einbindenden Module in `upms`, ~11 618 zusätzliche Expansionen. Gegen
339 262 eingesparte ist das ein guter Tausch, und der Anteil schrumpft genau dann, wenn viele
Platzhalter unaufgelöst sind, also wenn der Schritt echte Arbeit hat.
Die Vorabmessung hatte 80-120 s Ersparnis geschätzt; es wurden **145 s**. Wie bei Item 154 lag die
Schätzung zu niedrig, weil sie den Schreibanteil für irreduzibel hielt.
**Bilanz der Performance-Arbeit (Items 153-158):** Deep-Refresh `upms` **1 225 s -> 695 s (-43 %)**,
erreicht durch drei Cypher-Umformulierungen ohne einen einzigen neuen Index.
- [x] **159. `resolve-field-placeholder` expanded the field subtree once per reference**
(2026-09-05, found via item 155)
After item 158 the largest remaining cost in phase D. The qualified field reference
(`CDPDA-M.SORT-KEY`) was resolved reference-driven: for **every** one of the ~220 000
`(ph, phv, src, m)` rows the module's INCLUDES list was scanned for the structure name and the
whole field subtree of `real` re-expanded to depth 10 — ~50 M `Filter` hits for ~220 000 result
rows.
**Fix:** the resolution `(ph, phv) -> real -> realv` runs first, over the project's 485 placeholder
fields; the reference edges join on afterwards. `real` comes from the `(project, type, name)`
index. The `INCLUDES` edge still *decides* which data area counts — it just no longer *finds*
`real`, it checks it.
| | |
|---|---|
| subtree expansions before | ~220 000 (one per row) |
| after (once per `(phv, real)`) | **520** |
| | before | after |
|---|---|---|
| `resolve-field-placeholder READS` | ~50 s | **18.2 s** |
| `resolve-field-placeholder WRITES` | ~50 s | **22.5 s** |
| deep refresh `upms` total | 695 s | **628 s** |
**Two clauses are load-bearing, not cosmetic** — both verified with `EXPLAIN`, because the first
attempt without them had *no* effect at all: the `WITH DISTINCT` is a planner barrier (without it
the planner reverts to the reference-driven order and the hoist is undone), and the INCLUDES test
is written as `EXISTS { (m)-[:INCLUDES]->(real) }` rather than a `MATCH` (as a `MATCH` the planner
sources `m` from `real`'s includers and builds an `m x src` cartesian — measurably worse than the
starting point).
**Equivalence evidence:** 411 green ITs (among them `QualifiedFieldResolveIT`,
`QualifiedGroupTargetResolveIT`, `QualifiedWriteReconcileIT`, `PlaceholderResolveNullLineIT`) and,
more directly, the **old** query form finds **0 remaining rows** for both READS and WRITES on the
graph the new one produced: the new order resolves exactly the same set. The by-name fallback
(steps 26/27) stayed at `+1/-1` and `+260/-220`, so it picked up no leftovers either.
**The precondition that can tip:** the order only pays while a placeholder's structure name stays
selective enough that the index seek does not out-fan the include list it replaces — on `upms` at
most 4 real structures share a placeholder name (mean 1.45). The result stays correct either way,
since the INCLUDES test still filters; only the cost flips back.
**The `_SCOPED` variant is deliberately unchanged** — it starts from `$names` and processes a
handful of modules, where there is nothing to hoist. Whole-root and scoped therefore have
different query shapes for the same result.
**Balance of the performance work (items 153-159):** deep refresh `upms`
**1 225 s -> 628 s (-49 %)**, achieved with four Cypher reformulations and no new index.
- [x] **160. The five per-file reap/sweep statements seeked once per `(file, owner)` pair**
(2026-09-05, found by reading the item-153 persist instrumentation)
With finalize down to ~316 s, persist (290 s) became the larger half and was measured for the first
time. One batch stood out: the last 111 files cost 45.9 s, of which **32.4 s (71 %)** went into the
five reap/sweep statements — for 1 123 new nodes and 7 467 edges. Batch 3, with 48 154 edges, spent
1.4 s on the same statements.
**Cause — the item-154 pattern again, one level up.** All five ran as
`UNWIND $files AS p MATCH (n {project, sourceFile: p.f, ownerModule: p.o})`, but the index is
`(project, sourceFile)`. A copycode node carries its expansion site as `ownerModule`, so one file
holds many owners' nodes and every seek returned *all* of them:
| | |
|---|---|
| copycode files (`ownerModule <> ""`) | 264 |
| `(sourceFile, ownerModule)` pairs they carry | 20 343 |
| owners per file: mean / max | 77 / **1 993** (`YFRAMEC2.cpy`) |
| nodes in those files | 53 740 |
| rows the seeks requested to reach them | **23 255 522** (1 : 433) |
**Fix:** `$files` carries `{f, os: [ownerModule, ...]}` — one entry per file — and the query filters
`n.ownerModule IN p.os`. One seek per file instead of one per pair. No new index: the obvious
alternative, `(project, sourceFile, ownerModule)`, would tax the write side of all 940 k node merges,
and `merge-nodes` is the largest persist item at 69.5 s.
Measured read-only on `YFRAMEC2.cpy` with its full owner list before implementing: 7 944 098 db-hits
pair-driven against **3 986** grouped, both matching the same 3 986 nodes. The objection this shape
had to survive was whether `IN` re-scans the list per node — it does not, Neo4j hashes it.
| | before | after |
|---|---|---|
| `reap-table-access-edges` | 17.9 s | **3.5 s** |
| `sweep-resolved-field-edges` | 17.5 s | **4.3 s** |
| `sweep-stale-file-nodes` | 16.4 s | **1.4 s** |
| `reap-using-edges` | 16.3 s | **2.3 s** |
| `reap-call-edges` | 16.2 s | **2.3 s** |
| the five together | 84.3 s | **13.8 s** (-84 %) |
| persist phase | 289.8 s | **217.9 s** |
| deep refresh `upms` total | 628 s | **552 s** |
The 45.9 s outlier batch is gone; the most expensive batch is now 20.6 s and is dominated by
`merge-nodes` and `merge-edges`.
**Equivalence evidence:** every finalize step that consumes what these statements leave behind
reports byte-identical counts to the previous run — `resolve-field-placeholder` `+167 536/-123 279`
and `+206 414/-168 671`, `resolve-bare-included` `+23 788/-23 788` and `+68 936/-68 936`,
`delete-resolved-field-contains` `-157 667`, `delete-resolved-placeholders` `-178 371`. Had the reaps
deleted too much or too little, these would move. Plus 411 green ITs.
**A test that asserted nothing was fixed on the way:** `StaleFileSweepIndexIT` passed
`{sourceFile, ids}` — keys the query has never read — so it checked the plan of a query whose
parameters were meaningless. It now passes the real shape and covers all five statements instead of
one.
**Estimate vs. outcome:** predicted 45-70 s, measured 72 s in persist / 76 s end to end. Third time
in a row the estimate came in low.
**Balance of the performance work (items 153-160):** deep refresh `upms`
**1 225 s -> 552 s (-55 %)**, achieved with five Cypher reformulations and no new index.

View File

@@ -117,6 +117,20 @@ Namens.
`ph × writer`-Kartesianer (~28,7 Mio Zeilen) erzeugte; jetzt hat jeder `phv_M` nur die Writer seines
Moduls → **Schritt 19 in ~55 s** (60×), Feldblock 18–26 gesamt in ~2 min statt ~57 min.
- **Einschränkung:** löst nur bei eindeutigem `INCLUDES`-Treffer; mehrdeutige/fehlende Includes bleiben unaufgelöst.
- **Reihenfolge (Item 159 — 2026-09-05):** Die Query löst erst `(ph, phv) → real → realv` auf und
hängt die Referenzkanten danach an; `real` kommt aus dem Index `(project, type, name)` statt aus
einem Namensscan über die INCLUDES-Liste des Moduls. Der Feld-Subtree wird dadurch einmal pro
`(phv, real)`-Paar expandiert (520 auf `upms`) statt einmal pro Zeile (~220 000). Das `INCLUDES`
entscheidet weiterhin, *welcher* Datenbereich gilt — nur findet es `real` nicht mehr, sondern prüft
es. Zwei Klauseln sind dafür tragend und mit `EXPLAIN` verifiziert: das `WITH DISTINCT` als
Planner-Barriere (ohne sie plant Neo4j wieder referenzgetrieben) und das `EXISTS { (m)-[:INCLUDES]->(real) }`
als Prädikat statt `MATCH` (als `MATCH` bezieht der Planner `m` aus den Includern von `real` und
baut ein `m × src`-Kreuzprodukt). Die `_SCOPED`-Variante behält bewusst die modulgetriebene
Reihenfolge: sie startet bei `$names` und verarbeitet wenige Module, dort gibt es nichts zu heben.
- **Voraussetzung der Reihenfolge:** der Platzhaltername muss selektiv genug bleiben, dass der
Index-Seek nicht breiter auffächert als die ersetzte INCLUDES-Liste — auf `upms` teilen sich
höchstens 4 reale Strukturen einen Platzhalternamen (Mittel 1,45). Das Ergebnis bleibt in jedem
Fall korrekt, da der `INCLUDES`-Check weiterhin filtert; nur die Kosten kippen zurück.
**20. `resolve-field-placeholder-by-name READS`** / **21. `…WRITES`** — Fallback: dieselbe Auflösung, aber `real` *
*global per Name** statt über `INCLUDES` (für `GLOBAL USING`/Copycode ohne INCLUDES-Kante).

View File

@@ -415,6 +415,160 @@ after probing and are recorded at the end, so nobody re-files them.
## Ingest performance
- [ ] **156. Die fünf Feld-Auflösungsschritte expandieren breit und filtern spät** (gemessen
2026-09-05 mit `refresh?profile=true`, Item 155)
Nach dem Platzhalter-Sweep-Fix (Item 154) ist Finalize mit ~600 s die größere Hälfte eines
`upms`-Deep-Refresh (Persist: ~310 s). Fünf Schritte machen 88 % davon, und alle zeigen dasselbe
Muster — die Kosten liegen im Suchen, nicht im Schreiben:
| Schritt | Zeit | Messung |
|---|---|---|
| ~~`resolve-bare-included` READS/WRITES~~ | ~~2x ~165 s~~ | **erledigt 2026-09-05** (Item 158): 298 s -> 153 s, INCLUDE-getrieben statt platzhalter-getrieben |
| ~~`link-args-to-params`~~ | ~~105 s~~ | **erledigt 2026-09-05** (Item 157): 105 s -> 66,8 s durch Hochziehen der Parameter-Suche |
| ~~`resolve-field-placeholder` READS/WRITES~~ | ~~2x ~50 s~~ | **done 2026-09-05** (item 159): 99 s -> 40,7 s, resolution-first instead of reference-driven |
**Was bereits ausprobiert und verworfen wurde**, damit es niemand wiederholt: Die INCLUDES-Listen zu
deduplizieren bringt nichts — 52 995 Kanten stehen 52 975 verschiedenen Dateien gegenüber, also
20 Doubletten im ganzen Projekt. Bei
`resolve-bare-included` den Kandidaten erst per Index auf `(project, name)` zu suchen und danach das
Enthaltensein zu prüfen, war **langsamer** (nach 10 Minuten noch laufend, abgebrochen) — Feldnamen
sind projektweit nicht selektiv genug. Bei `link-args-to-params` hilft kein Index: der einzelne Seek
ist mit ~11 Zeilen effizient, er läuft nur millionenfach, weil die Query ein Kreuzprodukt über die
Include-Dateien beider Seiten mal die Argumentliste bildet.
**Status 2026-09-05:** all three items are done (157, 158, 159), and the persist phase has since
been measured and fixed as well (item 160: 290 s -> 218 s). The deep refresh is at **552 s** instead
of 1 225 s (-55 %).
| what is left | time | note |
|---|---|---|
| `resolve-bare-included` READS/WRITES | ~148 s | finalize; already halved once (item 158) |
| `link-args-to-params` | ~67 s | finalize; already reduced once (item 157); no index helps |
| `merge-nodes` | ~70 s | persist; never examined |
| `merge-positional-nodes` | ~52 s | persist; never examined |
| `merge-edges` | ~45 s | persist; never examined |
| `resolve-field-placeholder` READS/WRITES | ~41 s | finalize; already reduced once (item 159) |
| `resolve-view-alias-*` (3 steps) | ~33 s | finalize; never examined |
| `commit` | ~33 s | persist; irreducible floor, probably |
The write side of persist was profiled on 2026-09-05 and turned out to be half lookup, not write:
`MERGE_NODES` keys on five properties `(type, name, sourceFile, project, ownerModule)` while the
widest index has four, so the seek hits `(project, sourceFile, type, name)` and a `Filter` discards
the rest. For copycode that rest is enormous — 53 740 nodes sit behind only 804 distinct
`(sourceFile, type, name)` keys (mean 66.8, max 3 542), so the seeks deliver 54 M rows to place
53 740 nodes. Measured: 161 owner lookups on one key cost 570 262 db-hits.
**Experiment, run and reverted 2026-09-05 — a net loss. Do not repeat it.** Adding
`(project, sourceFile, type, name, ownerModule)` did exactly what it promised locally and cost more
than it saved globally:
| | without index | with index |
|---|---|---|
| `merge-nodes` | 69.5 s | **41.4 s** |
| `merge-positional-nodes` | 52.1 s | **18.4 s** |
| `merge-edges` | 44.9 s | 59.1 s |
| `commit` | 32.9 s | 40.2 s |
| persist total | 217.9 s | **182.1 s** |
| **finalize total** | **316.4 s** | **429.6 s** |
| deep refresh total | **552 s** | 628 s |
The two MERGE lookups gained 62 s, as predicted. But *every* finalize step got ~33 % slower —
`resolve-bare-included` 73.1 -> 97.1 s, `link-args-to-params` 67.4 -> 90.6 s,
`resolve-field-placeholder` 18.1 -> 24.1 s — a uniform surcharge, not one bad step. The cause is not
the query shapes: **`server.memory.pagecache.size` was 1 GiB against a 2.4 GB store with 1.2 GB of
indexes**. A fifth wide index (carrying the 59-char `sourceFile`, cf. item 111d-2) multiplies
page-cache misses. Note the cost of a miss here is *not* disk I/O — item 161 measured 237 MB of block
reads across two whole refreshes — but the syscall-plus-copy path through the OS file cache. The
index was dropped again.
**What that opens up, and it may be the largest lever in this whole list:** nothing here was ever
tuned for memory. Raising the page cache costs no code and no schema change. Tracked as item 161.
**Levers** (unproven, to be measured in this order): make `resolve-bare-included`'s
`qualifierGroup` clause cheaper (it doubled the read share from 22 s to 47 s, measured before the
rewrite); and — the biggest but riskiest lever — restrict finalize to changed modules, for which
the `*_SCOPED` variants already exist but a whole-root refresh does not use them.
**Also already falsified, from item 159:** simply reordering the MATCH clauses is not enough — the
planner reverts to the old order unless a `WITH` barrier pins it, and expressing an existence
constraint as a `MATCH` instead of `EXISTS { }` lets the planner source the wrong driving node and
build a cartesian. Both shapes must be re-checked with `EXPLAIN` after any edit to these queries.
- [ ] **161. Neo4j's page cache holds 42 % of the store — raised 1 G to 3 G, not yet measured**
(2026-09-05, fallout from the reverted index experiment in item 156)
| | |
|---|---|
| store on disk | 2.4 GB (1.2 GB range indexes) |
| `server.memory.pagecache.size` | **1 GiB** -> raised to 3 G |
| coverage | ~42 % -> full store with room to grow |
| `mem_limit` | 5 g -> 7 g (heap 2G + cache 3G + ~1G reserve) |
**Why this is believed to matter:** the index experiment in item 156 slowed *every* finalize step by
a uniform ~33 % (`resolve-bare-included` 73.1 -> 97.1 s, `link-args-to-params` 67.4 -> 90.6 s). A
uniform surcharge across unrelated queries is not a query-shape problem; the fifth index cost cache
pages that traversals needed. If eviction can cost 33 %, coverage should be able to buy something
back. That is an inference from one experiment, not a proof.
**The counter-argument, which has not been ruled out:** the host holds ~11 GiB in `buff/cache`, so
Linux may already keep the whole store in its own file cache. A Neo4j cache miss would then cost a
`read()` syscall plus a copy rather than disk I/O, and the gain would be small. Both observations
can only be reconciled by assuming the syscall path is itself expensive enough to matter.
**How to measure it honestly:** Community Edition cannot pre-warm the page cache, so the first
refresh after the container restart runs cold while the 552 s baseline had a 12-hour warm cache.
A result of "552 s, no change" would therefore prove nothing. Run one warm-up refresh, then measure.
Compare against persist 217.9 s / finalize 316.4 s / total 552 s.
**If it works, item 156's index verdict has to be re-taken** — that index gained 62 s on
`merge-nodes` + `merge-positional-nodes` and lost 113 s in finalize purely to eviction. With the
store resident, the gain might survive without the penalty.
**Measured 2026-09-05 (warm-up run then measurement run, as prescribed above): -33 s, -6 %.**
| | 1 G cache | 3 G cache |
|---|---|---|
| persist | 217.9 s | **207.1 s** |
| finalize | 316.4 s | **298.9 s** |
| **deep refresh total** | **552 s** | **519 s** |
Even the cold warm-up run came in at 540 s. Within finalize the gain sits entirely in the big
traversal steps and is uniform but small: `resolve-bare-included` 73.1 -> 68.7 s and 75.3 -> 70.8 s,
`link-args-to-params` 67.4 -> 61.4 s (each -6 to -9 %); the short steps did not move. In persist only
`commit` gained clearly (32.9 -> 26.4 s); `merge-nodes` barely (69.5 -> 66.5 s) and
`merge-positional-nodes` not at all.
**Why the gain is small — measured, not guessed.** `server.memory.pagecache.directio` is `false`, so
Neo4j reads through the OS file cache; the store is cached twice. The Neo4j container's block I/O
over *both* refresh runs was **237 MB read** (20 988 read IOs) against a 2.4 GB store — essentially
nothing ever came from disk, at 1 G of cache or at 3 G. **There was no disk I/O to save.** What a
page-cache miss actually costs is a `read()` syscall, a kernel-to-userspace copy and Neo4j's eviction
bookkeeping: real, but nanoseconds rather than milliseconds. Those are the 6 %.
**This retracts the explanation first given for the item-156 index failure.** That the fifth index
"evicted pages traversals needed and pushed the load onto disk" cannot be right — there was no disk
load. The same miss path explains both directions instead: far more misses cost 33 %, far fewer buy
6 %. Same cause, no thrashing hypothesis needed.
**Settled at 2 G / 6 g, not 3 G / 7 g.** The machine also runs a 4 GB VirtualBox guest
(`Windows11`, 2 vCPU) from time to time, and with it the host budget goes from ~13 GiB of file cache
to ~8-9 GiB. 2 G still covers nearly all of the 2.4 GB store and gives 1 GiB back. The 2 G
configuration was **not** measured — the difference to 3 G is expected to be under 1 % because the OS
file cache absorbs the misses either way, but that is an expectation, not a number. The 3 G figures
above stand as the measured data point.
**The bigger lever for that machine is not this setting at all:** `vm.swappiness` is at the Debian
default of 60, and `/proc/vmstat` shows 0.69 GiB swapped out over 14 hours with `allocstall = 0` —
i.e. the kernel evicts anonymous pages of idle processes with no memory pressure whatsoever, which is
exactly what makes a backgrounded IDE feel sluggish later. Lowering it to 10 addresses that directly.
And while the VM runs, `./manage-ac.sh stop` frees 7-9 GiB, more than any cache tuning.
**Item 156's index verdict stays as it was.** The hoped-for reconciliation (index gain survives once
the store is resident) is not supported: with the store now effectively resident the whole refresh
only gained 33 s, so there is no 113 s of eviction penalty to recover.
- [ ] **111d-2. `sourceFile` — 59 chars, 96% over the inline threshold, ~51× duplicated**
(found 2026-08-05 while sizing container memory)