Replies: 1 comment
-
Implementation Update — PR #13723The implementation has evolved significantly from the original proposal. Here is a summary of the key architectural changes and the current state. Approach Change: Build-Time Transpiler → Runtime ANTLR4 + Javassist CompilerThe original proposal used a build-time Groovy AST transpiler — parsing Groovy source into AST at Maven compile time, walking the AST to emit Java source, then compiling with The implemented solution uses runtime ANTLR4 + Javassist compilation — each DSL has its own ANTLR4 grammar, the parser produces an immutable AST model, and Javassist generates bytecode directly at OAP startup. No Groovy parser involved at any stage.
The key advantage: users can still add/modify MAL/LAL rules in YAML configs without rebuilding the OAP server — the same workflow as today. The Groovy dependency is completely removed, not just moved to build time. Scope Expansion: MAL + LAL → MAL + LAL + HierarchyThe original proposal covered MAL and LAL. The implementation also replaces the fourth Groovy-based DSL: Hierarchy matching rules (the All four script-based DSLs (OAL was already done separately) now use the same pipeline: Generated Code ComparisonThe generated bytecode is equivalent to what was shown in the original proposal, but uses Javassist instead of MAL — // Original proposal: Java source with lambdas
return samples.getOrDefault("instance_jvm_cpu", SampleFamily.EMPTY)
.sum(List.of("service", "instance"));
// Current: Javassist bytecode (decompiled), single-variable pattern
public SampleFamily run(Map samples) {
SampleFamily sf;
sf = ((SampleFamily) samples.getOrDefault("instance_jvm_cpu", SampleFamily.EMPTY));
sf = sf.sum(new String[]{"service", "instance"});
return sf;
}MAL closures — Since Javassist cannot compile lambdas or anonymous classes, closures become separate Javassist classes stored as fields: // Original proposal: Java lambda
.tag(tags -> { tags.put("cluster", "k8s-cluster::" + tags.get("cluster")); return tags; })
// Current: pre-compiled closure class, wired via field
sf = sf.tag(this._tag); // _tag is a MalExpr_N$_tag instanceLAL — The original proposal kept Groovy's // Original proposal: Consumer lambdas for each block
filterSpec.extractor(ext -> {
ext.service(String.valueOf(getAt(binding.parsed(), "service")));
});
// Current: flat class with private methods, explicit context passing
public void execute(FilterSpec filterSpec, ExecutionContext ctx) {
LalRuntimeHelper h = new LalRuntimeHelper(ctx);
filterSpec.json(ctx);
if (!ctx.shouldAbort()) { _extractor(filterSpec.extractor(), h); }
filterSpec.sink(ctx);
}
private void _extractor(ExtractorSpec _e, LalRuntimeHelper h) {
_e.service(h.ctx(), h.toStr(h.mapVal("service")));
}LAL with extraLogType (Envoy ALS) — Proto getter chains are resolved via Java reflection at compile time, generating direct typed method calls at runtime: // Original proposal: getAt(binding.parsed(), "response", "responseCode", "value")
// Current: direct proto getters with local variable caching
HTTPAccessLogEntry _p = (HTTPAccessLogEntry) h.ctx().extraLog();
HTTPResponseProperties _t0 = _p == null ? null : _p.getResponse();
UInt32Value _t1 = _t0 == null ? null : _t0.getResponseCode();
if (_t1 != null && _t1.getValue() < 400) { filterSpec.abort(ctx); }Thread SafetyThe original proposal inherited the Groovy-era
VerificationThe v1-v2 cross-verification mechanism works as described in the original proposal — dual-path comparison testing. Both Groovy v1 and Javassist v2 coexist in the same JVM via package isolation (
73 companion PR |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
TL;DR
In the SkyWalking GraalVM distro, we have built and shipped a build-time transpiler that converts all 1,250+ MAL expressions and 10 LAL scripts from Groovy DSL into pure Java source code. The transpiler is fully functional — all generated Java classes pass 1,300 dual-path comparison tests proving byte-for-byte semantic equivalence with the Groovy originals. The feature runs in production as part of the GraalVM native distro.
Given the significant performance, maintainability, and reliability benefits, we plan to upstream this into the main SkyWalking codebase to replace Groovy in the OAP runtime for all users — not just native image builds.
Why Replace Groovy?
1. Startup Cost
Every time the OAP server boots, it compiles 1,250+ MAL Groovy scripts and 10 LAL Groovy scripts via
GroovyShell.parse(). Each invocation spins up Groovy's compiler pipeline — parsing, AST transformation,@CompileStatictype checking (LAL), classloading. This adds measurable seconds to cold startup.2. Runtime Errors That Should Be Compile-Time Errors
MAL uses dynamic Groovy —
propertyMissing(),ExpandoMetaClassonNumber, runtime metaclass manipulation. A typo in a metric name or an invalid method chain is only discovered when that specific expression runs with real data. With Java source code, the compiler catches these immediately.3. Debugging Complexity
When a MAL expression fails at runtime, the stack trace contains Groovy MOP internals (
CallSite,MetaClassImpl,ExpandoMetaClass), making it hard to pinpoint the actual expression logic. Transpiled Java produces clean stack traces with direct method calls.4. Runtime Execution Performance
This is where the difference matters most. MAL expressions run on every metrics ingestion cycle — not just at startup. Every time the OAP server receives a batch of metrics from agents, OpenTelemetry collectors, or other data sources, it evaluates 1,250+ MAL expressions against incoming
SampleFamilydata. The per-expression overhead of dynamic Groovy compounds into a significant cost at scale.How Dynamic Groovy Executes MAL Expressions
Consider a real K8s MAL expression:
At runtime, Groovy's Meta-Object Protocol (MOP) intercepts every single operation in this chain:
Step 1 — Property resolution (
kube_node_status_capacity):Groovy does not know this is a metric name at compile time. It goes through
CallSite→MetaClassImpl.invokePropertyOrMissing()→ExpressionDelegate.propertyMissing("kube_node_status_capacity")→ThreadLocal<Map>lookup. That's 4+ layers of indirection and aThreadLocal.get()for what should be a simple map lookup.Step 2 — Method call (
.tagEqual('resource', 'cpu')):Each method call passes through Groovy's
CallSitedispatch mechanism. Even thoughSampleFamily.tagEqual()is a concrete Java method, Groovy's dynamic dispatch must:MetaClassfor the receiver objectMetaClassImpl.invokeMethod()Step 3 — Arithmetic expressions (
kube_node_status_capacity * 1000):This is the most expensive path. MAL uses
ExpandoMetaClassto registerplus,minus,multiply,divclosures onNumber.class:When
kube_node_status_capacity * 1000executes, Groovy must:Number.metaClasshas a registeredmultiplymethodClosureobject for the metaclass methodSampleFamilyas argumentsf.multiply(1000)— the actual computationThat's closure allocation + metaclass lookup + dynamic dispatch for what the transpiled Java does as a single direct method call:
sf.multiply(1000).Step 4 — Closure parameters (
.tag({tags -> tags.cluster = 'k8s-cluster::' + tags.cluster})):Groovy closures carry a
delegate,owner, andthisObjectreference. The.tag()method callscl.rehydrate(delegate, owner, thisObject)to set up delegation, thencl.call(arg)which goes through Groovy'sClosure.call()→MetaClassImpldispatch. Inside the closure,tags.clustertriggerspropertyMissingon the Map (Groovy's map property syntax), and string concatenation'k8s-cluster::' + tags.clustertriggers further MOP dispatch for the+operator.What Transpiled Java Does Instead
The same expression in transpiled Java:
samples.getOrDefault()— a singleHashMap.get()callinvokevirtualbytecode — no MOP, no metaclass lookup, no CallSite chain.multiply(1000)— direct method call, no ExpandoMetaClass, no closure allocationinvokedynamicwithLambdaMetafactory, which the JVM bootstraps once and reuses. No delegate/owner/thisObject overhead. No rehydration. Map access istags.get("cluster")— a directHashMap.get().JIT Optimization Difference
The JVM's JIT compiler (C2) can aggressively optimize transpiled Java because:
invokevirtualcalls on concrete classes are trivially inlineable. Groovy'sCallSite→MetaClassImpl→ target chain is too deep and polymorphic for reliable inlining.LambdaMetafactoryare often eliminated entirely by escape analysis. GroovyClosureobjects are full heap-allocated objects that carry delegate references and cannot be eliminated.MetaClassRegistrywith per-class metaclass entries.ExpandoMetaClassmodifications onNumberare globally visible and must be synchronized. Transpiled Java has no such shared mutable state.The Scale Factor
The OAP server evaluates these expressions continuously. With 1,250+ MAL expressions running on every ingestion cycle:
propertyMissing()calls per cycle → 1,250HashMap.get()callsinvokevirtualcallsEach individual operation is microseconds faster, but multiplied across 1,250+ expressions on every metrics ingestion cycle, the aggregate improvement is substantial.
5. GraalVM Native Image Incompatibility
Groovy's
invokedynamicbootstrapping andExpandoMetaClassare fundamentally incompatible with GraalVM ahead-of-time compilation. While this was our original motivation in the SkyWalking GraalVM distro project, the transpiler's benefits extend well beyond native image.How It Works
Architecture Overview
Step 1: Parse Groovy AST (Without Executing)
Both transpilers use Groovy's own
CompilationUnitat theCONVERSIONphase to parse expression strings into AST nodes. No Groovy code is executed — we only extract the syntax tree:Step 2: Walk AST → Emit Java Source
The transpiler recursively walks AST nodes and emits equivalent Java code. Each Groovy construct has a deterministic Java mapping.
MAL Transpilation: Groovy → Java
The Challenge
MAL expressions rely on three dynamic Groovy features that have no direct Java equivalent:
propertyMissing(String name): When an expression references a bare name likehttp_server_requests_count, Groovy's MOP callsExpressionDelegate.propertyMissing()to look up the sample family from aThreadLocalmap.ExpandoMetaClassonNumber: TheExpression.empower()method registersplus,minus,multiply,divonNumber.classat runtime, enablingkube_node_status_capacity * 1000to produce aSampleFamily.Closure parameters: Methods like
.tag({tags -> tags.cluster = 'k8s-cluster::' + tags.cluster})and.filter({tags -> tags.job_name in ['kubernetes-cadvisor', 'kube-state-metrics']})pass Groovy closures with dynamic property access.The Solution
MalExpressionInterfaceEach generated class implements this interface. The
samplesmap replaces Groovy'spropertyMissing()— metric names are resolved via explicitsamples.get("metricName").Closure → Java Functional Interfaces
Five
SampleFamilymethods acceptgroovy.lang.Closureparameters. We replace them with Java functional interfaces:.tag(closure){tags -> tags.key = val}TagFunction extends Function<Map, Map>.filter(closure){tags -> tags.x == 'y'}SampleFilter extends Predicate<Map>.forEach(list, closure){prefix, tags -> ...}ForEachFunction extends BiConsumer<String, Map>.decorate(closure){entity -> ...}DecorateFunction extends Consumer<MeterEntity>.instance(..., closure){tags -> ...}PropertiesExtractor extends Function<Map, Map>AST Mapping Rules
metric_name(bare property)samples.getOrDefault("metric_name", SampleFamily.EMPTY).sum(['a','b']).sum(List.of("a", "b"))100 * metricmetric.multiply(100)(operands swapped)metricA / metricBmetricA.div(metricB).tag({tags -> tags.cluster = 'k8s-cluster::' + tags.cluster}).tag(tags -> { tags.put("cluster", "k8s-cluster::" + tags.get("cluster")); return tags; }).filter({tags -> tags.job_name in ['kubernetes-cadvisor']}).filter(tags -> "kubernetes-cadvisor".equals(tags.get("job_name")))Layer.K8SLayer.K8S(enum constant, direct)time()Instant.now().getEpochSecond()Concrete Example: K8s Cluster Metrics
Input — MAL expression from
k8s/k8s-cluster.yaml:The YAML rule defines:
The full expression after prefix/suffix expansion is:
This expression showcases two key dynamic Groovy features: (1)
Number * SampleFamilyarithmetic viaExpandoMetaClass, and (2) a tag closure with dynamic property access.Output — Generated Java:
Notice how
(kube_node_status_capacity * 1000)— which requiresExpandoMetaClassto makeNumber.multiply(SampleFamily)work — becomes a simple.multiply(1000)direct method call. The tag closure{tags -> tags.cluster = 'k8s-cluster::' + tags.cluster}becomes a Java lambda with explicittags.put()/tags.get()calls.The generated code is readable, debuggable, and type-checked at compile time.
Second Example: K8s Service Pod CPU Usage
Input — MAL expression from
k8s/k8s-service.yaml:Output — Generated Java:
A longer method chain with 9 chained calls — in dynamic Groovy, each one goes through MOP dispatch. In transpiled Java, each is a direct
invokevirtual.Complex Example: forEach with Conditional Logic
Input — MAL expression from
network.yaml:Output — Generated Java:
LAL Transpilation: Groovy → Java
The Challenge
LAL (Log Analysis Language) uses
@CompileStaticwithLALPrecompiledExtension— it's statically typed unlike MAL. However, it still relies on:filter { extractor { sink { ... } } }parsed?.field?.nested) for log data accessas Long,as String)The Solution
LalExpressionInterfaceEach generated class implements this interface. Groovy closures become Java
Consumerlambdas.AST Mapping Rules
filter { ... }json {}filterSpec.json()text { regexp /pat/ }filterSpec.text(tp -> { tp.regexp("pat"); })extractor { ... }filterSpec.extractor(ext -> { ... })sink { sampler { ... } }filterSpec.sink(s -> { s.sampler(sm -> { ... }); })parsed.fieldgetAt(binding.parsed(), "field")parsed?.field?.nestedgetAt()with null safetyval as LongtoLong(val)tag("KEY") == "VALUE""VALUE".equals(filterSpec.tag("KEY"))if/else if/elseConcrete Example
Input — LAL script from
envoy-als.yaml:filter { json {} extractor { if (tag("LOG_KIND") == "NET_PROFILING_SAMPLED_TRACE") { sampledTrace { latency parsed.latency as Long uri parsed.uri as String reason parsed.reason as String } } } }Output — Generated Java:
Test Suite: 1,300 Dual-Path Comparison Tests
Every generated Java class is validated against the original Groovy behavior:
MAL Tests
LAL Tests
Test Integrity
Tests are designed to prevent vacuous agreement (both paths returning empty/null and "matching"):
SampleFamilyresults with actual metric valuesWhat Changes in the Codebase
New Interfaces (Minimal API Surface)
SampleFamily: Closure → Functional Interface
The only API change to
SampleFamilyis replacing 5groovy.lang.Closureparameters with Java functional interfaces:Internal implementation stays the same — only the parameter types change from Groovy closure to Java functional interface.
LAL Spec Classes: Consumer Overloads
LAL spec classes (
FilterSpec,ExtractorSpec,SinkSpec) get additional method overloads that acceptjava.util.function.Consumerinstead of Groovy closures:Both overloads coexist — no breaking change to existing Groovy-based usage.
What Gets Removed
GroovyShell.parse()calls inDSL.java(MAL) andDSL.java(LAL)ExpandoMetaClassregistration inExpression.empower()ExpressionDelegate.propertyMissing()dynamic dispatchgroovy.lang.ClosureinSampleFamilymethod signaturesgroovy-5.0.3.jarfrom runtime classpath (test-only dependency)Transpiler Statistics
Benefits Summary
invokevirtual— JIT-inlineablesf.multiply(n))Closurewith delegate/owner/rehydrateLambdaMetafactorycapturespropertyMissing()via MOP chain + ThreadLocalHashMap.get()— single callGroovyShell.parse()invocationsjavaccatches type errors)CallSite,MetaClassImpl)Upstream Plan
This is a fully implemented, tested, and production-running feature in the GraalVM distro. The upstream change is a straightforward module-level replacement — no coexistence, no gradual migration.
Module Structure
How It Works
mal-lal-v1— Move existing Groovy-basedmeter-analyzerandlog-analyzerinto this module. It has the full Groovy dependency. This module is not on OAP's runtime dependency tree — it exists solely for comparison testing.mal-lal-v2— The transpiler modules (mal-transpiler,lal-transpiler) run atmvn compiletime. They parse all MAL/LAL YAML files, walk the Groovy AST, and generate pure Java source intometer-analyzer-v2/log-analyzer-v2. Groovy is a build-time dependency of the transpiler only —v2runtime modules have zero Groovy dependency.mal-lal-v1-v2-checker— Depends on bothv1andv2. Runs 1,300 dual-path comparison tests: for every MAL expression and LAL script, executes via Groovy (v1) and via transpiled Java (v2), asserts identical results. This is the safety net that guarantees the transpiler produces semantically correct output.OAP main dependency tree — Only includes
v2. No Groovy on the runtime classpath. The server loadsMalExpressionandLalExpressionimplementations directly — pure Java, direct method calls, JIT-optimizable.What This Means
CompilationUnitatCONVERSIONphase) to extract AST. No Groovy bytecode, no MOP, noExpandoMetaClassat runtime.mal-lal-v1andmal-lal-v1-v2-checkercan be removed entirely. The transpiler's own unit tests provide sufficient coverage going forward.Source Code
The full implementation is available at: https://github.com/apache/skywalking-graalvm-distro
Key files:
build-tools/precompiler/.../MalToJavaTranspiler.java(~1,230 lines)build-tools/precompiler/.../LalToJavaTranspiler.java(~950 lines)oap-graalvm-server/src/test/.../graalvm/mal/(73 test classes, 1,281 assertions)oap-graalvm-server/src/test/.../graalvm/lal/(5 test classes, 19 assertions)docs/mal-immigration.md,docs/lal-immigration.mdWe welcome feedback on the approach and the upstream plan.
Beta Was this translation helpful? Give feedback.
All reactions