cats-eo

Allocation-free stream triage demo

A demo, not a module. The code lives in demos/grpc-filter and is not published. It shows what eo-jsoniter's byte-level optics buy a streaming service: a filter on a gRPC stream that classifies each message and forwards only the ones a policy keeps, without ever decoding a message, written with fs2.

The idea: read three values, not 64 fields

An upstream service streams issues to a downstream one. They travel as gRPC messages with JSON payloads, raw bytes off a TCP socket. Each issue has 64 fields. One of them, detail, says what kind of issue it is: a bug, an incident or a feature request, each with its own fields, nested up to three objects deep. A filter between the two services keeps:

and drops everything else.

To decide, the filter needs the kind and one or two values of the kind's own. The usual way decodes the whole issue to read them: 64 fields, lists, nested objects, about 8.5 KB of objects per 4 KB message. This filter reads them where they are, in the bytes, with eo-jsoniter's prisms. It never builds an Issue, and it forwards the messages it keeps exactly as they came.

upstream service ──TCP──▶ filter ──TCP──▶ downstream service
                           │
   bytes ─ GrpcFrames.splitFrames ─▶ message ─ Wire.detail ⇀ DetailJson ─ Policy.keep ─▶ keep?
                                                                                           │
   bytes ◀─ GrpcFrames.joinFrames ◀─ the kept messages, byte for byte ◀────────────────────┘

The stream

gRPC frames every message the same way, whatever its encoding: one byte saying whether the message is compressed, its length as a 4-byte big-endian unsigned integer, then the message (the Length-Prefixed-Message of the gRPC over HTTP/2 spec). gRPC is not tied to protobuf: a service can carry JSON (application/grpc+json), and this one does.

GrpcFrames holds the framing as two fs2 pipes over raw bytes. splitFrames cuts messages out of the bytes in whatever pieces the socket reads them, and fails the stream on a compressed frame (no message encoding is negotiated here), on a message over gRPC's default 4 MiB limit, or on bytes that end inside a frame. joinFrames frames messages again, without copying them.

socketBytes                           // Stream[F, Byte], as the socket reads it
  .through(GrpcFrames.splitFrames())  // Stream[F, Array[Byte]]: one per message
  .through(GrpcFrames.joinFrames)     // Stream[F, Byte]: the same frames again

The demo puts these frames straight on TCP rather than inside HTTP/2 streams: the subject is what happens to the message bytes, and over HTTP/2 the same frames arrive as the body of a call. So the demo does not speak HTTP/2, and has no headers, trailers or status codes.

The message

final case class Issue(
    id: Long,
    key: String,
    title: String,
    description: String,
    reporter: Person,          // Person → Team → Org: three objects deep
    // … 55 more …
    detail: Detail,            // the 61st field
    tags: Map[String, String],
    checklist: List[CheckItem],
    revision: Long,
)

enum Detail:
  case Bug(component: String, build: String, reproducible: Boolean, trace: Trace)
  case Incident(severity: Severity, startedAt: Long, mitigated: Boolean, impact: Impact)
  case Request(product: String, votes: Votes, quarter: String, requester: Customer)

final case class Impact(users: Long, errorRate: Double, service: Service)
final case class Service(name: String, tier: Tier, region: String)   // detail → impact → service

jsoniter-scala writes a Detail as an object whose first field, "type", names its case: {"type":"Incident","severity":"Sev1",…}.

The issues come from ScalaCheck generators that scalacheck-derived derives from the model, one per case class and enum, from its fields and cases:

object Issues:
  given Arbitrary[String] = /* words; one in twenty is something JSON must escape, or non-ASCII */
  given Arbitrary[Double] = Arbitrary(Gen.choose(0.0, 1e6))      // JSON has no NaN or infinities
  given Arbitrary[Module] = Arbitrary(Gen.oneOf(modules))
  given Arbitrary[Votes] = Arbitrary(Gen.choose(0, 200).map(Votes(_)))
  given Arbitrary[Detail] = deriveArbitrary
  given Arbitrary[Issue] = deriveArbitrary

Only the leaves are written by hand, so a field added to the model is generated without touching the generators. A third of the issues are of each kind. The stream draws them at size 7: up to seven words in a text and seven elements in a list, which makes a message 2.6 to 5.9 KB, 4.1 KB on average. The property tests draw them at every size from 2 to 40.

Reading without decoding

Wire holds the optics over a message's bytes. Each scans the bytes for its path and decodes only what it focuses:

