Skip to content

Register Blackbird module by default in JsonMessageFormatter#20

Merged
jkschneider merged 1 commit into
mainfrom
blackbird-default
May 10, 2026
Merged

Register Blackbird module by default in JsonMessageFormatter#20
jkschneider merged 1 commit into
mainfrom
blackbird-default

Conversation

@jkschneider
Copy link
Copy Markdown
Member

Summary

Register jackson-module-blackbird in the default JsonMessageFormatter builder. Blackbird generates LambdaMetafactory-backed property accessors so Jackson skips its reflective MethodHandle path. No protocol or schema change.

Why

Profiling a real mod run of org.openrewrite.node.migrate.upgrade-node-24 against the JavaScript Applications working set showed Jackson serialize/deserialize as a meaningful share of Java-side CPU on the RPC path — concentrated in BeanPropertyWriter.serializeAsField, BeanDeserializer.deserializeFromObject, and ConcurrentHashMap.get (the per-type serializer cache). The hot deserialize path is GetObject responses whose value field is a many-keyed nested Map, and field-count is exactly what the reflective accessor pays for.

Bench

JMH bench replaying a 100MB captured trace (5,661 frames; 1,266 GetObject responses; p99 message size 45KB; max 303KB):

Bench Jackson default + Blackbird
deserialize_max (303KB) 6283 µs 2887 µs (~2.2x)
serialize_max 1457 µs 992 µs (~1.5x)
deserialize_median (173B) 1.6 µs sub-µs

Synthetic NO_CHANGE-heavy fixtures saw no benefit, which is consistent with reflection cost scaling with field count: the small messages are too small to matter, the big ones are where Blackbird helps.

Error bars on the max-payload runs are wide (single 303KB allocation per iteration → GC noise), but every measurement put Blackbird ahead. The bench harness lives in a follow-up branch and the captured trace is reproducible by setting JSONRPC_CAPTURE_PATH during a mod run.

Tradeoffs considered

  • Cold-start cost: Blackbird generates one accessor class per bean type on first (de)serialize. Negligible for long-running flows like mod run. For very short invocations (mod --version) the AOT cache won't include the generated classes, so each run re-pays the small bytecode-gen cost.
  • Behavioral drift: Blackbird is a documented drop-in. RpcObjectData uses a slightly unusual Lombok @Value + private @JsonCreator + field-visibility deserialization pattern; the rewrite-rpc suite passes against this snapshot.
  • GraalVM native-image: Blackbird's runtime class-gen is incompatible. Doesn't apply to mod today.

Test plan

  • jsonrpc unit tests (./gradlew test) pass
  • rewrite-core RPC test suite passes against this snapshot:
    • RewriteRpcTest: 17 tests, 0 failures (2 skipped)
    • RpcSendQueueTest: 3/3 pass
    • RpcReceiveQueueTest: 5/5 pass
  • Run mod run end-to-end on a representative recipe and confirm no behavioral diff vs current main

Jackson's reflective property access (MethodHandleObjectFieldAccessorImpl)
shows up as a meaningful share of CPU on real OpenRewrite RPC traffic when
deserializing GetObject responses with many-keyed nested Maps. Registering
jackson-module-blackbird swaps the reflective accessors for
LambdaMetafactory-generated ones with no protocol or schema change.

Measured on a JMH bench replaying a captured trace from mod run
org.openrewrite.node.migrate.upgrade-node-24 against the JavaScript
Applications working set: roughly 1.5-2x on serialize/deserialize for the
max-size GetObject payloads. Synthetic NO_CHANGE-heavy fixtures see no
benefit, which is consistent with reflection cost scaling with field count.

rewrite-rpc test suite passes against this snapshot
(RewriteRpcTest 17/17, RpcSendQueueTest 3/3, RpcReceiveQueueTest 5/5).
@jkschneider jkschneider merged commit 4273b4f into main May 10, 2026
1 check passed
@jkschneider jkschneider deleted the blackbird-default branch May 10, 2026 15:17
jkschneider added a commit that referenced this pull request May 10, 2026
…ackson 2.21 (#21)

The Blackbird default registered in #20 was benched against Jackson 2.17.2,
where it showed a ~2x deserialize win. moderne-cli (the primary consumer) and
most current openrewrite components have moved to Jackson 2.21, where
Blackbird no longer wins on deserialize and *regresses* serialize by ~70%
on the real-trace JMH bench (vanilla 317±19 µs vs Blackbird 540±31 µs on a
303 KB GetObject payload — non-overlapping CIs).

End-to-end this manifests as visibly slower `mod run` in production after
1.0.8 shipped: BatchVisit p50/p99 times rose 4-12x / 4-65x compared to the
1.0.7 baseline on the JS Applications working set.

While here, bump jackson-module-parameter-names from 2.17.2 to 2.21.1 to
match what consumers force via their version-alignment rules; this keeps
the BOM resolution stable and avoids accidental version skew at runtime.

The streaming TokenBuffer-based deserialize from #18 stays — that's the
deserialize-side win Blackbird was attempting to chase, already shipped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant