Skip to content

Streaming RawJson deserialize + hot-path cleanups (1.15-1.60x round-trip)#18

Merged
jkschneider merged 1 commit into
mainfrom
streaming-rawjson-and-hotpath
May 7, 2026
Merged

Streaming RawJson deserialize + hot-path cleanups (1.15-1.60x round-trip)#18
jkschneider merged 1 commit into
mainfrom
streaming-rawjson-and-hotpath

Conversation

@jkschneider
Copy link
Copy Markdown
Member

Summary

Two compounding optimizations on the hot path of io.moderne.jsonrpc, the IPC layer for OpenRewrite multi-language work. Round-trip end-to-end measured on a paired-pipe dispatch loop (5000 iterations):

Payload Baseline This PR Speedup
tiny 48.9 μs 42.4 μs 1.15×
medium 43.1 μs 35.7 μs 1.21×
large (3.5KB) 168 μs 105 μs 1.60×

Caveat: artificial bench. Real-workload (JFR on a real OpenRewrite recipe) deferred — see Test plan.

Phase 2 — RawJson wrapper + streaming deserialize

  • New io.moderne.jsonrpc.RawJson: library-owned wrapper holding either a Jackson TokenBuffer (inbound, lazy materialization) or a POJO (outbound). Keeps Jackson out of the public ABI.
  • JsonRpcRequest.params and JsonRpcSuccess.result change from Object to RawJson. JsonRpcRequest.newRequest(method, POJO) factory keeps working.
  • JsonMessageFormatter.deserialize: full streaming via JsonParser + TokenBuffer, no intermediate JsonNode tree. convertValue unwraps RawJson for either POJO or TokenBuffer.

The streaming path moves cost from RECEIVE → DISPATCH (deferred conversion). On large payloads RECEIVE p50 dropped 64.9 → 46.6 μs (1.39×) while DISPATCH p50 rose 18.8 → 22.7 μs — net win is the streaming saving.

Phase 3 — Hot-path cleanups

  • JsonRpcMethod — paramType resolved once in the constructor instead of per dispatch via getClass().getGenericSuperclass().
  • JsonRpcIdDeserializer — direct JsonToken inspection, no JsonNode allocation per inbound id.
  • internal/SnowflakeId — lock-free generation via single packed AtomicLong (timestamp << SEQUENCE_BITS | sequence), preserving millisecond monotonicity. New concurrency test in SnowflakeIdTest.
  • HeaderDelimitedMessageHandler / NewLineDelimitedMessageHandler — wrap input in BufferedInputStream when the caller hasn't already.
  • MeteredMessageHandler — pre-build the four no-error Timers in the constructor; cache error-tagged Timers in a bounded ConcurrentHashMap (cap 64) to avoid the cardinality trap of unbounded per-method tags.

ABI note

JsonRpcRequest.params and JsonRpcSuccess.result are now typed RawJson rather than Object. Consumers that previously cast to Map will get a deterministic compile-time error rather than silent misbehavior. Major-version bump expected.

Test plan

  • ./gradlew test passes
  • SnowflakeIdTest confirms no duplicate ids under concurrent generation
  • Existing JsonRpcTest round-trip suite passes against the streaming deserializer
  • JFR profile on a real OpenRewrite multi-language recipe (deferred to a follow-up — gate criterion for any subsequent MessagePack work)

Round-trip end-to-end measured on a paired-pipe dispatch loop (5000
iterations): tiny 1.15x, medium 1.21x, large (3.5KB) 1.60x.

Phase 2 — RawJson wrapper + streaming deserialize
- New io.moderne.jsonrpc.RawJson: library-owned wrapper holding either
  a Jackson TokenBuffer (inbound, lazy materialization) or a POJO
  (outbound). Keeps Jackson out of the public ABI.
- JsonRpcRequest.params and JsonRpcSuccess.result change from Object
  to RawJson. JsonRpcRequest.newRequest(method, POJO) factory keeps
  working.
- JsonMessageFormatter.deserialize: full streaming via JsonParser +
  TokenBuffer, no intermediate JsonNode tree. convertValue unwraps
  RawJson for either POJO or TokenBuffer.

Phase 3 — hot-path cleanups
- JsonRpcMethod: paramType resolved once in the constructor instead
  of per dispatch via getClass().getGenericSuperclass().
- JsonRpcIdDeserializer: direct JsonToken inspection, no JsonNode
  allocation per inbound id.
- internal/SnowflakeId: lock-free generation via single packed
  AtomicLong (timestamp << SEQUENCE_BITS | sequence) preserving
  millisecond monotonicity. New concurrency test in
  SnowflakeIdTest verifies no duplicates under contention.
- HeaderDelimitedMessageHandler / NewLineDelimitedMessageHandler:
  wrap input in BufferedInputStream when the caller hasn't already.
- MeteredMessageHandler: pre-build the four no-error Timers in the
  constructor; cache error-tagged Timers in a bounded
  ConcurrentHashMap (cap 64) to avoid the cardinality trap of
  unbounded per-method tags.
@jkschneider jkschneider merged commit 904b464 into main May 7, 2026
1 check passed
@jkschneider jkschneider deleted the streaming-rawjson-and-hotpath branch May 7, 2026 02:04
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