object Wire:
  opaque type DetailJson = Array[Byte]     // a detail's own JSON, sliced out of a message

  // Message ⇀ its detail's bytes: one scan, past the 60 fields in front; nothing decoded.
  val detail: Focus[Array[Byte], DetailJson] = JsoniterPrism[Issue].field(_.detail).raw

  given kind: Focus[DetailJson, Kind] = /* JsoniterPrism.fromPath[Kind]("$.type") */
  given severity: Focus[DetailJson, Severity] = JsoniterPrism[Detail.Incident].field(_.severity)
  given tier: Focus[DetailJson, Tier] =
    JsoniterPrism[Detail.Incident].field(_.impact).field(_.service).field(_.tier)
  given module: Focus[DetailJson, Module] =
    JsoniterPrism[Detail.Bug].field(_.trace).field(_.frames).at(0).field(_.module)
  given plan: Focus[DetailJson, Plan] =
    JsoniterPrism[Detail.Request].field(_.requester).field(_.contract).field(_.plan)
  given votes: Focus[DetailJson, Votes] = JsoniterPrism[Detail.Request].field(_.votes)

Reading takes two steps. detail scans the message once and hands out the detail's own bytes, a few hundred of them (.raw focuses the encoded slice rather than a decoded value). The other optics read the policy's leaves from that slice: a kind, an enum, a number. DetailJson is an opaque type, so the detail's optics can't be pointed at a whole message by mistake.

The paths are typed: .field(_.impact) is checked against the model when Wire compiles, so renaming a field breaks the build, not the filter. Each case's paths start from that case's own root, JsoniterPrism[Detail.Incident], because the detail's JSON is that case's JSON plus its "type". Only the kind's path is a string: "type" is the codec's tag, not a field of the model.

Leaf optics of different cases don't check the kind: tier on a bug's detail misses only because a bug has no impact. The policy classifies before it reads a leaf, and the tests check that the bytes and the decoded issue are always judged alike.

One policy, two readers

The policy is written once, against capabilities, and doesn't know what it reads:

final case class Policy(urgent: Set[Severity], tiers: Set[Tier], modules: Set[Module],
                        plans: Set[Plan], minVotes: Votes):

  def keep[D](detail: D)(using
      kind: CanGetOption[D, Kind],
      severity: CanGetOption[D, Severity],
      tier: CanGetOption[D, Tier],
      module: CanGetOption[D, Module],
      plan: CanGetOption[D, Plan],
      votes: CanGetOption[D, Votes],
  ): Boolean =
    kind.getOption(detail) match
      case Some(Kind.Incident) =>
        severity.getOption(detail).exists(urgent) && tier.getOption(detail).exists(tiers)
      case Some(Kind.Bug)     => module.getOption(detail).exists(modules)
      case Some(Kind.Request) =>
        plan.getOption(detail).exists(plans) && votes.getOption(detail).exists(_.count >= minVotes.count)
      case None => false

With Wire's givens in scope, D is DetailJson and the policy reads bytes. With Model's, D is a decoded Detail, read through eo-generics' derived prisms and lenses:

object Model:
  given tier: Focus[Detail, Tier] =
    prism[Detail, Detail.Incident]
      .andThen(lens[Detail.Incident](_.impact))
      .andThen(lens[Impact](_.service))
      .andThen(lens[Service](_.tier))
  // … one per leaf, and the kind

The baseline uses Model, after decoding the whole issue. So do the tests, which check that both readers judge every message alike. That is what consume via capability, construct via optic buys: the code that decides never names the optic or the format it reads.

def optics(policy: Policy)(message: Array[Byte]): Verdict =
  import Wire.given
  Wire.detail.getOption(message) match
    case Some(detail) => Verdict(Wire.kind.getOption(detail), policy.keep(detail))
    case None         => Verdict(None, keep = false)

def decoding(policy: Policy)(message: Array[Byte]): Verdict =
  import Model.given
  Issue.decode(message).map(_.detail) match
    case Some(detail) => Verdict(Some(Kind.of(detail)), policy.keep(detail))
    case None         => Verdict(None, keep = false)

A message of a kind the demo doesn't know, from a newer producer say, has no kind: the policy drops it, and the tally counts it as unclassified. The optics read it like any other message. A decoder of the full model rejects the whole message, and the baseline drops it for that reason.

The filter

def filter[F[_]: Async](verdict: Array[Byte] => Verdict, tally: Ref[F, Tally]): Pipe[F, Byte, Byte] =
  _.through(GrpcFrames.splitFrames())
    .chunks
    .evalMap { messages =>
      Async[F].delay(messages.map(message => (message, verdict(message)))).timed.flatMap {
        (took, judged) =>
          tally.update(_.add(judged, took)).as(judged.collect { case (m, Verdict(_, true)) => m })
      }
    }
    .unchunks
    .through(GrpcFrames.joinFrames)

