Repository navigation
feat(demos): gRPC stream triage with eo-jsoniter and fs2 - #133
Merged
Merged
Conversation
A filter on a gRPC stream of JSON messages, read as raw bytes off a TCP
socket: it classifies every message and forwards only the ones a policy
keeps, without decoding any message.
- Each message is a 64-field Issue; its 61st field, `detail`, is a
three-case enum (Bug / Incident / Request) nested up to three objects
deep.
- Wire: `JsoniterPrism[Issue].field(_.detail).raw` slices the detail's
bytes out of a message in one scan (opaque `DetailJson`); typed per-case
leaf prisms read the few values the policy needs from that slice.
- Policy.keep is written once against CanGetOption. It runs on the bytes
(Wire's givens) and on a decoded Detail (Model's givens: eo-generics
prism/lens chains). The tests check that both judge every message alike.
- GrpcFrames: gRPC's Length-Prefixed-Message framing as fs2 pipes. Frames
may straddle chunks; compressed, oversized or truncated frames fail the
stream. No HTTP/2: the frames travel on plain TCP.
- Net: upstream service, filter and downstream service on loopback
sockets. Reads are 256 KiB (fs2's 8 KiB reads were the bottleneck).
Kept frames are forwarded byte for byte.
- Tests: framing over arbitrary chunkings; differential triage over 3 000
messages and 100 random policies; OptionalTests on Model's optics; the
whole filter in memory and over real sockets.
- JMH (demos/grpc-filter-bench), per message:
- judging one message: 4.6 µs / 700 B vs 10.1 µs / 9.4 KB decoding;
1.2 µs with `detail` first in the message;
- the whole filter pipe: 7.1 µs vs 13.6 µs.
- Outside the root aggregate; CI runs the suite and compiles the benches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5LJsPA524HKbSMvjStrv
scalafmt split the four-term shift expression mid-call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JG5LJsPA524HKbSMvjStrv
Contributor
|
🚀 Cloudflare Pages preview for https://68d62a4e.cats-eo-docs.pages.dev Branch alias: https://ccr-1ec1a317-uds4rn.cats-eo-docs.pages.dev Built from commit |
Contributor
Benchmark A/BAllocation (B/op) — authoritative
442 more benchmarks
Timing (ns/op) — directional only, same-VM but shared runner
base_sha: Informational only · head |
- Issues: the stream's issues come from ScalaCheck generators derived
from the model by scalacheck-derived. Only the leaves are written by
hand: text, JSON-safe doubles, and the opaque Module / Votes.
`Issues(seed)` samples at size 7 (about 4 KB per message), with a
`Seed.slide` per issue. Consecutive `Seed.next` seeds drew overlapping
values, which skewed the kinds.
- TriageSpec: a property over random policies and issues of every size
(2 to 40), and derived Arbitrary / Cogen for the policy and the enums.
- GrpcFrames: `decode` / `encode` are renamed `splitFrames` / `joinFrames`.
- `frame` builds a frame as a Chunk (header ++ message, the message not
copied), and `joinFrames` is `mapChunks(_.flatMap(frame))`.
- `split` is `unframe` (one frame, pure) run with `tailRecM`.
- Docs and benchmarks re-measured on the new messages: one message
judged in 5.9 µs vs 13.8 µs decoded (1.2 µs with `detail` first); the
whole pipe 8.9 µs vs 17.7 µs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5LJsPA524HKbSMvjStrv
- The left menu lists Demos before the Cookbook.
- `cookbook.md` (1 100 lines) is split into `cookbook/`:
- `README.md` is the section's landing page (rendered as
`cookbook/index.html`): the intro, the task index linking into the
pages, and further reading;
- one page per job: `navigating-structures.md`, `decoupling.md`,
`effect-threading.md`.
- Each page compiles on its own under mdoc, so each carries the imports
and definitions it uses. Effect threading re-declares the invoice
model it borrowed from Decoupling, and links back to it.
- Links into the cookbook from other pages point at the specific page
and recipe.
- `laika-static/_redirects` sends the old `/cookbook.html` to the
section (Cloudflare Pages), and the README link is updated.
Built with `docs/mdoc` and `docs/laikaSite` on JDK 25: no errors. The
only link warnings are mdoc's, and they predate this change (mdoc slugs
`what's` differently from Laika).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG5LJsPA524HKbSMvjStrv
- `site/docs/demos/` moves into `site/docs/cookbook/`, and the Demos menu entry goes away. The two demos are the first entries in the Cookbook's list, retitled: - `grpc-filter.md` → "Allocation-free stream triage demo"; - `uplc-jvm.md` → "Blockchain smart contract compilation demo". - The Cookbook landing page introduces both demos. - Links to the old paths are updated: benchmarks, the jsoniter page, both demo READMEs, the UPLC walkthrough's comment and CLAUDE.md. - `_redirects` sends `/demos/*.html` to the new pages. Built with `docs/mdoc` and `docs/laikaSite` on JDK 25: no errors, and the sidebar shows Cookbook → the two demos, then the three recipe pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JG5LJsPA524HKbSMvjStrv
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JG5LJsPA524HKbSMvjStrv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A demo filter for a gRPC stream of JSON messages, read as raw bytes off a TCP socket. It classifies each message and forwards only the ones a policy keeps. It never decodes a message: it reads the few values the policy needs straight from the bytes with eo-jsoniter's prisms, and forwards the kept messages byte for byte.
Issuehas 64 fields. The 61st,detail, is a three-case enum (Bug/Incident/Request) nested up to three objects deep (detail → impact → service).Wire).JsoniterPrism[Issue].field(_.detail).rawslices the detail's bytes out of a message in one scan. They come back as the opaqueDetailJson, so the detail's optics can't be pointed at a whole message.JsoniterPrism[Detail.Incident].field(_.impact).field(_.service).field(_.tier)."type"tag, because it is the codec's tag and not a model field.Policy.keepis written once, againstCanGetOption:Wire's givens it reads bytes;Model's givens it reads a decodedDetail, through eo-genericsprism/lenschains;Model, and the tests compare the two readers on every message.GrpcFrames.splitFrames/joinFrameshold gRPC's Length-Prefixed-Message framing as pipes. Frames may straddle chunks any way. Compressed, oversized or truncated frames fail the stream.joinFramesdoesn't copy messages: each frame is a header chunk followed by the message's own chunk.Netputs an upstream service, the filter and a downstream service on loopback sockets. Sockets are read 256 KiB at a time, because fs2's 8 KiBreadswas the bottleneck.Demoruns all three on loopback, once with each filter.Tests
sbt demoGrpcFilter/test: 35 tests, all passing.GrpcFramesSpec: messages come back whole however the bytes are cut. Also checks the frame layout, the size limit (lengths are unsigned), and that compressed frames and truncated bytes fail the stream.TriageSpec:detailand bytes that aren't JSON are all dropped as unclassified.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 kept messages, byte for byte and in order, and its tally adds up. A malformed frame fails it.Benchmarks
JMH in
demos/grpc-filter-bench. 3 forks, on a 4-vCPU 2.1 GHz Xeon container with JDK 21. Numbers are per message.detailmoved to the frontOver loopback sockets,
sbt "demoGrpcFilter/run 200000"filters 91 k messages/s with the optics and 48 k with full decoding.Most of the optics' time is the scan past the bytes in front of
detail, so the speed-up depends on where the producer puts the field: 2.3× when it is the 61st field, 11× when it is first. Allocation is 12–13× lower either way.Build, CI and docs
Modules. Two unpublished modules, both outside the root aggregate:
demoGrpcFilter(fs2-io 3.14.0, jsoniter-scala-macros, scalacheck, scalacheck-derived 0.10.0) anddemoGrpcFilterBench.CI.
ci.ymlis regenerated: the format and scalafix checks now cover the demo, and a new step runsdemoGrpcFilter/test demoGrpcFilterBench/Jmh/compile.Docs.
Site: the Cookbook becomes a section, and the demos move into it. The left menu now reads:
cookbook/README.md) holds the intro, an introduction to both demos, the task index and further reading. The one-page cookbook is split into the three recipe pages.laika-static/_redirectssends the old/cookbook.htmland/demos/*.htmlto their new pages on Cloudflare Pages.docs/mdocanddocs/laikaSiteon JDK 25 with no errors.🤖 Generated with Claude Code
https://claude.ai/code/session_01JG5LJsPA524HKbSMvjStrv