diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index ab4e47a..1e2a161 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-version: '21' + distribution: 'temurin' + java-version: '25' - name: Build with Maven run: mvn -B verify --file pom.xml diff --git a/.gitignore b/.gitignore index 9b2ff43..95f94cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .classpath .project .settings +.m2 # IntelliJ project files .idea @@ -24,6 +25,7 @@ pom.xml.versionsBackup .DS_Store *.jar +*.hprof # Eclipse Project files .metadata diff --git a/.tool-versions b/.tool-versions index 7e23f8c..456a106 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -java oracle-21 -maven 3.9.12 +java oracle-25 +maven 3.9.16 diff --git a/.vscode/settings.json b/.vscode/settings.json index e0f15db..0be1c0c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "java.configuration.updateBuildConfiguration": "automatic" + "java.configuration.updateBuildConfiguration": "automatic", + "java.compile.nullAnalysis.mode": "automatic" } \ No newline at end of file diff --git a/README.md b/README.md index e60d408..8f5a65e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,191 @@ -## Java Performance Workshop +# Java Performance Workshop A tutorial workshop that will dive in understanding "what's going on in your JVM". This workshop is utilizing a basic web service that is included, which isn't exactly optimal in how it performs. The goal is to use this service as an interactive example and identify its poor performing elements. To learn more about this web service that is included, see [the site](https://jvmperf.net/). + +## Projects in this repository + +| Project | Purpose | How to run | +| --- | --- | --- | +| `java-perf-workshop-server` | An intentionally inefficient Dropwizard web service used by the performance workshop. | Build the reactor, start WireMock for the remote dependency, then run the shaded server JAR. | +| `java-perf-workshop-tester` | Gatling and Scala simulations for exercising the workshop service under load. | Start the workshop server, then run the Gatling Maven goal. | +| `java-heap-mcp` | A Java MCP server for loading and analyzing `.hprof` heap dumps utilizing components of Eclipse MCP. | Run `scripts/run-server.sh` for MCP stdio, or enable its local HTTP UI. | +| `java-heap-workbench` | A browser UI that connects a local agent to the heap-analysis MCP server and renders visual results. | Build and serve it with the npm scripts after starting `java-heap-mcp`. | +| `java-heap-agent-example` | Reference material for an agent skill that explains how to query heap information with your coding agent | Read the skill documentation; it is not a Maven module or standalone service. | + +The three Java services are part of the root Maven reactor. Build and test them +from the repository root with: + +```bash +mvn test +``` + +The workshop server skips Docker image creation by default. To run the original +performance workshop locally, start the mocked upstream service first: + +```bash +mvn dependency:copy \ + -Dartifact=com.github.tomakehurst:wiremock-standalone:2.27.2 \ + -Dmdep.stripVersion=true \ + -DoutputDirectory=. +java -jar wiremock-standalone.jar \ + --port 9090 \ + --root-dir java-perf-workshop-server/src/test/resources +``` + +In another terminal, build and start the workshop server: + +```bash +mvn -pl java-perf-workshop-server package +java -jar java-perf-workshop-server/target/java-perf-workshop-server-2.0-SNAPSHOT.jar \ + server server.yml +``` + +The service is then available at . With the server and +WireMock running, execute the load simulations with: + +```bash +mvn -pl java-perf-workshop-tester gatling:test +``` + +Results are written under `java-perf-workshop-tester/target/gatling/`. + +## Java Heap Workbench + +Java Heap Workbench combines a Java heap-analysis MCP server with a local-agent web interface. Load a `.hprof` dump, ask questions in natural language, and receive visual analysis such as summary cards, charts, tables, and OQL results. + +### Repository modules + +| Module | Purpose | Documentation | +| --- | --- | --- | +| `java-heap-mcp` | Java 25/Maven server backed by Eclipse Memory Analyzer (MAT). It loads heap dumps, manages cached projects, and exposes OQL and heap-analysis tools over MCP plus a local HTTP API. | [`java-heap-mcp/README.md`](java-heap-mcp/README.md) | +| `java-heap-workbench` | Browser UI that connects Ollama or OpenRouter to the MCP HTTP API, emits AG-UI-compatible run events, and renders A2UI-style cards, charts, and tables. | [`java-heap-workbench/README.md`](java-heap-workbench/README.md) | + +The normal request path is: + +```text +Browser workbench → Ollama or OpenRouter model → MCP HTTP API → Eclipse MAT → heap dump + ↑ ↓ + └──── visual A2UI response + AG-UI run events +``` + +The MCP server also supports its native stdio protocol for MCP clients. The workbench uses the server's local HTTP API because it runs directly in the browser. + +### Prerequisites + +- Java 25 or newer +- Maven (the repository includes a local `.m2` directory configuration) +- Node.js 20 or newer +- [Ollama](https://ollama.com/) with a tool-capable chat model, or an [OpenRouter](https://openrouter.ai/) API key +- A Java `.hprof` heap dump + +### Complete setup + +Run each long-lived process in its own terminal. + +#### 1. Build and test the MCP server + +From the repository root: + +```bash +cd java-heap-mcp +mvn -Dmaven.repo.local=../.m2 test +``` + +The Maven build downloads the required MAT bundles into `java-heap-mcp/target/mat/`. See the [MCP module README](java-heap-mcp/README.md) for supported tools and configuration variables. + +#### 2. Start the MCP HTTP service + +The workbench needs the optional HTTP API enabled: + +```bash +cd java-heap-mcp +JAVA_HEAP_MCP_WEB_UI_ENABLED=true \ +JAVA_HEAP_MCP_WEB_UI_PORT=7777 \ +./scripts/run-server.sh +``` + +This starts the MCP server and makes its local web/API service available at . The server's built-in page can be used to upload and index a heap dump. Keep this process running. + +#### 3. Start Ollama (optional) + +In another terminal: + +```bash +ollama pull nemotron-3-nano:4b +OLLAMA_ORIGINS=http://127.0.0.1:4173 ollama serve +``` + +If Ollama is already running as a system service, configure its allowed origins using the platform-specific Ollama configuration instead. The browser must be allowed to call `http://127.0.0.1:11434` from the workbench origin. + +#### 4. Build and start the workbench + +In a third terminal: + +```bash +cd java-heap-workbench +npm run build +npm run dev +``` + +Open . The workbench has no runtime npm dependencies; `npm install` is not required. The build creates the ignored `java-heap-workbench/dist/` directory. + +#### 5. Connect the pieces + +1. Open . +2. Select a `.hprof` file, optionally enter a project name, and load it. +3. Open . +4. Select the loaded project from the workbench project selector. +5. Open **Connections** if the defaults need changing: + - MCP: `http://127.0.0.1:7777` + - Provider: `Ollama (local)` or `OpenRouter` + - Ollama URL: `http://127.0.0.1:11434` (when using Ollama) + - OpenRouter API URL: `https://openrouter.ai/api/v1` (when using OpenRouter) + - Model: `nemotron-3-nano:4b` for Ollama, or one of the indexed OpenRouter free coding models +6. Use a quick investigation or enter a question in the chat box. + +The agent can call overview, histogram, dominator, leak-suspect, OQL, object inspection, and GC-root path operations. Tool results are returned to the model and also rendered as visual A2UI components. Questions about class attributes are grounded with a valid MAT OQL object selection followed by object inspection, so the response includes actual fields and values when the heap exposes them. + +Agent-generated OQL uses the embedded Apache Calcite SQL dialect. Use one read-only `SELECT` statement with SQL grouping, aggregates, ordering, joins, and other supported clauses; do not generate MAT-only syntax. Heap functions are SQL functions, so use `toString(s.this)`, never Java-like alias method syntax such as `s.toString(s.this)`. The workbench sends a tool-level row limit separately, but SQL `LIMIT`/`OFFSET` may be used when they are part of the query's intended semantics. + +### Testing the complete flow + +Test the frontend build: + +```bash +cd java-heap-workbench +npm test +npm run build +``` + +Test a real interaction after both services are running: + +```text +Show me the top 10 classes by retained heap and visualize them as a bar chart. +``` + +Other useful prompts: + +- `Give me a high-level overview of this heap and highlight the biggest retention risks.` +- `Find leak suspects and summarize the evidence in cards and a table.` +- `Run an OQL query for the most common String values and show the results.` + +The workbench emits AG-UI-compatible `RUN_STARTED`, tool-call, text, state, finish, and error events. The A2UI renderer currently supports summary cards, tables, bar charts, pie charts, and markdown-style text responses. + +### Troubleshooting + +- **MCP offline:** confirm the Java server was started with `JAVA_HEAP_MCP_WEB_UI_ENABLED=true` and that port `7777` is available. +- **No project appears:** load or open a project in the MCP page first; cached projects without active handles must be opened before analysis. +- **Model request fails:** verify the selected provider, endpoint, model name, and (for OpenRouter) API key. Ollama also requires `OLLAMA_ORIGINS=http://127.0.0.1:4173`. +- **Large dump is slow:** MAT indexing and retained-heap calculations can take time. Keep the MCP process running while analysis completes. +- **Browser is using stale settings:** open **Connections**, save the correct URLs/model, and retry. + +For module-specific environment variables, MCP tools, licensing, and implementation details, see the [MCP README](java-heap-mcp/README.md) and [workbench README](java-heap-workbench/README.md). + +## References + +* [mat-calcite-plugin](https://github.com/vlsi/mat-calcite-plugin): Including some of the source files for the java-heap-mcp integration of Apache Calcite. +* diff --git a/docs/.tool-versions b/docs/.tool-versions index f73dda5..85873f0 100644 --- a/docs/.tool-versions +++ b/docs/.tool-versions @@ -1,3 +1,3 @@ -hugo extended_0.162.1 -golang 1.24.12 +hugo extended-0.165.0 +golang 1.27.1 nodejs 23.11.0 diff --git a/docs/content/docs/prereqs/_index.md b/docs/content/docs/prereqs/_index.md index 46ec4a3..9d8dace 100644 --- a/docs/content/docs/prereqs/_index.md +++ b/docs/content/docs/prereqs/_index.md @@ -8,7 +8,7 @@ hide_readingtime: true ## Java Development Kit -Install a Java Development Kit (21+) from Oracle or OpenJDK +Install a Java Development Kit (25+) from Oracle or OpenJDK * [Oracle](https://www.oracle.com/java/technologies/downloads/) * [OpenJDK](https://openjdk.java.net/install/) @@ -31,4 +31,4 @@ Install the standalone version of [Eclipse MAT](https://www.eclipse.org/mat/). ## Rancher Desktop + Docker CLI -Install the [Rancher Desktop](https://rancherdesktop.io/) container runtime environment with the Docker CLI. \ No newline at end of file +Install the [Rancher Desktop](https://rancherdesktop.io/) container runtime environment with the Docker CLI. diff --git a/docs/content/docs/talks/_index.md b/docs/content/docs/talks/_index.md index c9f3cc8..e0a562f 100644 --- a/docs/content/docs/talks/_index.md +++ b/docs/content/docs/talks/_index.md @@ -10,8 +10,8 @@ menu: pre: "" --- - - +* [Heap Space Nine: Explore your Java Memory with AI](/slides/heap-space-nine/) (September 2026): This talk explores how AI and the Model Context Protocol (MCP) can make Java heap dump analysis faster and easier. It covers traditional memory-analysis tools, then shows how an MCP server can let an AI model examine object graphs, find memory hotspots, diagnose issues, and suggest optimizations. + * Check out the code here: [java-perf-workshop](https://github.com/cchesser/java-perf-workshop) * [A Practical Guide to JVM Native Memory in Kubernetes](/slides/jvm-k8s-mem/) (May 2026): A lightning talk on how JVM native memory behaves in Kubernetes, why container limits can be surprising, and what to inspect when memory usage does not match heap settings. * [Hello World Example](/docs/containers/hello-world/): Tiny Hello World service example, with building it as a container, deploying on Kubernetes, and evaluated native memory utilization. * [Open Up your JVM with Open Source Tooling](/slides/jvm-tooling/) (May 2025): A tour of open source JVM observability and troubleshooting tools, including JDK Mission Control, Eclipse Memory Analyzer, VisualVM, and OpenTelemetry. diff --git a/docs/static/slides/heap-space-nine/img/heap-space-nine-title.png b/docs/static/slides/heap-space-nine/img/heap-space-nine-title.png new file mode 100644 index 0000000..335eaa7 Binary files /dev/null and b/docs/static/slides/heap-space-nine/img/heap-space-nine-title.png differ diff --git a/docs/static/slides/heap-space-nine/img/mcp.png b/docs/static/slides/heap-space-nine/img/mcp.png new file mode 100644 index 0000000..3240307 Binary files /dev/null and b/docs/static/slides/heap-space-nine/img/mcp.png differ diff --git a/docs/static/slides/heap-space-nine/img/qr.png b/docs/static/slides/heap-space-nine/img/qr.png new file mode 100644 index 0000000..963c277 Binary files /dev/null and b/docs/static/slides/heap-space-nine/img/qr.png differ diff --git a/docs/static/slides/heap-space-nine/img/simple-system-diagram.png b/docs/static/slides/heap-space-nine/img/simple-system-diagram.png new file mode 100644 index 0000000..aac47fa Binary files /dev/null and b/docs/static/slides/heap-space-nine/img/simple-system-diagram.png differ diff --git a/docs/static/slides/heap-space-nine/index.html b/docs/static/slides/heap-space-nine/index.html new file mode 100644 index 0000000..b67447d --- /dev/null +++ b/docs/static/slides/heap-space-nine/index.html @@ -0,0 +1,1380 @@ + + + + + + Heap Space Nine: Explore your Java Memory with AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ Heap Space Nine: Explore your Java Memory with AI + + + + + +
+ Carl Chesser + + Carl Chesser
@che55er | + che55er.io +
+
+
+

Today’s path

+
+
+

+ Capture + +

+

+ Why we take a heap dump and the two common commands. +

+
+
+

+ Understand + +

+

+ What a .hprof contains, and why it can be huge. +

+
+
+

+ Analyze + Extend + +

+

+ How MAT reads, indexes, and queries the snapshot with Calcite + for a richer query engine. +

+
+
+

+ Connect + +

+

+ How MCP exposes heap analysis to an AI application. +

+
+
+

+ Demonstrate + +

+

A live walk through the Java heap workbench.

+
+
+
+
+
Chapter #1
+

Capture the evidence

+

Why capture a heap dump?

+
+
+

Why capture a heap dump?

+
+
+

Reactive: an incident happened

+

OutOfMemoryError

+

+ Find which objects retained the heap and what application path + kept them reachable. +

+
+
+

Proactive: plan capacity

+

Size the container workload

+

+ Understand heap, object, and retention patterns before choosing + memory limits and headroom. +

+
+
+

+ A heap dump shows what the JVM was holding at that moment. +

+
+
+
The map
+

JVM memory is bigger than the heap

+
+
+
+ Java heap
objects, arrays, class instances +
+
+ Native memory
threads, code cache, metaspace, GC structures +
+
+ Process + libraries
JVM, agents, mapped files, runtime overhead +
+
+
+

Heap dump answers

+

+ What objects existed? How large were they? What kept them + reachable? +

+

It does not answer

+

+ What happened first, or which request created every object. +

+
+
+
+
+

Size is not one number

+
+
+

Shallow heap

+

+ The memory occupied by the object itself, excluding the objects + it references. +

+
+
+

Retained heap

+

+ The memory that would become collectible if this object were + removed from the graph. +

+
+
+

GC root

+

+ A runtime-held reference, such as a thread or static field, that + keeps objects reachable. +

+
+
+

Object count

+

+ The number of instances of a class; many small objects can + consume substantial memory in total. +

+
+
+
+
+

jcmd: capture a heap dump

+
jcmd <PID> GC.heap_dump \
+  /tmp/service.hprof
+

+ The target JVM must generally be owned by the same OS user. Choose a + destination with enough free space. +

+
+
+

jmap: the traditional JDK command

+
jmap -dump:format=b,\
+file=/tmp/service.hprof <PID>
+

+ This produces the same kind of binary heap-dump artifact for + analysis. +

+
+
+
Chapter #2
+

Understand the snapshot

+

What is actually inside a .hprof?

+
+
+

+ What is actually inside a .hprof? +

+
    +
  • + A snapshot of the JVM heap at one point in time. +
  • +
  • + Class metadata, object instances, arrays, references, and GC-root + information. +
  • +
  • + Potentially sensitive data, including strings and + application fields. +
  • +
  • + A potentially large binary file, often comparable + to the live heap. +
  • +
+
+
+

+ What can read a .hprof? +

+
+
+ Eclipse MATDeep heap analysis, reports, retained sizes, and OQL.
https://eclipse.dev/mat/
+
+
+ VisualVMObject browsing, summaries, references, and OQL.
https://visualvm.github.io/
+
+
+ JOverflowA JDK Mission Control plugin for common heap anti-patterns.
https://github.com/openjdk/jmc
+
+
+

+ These tools are covered in the + memory-analysis guide. +

+
+
+

Why focus on Eclipse MAT?

+

Open source since 2008.

+

+ Mature enough for large production heap dumps. +

+

+ Versatile enough for reports, object graphs, retained sizes, and + OQL. +

+

+ Licensed under the + Eclipse Public License 2.0. +

+ +
+
+

How MAT handles a large .hprof file

+ +
+
+ Object lookup.index
.o2hprof.index
.o2c.index
.idx.index
+
+
+ Reference graph.inbound.index
.outbound.index
.a2s.index
+
+
+ Retention.domIn.index
.domOut.index
.o2ret.index
+
+
Thread data.threads
+
+

+ Creates a file-based index to assist in different forms of lookups. +

+
+
+
Chapter #3
+

Analyze + Extend

+

Read, query, and extend heap analysis.

+
+
+

What MAT already gives us

+

+ A mature engine for reading heap snapshots. +

+

+ Indexes, retained sizes, dominators, references, reports, and OQL. +

+
+
+

+ OQL is the bridge to questions +

+
SELECT c.cacheLimit, c.innerCache.size
+FROM cchesser.javaperf.workshop.cache.CleverCache c
+

OQL is not standard SQL.

+

+ MAT publishes a + BNF grammar + an agent can follow. +

+
+
+

When OQL is not enough

+

+ MAT OQL provides essential capabilities for quering data; however, + it is quite limited. +

+

+ For joins, grouping, sorting, and aggregation, add Apache Calcite + based on the + mat-calcite-plugin. +

+
+
+

MAT + Calcite: a richer query

+
SELECT toString(file) AS url,
+       COUNT(*) AS copies,
+       SUM(retainedSize(this)) AS retained
+FROM java.net.URL
+GROUP BY toString(file)
+HAVING COUNT(*) > 1
+ORDER BY retained DESC
+

+ Now the agent can ask distribution and relationship questions in + SQL. +

+
+
+

Calcite keeps the heap as the data source

+
+
+

MAT provides

+

+ Objects, fields, references, shallow size, retained size. +

+
+
+

Calcite provides

+

+ Stronger relational query layer over MAT’s heap-backed schema. +

+
+
+ Calcite distribution query result +
+
+
Chapter #4
+

Connect analysis to an agent

+

+ The workbench turns tool results into a conversation. +

+
+
+

Why add an agent?

+

Ask a memory question in plain language.

+

+ Let the model choose how to resolve the question with the available + tools. +

+

+ It can further explore the data set through a clearly defined query + language (OQL). +

+
+
+

Model Context Protocol (MCP)

+

+ An AI application is configured with the MCP interface (server). +

+

+ The MCP server describes inputs and outputs. +

+

+ The model understands this protocol and then can utilize this to + utilize specific tools for specific use-cases. +

+
+ Model Context Protocol diagram +
+
+
+

+ Meet java-heap-mcp +

+
+
AI application
+
+
MCP over stdio
+
+
MAT + Calcite
+
+
+
+

Server owns the session

+

+ Load a hprof file into a named project, build or reuse MAT indexes, + and return a stable heap handle. +

+
+
+

Responses stay bounded

+

+ Tools return focused JSON summaries and rows instead of sending + the raw .hprof to the model. +

+
+
+
+
+

A request through the server

+
+
heap_open_project
+
+
heap_get_overview
+
+
MAT snapshot
+
+
Results!
+
+

+ The model never needs direct access to the raw heap file, it is able to access + through well-defined MCP tools. +

+
+
+

13 tools, three jobs

+
+
+

Lifecycle

+

heap_load_dump
load/index a dump

+

heap_open_project
resume a cached project

+

+ heap_list_projects · heap_list_dumps
discover cached state +

+

heap_unload_dump
release an active handle

+
+
+

Orient

+

+ heap_get_overview
summary, top classes, + dominators +

+

+ heap_get_histogram
sort by + retained/shallow/count +

+

+ heap_get_dominators
find retained-memory roots +

+

+ heap_get_oql_grammar
learn the supported + SQL/OQL +

+
+
+

Investigate

+

heap_run_oql
query with limit + offset

+

heap_inspect_object
fields and references

+

+ heap_find_path_to_gc_roots
trace reachability +

+

+ heap_find_leak_suspects
run MAT leak analysis +

+
+
+
+
+

A tool chain follows the evidence

+
+
+ heap_get_overview
What is large? +
+
+ heap_get_histogram
Which classes dominate? +
+
+ heap_inspect_object
What does this object + retain? +
+
+
+
+ heap_find_path_to_gc_roots
Why is it alive? +
+
+ heap_run_oql
Can we test a pattern? +
+
+ heap_find_leak_suspects
What does MAT flag? +
+
+

+ The model chooses the next tool from the returned evidence. +

+
+
+

Natural language becomes a traceable run

+
+
Browser
+
+
local or frontier model
+
+
MCP
+
+
MAT
+
+
+
+
+

A2UI: Agent-to-UI

+
+
+
+

A2UI keeps the workbench simple

+
+
tool result
+
+
components[]
+
+
browser renderer
+
+

The model returns declarative JSON.

+

+ The UI renders summaries, tables, charts, and graphs. +

+
+
+

What A2UI makes possible

+

The same tool result can become a table.

+

Numeric rows can become a chart.

+

References can become a graph.

+
+
+

Utilize your local filesystem for heap analysis

+

+ Browser local model (via ollama) + MCP + MAT + heap dump. +

+

+ You can also use any frontier model and coding agent to interact with this MCP server. +

+ + Just remember, your heap file contains sensitive data. + +
+
+ +
+
+
Chapter #5
+

+ Demo Time + +

+
+
+

Lessons Learned

+
    +
  • MCP server functions be fast, but having an agent iterated with it can be slow.
  • +
  • OQL can still be confusing to an agent without alot of sufficient guidance (system prompt, grammar context).
  • +
  • Enabling advanced querying capabilities with Apache Calcite can expand how you can answer general questions.
  • +
  • Utilizing local models help isolate what content is being analyzed, but it can be too slow on some simple operations.
  • +
+
+
+ Heap Space Nine: Explore your Java Memory with AI +
+

Thank You!

+ + + + + +
+ Carl Chesser + + Carl Chesser
@che55er · + che55er.io
jvmperf.net
+
+
+ QR code linking to more information +
+
+
+ + + + + + + diff --git a/docs/static/slides/heap-space-nine/og-image.svg b/docs/static/slides/heap-space-nine/og-image.svg new file mode 100644 index 0000000..026188b --- /dev/null +++ b/docs/static/slides/heap-space-nine/og-image.svg @@ -0,0 +1,12 @@ + + Heap Space Nine: Explore your Java Memory with AI + Opening slide for Heap Space Nine by Carl Chesser. + + diff --git a/java-heap-mcp/README.md b/java-heap-mcp/README.md new file mode 100644 index 0000000..fe6c581 --- /dev/null +++ b/java-heap-mcp/README.md @@ -0,0 +1,129 @@ +# java-heap-mcp + +`java-heap-mcp` is a Java MCP server for loading and analyzing Java heap dumps with Eclipse Memory Analyzer (MAT) components. It exposes focused MCP tools for dump lifecycle, OQL/SQL, histograms, dominators, object inspection, GC-root paths, and leak-suspect analysis. + +## Add the MCP server to Codex + +The server speaks MCP over stdio, so it can be registered directly with the local Codex CLI. From the repository root, run: + +```bash +codex mcp add javaHeapMcp -- /absolute/path/to/java-heap-mcp/scripts/run-server.sh +``` + +Use the real absolute path to this repository. Verify the registration with `codex mcp list`, then start a new Codex session if needed. Codex can use the heap tools through stdio; the workbench uses the same server's optional HTTP API instead. + +The equivalent `~/.codex/config.toml` entry is: + +```toml +[mcp_servers.javaHeapMcp] +command = "/absolute/path/to/java-heap-mcp/scripts/run-server.sh" +args = [] +``` + +If the server is not executable, run `chmod +x scripts/run-server.sh` once. The server writes diagnostics to stderr and keeps protocol responses on stdout. + +## Current Shape + +- Java 25+ Maven project +- Long-lived multi-dump cache with stable dump handles +- Service-managed cache under `~/.cache/java-heap-mcp` by default +- Named project workflow (each project tracks its cached heap dump + active handle) +- MAT plugin jars downloaded from the official Eclipse distribution during the Maven build +- Headless Apache Calcite heap schema embedded from `mat-calcite-plugin` +- Official MCP Java SDK server with stdio transport and tool/schema negotiation +- Optional built-in web UI for local loading and analysis + +## Engineering Governance + +- Project principles and quality gates are defined in `.specify/memory/constitution.md`. +- Feature specs, plans, and tasks are expected to align with those constitution requirements. + +## Tools + +- `heap_load_dump` +- `heap_open_project` +- `heap_list_projects` +- `heap_list_dumps` +- `heap_unload_dump` +- `heap_get_overview` +- `heap_run_oql` +- `heap_get_oql_grammar` (no heap handle required; returns the supported Calcite SQL grammar, heap schema, functions, and examples) +- `heap_get_histogram` +- `heap_get_dominators` +- `heap_inspect_object` +- `heap_find_path_to_gc_roots` +- `heap_find_leak_suspects` + +## Build + +```bash +mvn -Dmaven.repo.local=.m2 test +``` + +## Run + +```bash +./scripts/run-server.sh +``` + +Environment variables: + +- `JAVA_HEAP_MCP_CACHE_DIR` +- `JAVA_HEAP_MCP_DEFAULT_LIMIT` +- `JAVA_HEAP_MCP_MAX_LIMIT` +- `JAVA_HEAP_MCP_TOOL_TIMEOUT_SECONDS` +- `JAVA_HEAP_MCP_GC_ROOT_PATH_LIMIT` +- `JAVA_HEAP_MCP_WEB_UI_ENABLED` (`true`/`false`, default `false`) +- `JAVA_HEAP_MCP_WEB_UI_PORT` (default `7777`) +- `JAVA_HEAP_MCP_WEB_UI_MAX_UPLOAD_BYTES` (default `0` = unlimited) + +To enable the web UI: + +```bash +JAVA_HEAP_MCP_WEB_UI_ENABLED=true JAVA_HEAP_MCP_WEB_UI_PORT=7777 ./scripts/run-server.sh +``` + +Then open: `http://127.0.0.1:7777/` + +The HTTP API includes CORS headers and browser preflight support so the separate `java-heap-workbench` dev server can connect from `http://127.0.0.1:4173`. + +If MAT reports `NoClassDefFoundError: Could not initialize class` for `QueryRegistry` or `Icons`, stop and restart the MCP server after any runtime/classpath change. These are JVM-level class-initialization failures and cannot be repaired by refreshing the browser. + +`heap_run_oql` first executes queries through the embedded Calcite engine. This enables SQL joins, filters, grouping, ordering, lateral table functions, and MAT-aware functions such as `retainedSize`, `shallowSize`, `getField`, `getValues`, and `getMapEntries`. For example: + +```sql +select toString(file) as file_name, count(*) as count +from java.net.URL +group by toString(file) +order by count(*) desc +``` + +Calcite uses double-quoted identifiers for fully qualified Java class names when needed (for example, `from "java.util.HashMap"`). Existing MAT OQL remains supported as a fallback. OQL diagnostics are written to the MCP process stderr. Each request logs the normalized single-line query plus its limit and offset; parse/runtime failures log the same query with the full exception. + +Agents can call `heap_get_oql_grammar` before writing a query. The same read-only description is available from the HTTP API at `GET /api/oql-grammar` (also `GET /api/oql/grammar`). It describes the server's one-`SELECT` contract, the MAT Calcite heap schema, reference/table/collection functions, and working examples. The payload links to the [Apache Calcite SQL reference](https://calcite.apache.org/docs/reference.html) and [MAT Calcite plugin](https://github.com/vlsi/mat-calcite-plugin) for complete background. + +## Project Workflow + +1. Load a heap into a named project: + - MCP: `heap_load_dump` with `{ "project": "my-service", "path": "/abs/path/dump.hprof" }` + - Web UI: choose the `.hprof` file with the browser file picker and click **Load** + - If no project is supplied, the service assigns a short Docker-style name such as `calm_tesla`. +2. Re-open an existing cached project: + - MCP: `heap_open_project` with `{ "project": "my-service" }` +3. List projects and active handles: + - MCP: `heap_list_projects` +4. Analyze with overview, dominators, leak suspects, histogram, and OQL using the returned handle. + +## Notes + +- MAT is downloaded and unpacked into `target/mat/` by Maven because the bundles are published through Eclipse update-site/RCP distribution artifacts rather than Maven Central. +- Web UI uploads are streamed directly to disk in fixed-size chunks before indexing/load (not buffered fully in memory). +- The current automated tests cover cache metadata, fingerprinting, and manager/tooling behavior. End-to-end MAT integration tests against real HPROF dumps are the next step. + +## Licensing + +- Eclipse Memory Analyzer (MAT) JARs are build outputs under `target/mat/` and are not stored in source control. +- Third-party licensing details are documented in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). +- Included license texts are in `licenses/`: + - `licenses/EPL-2.0.txt` + - `licenses/MIT-DEFLATE.txt` diff --git a/java-heap-mcp/THIRD_PARTY_NOTICES.md b/java-heap-mcp/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..bedfcd3 --- /dev/null +++ b/java-heap-mcp/THIRD_PARTY_NOTICES.md @@ -0,0 +1,106 @@ +# Third-Party Notices + +## Compliance assessment + +The project is using the third-party components in a way that is generally +consistent with their published permissive/open-source license terms: + +1. MAT is redistributed as unmodified vendor JARs. Their manifests identify + EPL-2.0; the HPROF bundle also contains the separately identified MIT + DEFLATE code. The corresponding license texts are retained in `licenses/`. +2. The MAT Calcite implementation is copied into this repository as source. + It is attributed to the upstream project and identified as Apache-2.0 below. +3. Calcite and the other Maven libraries are declared as dependencies rather + than copied into this repository or shaded into the application JAR. Maven + resolves them at build/run time, and `scripts/run-server.sh` places those + JARs on the runtime classpath. Their original artifact metadata and license + files therefore remain with the resolved artifacts. +4. This file records the direct dependencies and the license families of their + resolved transitive dependencies. If a release starts bundling those Maven + JARs into a distribution archive, the release process must include the + license/NOTICE files from each resolved artifact as well; this repository + does not currently create such a bundle. + +This is a practical project-level inventory, not legal advice. License terms +can change between dependency versions, so the exact resolved dependency tree +and each artifact's `META-INF/LICENSE*`/`META-INF/NOTICE*` files should be +checked as part of a release review. + +## Maven dependencies + +The following are the notable direct dependencies declared in `pom.xml`. +Links point to the upstream project or its license information. + +| Component | Version | License / attribution | Why it is present | +| --- | --- | --- | --- | +| Jackson Databind, Core, Annotations, JSR310 | 2.18.3 | Apache-2.0 | MCP and JSON serialization | +| Eclipse Platform Runtime, Resources, Commands | 3.31.0 / 3.22.0 / 3.12.0 | EPL-2.0 | MAT runtime services | +| ICU4J | 76.1 | ICU License | Eclipse/MAT internationalization support | +| Apache Calcite Core | 1.41.0 | Apache-2.0 | SQL parser, planner, and execution engine | +| Guava | 33.5.0-jre | Apache-2.0 | Embedded MAT Calcite implementation | +| Janino and Commons Compiler | 3.1.12 | BSD-3-Clause | Calcite enumerable code generation | +| JUnit Jupiter | 5.12.2 | EPL-2.0 | Tests only | +| AssertJ Core | 3.27.3 | Apache-2.0 | Tests only | + +Calcite brings additional runtime artifacts, including Avatica, JTS, Proj4J, +Jackson YAML, SnakeYAML, SLF4J, Apache Commons components, protobuf, +json-path/json-smart, ASM, and related support libraries. These are transitive +dependencies resolved from Maven Central; their license metadata must be +retained if the artifacts are redistributed. The versions actually resolved +can be inspected with: + +```bash +mvn dependency:tree -Dscope=runtime +``` + +The project does not claim that one license applies to all Calcite +transitives: each artifact remains under its own upstream license. + +Useful upstream license references: + +- [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) +- [Eclipse Public License 2.0](https://www.eclipse.org/legal/epl-2.0/) +- [ICU License](https://icu.unicode.org/license) +- [Apache Calcite](https://calcite.apache.org/) and its [license](https://github.com/apache/calcite/blob/main/LICENSE) +- [Guava](https://github.com/google/guava/blob/master/LICENSE) +- [Janino](https://github.com/janino-compiler/janino/blob/master/LICENSE) +- [JUnit](https://github.com/junit-team/junit5/blob/main/LICENSE.md) +- [AssertJ](https://github.com/assertj/assertj/blob/main/LICENSE) + +## Redistributed source and artifacts + +The Maven build downloads Eclipse Memory Analyzer (MAT) plugin JARs into `target/mat/`. + +It also incorporates the headless MAT schema and function implementation from +[`vlsi/mat-calcite-plugin`](https://github.com/vlsi/mat-calcite-plugin), used under +the Apache License 2.0. The embedded sources are under +`src/main/java/com/github/vlsi/mat/calcite/`; the upstream project and license +are available at the linked repository. + +## Included Artifacts + +1. `org.eclipse.mat.api.jar` (Bundle-Version `1.16.1.202501091339`) + - Declared license: `EPL-2.0` +2. `org.eclipse.mat.parser.jar` (Bundle-Version `1.16.1.202501091339`) + - Declared license: `EPL-2.0` +3. `org.eclipse.mat.report.jar` (Bundle-Version `1.16.1.202501091339`) + - Declared license: `EPL-2.0` +4. `org.eclipse.mat.hprof.jar` (Bundle-Version `1.16.1.202501091339`) + - Declared licenses: `EPL-2.0`, `MIT` (for bundled DEFLATE library code) + +License declarations above are sourced from each JAR's `META-INF/MANIFEST.MF` (`Bundle-License`) and `about.html`. + +## License Texts and Links + +1. Eclipse Public License 2.0 (EPL-2.0) + - Local copy: `licenses/EPL-2.0.txt` + - Upstream: https://www.eclipse.org/legal/epl-2.0/ +2. MIT License notice for DEFLATE library code included in `org.eclipse.mat.hprof.jar` + - Local copy: `licenses/MIT-DEFLATE.txt` + - Upstream notice location in bundled artifact: `about_files/DEFLATE-mit.html` + +## Upstream Project + +- Eclipse Memory Analyzer (MAT): https://eclipse.dev/mat/ +- MAT Calcite plugin: https://github.com/vlsi/mat-calcite-plugin +- Apache License 2.0: https://www.apache.org/licenses/LICENSE-2.0 diff --git a/java-heap-mcp/docs/architecture.md b/java-heap-mcp/docs/architecture.md new file mode 100644 index 0000000..80bc4c5 --- /dev/null +++ b/java-heap-mcp/docs/architecture.md @@ -0,0 +1,78 @@ +# `java-heap-mcp` open-source dependencies + +`java-heap-mcp` is a Java 25+ Maven service. Dependency versions are managed in the repository parent [`pom.xml`](../../pom.xml). Eclipse MAT bundles are downloaded and unpacked into Maven's `target/mat/` directory during the build; no MAT JARs are stored in source control. + +## Runtime architecture + +```mermaid +flowchart LR + Client["MCP client\n(Codex / agent)"] -->|JSON-RPC over stdio| Engine["McpServerEngine"] + Browser["Local browser /\njava-heap-workbench"] -->|HTTP JSON\n127.0.0.1:7777| Web["WebUiServer\noptional"] + Web --> Manager["HeapDumpManager"] + Engine --> Registry["ToolRegistry"] + Registry --> Manager + Manager --> Cache["CacheMetadataStore\nproject + dump metadata"] + Manager --> Analysis["MatHeapService"] + Analysis --> Session["HeapSession /\nMatLoadedHeap"] + Session --> MAT["Eclipse MAT runtime\nSnapshotFactory + ISnapshot"] + Analysis --> Calcite["Apache Calcite\nembedded SQL/OQL engine"] + Calcite --> MAT + MAT --> Dumps[(".hprof / heap dumps")] + Manager --> Disk[("cache directory\nindexes + uploads")] + Cache --> Disk + Session --> Disk + Web -->|serves optional embedded page| WebAsset["src/main/resources/web/index.html"] + + classDef app fill:#1f6feb,color:#fff,stroke:#58a6ff; + classDef library fill:#238636,color:#fff,stroke:#3fb950; + classDef storage fill:#6e40c9,color:#fff,stroke:#bc8cff; + class Engine,Registry,Manager,Analysis,Web app; + class MAT,Calcite library; + class Cache,Session,Dumps,Disk,WebAsset storage; +``` + +The MCP client uses the stdio path; the workbench uses the optional HTTP path. Both routes converge on the same project, cache, and heap-analysis services. + +## Dependency relationships + +```mermaid +flowchart TB + App["java-heap-mcp"] --> MATAPI["Eclipse MAT API"] + App --> MATParser["MAT parser + HPROF"] + App --> MATReport["MAT reporting"] + App --> Eclipse["Eclipse Platform runtime"] + App --> Calcite["Apache Calcite"] + App --> Janino["Janino"] + App --> Jackson["Jackson"] + App --> ICU["ICU4J"] + MATAPI --> Service["MatHeapService"] + MATParser --> Service + MATReport --> Service + Eclipse --> MATAPI + Calcite --> Service + Janino --> Calcite + Jackson --> Boundary["MCP + HTTP JSON boundaries"] + Service --> Boundary +``` + +| Dependency | Why it is used | Value to the project | +| --- | --- | --- | +| [Eclipse Memory Analyzer (MAT) API](https://www.eclipse.org/mat/) — `org.eclipse.mat.api` | Provides the snapshot model and heap-analysis APIs. | Enables overviews, histograms, dominators, object inspection, GC-root paths, and leak-suspect analysis. | +| Eclipse MAT parser and HPROF support — `org.eclipse.mat.parser`, `org.eclipse.mat.hprof` | Reads heap-dump files and builds or reuses MAT indexes. | Makes `.hprof` dumps available as queryable `ISnapshot` instances. | +| Eclipse MAT reporting — `org.eclipse.mat.report` | Supplies MAT report and query infrastructure. | Supports MAT-native analysis and leak-hunter operations in headless mode. | +| Eclipse Platform — `org.eclipse.core.runtime`, `org.eclipse.core.resources`, `org.eclipse.core.commands` | Provides the runtime/plugin services expected by MAT’s OSGi bundles. | Allows MAT to run inside this standalone server rather than only inside the MAT desktop application. | +| [Apache Calcite](https://calcite.apache.org/) — `calcite-core` | Provides the embedded SQL planner and execution engine used for the Calcite-backed OQL dialect. | Adds read-only SQL capabilities such as filtering, joins, grouping, ordering, pagination, heap tables, and MAT-aware functions. | +| [Janino](https://janino-compiler.github.io/janino/) — `janino`, `commons-compiler` | Compiles expressions used by Calcite at runtime. | Makes dynamic query expressions executable without adding a separate compiler process. | +| [Jackson](https://github.com/FasterXML/jackson) — `jackson-databind`, `jackson-datatype-jsr310` | Serializes MCP messages, HTTP payloads, configuration data, and result records. | Provides the JSON boundary for MCP clients and the optional web API, including Java time values. | +| [Google Guava](https://github.com/google/guava) — `guava` | Supplies general-purpose collection and utility APIs used by the Calcite/MAT integration. | Reduces supporting code and provides well-tested collection utilities. | +| [ICU4J](https://icu.unicode.org/) — `icu4j` | Supplies Unicode and internationalization support required by the Eclipse/MAT runtime. | Keeps the embedded MAT runtime compatible with its platform dependencies. | +| [MCP Java SDK](https://github.com/modelcontextprotocol/java-sdk) — `mcp-core`, `mcp-json-jackson2` | Provides the MCP server model, stdio transport, protocol negotiation, tool registration, and schema validation. | Replaces the hand-rolled JSON-RPC transport with the official MCP implementation while preserving the project’s Jackson 2 stack. | + +## Test dependencies + +| Dependency | Why it is used | Value to the project | +| --- | --- | --- | +| [JUnit 5](https://junit.org/junit5/) — `junit-jupiter` | Defines and runs the Java unit tests. | Verifies MCP protocol handling, heap lifecycle behavior, caching, and OQL validation. | +| [AssertJ](https://assertj.github.io/doc/) — `assertj-core` | Provides fluent assertions in tests. | Keeps test expectations readable, especially for structured heap and JSON results. | + +The server’s own Java packages provide the application layer around these projects: `McpServerEngine` handles MCP JSON-RPC, `WebUiServer` exposes the optional HTTP API, `HeapDumpManager` owns project/handle/cache lifecycle, and `MatHeapService` coordinates MAT and Calcite analysis. diff --git a/java-heap-mcp/licenses/EPL-2.0.txt b/java-heap-mcp/licenses/EPL-2.0.txt new file mode 100644 index 0000000..cd86520 --- /dev/null +++ b/java-heap-mcp/licenses/EPL-2.0.txt @@ -0,0 +1,15 @@ +Eclipse Public License - v 2.0 (EPL-2.0) + +Official canonical text: +https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt + +Project-local notice: +The Eclipse Memory Analyzer (MAT) artifacts downloaded during the build declare +EPL-2.0 in their bundle metadata (`Bundle-License`) and plugin `about.html` +files. + +Relevant artifacts: +- org.eclipse.mat.api.jar +- org.eclipse.mat.parser.jar +- org.eclipse.mat.report.jar +- org.eclipse.mat.hprof.jar diff --git a/java-heap-mcp/licenses/MIT-DEFLATE.txt b/java-heap-mcp/licenses/MIT-DEFLATE.txt new file mode 100644 index 0000000..d6795ef --- /dev/null +++ b/java-heap-mcp/licenses/MIT-DEFLATE.txt @@ -0,0 +1,25 @@ +DEFLATE library (Java) License + +Copyright (c) 2016 Project Nayuki. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Source of this notice: +- org.eclipse.mat.hprof downloaded into `target/mat/` +- about_files/DEFLATE-mit.html diff --git a/java-heap-mcp/pom.xml b/java-heap-mcp/pom.xml new file mode 100644 index 0000000..ca613b3 --- /dev/null +++ b/java-heap-mcp/pom.xml @@ -0,0 +1,214 @@ + + 4.0.0 + + + cchesser.javaperf + java-perf-workshop-parent + 2.0-SNAPSHOT + + + java-heap-mcp + java-heap-mcp + MCP server for Java heap dump analysis backed by Eclipse MAT. + + + + + io.modelcontextprotocol.sdk + mcp-core + ${mcp.sdk.version} + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp.sdk.version} + + + + org.slf4j + slf4j-simple + 1.7.12 + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + org.eclipse.platform + org.eclipse.core.runtime + + + org.eclipse.platform + org.eclipse.core.resources + + + org.eclipse.platform + org.eclipse.core.commands + + + com.ibm.icu + icu4j + + + + org.apache.calcite + calcite-core + + + com.google.guava + guava + + + org.codehaus.janino + janino + + + org.codehaus.janino + commons-compiler + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + default-compile + + + -classpath + ${compile.classpath}${path.separator}${project.build.directory}/mat/org.eclipse.mat.api_${mat.distribution}.jar${path.separator}${project.build.directory}/mat/org.eclipse.mat.hprof_${mat.distribution}.jar${path.separator}${project.build.directory}/mat/org.eclipse.mat.parser_${mat.distribution}.jar${path.separator}${project.build.directory}/mat/org.eclipse.mat.report_${mat.distribution}.jar + + + + + default-testCompile + + + -classpath + ${test.classpath}${path.separator}${project.build.outputDirectory}${path.separator}${project.build.directory}/mat/org.eclipse.mat.api_${mat.distribution}.jar${path.separator}${project.build.directory}/mat/org.eclipse.mat.hprof_${mat.distribution}.jar${path.separator}${project.build.directory}/mat/org.eclipse.mat.parser_${mat.distribution}.jar${path.separator}${project.build.directory}/mat/org.eclipse.mat.report_${mat.distribution}.jar + + + + + + + com.googlecode.maven-download-plugin + download-maven-plugin + 1.6.8 + + + download-mat-distribution + generate-resources + wget + + https://download.eclipse.org/mat/1.16.1/MemoryAnalyzer-${mat.distribution}.zip + ${project.build.directory} + mat-${mat.distribution}.zip + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 3.1.0 + + + unpack-mat-bundles + generate-resources + run + + + + + + + + + + + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + build-compile-classpath + generate-resources + build-classpath + + compile + compile.classpath + + + + build-test-classpath + generate-resources + build-classpath + + test + test.classpath + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + false + + ${project.build.directory}/mat/org.eclipse.mat.api_${mat.distribution}.jar + ${project.build.directory}/mat/org.eclipse.mat.hprof_${mat.distribution}.jar + ${project.build.directory}/mat/org.eclipse.mat.parser_${mat.distribution}.jar + ${project.build.directory}/mat/org.eclipse.mat.report_${mat.distribution}.jar + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + cchesser.javaperf.mcp.server.HeapMcpServerApplication + + + + + + + diff --git a/java-heap-mcp/scripts/run-server.sh b/java-heap-mcp/scripts/run-server.sh new file mode 100755 index 0000000..7141739 --- /dev/null +++ b/java-heap-mcp/scripts/run-server.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLASSPATH_FILE="$ROOT_DIR/target/runtime.classpath" + +log() { + printf '[java-heap-mcp] %s\n' "$*" >&2 +} + +WEB_UI_ENABLED="${JAVA_HEAP_MCP_WEB_UI_ENABLED:-false}" +WEB_UI_PORT="${JAVA_HEAP_MCP_WEB_UI_PORT:-7777}" + +cd "$ROOT_DIR" + +log "Preparing the runtime dependency classpath..." +mvn -q -Dmaven.repo.local=.m2 -DincludeScope=runtime -Dmdep.outputFile="$CLASSPATH_FILE" dependency:build-classpath + +log "Compiling the server..." +mvn -q -Dmaven.repo.local=.m2 -DskipTests compile + +CLASSPATH="target/classes:target/mat/*:$(cat "$CLASSPATH_FILE")" + +log "Starting the MCP server (stdio transport)." +if [[ "$WEB_UI_ENABLED" =~ ^[Tt][Rr][Uu][Ee]$ ]]; then + log "Web UI: http://127.0.0.1:${WEB_UI_PORT}/" + log "HTTP API base: http://127.0.0.1:${WEB_UI_PORT}/api/" + log "Available API routes: /projects, /dumps, /load, /load-upload, /open-project, /unload, /overview, /histogram, /dominators, /leaks, /oql" +else + log "Web UI and HTTP APIs are disabled (set JAVA_HEAP_MCP_WEB_UI_ENABLED=true to enable them)." +fi +log "Server is launching 🚀..." +exec java -cp "$CLASSPATH" cchesser.javaperf.mcp.server.HeapMcpServerApplication diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/CacheMetadataStore.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/CacheMetadataStore.java new file mode 100644 index 0000000..b265cc8 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/CacheMetadataStore.java @@ -0,0 +1,85 @@ +package cchesser.javaperf.mcp.cache; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +public final class CacheMetadataStore { + private static final TypeReference> TYPE = new TypeReference<>() { }; + + private final Path metadataFile; + private final ObjectMapper mapper; + + public CacheMetadataStore(Path cacheRoot, ObjectMapper mapper) { + this.metadataFile = cacheRoot.resolve("metadata.json"); + this.mapper = mapper; + } + + public synchronized Optional get(String fingerprint) { + return readAll().values().stream() + .filter(record -> record.fingerprint().equals(fingerprint)) + .findFirst(); + } + + public synchronized Optional getByProject(String projectName) { + return Optional.ofNullable(readAll().get(projectName)); + } + + public synchronized Collection list() { + return readAll().values(); + } + + public synchronized CachedHeapDumpRecord upsert(CachedHeapDumpRecord record) { + Map all = readAll(); + all.put(record.projectName(), record); + writeAll(all); + return record; + } + + public synchronized CachedHeapDumpRecord touchLoaded(CachedHeapDumpRecord existing) { + CachedHeapDumpRecord updated = new CachedHeapDumpRecord( + existing.projectName(), + existing.fingerprint(), + existing.sourcePath(), + existing.cacheFileName(), + existing.fileSizeBytes(), + existing.lastModifiedTime(), + existing.createdAt(), + Instant.now(), + existing.fullFileHash(), + existing.cachedDumpSizeBytes() + ); + return upsert(updated); + } + + private Map readAll() { + try { + if (!Files.exists(metadataFile)) { + return new LinkedHashMap<>(); + } + return mapper.readValue(metadataFile.toFile(), TYPE); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read cache metadata from " + metadataFile, exception); + } + } + + private void writeAll(Map records) { + try { + Files.createDirectories(metadataFile.getParent()); + Path tempFile = metadataFile.resolveSibling(metadataFile.getFileName() + ".tmp"); + mapper.writerWithDefaultPrettyPrinter().writeValue(tempFile.toFile(), records); + Files.move(tempFile, metadataFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException exception) { + throw new IllegalStateException("Failed to write cache metadata to " + metadataFile, exception); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/CachedHeapDumpRecord.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/CachedHeapDumpRecord.java new file mode 100644 index 0000000..01c3fea --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/CachedHeapDumpRecord.java @@ -0,0 +1,21 @@ +package cchesser.javaperf.mcp.cache; + +import java.nio.file.Path; +import java.time.Instant; + +public record CachedHeapDumpRecord( + String projectName, + String fingerprint, + String sourcePath, + String cacheFileName, + long fileSizeBytes, + Instant lastModifiedTime, + Instant createdAt, + Instant lastLoadedAt, + String fullFileHash, + long cachedDumpSizeBytes +) { + public Path sourcePathAsPath() { + return Path.of(sourcePath); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/HeapDumpFingerprint.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/HeapDumpFingerprint.java new file mode 100644 index 0000000..720da36 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/cache/HeapDumpFingerprint.java @@ -0,0 +1,54 @@ +package cchesser.javaperf.mcp.cache; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; + +public record HeapDumpFingerprint( + String fingerprint, + Path sourcePath, + long fileSizeBytes, + Instant lastModifiedTime +) { + public static HeapDumpFingerprint from(Path sourcePath) throws IOException { + Path normalized = sourcePath.toAbsolutePath().normalize(); + long fileSize = Files.size(normalized); + Instant lastModified = Files.getLastModifiedTime(normalized).toInstant(); + String payload = normalized + "|" + fileSize + "|" + lastModified.toEpochMilli(); + return new HeapDumpFingerprint(sha256(payload.getBytes()), normalized, fileSize, lastModified); + } + + public String sourceFileName() { + return sourcePath.getFileName().toString(); + } + + public static String sha256File(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream inputStream = Files.newInputStream(path)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) >= 0) { + digest.update(buffer, 0, bytesRead); + } + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is unavailable", exception); + } + } + + private static String sha256(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(data)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm is unavailable", exception); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/CalciteDataSource.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/CalciteDataSource.java new file mode 100644 index 0000000..698d54c --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/CalciteDataSource.java @@ -0,0 +1,124 @@ +package cchesser.javaperf.mcp.calcite; + +import cchesser.javaperf.mcp.calcite.neo.PackageSchema; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import org.apache.calcite.jdbc.CalciteConnection; +import org.apache.calcite.schema.Schema; +import org.apache.calcite.schema.SchemaPlus; +import org.codehaus.commons.compiler.CompilerFactoryFactory; +import org.codehaus.janino.CompilerFactory; +import org.eclipse.mat.snapshot.ISnapshot; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; +import java.util.concurrent.ExecutionException; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.CalciteDataSource}. + */ +public class CalciteDataSource { + + private static final LoadingCache SCHEMA_CACHE = CacheBuilder + .newBuilder() + .weakKeys().build(new CacheLoader() { + @Override + public Schema load(ISnapshot key) throws Exception { +// return new HeapSchema(key); + return PackageSchema.resolveSchema(key); + } + }); + + private static boolean initCompilerDone; + + public static Connection getConnection(ISnapshot snapshot) + throws SQLException { + initJanino(); + + try { + Class.forName("org.apache.calcite.jdbc.Driver"); + } catch (ClassNotFoundException e) { + throw new SQLException( + "Unable to load Calcite JDBC driver", e); + } + Properties info = new Properties(); + info.put("lex", "JAVA"); + info.put("quoting", "DOUBLE_QUOTE"); + info.put("conformance", "LENIENT"); // enable cross apply, etc + Connection connection = DriverManager.getConnection( + "jdbc:calcite:", info); + CalciteConnection con = connection + .unwrap(CalciteConnection.class); + + if (snapshot == null) { + return connection; + } + + if ("HEAP".equals(con.getSchema())) { + return connection; + } + + SchemaPlus root = con.getRootSchema(); + Schema heapSchema; + try { + heapSchema = SCHEMA_CACHE.get(snapshot); + } catch (ExecutionException e) { + throw new SQLException("Unable to create heap schema", e); + } + root.add("HEAP", heapSchema); + con.setSchema("HEAP"); + + return connection; + } + + private static void initJanino() throws SQLException { + // For unknown reason, threadContextClassLoader.getResource("org.codehaus.commons.compiler.properties") + // returns null when accessed via BundleClassLoader + // We make a shortcut + // Some OSGi WA might probably exist + if (initCompilerDone) { + return; + } + initCompilerDone = true; + try { + if (CompilerFactoryFactory.getDefaultCompilerFactory(CompilerFactory.class.getClassLoader()) == null) { + throw new SQLException("Janino compiler is not initialized: CompilerFactoryFactory.getDefaultCompilerFactory" + + "() == null"); + } + } catch (Exception e) { + throw new SQLException("Unable to load Janino compiler", e); + } + } + + public static void close(ResultSet rs, Statement st, Connection con) { + if (rs != null) { + try { + rs.close(); + } catch (SQLException e) { + e.printStackTrace(); + } + } + if (st != null) { + try { + st.close(); + } catch (SQLException e) { + e.printStackTrace(); + } + } + if (con != null) { + try { + con.close(); + } catch (SQLException e) { + e.printStackTrace(); + } + } + } + +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/HeapReference.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/HeapReference.java new file mode 100644 index 0000000..93f6cca --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/HeapReference.java @@ -0,0 +1,143 @@ +package cchesser.javaperf.mcp.calcite; + +import cchesser.javaperf.mcp.calcite.functions.HeapFunctions; + +import org.eclipse.mat.snapshot.model.IArray; +import org.eclipse.mat.snapshot.model.IObject; + +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.HeapReference}. + */ +public class HeapReference implements Comparable, Map { + private final IObject o; + + public HeapReference(IObject o) { + this.o = o; + } + + public static HeapReference valueOf(IObject o) { + return o == null ? null : new HeapReference(o); + } + + public IObject getIObject() { + return o; + } + + @Override + public boolean equals(Object o1) { + if (this == o1) { + return true; + } + if (o1 == null || getClass() != o1.getClass()) { + return false; + } + + HeapReference that = (HeapReference) o1; + + return o.equals(that.o); + } + + @Override + public int hashCode() { + return o.hashCode(); + } + + @Override + public String toString() { + String classSpecific = o.getClassSpecificName(); + if (classSpecific != null) { + return classSpecific; + } + return o.getDisplayName(); + } + + @Override + public int compareTo(HeapReference o) { + int cmp = this.toString().compareTo(o.toString()); + if (cmp != 0) { + return cmp; + } + return getIObject().getObjectId() - o.getIObject().getObjectId(); + } + + @Override + public Object get(Object key) { + // This Map.get is called by Calcite when this['fieldA'] SQL syntax is used + if (key == null) { + return null; + } + if (key instanceof Number && getIObject() instanceof IArray) { + return HeapFunctions.getField(this, "[" + key + "]"); + } + String fieldName = String.valueOf(key); + if (fieldName.charAt(0) == '@') { + if ("@shallow".equalsIgnoreCase(fieldName)) { + return HeapFunctions.shallowSize(this); + } + if ("@retained".equalsIgnoreCase(fieldName)) { + return HeapFunctions.retainedSize(this); + } + } + return HeapFunctions.getField(this, fieldName); + } + + @Override + public int size() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isEmpty() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsKey(Object key) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsValue(Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Object put(Object key, Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public Object remove(Object key) { + throw new UnsupportedOperationException(); + } + + @Override + public void putAll(Map m) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public Set keySet() { + throw new UnsupportedOperationException(); + } + + @Override + public Collection values() { + throw new UnsupportedOperationException(); + } + + @Override + public Set entrySet() { + throw new UnsupportedOperationException(); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/RowSetTable.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/RowSetTable.java new file mode 100644 index 0000000..962cd38 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/RowSetTable.java @@ -0,0 +1,137 @@ +package cchesser.javaperf.mcp.calcite; + +import org.eclipse.mat.query.Column; +import org.eclipse.mat.query.ContextProvider; +import org.eclipse.mat.query.IContextObject; +import org.eclipse.mat.query.IResultTable; +import org.eclipse.mat.query.ResultMetaData; + +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Types; + +import javax.sql.rowset.CachedRowSet; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.RowSetTable}. + */ +public class RowSetTable implements IResultTable { + + private final ResultMetaData metaData; + private final CachedRowSet rowSet; + Column[] columns; + int idColumnPosition = -1; + + public RowSetTable(CachedRowSet rowSet) throws SQLException { + this.rowSet = rowSet; + ResultSetMetaData md = rowSet.getMetaData(); + + Column[] columns = new Column[md.getColumnCount()]; + + ResultMetaData.Builder mdBuilder = new ResultMetaData.Builder(); + for (int i = 0; i < columns.length; i++) { + String className = md.getColumnClassName(i + 1); + Class clazz; + try { + clazz = Class.forName(className); + } catch (ClassNotFoundException e) { + clazz = String.class; + } + String columnName = md.getColumnName(i + 1); + columns[i] = new Column(columnName, clazz); + if (md.getColumnType(i + 1) == Types.JAVA_OBJECT) { + // Most likely a HeapReference + final int columnPosition = i; + String tableName = md.getTableName(i + 1); + final String label; + if (tableName == null || tableName.isEmpty()) { + label = columnName; + } else { + label = tableName + "." + columnName; + } + mdBuilder.addContext(new ContextProvider(label) { + @Override + public IContextObject getContext(Object row) { + return RowSetTable.getContext(row, columnPosition); + } + }); + if (idColumnPosition == -1) { + // Use first object column as context provider (e.g. in case "this" column is missing) + idColumnPosition = i; + } + } + if (idColumnPosition == -1 && "this".equals(columns[i].getLabel())) { + idColumnPosition = i; + } + } + this.metaData = mdBuilder.build(); + this.columns = columns; + } + + @Override + public ResultMetaData getResultMetaData() { + return metaData; + } + + @Override + public Object getColumnValue(Object row, int columnIndex) { + if (row == null) { + return "null"; + } + return ((Object[]) row)[columnIndex]; + } + + @Override + public Column[] getColumns() { + return columns; + } + + @Override + public IContextObject getContext(final Object row) { + if (idColumnPosition == -1) { + return null; + } + return getContext(row, idColumnPosition); + } + + private static IContextObject getContext(final Object row, final int columnPosition) { + if (!(row instanceof Object[])) { + return null; + } + final Object[] data = (Object[]) row; + if (columnPosition >= data.length) { + return null; + } + final Object ref = data[columnPosition]; + if (!(ref instanceof HeapReference)) { + return null; + } + return () -> ((HeapReference) ref).getIObject().getObjectId(); + } + + @Override + public Object getRow(int rowId) { + try { + rowSet.absolute(rowId + 1); + } catch (SQLException e1) { + e1.printStackTrace(); + return null; + } + Object[] row = new Object[columns.length]; + for (int i = 0; i < row.length; i++) { + try { + row[i] = rowSet.getObject(i + 1); + } catch (SQLException e) { + e.printStackTrace(); + } + } + return row; + } + + @Override + public int getRowCount() { + System.out.println("size: " + rowSet.size()); + return rowSet.size(); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/SnapshotHolder.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/SnapshotHolder.java new file mode 100644 index 0000000..de8717e --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/SnapshotHolder.java @@ -0,0 +1,31 @@ +package cchesser.javaperf.mcp.calcite; + +import org.eclipse.mat.snapshot.ISnapshot; + +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.SnapshotHolder}. + */ +public class SnapshotHolder { + private static final List> SNAPSHOTS = new CopyOnWriteArrayList<>(); + + public static ISnapshot get(int index) { + return SNAPSHOTS.get(index).get(); + } + + public static synchronized int put(ISnapshot snapshot) { + for (int i = 0; i < SNAPSHOTS.size(); i++) { + Reference ref = SNAPSHOTS.get(i); + if (ref.get() == snapshot) { + return i; + } + } + SNAPSHOTS.add(new WeakReference<>(snapshot)); + return SNAPSHOTS.size() - 1; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/CollectionsActions.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/CollectionsActions.java new file mode 100644 index 0000000..679b349 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/CollectionsActions.java @@ -0,0 +1,41 @@ +package cchesser.javaperf.mcp.calcite.collections; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.inspections.collectionextract.CollectionExtractionUtils; +import org.eclipse.mat.inspections.collectionextract.ExtractedMap; +import org.eclipse.mat.inspections.collectionextract.IMapExtractor; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IObject; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.collections.CollectionsActions}. + */ +public class CollectionsActions { + + private static class MapExtractorInfo { + final String className; + final IMapExtractor extractor; + + MapExtractorInfo(String className, IMapExtractor extractor) { + this.className = className; + this.extractor = extractor; + } + } + + private static final MapExtractorInfo[] knownExtractors = new MapExtractorInfo[] + { + new MapExtractorInfo("com.github.andrewoma.dexx.collection.HashMap", new DexxHashMapCollectionExtractor()), + new MapExtractorInfo("vlsi.utils.CompactHashMap", new CompactHashMapCollectionExtractor()) + }; + + public static ExtractedMap extractMap(IObject object) throws SnapshotException { + IClass clazz = object.getClazz(); + for (MapExtractorInfo info : knownExtractors) { + if (clazz.doesExtend(info.className)) { + return CollectionExtractionUtils.extractMap(object, info.className, info.extractor); + } + } + return CollectionExtractionUtils.extractMap(object); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/CompactHashMapCollectionExtractor.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/CompactHashMapCollectionExtractor.java new file mode 100644 index 0000000..3d1f3d5 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/CompactHashMapCollectionExtractor.java @@ -0,0 +1,158 @@ +package cchesser.javaperf.mcp.calcite.collections; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.inspections.collectionextract.IMapExtractor; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IObject; +import org.eclipse.mat.snapshot.model.IObjectArray; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.collections.CompactHashMapCollectionExtractor}. + */ +public class CompactHashMapCollectionExtractor implements IMapExtractor { + @Override + public boolean hasSize() { + return true; + } + + @Override + public Integer getSize(IObject iObject) throws SnapshotException { + // TODO more efficient + int size = 0; + for (Iterator> it = extractMapEntries(iObject); it.hasNext(); it.next()) { + size++; + } + return size; + } + + @Override + public boolean hasExtractableContents() { + return true; + } + + @Override + public Iterator> extractMapEntries(IObject iObject) throws SnapshotException { + List> result = new ArrayList<>(); + + ISnapshot snapshot = iObject.getSnapshot(); + IObject v1 = (IObject) iObject.resolveValue("v1"); + IObject v2 = (IObject) iObject.resolveValue("v2"); + IObject v3 = (IObject) iObject.resolveValue("v3"); + HashSet explicitNames = new HashSet<>(); // Keys explicitly set in key2slot map + + IObject mapKlass = (IObject) iObject.resolveValue("klass"); + for (Entry entry : CollectionsActions.extractMap((IObject) mapKlass.resolveValue("key2slot"))) { + IObject key = entry.getKey(); + IObject value; + + int slot = (Integer) entry.getValue().resolveValue("value"); + switch (slot) { + case -1: + value = v1; + break; + case -2: + value = v2; + break; + case -3: + value = v3; + break; + default: + value = getObject(snapshot, ((IObjectArray) v1).getReferenceArray()[slot]); + } + + result.add(new IObjectsPair(key, value)); + explicitNames.add(toString(key)); + } + + // This is not entirely correct, as we are comparing String representation of the keys instead of real keys, + // but it's best what we can do here + if (getClassName(mapKlass).equals("vlsi.utils.CompactHashMapClassWithDefaults")) { + for (Entry entry : CollectionsActions.extractMap((IObject) mapKlass.resolveValue( + "defaultValues"))) { + IObject key = entry.getKey(); + IObject value = entry.getValue(); + + if (!explicitNames.contains(toString(key))) { + result.add(new IObjectsPair(key, value)); + } + } + } + + return result.iterator(); + } + + // Internal + + private String getClassName(IObject obj) { + return obj.getClazz().getName(); + } + + private IObject getObject(ISnapshot snapshot, long address) throws SnapshotException { + return address == 0 ? null : snapshot.getObject(snapshot.mapAddressToId(address)); + } + + private String toString(IObject object) { + String name = object.getClassSpecificName(); + return name != null ? name : object.getDisplayName(); + } + + // Not implemented + + @Override + public boolean hasCollisionRatio() { + return false; + } + + @Override + public Double getCollisionRatio(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public boolean hasCapacity() { + return false; + } + + @Override + public Integer getCapacity(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public boolean hasFillRatio() { + return false; + } + + @Override + public Double getFillRatio(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public int[] extractEntryIds(IObject iObject) throws SnapshotException { + return new int[0]; + } + + @Override + public boolean hasExtractableArray() { + return false; + } + + @Override + public IObjectArray extractEntries(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public Integer getNumberOfNotNullElements(IObject iObject) throws SnapshotException { + return null; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/DexxHashMapCollectionExtractor.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/DexxHashMapCollectionExtractor.java new file mode 100644 index 0000000..50c5ea2 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/DexxHashMapCollectionExtractor.java @@ -0,0 +1,160 @@ +package cchesser.javaperf.mcp.calcite.collections; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.inspections.collectionextract.IMapExtractor; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IObject; +import org.eclipse.mat.snapshot.model.IObjectArray; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.collections.DexxHashMapCollectionExtractor}. + */ +public class DexxHashMapCollectionExtractor implements IMapExtractor { + + @Override + public boolean hasSize() { + return true; + } + + @Override + public Integer getSize(IObject iObject) throws SnapshotException { + IObject chmObj = getCHM(iObject); + + if (chmObj != null) { + String typeName = getClassName(chmObj); + if ("com.github.andrewoma.dexx.collection.internal.hashmap.HashMap1".equals(typeName)) { + return 1; + } else if ("com.github.andrewoma.dexx.collection.internal.hashmap.CompactHashMap".equals(typeName)) { + return 0; + } else if ("com.github.andrewoma.dexx.collection.internal.hashmap.HashMapCollision1".equals(typeName)) { + int count = 0; + IObject item = (IObject) chmObj.resolveValue("kvs"); + while (getClassName(item).equals("com.github.andrewoma.dexx.collection.internal.hashmap.ListMap$Node")) { + count++; + item = (IObject) chmObj.resolveValue("this$0"); + } + return count; + } else if ("com.github.andrewoma.dexx.collection.internal.hashmap.HashTrieMap".equals(typeName)) { + return (Integer) chmObj.resolveValue("size"); + } + } + return null; + } + + @Override + public boolean hasExtractableContents() { + return true; + } + + @Override + public Iterator> extractMapEntries(IObject iObject) throws SnapshotException { + List> result = new ArrayList<>(); + IObject chmObj = getCHM(iObject); + if (chmObj != null) { + extractPairs(chmObj, result); + } + return result.iterator(); + } + + // Internal + + private String getClassName(IObject obj) { + return obj.getClazz().getName(); + } + + private IObject getCHM(IObject iObject) throws SnapshotException { + Object obj = iObject.resolveValue("compactHashMap"); + return obj instanceof IObject ? (IObject) obj : null; + } + + private void extractPairs(IObject chmObj, List> result) throws SnapshotException { + String typeName = getClassName(chmObj); + if ("com.github.andrewoma.dexx.collection.Pair".equals(typeName)) { + // Single entry + IObject key = (IObject) chmObj.resolveValue("component1"); + IObject value = (IObject) chmObj.resolveValue("component2"); + result.add(new IObjectsPair(key, value)); + } else if ("com.github.andrewoma.dexx.collection.internal.hashmap.HashMap1".equals(typeName)) { + // Single entry + extractPairs((IObject) chmObj.resolveValue("value"), result); + } else if ("com.github.andrewoma.dexx.collection.internal.hashmap.HashMapCollision1".equals(typeName)) { + // Multiple entries with hash collision - chain of nested Node classes in 'kvs' field + extractPairs((IObject) chmObj.resolveValue("kvs"), result); + } else if ("com.github.andrewoma.dexx.collection.internal.hashmap.ListMap$Node".equals(typeName)) { + // Get current value + extractPairs((IObject) chmObj.resolveValue("value"), result); + // Try to get next object in the list + extractPairs((IObject) chmObj.resolveValue("this$0"), result); + } else if ("com.github.andrewoma.dexx.collection.internal.hashmap.HashTrieMap".equals(typeName)) { + // Multiple entries + Object obj = chmObj.resolveValue("elems"); + if (obj instanceof IObjectArray) { + ISnapshot snapshot = chmObj.getSnapshot(); + for (long elem : ((IObjectArray) obj).getReferenceArray()) { + if (elem != 0) { + extractPairs(snapshot.getObject(snapshot.mapAddressToId(elem)), result); + } + } + } + } + } + + // Not implemented + + @Override + public boolean hasCollisionRatio() { + return false; + } + + @Override + public Double getCollisionRatio(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public boolean hasCapacity() { + return false; + } + + @Override + public Integer getCapacity(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public boolean hasFillRatio() { + return false; + } + + @Override + public Double getFillRatio(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public int[] extractEntryIds(IObject iObject) throws SnapshotException { + // TODO: return values? + return new int[0]; + } + + @Override + public boolean hasExtractableArray() { + return false; + } + + @Override + public IObjectArray extractEntries(IObject iObject) throws SnapshotException { + return null; + } + + @Override + public Integer getNumberOfNotNullElements(IObject iObject) throws SnapshotException { + return null; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/IObjectsPair.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/IObjectsPair.java new file mode 100644 index 0000000..48a9355 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/collections/IObjectsPair.java @@ -0,0 +1,34 @@ +package cchesser.javaperf.mcp.calcite.collections; + +import org.eclipse.mat.snapshot.model.IObject; + +import java.util.Map; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.collections.IObjectsPair}. + */ +class IObjectsPair implements Map.Entry { + private final IObject key; + private final IObject value; + + public IObjectsPair(IObject key, IObject value) { + this.key = key; + this.value = value; + } + + @Override + public IObject getKey() { + return key; + } + + @Override + public IObject getValue() { + return value; + } + + @Override + public IObject setValue(IObject value) { + throw new UnsupportedOperationException(); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/CollectionsFunctions.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/CollectionsFunctions.java new file mode 100644 index 0000000..ce94c87 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/CollectionsFunctions.java @@ -0,0 +1,187 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import cchesser.javaperf.mcp.calcite.HeapReference; +import cchesser.javaperf.mcp.calcite.collections.CollectionsActions; + +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; +import org.apache.calcite.adapter.enumerable.CallImplementor; +import org.apache.calcite.adapter.enumerable.NullPolicy; +import org.apache.calcite.adapter.enumerable.ReflectiveCallNotNullImplementor; +import org.apache.calcite.adapter.enumerable.RexImpTable; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.schema.ImplementableFunction; +import org.apache.calcite.schema.ScalarFunction; +import org.apache.calcite.schema.impl.ReflectiveFunctionBase; +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.inspections.collectionextract.CollectionExtractionUtils; +import org.eclipse.mat.inspections.collectionextract.ExtractedCollection; +import org.eclipse.mat.inspections.collectionextract.ExtractedMap; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IArray; +import org.eclipse.mat.snapshot.model.IObject; +import org.eclipse.mat.snapshot.model.IObjectArray; +import org.eclipse.mat.snapshot.model.IPrimitiveArray; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.CollectionsFunctions}. + */ +public class CollectionsFunctions extends HeapFunctionsBase { + + public abstract static class BaseImplementableFunction extends ReflectiveFunctionBase implements ScalarFunction, + ImplementableFunction { + final CallImplementor implementor; + + BaseImplementableFunction(Method method) { + super(method); + implementor = RexImpTable.createImplementor(new ReflectiveCallNotNullImplementor(method), NullPolicy.NONE, false); + } + + @Override + public CallImplementor getImplementor() { + return implementor; + } + } + + public static class MapFunction extends BaseImplementableFunction { + MapFunction(Method method) { + super(method); + } + + @Override + public RelDataType getReturnType(RelDataTypeFactory relDataTypeFactory) { + return relDataTypeFactory.createMapType(relDataTypeFactory.createJavaType(String.class), + relDataTypeFactory.createJavaType(HeapReference.class)); + } + } + + public static class MultiSetFunction extends BaseImplementableFunction { + MultiSetFunction(Method method) { + super(method); + } + + @Override + public RelDataType getReturnType(RelDataTypeFactory relDataTypeFactory) { + return relDataTypeFactory.createMultisetType(relDataTypeFactory.createJavaType(HeapReference.class), -1); + } + } + + public static class ArrayFunction extends BaseImplementableFunction { + private final Class elementType; + + ArrayFunction(Method method, Class elementType) { + super(method); + this.elementType = elementType; + } + + @Override + public RelDataType getReturnType(RelDataTypeFactory relDataTypeFactory) { + return relDataTypeFactory.createArrayType(relDataTypeFactory.createJavaType(elementType), -1); + } + } + + public static Multimap createAll() { + ImmutableMultimap.Builder builder = ImmutableMultimap.builder(); + builder.put("asMap", new MapFunction(findMethod(CollectionsFunctions.class, "asMap"))); + builder.put("asMultiSet", new MultiSetFunction(findMethod(CollectionsFunctions.class, "asMultiSet"))); + builder.put("asArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), HeapReference.class)); + builder.put("asByteArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Byte.class)); + builder.put("asShortArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Short.class)); + builder.put("asIntArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Integer.class)); + builder.put("asLongArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Long.class)); + builder.put("asBooleanArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Boolean.class)); + builder.put("asCharArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Character.class)); + builder.put("asFloatArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Float.class)); + builder.put("asDoubleArray", new ArrayFunction(findMethod(CollectionsFunctions.class, "asArray"), Double.class)); + return builder.build(); + } + + @SuppressWarnings("unused") + public static Map asMap(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return null; + } + + try { + ExtractedMap extractedMap = CollectionsActions.extractMap(ref.getIObject()); + if (extractedMap == null) { + return Collections.emptyMap(); + } else { + Map result = new HashMap<>(); + for (Map.Entry entry : extractedMap) { + result.put(toString(entry.getKey()), + resolveReference(entry.getValue()) + ); + } + return result; + } + } catch (SnapshotException e) { + throw new RuntimeException("Unable to extract map from " + r, e); + } + } + + @SuppressWarnings("unused") + public static List asMultiSet(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return null; + } + + try { + ExtractedCollection extractedCollection = CollectionExtractionUtils.extractList(ref.getIObject()); + if (extractedCollection == null) { + return Collections.emptyList(); + } else { + List result = new ArrayList<>(); + for (IObject entry : extractedCollection) { + result.add((HeapReference) resolveReference(entry)); + } + return result; + } + } catch (SnapshotException e) { + throw new RuntimeException("Unable to extract collection from " + r, e); + } + } + + @SuppressWarnings("unused") + public static List asArray(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return null; + } + + IObject iObject = ref.getIObject(); + if (!(iObject instanceof IArray)) { + return null; + } + + if (iObject instanceof IPrimitiveArray) { + IPrimitiveArray arrayObject = (IPrimitiveArray) iObject; + int length = arrayObject.getLength(); + List result = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + result.add(arrayObject.getValueAt(i)); + } + return result; + } else { + IObjectArray arrayObject = (IObjectArray) iObject; + ISnapshot snapshot = arrayObject.getSnapshot(); + int length = arrayObject.getLength(); + List result = new ArrayList<>(length); + for (long objectAddress : arrayObject.getReferenceArray()) { + result.add(resolveReference(snapshot, objectAddress)); + } + return result; + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/HeapFunctions.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/HeapFunctions.java new file mode 100644 index 0000000..a0be8b8 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/HeapFunctions.java @@ -0,0 +1,255 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import cchesser.javaperf.mcp.calcite.HeapReference; +import cchesser.javaperf.mcp.calcite.collections.CollectionsActions; +import cchesser.javaperf.mcp.calcite.schema.objects.SpecialFields; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.inspections.collectionextract.CollectionExtractionUtils; +import org.eclipse.mat.inspections.collectionextract.ICollectionExtractor; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.*; + +import java.util.Map; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.HeapFunctions}. + */ +public class HeapFunctions extends HeapFunctionsBase { + + @SuppressWarnings("unused") + public static int getId(Object r) { + HeapReference ref = ensureHeapReference(r); + return ref == null ? -1 : ref.getIObject().getObjectId(); + } + + @SuppressWarnings("unused") + public static Object getClass(Object r) { + HeapReference ref = ensureHeapReference(r); + return ref == null ? null : HeapReference.valueOf(ref.getIObject().getClazz()); + } + + @SuppressWarnings("unused") + public static String getType(Object r) { + HeapReference ref = ensureHeapReference(r); + return ref == null ? "" : ref.getIObject().getClazz().getName(); + } + + @SuppressWarnings("unused") + public static String toString(Object r) { + if (r == null) { + return null; + } + return r.toString(); + } + + @SuppressWarnings("unused") + public static String getStringContent(Object r, int limit) { + try { + HeapReference ref = ensureHeapReference(r); + return ref == null ? "" : PrettyPrinter.objectAsString(ref.getIObject(), limit); + } catch (SnapshotException e) { + throw new RuntimeException("Unable to represent as string", e); + } + } + + @SuppressWarnings("unused") + public static String introspect(Object r) { + if (r instanceof HeapReference) { + return "HeapReference: " + toString(r); + } else if (r instanceof Object[]) { + return "Array, length = " + ((Object[]) r).length; + } else { + return "Primitive type: " + toString(r); + } + } + + @SuppressWarnings("unused") + public static Object getByKey(Object r, String key) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return null; + } + + try { + for (Map.Entry entry : CollectionsActions.extractMap(ref.getIObject())) { + if (key.equals(toString(entry.getKey()))) { + return resolveReference(entry.getValue()); + } + } + return null; + } catch (SnapshotException e) { + throw new RuntimeException("Unable to lookup key " + key + " in " + r, e); + } + } + + @SuppressWarnings("unused") + public static int getSize(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return -1; + } + + try { + ICollectionExtractor collectionExtractor = CollectionExtractionUtils.findCollectionExtractor(ref.getIObject()); + if (collectionExtractor != null && collectionExtractor.hasSize()) { + return collectionExtractor.getSize(ref.getIObject()); + } else { + return -1; + } + } catch (SnapshotException e) { + throw new RuntimeException("Unable to obtain collection size for " + r, e); + } + } + + @SuppressWarnings("unused") + public static int length(Object r) { + HeapReference ref = ensureHeapReference(r); + + if (ref == null) { + return -1; + } + + IObject obj = ref.getIObject(); + + return obj instanceof IArray ? ((IArray) obj).getLength() : -1; + } + + @SuppressWarnings("unused") + public static long shallowSize(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return -1; + } + try { + return ref.getIObject().getSnapshot().getHeapSize(ref.getIObject().getObjectId()); + } catch (SnapshotException e) { + throw new RuntimeException("Cannot calculate shallow size for " + r, e); + } + } + + @SuppressWarnings("unused") + public static long retainedSize(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return -1; + } + + try { + return ref.getIObject().getSnapshot().getRetainedHeapSize(ref.getIObject().getObjectId()); + } catch (SnapshotException e) { + throw new RuntimeException("Cannot calculate retained size for " + r, e); + } + } + + @SuppressWarnings("unused") + public static Object getField(Object r, String fieldName) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return null; + } + IObject iObject = ref.getIObject(); + if (fieldName.charAt(0) == '@') { + if ("@shallow".equalsIgnoreCase(fieldName)) { + return HeapFunctions.shallowSize(ref); + } + if ("@retained".equalsIgnoreCase(fieldName)) { + return HeapFunctions.retainedSize(ref); + } + if (SpecialFields.CLASS_NAME.equalsIgnoreCase(fieldName)) { + return IClassMethods.getClassName(iObject.getClazz()); + } + if (iObject instanceof IClass) { + if (SpecialFields.CLASS_LOADER.equalsIgnoreCase(fieldName)) { + return resolveReference(IClassMethods.getClassLoader(iObject)); + } else if (SpecialFields.SUPER.equalsIgnoreCase(fieldName)) { + return resolveReference(IClassMethods.getSuper(iObject)); + } + } else if (SpecialFields.CLASS.equalsIgnoreCase(fieldName)) { + return resolveReference(iObject.getClazz()); + } + } else if (iObject instanceof IArray + && fieldName.length() > 2 + && fieldName.charAt(0) == '[' + && fieldName.charAt(fieldName.length() - 1) == ']') { + try { + // Field name is '[]' and target object is array. + // Initially such calls were routed to IObject.resolveValue, which accepts field names as '[]' for arrays. + // However, this have two problems: + // 1. This doesn't work with primitive arrays (they always return null) + // 2. This doesn't correctly work with values - MAT code doesn't handle 0 address properly in this case + // So, now we handle this case directly in our code + int index = Integer.parseInt(fieldName.substring(1, fieldName.length() - 1)); + int length = ((IArray) iObject).getLength(); + if (index >= 0 && index < length) { + if (iObject instanceof IPrimitiveArray) { + return ((IPrimitiveArray) iObject).getValueAt(index); + } else if (iObject instanceof IObjectArray) { + return resolveReference(iObject.getSnapshot(), ((IObjectArray) iObject).getReferenceArray()[index]); + } + } else { + return null; + } + } catch (NumberFormatException e) { + // fall down + } + } + return resolveReference(IObjectMethods.resolveSimpleValue(iObject, fieldName)); + } + + @SuppressWarnings("unused") + public static Object getStaticField(Object r, String name) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return null; + } + + IObject iObject = ref.getIObject(); + if (iObject instanceof IClass) { + IClass iClass = (IClass) iObject; + for (Field field : iClass.getStaticFields()) { + if (field.getName().equals(name)) { + Object value = field.getValue(); + if (value instanceof IObject) { + return new HeapReference((IObject) value); + } else { + return value; + } + } + } + } + return null; + } + + @SuppressWarnings("unused") + public static long getAddress(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return -1; + } + + return ref.getIObject().getObjectAddress(); + } + + @SuppressWarnings("unused") + public static long toLong(String value) { + return Long.decode(value); + } + + @SuppressWarnings("unused") + public static Object getDominator(Object r) { + HeapReference ref = ensureHeapReference(r); + if (ref == null) { + return null; + } + + try { + ISnapshot snapshot = ref.getIObject().getSnapshot(); + return HeapReference.valueOf(snapshot.getObject(snapshot.getImmediateDominatorId(ref.getIObject().getObjectId()))); + } catch (SnapshotException e) { + throw new RuntimeException("Cannot obtain immediate dominator object for " + r, e); + } + } + +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/HeapFunctionsBase.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/HeapFunctionsBase.java new file mode 100644 index 0000000..018805b --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/HeapFunctionsBase.java @@ -0,0 +1,52 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import cchesser.javaperf.mcp.calcite.HeapReference; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IObject; + +import java.lang.reflect.Method; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.HeapFunctionsBase}. + */ +public class HeapFunctionsBase { + protected static Object resolveReference(Object value) { + return value instanceof IObject ? HeapReference.valueOf((IObject) value) : value; + } + + protected static HeapReference resolveReference(ISnapshot snapshot, long address) { + if (address == 0) { + // Eclipse MAT always returns "SystemClassLoader" for address=0, so we return null instead + return null; + } + try { + return HeapReference.valueOf(snapshot.getObject(snapshot.mapAddressToId(address))); + } catch (SnapshotException e) { + return null; + } + } + + protected static HeapReference ensureHeapReference(Object r) { + return r instanceof HeapReference ? (HeapReference) r : null; + } + + protected static String toString(IObject o) { + String classSpecific = o.getClassSpecificName(); + if (classSpecific != null) { + return classSpecific; + } + return o.getDisplayName(); + } + + protected static Method findMethod(Class cls, String name) { + for (Method m : cls.getMethods()) { + if (m.getName().equals(name)) { + return m; + } + } + return null; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/IClassMethods.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/IClassMethods.java new file mode 100644 index 0000000..463a6ae --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/IClassMethods.java @@ -0,0 +1,39 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IObject; + +@SuppressWarnings("unused") +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.IClassMethods}. + */ +public abstract class IClassMethods { + public static IClass getSuper(IObject clazz) { + if (clazz instanceof IClass) { + return ((IClass) clazz).getSuperClass(); + } + return null; + } + + public static IObject getClassLoader(IObject clazz) { + if (!(clazz instanceof IClass)) { + return null; + } + int classLoaderId = ((IClass) clazz).getClassLoaderId(); + try { + return clazz.getSnapshot().getObject(classLoaderId); + } catch (SnapshotException e) { + throw new IllegalArgumentException( + "Unable to retrieve classloader of class " + clazz + " in heap " + clazz.getSnapshot(), e); + } + } + + public static String getClassName(IObject clazz) { + if (clazz instanceof IClass) { + return ((IClass) clazz).getName(); + } + return null; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/IObjectMethods.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/IObjectMethods.java new file mode 100644 index 0000000..672ca69 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/IObjectMethods.java @@ -0,0 +1,32 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import cchesser.javaperf.mcp.calcite.HeapReference; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IObject; + +@SuppressWarnings("unused") +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.IObjectMethods}. + */ +public class IObjectMethods { + public static Object resolveSimpleValue(IObject object, String name) { + try { + if (object instanceof IClass) { + IClass clazz = (IClass) object; + if ("name".equalsIgnoreCase(name)) { + return IClassMethods.getClassName(object); + } + } + return object.resolveValue(name); + } catch (SnapshotException e) { + throw new IllegalArgumentException("Unable to resolve value " + name + " for object " + object, e); + } + } + + public static HeapReference toHeapReference(Object object) { + return HeapReference.valueOf((IObject) object); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/ISnapshotMethods.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/ISnapshotMethods.java new file mode 100644 index 0000000..f8a3514 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/ISnapshotMethods.java @@ -0,0 +1,45 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IObject; + +@SuppressWarnings("unused") +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.ISnapshotMethods}. + */ +public class ISnapshotMethods { + public static long getShallowSize(ISnapshot snapshot, int id) { + try { + return snapshot.getHeapSize(id); + } catch (SnapshotException e) { + throw new IllegalArgumentException("Unable to get shallow size of object " + id + " in heap " + snapshot, e); + } + } + + public static long getRetainedSize(ISnapshot snapshot, int id) { + try { + return snapshot.getRetainedHeapSize(id); + } catch (SnapshotException e) { + throw new IllegalArgumentException("Unable to get retained size of object " + id + " in heap " + snapshot, e); + } + } + + public static IObject getIObject(ISnapshot snapshot, int id) { + try { + return snapshot.getObject(id); + } catch (SnapshotException e) { + throw new IllegalArgumentException("Unable to get object " + id + " in heap " + snapshot, e); + } + } + + public static IClass getClassOf(ISnapshot snapshot, int id) { + try { + return snapshot.getClassOf(id); + } catch (SnapshotException e) { + throw new IllegalArgumentException("Unable to get class of " + id + " in heap " + snapshot, e); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/SnapshotFunctions.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/SnapshotFunctions.java new file mode 100644 index 0000000..89ed642 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/SnapshotFunctions.java @@ -0,0 +1,105 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import cchesser.javaperf.mcp.calcite.HeapReference; +import cchesser.javaperf.mcp.calcite.SnapshotHolder; + +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; +import org.apache.calcite.adapter.enumerable.CallImplementor; +import org.apache.calcite.adapter.enumerable.NotNullImplementor; +import org.apache.calcite.adapter.enumerable.NullPolicy; +import org.apache.calcite.adapter.enumerable.RexImpTable; +import org.apache.calcite.adapter.enumerable.RexToLixTranslator; +import org.apache.calcite.linq4j.tree.Expression; +import org.apache.calcite.linq4j.tree.Expressions; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexCall; +import org.apache.calcite.schema.Function; +import org.apache.calcite.schema.FunctionParameter; +import org.apache.calcite.schema.ImplementableFunction; +import org.apache.calcite.schema.ScalarFunction; +import org.apache.calcite.schema.impl.ReflectiveFunctionBase; +import org.eclipse.mat.snapshot.ISnapshot; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.List; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.SnapshotFunctions}. + */ +public class SnapshotFunctions { + private final ISnapshot snapshot; + + public SnapshotFunctions(int snapshotId) { + snapshot = SnapshotHolder.get(snapshotId); + } + + @SuppressWarnings("unused") + public HeapReference getReference(String address) { + return HeapFunctionsBase.resolveReference(snapshot, Long.decode(address)); + } + + public static Multimap createAll(ISnapshot snapshot) { + ImmutableMultimap.Builder builder = ImmutableMultimap.builder(); + builder.put("getReference", getFunction(snapshot, "getReference", String.class)); + return builder.build(); + } + + private static Function getFunction(ISnapshot snapshot, String name, Class ... argumentClasses) { + return new SnapshotFunction(snapshot, getMethod(SnapshotFunctions.class, name, argumentClasses)); + } + + private static Method getMethod(Class cls, String name, Class ... argumentClasses) { + try { + return cls.getMethod(name, argumentClasses); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private static Constructor getConstructor(Class cls, Class ... argumentClasses) { + try { + return cls.getConstructor(argumentClasses); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private static class SnapshotFunction implements ScalarFunction, ImplementableFunction, NotNullImplementor { + private final ISnapshot snapshot; + private final Method functionMethod; + + public SnapshotFunction(ISnapshot snapshot, Method functionMethod) { + this.snapshot = snapshot; + this.functionMethod = functionMethod; + } + + @Override + public CallImplementor getImplementor() { + return RexImpTable.createImplementor(this, NullPolicy.NONE, false); + } + + @Override + public Expression implement(RexToLixTranslator rexToLixTranslator, RexCall rexCall, List operands) { + int snapshotId = SnapshotHolder.put(snapshot); + + return Expressions.call( + Expressions.new_(getConstructor(SnapshotFunctions.class, Integer.TYPE), Expressions.constant(snapshotId, + Integer.TYPE)), + functionMethod, operands); + } + + @Override + public RelDataType getReturnType(RelDataTypeFactory typeFactory) { + return typeFactory.createJavaType(functionMethod.getReturnType()); + } + + @Override + public List getParameters() { + return ReflectiveFunctionBase.builder().addMethodParameters(functionMethod).build(); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/TableFunctions.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/TableFunctions.java new file mode 100644 index 0000000..bae6c3d --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/functions/TableFunctions.java @@ -0,0 +1,228 @@ +package cchesser.javaperf.mcp.calcite.functions; + +import cchesser.javaperf.mcp.calcite.HeapReference; +import cchesser.javaperf.mcp.calcite.collections.CollectionsActions; +import cchesser.javaperf.mcp.calcite.schema.references.OutboundReferencesTable; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; +import org.apache.calcite.adapter.java.AbstractQueryableTable; +import org.apache.calcite.linq4j.BaseQueryable; +import org.apache.calcite.linq4j.Enumerator; +import org.apache.calcite.linq4j.Linq4j; +import org.apache.calcite.linq4j.QueryProvider; +import org.apache.calcite.linq4j.Queryable; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.schema.QueryableTable; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.schema.Statistic; +import org.apache.calcite.schema.Statistics; +import org.apache.calcite.schema.TableFunction; +import org.apache.calcite.schema.impl.TableFunctionImpl; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.ImmutableBitSet; +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.inspections.collectionextract.CollectionExtractionUtils; +import org.eclipse.mat.inspections.collectionextract.ExtractedMap; +import org.eclipse.mat.inspections.collectionextract.ICollectionExtractor; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IObject; +import org.eclipse.mat.snapshot.model.NamedReference; +import org.eclipse.mat.util.VoidProgressListener; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.functions.TableFunctions}. + */ +public class TableFunctions { + + public static Multimap createAll() { + ImmutableMultimap.Builder builder = ImmutableMultimap.builder(); + builder.put("getValues", TableFunctionImpl.create(TableFunctions.class, "getValues")); + builder.put("getMapEntries", TableFunctionImpl.create(TableFunctions.class, "getMapEntries")); + builder.put("getRetainedSet", TableFunctionImpl.create(TableFunctions.class, "getRetainedSet")); + builder.put("getOutboundReferences", TableFunctionImpl.create(TableFunctions.class, "getOutboundReferences")); + builder.put("getInboundReferences", TableFunctionImpl.create(TableFunctions.class, "getInboundReferences")); + return builder.build(); + } + + @SuppressWarnings("unused") + public static QueryableTable getValues(Object r) { + List references; + if (!(r instanceof HeapReference)) { + references = Collections.emptyList(); + } else { + HeapReference ref = (HeapReference) r; + try { + ICollectionExtractor collectionExtractor = CollectionExtractionUtils.findCollectionExtractor(ref.getIObject()); + if (collectionExtractor == null) { + references = Collections.emptyList(); + } else { + references = collectReferences(ref.getIObject().getSnapshot(), + collectionExtractor.extractEntryIds(ref.getIObject())); + } + } catch (SnapshotException e) { + throw new RuntimeException("Cannot extract values from " + ref, e); + } + } + return new HeapReferenceTable(references, false); + } + + @SuppressWarnings("unused") + public static QueryableTable getMapEntries(Object r) { + List references; + if (!(r instanceof HeapReference)) { + references = Collections.emptyList(); + } else { + HeapReference ref = (HeapReference) r; + try { + ExtractedMap extractedMap = CollectionsActions.extractMap(ref.getIObject()); + if (extractedMap == null) { + references = Collections.emptyList(); + } else { + references = new ArrayList<>(); + for (Map.Entry entry : extractedMap) { + references.add(new HeapReference[]{ + HeapReference.valueOf(entry.getKey()), + HeapReference.valueOf(entry.getValue()) + }); + } + } + } catch (SnapshotException e) { + throw new RuntimeException("Cannot extract values from " + ref, e); + } + } + return new HeapReferencesTable(new String[]{"key", "value"}, references); + } + + @SuppressWarnings("unused") + public static QueryableTable getRetainedSet(Object r) { + List references; + if (!(r instanceof HeapReference)) { + references = Collections.emptyList(); + } else { + HeapReference ref = (HeapReference) r; + ISnapshot snapshot = ref.getIObject().getSnapshot(); + try { + references = collectReferences + ( + snapshot, + snapshot.getRetainedSet(new int[]{ref.getIObject().getObjectId()}, new VoidProgressListener()) + ); + } catch (SnapshotException e) { + throw new RuntimeException("Cannot extract retained set from " + r, e); + } + } + return new HeapReferenceTable(references, true); + } + + @SuppressWarnings("unused") + public static QueryableTable getOutboundReferences(Object r) { + List references; + if (!(r instanceof HeapReference)) { + references = Collections.emptyList(); + } else { + HeapReference ref = (HeapReference) r; + references = ref.getIObject().getOutboundReferences(); + } + return new OutboundReferencesTable(references); + } + + @SuppressWarnings("unused") + public static QueryableTable getInboundReferences(Object r) { + List references; + if (!(r instanceof HeapReference)) { + references = Collections.emptyList(); + } else { + HeapReference ref = (HeapReference) r; + ISnapshot snapshot = ref.getIObject().getSnapshot(); + try { + references = collectReferences + ( + snapshot, + snapshot.getInboundRefererIds(ref.getIObject().getObjectId()) + ); + } catch (SnapshotException e) { + throw new RuntimeException("Cannot extract inbound references from " + r, e); + } + } + return new HeapReferenceTable(references, true); + } + + private static List collectReferences(ISnapshot snapshot, int[] objectIds) throws SnapshotException { + if (objectIds != null && objectIds.length > 0) { + List references = new ArrayList<>(); + for (int objectId : objectIds) { + references.add(HeapReference.valueOf(snapshot.getObject(objectId))); + } + return references; + } else { + return Collections.emptyList(); + } + } + + private static class HeapReferenceTable extends ValuesListTable { + HeapReferenceTable(Collection references, boolean unique) { + super(HeapReference.class, new String[]{"this"}, references, unique); + } + } + + private static class HeapReferencesTable extends ValuesListTable { + HeapReferencesTable(String[] columnNames, Collection references) { + super(HeapReference[].class, columnNames, references, false); + } + } + + private static class ValuesListTable extends AbstractQueryableTable { + private final static List UNIQUE_KEYS_STATISTICS = ImmutableList.of(ImmutableBitSet.of(0)); + private final static List NON_UNIQUE_KEYS_STATISTICS = ImmutableList.of(ImmutableBitSet.of()); + + private final String[] columnNames; + private final Collection values; + private final boolean unique; + + ValuesListTable(Type targetRowType, String[] columnNames, Collection values, boolean unique) { + super(targetRowType); + this.columnNames = columnNames; + this.values = values; + this.unique = unique; + } + + @SuppressWarnings("unchecked") + @Override + public Queryable asQueryable(QueryProvider queryProvider, SchemaPlus schemaPlus, String s) { + BaseQueryable queryable = new BaseQueryable(null, getElementType(), null) { + @Override + public Enumerator enumerator() { + return Linq4j.enumerator(values); + } + }; + return (Queryable) queryable; + } + + @Override + public RelDataType getRowType(RelDataTypeFactory relDataTypeFactory) { + RelDataTypeFactory.Builder builder = relDataTypeFactory.builder(); + RelDataType anyNull = relDataTypeFactory.createTypeWithNullability( + relDataTypeFactory.createSqlType(SqlTypeName.ANY), true); + for (String columnName : columnNames) { + builder.add(columnName, anyNull); + } + return builder.build(); + } + + @Override + public Statistic getStatistic() { + return Statistics.of(values.size(), unique ? UNIQUE_KEYS_STATISTICS : NON_UNIQUE_KEYS_STATISTICS); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/GroupEnumerator.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/GroupEnumerator.java new file mode 100644 index 0000000..2abc301 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/GroupEnumerator.java @@ -0,0 +1,106 @@ +package cchesser.javaperf.mcp.calcite.neo; + +import org.apache.calcite.linq4j.Enumerator; + +import java.util.NoSuchElementException; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.neo.GroupEnumerator}. + */ +public abstract class GroupEnumerator implements Enumerator { + private final GroupType[] groups; + private RowsType rows; + private int rowsCount; + + private int currentRow = -1; + private int currentGroup = -1; + + private ResultType currentResult; + + public GroupEnumerator(GroupType[] groups) { + this.groups = groups; + } + + @Override + public ResultType current() { + if (currentResult == null) { + throw new NoSuchElementException(); + } else { + return currentResult; + } + } + + @Override + public boolean moveNext() { + do { + if (advanceRow()) { + return true; + } + } while (advanceGroup()); + return false; + } + + @Override + public void reset() { + currentRow = -1; + currentGroup = -1; + currentResult = null; + } + + @Override + public void close() { + reset(); + } + + private boolean advanceRow() { + if (currentGroup == -1) { + return false; + } else if (currentRow < rowsCount - 1) { + currentRow++; + resolveRow(); + return true; + } else { + return false; + } + } + + private boolean advanceGroup() { + if (currentGroup < groups.length - 1) { + currentGroup++; + currentRow = -1; + resolveGroup(); + return true; + } else { + currentResult = null; + return false; + } + } + + private void resolveGroup() { + try { + rows = resolveGroup(groups[currentGroup]); + rowsCount = rowsCount(rows); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void resolveRow() { + try { + currentResult = resolveRow(groups[currentGroup], rows, currentRow); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected abstract int rowsCount(RowsType rows); + + protected abstract RowsType resolveGroup(GroupType group) throws Exception; + + protected abstract ResultType resolveRow(GroupType group, RowsType rows, int currentRow) throws Exception; +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/PackageSchema.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/PackageSchema.java new file mode 100644 index 0000000..1359e52 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/PackageSchema.java @@ -0,0 +1,159 @@ +package cchesser.javaperf.mcp.calcite.neo; + +import cchesser.javaperf.mcp.calcite.functions.CollectionsFunctions; +import cchesser.javaperf.mcp.calcite.functions.HeapFunctions; +import cchesser.javaperf.mcp.calcite.functions.SnapshotFunctions; +import cchesser.javaperf.mcp.calcite.functions.TableFunctions; +import cchesser.javaperf.mcp.calcite.schema.objects.IClassesList; +import cchesser.javaperf.mcp.calcite.schema.objects.InstanceByClassTable; +import cchesser.javaperf.mcp.calcite.schema.objects.InstanceIdsByClassTable; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; +import org.apache.calcite.schema.Function; +import org.apache.calcite.schema.Schema; +import org.apache.calcite.schema.Table; +import org.apache.calcite.schema.impl.AbstractSchema; +import org.apache.calcite.schema.impl.ScalarFunctionImpl; +import org.apache.calcite.sql.advise.SqlAdvisorGetHintsFunction; +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IClass; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.neo.PackageSchema}. + */ +public class PackageSchema extends AbstractSchema { + private final Multimap functions; + private final Map subPackages = new HashMap<>(); + private final Map classes = new HashMap<>(); + + private PackageSchema() { + this(ImmutableMultimap.of()); + } + + private PackageSchema(Multimap functions) { + this.functions = functions; + } + + private PackageSchema getPackage(String subSchemaName) { + + PackageSchema subSchema = subPackages.get(subSchemaName); + if (subSchema == null) { + subSchema = new PackageSchema(); + subPackages.put(subSchemaName, subSchema); + } + return subSchema; + } + + private void addClass(String name, Table table) { + if (!classes.containsKey(name)) { + classes.put(name, table); + } + } + + private void addClass(String className, IClassesList classesList) { + addClass(className, new InstanceByClassTable(classesList)); + addClass("$ids$:" + className, new InstanceIdsByClassTable(classesList)); + } + + @Override + protected Map getSubSchemaMap() { + return ImmutableMap.copyOf(subPackages); + } + + @Override + protected Map getTableMap() { + return Collections.unmodifiableMap(classes); + } + + @Override + protected Multimap getFunctionMultimap() { + return functions; + } + + @Override + public boolean isMutable() { + return false; + } + + private static String getClassName(final String fullClassName) { + int lastDotIndex = fullClassName.lastIndexOf('.'); + return lastDotIndex == -1 ? fullClassName : fullClassName.substring(lastDotIndex + 1); + } + + private static PackageSchema getPackage(final PackageSchema rootPackage, final String fullClassName) { + String[] nameParts = fullClassName.split("\\."); + PackageSchema targetSchema = rootPackage; + for (int i = 0; i < nameParts.length - 1; i++) { + targetSchema = targetSchema.getPackage(nameParts[i]); + } + return targetSchema; + } + + public static PackageSchema resolveSchema(ISnapshot snapshot) { + + try { + // Create functions for schema + ImmutableMultimap.Builder builder = ImmutableMultimap.builder(); + builder.putAll(ScalarFunctionImpl.functions(HeapFunctions.class)); + builder.putAll(CollectionsFunctions.createAll()); + builder.putAll(TableFunctions.createAll()); + builder.putAll(SnapshotFunctions.createAll(snapshot)); + builder.put("getHints", new SqlAdvisorGetHintsFunction()); + ImmutableMultimap functions = builder.build(); + + // Create default schema + PackageSchema defaultSchema = new PackageSchema(functions); + + // Collect all classes names + Collection classes = snapshot.getClasses(); + HashSet classesNames = new HashSet<>(); + for (IClass iClass : classes) { + classesNames.add(iClass.getName()); + } + + PackageSchema instanceOfPackage = defaultSchema.getPackage("instanceof"); + + // Add all classes to schema + for (String fullClassName : classesNames) { + IClassesList classOnly = new IClassesList(snapshot, fullClassName, false); + + // Make class available via "package.name.ClassName" (full class name in a root schema) + defaultSchema.addClass(fullClassName, classOnly); + + String simpleClassName = getClassName(fullClassName); + + // Make class available via package.name.ClassName (schema.schema.Class) + PackageSchema packageSchema = getPackage(defaultSchema, fullClassName); + packageSchema.addClass(simpleClassName, classOnly); + + // Add instanceof + IClassesList withSubClasses = new IClassesList(snapshot, fullClassName, true); + + // Make class available via "instanceof.package.name.ClassName" + defaultSchema.addClass("instanceof." + fullClassName, withSubClasses); + + // Make class available via instanceof.package.name.ClassName + PackageSchema instanceOfSchema = getPackage(instanceOfPackage, fullClassName); + instanceOfSchema.addClass(simpleClassName, withSubClasses); + + } + + // Add thread stacks table + defaultSchema.getPackage("native").addClass("ThreadStackFrames", new SnapshotThreadStacksTable(snapshot)); + + return defaultSchema; + } catch (SnapshotException e) { + throw new RuntimeException("Cannot resolve package schemes", e); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/SnapshotThreadStacksTable.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/SnapshotThreadStacksTable.java new file mode 100644 index 0000000..eeddfb7 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/neo/SnapshotThreadStacksTable.java @@ -0,0 +1,122 @@ +package cchesser.javaperf.mcp.calcite.neo; + +import cchesser.javaperf.mcp.calcite.HeapReference; + +import com.google.common.collect.ImmutableList; +import org.apache.calcite.DataContext; +import org.apache.calcite.linq4j.AbstractEnumerable; +import org.apache.calcite.linq4j.Enumerable; +import org.apache.calcite.linq4j.Enumerator; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.schema.ScannableTable; +import org.apache.calcite.schema.Statistic; +import org.apache.calcite.schema.Statistics; +import org.apache.calcite.schema.impl.AbstractTable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.ImmutableBitSet; +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IStackFrame; +import org.eclipse.mat.snapshot.model.IThreadStack; + +import java.util.ArrayList; +import java.util.List; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.neo.SnapshotThreadStacksTable}. + */ +public class SnapshotThreadStacksTable extends AbstractTable implements ScannableTable { + private final ISnapshot snapshot; + + public SnapshotThreadStacksTable(ISnapshot snapshot) { + this.snapshot = snapshot; + } + + @Override + public Statistic getStatistic() { + int counter = 0; + for (IThreadStack threadStack : getThreadStacks()) { + counter += threadStack.getStackFrames().length; + } + return Statistics.of(counter, ImmutableList.of(ImmutableBitSet.of(0, 1))); + } + + @Override + public RelDataType getRowType(RelDataTypeFactory typeFactory) { + RelDataTypeFactory.Builder builder = typeFactory.builder(); + RelDataType anyType = typeFactory.createSqlType(SqlTypeName.ANY); + builder.add("thread", anyType); // non-null + builder.add("depth", typeFactory.createJavaType(int.class)); + builder.add("text", typeFactory.createTypeWithNullability( + typeFactory.createJavaType(String.class), true)); + builder.add("objects", typeFactory.createTypeWithNullability( + typeFactory.createMultisetType(anyType, -1), true)); + return builder.build(); + } + + @Override + public Enumerable scan(DataContext dataContext) { + return new AbstractEnumerable() { + @Override + public Enumerator enumerator() { + return new StackFramesEnumerator(snapshot, getThreadStacks()); + } + }; + } + + private IThreadStack[] getThreadStacks() { + try { + List threadStacks = new ArrayList<>(); + for (IClass threadClass : snapshot.getClassesByName("java.lang.Thread", true)) { + if (threadClass.getNumberOfObjects() > 0) { + for (int threadObject : threadClass.getObjectIds()) { + IThreadStack threadStack = snapshot.getThreadStack(threadObject); + if (threadStack != null) { + threadStacks.add(threadStack); + } + } + } + } + return threadStacks.toArray(new IThreadStack[0]); + } catch (SnapshotException e) { + throw new RuntimeException(e); + } + } + + private static class StackFramesEnumerator extends GroupEnumerator { + private final ISnapshot snapshot; + + public StackFramesEnumerator(ISnapshot snapshot, IThreadStack[] groups) { + super(groups); + this.snapshot = snapshot; + } + + @Override + protected IStackFrame[] resolveGroup(IThreadStack group) throws Exception { + return group.getStackFrames(); + } + + @Override + protected int rowsCount(IStackFrame[] rows) { + return rows.length; + } + + @Override + protected Object[] resolveRow(IThreadStack group, IStackFrame[] rows, int currentRow) throws Exception { + Object[] result = new Object[4]; + IStackFrame frame = rows[currentRow]; + result[0] = HeapReference.valueOf(snapshot.getObject(group.getThreadId())); + result[1] = currentRow; + result[2] = frame.getText(); + List objects = new ArrayList<>(); + for (int objectId : frame.getLocalObjectsIds()) { + objects.add(HeapReference.valueOf(snapshot.getObject(objectId))); + } + result[3] = objects; + return result; + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rex/ExecutionRexBuilderContext.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rex/ExecutionRexBuilderContext.java new file mode 100644 index 0000000..57f02bd --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rex/ExecutionRexBuilderContext.java @@ -0,0 +1,67 @@ +package cchesser.javaperf.mcp.calcite.rex; + +import cchesser.javaperf.mcp.calcite.SnapshotHolder; + +import com.google.common.collect.ImmutableList; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.schema.impl.ScalarFunctionImpl; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlIdentifier; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.parser.SqlParserPos; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeFamily; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.sql.validate.SqlUserDefinedFunction; +import org.eclipse.mat.snapshot.ISnapshot; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.rex.ExecutionRexBuilderContext}. + */ +public class ExecutionRexBuilderContext extends RexBuilderContext { + private final int snapshotId; + private final RexNode objectId; + + private RexNode snapshot; + + private static final SqlFunction GET_SNAPSHOT = + new SqlUserDefinedFunction( + new SqlIdentifier("GET_SNAPSHOT", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(tf -> tf.createTypeWithNullability(tf.createJavaType(ISnapshot.class), + false)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.NUMERIC), + tf -> ImmutableList.of( + tf.createJavaType(int.class)), + i -> "snapshotId", + i -> false), + ScalarFunctionImpl.create(SnapshotHolder.class, "get")); + + public ExecutionRexBuilderContext(RelOptCluster cluster, int snapshotId, RexNode objectId) { + super(cluster); + this.snapshotId = snapshotId; + this.objectId = objectId; + } + + @Override + public RexNode getSnapshot() { + if (snapshot == null) { + RelDataTypeFactory typeFactory = getCluster().getTypeFactory(); + RexBuilder b = getBuilder(); + snapshot = b.makeCall(GET_SNAPSHOT, b.makeLiteral(snapshotId, typeFactory.createSqlType(SqlTypeName.INTEGER), false)); + } + return snapshot; + } + + @Override + public RexNode getIObjectId() { + return objectId; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rex/RexBuilderContext.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rex/RexBuilderContext.java new file mode 100644 index 0000000..8c704e0 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rex/RexBuilderContext.java @@ -0,0 +1,72 @@ +package cchesser.javaperf.mcp.calcite.rex; + +import cchesser.javaperf.mcp.calcite.functions.ISnapshotMethods; +import cchesser.javaperf.mcp.calcite.schema.objects.HeapOperatorTable; + +import com.google.common.collect.ImmutableList; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.rex.RexBuilder; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.schema.impl.ScalarFunctionImpl; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlIdentifier; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.parser.SqlParserPos; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeFamily; +import org.apache.calcite.sql.validate.SqlUserDefinedFunction; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IObject; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.rex.RexBuilderContext}. + */ +public abstract class RexBuilderContext { + private final RelOptCluster cluster; + private RexNode object; + + private static final SqlFunction GET_IOBJECT = + new SqlUserDefinedFunction( + new SqlIdentifier("GET_IOBJECT", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(tf -> + tf.createTypeWithNullability(tf.createJavaType(IObject.class), false)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.ANY, SqlTypeFamily.NUMERIC), + tf -> ImmutableList.of( + tf.createTypeWithNullability(tf.createJavaType(ISnapshot.class), false), + tf.createJavaType(int.class)), + i -> i == 0 ? "snapshotId" : "objectId", + i -> false), + ScalarFunctionImpl.create(ISnapshotMethods.class, "getIObject")); + + public RexBuilderContext(RelOptCluster cluster) { + this.cluster = cluster; + } + + public RexBuilder getBuilder() { + return cluster.getRexBuilder(); + } + + public RelOptCluster getCluster() { + return cluster; + } + + public abstract RexNode getSnapshot(); + + public abstract RexNode getIObjectId(); + + public RexNode toHeapReference(RexNode node) { + return getBuilder().makeCall(HeapOperatorTable.TO_HEAP_REFERENCE, node); + } + + public RexNode getIObject() { + if (object == null) { + object = getBuilder().makeCall(GET_IOBJECT, getSnapshot(), getIObjectId()); + } + return object; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rules/DefaultRuleConfig.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rules/DefaultRuleConfig.java new file mode 100644 index 0000000..bfe0a9a --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rules/DefaultRuleConfig.java @@ -0,0 +1,60 @@ +package cchesser.javaperf.mcp.calcite.rules; + +import org.apache.calcite.plan.RelOptRule; +import org.apache.calcite.plan.RelRule; +import org.apache.calcite.rel.core.RelFactories; +import org.apache.calcite.tools.RelBuilderFactory; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.rules.DefaultRuleConfig}. + */ +public class DefaultRuleConfig implements RelRule.Config { + public static final RelRule.Config EMPTY = + new DefaultRuleConfig(RelFactories.LOGICAL_BUILDER, null, null); + + protected final RelBuilderFactory relBuilderFactory; + protected final String description; + protected final RelRule.OperandTransform operandTransform; + + protected DefaultRuleConfig(RelBuilderFactory relBuilderFactory, String description, RelRule.OperandTransform operandTransform) { + this.relBuilderFactory = relBuilderFactory; + this.description = description; + this.operandTransform = operandTransform; + } + + @Override + public RelRule.Config withRelBuilderFactory(RelBuilderFactory relBuilderFactory) { + return new DefaultRuleConfig(relBuilderFactory, description, operandTransform); + } + + @Override + public String description() { + return description; + } + + @Override + public RelBuilderFactory relBuilderFactory() { + return relBuilderFactory; + } + + @Override + public RelRule.Config withDescription(@org.checkerframework.checker.nullness.qual.Nullable String description) { + return new DefaultRuleConfig(relBuilderFactory, description, operandTransform); + } + + @Override + public RelRule.OperandTransform operandSupplier() { + return operandTransform; + } + + @Override + public RelRule.Config withOperandSupplier(RelRule.OperandTransform operandTransform) { + return new DefaultRuleConfig(relBuilderFactory, description, operandTransform); + } + + @Override + public RelOptRule toRule() { + return null; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rules/InstanceAccessByIdRule.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rules/InstanceAccessByIdRule.java new file mode 100644 index 0000000..10ab072 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/rules/InstanceAccessByIdRule.java @@ -0,0 +1,30 @@ +package cchesser.javaperf.mcp.calcite.rules; + +import org.apache.calcite.plan.RelOptRuleCall; +import org.apache.calcite.plan.RelRule; +import org.apache.calcite.rel.core.Correlate; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.rules.InstanceAccessByIdRule}. + */ +public class InstanceAccessByIdRule extends RelRule { + public static final InstanceAccessByIdRule INSTANCE = + new InstanceAccessByIdRule( + DefaultRuleConfig.EMPTY + .withOperandSupplier( + b0 -> + b0.operand(Correlate.class) + .anyInputs() + ) + ); + + public InstanceAccessByIdRule(RelRule.Config config) { + super(config); + } + + @Override + public void onMatch(RelOptRuleCall call) { + System.out.println("InstanceAccessByIdRule fired for call " + call); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/ClassRowTypeCache.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/ClassRowTypeCache.java new file mode 100644 index 0000000..4ed57b5 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/ClassRowTypeCache.java @@ -0,0 +1,228 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import cchesser.javaperf.mcp.calcite.rex.RexBuilderContext; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.Pair; +import org.eclipse.mat.snapshot.model.FieldDescriptor; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.function.Function; + +import static cchesser.javaperf.mcp.calcite.schema.objects.SnapshotRexExpressions.getClassLoader; +import static cchesser.javaperf.mcp.calcite.schema.objects.SnapshotRexExpressions.getClassName; +import static cchesser.javaperf.mcp.calcite.schema.objects.SnapshotRexExpressions.getClassOf; +import static cchesser.javaperf.mcp.calcite.schema.objects.SnapshotRexExpressions.getSuper; +import static cchesser.javaperf.mcp.calcite.schema.objects.SnapshotRexExpressions.resolveField; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.ClassRowTypeCache}. + */ +public class ClassRowTypeCache { + + public static LoadingCache>>>> CACHE = CacheBuilder + .newBuilder() + .weakKeys() + .build(new CacheLoader>>>>() { + @Override + public LoadingCache>>> load( + final RelDataTypeFactory typeFactory) throws Exception { + return CacheBuilder.newBuilder().weakKeys() + .build(new ClassRowTypeResolver(typeFactory)); + } + }); + + private interface ExtraTypes extends IObject.Type { + int ANY = -1; + int CHARACTER = -2; + } + + private static final class ClassRowTypeResolver + extends + CacheLoader>>> { + private final RelDataTypeFactory typeFactory; + + private ClassRowTypeResolver(RelDataTypeFactory typeFactory) { + this.typeFactory = typeFactory; + } + + private LinkedHashMap getAllInstanceFields(IClass clazz) { + // In case base class and subclass have a field with the same name, we just use the one from a subclass + // TODO: use org.eclipse.mat.snapshot.model.IInstance.getFields() in those cases + LinkedHashMap seenFields = new LinkedHashMap<>(); + for (IClass i = clazz; i != null; i = i.getSuperClass()) { + List fds = i.getFieldDescriptors(); + // Iterate fields in a reversed order in order. + // We iterate class hierarchy in reverse as well (subclass -> superclass), + for (ListIterator it = fds.listIterator(fds.size()); it.hasPrevious(); ) { + FieldDescriptor fd = it.previous(); + if (!seenFields.containsKey(fd.getName())) { + seenFields.put(fd.getName(), new Field(fd)); + } + } + if (IClass.JAVA_LANG_CLASS.equals(i.getName())) { + seenFields.put(SpecialFields.CLASS_LOADER, new Field(SpecialFields.CLASS_LOADER, IObject.Type.OBJECT)); + seenFields.put(SpecialFields.SUPER, new Field(SpecialFields.SUPER, IObject.Type.OBJECT)); + seenFields.put(SpecialFields.CLASS_NAME, new Field(SpecialFields.CLASS_NAME, ExtraTypes.CHARACTER)); + // Hide the default "Class.name" field as it might be null + seenFields.remove("name"); + } + } + seenFields.put(SpecialFields.CLASS, new Field(SpecialFields.CLASS, IObject.Type.OBJECT)); + return seenFields; + } + + @Override + public Pair>> load( + IClassesList classesList) throws Exception { + List> resolvers = new ArrayList<>(); + List names = new ArrayList<>(); + List types = new ArrayList<>(); + + names.add("this"); + RelDataType any = typeFactory.createSqlType(SqlTypeName.ANY); + RelDataType anyNull = typeFactory.createTypeWithNullability(any, true); + types.add(any); + resolvers.add(SnapshotRexExpressions::computeThis); + + // In case multiple classes have a field with different datatype, we just make field type "ANY" + + LinkedHashMap fields = null; + for (IClass aClass : classesList.getRootClasses()) { + LinkedHashMap allInstanceFields = getAllInstanceFields(aClass); + if (fields == null) { + fields = allInstanceFields; + continue; + } + for (Iterator> it = fields.entrySet().iterator(); it.hasNext(); ) { + Map.Entry entry = it.next(); + String fieldName = entry.getKey(); + Field field = allInstanceFields.get(fieldName); + if (field == null) { + // Keep just common fields + it.remove(); + continue; + } + if (entry.getValue().getType() == field.getType()) { + continue; + } + // If data type differs, use "ANY" type + entry.setValue(new Field(fieldName, ExtraTypes.ANY)); + } + } + + List fieldsInOrder = fields == null ? Collections.emptyList() : new ArrayList<>(fields.values()); + Collections.reverse(fieldsInOrder); + + for (Field field : fieldsInOrder) { + int type = field.getType(); + RelDataType dataType; + switch (type) { + case IObject.Type.BOOLEAN: + dataType = typeFactory.createJavaType(boolean.class); + break; + case IObject.Type.BYTE: + dataType = typeFactory.createJavaType(byte.class); + break; + case IObject.Type.CHAR: + dataType = typeFactory.createJavaType(char.class); + break; + case IObject.Type.DOUBLE: + dataType = typeFactory.createJavaType(double.class); + break; + case IObject.Type.FLOAT: + dataType = typeFactory.createJavaType(float.class); + break; + case IObject.Type.SHORT: + dataType = typeFactory.createJavaType(short.class); + break; + case IObject.Type.INT: + dataType = typeFactory.createJavaType(int.class); + break; + case IObject.Type.LONG: + dataType = typeFactory.createJavaType(long.class); + break; + case IObject.Type.OBJECT: + // fall-through + case ExtraTypes.ANY: + dataType = anyNull; + break; + case ExtraTypes.CHARACTER: + dataType = typeFactory.createTypeWithNullability( + typeFactory.createSqlType(SqlTypeName.VARCHAR), true); + break; + default: + dataType = typeFactory.createJavaType(String.class); + break; + } + types.add(dataType); + String fieldName = field.getName(); + Function columnCalc; + switch (fieldName) { + case SpecialFields.CLASS: + // This is Object#getClass + // For java.lang.Class it returns "java.lang.Class" + columnCalc = (RexBuilderContext context) -> + getClassOf(context, context.getIObjectId()); + break; + case SpecialFields.SUPER: + // This property is available only for classes + // It is assumed that getIObject would return IClass + columnCalc = (RexBuilderContext context) -> + getSuper(context, context.getIObject()); + break; + case SpecialFields.CLASS_LOADER: + // This property is available only for classes + // It is assumed that getIObject would return IClass + columnCalc = (RexBuilderContext context) -> + getClassLoader(context, context.getIObject()); + break; + case SpecialFields.CLASS_NAME: + // This property is available only for classes + // It is assumed that getIObject would return IClass + columnCalc = (RexBuilderContext context) -> + getClassName(context, context.getIObject()); + fieldName = "name"; + break; + default: + String resolvedField = fieldName; + columnCalc = (RexBuilderContext context) -> + resolveField(context, resolvedField); + } + if (type == IObject.Type.OBJECT) { + // Wrap object fields with HeapReference + Function prev = columnCalc; + columnCalc = (RexBuilderContext context) -> + context.toHeapReference(prev.apply(context)); + } else if (dataType != anyNull) { + Function prev = columnCalc; + columnCalc = (RexBuilderContext context) -> + context.getBuilder().makeCast(dataType, prev.apply(context)); + } + names.add(fieldName); + resolvers.add(columnCalc); + } + + return Pair.of( + typeFactory.createStructType(types, names), + resolvers); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/Field.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/Field.java new file mode 100644 index 0000000..de1a06e --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/Field.java @@ -0,0 +1,50 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import org.eclipse.mat.snapshot.model.FieldDescriptor; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.Field}. + */ +public class Field { + private final String name; + private final int type; + + public Field(String name, int type) { + this.name = name; + this.type = type; + } + + public Field(FieldDescriptor descriptor) { + this(descriptor.getName(), descriptor.getType()); + } + + public String getName() { + return name; + } + + public int getType() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Field field = (Field) o; + + return type == field.type && name.equals(field.name); + } + + @Override + public int hashCode() { + int result = name.hashCode(); + result = 31 * result + type; + return result; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/HeapOperatorTable.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/HeapOperatorTable.java new file mode 100644 index 0000000..43186d8 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/HeapOperatorTable.java @@ -0,0 +1,113 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import cchesser.javaperf.mcp.calcite.HeapReference; +import cchesser.javaperf.mcp.calcite.functions.IClassMethods; +import cchesser.javaperf.mcp.calcite.functions.IObjectMethods; +import cchesser.javaperf.mcp.calcite.functions.ISnapshotMethods; + +import com.google.common.collect.ImmutableList; +import org.apache.calcite.schema.impl.ScalarFunctionImpl; +import org.apache.calcite.sql.SqlFunction; +import org.apache.calcite.sql.SqlIdentifier; +import org.apache.calcite.sql.SqlKind; +import org.apache.calcite.sql.parser.SqlParserPos; +import org.apache.calcite.sql.type.OperandTypes; +import org.apache.calcite.sql.type.ReturnTypes; +import org.apache.calcite.sql.type.SqlTypeFamily; +import org.apache.calcite.sql.validate.SqlUserDefinedFunction; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IObject; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.HeapOperatorTable}. + */ +public interface HeapOperatorTable { + // IObject + SqlFunction TO_HEAP_REFERENCE = new SqlUserDefinedFunction( + new SqlIdentifier("TO_HEAP_REFERENCE", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(tf -> + tf.createTypeWithNullability(tf.createJavaType(HeapReference.class), true)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.ANY), + tf -> ImmutableList.of( + tf.createTypeWithNullability(tf.createJavaType(Object.class), true)), + i -> "iobject", + i -> false), + ScalarFunctionImpl.create(IObjectMethods.class, "toHeapReference")); + + SqlFunction RESOLVE_VALUE = new SqlUserDefinedFunction( + new SqlIdentifier("RESOLVE_VALUE", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(typeFactory -> typeFactory.createJavaType(Object.class)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER), + tf -> ImmutableList.of( + tf.createTypeWithNullability(tf.createJavaType(IObject.class), false), + tf.createTypeWithNullability(tf.createJavaType(String.class), false)), + i -> i == 0 ? "iobject" : "fieldName", + i -> false), + ScalarFunctionImpl.create(IObjectMethods.class, "resolveSimpleValue")); + + SqlFunction GET_CLASS_OF = new SqlUserDefinedFunction( + new SqlIdentifier("GET_CLASS_OF", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(tf -> + tf.createTypeWithNullability(tf.createJavaType(IClass.class), true)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.ANY), + tf -> ImmutableList.of( + tf.createTypeWithNullability(tf.createJavaType(ISnapshot.class), false), + tf.createTypeWithNullability(tf.createJavaType(int.class), false)), + i -> i == 0 ? "snapshot" : "id", + i -> false), + ScalarFunctionImpl.create(ISnapshotMethods.class, "getClassOf")); + + // IClass + SqlFunction GET_SUPER = new SqlUserDefinedFunction( + new SqlIdentifier("GET_SUPER", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(tf -> + tf.createTypeWithNullability(tf.createJavaType(IClass.class), true)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.ANY), + tf -> ImmutableList.of( + tf.createTypeWithNullability(tf.createJavaType(IObject.class), true)), + i -> "iclass", + i -> false), + ScalarFunctionImpl.create(IClassMethods.class, "getSuper")); + + SqlFunction GET_CLASS_LOADER = new SqlUserDefinedFunction( + new SqlIdentifier("GET_CLASS_LOADER", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(tf -> + tf.createTypeWithNullability(tf.createJavaType(IObject.class), true)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.ANY), + tf -> ImmutableList.of( + tf.createTypeWithNullability(tf.createJavaType(IObject.class), false)), + i -> "iclass", + i -> false), + ScalarFunctionImpl.create(IClassMethods.class, "getClassLoader")); + + SqlFunction GET_CLASS_NAME = new SqlUserDefinedFunction( + new SqlIdentifier("GET_CLASS_NAME", SqlParserPos.ZERO), + SqlKind.OTHER_FUNCTION, + ReturnTypes.explicit(tf -> + tf.createTypeWithNullability(tf.createJavaType(String.class), true)), + null, + OperandTypes.operandMetadata( + ImmutableList.of(SqlTypeFamily.ANY), + tf -> ImmutableList.of( + tf.createTypeWithNullability(tf.createJavaType(IObject.class), true)), + i -> "iclass", + i -> false), + ScalarFunctionImpl.create(IClassMethods.class, "getClassName")); +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/IClassesList.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/IClassesList.java new file mode 100644 index 0000000..65deebb --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/IClassesList.java @@ -0,0 +1,62 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.model.IClass; + +import java.util.Collection; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.IClassesList}. + */ +public class IClassesList { + public final ISnapshot snapshot; + public final String className; + public final boolean includeSubClasses; + private long totalObjects = -1; + + public IClassesList(ISnapshot snapshot, String className, boolean includeSubClasses) { + this.snapshot = snapshot; + this.className = className; + this.includeSubClasses = includeSubClasses; + } + + public Collection getClasses() { + return getClasses(false); + } + + public Collection getRootClasses() { + return getClasses(false); + } + + public double getTotalObjects() { + if (totalObjects >= 0) { + return totalObjects; + } + long rows = 0; + for (IClass iClass : getClasses()) { + rows += iClass.getNumberOfObjects(); + } + return totalObjects = rows; + } + + private Collection getClasses(boolean justFirstItem) { + Collection classesByName; + try { + classesByName = snapshot.getClassesByName(className, + includeSubClasses && !justFirstItem); + } catch (SnapshotException e) { + throw new IllegalStateException("Unable to get class " + className); + } + return classesByName; + } + + @Override + public String toString() { + return "IClassesList{" + + "className='" + className + '\'' + + ", includeSubClasses=" + includeSubClasses + + '}'; + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceAccessByClassIdRule.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceAccessByClassIdRule.java new file mode 100644 index 0000000..ac512ac --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceAccessByClassIdRule.java @@ -0,0 +1,63 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import cchesser.javaperf.mcp.calcite.SnapshotHolder; +import cchesser.javaperf.mcp.calcite.rex.ExecutionRexBuilderContext; +import cchesser.javaperf.mcp.calcite.rex.RexBuilderContext; +import cchesser.javaperf.mcp.calcite.rules.DefaultRuleConfig; + +import org.apache.calcite.plan.*; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.tools.RelBuilder; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.InstanceAccessByClassIdRule}. + */ +public class InstanceAccessByClassIdRule extends RelRule { + public static final InstanceAccessByClassIdRule INSTANCE = + new InstanceAccessByClassIdRule( + DefaultRuleConfig.EMPTY + .withOperandSupplier( + b0 -> + b0.operand(InstanceByClassTableScan.class) + .anyInputs() + ) + ); + + public InstanceAccessByClassIdRule(RelRule.Config config) { + super(config); + } + + @Override + public void onMatch(RelOptRuleCall call) { + InstanceByClassTableScan scan = call.rel(0); + RelOptTable table = scan.getTable(); + RelOptSchema schema = table.getRelOptSchema(); + List indexName = new ArrayList<>(table.getQualifiedName()); + indexName.set(indexName.size() - 1, "$ids$:" + indexName.get(indexName.size() - 1)); + RelBuilder relBuilder = call.builder(); + relBuilder.push( + relBuilder.getScanFactory().createScan( + ViewExpanders.simpleContext(relBuilder.getCluster()), + schema.getTableForMember(indexName))); + + InstanceByClassTable instanceByClassTable = table.unwrap(InstanceByClassTable.class); + int snapshotId = SnapshotHolder.put(instanceByClassTable.snapshot); + + RexBuilderContext rexContext = new ExecutionRexBuilderContext( + scan.getCluster(), snapshotId, relBuilder.field(0)); + + List> resolvers = instanceByClassTable.getResolvers(); + List exprs = new ArrayList<>(resolvers.size()); + for (Function resolver : resolvers) { + exprs.add(resolver.apply(rexContext)); + } + call.transformTo( + relBuilder.projectNamed(exprs, table.getRowType().getFieldNames(), false) + .build()); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceByClassTable.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceByClassTable.java new file mode 100644 index 0000000..30fa13e --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceByClassTable.java @@ -0,0 +1,66 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import cchesser.javaperf.mcp.calcite.rex.RexBuilderContext; + +import com.google.common.collect.ImmutableList; +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.rex.RexNode; +import org.apache.calcite.schema.Statistic; +import org.apache.calcite.schema.Statistics; +import org.apache.calcite.schema.TranslatableTable; +import org.apache.calcite.schema.impl.AbstractTable; +import org.apache.calcite.util.ImmutableBitSet; +import org.apache.calcite.util.Pair; +import org.eclipse.mat.snapshot.ISnapshot; + +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.InstanceByClassTable}. + */ +public class InstanceByClassTable extends AbstractTable implements TranslatableTable { + public final ISnapshot snapshot; + public final IClassesList classesList; + private List> resolvers; + + public InstanceByClassTable(IClassesList classesList) { + this.classesList = classesList; + this.snapshot = classesList.snapshot; + } + + public List> getResolvers() { + return resolvers; + } + + @Override + public RelDataType getRowType(RelDataTypeFactory typeFactory) { + Pair>> typeAndResolvers; + try { + typeAndResolvers = ClassRowTypeCache.CACHE.get(typeFactory).get( + classesList); + } catch (ExecutionException e) { + throw new IllegalStateException( + "Unable to identify row type for class " + classesList); + } + this.resolvers = typeAndResolvers.right; + + return typeAndResolvers.left; + } + + @Override + public Statistic getStatistic() { + List uniqueKeys = ImmutableList.of(ImmutableBitSet.of(0)); + return Statistics.of(classesList.getTotalObjects(), uniqueKeys); + } + + @Override + public RelNode toRel(RelOptTable.ToRelContext context, RelOptTable table) { + return new InstanceByClassTableScan(context.getCluster(), table, this); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceByClassTableScan.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceByClassTableScan.java new file mode 100644 index 0000000..5d8b3aa --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceByClassTableScan.java @@ -0,0 +1,42 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import com.google.common.collect.ImmutableList; +import org.apache.calcite.plan.RelOptCluster; +import org.apache.calcite.plan.RelOptPlanner; +import org.apache.calcite.plan.RelOptTable; +import org.apache.calcite.plan.RelTraitSet; +import org.apache.calcite.rel.RelNode; +import org.apache.calcite.rel.core.TableScan; +import org.apache.calcite.rel.rules.CoreRules; + +import java.util.List; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.InstanceByClassTableScan}. + */ +public class InstanceByClassTableScan extends TableScan { + private final InstanceByClassTable instanceByClassTable; + + public InstanceByClassTableScan(RelOptCluster cluster, RelOptTable relOptTable, + InstanceByClassTable instanceByClassTable) { + super(cluster, cluster.traitSet(), ImmutableList.of(), relOptTable); + this.instanceByClassTable = instanceByClassTable; + } + + @Override + public void register(RelOptPlanner planner) { + planner.addRule(InstanceAccessByClassIdRule.INSTANCE); + planner.addRule(CoreRules.PROJECT_JOIN_TRANSPOSE); + // Does not yet work. + // These rules should convert join (a."@ID" = :var) to "snapshot.getObject(:var)" +// planner.addRule(NestedLoopsJoinRule.INSTANCE); +// planner.addRule(InstanceAccessByIdRule.INSTANCE); + } + + @Override + public RelNode copy(RelTraitSet traitSet, List inputs) { + assert inputs.isEmpty(); + return new InstanceByClassTableScan(getCluster(), table, instanceByClassTable); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceIdsByClassTable.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceIdsByClassTable.java new file mode 100644 index 0000000..2a33844 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/InstanceIdsByClassTable.java @@ -0,0 +1,79 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import com.google.common.collect.FluentIterable; +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Ints; +import org.apache.calcite.adapter.java.AbstractQueryableTable; +import org.apache.calcite.linq4j.Enumerator; +import org.apache.calcite.linq4j.Linq4j; +import org.apache.calcite.linq4j.QueryProvider; +import org.apache.calcite.linq4j.Queryable; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.schema.Schema; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.schema.Statistic; +import org.apache.calcite.schema.Statistics; +import org.apache.calcite.schema.impl.AbstractTableQueryable; +import org.apache.calcite.util.ImmutableBitSet; +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.ISnapshot; + +import java.util.Collections; +import java.util.List; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.InstanceIdsByClassTable}. + */ +public class InstanceIdsByClassTable extends AbstractQueryableTable { + private final ISnapshot snapshot; + private final IClassesList classesList; + + public InstanceIdsByClassTable(IClassesList classesList) { + super(Object[].class); + this.classesList = classesList; + this.snapshot = classesList.snapshot; + } + + @Override + public Schema.TableType getJdbcTableType() { + return Schema.TableType.SYSTEM_TABLE; + } + + @Override + public Queryable asQueryable(QueryProvider queryProvider, SchemaPlus schemaPlus, String tableName) { + return new AbstractTableQueryable(queryProvider, schemaPlus, this, tableName) { + @Override + public Enumerator enumerator() { + FluentIterable it = FluentIterable + .from(classesList.getClasses()) + .transformAndConcat( + input -> { + try { + return Ints.asList(input.getObjectIds()); + } catch (SnapshotException e) { + e.printStackTrace(); + return Collections.emptyList(); + } + }); + + return Linq4j.iterableEnumerator(it); + } + }; + } + + @Override + public Statistic getStatistic() { + List uniqueKeys = ImmutableList.of(ImmutableBitSet.of(0)); + return Statistics.of(classesList.getTotalObjects(), uniqueKeys); + } + + @Override + public RelDataType getRowType(RelDataTypeFactory typeFactory) { + return typeFactory.createStructType( + Collections.singletonList(typeFactory.createJavaType(int.class)), + Collections.singletonList("@ID") + ); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/SnapshotRexExpressions.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/SnapshotRexExpressions.java new file mode 100644 index 0000000..3348d20 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/SnapshotRexExpressions.java @@ -0,0 +1,39 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +import cchesser.javaperf.mcp.calcite.rex.RexBuilderContext; + +import org.apache.calcite.rex.RexNode; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.SnapshotRexExpressions}. + */ +public interface SnapshotRexExpressions { + static RexNode computeThis(RexBuilderContext context) { + return context.getBuilder() + .makeCall(HeapOperatorTable.TO_HEAP_REFERENCE, context.getIObject()); + } + + static RexNode resolveField(RexBuilderContext context, String fieldName) { + return context.getBuilder() + .makeCall(HeapOperatorTable.RESOLVE_VALUE, + context.getIObject(), + context.getBuilder().makeLiteral(fieldName)); + } + + static RexNode getClassOf(RexBuilderContext context, RexNode iobject) { + return context.getBuilder().makeCall(HeapOperatorTable.GET_CLASS_OF, context.getSnapshot(), iobject); + } + + static RexNode getSuper(RexBuilderContext context, RexNode iclass) { + return context.getBuilder().makeCall(HeapOperatorTable.GET_SUPER, iclass); + } + + static RexNode getClassLoader(RexBuilderContext context, RexNode iclass) { + return context.getBuilder().makeCall(HeapOperatorTable.GET_CLASS_LOADER, iclass); + } + + static RexNode getClassName(RexBuilderContext context, RexNode iclass) { + return context.getBuilder().makeCall(HeapOperatorTable.GET_CLASS_NAME, iclass); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/SpecialFields.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/SpecialFields.java new file mode 100644 index 0000000..c7cc5fa --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/objects/SpecialFields.java @@ -0,0 +1,12 @@ +package cchesser.javaperf.mcp.calcite.schema.objects; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.objects.SpecialFields}. + */ +public interface SpecialFields { + String CLASS = "@class"; + String SUPER = "@super"; + String CLASS_LOADER = "@classLoader"; + String CLASS_NAME = "@className"; +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/references/OutboundReferencesTable.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/references/OutboundReferencesTable.java new file mode 100644 index 0000000..fa53416 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/calcite/schema/references/OutboundReferencesTable.java @@ -0,0 +1,71 @@ +package cchesser.javaperf.mcp.calcite.schema.references; + +import cchesser.javaperf.mcp.calcite.HeapReference; + +import com.google.common.collect.ImmutableList; +import org.apache.calcite.adapter.java.AbstractQueryableTable; +import org.apache.calcite.linq4j.Enumerator; +import org.apache.calcite.linq4j.Linq4j; +import org.apache.calcite.linq4j.QueryProvider; +import org.apache.calcite.linq4j.Queryable; +import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; +import org.apache.calcite.schema.SchemaPlus; +import org.apache.calcite.schema.Statistic; +import org.apache.calcite.schema.Statistics; +import org.apache.calcite.schema.impl.AbstractTableQueryable; +import org.apache.calcite.sql.type.SqlTypeName; +import org.apache.calcite.util.ImmutableBitSet; +import org.apache.calcite.util.Util; +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.snapshot.model.NamedReference; + +import java.util.List; + +/** + * Sourced from MAT Calcite Plugin. + * Original class: {@code com.github.vlsi.mat.calcite.schema.references.OutboundReferencesTable}. + */ +public class OutboundReferencesTable extends AbstractQueryableTable { + private final static List NON_UNIQUE_KEYS_STATISTICS = ImmutableList.of(ImmutableBitSet.of()); + + private final List references; + + public OutboundReferencesTable(List references) { + super(Object[].class); + this.references = references; + } + + @Override + public Queryable asQueryable(QueryProvider queryProvider, SchemaPlus schemaPlus, String tableName) { + return new AbstractTableQueryable(queryProvider, schemaPlus, this, tableName) { + @Override + public Enumerator enumerator() { + List it = Util.transform(references, namedReference -> { + HeapReference ref = null; + try { + ref = HeapReference.valueOf(namedReference.getObject()); + } catch (SnapshotException e) { + e.printStackTrace(); + } + return new Object[]{namedReference.getName(), ref}; + }); + + return Linq4j.iterableEnumerator(it); + } + }; + } + + @Override + public Statistic getStatistic() { + return Statistics.of(references.size(), NON_UNIQUE_KEYS_STATISTICS); + } + + @Override + public RelDataType getRowType(RelDataTypeFactory typeFactory) { + return typeFactory.builder() + .add("name", typeFactory.createJavaType(String.class)) + .add("this", typeFactory.createSqlType(SqlTypeName.ANY)) + .build(); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/config/ServerConfig.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/config/ServerConfig.java new file mode 100644 index 0000000..c4e8347 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/config/ServerConfig.java @@ -0,0 +1,71 @@ +package cchesser.javaperf.mcp.config; + +import java.nio.file.Path; +import java.time.Duration; + +public record ServerConfig( + Path cacheRoot, + int defaultRowLimit, + int maxRowLimit, + Duration toolTimeout, + int gcRootPathLimit, + boolean webUiEnabled, + int webUiPort, + long webUiMaxUploadBytes +) { + private static final String CACHE_ENV = "JAVA_HEAP_MCP_CACHE_DIR"; + private static final String DEFAULT_LIMIT_ENV = "JAVA_HEAP_MCP_DEFAULT_LIMIT"; + private static final String MAX_LIMIT_ENV = "JAVA_HEAP_MCP_MAX_LIMIT"; + private static final String TOOL_TIMEOUT_SECONDS_ENV = "JAVA_HEAP_MCP_TOOL_TIMEOUT_SECONDS"; + private static final String GC_ROOT_PATH_LIMIT_ENV = "JAVA_HEAP_MCP_GC_ROOT_PATH_LIMIT"; + private static final String WEB_UI_ENABLED_ENV = "JAVA_HEAP_MCP_WEB_UI_ENABLED"; + private static final String WEB_UI_PORT_ENV = "JAVA_HEAP_MCP_WEB_UI_PORT"; + private static final String WEB_UI_MAX_UPLOAD_BYTES_ENV = "JAVA_HEAP_MCP_WEB_UI_MAX_UPLOAD_BYTES"; + + public static ServerConfig fromEnvironment() { + Path cacheRoot = Path.of(System.getenv().getOrDefault( + CACHE_ENV, + Path.of(System.getProperty("user.home"), ".cache", "java-heap-mcp").toString() + )); + + return new ServerConfig( + cacheRoot, + intEnv(DEFAULT_LIMIT_ENV, 50), + intEnv(MAX_LIMIT_ENV, 250), + Duration.ofSeconds(intEnv(TOOL_TIMEOUT_SECONDS_ENV, 120)), + intEnv(GC_ROOT_PATH_LIMIT_ENV, 10), + boolEnv(WEB_UI_ENABLED_ENV, false), + intEnv(WEB_UI_PORT_ENV, 7777), + longEnv(WEB_UI_MAX_UPLOAD_BYTES_ENV, 0L) + ); + } + + public int normalizeLimit(Integer requestedLimit) { + int limit = requestedLimit == null ? defaultRowLimit : requestedLimit; + return Math.max(1, Math.min(limit, maxRowLimit)); + } + + private static int intEnv(String env, int defaultValue) { + String value = System.getenv(env); + if (value == null || value.isBlank()) { + return defaultValue; + } + return Integer.parseInt(value); + } + + private static boolean boolEnv(String env, boolean defaultValue) { + String value = System.getenv(env); + if (value == null || value.isBlank()) { + return defaultValue; + } + return "1".equals(value) || "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value); + } + + private static long longEnv(String env, long defaultValue) { + String value = System.getenv(env); + if (value == null || value.isBlank()) { + return defaultValue; + } + return Long.parseLong(value); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/DominatorEntry.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/DominatorEntry.java new file mode 100644 index 0000000..e492bf6 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/DominatorEntry.java @@ -0,0 +1,10 @@ +package cchesser.javaperf.mcp.heap; + +public record DominatorEntry( + int objectId, + String className, + long shallowHeapBytes, + long retainedHeapBytes, + String displayName +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/GcRootPath.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/GcRootPath.java new file mode 100644 index 0000000..2a0fb0a --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/GcRootPath.java @@ -0,0 +1,9 @@ +package cchesser.javaperf.mcp.heap; + +import java.util.List; + +public record GcRootPath( + int terminalObjectId, + List path +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapAnalysisService.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapAnalysisService.java new file mode 100644 index 0000000..90036de --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapAnalysisService.java @@ -0,0 +1,25 @@ +package cchesser.javaperf.mcp.heap; + +import java.nio.file.Path; +import java.util.List; + +public interface HeapAnalysisService extends AutoCloseable { + LoadedHeap open(Path heapDumpPath); + + HeapOverview overview(LoadedHeap heap, int limit); + + OqlQueryResult runOql(LoadedHeap heap, String query, int limit, int offset); + + List histogram(LoadedHeap heap, String sortBy, int limit); + + List dominators(LoadedHeap heap, Integer rootObjectId, int limit); + + ObjectInspection inspectObject(LoadedHeap heap, int objectId, int limit); + + List findPathsToGcRoots(LoadedHeap heap, int objectId, boolean excludeWeakRefs, int limit); + + Object leakSuspects(LoadedHeap heap, int limit); + + @Override + void close(); +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapDumpManager.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapDumpManager.java new file mode 100644 index 0000000..7e61ffc --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapDumpManager.java @@ -0,0 +1,295 @@ +package cchesser.javaperf.mcp.heap; + +import cchesser.javaperf.mcp.cache.CacheMetadataStore; +import cchesser.javaperf.mcp.cache.CachedHeapDumpRecord; +import cchesser.javaperf.mcp.cache.HeapDumpFingerprint; +import cchesser.javaperf.mcp.config.ServerConfig; +import cchesser.javaperf.mcp.util.Timeouts; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; + +public final class HeapDumpManager implements AutoCloseable { + private static final String[] PROJECT_ADJECTIVES = { + "bold", "bright", "calm", "clever", "eager", "fuzzy", "gentle", "happy", + "keen", "lively", "mighty", "noble", "quick", "quiet", "sunny", "wise" + }; + private static final String[] PROJECT_NAMES = { + "ada", "curie", "darwin", "einstein", "hopper", "lovelace", "morse", "newton", + "noether", "pascal", "tesla", "turing", "watson", "wright" + }; + + private final ServerConfig config; + private final CacheMetadataStore metadataStore; + private final HeapAnalysisService heapAnalysisService; + private final Map sessions = new ConcurrentHashMap<>(); + + public HeapDumpManager(ServerConfig config, CacheMetadataStore metadataStore, HeapAnalysisService heapAnalysisService) { + this.config = config; + this.metadataStore = metadataStore; + this.heapAnalysisService = heapAnalysisService; + } + + public Map loadDump(String dumpPath) { + return loadDump(null, dumpPath); + } + + public Map loadDump(String projectName, String dumpPath) { + Path sourcePath = Path.of(dumpPath).toAbsolutePath().normalize(); + if (!Files.exists(sourcePath)) { + throw new HeapOperationException(HeapErrorCode.INVALID_PATH, "Heap dump path does not exist: " + sourcePath); + } + if (!Files.isReadable(sourcePath)) { + throw new HeapOperationException(HeapErrorCode.INVALID_PATH, "Heap dump path is not readable: " + sourcePath); + } + String normalizedProject = normalizeProjectName(projectName); + + return Timeouts.call("heap_load_dump", config.toolTimeout(), () -> { + closeActiveSessionsForProject(normalizedProject); + HeapDumpFingerprint fingerprint = HeapDumpFingerprint.from(sourcePath); + CachedHeapDumpRecord cacheRecord = prepareCacheEntry(normalizedProject, fingerprint); + LoadedHeap loadedHeap = heapAnalysisService.open(cacheDumpPath(cacheRecord)); + String handle = "heap-" + UUID.randomUUID(); + HeapSession session = new HeapSession(handle, metadataStore.touchLoaded(cacheRecord), loadedHeap, Instant.now()); + sessions.put(handle, session); + Map response = new LinkedHashMap<>(); + response.put("project", normalizedProject); + response.put("handle", handle); + response.put("fingerprint", cacheRecord.fingerprint()); + response.put("sourcePath", cacheRecord.sourcePath()); + response.put("loadedAt", session.loadedAt()); + response.put("summary", session.snapshotSummary()); + return response; + }); + } + + public Map openProject(String projectName) { + String normalizedProject = normalizeProjectName(projectName); + CachedHeapDumpRecord record = metadataStore.getByProject(normalizedProject) + .orElseThrow(() -> new HeapOperationException(HeapErrorCode.NOT_FOUND, "Unknown project: " + normalizedProject)); + + return Timeouts.call("heap_open_project", config.toolTimeout(), () -> { + closeActiveSessionsForProject(normalizedProject); + LoadedHeap loadedHeap = heapAnalysisService.open(cacheDumpPath(record)); + String handle = "heap-" + UUID.randomUUID(); + HeapSession session = new HeapSession(handle, metadataStore.touchLoaded(record), loadedHeap, Instant.now()); + sessions.put(handle, session); + Map response = new LinkedHashMap<>(); + response.put("project", normalizedProject); + response.put("handle", handle); + response.put("fingerprint", record.fingerprint()); + response.put("sourcePath", record.sourcePath()); + response.put("loadedAt", session.loadedAt()); + response.put("summary", session.snapshotSummary()); + return response; + }); + } + + public List> listDumps() { + List> result = new ArrayList<>(); + for (CachedHeapDumpRecord record : metadataStore.list().stream() + .sorted(Comparator + .comparing(CachedHeapDumpRecord::projectName) + .thenComparing(CachedHeapDumpRecord::lastLoadedAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .toList()) { + Optional activeHandle = sessions.values().stream() + .filter(session -> session.cacheRecord().projectName().equals(record.projectName())) + .map(HeapSession::handle) + .findFirst(); + Map item = new LinkedHashMap<>(); + item.put("project", record.projectName()); + item.put("fingerprint", record.fingerprint()); + item.put("sourcePath", record.sourcePath()); + item.put("fileSizeBytes", record.fileSizeBytes()); + item.put("cachedDumpSizeBytes", record.cachedDumpSizeBytes()); + item.put("lastModifiedTime", record.lastModifiedTime()); + item.put("createdAt", record.createdAt()); + item.put("lastLoadedAt", record.lastLoadedAt()); + item.put("activeHandle", activeHandle.orElse(null)); + item.put("cachePath", cacheDumpPath(record).toString()); + result.add(item); + } + return result; + } + + public List> listProjects() { + return metadataStore.list().stream() + .sorted(Comparator.comparing(CachedHeapDumpRecord::projectName)) + .map(record -> { + Optional activeHandle = sessions.values().stream() + .filter(session -> session.cacheRecord().projectName().equals(record.projectName())) + .map(HeapSession::handle) + .findFirst(); + Map item = new LinkedHashMap<>(); + item.put("project", record.projectName()); + item.put("fingerprint", record.fingerprint()); + item.put("sourcePath", record.sourcePath()); + item.put("lastLoadedAt", record.lastLoadedAt()); + item.put("activeHandle", activeHandle.orElse(null)); + return item; + }) + .collect(Collectors.toList()); + } + + public Map unloadDump(String handle) { + HeapSession removed = sessions.remove(handle); + if (removed == null) { + throw new HeapOperationException(HeapErrorCode.NOT_FOUND, "Unknown heap handle: " + handle); + } + removed.loadedHeap().dispose(); + return Map.of( + "project", removed.cacheRecord().projectName(), + "handle", handle, + "unloaded", true, + "cachePreserved", true, + "fingerprint", removed.cacheRecord().fingerprint() + ); + } + + public HeapOverview overview(String handle, Integer limit) { + HeapSession session = requireSession(handle); + return Timeouts.call("heap_get_overview", config.toolTimeout(), + () -> heapAnalysisService.overview(session.loadedHeap(), config.normalizeLimit(limit))); + } + + public OqlQueryResult runOql(String handle, String query, Integer limit, Integer offset) { + HeapSession session = requireSession(handle); + return Timeouts.call("heap_run_oql", config.toolTimeout(), + () -> heapAnalysisService.runOql(session.loadedHeap(), query, config.normalizeLimit(limit), Math.max(0, offset == null ? 0 : offset))); + } + + public List histogram(String handle, String sortBy, Integer limit) { + HeapSession session = requireSession(handle); + return Timeouts.call("heap_get_histogram", config.toolTimeout(), + () -> heapAnalysisService.histogram(session.loadedHeap(), sortBy, config.normalizeLimit(limit))); + } + + public List dominators(String handle, Integer rootObjectId, Integer limit) { + HeapSession session = requireSession(handle); + return Timeouts.call("heap_get_dominators", config.toolTimeout(), + () -> heapAnalysisService.dominators(session.loadedHeap(), rootObjectId, config.normalizeLimit(limit))); + } + + public ObjectInspection inspectObject(String handle, int objectId, Integer limit) { + HeapSession session = requireSession(handle); + return Timeouts.call("heap_inspect_object", config.toolTimeout(), + () -> heapAnalysisService.inspectObject(session.loadedHeap(), objectId, config.normalizeLimit(limit))); + } + + public List findPathsToGcRoots(String handle, int objectId, boolean excludeWeakRefs, Integer limit) { + HeapSession session = requireSession(handle); + int effectiveLimit = Math.min(config.gcRootPathLimit(), config.normalizeLimit(limit)); + return Timeouts.call("heap_find_path_to_gc_roots", config.toolTimeout(), + () -> heapAnalysisService.findPathsToGcRoots(session.loadedHeap(), objectId, excludeWeakRefs, effectiveLimit)); + } + + public Object findLeakSuspects(String handle, Integer limit) { + HeapSession session = requireSession(handle); + return Timeouts.call("heap_find_leak_suspects", config.toolTimeout(), + () -> heapAnalysisService.leakSuspects(session.loadedHeap(), config.normalizeLimit(limit))); + } + + private CachedHeapDumpRecord prepareCacheEntry(String projectName, HeapDumpFingerprint fingerprint) throws IOException { + Path entryDir = config.cacheRoot().resolve("entries").resolve(fingerprint.fingerprint()); + Files.createDirectories(entryDir); + Path cachedDumpPath = entryDir.resolve(fingerprint.sourceFileName()); + + if (!Files.exists(cachedDumpPath)) { + try { + Files.createLink(cachedDumpPath, fingerprint.sourcePath()); + } catch (UnsupportedOperationException | IOException linkFailure) { + Files.copy(fingerprint.sourcePath(), cachedDumpPath, StandardCopyOption.REPLACE_EXISTING); + } + } + + String fullFileHash = HeapDumpFingerprint.sha256File(fingerprint.sourcePath()); + CachedHeapDumpRecord record = new CachedHeapDumpRecord( + projectName, + fingerprint.fingerprint(), + fingerprint.sourcePath().toString(), + cachedDumpPath.getFileName().toString(), + fingerprint.fileSizeBytes(), + fingerprint.lastModifiedTime(), + Instant.now(), + null, + fullFileHash, + Files.size(cachedDumpPath) + ); + return metadataStore.getByProject(projectName).map(existing -> new CachedHeapDumpRecord( + projectName, + fingerprint.fingerprint(), + fingerprint.sourcePath().toString(), + cachedDumpPath.getFileName().toString(), + fingerprint.fileSizeBytes(), + fingerprint.lastModifiedTime(), + existing.createdAt(), + existing.lastLoadedAt(), + fullFileHash, + fingerprint.fileSizeBytes() + )).map(metadataStore::upsert).orElseGet(() -> metadataStore.upsert(record)); + } + + private Path cacheDumpPath(CachedHeapDumpRecord record) { + return config.cacheRoot().resolve("entries").resolve(record.fingerprint()).resolve(record.cacheFileName()); + } + + private HeapSession requireSession(String handle) { + HeapSession session = sessions.get(handle); + if (session == null) { + throw new HeapOperationException(HeapErrorCode.NOT_FOUND, "Unknown heap handle: " + handle); + } + return session; + } + + private void closeActiveSessionsForProject(String projectName) { + List handles = sessions.values().stream() + .filter(session -> session.cacheRecord().projectName().equals(projectName)) + .map(HeapSession::handle) + .toList(); + for (String handle : handles) { + HeapSession removed = sessions.remove(handle); + if (removed != null) { + removed.loadedHeap().dispose(); + } + } + } + + private String normalizeProjectName(String projectName) { + if (projectName == null || projectName.isBlank()) { + return memorableProjectName(); + } + return projectName.trim(); + } + + private String memorableProjectName() { + for (int attempt = 0; attempt < 100; attempt++) { + String candidate = PROJECT_ADJECTIVES[ThreadLocalRandom.current().nextInt(PROJECT_ADJECTIVES.length)] + + "_" + + PROJECT_NAMES[ThreadLocalRandom.current().nextInt(PROJECT_NAMES.length)]; + if (metadataStore.getByProject(candidate).isEmpty()) { + return candidate; + } + } + return "heap_" + UUID.randomUUID().toString().substring(0, 8); + } + + @Override + public void close() { + sessions.values().forEach(session -> session.loadedHeap().dispose()); + sessions.clear(); + heapAnalysisService.close(); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapErrorCode.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapErrorCode.java new file mode 100644 index 0000000..8d2c75f --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapErrorCode.java @@ -0,0 +1,11 @@ +package cchesser.javaperf.mcp.heap; + +public enum HeapErrorCode { + INVALID_PATH, + UNSUPPORTED_FORMAT, + CORRUPT_INDEX, + OQL_ERROR, + TIMEOUT, + NOT_FOUND, + INTERNAL +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapOperationException.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapOperationException.java new file mode 100644 index 0000000..1a376b8 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapOperationException.java @@ -0,0 +1,37 @@ +package cchesser.javaperf.mcp.heap; + +import java.time.Duration; + +public final class HeapOperationException extends RuntimeException { + private final HeapErrorCode errorCode; + + public HeapOperationException(HeapErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public HeapOperationException(HeapErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public HeapErrorCode errorCode() { + return errorCode; + } + + public static HeapOperationException timeout(String operationName, Duration timeout, Throwable cause) { + return new HeapOperationException( + HeapErrorCode.TIMEOUT, + "Operation '%s' timed out after %d seconds".formatted(operationName, timeout.toSeconds()), + cause + ); + } + + public static HeapOperationException interrupted(String operationName, Throwable cause) { + return new HeapOperationException( + HeapErrorCode.INTERNAL, + "Operation '%s' was interrupted".formatted(operationName), + cause + ); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapOverview.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapOverview.java new file mode 100644 index 0000000..1a77e2a --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapOverview.java @@ -0,0 +1,11 @@ +package cchesser.javaperf.mcp.heap; + +import java.util.List; +import java.util.Map; + +public record HeapOverview( + Map snapshot, + List topClasses, + List topDominators +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapSession.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapSession.java new file mode 100644 index 0000000..5c1cd1e --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HeapSession.java @@ -0,0 +1,17 @@ +package cchesser.javaperf.mcp.heap; + +import cchesser.javaperf.mcp.cache.CachedHeapDumpRecord; + +import java.time.Instant; +import java.util.Map; + +public record HeapSession( + String handle, + CachedHeapDumpRecord cacheRecord, + LoadedHeap loadedHeap, + Instant loadedAt +) { + public Map snapshotSummary() { + return loadedHeap.snapshotSummary(); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HistogramEntry.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HistogramEntry.java new file mode 100644 index 0000000..f980907 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/HistogramEntry.java @@ -0,0 +1,10 @@ +package cchesser.javaperf.mcp.heap; + +public record HistogramEntry( + int classId, + String className, + long objectCount, + long shallowHeapBytes, + Long retainedHeapBytes +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/LoadedHeap.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/LoadedHeap.java new file mode 100644 index 0000000..8ad992e --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/LoadedHeap.java @@ -0,0 +1,14 @@ +package cchesser.javaperf.mcp.heap; + +import java.nio.file.Path; +import java.util.Map; + +public interface LoadedHeap { + Path heapDumpPath(); + + Map snapshotSummary(); + + Object nativeSnapshot(); + + void dispose(); +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatHeapService.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatHeapService.java new file mode 100644 index 0000000..0f396d0 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatHeapService.java @@ -0,0 +1,397 @@ +package cchesser.javaperf.mcp.heap; + +import cchesser.javaperf.mcp.calcite.CalciteDataSource; +import cchesser.javaperf.mcp.calcite.RowSetTable; +import cchesser.javaperf.mcp.config.ServerConfig; +import org.eclipse.mat.SnapshotException; +import org.eclipse.mat.inspections.LeakHunterQuery; +import org.eclipse.mat.query.IResultTable; +import org.eclipse.mat.snapshot.ClassHistogramRecord; +import org.eclipse.mat.snapshot.Histogram; +import org.eclipse.mat.snapshot.IOQLQuery; +import org.eclipse.mat.snapshot.ISnapshot; +import org.eclipse.mat.snapshot.SnapshotFactory; +import org.eclipse.mat.snapshot.model.Field; +import org.eclipse.mat.snapshot.model.FieldDescriptor; +import org.eclipse.mat.snapshot.model.GCRootInfo; +import org.eclipse.mat.snapshot.model.IClass; +import org.eclipse.mat.snapshot.model.IInstance; +import org.eclipse.mat.snapshot.model.IObject; +import org.eclipse.mat.snapshot.model.NamedReference; +import org.eclipse.mat.util.IProgressListener; +import org.eclipse.mat.util.VoidProgressListener; + +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +import javax.sql.rowset.CachedRowSet; +import javax.sql.rowset.RowSetFactory; +import javax.sql.rowset.RowSetProvider; + +public final class MatHeapService implements HeapAnalysisService { + private static final Logger LOGGER = Logger.getLogger(MatHeapService.class.getName()); + @SuppressWarnings("unused") + private final ServerConfig config; + + public MatHeapService(ServerConfig config) { + this.config = config; + MatRuntime.initialize(); + } + + @Override + public LoadedHeap open(Path heapDumpPath) { + try { + ISnapshot snapshot = SnapshotFactory.openSnapshot(heapDumpPath.toFile(), progress()); + return new MatLoadedHeap(heapDumpPath, snapshot, snapshotSummary(snapshot)); + } catch (SnapshotException exception) { + throw classifySnapshotException("Failed to open heap dump " + heapDumpPath, exception); + } + } + + @Override + public HeapOverview overview(LoadedHeap heap, int limit) { + ISnapshot snapshot = snapshot(heap); + Map summary = snapshotSummary(snapshot); + List topClasses = histogram(heap, "retained", limit); + List topDominators = dominators(heap, null, limit); + return new HeapOverview(summary, topClasses, topDominators); + } + + @Override + public OqlQueryResult runOql(LoadedHeap heap, String query, int limit, int offset) { + ISnapshot snapshot = snapshot(heap); + LOGGER.info(() -> "Executing OQL query (limit=" + limit + ", offset=" + offset + "): " + diagnosticQuery(query)); + try { + // Calcite is the primary query engine. It provides SQL joins, grouping, + // ordering, lateral table functions, and the MAT-aware functions exposed + // by the embedded mat-calcite-plugin implementation. + try { + return tableResult(query, executeCalcite(snapshot, query), offset, limit); + } catch (SQLException calciteException) { + // Keep MAT OQL available for existing callers using MAT-only syntax. + LOGGER.fine(() -> "Query was not accepted by Calcite; trying MAT OQL: " + calciteException.getMessage()); + } + + IOQLQuery compiledQuery = SnapshotFactory.createQuery(query); + Object raw = compiledQuery.execute(snapshot, progress()); + if (raw instanceof int[] objectIds) { + List> rows = new ArrayList<>(); + int end = Math.min(objectIds.length, offset + limit); + for (int index = offset; index < end; index++) { + int objectId = objectIds[index]; + IObject object = snapshot.getObject(objectId); + rows.add(Map.of( + "objectId", objectId, + "address", snapshot.mapIdToAddress(objectId), + "className", object.getClazz().getName(), + "displayName", safeDisplayName(object), + "shallowHeapBytes", snapshot.getHeapSize(objectId), + "retainedHeapBytes", snapshot.getRetainedHeapSize(objectId) + )); + } + return new OqlQueryResult(query, List.of("objectId", "address", "className", "displayName", "shallowHeapBytes", "retainedHeapBytes"), + rows, offset, limit, end < objectIds.length, objectIds.length); + } + if (raw instanceof IResultTable table) { + return tableResult(query, table, offset, limit); + } + LOGGER.info(() -> "OQL scalar result type: " + (raw == null ? "null" : raw.getClass().getName())); + Map scalarRow = new LinkedHashMap<>(); + scalarRow.put("value", raw); + return new OqlQueryResult(query, List.of("value"), List.of(scalarRow), offset, limit, false, 1); + } catch (SnapshotException exception) { + LOGGER.log(Level.WARNING, "OQL query failed: " + diagnosticQuery(query), exception); + throw new HeapOperationException(HeapErrorCode.OQL_ERROR, "Failed to execute OQL query: " + diagnosticQuery(query), exception); + } catch (RuntimeException exception) { + LOGGER.log(Level.WARNING, "OQL query failed with runtime error: " + diagnosticQuery(query), exception); + throw exception; + } + } + + private IResultTable executeCalcite(ISnapshot snapshot, String query) throws SQLException { + Connection connection = null; + Statement statement = null; + ResultSet resultSet = null; + try { + connection = CalciteDataSource.getConnection(snapshot); + statement = connection.createStatement(); + resultSet = statement.executeQuery(query); + RowSetFactory rowSetFactory = RowSetProvider.newFactory(); + CachedRowSet rowSet = rowSetFactory.createCachedRowSet(); + rowSet.populate(resultSet); + return new RowSetTable(rowSet); + } finally { + CalciteDataSource.close(resultSet, statement, connection); + } + } + + private static String diagnosticQuery(String query) { + if (query == null) { + return ""; + } + return query.replace("\r", "\\r").replace("\n", "\\n"); + } + + @Override + public List histogram(LoadedHeap heap, String sortBy, int limit) { + ISnapshot snapshot = snapshot(heap); + try { + Histogram histogram = snapshot.getHistogram(progress()); + Comparator comparator = switch (sortBy == null ? "retained" : sortBy) { + case "count" -> Comparator.comparingLong(HistogramEntry::objectCount).reversed(); + case "shallow" -> Comparator.comparingLong(HistogramEntry::shallowHeapBytes).reversed(); + default -> Comparator.comparingLong( + entry -> entry.retainedHeapBytes() == null ? Long.MIN_VALUE : entry.retainedHeapBytes() + ).reversed(); + }; + return histogram.getClassHistogramRecords().stream() + .map(record -> toHistogramEntry(snapshot, record)) + .sorted(comparator) + .limit(limit) + .toList(); + } catch (SnapshotException exception) { + throw classifySnapshotException("Failed to compute histogram", exception); + } + } + + @Override + public List dominators(LoadedHeap heap, Integer rootObjectId, int limit) { + ISnapshot snapshot = snapshot(heap); + try { + int[] roots = rootObjectId == null ? snapshot.getImmediateDominatedIds(-1) : snapshot.getImmediateDominatedIds(rootObjectId); + if (roots == null) { + return List.of(); + } + return Arrays.stream(roots) + .mapToObj(objectId -> toDominatorEntry(snapshot, objectId)) + .sorted(Comparator.comparingLong(DominatorEntry::retainedHeapBytes).reversed()) + .limit(limit) + .toList(); + } catch (SnapshotException exception) { + throw classifySnapshotException("Failed to compute dominators", exception); + } + } + + @Override + public ObjectInspection inspectObject(LoadedHeap heap, int objectId, int limit) { + ISnapshot snapshot = snapshot(heap); + try { + IObject object = snapshot.getObject(objectId); + List fields = extractFields(object); + List inbound = Arrays.stream(snapshot.getInboundRefererIds(objectId)) + .limit(limit) + .mapToObj(id -> toReference(snapshot, id)) + .toList(); + List outbound = Arrays.stream(snapshot.getOutboundReferentIds(objectId)) + .limit(limit) + .mapToObj(id -> toReference(snapshot, id)) + .toList(); + GCRootInfo[] gcRootInfos = snapshot.getGCRootInfo(objectId); + return new ObjectInspection( + objectId, + snapshot.mapIdToAddress(objectId), + object.getClazz().getName(), + safeDisplayName(object), + snapshot.getHeapSize(objectId), + snapshot.getRetainedHeapSize(objectId), + snapshot.getImmediateDominatorId(objectId), + gcRootInfos == null ? List.of() : List.of(GCRootInfo.getTypeSetAsString(gcRootInfos)), + fields, + inbound, + outbound + ); + } catch (SnapshotException exception) { + throw classifySnapshotException("Failed to inspect object " + objectId, exception); + } + } + + @Override + public List findPathsToGcRoots(LoadedHeap heap, int objectId, boolean excludeWeakRefs, int limit) { + ISnapshot snapshot = snapshot(heap); + try { + Map> exclusions = excludeWeakRefs ? Map.of() : null; + var computer = snapshot.getPathsFromGCRoots(objectId, exclusions); + List paths = new ArrayList<>(); + while (paths.size() < limit) { + int[] rawPath = computer.getNextShortestPath(); + if (rawPath == null) { + break; + } + List references = Arrays.stream(rawPath) + .mapToObj(id -> toReference(snapshot, id)) + .toList(); + paths.add(new GcRootPath(objectId, references)); + } + return paths; + } catch (SnapshotException exception) { + throw classifySnapshotException("Failed to find GC root paths for object " + objectId, exception); + } + } + + @Override + public Object leakSuspects(LoadedHeap heap, int limit) { + ISnapshot snapshot = snapshot(heap); + try { + LeakHunterQuery query = new LeakHunterQuery(); + query.snapshot = snapshot; + Object result = query.execute(progress()); + if (result instanceof IResultTable table) { + return tableResult("leak_suspects", table, 0, limit); + } + return Map.of("summary", String.valueOf(result)); + } catch (Exception exception) { + return Map.of( + "available", false, + "message", "Leak suspect analysis is not available for this heap dump or MAT runtime.", + "details", exception.getMessage() + ); + } + } + + private OqlQueryResult tableResult(String query, IResultTable table, int offset, int limit) { + List columns = Arrays.stream(table.getColumns()).map(column -> column.getLabel()).toList(); + int totalRows = table.getRowCount(); + int end = Math.min(totalRows, offset + limit); + List> rows = new ArrayList<>(); + for (int rowIndex = offset; rowIndex < end; rowIndex++) { + Object row = table.getRow(rowIndex); + Map values = new LinkedHashMap<>(); + for (int columnIndex = 0; columnIndex < columns.size(); columnIndex++) { + values.put(columns.get(columnIndex), table.getColumnValue(row, columnIndex)); + } + rows.add(values); + } + return new OqlQueryResult(query, columns, rows, offset, limit, end < totalRows, totalRows); + } + + private HistogramEntry toHistogramEntry(ISnapshot snapshot, ClassHistogramRecord record) { + try { + long retained = record.getRetainedHeapSize(); + if (retained == 0L) { + retained = record.calculateRetainedSize(snapshot, true, false, progress()); + } + return new HistogramEntry( + record.getClassId(), + record.getLabel(), + record.getNumberOfObjects(), + record.getUsedHeapSize(), + retained == 0L ? null : Math.abs(retained) + ); + } catch (SnapshotException exception) { + return new HistogramEntry(record.getClassId(), record.getLabel(), record.getNumberOfObjects(), record.getUsedHeapSize(), null); + } + } + + private DominatorEntry toDominatorEntry(ISnapshot snapshot, int objectId) { + try { + IObject object = snapshot.getObject(objectId); + return new DominatorEntry( + objectId, + object.getClazz().getName(), + snapshot.getHeapSize(objectId), + snapshot.getRetainedHeapSize(objectId), + safeDisplayName(object) + ); + } catch (SnapshotException exception) { + throw classifySnapshotException("Failed to convert dominator " + objectId, exception); + } + } + + private ObjectReference toReference(ISnapshot snapshot, int objectId) { + try { + IObject object = snapshot.getObject(objectId); + return new ObjectReference(objectId, object.getClazz().getName(), snapshot.mapIdToAddress(objectId), safeDisplayName(object)); + } catch (SnapshotException exception) { + throw classifySnapshotException("Failed to resolve object reference " + objectId, exception); + } + } + + private List extractFields(IObject object) { + if (!(object instanceof IInstance instance)) { + List pseudoFields = new ArrayList<>(); + for (NamedReference reference : object.getOutboundReferences()) { + Integer referencedObjectId; + try { + referencedObjectId = reference.getObjectId(); + } catch (SnapshotException exception) { + referencedObjectId = null; + } + pseudoFields.add(new ObjectFieldValue(reference.getName(), "reference", reference.getObjectAddress(), referencedObjectId)); + } + return pseudoFields; + } + + List values = new ArrayList<>(); + for (Field field : instance.getFields()) { + Object value = field.getValue(); + values.add(new ObjectFieldValue(field.getName(), field.getVerboseSignature(), value, null)); + } + Collection descriptors = instance.getClazz().getFieldDescriptors(); + for (FieldDescriptor descriptor : descriptors) { + boolean alreadyPresent = values.stream().anyMatch(value -> value.name().equals(descriptor.getName())); + if (!alreadyPresent) { + values.add(new ObjectFieldValue(descriptor.getName(), descriptor.getVerboseSignature(), null, null)); + } + } + return values; + } + + private Map snapshotSummary(ISnapshot snapshot) { + var info = snapshot.getSnapshotInfo(); + Map summary = new LinkedHashMap<>(); + summary.put("path", info.getPath()); + summary.put("prefix", info.getPrefix()); + summary.put("jvmInfo", info.getJvmInfo()); + summary.put("identifierSize", info.getIdentifierSize()); + summary.put("creationDate", info.getCreationDate()); + summary.put("numberOfObjects", info.getNumberOfObjects()); + summary.put("numberOfClasses", info.getNumberOfClasses()); + summary.put("numberOfClassLoaders", info.getNumberOfClassLoaders()); + summary.put("usedHeapSize", info.getUsedHeapSize()); + return summary; + } + + private String safeDisplayName(IObject object) { + try { + return object.getDisplayName(); + } catch (RuntimeException ignored) { + return object.getTechnicalName(); + } + } + + private ISnapshot snapshot(LoadedHeap heap) { + return (ISnapshot) heap.nativeSnapshot(); + } + + private IProgressListener progress() { + return new VoidProgressListener(); + } + + private HeapOperationException classifySnapshotException(String message, SnapshotException exception) { + String lowerMessage = exception.getMessage() == null ? "" : exception.getMessage().toLowerCase(); + if (lowerMessage.contains("hprof") || lowerMessage.contains("format")) { + return new HeapOperationException(HeapErrorCode.UNSUPPORTED_FORMAT, message, exception); + } + if (lowerMessage.contains("index")) { + return new HeapOperationException(HeapErrorCode.CORRUPT_INDEX, message, exception); + } + return new HeapOperationException(HeapErrorCode.INTERNAL, message, exception); + } + + @Override + public void close() { + // No global resources to close beyond per-snapshot disposal. + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatLoadedHeap.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatLoadedHeap.java new file mode 100644 index 0000000..20cd278 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatLoadedHeap.java @@ -0,0 +1,26 @@ +package cchesser.javaperf.mcp.heap; + +import org.eclipse.mat.snapshot.ISnapshot; + +import java.nio.file.Path; +import java.util.Map; + +public record MatLoadedHeap( + Path heapDumpPath, + ISnapshot snapshot, + Map snapshotSummary +) implements LoadedHeap { + @Override + public Object nativeSnapshot() { + return snapshot; + } + + @Override + public void dispose() { + try { + org.eclipse.mat.snapshot.SnapshotFactory.dispose(snapshot); + } catch (RuntimeException ignored) { + // Best-effort disposal during shutdown or handle close. + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatRuntime.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatRuntime.java new file mode 100644 index 0000000..9e07709 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/MatRuntime.java @@ -0,0 +1,285 @@ +package cchesser.javaperf.mcp.heap; + +import org.eclipse.core.internal.registry.ExtensionRegistry; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IExtensionRegistry; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.RegistryFactory; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.dynamichelpers.ExtensionTracker; +import org.eclipse.core.runtime.dynamichelpers.IExtensionTracker; +import org.eclipse.core.runtime.spi.RegistryContributor; +import org.eclipse.core.runtime.spi.RegistryStrategy; +import org.eclipse.mat.internal.MATPlugin; +import org.eclipse.mat.parser.internal.ParserPlugin; +import org.eclipse.mat.report.internal.ReportPlugin; +import org.eclipse.mat.hprof.HprofPlugin; +import org.eclipse.core.runtime.preferences.IPreferencesService; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.eclipse.core.runtime.content.IContentType; +import org.eclipse.core.runtime.content.IContentTypeManager; +import org.osgi.util.tracker.ServiceTracker; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.jar.JarFile; + +/** Initializes the MAT OSGi services needed when MAT is used from a plain JVM. */ +final class MatRuntime { + private static final Object LOCK = new Object(); + private static volatile boolean initialized; + + private MatRuntime() { + } + + static void initialize() { + if (initialized) { + return; + } + synchronized (LOCK) { + if (initialized) { + return; + } + try { + IExtensionRegistry registry = RegistryFactory.createRegistry(new ClassLoadingRegistryStrategy(), null, null); + RegistryFactory.setDefaultRegistryProvider(() -> registry); + addMatContributions((ExtensionRegistry) registry); + + installContentTypeManager(simpleContentTypes()); + activateHprofPlugin(); + + IExtensionTracker tracker = new ExtensionTracker(registry); + MATPlugin matPlugin = new MATPlugin(); + setBundle(matPlugin, bundle("org.eclipse.mat.api")); + activate(MATPlugin.class, matPlugin, tracker); + ReportPlugin reportPlugin = new ReportPlugin(); + setBundle(reportPlugin, bundle("org.eclipse.mat.report")); + activate(ReportPlugin.class, reportPlugin, tracker); + ParserPlugin parserPlugin = new ParserPlugin(); + setStaticField(ParserPlugin.class, "plugin", parserPlugin); + setField(parserPlugin, "tracker", tracker); + setField(parserPlugin, "registry", new org.eclipse.mat.parser.internal.util.ParserRegistry(tracker)); + initialized = true; + } catch (ReflectiveOperationException | IOException | CoreException exception) { + throw new IllegalStateException("Unable to initialize the MAT runtime", exception); + } + } + } + + private static void addMatContributions(ExtensionRegistry registry) throws IOException { + List bundles = List.of( + "org.eclipse.mat.api.jar", + "org.eclipse.mat.report.jar", + "org.eclipse.mat.parser.jar", + "org.eclipse.mat.hprof.jar" + ); + Path matDirectory = findMatDirectory(); + for (String bundle : bundles) { + Path path = findBundle(matDirectory, bundle); + try (JarFile jar = new JarFile(path.toFile()); InputStream pluginXml = jar.getInputStream(jar.getJarEntry("plugin.xml"))) { + String contributor = bundle.substring(0, bundle.length() - ".jar".length()); + RegistryContributor registryContributor = new RegistryContributor(contributor, contributor, null, null); + registry.addContribution(pluginXml, registryContributor, true, contributor, null, null); + } + } + } + + private static Path findMatDirectory() throws IOException { + List candidates = new ArrayList<>(); + String configuredDirectory = System.getProperty("java.heap.mcp.mat.directory"); + if (configuredDirectory != null && !configuredDirectory.isBlank()) { + candidates.add(Path.of(configuredDirectory)); + } + candidates.add(Path.of("target", "mat")); + candidates.add(Path.of("vendor", "mat")); + + try { + Path applicationDirectory = Path.of(MatRuntime.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).getParent(); + candidates.add(applicationDirectory.resolve("mat")); + candidates.add(applicationDirectory.resolveSibling("vendor").resolve("mat")); + } catch (Exception ignored) { + // The working-directory candidates still cover normal Maven/script launches. + } + + for (Path candidate : candidates) { + if (Files.isDirectory(candidate) && hasMatBundle(candidate)) { + return candidate; + } + } + throw new IOException("Could not find MAT bundles. Checked: " + candidates); + } + + private static boolean hasMatBundle(Path directory) { + return Files.exists(directory.resolve("org.eclipse.mat.api.jar")) + || hasVersionedBundle(directory, "org.eclipse.mat.api.jar"); + } + + private static Path findBundle(Path directory, String bundleName) throws IOException { + Path exactPath = directory.resolve(bundleName); + if (Files.isRegularFile(exactPath)) { + return exactPath; + } + try (var paths = Files.list(directory)) { + return paths.filter(path -> path.getFileName().toString().startsWith(bundleName.substring(0, bundleName.length() - 4) + "_") + && path.getFileName().toString().endsWith(".jar")) + .findFirst() + .orElseThrow(() -> new IOException("Could not find MAT bundle " + bundleName + " in " + directory)); + } + } + + private static boolean hasVersionedBundle(Path directory, String bundleName) { + try (var paths = Files.list(directory)) { + String prefix = bundleName.substring(0, bundleName.length() - 4) + "_"; + return paths.anyMatch(path -> path.getFileName().toString().startsWith(prefix) + && path.getFileName().toString().endsWith(".jar")); + } catch (IOException exception) { + return false; + } + } + + private static void activate(Class pluginType, Object plugin, IExtensionTracker tracker) + throws ReflectiveOperationException { + setStaticField(pluginType, "plugin", plugin); + setField(plugin, "tracker", tracker); + } + + private static void installContentTypeManager(Object manager) throws ReflectiveOperationException { + Class platformType = Class.forName("org.eclipse.core.internal.runtime.InternalPlatform"); + Object platform = platformType.getMethod("getDefault").invoke(null); + Field trackerField = platformType.getDeclaredField("contentTracker"); + trackerField.setAccessible(true); + trackerField.set(platform, new ContentTypeServiceTracker(manager)); + Field preferenceTracker = platformType.getDeclaredField("preferencesTracker"); + preferenceTracker.setAccessible(true); + IPreferencesService preferences = (IPreferencesService) Proxy.newProxyInstance( + MatRuntime.class.getClassLoader(), new Class[]{IPreferencesService.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getString" -> ""; + case "getBoolean" -> false; + default -> null; + }); + preferenceTracker.set(platform, new ContentTypeServiceTracker(preferences)); + } + + private static void activateHprofPlugin() throws ReflectiveOperationException { + Class platformActivator = Class.forName("org.eclipse.core.internal.runtime.PlatformActivator"); + Field context = platformActivator.getDeclaredField("context"); + context.setAccessible(true); + context.set(null, ContentTypeServiceTracker.nullContext()); + HprofPlugin plugin = new HprofPlugin(); + setBundle(plugin, bundle("org.eclipse.mat.hprof")); + setStaticField(HprofPlugin.class, "plugin", plugin); + } + + private static Bundle bundle(String symbolicName) { + return (Bundle) Proxy.newProxyInstance( + MatRuntime.class.getClassLoader(), new Class[]{Bundle.class}, + (proxy, method, args) -> { + if ("getSymbolicName".equals(method.getName())) { + return symbolicName; + } + if ("getEntry".equals(method.getName()) && args != null && args.length == 1 && args[0] != null) { + String resource = args[0].toString().replaceFirst("^\\$nl\\$/?", "").replaceFirst("^/", ""); + return MatRuntime.class.getClassLoader().getResource(resource); + } + return null; + }); + } + + private static void setBundle(Object plugin, Bundle bundle) throws ReflectiveOperationException { + Field bundleField = org.eclipse.core.runtime.Plugin.class.getDeclaredField("bundle"); + bundleField.setAccessible(true); + bundleField.set(plugin, bundle); + } + + private static IContentTypeManager simpleContentTypes() { + IContentType base = contentType("org.eclipse.mat.JavaHeapDump", null); + IContentType hprof = contentType("org.eclipse.mat.HprofHeapDump", base); + return (IContentTypeManager) Proxy.newProxyInstance( + MatRuntime.class.getClassLoader(), new Class[]{IContentTypeManager.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getContentType" -> "org.eclipse.mat.HprofHeapDump".equals(args[0]) ? hprof : base; + case "findContentTypeFor" -> hprof; + case "findContentTypesFor" -> new IContentType[]{hprof, base}; + case "getAllContentTypes" -> new IContentType[]{base, hprof}; + case "getMatcher" -> proxy; + default -> null; + }); + } + + private static IContentType contentType(String id, IContentType base) { + return (IContentType) Proxy.newProxyInstance( + MatRuntime.class.getClassLoader(), new Class[]{IContentType.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getId" -> id; + case "getName" -> id; + case "getBaseType" -> base; + case "isKindOf" -> args[0] == base || proxy == args[0] || id.equals(((IContentType) args[0]).getId()); + case "getFileSpecs" -> new String[0]; + case "getDefaultCharset" -> "UTF-8"; + case "isUserDefined", "isAssociatedWith" -> false; + default -> null; + }); + } + + private static void setStaticField(Class type, String name, Object value) throws ReflectiveOperationException { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(null, value); + } + + private static void setField(Object target, String name, Object value) throws ReflectiveOperationException { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static final class ClassLoadingRegistryStrategy extends RegistryStrategy { + private ClassLoadingRegistryStrategy() { + super(new java.io.File[]{Path.of(System.getProperty("java.io.tmpdir"), "java-heap-mcp-mat-registry").toFile()}, + new boolean[]{false}); + } + + @Override + public Object createExecutableExtension(RegistryContributor contributor, String className, + String overriddenContributorName) throws CoreException { + try { + return Class.forName(className).getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException | LinkageError exception) { + throw new CoreException(new Status(IStatus.ERROR, "java-heap-mcp", exception.getMessage(), exception)); + } + } + } + + private static final class ContentTypeServiceTracker extends ServiceTracker { + private final Object service; + + private ContentTypeServiceTracker(Object service) { + super(nullContext(), "org.eclipse.core.runtime.content.IContentTypeManager", null); + this.service = Objects.requireNonNull(service); + } + + @Override + public Object getService() { + return service; + } + + private static BundleContext nullContext() { + return (BundleContext) Proxy.newProxyInstance( + MatRuntime.class.getClassLoader(), + new Class[]{BundleContext.class}, + (proxy, method, args) -> null + ); + } + } + +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectFieldValue.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectFieldValue.java new file mode 100644 index 0000000..81aa3c5 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectFieldValue.java @@ -0,0 +1,9 @@ +package cchesser.javaperf.mcp.heap; + +public record ObjectFieldValue( + String name, + String type, + Object value, + Integer referencedObjectId +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectInspection.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectInspection.java new file mode 100644 index 0000000..3532528 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectInspection.java @@ -0,0 +1,18 @@ +package cchesser.javaperf.mcp.heap; + +import java.util.List; + +public record ObjectInspection( + int objectId, + long address, + String className, + String displayName, + long shallowHeapBytes, + long retainedHeapBytes, + Integer immediateDominatorId, + List gcRootTypes, + List fields, + List inboundReferences, + List outboundReferences +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectReference.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectReference.java new file mode 100644 index 0000000..809e543 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/ObjectReference.java @@ -0,0 +1,9 @@ +package cchesser.javaperf.mcp.heap; + +public record ObjectReference( + int objectId, + String className, + long address, + String displayName +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/OqlGrammar.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/OqlGrammar.java new file mode 100644 index 0000000..4cb7183 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/OqlGrammar.java @@ -0,0 +1,93 @@ +package cchesser.javaperf.mcp.heap; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Agent-facing description of the SQL dialect accepted by heap_run_oql. + * + *

This is deliberately a server contract rather than a copy of Calcite's + * complete parser grammar. Calcite can parse mutating statements, while this + * service only permits one read-only SELECT statement.

+ */ +public final class OqlGrammar { + private OqlGrammar() { + } + + public static Map describe() { + Map result = new LinkedHashMap<>(); + result.put("dialect", "Apache Calcite SQL backed by the MAT Calcite plugin"); + result.put("version", "1"); + result.put("readOnly", true); + result.put("statementContract", "Exactly one SELECT statement, without a trailing semicolon"); + result.put("grammar", Map.of( + "statement", "query", + "query", "[ WITH [ RECURSIVE ] withItem [, ...] ] (select | selectWithoutFrom | query UNION [ALL | DISTINCT] query | query EXCEPT [ALL | DISTINCT] query | query INTERSECT [ALL | DISTINCT] query) [ ORDER BY orderItem [, ...] ] [ LIMIT [start,] count ] [ OFFSET start { ROW | ROWS } ] [ FETCH { FIRST | NEXT } [count] { ROW | ROWS } ONLY ]", + "select", "SELECT [ ALL | DISTINCT ] projectItem [, projectItem ...] FROM tableExpression [ WHERE booleanExpression ] [ GROUP BY groupItem [, ...] ] [ HAVING booleanExpression ]", + "projectItem", "expression [ AS columnAlias ] | tableAlias.*", + "tableExpression", "tableReference [, tableReference ...] | tableExpression [NATURAL] [LEFT|RIGHT|FULL] [OUTER] JOIN tableExpression joinCondition | tableExpression CROSS JOIN tableExpression", + "tableReference", "tablePrimary [ AS alias ]", + "tablePrimary", "[catalog.]schema.table | UNNEST(expression) [WITH ORDINALITY] | [LATERAL] TABLE(functionName(expression [, ...]))", + "joinCondition", "ON booleanExpression | USING (column [, ...])", + "orderItem", "expression [ ASC | DESC ] [ NULLS FIRST | NULLS LAST ]", + "expression", "identifiers, literals, function calls, CASE, arithmetic, comparisons, boolean operators, MAP/ARRAY indexing, and CAST" + )); + result.put("restrictions", List.of( + "Only SELECT queries are supported; do not use EXPLAIN, DESCRIBE, INSERT, UPDATE, DELETE, MERGE, SET, or RESET.", + "Send one statement without a semicolon.", + "Double quotes delimit Java class/table identifiers; single quotes delimit string literals.", + "The MCP tool applies its own result limit and offset after query execution." + )); + result.put("heapSchema", Map.of( + "classes", "Each Java class is a table of direct instances without subclasses, for example java.util.HashMap.", + "subclasses", "Use the instanceof schema, for example instanceof.java.util.HashMap, to include subclass instances.", + "quotedClasses", "Quote fully qualified class names when needed, for example \"java.util.HashMap\" or \"long[]\".", + "specialColumn", "this is a reference to the current heap object.", + "dynamicFields", "Use this['fieldName'] and virtual properties this['@className'], this['@class'], this['@shallow'], and this['@retained'].", + "nativeTable", "native.ThreadStackFrames exposes thread stack and local-variable information." + )); + result.put("referenceFunctions", List.of( + function("getId(ref)", "internal object identifier"), + function("getAddress(ref)", "memory address"), + function("getType(ref)", "runtime class name"), + function("toString(ref)", "textual representation"), + function("shallowSize(ref)", "shallow heap size"), + function("retainedSize(ref)", "retained heap size"), + function("length(ref)", "array length"), + function("getSize(ref)", "collection, map, or array size"), + function("getByKey(ref, key)", "value from a referenced map"), + function("getField(ref, name)", "field value by name"), + function("getStaticField(ref, name)", "static field value"), + function("getStringContent(ref)", "formatted object content") + )); + result.put("tableFunctions", List.of( + function("getRetainedSet(ref)", "table of retained objects"), + function("getOutboundReferences(ref)", "(name, this) reference pairs"), + function("getInboundReferences(ref)", "inbound object references"), + function("getValues(ref)", "values in a Java collection"), + function("getMapEntries(ref)", "(key, value) map-entry pairs") + )); + result.put("collectionFunctions", List.of( + "asMap(ref)", "asMultiSet(ref)", "asArray(ref)", "asByteArray(ref)", "asShortArray(ref)", + "asIntArray(ref)", "asLongArray(ref)", "asBooleanArray(ref)", "asCharArray(ref)", + "asFloatArray(ref)", "asDoubleArray(ref)" + )); + result.put("examples", List.of( + "SELECT s.this FROM \"java.lang.String\" s", + "SELECT toString(file) AS file_str, COUNT(*) AS cnt FROM java.net.URL GROUP BY toString(file) HAVING COUNT(*) > 1 ORDER BY SUM(retainedSize(this)) DESC", + "SELECT u.this, refs.name, refs.this AS reference FROM java.net.URL u, LATERAL TABLE(getOutboundReferences(u.this)) refs", + "SELECT p.this, vals.key, vals.\"value\" FROM java.util.Properties p, LATERAL TABLE(getMapEntries(p.this)) vals", + "SELECT arrays.this, asLongArray(arrays.this)[1] AS first_element FROM \"long[]\" arrays WHERE length(arrays.this) > 0" + )); + result.put("references", Map.of( + "calciteSqlReference", "https://calcite.apache.org/docs/reference.html", + "matCalcitePlugin", "https://github.com/vlsi/mat-calcite-plugin" + )); + return result; + } + + private static Map function(String signature, String description) { + return Map.of("signature", signature, "description", description); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/OqlQueryResult.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/OqlQueryResult.java new file mode 100644 index 0000000..bd7e1cd --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/heap/OqlQueryResult.java @@ -0,0 +1,15 @@ +package cchesser.javaperf.mcp.heap; + +import java.util.List; +import java.util.Map; + +public record OqlQueryResult( + String query, + List columns, + List> rows, + int offset, + int limit, + boolean truncated, + int totalRows +) { +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/McpServerEngine.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/McpServerEngine.java new file mode 100644 index 0000000..b6e10e7 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/McpServerEngine.java @@ -0,0 +1,54 @@ +package cchesser.javaperf.mcp.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.CountDownLatch; + +/** Owns the official MCP SDK server and keeps the process alive until stdio closes. */ +public final class McpServerEngine implements AutoCloseable { + private final McpSyncServer server; + private final CountDownLatch inputClosed = new CountDownLatch(1); + + public McpServerEngine(ObjectMapper mapper, ToolRegistry registry, InputStream input, OutputStream output) { + JacksonMcpJsonMapper jsonMapper = new JacksonMcpJsonMapper(mapper); + StdioServerTransportProvider transport = new StdioServerTransportProvider( + jsonMapper, new EofAwareInputStream(input, inputClosed), output); + this.server = McpServer.sync(transport) + .serverInfo("java-heap-mcp", "0.1.0") + .tools(registry.specifications()) + .jsonMapper(jsonMapper) + .immediateExecution(true) + .build(); + } + + public void run() throws InterruptedException { + inputClosed.await(); + } + + @Override + public void close() { + server.closeGracefully(); + inputClosed.countDown(); + } + + private static final class EofAwareInputStream extends InputStream { + private final InputStream delegate; + private final CountDownLatch eof; + + private EofAwareInputStream(InputStream delegate, CountDownLatch eof) { + this.delegate = delegate; + this.eof = eof; + } + + @Override public int read() throws IOException { int value = delegate.read(); if (value < 0) eof.countDown(); return value; } + @Override public int read(byte[] bytes, int offset, int length) throws IOException { int value = delegate.read(bytes, offset, length); if (value < 0) eof.countDown(); return value; } + @Override public void close() throws IOException { delegate.close(); eof.countDown(); } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/McpTool.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/McpTool.java new file mode 100644 index 0000000..070befe --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/McpTool.java @@ -0,0 +1,17 @@ +package cchesser.javaperf.mcp.mcp; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.Map; + +public record McpTool( + String name, + String description, + Map inputSchema, + ToolHandler handler +) { + @FunctionalInterface + public interface ToolHandler { + Object invoke(JsonNode arguments); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/ToolRegistry.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/ToolRegistry.java new file mode 100644 index 0000000..17cee52 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/mcp/ToolRegistry.java @@ -0,0 +1,127 @@ +package cchesser.javaperf.mcp.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import cchesser.javaperf.mcp.config.ServerConfig; +import cchesser.javaperf.mcp.heap.HeapDumpManager; +import cchesser.javaperf.mcp.heap.HeapOperationException; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Adapts heap operations to the official MCP Java SDK tool model. */ +public final class ToolRegistry { + private final Map tools; + private final ObjectMapper mapper; + + public ToolRegistry(HeapDumpManager manager, ObjectMapper mapper, ServerConfig config) { + this.mapper = mapper; + this.tools = registerTools(manager, config); + } + + public List specifications() { + return tools.values().stream().map(this::specification).toList(); + } + + private McpServerFeatures.SyncToolSpecification specification(McpTool tool) { + McpSchema.Tool definition = McpSchema.Tool.builder() + .name(tool.name()) + .description(tool.description()) + .inputSchema(schema(tool.inputSchema())) + .build(); + + return McpServerFeatures.SyncToolSpecification.builder() + .tool(definition) + .callHandler((exchange, request) -> { + long startedAt = System.nanoTime(); + System.err.printf("[java-heap-mcp] MCP tool %s called%n", tool.name()); + try { + Object result = tool.handler().invoke(mapper.valueToTree(request.arguments())); + logToolCompletion(tool.name(), startedAt, "ok"); + return McpSchema.CallToolResult.builder() + .addTextContent(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(result)) + .structuredContent(result) + .isError(false) + .build(); + } catch (HeapOperationException exception) { + logToolCompletion(tool.name(), startedAt, exception.errorCode().name()); + return errorResult(exception.errorCode().name(), exception.getMessage()); + } catch (Exception exception) { + logToolCompletion(tool.name(), startedAt, "INTERNAL"); + return errorResult("INTERNAL", exception.getMessage()); + } + }) + .build(); + } + + private static void logToolCompletion(String toolName, long startedAt, String outcome) { + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000; + System.err.printf("[java-heap-mcp] MCP tool %s -> %s (%d ms)%n", toolName, outcome, elapsedMillis); + } + + @SuppressWarnings("unchecked") + private static McpSchema.JsonSchema schema(Map schema) { + return new McpSchema.JsonSchema( + (String) schema.get("type"), + (Map) schema.getOrDefault("properties", Map.of()), + (List) schema.getOrDefault("required", List.of()), + null, + Map.of(), + Map.of() + ); + } + + private McpSchema.CallToolResult errorResult(String errorCode, String message) { + Map errorBody = Map.of( + "errorCode", errorCode, + "message", message == null ? errorCode : message + ); + try { + return McpSchema.CallToolResult.builder() + .addTextContent(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(errorBody)) + .structuredContent(errorBody) + .isError(true) + .build(); + } catch (Exception exception) { + return McpSchema.CallToolResult.builder() + .addTextContent(String.valueOf(errorBody)) + .isError(true) + .build(); + } + } + + private Map registerTools(HeapDumpManager manager, ServerConfig config) { + Map registered = new LinkedHashMap<>(); + registered.put("heap_load_dump", new McpTool("heap_load_dump", "Load a Java heap dump into a named project, build or reuse MAT indexes, and return a session handle.", McpSchemaHelper.objectSchema(Map.of("project", McpSchemaHelper.string(), "path", McpSchemaHelper.string()), List.of("path")), a -> manager.loadDump(textOrNull(a, "project"), text(a, "path")))); + registered.put("heap_open_project", new McpTool("heap_open_project", "Open an existing cached project and return an active heap handle.", McpSchemaHelper.objectSchema(Map.of("project", McpSchemaHelper.string()), List.of("project")), a -> manager.openProject(text(a, "project")))); + registered.put("heap_list_projects", new McpTool("heap_list_projects", "List named projects that have cached heap dumps and show active handles.", McpSchemaHelper.objectSchema(Map.of(), List.of()), a -> manager.listProjects())); + registered.put("heap_list_dumps", new McpTool("heap_list_dumps", "List cached heap dumps, their metadata, and any currently active handles.", McpSchemaHelper.objectSchema(Map.of(), List.of()), a -> manager.listDumps())); + registered.put("heap_unload_dump", new McpTool("heap_unload_dump", "Unload an active heap handle while preserving its on-disk cache entry and indexes.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string()), List.of("handle")), a -> manager.unloadDump(text(a, "handle")))); + registered.put("heap_get_overview", new McpTool("heap_get_overview", "Return snapshot summary, top classes, and top retained-memory dominators for a loaded heap.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string(), "limit", McpSchemaHelper.integer()), List.of("handle")), a -> manager.overview(text(a, "handle"), integer(a, "limit")))); + registered.put("heap_run_oql", new McpTool("heap_run_oql", "Execute an OQL query and return bounded, LLM-friendly rows with pagination.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string(), "query", McpSchemaHelper.string(), "limit", McpSchemaHelper.integer(), "offset", McpSchemaHelper.integer()), List.of("handle", "query")), a -> manager.runOql(text(a, "handle"), text(a, "query"), integer(a, "limit"), integer(a, "offset")))); + registered.put("heap_get_oql_grammar", new McpTool("heap_get_oql_grammar", "Return the read-only Apache Calcite SQL grammar, MAT heap schema, functions, and examples accepted by heap_run_oql.", McpSchemaHelper.objectSchema(Map.of(), List.of()), a -> cchesser.javaperf.mcp.heap.OqlGrammar.describe())); + registered.put("heap_get_histogram", new McpTool("heap_get_histogram", "Return a bounded class histogram ordered by retained heap, shallow heap, or object count.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string(), "sort", McpSchemaHelper.string(), "limit", McpSchemaHelper.integer()), List.of("handle")), a -> manager.histogram(text(a, "handle"), textOrNull(a, "sort"), integer(a, "limit")))); + registered.put("heap_get_dominators", new McpTool("heap_get_dominators", "Return retained-memory dominators from the heap root or from a supplied object root.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string(), "root", McpSchemaHelper.integer(), "limit", McpSchemaHelper.integer()), List.of("handle")), a -> manager.dominators(text(a, "handle"), integer(a, "root"), integer(a, "limit")))); + registered.put("heap_inspect_object", new McpTool("heap_inspect_object", "Inspect a heap object including class, sizes, fields, and inbound/outbound references.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string(), "objectId", McpSchemaHelper.integer(), "limit", McpSchemaHelper.integer()), List.of("handle", "objectId")), a -> manager.inspectObject(text(a, "handle"), requiredInteger(a, "objectId"), integer(a, "limit")))); + registered.put("heap_find_path_to_gc_roots", new McpTool("heap_find_path_to_gc_roots", "Find bounded object-reference paths from the target object to GC roots.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string(), "objectId", McpSchemaHelper.integer(), "excludeWeakRefs", McpSchemaHelper.bool(), "limit", McpSchemaHelper.integer()), List.of("handle", "objectId")), a -> manager.findPathsToGcRoots(text(a, "handle"), requiredInteger(a, "objectId"), booleanValue(a, "excludeWeakRefs"), integer(a, "limit")))); + registered.put("heap_find_leak_suspects", new McpTool("heap_find_leak_suspects", "Run MAT leak-suspect analysis when available and return a bounded summary.", McpSchemaHelper.objectSchema(Map.of("handle", McpSchemaHelper.string(), "limit", McpSchemaHelper.integer()), List.of("handle")), a -> manager.findLeakSuspects(text(a, "handle"), integer(a, "limit")))); + return registered; + } + + private static String text(JsonNode a, String f) { JsonNode v = a.get(f); if (v == null || v.isNull() || v.asText().isBlank()) throw new IllegalArgumentException("Missing required argument: " + f); return v.asText(); } + private static String textOrNull(JsonNode a, String f) { JsonNode v = a.get(f); return v == null || v.isNull() ? null : v.asText(); } + private static Integer integer(JsonNode a, String f) { JsonNode v = a.get(f); return v == null || v.isNull() ? null : v.asInt(); } + private static int requiredInteger(JsonNode a, String f) { JsonNode v = a.get(f); if (v == null || v.isNull()) throw new IllegalArgumentException("Missing required argument: " + f); return v.asInt(); } + private static boolean booleanValue(JsonNode a, String f) { JsonNode v = a.get(f); return v != null && !v.isNull() && v.asBoolean(false); } + + private static final class McpSchemaHelper { + private McpSchemaHelper() { } + static Map objectSchema(Map properties, List required) { return Map.of("type", "object", "properties", properties, "required", required); } + static Map string() { return Map.of("type", "string"); } + static Map integer() { return Map.of("type", "integer"); } + static Map bool() { return Map.of("type", "boolean"); } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/server/HeapMcpServerApplication.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/server/HeapMcpServerApplication.java new file mode 100644 index 0000000..65ffefd --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/server/HeapMcpServerApplication.java @@ -0,0 +1,45 @@ +package cchesser.javaperf.mcp.server; + +import cchesser.javaperf.mcp.cache.CacheMetadataStore; +import cchesser.javaperf.mcp.config.ServerConfig; +import cchesser.javaperf.mcp.heap.HeapDumpManager; +import cchesser.javaperf.mcp.heap.MatHeapService; +import cchesser.javaperf.mcp.mcp.McpServerEngine; +import cchesser.javaperf.mcp.mcp.ToolRegistry; +import cchesser.javaperf.mcp.util.JsonSupport; +import cchesser.javaperf.mcp.web.WebUiServer; + +public final class HeapMcpServerApplication { + private HeapMcpServerApplication() { + } + + public static void main(String[] args) throws Exception { + ServerConfig config = ServerConfig.fromEnvironment(); + JsonSupport jsonSupport = JsonSupport.create(); + CacheMetadataStore metadataStore = new CacheMetadataStore(config.cacheRoot(), jsonSupport.mapper()); + MatHeapService heapService = new MatHeapService(config); + HeapDumpManager heapDumpManager = new HeapDumpManager(config, metadataStore, heapService); + ToolRegistry toolRegistry = new ToolRegistry(heapDumpManager, jsonSupport.mapper(), config); + WebUiServer webUiServer = null; + if (config.webUiEnabled()) { + webUiServer = new WebUiServer( + config.webUiPort(), + config.cacheRoot(), + config.webUiMaxUploadBytes(), + jsonSupport.mapper(), + heapDumpManager + ); + webUiServer.start(); + System.err.println("java-heap-mcp web UI available at http://127.0.0.1:" + config.webUiPort() + "/"); + } + + try (McpServerEngine engine = new McpServerEngine(jsonSupport.mapper(), toolRegistry, System.in, System.out)) { + engine.run(); + } finally { + if (webUiServer != null) { + webUiServer.close(); + } + heapDumpManager.close(); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/util/JsonSupport.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/util/JsonSupport.java new file mode 100644 index 0000000..65cfea1 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/util/JsonSupport.java @@ -0,0 +1,18 @@ +package cchesser.javaperf.mcp.util; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +public record JsonSupport(ObjectMapper mapper) { + public static JsonSupport create() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + return new JsonSupport(mapper); + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/util/Timeouts.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/util/Timeouts.java new file mode 100644 index 0000000..826dbad --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/util/Timeouts.java @@ -0,0 +1,42 @@ +package cchesser.javaperf.mcp.util; + +import cchesser.javaperf.mcp.heap.HeapOperationException; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public final class Timeouts { + private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r, "java-heap-mcp-worker"); + thread.setDaemon(true); + return thread; + }); + + private Timeouts() { + } + + public static T call(String operationName, Duration timeout, Callable task) { + Future future = EXECUTOR.submit(task); + try { + return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException exception) { + future.cancel(true); + throw HeapOperationException.timeout(operationName, timeout, exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw HeapOperationException.interrupted(operationName, exception); + } catch (ExecutionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(cause); + } + } +} diff --git a/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/web/WebUiServer.java b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/web/WebUiServer.java new file mode 100644 index 0000000..40034d0 --- /dev/null +++ b/java-heap-mcp/src/main/java/cchesser/javaperf/mcp/web/WebUiServer.java @@ -0,0 +1,354 @@ +package cchesser.javaperf.mcp.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import cchesser.javaperf.mcp.heap.HeapDumpManager; +import cchesser.javaperf.mcp.heap.HeapOperationException; +import cchesser.javaperf.mcp.heap.OqlGrammar; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.StandardCopyOption; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.function.Supplier; + +public final class WebUiServer implements AutoCloseable { + private static final int STREAM_BUFFER_BYTES = 1024 * 1024; + + private final HttpServer server; + private final ObjectMapper mapper; + private final HeapDumpManager manager; + private final Path uploadRoot; + private final long maxUploadBytes; + + public WebUiServer(int port, Path cacheRoot, long maxUploadBytes, ObjectMapper mapper, HeapDumpManager manager) throws IOException { + this.mapper = mapper; + this.manager = manager; + this.uploadRoot = cacheRoot.resolve("uploads"); + this.maxUploadBytes = maxUploadBytes; + this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + this.server.setExecutor(Executors.newCachedThreadPool()); + registerRoutes(); + } + + public void start() { + server.start(); + } + + private void registerRoutes() { + registerRoute("/", this::handleRoot); + registerRoute("/api/projects", exchange -> handleJsonGet(exchange, manager::listProjects)); + registerRoute("/api/dumps", exchange -> handleJsonGet(exchange, manager::listDumps)); + registerRoute("/api/load", exchange -> handleJsonPost(exchange, body -> + manager.loadDump(textOrNull(body, "project"), text(body, "path")))); + registerRoute("/api/load-upload", this::handleLoadUpload); + registerRoute("/api/open-project", exchange -> handleJsonPost(exchange, body -> + manager.openProject(text(body, "project")))); + registerRoute("/api/unload", exchange -> handleJsonPost(exchange, body -> + manager.unloadDump(text(body, "handle")))); + registerRoute("/api/overview", exchange -> handleJsonPost(exchange, body -> + manager.overview(text(body, "handle"), integer(body, "limit")))); + registerRoute("/api/histogram", exchange -> handleJsonPost(exchange, body -> + manager.histogram(text(body, "handle"), textOrNull(body, "sort"), integer(body, "limit")))); + registerRoute("/api/dominators", exchange -> handleJsonPost(exchange, body -> + manager.dominators(text(body, "handle"), integer(body, "root"), integer(body, "limit")))); + registerRoute("/api/leaks", exchange -> handleJsonPost(exchange, body -> + manager.findLeakSuspects(text(body, "handle"), integer(body, "limit")))); + registerRoute("/api/inspect", exchange -> handleJsonPost(exchange, body -> + manager.inspectObject(text(body, "handle"), requiredInteger(body, "objectId"), integer(body, "limit")))); + registerRoute("/api/gc-roots", exchange -> handleJsonPost(exchange, body -> + manager.findPathsToGcRoots(text(body, "handle"), requiredInteger(body, "objectId"), + booleanOrDefault(body, "excludeWeakRefs", false), integer(body, "limit")))); + registerRoute("/api/oql", exchange -> handleJsonPost(exchange, body -> + manager.runOql(text(body, "handle"), text(body, "query"), integer(body, "limit"), integer(body, "offset")))); + registerRoute("/api/oql-grammar", exchange -> handleJsonGet(exchange, OqlGrammar::describe)); + registerRoute("/api/oql/grammar", exchange -> handleJsonGet(exchange, OqlGrammar::describe)); + } + + private void registerRoute(String path, HttpHandler handler) { + server.createContext(path, exchange -> { + long startedAt = System.nanoTime(); + try { + handler.handle(exchange); + } finally { + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000; + System.err.printf("[java-heap-mcp] %s %s -> %d (%d ms)%n", + exchange.getRequestMethod(), exchange.getRequestURI(), exchange.getResponseCode(), elapsedMillis); + } + }); + } + + private void handleRoot(HttpExchange exchange) throws IOException { + if (handleCorsPreflight(exchange)) { + return; + } + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + writeJson(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + try (InputStream resource = getClass().getResourceAsStream("/web/index.html")) { + if (resource == null) { + writeJson(exchange, 500, Map.of("error", "Missing web UI resource")); + return; + } + byte[] bytes = resource.readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + } finally { + exchange.close(); + } + } + + private void handleJsonGet(HttpExchange exchange, Supplier handler) throws IOException { + if (handleCorsPreflight(exchange)) { + return; + } + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + writeJson(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + try { + writeJson(exchange, 200, handler.get()); + } catch (HeapOperationException exception) { + logFailure(exchange, exception); + writeJson(exchange, 400, operationError(exception)); + } catch (IllegalArgumentException exception) { + logFailure(exchange, exception); + writeJson(exchange, 400, Map.of("error", exception.getMessage())); + } catch (Exception exception) { + logFailure(exchange, exception); + writeJson(exchange, 500, Map.of("error", exception.getMessage())); + } + } + + private void handleJsonPost(HttpExchange exchange, PostHandler handler) throws IOException { + if (handleCorsPreflight(exchange)) { + return; + } + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + writeJson(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + try { + JsonNode body = readJsonBody(exchange); + writeJson(exchange, 200, handler.handle(body)); + } catch (HeapOperationException exception) { + logFailure(exchange, exception); + writeJson(exchange, 400, operationError(exception)); + } catch (IllegalArgumentException exception) { + logFailure(exchange, exception); + writeJson(exchange, 400, Map.of("error", exception.getMessage())); + } catch (Exception exception) { + logFailure(exchange, exception); + writeJson(exchange, 500, Map.of("error", exception.getMessage())); + } + } + + private void handleLoadUpload(HttpExchange exchange) throws IOException { + if (handleCorsPreflight(exchange)) { + return; + } + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + writeJson(exchange, 405, Map.of("error", "Method not allowed")); + return; + } + try { + Map query = parseQuery(exchange.getRequestURI().getRawQuery()); + String project = query.get("project"); + String fileName = query.get("filename"); + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("Missing required query parameter: filename"); + } + String effectiveProject = project == null || project.isBlank() ? null : project.trim(); + Path projectDir = uploadRoot.resolve(safeSegment(effectiveProject == null ? "incoming" : effectiveProject)); + Files.createDirectories(projectDir); + + String cleanName = safeFileName(fileName); + Path uploadedPath = projectDir.resolve(System.currentTimeMillis() + "-" + cleanName); + Path tempPath = uploadedPath.resolveSibling(uploadedPath.getFileName() + ".tmp"); + try (InputStream body = exchange.getRequestBody()) { + streamToFile(body, tempPath); + } + Files.move(tempPath, uploadedPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + writeJson(exchange, 200, manager.loadDump(effectiveProject, uploadedPath.toString())); + } catch (HeapOperationException exception) { + logFailure(exchange, exception); + writeJson(exchange, 400, operationError(exception)); + } catch (IllegalArgumentException exception) { + logFailure(exchange, exception); + writeJson(exchange, 400, Map.of("error", exception.getMessage())); + } catch (Exception exception) { + logFailure(exchange, exception); + writeJson(exchange, 500, Map.of("error", exception.getMessage())); + } + } + + private JsonNode readJsonBody(HttpExchange exchange) throws IOException { + byte[] bytes = exchange.getRequestBody().readAllBytes(); + if (bytes.length == 0) { + return mapper.nullNode(); + } + return mapper.readTree(bytes); + } + + private void writeJson(HttpExchange exchange, int statusCode, Object body) throws IOException { + byte[] bytes = mapper.writeValueAsBytes(body); + addCorsHeaders(exchange); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(statusCode, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private static Map operationError(HeapOperationException exception) { + Map body = new java.util.LinkedHashMap<>(); + body.put("error", exception.getMessage()); + body.put("errorCode", exception.errorCode().name()); + if (exception.getCause() != null && exception.getCause().getMessage() != null) { + body.put("cause", exception.getCause().getMessage()); + } + return body; + } + + private boolean handleCorsPreflight(HttpExchange exchange) throws IOException { + if (!"OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { + addCorsHeaders(exchange); + return false; + } + addCorsHeaders(exchange); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + return true; + } + + private static void addCorsHeaders(HttpExchange exchange) { + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type"); + exchange.getResponseHeaders().set("Access-Control-Max-Age", "86400"); + } + + private static void logFailure(HttpExchange exchange, Exception exception) { + System.err.printf("[java-heap-mcp] %s %s failed: %s%n", + exchange.getRequestMethod(), exchange.getRequestURI(), exception.getMessage()); + exception.printStackTrace(System.err); + } + + private static String text(JsonNode body, String field) { + JsonNode value = body.get(field); + if (value == null || value.isNull() || value.asText().isBlank()) { + throw new IllegalArgumentException("Missing required field: " + field); + } + return value.asText(); + } + + private static String textOrNull(JsonNode body, String field) { + JsonNode value = body.get(field); + if (value == null || value.isNull()) { + return null; + } + String result = value.asText(); + return result.isBlank() ? null : result; + } + + private static Integer integer(JsonNode body, String field) { + JsonNode value = body.get(field); + if (value == null || value.isNull()) { + return null; + } + return value.asInt(); + } + + private static int requiredInteger(JsonNode body, String field) { + JsonNode value = body.get(field); + if (value == null || value.isNull()) { + throw new IllegalArgumentException("Missing required field: " + field); + } + if (value.isIntegralNumber()) { + return value.asInt(); + } + if (value.isTextual()) { + try { + return Integer.parseInt(value.asText().trim()); + } catch (NumberFormatException ignored) { + // Fall through to the consistent missing/invalid-field response. + } + } + throw new IllegalArgumentException("Field must be an integer: " + field); + } + + private static boolean booleanOrDefault(JsonNode body, String field, boolean defaultValue) { + JsonNode value = body.get(field); + return value == null || value.isNull() ? defaultValue : value.asBoolean(defaultValue); + } + + private static Map parseQuery(String rawQuery) { + if (rawQuery == null || rawQuery.isBlank()) { + return Map.of(); + } + return java.util.Arrays.stream(rawQuery.split("&")) + .map(part -> part.split("=", 2)) + .collect(java.util.stream.Collectors.toMap( + pair -> decodeQueryComponent(pair[0]), + pair -> pair.length > 1 ? decodeQueryComponent(pair[1]) : "", + (left, right) -> right + )); + } + + private static String decodeQueryComponent(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private static String safeSegment(String value) { + return value.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private static String safeFileName(String value) { + String normalized = value.replace('\\', '/'); + int lastSlash = normalized.lastIndexOf('/'); + String base = lastSlash >= 0 ? normalized.substring(lastSlash + 1) : normalized; + if (base.isBlank()) { + return "uploaded.hprof"; + } + return base.replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + private void streamToFile(InputStream input, Path destination) throws IOException { + byte[] buffer = new byte[STREAM_BUFFER_BYTES]; + long totalWritten = 0L; + try (var output = Files.newOutputStream(destination, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + int read; + while ((read = input.read(buffer)) != -1) { + totalWritten += read; + if (maxUploadBytes > 0 && totalWritten > maxUploadBytes) { + throw new IllegalArgumentException("Upload exceeds configured max bytes: " + maxUploadBytes); + } + output.write(buffer, 0, read); + } + } catch (Exception exception) { + Files.deleteIfExists(destination); + throw exception; + } + } + + @Override + public void close() { + server.stop(0); + } + + @FunctionalInterface + private interface PostHandler { + Object handle(JsonNode body); + } +} diff --git a/java-heap-mcp/src/main/resources/web/index.html b/java-heap-mcp/src/main/resources/web/index.html new file mode 100644 index 0000000..6a9af65 --- /dev/null +++ b/java-heap-mcp/src/main/resources/web/index.html @@ -0,0 +1,184 @@ + + + + + + Java Heap MCP + + + +
+
+

Java Heap MCP Workspace

+

Load .hprof dumps into memorable projects and run focused heap analysis.

+
+ + + +
+

Load heap dump

+
+
+
+
+
+
+
+ +
+

Projects

+ + + + +
ProjectSourceStatusActions
+
+ +
+

Analysis

+
+
+
+
+
+
+ + +
+ +

Result

{}
+
+ + + + diff --git a/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/cache/CacheMetadataStoreTest.java b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/cache/CacheMetadataStoreTest.java new file mode 100644 index 0000000..9ebc4b5 --- /dev/null +++ b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/cache/CacheMetadataStoreTest.java @@ -0,0 +1,38 @@ +package cchesser.javaperf.mcp.cache; + +import cchesser.javaperf.mcp.util.JsonSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class CacheMetadataStoreTest { + @TempDir + Path tempDir; + + @Test + void persistsAndReloadsMetadata() { + CacheMetadataStore store = new CacheMetadataStore(tempDir, JsonSupport.create().mapper()); + CachedHeapDumpRecord record = new CachedHeapDumpRecord( + "proj-a", + "fp-1", + "/tmp/heap.hprof", + "heap.hprof", + 12L, + Instant.parse("2025-01-01T00:00:00Z"), + Instant.parse("2025-01-01T00:00:01Z"), + Instant.parse("2025-01-01T00:00:02Z"), + "hash", + 12L + ); + + store.upsert(record); + + CacheMetadataStore reloaded = new CacheMetadataStore(tempDir, JsonSupport.create().mapper()); + assertThat(reloaded.get("fp-1")).contains(record); + assertThat(reloaded.getByProject("proj-a")).contains(record); + } +} diff --git a/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/cache/HeapDumpFingerprintTest.java b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/cache/HeapDumpFingerprintTest.java new file mode 100644 index 0000000..7524ab4 --- /dev/null +++ b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/cache/HeapDumpFingerprintTest.java @@ -0,0 +1,28 @@ +package cchesser.javaperf.mcp.cache; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class HeapDumpFingerprintTest { + @TempDir + Path tempDir; + + @Test + void fingerprintIncludesPathSizeAndTimestamp() throws Exception { + Path first = tempDir.resolve("first.hprof"); + Path second = tempDir.resolve("second.hprof"); + Files.writeString(first, "same-content"); + Files.writeString(second, "same-content"); + + HeapDumpFingerprint firstFingerprint = HeapDumpFingerprint.from(first); + HeapDumpFingerprint secondFingerprint = HeapDumpFingerprint.from(second); + + assertThat(firstFingerprint.fingerprint()).isNotEqualTo(secondFingerprint.fingerprint()); + assertThat(HeapDumpFingerprint.sha256File(first)).isEqualTo(HeapDumpFingerprint.sha256File(second)); + } +} diff --git a/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/heap/HeapDumpManagerTest.java b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/heap/HeapDumpManagerTest.java new file mode 100644 index 0000000..ab94b00 --- /dev/null +++ b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/heap/HeapDumpManagerTest.java @@ -0,0 +1,110 @@ +package cchesser.javaperf.mcp.heap; + +import cchesser.javaperf.mcp.cache.CacheMetadataStore; +import cchesser.javaperf.mcp.config.ServerConfig; +import cchesser.javaperf.mcp.util.JsonSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class HeapDumpManagerTest { + @TempDir + Path tempDir; + + @Test + void loadAndUnloadTracksSessionsAndMetadata() throws Exception { + Path heapDump = tempDir.resolve("sample.hprof"); + Files.writeString(heapDump, "not-a-real-heap"); + + ServerConfig config = new ServerConfig(tempDir.resolve("cache"), 10, 50, Duration.ofSeconds(5), 5, false, 7777, 0L); + CacheMetadataStore metadataStore = new CacheMetadataStore(config.cacheRoot(), JsonSupport.create().mapper()); + try (HeapDumpManager manager = new HeapDumpManager(config, metadataStore, new FakeHeapAnalysisService())) { + Map loadResult = manager.loadDump("proj-a", heapDump.toString()); + String handle = (String) loadResult.get("handle"); + + assertThat(handle).startsWith("heap-"); + assertThat(loadResult).containsEntry("project", "proj-a"); + assertThat(manager.listDumps()).hasSize(1); + assertThat(manager.listProjects()).hasSize(1); + + Map unloadResult = manager.unloadDump(handle); + assertThat(unloadResult).containsEntry("unloaded", true); + + Map reopened = manager.openProject("proj-a"); + assertThat(reopened.get("project")).isEqualTo("proj-a"); + assertThat(reopened.get("handle")).isInstanceOf(String.class); + } + } + + private static final class FakeHeapAnalysisService implements HeapAnalysisService { + @Override + public LoadedHeap open(Path heapDumpPath) { + return new LoadedHeap() { + @Override + public Path heapDumpPath() { + return heapDumpPath; + } + + @Override + public Map snapshotSummary() { + return Map.of("path", heapDumpPath.toString(), "numberOfObjects", 3); + } + + @Override + public Object nativeSnapshot() { + return Map.of(); + } + + @Override + public void dispose() { + } + }; + } + + @Override + public HeapOverview overview(LoadedHeap heap, int limit) { + return new HeapOverview(heap.snapshotSummary(), List.of(), List.of()); + } + + @Override + public OqlQueryResult runOql(LoadedHeap heap, String query, int limit, int offset) { + return new OqlQueryResult(query, List.of("value"), List.of(Map.of("value", 1)), offset, limit, false, 1); + } + + @Override + public List histogram(LoadedHeap heap, String sortBy, int limit) { + return List.of(); + } + + @Override + public List dominators(LoadedHeap heap, Integer rootObjectId, int limit) { + return List.of(); + } + + @Override + public ObjectInspection inspectObject(LoadedHeap heap, int objectId, int limit) { + return new ObjectInspection(objectId, 0L, "Object", "Object", 1L, 1L, null, List.of(), List.of(), List.of(), List.of()); + } + + @Override + public List findPathsToGcRoots(LoadedHeap heap, int objectId, boolean excludeWeakRefs, int limit) { + return List.of(); + } + + @Override + public Object leakSuspects(LoadedHeap heap, int limit) { + return Map.of(); + } + + @Override + public void close() { + } + } +} diff --git a/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/heap/OqlGrammarTest.java b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/heap/OqlGrammarTest.java new file mode 100644 index 0000000..5722624 --- /dev/null +++ b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/heap/OqlGrammarTest.java @@ -0,0 +1,31 @@ +package cchesser.javaperf.mcp.heap; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class OqlGrammarTest { + @Test + void describesTheReadOnlyCalciteHeapQueryContract() { + Map grammar = OqlGrammar.describe(); + + assertThat(grammar) + .containsEntry("readOnly", true) + .containsEntry("statementContract", "Exactly one SELECT statement, without a trailing semicolon"); + @SuppressWarnings("unchecked") + Map syntax = (Map) grammar.get("grammar"); + assertThat(syntax).containsKeys("select", "tablePrimary"); + assertThat(syntax.get("select")).isInstanceOf(String.class); + assertThat(syntax.get("tablePrimary")).isInstanceOf(String.class); + assertThat((List) grammar.get("examples")) + .anyMatch(example -> example.toString().contains("LATERAL TABLE(getOutboundReferences")); + @SuppressWarnings("unchecked") + Map references = (Map) grammar.get("references"); + assertThat(references) + .containsEntry("calciteSqlReference", "https://calcite.apache.org/docs/reference.html") + .containsEntry("matCalcitePlugin", "https://github.com/vlsi/mat-calcite-plugin"); + } +} diff --git a/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/mcp/McpServerEngineTest.java b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/mcp/McpServerEngineTest.java new file mode 100644 index 0000000..254f714 --- /dev/null +++ b/java-heap-mcp/src/test/java/cchesser/javaperf/mcp/mcp/McpServerEngineTest.java @@ -0,0 +1,112 @@ +package cchesser.javaperf.mcp.mcp; + +import cchesser.javaperf.mcp.cache.CacheMetadataStore; +import cchesser.javaperf.mcp.config.ServerConfig; +import cchesser.javaperf.mcp.heap.HeapAnalysisService; +import cchesser.javaperf.mcp.heap.HeapDumpManager; +import cchesser.javaperf.mcp.heap.HeapOverview; +import cchesser.javaperf.mcp.heap.LoadedHeap; +import cchesser.javaperf.mcp.heap.ObjectInspection; +import cchesser.javaperf.mcp.heap.OqlQueryResult; +import cchesser.javaperf.mcp.heap.GcRootPath; +import cchesser.javaperf.mcp.heap.HistogramEntry; +import cchesser.javaperf.mcp.heap.DominatorEntry; +import cchesser.javaperf.mcp.util.JsonSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpServerEngineTest { + @TempDir + Path tempDir; + + @Test + void registersHeapToolsWithTheSdkAdapter() throws Exception { + JsonSupport jsonSupport = JsonSupport.create(); + ServerConfig config = new ServerConfig(tempDir.resolve("cache"), 10, 50, Duration.ofSeconds(5), 5, false, 7777, 0L); + CacheMetadataStore metadataStore = new CacheMetadataStore(config.cacheRoot(), jsonSupport.mapper()); + HeapDumpManager manager = new HeapDumpManager(config, metadataStore, new FakeHeapAnalysisService()); + ToolRegistry registry = new ToolRegistry(manager, jsonSupport.mapper(), config); + + assertThat(registry.specifications()) + .extracting(specification -> specification.tool().name()) + .contains("heap_load_dump", "heap_open_project", "heap_list_projects", + "heap_run_oql", "heap_get_oql_grammar", "heap_find_leak_suspects"); + } + + private static final class FakeHeapAnalysisService implements HeapAnalysisService { + @Override + public LoadedHeap open(Path heapDumpPath) { + try { + Files.createDirectories(heapDumpPath.getParent()); + } catch (Exception ignored) { + } + return new LoadedHeap() { + @Override + public Path heapDumpPath() { + return heapDumpPath; + } + + @Override + public Map snapshotSummary() { + return Map.of("path", heapDumpPath.toString(), "numberOfObjects", 3); + } + + @Override + public Object nativeSnapshot() { + return Map.of(); + } + + @Override + public void dispose() { + } + }; + } + + @Override + public HeapOverview overview(LoadedHeap heap, int limit) { + return new HeapOverview(heap.snapshotSummary(), List.of(), List.of()); + } + + @Override + public OqlQueryResult runOql(LoadedHeap heap, String query, int limit, int offset) { + return new OqlQueryResult(query, List.of("value"), List.of(Map.of("value", 1)), offset, limit, false, 1); + } + + @Override + public List histogram(LoadedHeap heap, String sortBy, int limit) { + return List.of(); + } + + @Override + public List dominators(LoadedHeap heap, Integer rootObjectId, int limit) { + return List.of(); + } + + @Override + public ObjectInspection inspectObject(LoadedHeap heap, int objectId, int limit) { + return new ObjectInspection(objectId, 0L, "Object", "Object", 1L, 1L, null, List.of(), List.of(), List.of(), List.of()); + } + + @Override + public List findPathsToGcRoots(LoadedHeap heap, int objectId, boolean excludeWeakRefs, int limit) { + return List.of(); + } + + @Override + public Object leakSuspects(LoadedHeap heap, int limit) { + return Map.of(); + } + + @Override + public void close() { + } + } +} diff --git a/java-heap-workbench/.gitignore b/java-heap-workbench/.gitignore new file mode 100644 index 0000000..3c07338 --- /dev/null +++ b/java-heap-workbench/.gitignore @@ -0,0 +1,4 @@ +dist/ +java-heap-mcp/.m2 +java-heap-mcp/vendor +java-heap-mcp/*.hprof \ No newline at end of file diff --git a/java-heap-workbench/README.md b/java-heap-workbench/README.md new file mode 100644 index 0000000..96fedff --- /dev/null +++ b/java-heap-workbench/README.md @@ -0,0 +1,101 @@ +# java-heap-workbench + +`java-heap-workbench` is a browser workspace for asking an agent questions about Java heap dumps. Its focused split-pane layout keeps the Agent chat on the left and A2UI-rendered visual analysis on the right. It supports Ollama locally and OpenRouter through its OpenAI-compatible chat-completions API, with the existing `java-heap-mcp` HTTP API providing heap tools. + +## Build and run + +Requirements: Node.js 20+ and a running `java-heap-mcp` server. + +```bash +cd java-heap-workbench +npm run build +npm run dev +``` + +Open . The app is intentionally dependency-light: `npm install` is not required. `npm run build` copies the browser application to `dist/`; `npm run dev` serves that build. + +Start the MCP server from the sibling project: + +```bash +cd ../java-heap-mcp +JAVA_HEAP_MCP_WEB_UI_ENABLED=true ./scripts/run-server.sh +``` + +Use the MCP server's existing web UI at to load a `.hprof` file into a named project. Return to the workbench and select that project. + +## Connect Ollama or OpenRouter + +Install Ollama, start it, and pull a tool-capable chat model: + +```bash +ollama serve +ollama pull nemotron-3-nano:4b +``` + +If the browser blocks requests, allow the workbench origin when starting Ollama: + +```bash +OLLAMA_ORIGINS=http://127.0.0.1:4173 ollama serve +``` + +Open **Connections** in the workbench and choose a provider. Ollama uses `http://127.0.0.1:11434` and a locally pulled model. OpenRouter uses `https://openrouter.ai/api/v1`, requires an API key, and accepts free coding models such as: + +- `cohere/north-mini-code:free` +- `nvidia/nemotron-3.5-lightning:free` +- `qwen/qwen3-coder:free` +- `openrouter/free` (automatic free-model routing) + +The workbench includes these model IDs in the OpenRouter model picker. OpenRouter free-model availability can change; confirm the current list at [OpenRouter's free models](https://openrouter.ai/models?variant=free). The API key is stored in browser local storage for this local-only UI, so do not use this setup for a shared or untrusted browser profile. + +## How the integration works + +- `src/ag-ui.js` emits `RUN_STARTED`, text, tool-call, state, finish, and error events. This is the seam for replacing the local loop with an AG-UI transport/server. +- `src/main.js` gives the selected Ollama or OpenRouter model the MCP analysis tools. Tool calls are routed to `/api/overview`, `/api/histogram`, `/api/dominators`, `/api/leaks`, `/api/oql`, `/api/inspect`, and `/api/gc-roots`, then returned to the model for follow-up reasoning. +- `src/agent-prompt.js` supplies the agent with the tool contract and Calcite investigation rules. Attribute questions must be grounded by selecting an object with OQL and inspecting its returned object id; the agent must not claim success without tool evidence. +- `src/a2ui.js` renders the resulting declarative components: `summary`, `chart` (bar, line, or pie), `table`, and `markdown`. The renderer also converts legacy markdown tables into real table components. It uses safe DOM strings and SVG charts, so no chart CDN is needed. +- The agent prompt requests JSON visual responses with a `components` array. If a model still returns a markdown table, the workbench parses it as a fallback; numeric tool results are visualized immediately while the model is preparing its final response. + +## Test an interaction + +1. Load `my_little_heap_dump.hprof` (or another dump) in the MCP web UI. +2. Select the active project in the workbench. +3. Click **Largest retained classes**, or ask “Show me the top 10 classes by retained heap and visualize them as a bar chart.” +4. Watch the run details for AG-UI events and the visual report for A2UI cards/table/chart output. +5. Try an OQL request, for example: `Find String objects where the count is greater than 10.` The agent emits `SELECT * FROM java.lang.String s WHERE s.count > 10`; result limits are sent separately to the MCP tool. + +The browser calls only local services. No heap data or model prompts leave the machine unless you configure URLs that point elsewhere. + +OQL is the workbench name for the embedded [Apache Calcite SQL](https://calcite.apache.org/docs/reference.html) heap-query language. Only read-only `SELECT` statements are supported. Formulate every OQL query as a Calcite `SELECT`; use SQL joins, grouping, aggregates, ordering, pagination, and lateral table functions. The server may retain MAT compatibility internally, but the agent should not generate MAT OQL syntax. + +### Advanced Calcite OQL patterns + +The agent can use these Calcite SQL techniques for deeper investigations: + +- Each Java class is a Calcite table of direct instances; use the `instanceof` schema/table for subclasses. The special `this` column identifies the heap object, and Java fields are SQL columns. +- Use aliases and SQL expressions such as `COUNT`, `SUM`, `GROUP BY`, `HAVING`, `ORDER BY`, `JOIN`, `LATERAL TABLE(...)`, `UNNEST`, and `LIMIT/OFFSET`. +- Use `this['fieldName']` for dynamic fields and Calcite heap functions such as `toString`, `retainedSize`, `shallowSize`, `getField`, `getValues`, and `getMapEntries`. +- Heap functions use SQL function syntax, for example `toString(s.this)`; do not use Java-like alias method syntax such as `s.toString(s.this)`. +- To find the most common String values, use `SELECT toString(s.this) AS unique_value, COUNT(*) AS count FROM "java.lang.String" s GROUP BY toString(s.this) ORDER BY COUNT(*) DESC`. + +Examples: + +```sql +SELECT DISTINCT OBJECTS classof(s) +FROM "java\\.lang\\.S.*" s +``` + +```sql +SELECT s AS String, s.value AS characters, inbounds(s).@length AS inbound_count +FROM java.lang.String s +WHERE s.@retainedHeapSize > 1048576 +``` + +```sql +SELECT * +FROM OBJECTS ( + SELECT s, s.value AS value + FROM java.lang.String s +) v +``` + +Native `JOIN`, `GROUP BY`, `COUNT`, `SUM`, `ORDER BY`, and `LIMIT/OFFSET` are supported by the embedded [MAT Calcite Plugin](https://github.com/vlsi/mat-calcite-plugin) through the same `heap_run_oql` endpoint. The service still applies its tool-level result limit and offset after query execution. diff --git a/java-heap-workbench/build.mjs b/java-heap-workbench/build.mjs new file mode 100644 index 0000000..5a34605 --- /dev/null +++ b/java-heap-workbench/build.mjs @@ -0,0 +1,26 @@ +import { cp, mkdir, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const dist = join(root, 'dist'); +const contentTypes = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml' }; +await rm(dist, { recursive: true, force: true }); +await mkdir(dist, { recursive: true }); +await cp(join(root, 'index.html'), join(dist, 'index.html')); +await cp(join(root, 'src'), join(dist, 'src'), { recursive: true }); +console.log(`Built java-heap-workbench → ${dist}`); + +if (process.argv.includes('--serve')) { + const port = Number(process.env.PORT || 4173); + createServer(async (req, res) => { + const path = req.url === '/' ? '/index.html' : req.url; + const file = join(dist, path.replace(/^\//, '').split('?')[0]); + try { + const body = await (await import('node:fs/promises')).readFile(file); + res.writeHead(200, { 'Content-Type': contentTypes[extname(file)] || 'application/octet-stream' }); + res.end(body); + } catch { res.writeHead(404); res.end('Not found'); } + }).listen(port, () => console.log(`Workbench at http://127.0.0.1:${port}`)); +} diff --git a/java-heap-workbench/docs/architecture.md b/java-heap-workbench/docs/architecture.md new file mode 100644 index 0000000..2d8104d --- /dev/null +++ b/java-heap-workbench/docs/architecture.md @@ -0,0 +1,90 @@ +# `java-heap-workbench` open-source dependencies + +`java-heap-workbench` is a static browser application. Its [`package.json`](../package.json) has no runtime or development package dependencies, so there is no npm dependency tree to maintain. + +## Browser and service boundaries + +```mermaid +flowchart LR + User["Heap analyst"] --> UI["Browser UI\nindex.html + styles.css"] + UI --> Main["src/main.js\nworkspace state + orchestration"] + Main --> MCP["java-heap-mcp\nHTTP API :7777"] + MCP --> Heap["Eclipse MAT +\nApache Calcite analysis"] + Heap --> Dump[("heap dump\n+ MAT indexes")] + + Main --> Provider{Model provider} + Provider --> Ollama["Ollama\nlocal /api/chat"] + Provider --> OpenRouter["OpenRouter\n/chat/completions"] + Main --> Agent["src/agent-prompt.js\ntool definitions + agent rules"] + Agent --> Provider + Provider -->|tool calls| Main + Main -->|callTool + handle| MCP + + Main --> Events["src/ag-ui.js\nAG-UI-compatible run events"] + Main --> OQL["src/oql.js\nread-only Calcite SELECT guard"] + Main --> A2UI["src/a2ui.js\nresponse parsing + rendering"] + A2UI --> Visuals["summary cards, charts, tables,\nreference graphs, markdown"] + Visuals --> UI + + Build["build.mjs"] --> Dist["dist/"] + UI -. source files .-> Build + Dist -. served locally on :4173 .-> UI + + classDef app fill:#1f6feb,color:#fff,stroke:#58a6ff; + classDef service fill:#238636,color:#fff,stroke:#3fb950; + classDef model fill:#9e6a03,color:#fff,stroke:#d29922; + classDef data fill:#6e40c9,color:#fff,stroke:#bc8cff; + class UI,Main,Agent,Events,OQL,A2UI,Build,Dist app; + class MCP,Heap service; + class Provider,Ollama,OpenRouter model; + class Dump,Visuals data; +``` + +## Analysis run flow + +```mermaid +sequenceDiagram + actor User + participant UI as main.js + participant Model as Ollama or OpenRouter + participant MCP as java-heap-mcp HTTP API + participant Render as a2ui.js + + User->>UI: Ask about selected project + UI->>MCP: GET /api/projects + UI->>MCP: POST /api/open-project (when needed) + UI->>Model: chat request with tool definitions + Model-->>UI: tool call (overview, histogram, OQL, ...) + UI->>UI: validate OQL when tool is heap_run_oql + UI->>MCP: POST /api/{operation} with active handle + MCP-->>UI: JSON analysis result + UI->>Model: append tool result and continue run + Model-->>UI: JSON message with components + UI->>Render: parse, hydrate, and render A2UI components + Render-->>User: cards, charts, tables, or object graph +``` + +## Project dependencies and integrations + +| Dependency or platform API | Why it is used | Value to the project | +| --- | --- | --- | +| [Node.js](https://nodejs.org/) built-in `fs/promises`, `http`, `path`, and `url` modules | Runs [`build.mjs`](../build.mjs), copies source files into `dist/`, and serves the app during local development. | Keeps the build and development server small, dependency-free, and easy to run. | +| Browser Fetch API | Calls the `java-heap-mcp` HTTP endpoints and the configured model endpoint. | Provides the network layer for project selection, heap analysis, and agent requests without a frontend framework. | +| Browser Web Storage API (`localStorage`) | Stores provider, model, endpoint, and connection settings. | Preserves local workbench configuration between browser sessions. | +| Browser DOM, SVG, Canvas, and `crypto.randomUUID()` APIs | Builds the interface, renders charts/reference graphs, and identifies AG-UI-compatible runs. | Provides the UI, visual analysis output, and event tracking without a UI framework or chart library. | +| [`java-heap-mcp`](../../java-heap-mcp/) HTTP API | Supplies project handles, heap loading state, overviews, histograms, dominators, OQL, object inspection, GC-root paths, and leak suspects. | Keeps heap parsing and analysis in the Java service while the workbench remains a lightweight client. | +| [Ollama](https://ollama.com/) integration | Optionally sends chat/tool requests to a locally hosted model through `/api/chat`. | Supports private, local agent-assisted heap investigations. | +| [OpenRouter](https://openrouter.ai/) integration | Optionally sends OpenAI-compatible chat/tool requests to `/chat/completions`. | Allows the same workbench flow to use hosted models selected by the user. | + +## Internal modules + +The workbench’s reusable behavior is implemented in its own small ES modules rather than imported packages: + +| Module | Why it is used | Value to the project | +| --- | --- | --- | +| [`src/main.js`](../src/main.js) | Coordinates UI state, model calls, MCP calls, and analysis runs. | Connects the user workflow end to end. | +| [`src/oql.js`](../src/oql.js) | Performs a lightweight read-only Calcite `SELECT` validation before requests leave the browser. | Prevents common invalid or mutating query requests. | +| [`src/ag-ui.js`](../src/ag-ui.js) | Emits AG-UI-compatible run lifecycle and tool events. | Gives the UI a transport-neutral progress/event model. | +| [`src/a2ui.js`](../src/a2ui.js) | Parses model responses and renders summaries, charts, tables, and graphs. | Turns tool results into visual reports without a component framework. | + +Ollama and OpenRouter are service integrations rather than npm dependencies; the workbench only uses their HTTP APIs. MAT, Calcite, and the Java dependency set belong to `java-heap-mcp`, not to the browser bundle. diff --git a/java-heap-workbench/index.html b/java-heap-workbench/index.html new file mode 100644 index 0000000..7e198b4 --- /dev/null +++ b/java-heap-workbench/index.html @@ -0,0 +1,14 @@ + + + + + + + Java Heap Workbench + + + +
+ + + diff --git a/java-heap-workbench/package-lock.json b/java-heap-workbench/package-lock.json new file mode 100644 index 0000000..82d72af --- /dev/null +++ b/java-heap-workbench/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "java-heap-workbench", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "java-heap-workbench", + "version": "0.1.0" + } + } +} diff --git a/java-heap-workbench/package.json b/java-heap-workbench/package.json new file mode 100644 index 0000000..cf8c941 --- /dev/null +++ b/java-heap-workbench/package.json @@ -0,0 +1,11 @@ +{ + "name": "java-heap-workbench", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build": "node build.mjs", + "dev": "node build.mjs --serve", + "test": "node --test test/*.test.mjs" + } +} diff --git a/java-heap-workbench/src/a2ui.js b/java-heap-workbench/src/a2ui.js new file mode 100644 index 0000000..a465efa --- /dev/null +++ b/java-heap-workbench/src/a2ui.js @@ -0,0 +1,197 @@ +// A2UI is represented as declarative components. Agents return {components: []} +// and this renderer turns those components into safe DOM, charts included. +const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +const number = value => Number(value || 0).toLocaleString(); +let graphMarkerSequence = 0; +const displayValue = value => { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') { + try { return JSON.stringify(value); } catch { return String(value); } + } + return String(value); +}; +const uniqueComponents = components => { + const seen = new Set(); + return (components || []).filter(component => { + const key = JSON.stringify(component); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +export function renderMarkdown(value) { + const lines = String(value ?? '').split('\n'); + let html = '', list = null; + const inline = renderInlineMarkdown; + const closeList = () => { if (list) { html += ``; list = null; } }; + for (const rawLine of lines) { + const line = inline(rawLine.trim()); + const heading = line.match(/^(#{1,6})\s+(.+)$/); + const ordered = line.match(/^\d+[.)]\s+(.+)$/); + const unordered = line.match(/^[-*+]\s+(.+)$/); + if (!line) { closeList(); html += '
'; continue; } + if (heading) { closeList(); const level = heading[1].length; html += `${heading[2]}`; continue; } + if (ordered || unordered) { const nextList = ordered ? 'ol' : 'ul'; if (list !== nextList) { closeList(); list = nextList; html += `<${list}>`; } html += `
  • ${(ordered || unordered)[1]}
  • `; continue; } + closeList(); html += `

    ${line}

    `; + } + closeList(); + return html; +} + +export function renderInlineMarkdown(value) { + const source = String(value ?? '').replace(/\\([\\`*_])/g, '$1'); + const escaped = esc(source); + return escaped.replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/_([^_]+)_/g, '$1'); +} + +export function normalizeAgentResponse(payload) { + if (payload?.components) return { ...payload, components: uniqueComponents(payload.components) }; + return { components: [{ type: 'summary', title: 'Agent response', value: payload?.text || 'No visual response returned.', tone: 'neutral' }] }; +} + +const suggestedActions = [ + { label: 'Overall Memory Usage', prompt: 'Get a general overview of heap sizes and object counts.' }, + { label: 'Top Memory Consumers', prompt: 'Identify the top memory-consuming classes by retained size, shallow size, and object count.' }, + { label: 'Dominant Objects', prompt: 'Find the dominant objects or object types retaining the most heap memory.' }, + { label: 'Leak Detection', prompt: 'Run leak suspect analysis and summarize the evidence.' }, + { label: 'Deep Inspection', prompt: 'Perform a deep heap inspection using OQL and inspect relevant objects and paths to GC roots.' } +]; + +export function extractSuggestedActions(text, supplied = []) { + const source = String(text || ''); + const actions = supplied.length ? supplied : suggestedActions.filter(action => new RegExp(action.label.replace(/\s+/g, '\\s+'), 'i').test(source)); + return actions.map(action => ({ label: action.label, prompt: action.prompt || action.action || action.query })).filter(action => action.label && action.prompt); +} + +export function parseAgentContent(content) { + const source = String(content || '').trim(); + const fenced = source.match(/```(?:json)?\s*([\s\S]*?)```/i); + const objectStart = source.indexOf('{'); + const objectEnd = source.lastIndexOf('}'); + const candidate = fenced ? fenced[1].trim() : objectStart >= 0 && objectEnd > objectStart ? source.slice(objectStart, objectEnd + 1) : ''; + if (candidate.startsWith('{')) { + try { + const payload = JSON.parse(candidate); + if (payload && typeof payload === 'object' && ('components' in payload || 'message' in payload || 'text' in payload)) { + const text = payload.message || payload.text || ''; + return { view: { ...payload, components: Array.isArray(payload.components) ? payload.components : [] }, text, actions: extractSuggestedActions(text, payload.actions || []) }; + } + } catch { /* Fall through to markdown/plain text rendering. */ } + } + if (fenced || candidate) return { view: { components: [] }, text: 'Analysis complete.', actions: [] }; + const lines = source.split('\n').map(line => line.trim()).filter(Boolean); + const tableLines = lines.filter(line => line.startsWith('|') && line.endsWith('|')); + if (tableLines.length >= 3) { + const cells = line => line.slice(1, -1).split('|').map(value => value.trim()); + const columns = cells(tableLines[0]); + const rows = tableLines.slice(2).map(line => cells(line)).filter(row => row.length === columns.length).map(row => Object.fromEntries(columns.map((column, index) => [column, row[index]]))); + if (rows.length) return { view: { components: [{ type: 'table', title: 'Agent results', columns, rows }] }, text: lines.filter(line => !tableLines.includes(line)).join('\n') }; + } + return { view: { components: [{ type: 'markdown', text: source || 'Analysis complete.' }] }, text: source, actions: extractSuggestedActions(source) }; +} + +export function renderA2UI(target, payload) { + if (target.dataset.busy === 'true') return; + const view = normalizeAgentResponse(payload); + target.innerHTML = (view.components || []).map(component => renderComponent(component)).join(''); + target.querySelectorAll('[data-chart]').forEach(node => drawChart(node, JSON.parse(node.dataset.chart))); + target.querySelectorAll('[data-graph]').forEach(node => drawGraph(node, JSON.parse(node.dataset.graph))); +} + +export function hydrateVisualData(view, fallback) { + const primary = normalizeAgentResponse(view); + const backup = normalizeAgentResponse(fallback); + const fallbackTable = backup.components.find(component => component.type === 'table' && rowsFor(component).length); + const fallbackChart = backup.components.find(component => component.type === 'chart' && chartDataFor(component).length); + const fallbackGraph = backup.components.find(component => component.type === 'graph' && component.nodes?.length); + const components = primary.components.map(component => { + if (component.type === 'table' && fallbackTable) { + const rows = rowsFor(component); + const columnsMatchRows = component.columns?.length && rows.some(row => component.columns.some(column => Object.prototype.hasOwnProperty.call(row, column))); + if (!rows.length || !columnsMatchRows) return { ...component, columns: fallbackTable.columns, rows: rowsFor(fallbackTable) }; + } + if (component.type === 'chart' && !chartDataFor(component).length && fallbackChart) { + return { ...component, data: chartDataFor(fallbackChart) }; + } + if (component.type === 'graph' && !component.nodes?.length && fallbackGraph) return fallbackGraph; + return component; + }); + if (fallbackGraph && !components.some(component => component.type === 'graph')) components.push(fallbackGraph); + return { ...primary, components: components.length ? components : backup.components }; +} + +const rowsFor = component => { + const rows = component?.rows ?? component?.data ?? component?.entries ?? component?.results ?? []; + return Array.isArray(rows) ? rows : Array.isArray(rows.rows) ? rows.rows : []; +}; +const chartDataFor = component => { + const data = component?.data ?? component?.points ?? component?.values ?? component?.rows ?? []; + return Array.isArray(data) ? data : Array.isArray(data.rows) ? data.rows : []; +}; + +function renderComponent(c) { + if (c.type === 'summary') return `
    ${esc(c.title)}${esc(c.value)}${esc(c.detail || '')}
    `; + if (c.type === 'markdown') return `
    ${renderMarkdown(c.text)}
    `; + if (c.type === 'table') { + const rows = rowsFor(c); + const cols = c.columns || Object.keys(rows[0] || {}); + const renderedRows = rows.map(row => { + const objectId = row?.objectId; + const actionable = objectId !== undefined && objectId !== null && objectId !== ''; + const attributes = actionable ? ` data-object-id="${esc(objectId)}" data-class-name="${esc(row.className || row.displayName || '')}" tabindex="0" role="button"` : ''; + return `${cols.map(x => `${renderInlineMarkdown(displayValue(row[x]))}`).join('')}`; + }).join(''); + return `

    ${esc(c.title || 'Results')}

    ${number(rows.length)} rows
    ${cols.map(x => ``).join('')}${renderedRows}
    ${esc(x)}
    `; + } + if (c.type === 'chart') return `

    ${esc(c.title || 'Chart')}

    ${esc(c.subtitle || '')}
    `; + if (c.type === 'graph') return `

    ${esc(c.title || 'Object references')}

    ${number((c.nodes || []).length)} objects
    `; + return ''; +} + +function drawChart(node, chart) { + const points = chartDataFor(chart).map(x => ({ label: x.label ?? x.name ?? x.className ?? x.displayName ?? '', value: Number(x.value ?? x.retainedHeapBytes ?? x.retainedBytes ?? x.retainedHeap ?? x.shallowHeapBytes ?? x.count ?? x.objectCount ?? 0) })).slice(0, 12); + if (!points.length) { node.innerHTML = '
    No chart data
    '; return; } + const max = Math.max(...points.map(x => x.value), 1), width = 720, height = 230, pad = 38; + if (chart.chartType === 'pie') { + const total = points.reduce((a, x) => a + Math.max(0, x.value), 0); + if (!total) { node.innerHTML = '
    No positive chart values
    '; return; } + let angle = -Math.PI / 2; + const colors = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#39c5cf']; + const paths = points.map((p, i) => { const value = Math.max(0, p.value); const next = angle + value / total * Math.PI * 2; const large = next - angle > Math.PI ? 1 : 0; const arc = `${Math.cos(angle) * 72 + 120},${Math.sin(angle) * 72 + 120} ${Math.cos(next) * 72 + 120},${Math.sin(next) * 72 + 120}`; const d = `M120,120 L${Math.cos(angle) * 72 + 120},${Math.sin(angle) * 72 + 120} A72,72 0 ${large} 1 ${arc.split(' ')[1]} Z`; angle = next; return `${esc(p.label)} · ${Math.round(value / total * 100)}%`; }).join(''); + node.innerHTML = `${paths}`; return; + } + if (chart.chartType === 'line') { + const pointsPath = points.map((p, i) => `${pad + i * ((width - pad * 2) / Math.max(points.length - 1, 1))},${height - pad - p.value / max * (height - pad * 2)}`).join(' '); + const dots = points.map((p, i) => { const x = pad + i * ((width - pad * 2) / Math.max(points.length - 1, 1)), y = height - pad - p.value / max * (height - pad * 2); return `${esc(p.label)}: ${number(p.value)}${esc(p.label).slice(0, 12)}`; }).join(''); + node.innerHTML = `${dots}`; return; + } + const rowHeight = 28, chartHeight = Math.max(230, points.length * rowHeight + 18), labelWidth = 210, plotWidth = width - labelWidth - 28; + const bars = points.map((p, i) => { const y = 9 + i * rowHeight, barW = p.value / max * plotWidth, label = String(p.label).length > 28 ? `${String(p.label).slice(0, 27)}…` : p.label; return `${esc(label)}${esc(p.label)}: ${number(p.value)}${number(p.value)}`; }).join(''); + node.innerHTML = `${bars}`; +} + +function drawGraph(node, graph) { + const sourceNodes = Array.isArray(graph.nodes) ? graph.nodes : []; + const sourceEdges = Array.isArray(graph.edges) ? graph.edges : []; + if (!sourceNodes.length) { node.innerHTML = '
    No object references
    '; return; } + const nodes = sourceNodes.slice(0, 30).map((item, index) => ({ id: String(item.id ?? item.objectId ?? index), label: item.label ?? item.className ?? item.displayName ?? item.id ?? 'object', objectId: item.objectId ?? (Number.isFinite(Number(item.id)) ? item.id : null), level: item.level })); + const nodeIds = new Set(nodes.map(item => item.id)); + const edges = sourceEdges.filter(edge => nodeIds.has(String(edge.source)) && nodeIds.has(String(edge.target))).slice(0, 60).map(edge => ({ source: String(edge.source), target: String(edge.target), label: edge.label || '' })); + const root = nodes.find(item => item.level === 0) || nodes[0]; + const levels = new Map([[root.id, 0]]); + for (let pass = 0; pass < nodes.length; pass += 1) edges.forEach(edge => { if (levels.has(edge.source) && !levels.has(edge.target)) levels.set(edge.target, Math.min(3, levels.get(edge.source) + 1)); }); + nodes.forEach(item => { if (!levels.has(item.id)) levels.set(item.id, Math.min(3, item.level ?? 1)); }); + const grouped = new Map(); nodes.forEach(item => { const level = levels.get(item.id); if (!grouped.has(level)) grouped.set(level, []); grouped.get(level).push(item); }); + const width = 900, columnWidth = 210, nodeWidth = 170, nodeHeight = 42, rowGap = 18; + const height = Math.max(190, ...Array.from(grouped.values(), group => group.length * (nodeHeight + rowGap) + 30)); + const positions = new Map(); grouped.forEach((group, level) => { const total = group.length * (nodeHeight + rowGap) - rowGap; const start = Math.max(15, (height - total) / 2); group.forEach((item, index) => positions.set(item.id, { x: 18 + level * columnWidth, y: start + index * (nodeHeight + rowGap) })); }); + const markerId = `graph-arrow-${++graphMarkerSequence}`; + const lines = edges.map(edge => { const from = positions.get(edge.source), to = positions.get(edge.target); if (!from || !to) return ''; const x1 = from.x + nodeWidth, y1 = from.y + nodeHeight / 2, x2 = to.x, y2 = to.y + nodeHeight / 2; return `${esc(edge.label)}`; }).join(''); + const boxes = nodes.map(item => { const position = positions.get(item.id), label = String(item.label).length > 24 ? `${String(item.label).slice(0, 23)}…` : item.label; const action = item.objectId !== null && item.objectId !== undefined ? ` data-object-id="${esc(item.objectId)}" data-class-name="${esc(item.label)}" tabindex="0" role="button"` : ''; return `${esc(label)}#${esc(item.objectId ?? item.id)}`; }).join(''); + node.innerHTML = `${lines}${boxes}`; +} diff --git a/java-heap-workbench/src/ag-ui.js b/java-heap-workbench/src/ag-ui.js new file mode 100644 index 0000000..dd70967 --- /dev/null +++ b/java-heap-workbench/src/ag-ui.js @@ -0,0 +1,15 @@ +// Small AG-UI-compatible event bus. The UI can later swap this for an AG-UI +// transport without changing its A2UI rendering or MCP orchestration layers. +export const AG_EVENTS = Object.freeze({ RUN_STARTED: 'RUN_STARTED', TEXT_MESSAGE_CONTENT: 'TEXT_MESSAGE_CONTENT', TOOL_CALL_START: 'TOOL_CALL_START', TOOL_CALL_END: 'TOOL_CALL_END', STATE_SNAPSHOT: 'STATE_SNAPSHOT', RUN_FINISHED: 'RUN_FINISHED', RUN_ERROR: 'RUN_ERROR' }); + +export class AgUiRun { + constructor(onEvent) { this.onEvent = onEvent; this.id = crypto.randomUUID(); } + emit(type, data = {}) { this.onEvent({ type, runId: this.id, timestamp: Date.now(), ...data }); } + start(input) { this.emit(AG_EVENTS.RUN_STARTED, { input }); } + text(content) { this.emit(AG_EVENTS.TEXT_MESSAGE_CONTENT, { content }); } + toolStart(name, args) { this.emit(AG_EVENTS.TOOL_CALL_START, { name, args }); } + toolEnd(name, result) { this.emit(AG_EVENTS.TOOL_CALL_END, { name, result }); } + state(snapshot) { this.emit(AG_EVENTS.STATE_SNAPSHOT, { snapshot }); } + finish() { this.emit(AG_EVENTS.RUN_FINISHED); } + error(message) { this.emit(AG_EVENTS.RUN_ERROR, { message }); } +} diff --git a/java-heap-workbench/src/agent-prompt.js b/java-heap-workbench/src/agent-prompt.js new file mode 100644 index 0000000..f2b46e6 --- /dev/null +++ b/java-heap-workbench/src/agent-prompt.js @@ -0,0 +1,28 @@ +import { OQL_AGENT_RULES } from './oql.js'; + +export const AGENT_SYSTEM_PROMPT = `You are Agent, a precise Java heap analyst working against the currently selected heap handle. + +Available MCP tools: +- heap_get_overview: returns snapshot metadata plus topClasses and topDominators. +- heap_get_histogram: returns class rows with className, objectCount, shallowHeapBytes, and retainedHeapBytes. Use sort=retained, shallow, or count. +- heap_get_dominators: returns object rows with objectId, className, displayName, shallowHeapBytes, and retainedHeapBytes. +- heap_find_leak_suspects: returns MAT leak-suspect evidence when available. +- heap_run_oql: executes only a read-only Apache Calcite SQL SELECT statement against the heap with a separate result limit and offset; the handle is supplied automatically. OQL must use Calcite SQL syntax. +- heap_get_oql_grammar: returns the authoritative Calcite SQL grammar, heap schema, functions, and examples for heap_run_oql; it does not require a heap handle. +- heap_inspect_object: accepts objectId and returns fields, inbound/outbound references, sizes, class, and GC-root information. +- heap_find_path_to_gc_roots: accepts objectId and returns reference paths to GC roots. + +Investigation rules: +1. Use the tools. Do not claim that a query or analysis succeeded unless a tool returned a result. +2. Before constructing or calling heap_run_oql, always call heap_get_oql_grammar and follow its returned schema, function signatures, quoting rules, and examples. Do not rely only on this prompt's examples. +3. For questions about a class's attributes, first use Calcite OQL to select representative objects, then inspect an object when field values or references are needed. Use SELECT s.this FROM fully.qualified.ClassName s and the exact fully qualified class name supplied by the user. +4. For unique or common String values, use the Calcite function form toString(s.this), never the Java-like method form s.toString(s.this). When asked for commonly used or most common String values, use this example query: SELECT toString(s.this) AS val, count(*) AS cnt FROM "java.lang.String" s GROUP BY toString(s.this) ORDER BY COUNT(*) DESC. For unique values, use SELECT DISTINCT toString(s.this) AS unique_value. If a field is unknown, inspect representative objects or use this['fieldName'] before inventing a field name. +5. For a directed object-reference graph of the largest dominator, first call heap_get_dominators, choose the returned row with the largest retained heap and its objectId, then call heap_inspect_object for that objectId. Build graph nodes only from the inspected root and its returned outboundReferences, and build directed edges from each actual reference objectId. Do not call heap_find_path_to_gc_roots as a substitute for outboundReferences unless the user explicitly asks for a GC-root path; GC-root paths are not object-reference graphs. +6. Use heap_get_overview, heap_get_histogram, and heap_get_dominators for broad retention questions before using OQL. Use inspect/path tools when a specific object is identified. +7. If a tool returns an error or empty result, explain that limitation and try a grounded alternative. Never fabricate field names, values, or successful execution. +8. If heap_run_oql fails, do not stop. Re-evaluate it against the grammar returned by heap_get_oql_grammar and retry up to three times with simpler Calcite SQL semantics: reduce the selected columns, remove optional clauses or functions, verify table/field names and aliases, and preserve only the filtering, grouping, or ordering needed for the question. Do not repeat the same failed query. The workbench displays retry status updates while you reconsider the query. +9. If all OQL attempts fail, stop trying to guess. Return a simple follow-up question stating that you are having issues correctly determining the data and need more information, such as the target class, field, relationship, or desired filter. + +${OQL_AGENT_RULES} + +After tools finish, return ONLY valid JSON: {"message":"brief evidence-based finding","components":[...]}. Use these exact A2UI shapes: a bar or pie chart is {"type":"chart","chartType":"bar|pie","title":"...","data":[{"label":"...","value":123}]}; a directed object graph is {"type":"graph","title":"...","nodes":[{"id":"123","objectId":123,"label":"Class or object"}],"edges":[{"source":"123","target":"456","label":"field or reference"}]}. Graph nodes and edges must be top-level graph properties, not chart data or table rows; every edge source and target must match a node id. Use actual tool results only. Never return an empty rows/data/nodes/edges array when tool data exists. Do not return markdown tables.`; diff --git a/java-heap-workbench/src/logo.svg b/java-heap-workbench/src/logo.svg new file mode 100644 index 0000000..87126d6 --- /dev/null +++ b/java-heap-workbench/src/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/java-heap-workbench/src/main.js b/java-heap-workbench/src/main.js new file mode 100644 index 0000000..1c6fb7e --- /dev/null +++ b/java-heap-workbench/src/main.js @@ -0,0 +1,280 @@ +import { AgUiRun } from './ag-ui.js'; +import { extractSuggestedActions, hydrateVisualData, parseAgentContent, renderMarkdown, renderA2UI } from './a2ui.js'; +import { validateOql } from './oql.js'; +import { AGENT_SYSTEM_PROMPT } from './agent-prompt.js'; +import { planToolVisuals } from './visualization-policy.js'; + +const savedModel = localStorage.getItem('heap.model'); + +const defaultModel = 'qwen3.5:9b'; // 'nemotron-3-nano:4b'; + +const OPENROUTER_MODELS = [ + ['cohere/north-mini-code:free', 'Cohere North Mini Code (free)'], + ['nvidia/nemotron-3.5-lightning:free', 'NVIDIA Nemotron 3.5 Lightning (free)'], + ['qwen/qwen3-coder:free', 'Qwen3 Coder (free)'], + ['openrouter/free', 'OpenRouter Free Models Router'] +]; +const state = { provider: localStorage.getItem('heap.provider') || 'ollama', mcp: localStorage.getItem('heap.mcp') || 'http://127.0.0.1:7777', ollama: localStorage.getItem('heap.ollama') || 'http://127.0.0.1:11434', openrouter: localStorage.getItem('heap.openrouter') || 'https://openrouter.ai/api/v1', openrouterKey: localStorage.getItem('heap.openrouterKey') || '', model: savedModel && savedModel !== 'qwen2.5:7b' ? savedModel : defaultModel, projects: [], project: null, handle: null, running: false, startedAt: 0, timer: null }; +const $ = (selector, root = document) => root.querySelector(selector); +const app = $('#app'); +app.innerHTML = `
    HEAP EXPLORATION

    Analysis workspace

    PROJECT / no heap loaded

    See what your heap is holding.

    Ask your local agent to investigate memory behavior and turn findings into a visual report.

    Heap signals

    Load a heap dump in the MCP server, then select its project.

    Quick investigations

    Ask the agent
    Agent
    Ask a question about the selected heap to begin.

    Run details

    idle
    AG-UI events will appear here.

    Heap files are loaded by java-heap-mcp
    Use its existing web UI or MCP client.
    `; +const openrouterModel = $('#openrouter-model'); +const modelControl = document.createElement('span'); +modelControl.className = 'field-control'; +const freeModelsLink = document.createElement('a'); +freeModelsLink.href = 'https://openrouter.ai/models?variant=free&output_modalities=text&order=most-popular'; +freeModelsLink.target = '_blank'; +freeModelsLink.rel = 'noopener noreferrer'; +freeModelsLink.textContent = 'Discover Free Models'; +openrouterModel.parentNode.insertBefore(modelControl, openrouterModel); +modelControl.append(openrouterModel, freeModelsLink); +$('#provider').value = state.provider; +const updateProviderFields = () => { const openrouter = $('#provider').value === 'openrouter'; const modelField = openrouter ? $('#openrouter-model') : $('#ollama-model'); $('#ollama-setting').hidden = openrouter; $('#ollama-model-setting').hidden = openrouter; $('#openrouter-setting').hidden = !openrouter; $('#key-setting').hidden = !openrouter; $('#openrouter-model-setting').hidden = !openrouter; const models = openrouter ? OPENROUTER_MODELS : [[defaultModel, 'Nemotron 3 Nano (local)']]; $('#model-options').innerHTML = models.map(([value, label]) => ``).join(''); if (!models.some(([value]) => value === modelField.value)) modelField.value = models[0][0]; }; +updateProviderFields(); +const visualPanel = $('#visuals'); +visualPanel.innerHTML = '

    Visual analysis

    Ask the Agent to investigate the selected heap. Charts, tables, and summary cards will appear here.
    '; +const quickPanel = document.querySelector('.dashboard-grid > .panel:nth-child(2)'); +quickPanel.classList.add('chat-prompts'); +document.querySelector('.chat-panel').insertBefore(quickPanel, $('#messages')); + +const api = async (path, method = 'GET', body) => { const response = await fetch(`${state.mcp}${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body && JSON.stringify(body) }); const data = await response.json(); if (!response.ok || data.error) throw new Error([data.error || `MCP request failed (${response.status})`, data.errorCode, data.cause].filter(Boolean).join(' — ')); return data; }; +const visualState = { nextId: 0, entries: new Map(), observer: null }; +const rowsFromResult = result => { + if (Array.isArray(result)) return result; + const candidates = [result, result?.structuredContent, result?.result]; + for (const candidate of candidates) { + for (const key of ['rows', 'entries', 'results', 'data']) { + if (Array.isArray(candidate?.[key])) return candidate[key]; + } + } + return []; +}; +const resultValue = result => result?.structuredContent || result?.result || result || {}; +const columnsFromResult = (result, rows) => { + const value = resultValue(result); + return Array.isArray(value.columns) && value.columns.length ? value.columns : Object.keys(rows[0] || {}); +}; +const showVisualLoading = () => { $('#visual-output').dataset.busy = 'true'; $('#visual-live').innerHTML = ''; $('#visual-live').hidden = false; }; +const previousVisualId = node => { let previous = node.previousElementSibling; while (previous) { if (previous.dataset.visualId) return previous.dataset.visualId; previous = previous.previousElementSibling; } return null; }; +const activateVisual = id => { if (!id || !visualState.entries.has(id)) return; visualState.entries.forEach((entry, entryId) => entry.classList.toggle('active', entryId === id)); document.querySelectorAll('.message[data-visual-id], .message[data-visual-target]').forEach(node => node.classList.toggle('visual-selected', node.dataset.visualId === id || node.dataset.visualTarget === id)); }; +const bindResponse = node => { node.tabIndex = 0; node.setAttribute('role', 'button'); const select = () => activateVisual(node.dataset.visualId || previousVisualId(node)); node.onclick = select; node.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(); } }; }; +const runSuggestedAction = prompt => runAgent(prompt).catch(error => { removeThinking(); addMessage(`Error: ${error.message}`); log(error.message); endRun('Failed', 'error'); }); +const addMessage = (text, kind = '', actions = []) => { const node = document.createElement('div'); node.className = `message ${kind}`; node.innerHTML = `${kind === 'user' ? 'you' : 'Agent'}${renderMarkdown(text)}`; if (kind !== 'user' && actions.length) { const actionBar = document.createElement('div'); actionBar.className = 'response-actions'; actions.forEach(action => { const button = document.createElement('button'); button.className = 'prompt'; button.type = 'button'; button.textContent = action.label; button.onclick = () => { if (!state.running) runSuggestedAction(action.prompt); }; actionBar.append(button); }); node.append(actionBar); } $('#messages').append(node); if (kind !== 'user') bindResponse(node); node.scrollIntoView({ block: 'nearest' }); return node; }; +const commitVisual = responseNode => { const live = $('#visual-live'); if (!live || !live.innerHTML.trim()) return; const id = `visual-${++visualState.nextId}`; const entry = document.createElement('section'); entry.className = 'visual-entry'; entry.dataset.visualId = id; entry.innerHTML = live.innerHTML; $('#visual-history').append(entry); visualState.entries.set(id, entry); responseNode.dataset.visualId = id; responseNode.classList.add('has-visual'); live.hidden = true; activateVisual(id); }; +const log = text => { $('#event-log').textContent = text; }; +const setRunProgress = (label, mode = 'working') => { $('#run-progress').hidden = false; $('#run-progress').dataset.mode = mode; $('#run-progress-label').textContent = label; $('#run-state').textContent = label.toLowerCase(); }; +const updateThinking = label => { const status = $('#thinking-status'); if (status) status.textContent = label; }; +const beginRun = () => { state.running = true; state.startedAt = Date.now(); $('#send').disabled = true; $('#send').textContent = 'Working…'; $('#prompt').disabled = true; document.querySelectorAll('.prompt').forEach(button => { button.disabled = true; }); showVisualLoading(); setRunProgress('Thinking…'); state.timer = setInterval(() => { $('#run-elapsed').textContent = `${Math.floor((Date.now() - state.startedAt) / 1000)}s`; }, 250); }; +const endRun = (label, mode = 'complete') => { state.running = false; clearInterval(state.timer); state.timer = null; $('#send').disabled = false; $('#send').textContent = 'Run'; $('#prompt').disabled = false; document.querySelectorAll('.prompt').forEach(button => { button.disabled = false; }); setRunProgress(label, mode); setTimeout(() => { if (!state.running) $('#run-progress').hidden = true; }, 1800); }; +const removeThinking = () => { const node = $('#thinking-message'); if (node) node.remove(); $('#visual-output').dataset.busy = 'false'; }; +const addThinking = () => { removeThinking(); const node = document.createElement('div'); node.id = 'thinking-message'; node.className = 'message thinking'; node.innerHTML = 'Agent
    Planning the investigation…'; $('#messages').append(node); node.scrollIntoView({ block: 'nearest' }); }; +visualState.observer = new IntersectionObserver(entries => { + const visible = entries.filter(entry => entry.isIntersecting && entry.target.dataset.visualId) + .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; + if (visible && !state.running) activateVisual(visible.target.dataset.visualId); +}); + +async function refreshProjects() { try { state.projects = await api('/api/projects'); const select = $('#project-select'); select.innerHTML = ''; state.projects.forEach(p => { const option = new Option(`${p.project}${p.activeHandle ? '' : ' (cached)'}`, p.project); select.add(option); }); const active = state.projects.find(p => p.activeHandle); if (active) { select.value = active.project; selectProject(active.project); } $('#mcp-status').textContent = 'MCP connected'; } catch (error) { $('#mcp-status').textContent = 'MCP offline'; log(error.message); } } +async function selectProject(name) { const project = state.projects.find(p => p.project === name); if (!project) return; try { const opened = project.activeHandle ? project : await api('/api/open-project', 'POST', { project: name }); state.project = opened.project; state.handle = opened.handle || opened.activeHandle; $('#project-name').textContent = state.project; await loadOverview(); } catch (error) { log(error.message); } } +async function loadOverview() { const overview = await api('/api/overview', 'POST', { handle: state.handle, limit: 12 }); const values = overview?.snapshot || {}; $('#metrics').innerHTML = [['used heap', values.usedHeapSize, 'blue'], ['objects', values.numberOfObjects, 'green'], ['classes', values.numberOfClasses, 'yellow'], ['class loaders', values.numberOfClassLoaders, 'red']].map(([label, value, tone]) => `
    ${label}${Number(value || 0).toLocaleString()}from current MAT snapshot
    `).join(''); } + +const toolDefinitions = [ + { type: 'function', function: { name: 'heap_get_overview', description: 'Get heap size, object, class, and GC overview.', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'heap_get_oql_grammar', description: 'Get the supported Apache Calcite SQL grammar, heap schema, functions, and examples before constructing an OQL query.', parameters: { type: 'object', properties: {} } } }, + { type: 'function', function: { name: 'heap_get_histogram', description: 'Get classes ranked by heap usage.', parameters: { type: 'object', properties: { sort: { type: 'string' }, limit: { type: 'integer' } } } } }, + { type: 'function', function: { name: 'heap_get_dominators', description: 'Get objects/classes ranked by retained heap.', parameters: { type: 'object', properties: { limit: { type: 'integer' } } } } }, + { type: 'function', function: { name: 'heap_find_leak_suspects', description: 'Find suspected memory leaks.', parameters: { type: 'object', properties: { limit: { type: 'integer' } } } } }, + { type: 'function', function: { name: 'heap_run_oql', description: 'Run only a read-only Apache Calcite SQL SELECT statement (the OQL language) against the selected heap. Use SQL joins, grouping, aggregates, ordering, and pagination.', parameters: { type: 'object', required: ['query'], properties: { query: { type: 'string' }, limit: { type: 'integer' } } } } }, + { type: 'function', function: { name: 'heap_inspect_object', description: 'Inspect a heap object returned by OQL, including its class, fields, sizes, and references.', parameters: { type: 'object', required: ['objectId'], properties: { objectId: { type: 'integer' }, limit: { type: 'integer' } } } } }, + { type: 'function', function: { name: 'heap_find_path_to_gc_roots', description: 'Find reference paths from an object to GC roots.', parameters: { type: 'object', required: ['objectId'], properties: { objectId: { type: 'integer' }, excludeWeakRefs: { type: 'boolean' }, limit: { type: 'integer' } } } } } +]; +async function callTool(name, args) { args = typeof args === 'string' ? JSON.parse(args) : (args || {}); if (name === 'heap_get_oql_grammar') return api('/api/oql-grammar'); if (name === 'heap_run_oql') { console.info('[java-heap-workbench] Generated heap query:', args.query); const validationError = validateOql(args.query); if (validationError) return { error: 'OQL_VALIDATION_ERROR', message: validationError, query: args.query, correction: 'Use one read-only SELECT query. Prefer Apache Calcite SQL for JOIN/GROUP BY/HAVING/COUNT/SUM/ORDER BY/LIMIT/OFFSET and use MAT OQL for MAT-native expressions; do not add a semicolon.' }; } const routes = { heap_get_overview: ['/api/overview', {}], heap_get_histogram: ['/api/histogram', { sort: args.sort || 'retained', limit: args.limit || 10 }], heap_get_dominators: ['/api/dominators', { limit: args.limit || 10 }], heap_find_leak_suspects: ['/api/leaks', { limit: args.limit || 10 }], heap_run_oql: ['/api/oql', { query: args.query, limit: args.limit || 25, offset: 0 }], heap_inspect_object: ['/api/inspect', { objectId: args.objectId, limit: args.limit || 25 }], heap_find_path_to_gc_roots: ['/api/gc-roots', { objectId: args.objectId, excludeWeakRefs: args.excludeWeakRefs !== false, limit: args.limit || 25 }] }; const route = routes[name]; if (!route) throw new Error(`Unsupported tool: ${name}`); const [path, body] = route; return api(path, 'POST', { handle: state.handle, ...body }); } +function visualFromTool(name, result) { if (name === 'heap_get_overview') { const snapshot = result.snapshot || {}, classes = result.topClasses || [], dominators = result.topDominators || []; return { components: [{ type: 'summary', title: 'Used heap', value: Number(snapshot.usedHeapSize || 0).toLocaleString(), detail: `${Number(snapshot.numberOfObjects || 0).toLocaleString()} objects`, tone: 'blue' }, { type: 'summary', title: 'Classes', value: Number(snapshot.numberOfClasses || 0).toLocaleString(), detail: `${Number(snapshot.numberOfClassLoaders || 0).toLocaleString()} class loaders`, tone: 'yellow' }, { type: 'chart', title: 'Top retained classes', chartType: 'bar', data: classes.map(x => ({ label: x.className, value: x.retainedHeapBytes || x.shallowHeapBytes || 0 })) }, { type: 'table', title: 'Top retained contributors', columns: ['className', 'objectCount', 'retainedHeapBytes'], rows: classes }, { type: 'table', title: 'Top dominators', columns: ['className', 'displayName', 'retainedHeapBytes'], rows: dominators }] }; } if (name === 'heap_get_histogram' || name === 'heap_get_dominators') { const rows = rowsFromResult(result); return { components: [{ type: 'chart', title: name === 'heap_get_histogram' ? 'Retained heap by class' : 'Top dominators', chartType: 'bar', data: rows.map(x => ({ label: x.className || x.name || x.displayName || 'object', value: x.retainedHeapBytes || x.retainedHeap || x.retainedBytes || x.usedHeap || x.shallowHeapBytes || x.shallowHeap || x.objectCount || x.count || 0 })) }, { type: 'table', title: 'Analysis rows', rows }] }; } if (name === 'heap_find_leak_suspects') { const rows = Array.isArray(result) ? result : result.suspects || result.rows || []; return { components: [{ type: 'summary', title: 'Leak suspects', value: rows.length, detail: 'candidates returned by MAT', tone: 'red' }, { type: 'table', title: 'Suspect evidence', rows }] }; } if (name === 'heap_inspect_object') { const fields = Array.isArray(result.fields) ? result.fields : result.fields?.rows || []; const references = Array.isArray(result.outboundReferences) ? result.outboundReferences : []; return { components: [{ type: 'summary', title: 'Inspected object', value: result.className || result.displayName || `#${result.objectId}`, detail: `${Number(result.retainedHeapBytes || 0).toLocaleString()} retained bytes`, tone: 'blue' }, { type: 'table', title: 'Attributes and fields', rows: fields }, { type: 'table', title: 'Outbound references', rows: references }] }; } if (name === 'heap_find_path_to_gc_roots') { const rows = Array.isArray(result) ? result : result.paths || result.rows || []; return { components: [{ type: 'summary', title: 'Paths to GC roots', value: rows.length, detail: 'reference paths returned by MAT', tone: 'yellow' }, { type: 'table', title: 'GC root paths', rows }] }; } return { components: [{ type: 'summary', title: name === 'heap_run_oql' ? 'OQL result' : 'Analysis result', value: rowsFromResult(result).length || 'ready', detail: 'tool result returned', tone: 'blue' }, { type: 'table', title: 'Results', rows: rowsFromResult(result) }] }; } + +function referenceGraph(result) { + const rootId = result?.objectId; + const references = Array.isArray(result?.outboundReferences) ? result.outboundReferences : []; + const nodes = [{ id: String(rootId), objectId: rootId, label: result.className || result.displayName || `#${rootId}`, level: 0 }]; + const edges = []; + references.slice(0, 24).forEach(reference => { + if (reference?.objectId === undefined || reference?.objectId === null) return; + const id = String(reference.objectId); + if (!nodes.some(node => node.id === id)) nodes.push({ id, objectId: reference.objectId, label: reference.className || reference.displayName || `#${id}`, level: 1 }); + edges.push({ source: String(rootId), target: id, label: reference.displayName || reference.className || 'reference' }); + }); + return { type: 'graph', title: 'Outbound object references', nodes, edges }; +} + +function appendToolVisual(name, result, toolVisuals) { + const toolView = visualFromTool(name, result); + if (name === 'heap_inspect_object') toolView.components.push(referenceGraph(result)); + toolVisuals.components.push(...toolView.components); +} + +async function runAgent(question) { + if (!state.handle) throw new Error('Select or open a loaded heap project first.'); + beginRun(); + addMessage(question, 'user'); + addThinking(); + const run = new AgUiRun(event => { + if (event.type === 'RUN_STARTED') setRunProgress('Thinking…'); + if (event.type === 'TOOL_CALL_START') setRunProgress(`Calling ${event.name}…`); + if (event.type === 'TOOL_CALL_END') setRunProgress('Reading tool result…'); + if (event.type === 'TEXT_MESSAGE_CONTENT') setRunProgress('Writing visual response…'); + }); + run.start({ question }); + const messages = [{ role: 'system', content: AGENT_SYSTEM_PROMPT }, { role: 'user', content: question }]; + let lastTool = null; + let oqlFailed = false; + let oqlSucceeded = false; + const toolVisuals = { components: [] }; + const maxTurns = 4; + const graphRequest = /\bgraph\b/i.test(question) && /directed|object[- ]reference|outbound/i.test(question); + if (graphRequest) { + updateThinking('Selecting the largest dominator for the object graph…'); + try { + const dominatorArgs = { limit: 10 }; + run.toolStart('heap_get_dominators', dominatorArgs); + const dominatorResult = await callTool('heap_get_dominators', dominatorArgs); + run.toolEnd('heap_get_dominators', dominatorResult); + if (dominatorResult?.error) throw new Error(dominatorResult.message || dominatorResult.error); + appendToolVisual('heap_get_dominators', dominatorResult, toolVisuals); + renderA2UI($('#visual-live'), toolVisuals); + const dominatorRows = Array.isArray(dominatorResult) ? dominatorResult : dominatorResult.entries || dominatorResult.rows || []; + const root = dominatorRows.filter(row => row?.objectId !== undefined && row?.objectId !== null) + .sort((left, right) => Number(right.retainedHeapBytes || right.retainedBytes || 0) - Number(left.retainedHeapBytes || left.retainedBytes || 0))[0]; + if (root) { + updateThinking(`Inspecting dominator object ${root.objectId} for outbound references…`); + const inspectArgs = { objectId: root.objectId, limit: 25 }; + run.toolStart('heap_inspect_object', inspectArgs); + const inspectResult = await callTool('heap_inspect_object', inspectArgs); + run.toolEnd('heap_inspect_object', inspectResult); + if (inspectResult?.error) throw new Error(inspectResult.message || inspectResult.error); + appendToolVisual('heap_inspect_object', inspectResult, toolVisuals); + renderA2UI($('#visual-live'), toolVisuals); + messages.push({ role: 'system', content: `The workbench preloaded the directed graph workflow. Use this evidence to summarize the graph and return the exact A2UI graph shape with top-level nodes and edges. Dominator: ${JSON.stringify(root)}. Inspection: ${JSON.stringify(inspectResult)}` }); + updateThinking('Directed graph is visible; asking the agent to summarize the evidence…'); + } + } catch (error) { + updateThinking(`Graph preflight was unavailable; asking the agent to continue (${error.message})…`); + } + } + for (let turn = 0; turn < maxTurns; turn++) { + const turnStatus = turn === 0 ? 'Planning the investigation…' : `Planning the next step (${turn + 1}/${maxTurns})…`; + setRunProgress(turn === 0 ? 'Thinking…' : 'Planning next step…'); + updateThinking(turnStatus); + const openrouter = state.provider === 'openrouter'; + const endpoint = openrouter ? `${state.openrouter.replace(/\/$/, '')}/chat/completions` : `${state.ollama}/api/chat`; + const headers = { 'Content-Type': 'application/json' }; + if (openrouter) { if (!state.openrouterKey) throw new Error('OpenRouter API key is required. Open **Connections** to add it.'); headers.Authorization = `Bearer ${state.openrouterKey}`; headers['X-Title'] = 'java-heap-workbench'; } + updateThinking('Asking the agent to choose the next evidence step'); + const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ model: state.model, stream: false, messages, tools: toolDefinitions }) }); + if (!response.ok) throw new Error(`${openrouter ? 'OpenRouter' : 'Ollama'} request failed (${response.status})`); + const data = await response.json(); + const message = openrouter ? (data.choices?.[0]?.message || {}) : (data.message || {}); + messages.push(message); + const calls = message.tool_calls || []; + if (!calls.length) { + if (oqlFailed && !oqlSucceeded) { + const clarification = 'I’m having trouble correctly determining the data to query. What specific class, field, relationship, or filter should I investigate?'; + run.text(clarification); + removeThinking(); + addMessage(clarification); + run.finish(); + endRun('Needs clarification'); + return; + } + updateThinking('Preparing the final visual summary'); + const parsed = parseAgentContent(message.content || 'Analysis complete.'); + run.text(parsed.text || 'Visual response ready.'); + removeThinking(); + const hydratedView = hydrateVisualData(parsed.view, toolVisuals); + const visualComponents = hydratedView.components.filter(component => component.type !== 'markdown'); + const accumulatedComponents = [...toolVisuals.components, ...visualComponents] + .filter((component, index, components) => components.findIndex(item => JSON.stringify(item) === JSON.stringify(component)) === index); + const finalView = accumulatedComponents.length ? { ...hydratedView, components: accumulatedComponents } : toolVisuals; + if (finalView.components.length) renderA2UI($('#visual-live'), finalView); + const responseNode = addMessage(parsed.text || 'Visual response ready.', '', parsed.actions || extractSuggestedActions(parsed.text)); + if (visualComponents.length) { + commitVisual(responseNode); + visualState.observer.observe(responseNode); + } + run.finish(); + endRun('Complete'); + return; + } + for (const call of calls) { + const name = call.function.name; + const args = call.function.arguments || {}; + run.toolStart(name, args); + lastTool = name; + const toolLabel = name.replace(/^heap_/, '').replaceAll('_', ' '); + updateThinking(`Calling ${toolLabel}…`); + let result; + try { + result = await callTool(name, args); + } catch (error) { + result = { error: error.message, errorCode: error.code || 'TOOL_ERROR' }; + } + run.toolEnd(name, result); + updateThinking(result?.error ? `${toolLabel} returned an error; evaluating the next step…` : `${toolLabel} returned evidence; updating the visual…`); + messages.push({ role: 'tool', content: JSON.stringify(result), ...(openrouter && call.id ? { tool_call_id: call.id } : {}) }); + if (result?.error) { + if (name === 'heap_run_oql') oqlFailed = true; + const retryAvailable = name === 'heap_run_oql' && turn < maxTurns - 1; + if (retryAvailable) { + updateThinking(`The OQL attempt failed; retrying with simpler Calcite SQL (${turn + 2}/${maxTurns})…`); + addMessage(`The OQL attempt failed. I’m re-evaluating it and will retry with simpler Calcite SQL (attempt ${turn + 2} of ${maxTurns}).`, ''); + } + continue; + } + if (name === 'heap_run_oql') oqlSucceeded = true; + const toolView = visualFromTool(name, result); + if (name === 'heap_run_oql') { + const rows = rowsFromResult(result); + const value = resultValue(result); + const components = [{ type: 'table', title: 'OQL results', columns: columnsFromResult(result, rows), rows }]; + if (!rows.length) { + const totalRows = Number(value.totalRows); + components.push({ type: 'markdown', text: Number.isFinite(totalRows) && totalRows === 0 + ? 'The OQL query completed successfully but returned no rows.' + : 'The OQL query completed, but the returned result did not contain tabular rows.' }); + } + toolView.components = components; + } + if (name === 'heap_inspect_object') toolView.components.push(referenceGraph(result)); + toolView.components.push(...planToolVisuals(question, name, args, result)); + toolVisuals.components.push(...toolView.components); + renderA2UI($('#visual-live'), toolVisuals); + updateThinking('Visual updated; continuing the analysis…'); + } + } + removeThinking(); + if (oqlFailed && !oqlSucceeded) { + addMessage('I’m having trouble correctly determining the data to query. What specific class, field, relationship, or filter should I investigate?'); + } else if (lastTool) addMessage('The analysis tools returned results. See the visual report above.'); + run.finish(); + endRun('Complete'); +} + +const inspectObjectRow = row => { + if (!row || state.running) return; + const objectId = row.dataset.objectId; + const className = row.dataset.className || 'the selected object'; + runSuggestedAction(`Inspect object ${objectId} (${className}) and explain its fields, outbound references, retained memory, and path to GC roots.`); +}; +$('#visual-output').addEventListener('click', event => inspectObjectRow(event.target.closest('tr[data-object-id], g.graph-node[data-object-id]'))); +$('#visual-output').addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') inspectObjectRow(event.target.closest('tr[data-object-id], g.graph-node[data-object-id]')); +}); + +const content = $('.content'); +const navLinks = [...document.querySelectorAll('.nav a')]; +const showView = view => { + const connections = view === 'connections'; + content.classList.toggle('connections-view', connections); + $('#settings').hidden = !connections; + navLinks.forEach(link => link.classList.toggle('active', connections ? link.hash === '#settings' : link.hash !== '#settings')); +}; + +$('#refresh').onclick = refreshProjects; $('#project-select').onchange = e => selectProject(e.target.value); $('#send').onclick = () => { if (state.running) return; const prompt = $('#prompt').value.trim(); if (!prompt) return; $('#prompt').value = ''; runSuggestedAction(prompt); }; $('#prompt').onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('#send').click(); } }; document.querySelectorAll('.prompt').forEach(button => button.onclick = () => { if (!state.running) runSuggestedAction(button.dataset.prompt); }); navLinks.forEach(link => link.onclick = event => { event.preventDefault(); showView(link.hash === '#settings' ? 'connections' : 'workspace'); }); $('#close-settings').onclick = () => showView('workspace'); $('#provider').onchange = updateProviderFields; $('#save-settings').onclick = () => { state.provider = $('#provider').value; state.mcp = $('#mcp-url').value.replace(/\/$/, ''); state.ollama = $('#ollama-url').value.replace(/\/$/, ''); state.openrouter = $('#openrouter-url').value.replace(/\/$/, ''); state.openrouterKey = $('#openrouter-key').value.trim(); state.model = (state.provider === 'openrouter' ? $('#openrouter-model') : $('#ollama-model')).value.trim(); localStorage.setItem('heap.provider', state.provider); localStorage.setItem('heap.mcp', state.mcp); localStorage.setItem('heap.ollama', state.ollama); localStorage.setItem('heap.openrouter', state.openrouter); localStorage.setItem('heap.openrouterKey', state.openrouterKey); localStorage.setItem('heap.model', state.model); showView('workspace'); refreshProjects(); }; refreshProjects(); diff --git a/java-heap-workbench/src/oql.js b/java-heap-workbench/src/oql.js new file mode 100644 index 0000000..824702c --- /dev/null +++ b/java-heap-workbench/src/oql.js @@ -0,0 +1,36 @@ +export const OQL_AGENT_RULES = `OQL query language (Apache Calcite SQL; full grammar: https://calcite.apache.org/docs/reference.html): +- OQL is backed by Apache Calcite SQL by default. Always formulate OQL as a read-only Calcite SELECT query; do not generate Eclipse MAT OQL syntax or switch dialects. +- Only read-only SELECT statements are supported. Use the Calcite query shape SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ... LIMIT/OFFSET or FETCH. Standard SQL is supported for filtering and projection (WHERE, CASE, DISTINCT), joins (JOIN ... ON), aggregation (COUNT, SUM, AVG, MIN, MAX with GROUP BY/HAVING), sorting (ORDER BY), set operations (UNION/EXCEPT/INTERSECT), and paging (LIMIT/OFFSET/FETCH). Submit one SELECT statement without a semicolon. Do not send EXPLAIN, DESCRIBE, INSERT, UPDATE, DELETE, or other statements to heap_run_oql. +- Heap schema: each Java class is a table containing its direct instances, without subclasses. Use the 'instanceof' schema/table when subclasses should be included. Quote a fully qualified class table when needed, for example FROM "java.util.HashMap" h. The special 'this' column is the current heap object; Java fields are exposed as columns. +- Qualify fields with table aliases. Use this['fieldName'] for dynamic field lookup and this['@className'], this['@class'], this['@shallow'], and this['@retained'] for virtual properties. Use double-quoted identifiers for class names or field names that need quoting; quoted identifiers are case-sensitive. Use single quotes for string literals. Do not use Java method syntax on aliases. +- Useful heap functions include getId, getAddress, getType, toString, shallowSize, retainedSize, length, getSize, getField, getStaticField, getStringContent, getValues, getMapEntries, getInboundReferences, getOutboundReferences, and getRetainedSet. These are SQL functions: call them as toString(s.this), retainedSize(s.this), or getField(s.this, 'fieldName'); never call them as row-alias methods such as s.toString(s.this). +- Expand references and maps with LATERAL TABLE(getOutboundReferences(o.this)), LATERAL TABLE(getInboundReferences(o.this)), or LATERAL TABLE(getMapEntries(o.this)). Use UNNEST(asMultiSet(collection)) for collections and UNNEST(asArray(referenceArray)) for arrays; SQL array indexes start at 1, and WITH ORDINALITY adds an index column. Collection conversion helpers include asMap, asMultiSet, asArray, asByteArray, asShortArray, asIntArray, asLongArray, asBooleanArray, asCharArray, asFloatArray, and asDoubleArray. +- Canonical frequency query for String objects: SELECT toString(s.this) AS val, count(*) AS cnt FROM "java.lang.String" s GROUP BY toString(s.this) ORDER BY COUNT(*) DESC. Keep the function call exactly in function form; do not rewrite it as s.toString(s.this). Add a WHERE clause before GROUP BY when filtering values. +- Examples: SELECT toString(file) AS file_str, COUNT(*) AS cnt, SUM(retainedSize(this)) AS sum_retained, SUM(shallowSize(this)) AS sum_shallow FROM java.net.URL GROUP BY toString(file) HAVING COUNT(*) > 1 ORDER BY SUM(retainedSize(this)) DESC; SELECT s.this, u.this FROM "java.lang.String" s JOIN "java.net.URL" u ON s.this = u.path; SELECT p.this, e.key, e."value" FROM java.util.Properties p, LATERAL TABLE(getMapEntries(p.this)) e; SELECT fpc.this, fp.fp_ref FROM java.io.FilePermissionCollection fpc, UNNEST(asMultiSet(fpc.perms)) fp(fp_ref); SELECT c.this, cs.index, cs.val FROM java.util.GregorianCalendar c, UNNEST(asIntArray(c.stamp)) WITH ORDINALITY cs(val, index). +- Aggregate rule: in an aggregate query, every SELECT, HAVING, and ORDER BY expression must be grouped, constant, or an aggregate. Prefer repeating the aggregate expression in ORDER BY (for example ORDER BY COUNT(*) DESC or ORDER BY SUM(retainedSize(this)) DESC) instead of relying on an alias. +- The MCP tool's limit and offset arguments cap rows returned to the UI. Use SQL LIMIT/OFFSET when it is part of the query's intended semantics; otherwise rely on the tool limit. +- Use the inspection and GC-root tools after an OQL query returns an object reference. Never claim fields or values that the tool did not return.`; + +// Lightweight guard for the Calcite SELECT dialect. Calcite owns the final +// parse; this prevents mutations, multiple statements, and malformed input. +export function validateOql(query) { + const source = String(query || '').trim(); + if (!source) return 'OQL query is empty.'; + if (source.includes(';')) return 'OQL must contain one Calcite SELECT statement without a semicolon.'; + if (!/^SELECT\b/i.test(source)) return 'OQL must be a read-only Calcite SELECT statement.'; + if (!/\bFROM\b/i.test(source)) return 'OQL SELECT must contain a FROM clause naming a heap table.'; + if (/\b(INSERT|UPDATE|DELETE|MERGE|ALTER|DROP|TRUNCATE)\b/i.test(source)) return 'Only read-only Calcite SELECT statements are allowed for heap queries.'; + if (/\b[A-Za-z_][A-Za-z0-9_]*\s*\.\s*toString\s*\(/i.test(source)) return 'Use the Calcite SQL function toString(expression), not alias.toString(expression).'; + let quote = null; + let depth = 0; + for (const character of source) { + if (quote) { if (character === quote) quote = null; continue; } + if (character === '"' || character === "'") { quote = character; continue; } + if (character === '(') depth += 1; + if (character === ')') depth -= 1; + if (depth < 0) return 'OQL has an unmatched closing parenthesis.'; + } + if (quote) return 'OQL has an unterminated quoted identifier or string literal.'; + if (depth !== 0) return 'OQL has unbalanced parentheses.'; + return null; +} diff --git a/java-heap-workbench/src/styles.css b/java-heap-workbench/src/styles.css new file mode 100644 index 0000000..6403795 --- /dev/null +++ b/java-heap-workbench/src/styles.css @@ -0,0 +1,16 @@ +:root{color-scheme:dark;--bg:#0d1117;--panel:#161b22;--panel2:#21262d;--line:#30363d;--text:#f0f6fc;--muted:#8b949e;--blue:#58a6ff;--green:#3fb950;--yellow:#d29922;--red:#f85149}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,textarea{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:8px 12px;background:#238636;color:#fff;font-weight:600}button:hover{background:#2ea043}button:disabled{opacity:.65;cursor:wait}button.secondary{background:var(--panel2)}.shell{display:grid;grid-template-columns:250px 1fr;min-height:100vh}.sidebar{padding:24px 18px;border-right:1px solid var(--line);background:#010409}.brand{display:flex;gap:10px;align-items:center;font-weight:700;font-size:16px}.mark{display:grid;place-items:center;width:28px;height:28px;border-radius:6px;background:#1f6feb;color:#fff}.nav{margin-top:34px;display:grid;gap:4px}.nav a{padding:9px 10px;color:var(--muted);border-radius:6px;text-decoration:none}.nav a.active,.nav a:hover{color:var(--text);background:var(--panel2)}.connection{position:absolute;bottom:20px;font-size:12px;color:var(--muted);display:grid;gap:5px}.dot{display:inline-block;width:7px;height:7px;border-radius:99px;background:var(--green);margin-right:6px}.main{min-width:0}.topbar{height:70px;padding:0 32px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}.topbar h1{font-size:20px;margin:0}.eyebrow,.muted{color:var(--muted);font-size:12px}.content{max-width:1400px;margin:auto;padding:30px 32px 60px;display:grid;gap:22px}.hero{display:flex;align-items:end;justify-content:space-between;gap:20px}.hero h2{font-size:28px;margin:5px 0}.hero p{margin:0;color:var(--muted)}.controls{display:flex;gap:8px;align-items:center}.select,input,textarea{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:8px 10px}.select{max-width:230px}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.summary-card,.panel{border:1px solid var(--line);background:var(--panel);border-radius:6px}.summary-card{padding:17px;display:grid;gap:8px;min-height:112px}.summary-card span,.panel header span{font-size:12px;color:var(--muted)}.summary-card strong{font-size:25px}.summary-card small{color:var(--muted)}.summary-card.blue{border-top:2px solid var(--blue)}.summary-card.green{border-top:2px solid var(--green)}.summary-card.yellow{border-top:2px solid var(--yellow)}.summary-card.red{border-top:2px solid var(--red)}.dashboard-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:14px}.panel{padding:18px}.panel header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.panel h3{margin:0;font-size:14px}.chart{min-height:230px}.chart svg{width:100%;height:230px}.chat{display:grid;grid-template-columns:1fr 310px;gap:14px}.chat-panel{min-height:400px;display:grid;grid-template-rows:1fr auto auto}.messages{display:grid;align-content:start;gap:12px;padding-bottom:16px}.message{padding:12px 14px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);line-height:1.6}.message.user{background:#10243e;border-color:#1f6feb}.message strong{color:var(--blue)}.message p{margin:0 0 10px}.message p:last-child{margin-bottom:0}.message h1,.message h2,.message h3,.message h4,.message h5,.message h6{margin:0 0 10px;color:var(--text);line-height:1.3}.message ul,.message ol{margin:4px 0 10px;padding-left:22px}.message li{margin:4px 0}.message code{padding:2px 5px;border-radius:4px;background:var(--bg);color:#ffa657;font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.message.thinking{border-color:rgba(88,166,255,.5);background:linear-gradient(90deg,var(--panel2),#17263a,var(--panel2));background-size:200% 100%;animation:thinking-sheen 2s ease-in-out infinite}.thinking-copy{color:var(--muted)}.thinking-dots{display:inline-flex;gap:3px;margin-left:5px;vertical-align:middle}.thinking-dots i{width:4px;height:4px;border-radius:50%;background:var(--blue);animation:thinking-dot 1.2s infinite ease-in-out}.thinking-dots i:nth-child(2){animation-delay:.16s}.thinking-dots i:nth-child(3){animation-delay:.32s}.run-progress{display:flex;align-items:center;gap:9px;margin:0 0 12px;padding:9px 11px;border:1px solid rgba(88,166,255,.4);border-radius:6px;background:rgba(31,111,235,.1);color:var(--blue);font-size:12px}.run-progress[data-mode=error]{color:var(--red);border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.08)}.run-progress[data-mode=complete]{color:var(--green);border-color:rgba(63,185,80,.45);background:rgba(63,185,80,.08)}.run-progress #run-elapsed{margin-left:auto;color:var(--muted);font-variant-numeric:tabular-nums}.spinner{width:13px;height:13px;border:2px solid rgba(88,166,255,.3);border-top-color:var(--blue);border-radius:50%;animation:spin .8s linear infinite}.run-progress[data-mode=complete] .spinner,.run-progress[data-mode=error] .spinner{display:none}.composer{display:flex;gap:8px;border-top:1px solid var(--line);padding-top:14px}.composer textarea{resize:none;min-height:42px;flex:1}.prompt-list{display:grid;gap:7px}.prompt{text-align:left;background:transparent;color:var(--muted);font-weight:400}.prompt:hover{color:var(--text)}.dropzone{border:1px dashed var(--line);padding:18px;text-align:center;color:var(--muted);border-radius:6px}.a2ui-table{margin-top:14px}.scroll{overflow:auto}.a2ui-table table{width:100%;border-collapse:collapse}.a2ui-table th,.a2ui-table td{padding:9px 8px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap}.a2ui-table th{font-size:11px;color:var(--muted)}.empty{padding:55px 15px;text-align:center;color:var(--muted)}.toast{position:fixed;right:20px;bottom:20px;padding:10px 14px;background:var(--panel2);border:1px solid var(--line);border-radius:6px}@keyframes spin{to{transform:rotate(360deg)}}@keyframes thinking-dot{0%,60%,100%{opacity:.25;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}@keyframes thinking-sheen{0%,100%{background-position:0 0}50%{background-position:100% 0}}@media(max-width:1000px){.shell{grid-template-columns:1fr}.sidebar{display:none}.grid{grid-template-columns:repeat(2,1fr)}.dashboard-grid,.chat{grid-template-columns:1fr}}@media(max-width:600px){.content{padding:22px 14px}.topbar{padding:0 14px}.grid{grid-template-columns:1fr}.hero{display:grid}.controls{align-items:stretch;flex-direction:column}} +.content{grid-template-columns:minmax(340px,.9fr) minmax(0,1.4fr);align-items:start}.hero{grid-column:1/-1}.content>#metrics{grid-column:2;grid-row:2}.dashboard-grid{grid-column:2;grid-row:3;display:block}.dashboard-grid>#visuals{width:100%;min-height:520px}.chat{grid-column:1;grid-row:2 / span 2;display:block}.chat-panel{min-height:650px;grid-template-rows:1fr auto auto auto}.chat>.panel:not(.chat-panel){display:none}.chat-prompts{margin:0 0 12px;padding:0;border:0;background:transparent}.chat-prompts header{margin-bottom:8px}.visual-output{display:grid;gap:14px}.visual-output>.empty{min-height:390px;display:grid;place-items:center}.run-progress{display:none}.dashboard-grid .panel+ .panel{display:none}.content>#settings{grid-column:1/-1;grid-row:4}.message.markdown{background:transparent;border:0;padding:0}.message.markdown p{margin:0 0 10px}.message.markdown h1,.message.markdown h2,.message.markdown h3,.message.markdown h4,.message.markdown h5,.message.markdown h6{margin-top:12px} +.content{grid-template-columns:minmax(340px,.9fr) minmax(0,1.4fr);align-items:start}.hero{grid-column:1/-1}.content>#metrics{grid-column:2;grid-row:2}.dashboard-grid{grid-column:2;grid-row:3;display:block}.dashboard-grid>#visuals{width:100%;min-height:520px}.chat{grid-column:1;grid-row:2 / span 2;display:block}.chat-panel{min-height:650px;grid-template-rows:1fr auto auto auto}.chat>.panel:not(.chat-panel){display:none}.chat-prompts{margin:0 0 12px;padding:0;border:0;background:transparent}.chat-prompts header{margin-bottom:8px}.visual-output{display:grid;gap:14px}.visual-output>.empty{min-height:390px;display:grid;place-items:center}.run-progress{display:none}.dashboard-grid .panel+ .panel{display:none}.content>#settings{grid-column:1/-1;grid-row:4}.message.markdown{background:transparent;border:0;padding:0}.message.markdown p{margin:0 0 10px}.message.markdown h1,.message.markdown h2,.message.markdown h3,.message.markdown h4,.message.markdown h5,.message.markdown h6{margin-top:12px} +@media(max-width:1000px){.content{grid-template-columns:1fr}.hero,.content>#metrics,.dashboard-grid,.chat,.content>#settings{grid-column:1;grid-row:auto}.dashboard-grid>#visuals{min-height:0}.chat-panel{min-height:520px}} +.activity-dots{display:inline-flex;gap:3px;align-items:center;height:16px}.activity-dots i{width:5px;height:5px;border-radius:50%;background:var(--blue);animation:visual-pulse 1.2s infinite ease-in-out}.activity-dots i:nth-child(2){animation-delay:.16s}.activity-dots i:nth-child(3){animation-delay:.32s}@keyframes visual-pulse{0%,60%,100%{opacity:.25;transform:scale(.8)}30%{opacity:1;transform:scale(1.25)}} +.dashboard-grid{position:sticky;top:88px}.visual-output{position:relative}#visual-live[hidden]{display:none}.visual-entry{opacity:.28;filter:brightness(.55);transform:scale(.985);transform-origin:top center;transition:opacity .2s ease,filter .2s ease,transform .2s ease;border:1px solid transparent;border-radius:6px;cursor:pointer}.visual-entry.active{opacity:1;filter:none;transform:scale(1);border-color:rgba(88,166,255,.6);box-shadow:0 0 0 1px rgba(88,166,255,.12)}.visual-entry:not(.active) .panel,.visual-entry:not(.active) .summary-card{pointer-events:none}.message.has-visual,.message[data-visual-target]{cursor:pointer;transition:border-color .2s ease,background .2s ease}.message.has-visual:hover,.message.has-visual:focus-visible,.message.visual-selected{border-color:var(--blue);outline:none}.message.has-visual:focus-visible{box-shadow:0 0 0 2px rgba(88,166,255,.25)} +.visual-entry:not(.active){max-height:78px;overflow:hidden} +@media(max-width:1000px){.dashboard-grid{position:static}} +.chat-panel{grid-template-rows:auto 1fr auto auto}#messages{display:grid;align-content:start;gap:12px;padding-bottom:16px;min-height:0;overflow:auto}.response-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}.response-actions .prompt{padding:6px 9px;font-size:12px} +.a2ui-table tr[data-object-id]{cursor:pointer}.a2ui-table tr[data-object-id]:hover,.a2ui-table tr[data-object-id]:focus-visible{background:rgba(88,166,255,.1);outline:none}.a2ui-table tr[data-object-id]:focus-visible{box-shadow:inset 0 0 0 2px rgba(88,166,255,.45)} +.graph{overflow:auto;min-height:190px}.graph svg{display:block;width:100%;min-width:720px;height:auto}.graph-node{cursor:pointer}.graph-node:hover rect,.graph-node:focus rect{stroke:#f0f6fc;stroke-width:2}.graph-node:focus{outline:none} +@media(min-width:1001px){.chat{position:sticky;top:88px;align-self:start}.chat-panel{height:calc(100vh - 112px);max-height:calc(100vh - 112px)}} +.content.connections-view > :not(#settings){display:none}.content.connections-view #settings{display:block;grid-column:1/-1;grid-row:1} +.brand .mark{object-fit:contain;padding:3px;background:transparent}.connection-form{display:grid;gap:12px;max-width:760px}.connection-form label{display:grid;grid-template-columns:180px minmax(0,1fr);align-items:center;gap:16px}.connection-form [hidden]{display:none!important}.connection-form input,.connection-form select{width:100%;max-width:none}.field-control{display:flex;align-items:center;gap:10px;min-width:0}.field-control input{flex:1;min-width:0}.field-control a{color:var(--blue);font-size:12px;white-space:nowrap}.connection-submit{margin-top:6px} +.a2ui-table table{table-layout:fixed}.a2ui-table th,.a2ui-table td{white-space:normal;overflow-wrap:anywhere;word-break:break-word;vertical-align:top} +@media(min-width:1001px){.chat-panel,.dashboard-grid>#visuals{height:calc(100vh - 170px);max-height:calc(100vh - 170px);min-height:0}.chat-panel{overflow:hidden;padding-bottom:34px}.dashboard-grid>#visuals{overflow-y:auto}} diff --git a/java-heap-workbench/src/visualization-policy.js b/java-heap-workbench/src/visualization-policy.js new file mode 100644 index 0000000..0c87951 --- /dev/null +++ b/java-heap-workbench/src/visualization-policy.js @@ -0,0 +1,42 @@ +const rowsFor = result => { + if (Array.isArray(result)) return result; + for (const candidate of [result, result?.structuredContent, result?.result]) { + for (const key of ['rows', 'entries', 'results', 'data']) { + if (Array.isArray(candidate?.[key])) return candidate[key]; + } + } + return []; +}; + +const valueFor = (row, names) => { + const key = Object.keys(row || {}).find(candidate => names.includes(candidate.toLowerCase())); + return key === undefined ? undefined : row[key]; +}; + +const numericValue = value => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +export function planToolVisuals(question, name, args, result) { + const source = `${question || ''} ${args?.query || ''}`.toLowerCase(); + const rows = rowsFor(result); + + // A grouped value/count result is a frequency distribution. String frequency + // queries are the primary pie-chart use case and do not depend on model JSON. + if (name === 'heap_run_oql' && rows.length) { + const data = rows.map(row => ({ + label: valueFor(row, ['val', 'value', 'label', 'name']), + value: numericValue(valueFor(row, ['cnt', 'count', 'objectcount', 'frequency'])) + })).filter(point => point.label !== undefined && point.value !== null && point.value > 0).slice(0, 8); + const looksLikeFrequency = data.length > 0 && ( + /string|frequency|common|duplicate|pie/.test(source) + || rows.some(row => valueFor(row, ['val']) !== undefined && valueFor(row, ['cnt', 'count']) !== undefined) + ); + if (looksLikeFrequency) { + return [{ type: 'chart', title: 'Most common values', subtitle: 'Object-count distribution', chartType: 'pie', data }]; + } + } + + return []; +} diff --git a/java-heap-workbench/test/build.test.mjs b/java-heap-workbench/test/build.test.mjs new file mode 100644 index 0000000..35d67a9 --- /dev/null +++ b/java-heap-workbench/test/build.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractSuggestedActions, hydrateVisualData, normalizeAgentResponse, parseAgentContent, renderInlineMarkdown } from '../src/a2ui.js'; +import { AGENT_SYSTEM_PROMPT } from '../src/agent-prompt.js'; +import { OQL_AGENT_RULES, validateOql } from '../src/oql.js'; +import { planToolVisuals } from '../src/visualization-policy.js'; + +test('workbench build contains the app entry points', async () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + await import(join(root, 'build.mjs')); + await stat(join(root, 'dist/index.html')); + await stat(join(root, 'dist/src/main.js')); + await stat(join(root, 'dist/src/a2ui.js')); +}); + +test('agent responses become visual components', () => { + const json = parseAgentContent('{"message":"Found the largest classes.","components":[{"type":"chart","chartType":"line","data":[{"label":"A","value":2}]}]}'); + assert.equal(json.view.components[0].type, 'chart'); + assert.equal(json.view.components[0].chartType, 'line'); + const messageOnly = parseAgentContent('{"message":"The table is shown in Visual Analysis.","components":[]}'); + assert.equal(messageOnly.text, 'The table is shown in Visual Analysis.'); + assert.deepEqual(messageOnly.view.components, []); + + const markdown = parseAgentContent('| Class | Retained |\n| --- | --- |\n| java.lang.String | 42 |'); + assert.equal(markdown.view.components[0].type, 'table'); + assert.equal(markdown.view.components[0].rows[0].Class, 'java.lang.String'); +}); + +test('agent responses preserve pie charts and directed object graphs', () => { + const response = normalizeAgentResponse({ components: [ + { type: 'chart', title: 'String frequency', chartType: 'pie', data: [{ label: 'alpha', value: 8 }] }, + { type: 'graph', title: 'Outbound references', nodes: [{ id: '1', objectId: 1 }, { id: '2', objectId: 2 }], edges: [{ source: '1', target: '2', label: 'value' }] } + ] }); + assert.equal(response.components[0].chartType, 'pie'); + assert.deepEqual(response.components[1].edges[0], { source: '1', target: '2', label: 'value' }); +}); + +test('visualization policy plans a pie chart for String frequency results', () => { + const planned = planToolVisuals('Show the top 5 String values as a pie chart.', 'heap_run_oql', {}, { + columns: ['val', 'cnt'], + rows: [{ val: 'alpha', cnt: 5 }, { val: 'beta', cnt: 3 }] + }); + assert.equal(planned[0].type, 'chart'); + assert.equal(planned[0].chartType, 'pie'); + assert.deepEqual(planned[0].data, [{ label: 'alpha', value: 5 }, { label: 'beta', value: 3 }]); +}); + +test('visualization policy accepts wrapped OQL result rows', () => { + const planned = planToolVisuals('Show common strings as a pie chart.', 'heap_run_oql', {}, { + structuredContent: { rows: [{ val: 'alpha', cnt: 2 }] } + }); + assert.equal(planned[0].chartType, 'pie'); +}); + +test('table cell markdown is rendered as inline markup', () => { + const rendered = renderInlineMarkdown('**1.4KB** \\*\\*`Com.escaper.Escape`\\*\\*'); + assert.match(rendered, /1\.4KB<\/strong>/); + assert.match(rendered, /Com\.escaper\.Escape<\/code>/); + assert.doesNotMatch(rendered, /\\\*\\\*/); +}); + +test('empty agent tables are hydrated from tool data', () => { + const hydrated = hydrateVisualData( + { components: [{ type: 'table', title: 'Top 10 Classes Ranked by Retained Heap Bytes', rows: [] }] }, + { components: [{ type: 'table', rows: [{ className: 'java.lang.String', retainedHeapBytes: 123 }] }] } + ); + assert.equal(hydrated.components[0].rows[0].className, 'java.lang.String'); +}); + +test('plain-text investigation guidance becomes action buttons', () => { + const actions = extractSuggestedActions('Overall Memory Usage\nTop Memory Consumers (Classes)\nDominant Objects\nLeak Detection\nDeep Inspection'); + assert.deepEqual(actions.map(action => action.label), ['Overall Memory Usage', 'Top Memory Consumers', 'Dominant Objects', 'Leak Detection', 'Deep Inspection']); +}); + +test('duplicate visual components render only once', () => { + const view = normalizeAgentResponse({ components: [ + { type: 'chart', title: 'Retained classes', chartType: 'bar', data: [{ label: 'A', value: 1 }] }, + { type: 'chart', title: 'Retained classes', chartType: 'bar', data: [{ label: 'A', value: 1 }] } + ] }); + assert.equal(view.components.length, 1); +}); + +test('OQL guard follows the Calcite SQL dialect', () => { + assert.equal(validateOql('SELECT * FROM java.lang.String s WHERE s.count > 10'), null); + assert.equal(validateOql('SELECT title FROM ConferenceSession'), null); + assert.equal(validateOql('SELECT * FROM java.lang.String LIMIT 10'), null); + assert.equal(validateOql('SELECT DISTINCT title FROM ConferenceSession'), null); + assert.equal(validateOql('SELECT s.this, e.key FROM java.util.Properties s, LATERAL TABLE(getMapEntries(s.this)) e'), null); + assert.equal(validateOql('SELECT toString(s.this) AS unique_value, COUNT(*) AS count FROM "java.lang.String" s GROUP BY toString(s.this) ORDER BY COUNT(*) DESC'), null); + assert.match(validateOql('SELECT * FROM java.lang.String;'), /one Calcite SELECT statement/); + assert.match(validateOql('SELECT * FROM java.lang.String WHERE ('), /unbalanced parentheses/); + assert.match(validateOql('UPDATE java.lang.String SET value = 1'), /read-only Calcite SELECT/); + assert.match(validateOql('SELECT s.toString(s.this) FROM "java.lang.String" s'), /Calcite SQL function toString/); +}); + +test('OQL agent context uses Calcite function syntax for common String values', () => { + assert.match(OQL_AGENT_RULES, /toString\(s\.this\).*never.*s\.toString\(s\.this\)/s); + assert.match(OQL_AGENT_RULES, /toString\(s\.this\) AS val, count\(\*\) AS cnt FROM "java\.lang\.String" s GROUP BY toString\(s\.this\) ORDER BY COUNT\(\*\) DESC/); + assert.match(OQL_AGENT_RULES, /HAVING COUNT\(\*\) > 1 ORDER BY SUM\(retainedSize\(this\)\) DESC/); + assert.match(OQL_AGENT_RULES, /LATERAL TABLE\(getMapEntries\(p\.this\)\)/); + assert.match(OQL_AGENT_RULES, /UNNEST\(asMultiSet\(fpc\.perms\)\)/); +}); + +test('agent context instructs query retries to simplify failed OQL', () => { + assert.match(AGENT_SYSTEM_PROMPT, /Before constructing or calling heap_run_oql, always call heap_get_oql_grammar/); + assert.match(AGENT_SYSTEM_PROMPT, /Do not call heap_find_path_to_gc_roots as a substitute for outboundReferences/); + assert.match(AGENT_SYSTEM_PROMPT, /Graph nodes and edges must be top-level graph properties/); + assert.match(AGENT_SYSTEM_PROMPT, /heap_run_oql fails, do not stop/); + assert.match(AGENT_SYSTEM_PROMPT, /retry up to three times/); + assert.match(AGENT_SYSTEM_PROMPT, /Do not repeat the same failed query/); + assert.match(AGENT_SYSTEM_PROMPT, /all OQL attempts fail, stop trying to guess/); + assert.match(AGENT_SYSTEM_PROMPT, /need more information/); +}); diff --git a/java-perf-workshop-server/pom.xml b/java-perf-workshop-server/pom.xml index 4faec7a..6041dbb 100644 --- a/java-perf-workshop-server/pom.xml +++ b/java-perf-workshop-server/pom.xml @@ -134,7 +134,7 @@ latest ${project.version} - openjdk:21 + openjdk:25 server-assembly.xml diff --git a/java-perf-workshop-tester/pom.xml b/java-perf-workshop-tester/pom.xml index 092a90f..1490cfc 100644 --- a/java-perf-workshop-tester/pom.xml +++ b/java-perf-workshop-tester/pom.xml @@ -11,8 +11,8 @@ java-perf-workshop-tester - 21 - 21 + 25 + 25 UTF-8 @@ -47,10 +47,11 @@ testCompile + 25 -Jbackend:GenBCode -Jdelambdafy:method - -target:jvm-21 + -target:jvm-25 -deprecation -feature -unchecked diff --git a/pom.xml b/pom.xml index 0262507..39328eb 100644 --- a/pom.xml +++ b/pom.xml @@ -24,7 +24,19 @@ 3.14.9 - 2.13.12 + 2.13.17 + + + 3.31.0 + 3.22.0 + 3.12.0 + 76.1 + 5.12.2 + 3.27.3 + 1.41.0 + 3.1.12 + 1.16.1.202501091339 + 1.1.4 4.7.1 @@ -60,6 +72,7 @@ java-perf-workshop-server java-perf-workshop-tester + java-heap-mcp @@ -138,6 +151,56 @@ scala-library ${scala.version} + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + + + org.eclipse.platform + org.eclipse.core.runtime + ${eclipse.platform.version} + + + org.eclipse.platform + org.eclipse.core.resources + ${eclipse.core.resources.version} + + + org.eclipse.platform + org.eclipse.core.commands + ${eclipse.core.commands.version} + + + com.ibm.icu + icu4j + ${icu4j.version} + + + org.apache.calcite + calcite-core + ${calcite.version} + + + org.codehaus.janino + janino + ${janino.version} + + + org.codehaus.janino + commons-compiler + ${janino.version} + + + org.junit.jupiter + junit-jupiter + ${junit.version} + + + org.assertj + assertj-core + ${assertj.version} + @@ -167,8 +230,8 @@ true true - 21 - 21 + 25 + 25