Net puts it between two sockets. The upstream service streams frames to whoever connects. The filter reads up to 256 KiB per read (fs2's reads asks for 8 KiB, and on loopback the reads were the bottleneck), triages, and writes what it keeps to the downstream service. Reading, judging and writing run on separate fibers, with a few chunks buffered between them (prefetchN). The downstream service decodes everything it receives and checks it against the policy.

sbt "demoGrpcFilter/run 200000" runs the three of them on the loopback interface, once with each filter (after an untimed run of each to warm the JIT compiler up):

eo-jsoniter optics: 200000 messages, 813.3 MB in 2.21 s:
  91 k messages/s, 368 MB/s
  judging them took 7.1 µs per message; the rest is sockets and framing
  kind            seen      kept
  Bug            66199      8205
  Incident       63289     10745
  Request        70512     12500
  none               0         0
  downstream received 31450 messages (31450 kept); 31450 decode as issues, and the policy keeps 31450 of them
full decode (baseline): 200000 messages, 813.3 MB in 4.16 s:
  48 k messages/s, 196 MB/s
  judging them took 16.8 µs per message; the rest is sockets and framing
  …

Correctness

sbt demoGrpcFilter/test:

suite checks
GrpcFramesSpec any messages, joined into frames and cut into arbitrary pieces, split back whole; the frame layout; the size limit (unsigned lengths included); compressed frames and truncated bytes fail the stream
TriageSpec the optics judge every message exactly as decoding does: 3 000 issues the size the stream's are, under the default policy and under 100 random ones, and 500 random issues of every size under random policies, every kind both kept and dropped; the slice Wire.detail hands out is the detail's own JSON; pretty-printed JSON is read the same; an unknown kind, a missing detail and bytes that aren't JSON are dropped as unclassified
TriageSpec (laws) Model's optics pass cats-eo-laws' OptionalTests
PipelineSpec the whole filter, in memory with the bytes cut at random, and over real sockets: it forwards exactly the messages the decoded issues say to keep, byte for byte and in order, and its tally adds up; a malformed frame fails it

Benchmarks

JMH, in demos/grpc-filter-bench. Average time per message in ns (lower is better), error = 99.9 % confidence half-width; allocation per message from -prof gc (gc.alloc.rate.norm). Measured on a 4-vCPU Intel Xeon (2.1 GHz) container, OpenJDK 21.0.12, fs2 3.14.0, jsoniter-scala 2.41.2; three forks, 5 measurements after 3 warm-ups. A shared container is not a quiet benchmark machine: read the ratios, not the absolute values.

One message

TriageBench judges a pool of 256 messages, small enough to stay in cache, as a message just read from a socket is. decode decodes the whole issue and runs the policy on its Detail; optics slices out the detail and runs the policy on its bytes; detail is the slicing alone.

layout is where detail sits in the message: late is where the codec writes it (the 61st of 64 fields, about 90 % of the way in), first is the same message with detail moved to the front.

layout decode optics detail speed-up allocation
late 13 833 ± 620 ns, 8 557 B 5 929 ± 450 ns, 705 B 5 371 ± 219 ns, 367 B 2.3× 12× less
first 13 403 ± 707 ns, 8 548 B 1 207 ± 78 ns, 664 B 711 ± 63 ns, 431 B 11× 13× less

Decoding reads every byte, wherever detail is. The scan reads the bytes in front of detail, so its cost depends on where the producer puts the field: in the codec's own layout, nine tenths of the optics' time is the scan past the 60 fields in front of detail. It skips them at about the speed of jsoniter-scala's own skip. What it saves either way is the building: 700 bytes allocated per message instead of 8.6 KB.

The whole filter

StreamBench runs the filter's pipe in memory: 1 024 messages' frames, arriving in 256 KiB pieces, are cut into messages, judged, tallied and the kept ones framed again.

judge per message allocation
decode 17 695 ± 845 ns 13 917 B
optics 8 911 ± 449 ns 6 001 B

2.0× faster, with 2.3× less allocation. The pipe adds 3 to 4 µs and 5.3 KB per message either way. Most of that is copying each message out of the socket's chunks into an array of its own (4 KB), because a JsoniterPrism reads a whole Array[Byte].

Limits

Running it

# the suite
sbt demoGrpcFilter/test
# the three services on loopback, with each filter
sbt "demoGrpcFilter/run 200000"
# the benchmarks
sbt "demoGrpcFilterBench/Jmh/run -prof gc"

The demo and its benchmarks sit outside the root aggregate; CI runs the suite and compiles the benchmarks as their own step.