Compare commits
3 Commits
40f9aaf551
...
c4ff96b5fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4ff96b5fe | ||
|
|
9ce92c974c | ||
|
|
736fb48512 |
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user