diff --git a/frameworks/libuvcpp/Dockerfile b/frameworks/libuvcpp/Dockerfile index 0060c5f8c..a38473ca5 100644 --- a/frameworks/libuvcpp/Dockerfile +++ b/frameworks/libuvcpp/Dockerfile @@ -37,12 +37,202 @@ ENV DEBIAN_FRONTEND=noninteractive # and the bytes under it re-uploaded, so a SHA is the only thing that fixes # which revision gets built. `3fdd6208` was `v1.6.1` -- what the first version # of this entry built, and what the platform measured as the board's 58 req/s on -# `baseline-h3-64`. The pin has since moved twice: to `2d5fe74` (`1.6.20-dev`), -# and then to `6eede33` (`1.6.51-dev`), which was the tip of the library's -# `master` when it was taken and is the revision the platform's fourth run -# measured. Each move was measured against the previous pin before being taken; -# the criteria for the `6eede33` batch are further down this file, and each -# commit's own criterion is in the library's `CHANGELOG.md`. +# `baseline-h3-64`. The pin has since moved six times: to `2d5fe74` +# (`1.6.20-dev`), then to `6eede33` (`1.6.51-dev`), which was the tip of the +# library's `master` when it was taken and is the revision the platform's fourth +# run measured, then to `8150720` (`1.7.2-dev`), which is the `v1.7.0` tag plus +# one documentation commit and two that change code, then to `02ae418` +# (`1.7.2-dev`), which is four code commits past it and is what the sixth run +# carried, then to `1f6a8fb` (`1.7.2-dev`), which is two code commits and a +# documentation commit past `02ae418` and is what the seventh run carried, and +# now to `0f34937` (`1.7.2-dev`), which is two code commits and four +# documentation commits past `1f6a8fb` and is the tip of the library's `master` +# when it was taken. +# Each move was measured against the previous pin before being taken; the +# criteria for the `6eede33` batch are further down this file, and each commit's +# own criterion is in the library's `CHANGELOG.md`. +# +# The `02ae418` move was the pin the sixth run carried, and like the `8150720` +# move it is a batch whose members carry local deterministic counters rather than +# on-board measurements. The four code commits between the two pins -- the other +# two are documentation -- and what each one's criterion is: +# +# * `e716010` stops the database path from building a log line when no log +# callback is installed: `106.16 / 106.15 -> 102.18 / 102.22` allocations +# per response (A B B A), with `utime + stime` unmoved -- 3.96 allocations +# is about 0.12 us, under the rig's resolution, so the allocation count is +# the criterion. `async-db` is Reference-only and contributes nothing to +# the score; this one is a counter and nothing more. +# * `3cbfc9a` and `370d6ef` are the QUIC send path. The first lets one +# datagram carry STREAM frames from several streams (`sendmsg` per request +# `1.0316 / 1.0360 -> 0.0428 / 0.0561`), the second sizes each send chunk +# from what that write needs instead of reserving 64 KiB flat (`72 374.8 -> +# 7 507.5` bytes allocated per request, `-89.6%`, peak RSS 91 -> 50 MiB on +# the local h3 rig, where the flat reserve is the same shape as the 238 MiB +# the board reports for `baseline-h3-64`). Both are measured on the h3 +# profiles this entry subscribes to, on this machine's rig rather than the +# board's, and are not offered as predictions. +# * `02ae418` lets `resp.json()` hand the dumped body to the response instead +# of copying it: `-1.0000` `memcpy` per request and `-8 397` bytes copied +# per request -- the body's own length, so it is that copy and not some +# other one -- with `-0.83%` instructions per request on `/json/50?m=6`. +# The instruction figure is 0.83% of one handler, smaller than the spread +# between two board readings; the counters are the criterion. +# +# The `1f6a8fb` move is for the next run and is the shortest batch so far: two +# code commits and a documentation one past `02ae418`, neither of the two +# touching a public header, so this entry needs no code change for it. One of the +# two cannot reach this build at all, and the other is the one that matters. +# +# * `0aaa415` gives the brotli encoder the ~700 KiB of state it needs per +# request out of a thread-local arena instead of the heap. brotli has no +# `BrotliEncoderReset()` -- which is why the deflate arm can carry one kernel +# across requests and this one cannot -- so the state was created and +# destroyed on every request, two of its blocks are 256 KiB, and those are +# above glibc's 128 KiB threshold, so `free` handed them back to the kernel +# for the next request to fault in again. On the local `br` arm: `72.43 -> +# 0.00` minor faults per request and `508 -> 385` us of CPU per request, with +# a gzip arm as the negative control (`+0.8%`, no faults either way) and the +# compressed bytes bit-for-bit the same. The commit's own run of the same +# effect reads `72.165 -> 0.001` and `515.5 -> 398.5` us; the numbers already +# in this file above `ARG LIBUVCPP_REF` are this entry's rig on the same +# request. +# **This build compiles none of it**: the arena, the encoder +# call and the frame in between all sit under `#if UVCPP_BROTLI_ENABLE` in +# `src/web/uvcpp_http_compress.cpp`, and the flag below is +# `-DUVCPP_ENABLE_BROTLI=OFF`. It comes along because a pin is a commit and +# not a patch list. +# * `1f6a8fb` moves the two tables behind the static handler's `LOOP_STAT` +# cache lookup to a front copy per loop, so that the thread reading them is +# the thread that wrote them. Before it, every loop read the global tables +# under one shared lock and the lock was the ceiling: `mutex_lock` per request +# `2.03 / 2.04 -> 0.03 / 0.02`, time held `3 679 / 4 171 -> 969 / 1 259` ns, +# time waiting `7 761 / 9 368 -> 183 / 239` ns. At `1 / held` the old lock +# admits 240 000-270 000 requests a second, which is the order the board's +# `static-h2` rows sit at (about 235 000) while the same round's +# `json-tls-4096` spends 63.3 cores at a lower per-request cost -- so the +# static profiles were short of parallelism, not of work. The local +# end-to-end moved `+19.6%` (109 997 -> 131 608 req/s, two interleaved +# rounds, server on 9 cores with the generator pinned to the same machine), +# which is offered as direction only; the counter and the `1 / held` +# arithmetic are the criterion. This is the h1/h2 static handler; the h3 leg +# goes through `uvcpp_static_server` and does not take this path. +# +# The `0f34937` move is for the next run: two code commits and four documentation +# ones past `1f6a8fb`. One of the two does touch a public header -- `239fef0` +# changes the layout of `uvcpp_malloc_block_cache` in `uvcpp_alloc.h`, keeping +# its interface -- but this entry does not include that header (it is reached +# only from `uvcpp_buf.cpp` and `uvcpp_write.cpp`), so no entry change is needed +# and this build gets both sides of it from the same revision. Both commits are +# aimed at regressions the seventh run reported, and both carry a local +# deterministic counter as their criterion. +# +# * `239fef0` moves the free list of the buffer allocator's own blocks out of +# the blocks and into per-thread tables. The family serves 1-7 byte blocks -- +# a WebSocket frame header is two bytes and `echo-ws`'s payload is `"hello"` +# -- and the old scheme wrote the "next block" pointer into the block's first +# 8 bytes, so a block below that size could never go on the list: `put()` +# freed it in place and `try_take()` could never hit. Four blocks per +# WebSocket echo message went to `malloc` every time (3 x 5 bytes + 1 x 7 +# bytes, measured at the call site) and none of them was ever reused. On the +# local rig, message-allocations per message `11.255 -> 6.255` at `PIPE=1` +# and `3.692 -> 0.567` at `PIPE=16` (A B B A, the same entry binary with only +# the `.so` swapped, zero spread within a group), and `0.775 -> 0.730` us per +# message end to end with `PIPE=16` (`-5.8%`, 255 891 -> 267 986 msg/s). +# `echo-ws-pipeline-4096` is the profile it is aimed at: the seventh run put +# it at `29 440 675` req/s and `2.31` us of CPU per message, `-6.8%` in rate +# and `+13.8%` in CPU per request against the sixth run -- the largest +# single-profile regression in that round -- and its 5-byte payload means +# none of that cost can be amortised over bytes, so per-message overhead is +# the whole story. +# * `2872c20` gives the TCP read buffer a per-thread table of exact-size slots. +# libuv's `suggested_size` for a TCP stream is 64 KiB flat, so on the +# `pipeline = 1` profiles, where one read event is one request, every request +# costs a 64 KiB `malloc`/`free` pair. The pair is cheap by itself; what is +# not is dragging the large-bin `unlink_chunk` and the fastbin-consolidation +# scan into the hot path on profiles that already allocate heavily per +# response. callgrind on this entry, same URL, same binary: `/json/50?m=6` +# `4 550 109 494 -> 3 796 143 977` instructions (`-16.57%`, with `_int_malloc` +# 12.01% -> 4.86% and `malloc_consolidate` 4.72% -> 0.02%), `/baseline11` +# `-1.36%`. End to end on the local rig, same entry binary with only the +# `.so` swapped: `-14.1%` on `/json/50?m=6`, `-8.9%` on the h2c leg, and +# `baseline11` **neutral** (`11.40 -> 11.60` us, inside the spread). +# +# (The two ends of this one are worth stating together, because the json +# profiles are where the `nlohmann` DOM the entry answered with at the time +# costs the most and this is the only lever left on them: the change cannot +# touch the serialisation the profile measures, only the allocator traffic +# underneath it.) +# +# Neither is offered as a predicted board movement. On the h1 profiles the +# `-16.57%` of instructions is one handler's user-mode share of an end-to-end +# cost that those profiles spend mostly elsewhere, and the `-14.1%` and `-5.8%` +# figures are this machine's rig, not the board's. +# +# What this run also carries, and what is *not* in the pin, is the entry side: +# the JSON profiles answer through the framework's standard serializer again +# (see the `/json` note below). The fifth run measured the size of that change +# for us by accident: its tree had the entry on `uvcpp_json_writer` (9257623), +# the revision the maintainer then objected to, and it reported `async-db-1024` +# at `271.85` us of CPU per response against the fourth run's `407.2` -- a fall +# that the doc attributes to that one entry commit and to nothing in the library. +# This entry has the DOM path back, so its `async-db` and `json-*` rows read +# against the sixth run's -- the most recent one that also had it back -- and not +# against the fifth's, whose tree answered through `uvcpp_json_writer`. +# +# The pin this file now names carries the entry side one step further, and its +# `json-*` and `async-db` rows are *not* like-for-like with that run's either: +# both handlers build their bodies with the library's yyjson backend instead of +# the `nlohmann` DOM, so the serializer is a second variable on those rows +# alongside whatever the pin itself moved. That change is argued in full at the +# end of the pin block below, criterion first. +# +# The other entry-side change since the sixth run is on the h3 static leg, and it +# is the same shape of omission the JSON one was. `uvcpp_static_server` is a +# different handler from the `uvcpp_web_app` static code the h1/h2 legs use -- +# `uvcpp_web_app` has no QUIC, so `static-h3-64` has to mount this one -- and +# options set on one are not inherited by the other. Its `revalidate` had been +# left on the `POOL` default, so every `static-h3-64` request queued to the libuv +# thread pool to `stat()` there and came back through `after_work`; the h1/h2 +# legs have been on `LOOP_STAT` since `7095e92` (`af3b4fc` is where this branch +# got it). One line sets it here. On this machine's h3 rig, one line apart with +# everything else equal: `29 434 -> 38 280` req/s and `216.8 -> 193.3` us per +# request, the two arms' rep ranges not +# overlapping (29 129-30 454 against 33 855-42 360). The library's section 4.8 +# ran the same A/B on the same handler with `statx` per request as its control, +# and the control did not move (`2.24` on both arms) -- the mtime and size check +# still happens on every request, only the thread it happens on changed. +# +# The sixth run also turned the framework's brotli encoder on, and this tree turns +# it back off. That is the one change here that is not a like-for-like restoration +# and it is argued in full above `ARG LIBUVCPP_REF`; the short version is that the +# profile's score is quadratic in bytes per response, brotli buys 1.23x on that +# term, and it costs 1.5x of gzip's CPU even after the encoder state is no longer +# re-faulted every request. +# +# The `8150720` move, like the `02ae418` one above and for the same reason, is a +# batch whose criterion is *not* an on-board measurement. Its two commits carry +# a local deterministic counter as their criterion -- `ebeaa20` is `-1.90%` +# user-mode instructions per request on the h1 shape (22,459.6 -> 22,032.4 Ir, +# callgrind, the path normalisation collapsing to one function with a fast path +# for the already-normal form), and `8150720` is `204.78 -> 102.77` `malloc`s per +# response on the async-db shape (the result-set vectors now reserve, which +# `PQntuples`/`PQnfields` let them do exactly). Neither is offered as a predicted +# board movement. The h1 profiles +# are all `pipeline = 1`, so `-1.90%` of user-mode instructions is about `0.35%` +# of the end-to-end time on those profiles, which is smaller than the spread +# between two platform readings of the same profile; and `async-db` is +# Reference-only, contributing nothing to the board's score and showing up only +# in its own `cpu_per_req_us`, which is user plus system time where the change +# is on the user side. So both are accounted for as "the counter certainly went +# down", not as a score. The fifth run has since reported on them, and neither is +# separable in it: that round's rps carried an upward bias of up to 3.2% even on +# the profiles pinned by external factors, where the paragraph above puts +# `ebeaa20`'s `-1.90%` of user-mode instructions at about 0.35% of end-to-end +# time; and `async-db` is one row per round, where that same round's entry-side +# change moved the number by 135 us. The library-side +# criteria and the guards (byte-identical response bodies, the postgres suite +# byte-identical across arms, 127/127 ctest) are in its `CHANGELOG.md`. # # `6eede33` is also what the library's `v1.7.0` release carries, in the part # that decides behaviour: `git diff 6eede33 v1.7.0 -- src/ ':!src/db'` is one @@ -53,11 +243,16 @@ ENV DEBIAN_FRONTEND=noninteractive # the range is packaging, build system and CI, and only one piece of that # reaches the ELF build: an `--exclude-libs,ALL` link option that hides symbols # arriving from static archives and leaves the library's own API exported -- it -# changes the dynamic symbol table, not the code. So this entry's numbers on the -# board are the release's numbers. The pin stays on the commit rather than -# moving to the tag because moving it would put the entry's source a revision -# away from the revision the saved results came from, for a library that is -- -# where it counts -- the same one. +# changes the dynamic symbol table, not the code. So the fourth run's numbers +# are `v1.7.0`'s numbers, and the distance from the tag to this pin is small and +# fully accounted for: `git diff --stat v1.7.0 8150720 -- src/ ':!src/db'` is +# the version macros plus four files under `src/webapp/` -- all of them +# `ebeaa20`, the path normalisation -- and `git diff v1.7.0 8150720 -- src/db/` +# is `8150720`, the result-set reservation. The third commit in the range, +# `d9581d9`, is the download table in `README.md`: documentation only. The pin +# stays on the commit rather than moving to the tag because the two behavioural +# commits have no tag -- they are unreleased, and they are the whole reason this +# revision is here. # # The tip is a revision whose CI is green on all four platforms -- Linux, # macOS, MSVC and MinGW-w64 -- which is worth commits that do not otherwise @@ -139,7 +334,18 @@ ENV DEBIAN_FRONTEND=noninteractive # 0.71 segments/frame and 1,014k frames/s. # # The pin then sat on `perf(json): 写出器的键与字符串值改成一次追加` (c8950ae), the -# change this entry's `/json` handler was rewritten onto. `uvcpp_json_writer`'s +# change this entry's `/json` handler was rewritten onto. **The entry no longer +# uses it.** `uvcpp_json_writer` is an alternative JSON serializer -- the rule +# that lists "Alternative JSON serializers" under Tuned is `tuned.md`, and this +# entry is `mode: standard` -- so these profiles are answered by the framework's +# own JSON backend instead: an `nlohmann::json` DOM through `resp.json()` at the +# pin this paragraph was written at, and a `uvcpp_json_builder` tree written by +# `uvcpp_json_write` since the pin moved to `134f34d`. Both are the framework's +# backend, and the cost of not answering through the streaming writer is on +# `json-comp`, `json-tls` and `/async-db` -- the README's `/json` note is where +# that is recorded. The paragraphs below are kept as the record of why the pin +# moved when it did, not as a description of what this entry does now. +# `uvcpp_json_writer`'s # `key()` used to append the comma, the opening quote, the escaped body, the # closing quote and the colon separately -- four or five out-of-line # `std::string::append` calls per key, three per string value, 4073 of them on a @@ -197,11 +403,115 @@ ENV DEBIAN_FRONTEND=noninteractive # LRU and stops copying the whole body on a hit; an h1 pipeline's final # `Connection: close` no longer takes the earlier responses with it; and the h2 # outbound body is held by reference rather than copied per response. Two optional -# modules arrive in the same range and both default to OFF -- a yyjson parse -# backend and a brotli encoder -- so the platform's flag set above builds neither -# and the binary this Dockerfile produces is unaffected by them. +# modules arrive in the same range and both default to OFF -- a yyjson backend and +# a brotli encoder. Of the two, the yyjson one is now on in the flag set above, +# and the next paragraph is why; brotli stays off two paragraphs further down. +# +# `UVCPP_ENABLE_YYJSON`. The pin moves to `134f34d`, the tip of the library's +# `master` when it was taken. Three things are in that range. `a063645` gives the +# JSON backend a construction side and yyjson's own serializer, and that is what +# `/json` and `/async-db` answer through now -- it is the reason for this move. +# `638512b` takes three copies off the h1 dynamic hot path (`-11.03%` user-mode +# instructions per request on the library's own callgrind rig, which the commit +# sizes at `-3` to `-4%` end to end and does not claim as a board movement +# either); it is a library-side change and it is not a like-for-like factor on +# this entry's h1 rows against the previous run's. The library's fourth database +# backend (ODBC, PR #36) lands in the same range with its CI and packaging; it is +# off in the flag set below. Four CI legs on `134f34d` -- Linux, macOS, MSVC and +# MinGW-w64 -- are green. +# +# What changed, in one sentence: the handlers no longer build an `nlohmann::json` DOM +# per request. They build a `uvcpp_json_builder` tree and hand it to +# `uvcpp_json_write`, which is yyjson's own serializer (`yyjson_mut_val_write_buf`) +# -- and yyjson is a production-grade library the framework itself bundles, which +# is the standard rule's own exception +# ("If the framework itself bundles or officially recommends a specific library, that +# library is acceptable", `implementation-rules/frameworks/standard.md`). It is also +# the backend decision the shipped packages already make: all six release legs pass +# `-DUVCPP_ENABLE_YYJSON=ON` in their package builds, so this is the released +# library's own configuration rather than an entry-only one. +# +# The criterion is byte-identity, and it is met. Same library, two `main.cc`, 17 URLs +# -- `/json/0` through `/json/50` with and without `m`, `count` past the end of the +# dataset, one negative `count`, one `m` of a million, and five `/async-db` ranges +# against the seeded Postgres: every body `cmp`-equal, every status line identical, +# every `Content-Type` identical. The key order the DOM path got for free from +# `std::map` is spelled out in the builder -- insertion order is what a builder keeps +# -- and the nested `rating` object is rebuilt key by key, because the data file lists +# it `score, count` while the response has always answered `count, score`. Getting +# that wrong would have been a silently different body, not a crash. +# +# Measured on the local rig, the same library with only `main.cc` swapped, arms +# alternating over four rounds, the client at the shape these profiles actually use +# (`pipeline = 1`, 1024 connections -- `scripts/lib/profiles.sh` has `json-comp`, +# `json-tls` and `async-db` at pipeline 1, and depth past the library's documented +# per-connection cap answers `503`). `/json/50?m=6`: 228.60 -> 125.31 us of server CPU +# per request and 60,350 -> 89,605 req/s, the arms' four-round ranges not overlapping +# (228.60-303.57 against 125.31-156.23 us). `/async-db` at `min=1&max=500&limit=20`: +# 258.62 -> 158.95 us and 30,794 -> 41,120 req/s (258.62-319.94 against 158.95-194.29). +# Both arms answered zero non-2xx. Both arms also run the same library, so `638512b` +# and the ODBC batch are held constant and are in neither column. The dataset is +# additionally parsed once at startup by `uvcpp_json_parse` instead of being held as +# an `nlohmann::json`; that parse happens before the first request is accepted, so it +# is in neither figure. +# +# Why brotli is off, given that `json-compressed` is the one profile in this suite +# whose request asks for it (`Accept-Encoding: gzip, br`, byte for byte in +# `requests/json-gzip-*.raw`; equal `q` resolves to `br` by the rank rule in +# `web/uvcpp_http_compress.cpp`). It was on for exactly one run, the sixth, and that +# run measured it. Against the fifth run, whose tree built this flag out and answered +# with gzip, `json-comp-4096` went 335 926 -> 50 389 rps (-85.0%) with `CPU/req` +# 186.4 -> 1 238.2 us, and `json-comp-16384` 330 411 -> 59 118 with 196.9 -> 1 036.5 +# us. Both runs saturate the same 62 cores (62.4 / 61.3), so that is cost per request +# and not a core given up. Three other `json-*` profiles carry the same batch's +# standard serializer and pay 58-66 us each for it (`json-h2c-1024` 38.5 -> 96.6, +# `json-h2c-4096` 39.5 -> 98.4, `json-tls-4096` 87.4 -> 153.8), which leaves about +# 990 us of the json-comp increase on the encoder. +# +# The ~+31.5 us that was predicted for that encoder was measured single-threaded, and +# the cost is a function of concurrency too. Same entry, same request, server pinned +# to 8 cores, only `Accept-Encoding` swapped: gzip 257 us, `br` 508 us with 72.43 +# minor faults per request -- the encoder's ~700 KB of state being handed back to the +# kernel between requests and re-faulted in. Removing that churn is a real fix and +# the library's `master` now carries it (`0aaa415`'s thread-local arena -- with +# brotli off it is a no-op for this entry, which is why the pin now carrying it +# changes nothing in these rows; raising +# `MALLOC_MMAP_THRESHOLD_` and `MALLOC_TRIM_THRESHOLD_` does the same thing from +# outside, which is what made the cause unambiguous): faults 72.43 -> 0.00, `br` +# 508 -> 385 us. 385 against gzip's 257 is still 1.5x, and that is the ratio at its +# kindest -- 1.94x before the fix. The gap this machine cannot reproduce, the board's +# 990 us against the 251 us it measures here, is in the direction that makes `br` +# worse, not better. +# +# 1.5x is more than the profile pays for. Its score is `rps * (minBpr/bpr)^2`, so `br` +# buys `(1429.2/1288.4)^2 = 1.23` on the byte term and has to cost less than 1.23x of +# gzip to be worth having. It costs 1.5x with the known fix already applied. +# +# The other half of the earlier case -- that the profiles merely passing `br` through +# (gateway-64, gateway-h3, production-stack) are not CPU-bound and so collect the same +# ~11% smaller bodies for free -- protects a quantity that is not scored anywhere. +# `effectiveRps` and `minBpr` occur in exactly one file of this repository's profile +# documentation, `h1/json-compressed/implementation.md`; no other profile is scored on +# response bytes. `json-tls`, `json-h2c`, `async-db` and the h1 baselines send no +# `Accept-Encoding` at all. The static legs serve the `.br` twins `data/static` already +# ships, and that selection is runtime in `http_pick_precompressed_variants()`, +# unaffected by this flag in either direction. And no client here asks for `br` +# without also accepting gzip: `json-comp` sends `gzip, br`, the h2 and h3 load +# generators send `br;q=1, gzip;q=0.8`, and `scripts/validate.sh` reads "Must return +# Content-Encoding: gzip or br when Accept-Encoding is sent". So gzip is a valid +# negotiated answer on every profile, and no request comes back uncompressed. +# +# The profile's own guide settles it rather than merely permitting it. Its +# `standard=` rule asks for "the framework standard JSON serialization and the +# framework or engine's built-in response compression (middleware, filter, or +# equivalent)"; `tuned=` only adds "as long as the output is valid gzip or brotli"; +# and the worked example under its "Expected response" heading answers a request +# that carried `Accept-Encoding: gzip, br` with `Content-Encoding: gzip`. The page's +# title and its SEO title both name brotli, but no rule in it prefers one encoding, +# and this entry is `mode: standard` -- so the framework's own middleware, answering +# gzip, is what the guide asks for. ARG LIBUVCPP_REPO=https://github.com/Antruly/libuvcpp.git -ARG LIBUVCPP_REF=6eede33a0662631dcf741989e242b7028392cc12 +ARG LIBUVCPP_REF=134f34db3b7eba08290504480fa89062fde5a3b5 ARG OPENSSL_VERSION=3.5.0 @@ -227,6 +537,11 @@ ARG OPENSSL_VERSION=3.5.0 # file or directory`. Confirmed against this pin rather than assumed: `g++ -H` # resolves `` to `/include/zlib.h`, and it keeps resolving there # with the package gone, since `-I` is searched before the system directories. +# `libbrotli-dev` was listed here while `-DUVCPP_ENABLE_BROTLI` was ON (it is the +# package that keeps the encoder off the FetchContent branch, which would otherwise +# clone brotli at build time) and is dropped with the flag: the build out in the flag +# set below means CMake never looks for it, and the library's `` is +# inside one `.cpp`, so nothing in the installed tree reaches for the package either. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates cmake curl g++ git libpq-dev make ninja-build \ @@ -262,12 +577,31 @@ RUN git init . \ && git checkout FETCH_HEAD # `BUILD_SHARED_LIBS=OFF` makes every fetched dependency (libuv, llhttp, zlib, -# nghttp2, ngtcp2, nghttp3) a static library, and `OPENSSL_USE_STATIC_LIBS=ON` -# does the same for OpenSSL, so libpq is the only shared object the result -# needs beyond libc and libstdc++. `CMAKE_POSITION_INDEPENDENT_CODE=ON` is +# yyjson, nghttp2, ngtcp2, nghttp3) a static library, and +# `OPENSSL_USE_STATIC_LIBS=ON` does the same for OpenSSL, so libpq is the only +# shared object the result needs beyond libc and libstdc++. +# `CMAKE_POSITION_INDEPENDENT_CODE=ON` is # required, not defensive: BUILD_SHARED_LIBS=OFF also flips CMake's PIC default # to OFF, and a non-PIC libuv/llhttp cannot be linked into libuvcpp.so. # +# `UVCPP_ENABLE_YYJSON=ON` is the JSON backend the `/json` and `/async-db` +# handlers answer through, and it is the flag the six release legs already pass +# in their package builds (`release.yml`), so what this entry builds is the +# released configuration and not an entry-only one. It fetches yyjson 0.13.0 +# over FetchContent exactly like the other five dependencies and compiles its +# single `.c` into this same `libuvcpp.so` -- nothing extra is installed and +# nothing else in the image changes. The paragraph at the pin note above is the +# argument; the short version is that the profile's standard rule admits a +# library the framework itself bundles, and this one it bundles. +# +# `UVCPP_ENABLE_DB_ODBC=OFF` is stated rather than left to the default, and the +# default is why: the pin below carries the library's fourth database backend +# (`1607dff`), whose option defaults ON and needs `sql.h` plus a driver manager. +# This image installs neither, so CMake would force the backend off itself and +# say so in a configure-time warning -- the same end state, reached by a warning +# instead of a flag. The entry needs Postgres and nothing else, which is what the +# two `OFF`s next to it already say. +# # websocket, static files, h2 and h3 all come from these same flags -- there is # no second build for the TLS legs. RUN cmake -S . -B build -G Ninja \ @@ -285,6 +619,8 @@ RUN cmake -S . -B build -G Ninja \ -DUVCPP_BUILD_WEB=ON \ -DUVCPP_BUILD_WEBAPP=ON \ -DUVCPP_ENABLE_ZLIB=ON \ + -DUVCPP_ENABLE_YYJSON=ON \ + -DUVCPP_ENABLE_BROTLI=OFF \ -DUVCPP_ENABLE_OPENSSL=ON \ -DUVCPP_ENABLE_NGHTTP2=ON \ -DUVCPP_ENABLE_QUIC=ON \ @@ -293,6 +629,7 @@ RUN cmake -S . -B build -G Ninja \ -DUVCPP_ENABLE_DB_PGSQL=ON \ -DUVCPP_ENABLE_DB_MYSQL=OFF \ -DUVCPP_ENABLE_DB_SQLITE=OFF \ + -DUVCPP_ENABLE_DB_ODBC=OFF \ -DUVCPP_ENABLE_CAPI=OFF \ -DUVCPP_ENABLE_WSDL=OFF \ -DOPENSSL_USE_STATIC_LIBS=ON \ @@ -313,14 +650,23 @@ RUN g++ -std=c++11 -O2 -DNDEBUG -o /server main.cc \ FROM ubuntu:24.04 -# libstdc++ for the entry's own objects, libpq5 for the one shared library the -# build above could not absorb. apt pulls in libpq's transitive closure +# libstdc++ for the entry's own objects, and libpq5 for the one shared library the +# build above could not absorb. `libbrotli1` was the other one while +# `-DUVCPP_ENABLE_BROTLI` was ON: unlike zlib and libuv, which the project fetches +# and links statically, that flag takes the system copy, so `libuvcpp.so` carried +# `libbrotlienc.so.1` and `libbrotlidec.so.1` in its `DT_NEEDED` and the server would +# not start without them (on noble both are files of that one package -- the name is +# not `libbrotlienc1`, which exists on Debian but not here). With the flag off the +# library carries neither, and this stage does not need them. +# +# apt pulls in libpq's transitive closure # (libgssapi, libldap, libgnutls, libssl3t64, ...) on its own -- that OpenSSL # 3.0 is libpq's, and it is a different copy from the 3.5 the library carries # statically. Both live in the process; only libuvcpp.so's copy serves the h2 # and h3 listeners. RUN apt-get update \ - && apt-get install -y --no-install-recommends libstdc++6 libpq5 \ + && apt-get install -y --no-install-recommends \ + libpq5 libstdc++6 \ && rm -rf /var/lib/apt/lists/* COPY --from=build /opt/uvcpp/lib/libuvcpp.so /opt/uvcpp/lib/libuvcpp.so diff --git a/frameworks/libuvcpp/README.md b/frameworks/libuvcpp/README.md index 3494fe479..83bb8025c 100644 --- a/frameworks/libuvcpp/README.md +++ b/frameworks/libuvcpp/README.md @@ -5,16 +5,24 @@ libuvcpp on the libuv event loop, one loop per available core. ## Stack - **Language:** C++11 -- **Library:** libuvcpp 1.6.x ([repo](https://github.com/Antruly/libuvcpp)), +- **Library:** libuvcpp 1.7.x ([repo](https://github.com/Antruly/libuvcpp)), built from source in the image - **Build:** `cmake -DCMAKE_BUILD_TYPE=Release` on `ubuntu:24.04`, with libuv, - llhttp, zlib, nghttp2, ngtcp2, nghttp3 and OpenSSL 3.5 all built statically - into `libuvcpp.so`; the entry is `g++ -std=c++11 -O2 -DNDEBUG` + llhttp, zlib, yyjson 0.13.0, nghttp2, ngtcp2, nghttp3 and OpenSSL 3.5 all + built statically into `libuvcpp.so`; the entry is `g++ -std=c++11 -O2 -DNDEBUG` -The library is built from source rather than installed from the published -`libuvcpp-1.6.x-linux-x64` release package, because that package is built with -both database backends off — the release workflow asserts it with an `ldd` check -that fails if `libpq.so` appears — and `/async-db` needs the Postgres backend. +The library is built from source rather than installed from a published +`libuvcpp--linux-x64` release package. That package was the only +alternative when this entry pinned `v1.6.1`, and at that revision all six of the +release workflow's legs passed `-DUVCPP_ENABLE_DB_MYSQL=OFF +-DUVCPP_ENABLE_DB_PGSQL=OFF` — the workflow asserts it with an `ldd` check that +fails if `libpq.so` appears — while `/async-db` needs the Postgres backend. The +pin has since moved onto development revisions: the `v1.7.0` tag is the nearest +release, the pin is 37 commits past it, and a release package exists only for +a tag, so building the pinned SHA from source is the only way to build exactly +what the pin names. The `1.7.0` packages do carry both remote backends, behind +the runtime-load seam that is why the same `ldd` list still passes, so the +reason is now "the pin is not a release" rather than "the release cannot do it". Everything else about the build follows that same release recipe. The pinned revision installs `include/zlib.h` and `include/zconf.h` along with the library, which matters because `web/uvcpp_ws_parser.h` includes `` whenever @@ -124,54 +132,98 @@ the profile forbids the framework from doing the JWT verification itself. documented static handler. Nothing in the entry reads or caches static bytes itself — the handler revalidates each request against the file's size and `mtime` (seconds and nanoseconds), so a replaced file is served immediately. -- **JSON is serialized per request through the framework's own writer.** The body - is built by walking the live dataset with `uvcpp_json_writer` — libuvcpp's - documented streaming constructor, the one `doc/json-guide.md` describes — and - the `total` field is computed inside the loop, from the values the writer is - reading. Nothing is pre-serialized, cached, or spliced out of a previously - rendered buffer, and the response is byte-for-byte what the previous - implementation produced. What moved is what writing it costs: a key now goes - out in one `append` instead of four or five, which on this box takes - `/json/50?m=6` from 80.4 to 63.2 us of server CPU per request — a repeat run - put the same pair at 73.8 and 59.2 us — measured best-of-3 interleaved rounds - with the server pinned to 6 single-threaded cores and saturated at 100% of - them. With `Accept-Encoding: gzip, br` the pair is 155.0 to - 141.1 us: compressing the 8.4 KB body costs more than serializing it, so the - gain is smaller there, not absent. End to end (h2load, best of 3): 73.7k -> - 94.2k req/s over h2c and 81.0k -> 96.3k over h1. Against the copy-then-dump - shape this handler used before — each item duplicated into a second DOM and - the whole payload dumped, still in `main.cc` as `build_json_general`, the - fallback for any value the writer cannot reproduce byte-for-byte — the whole - path costs 3.0x less CPU per request (176.0 -> 59.2 us). - The dataset is now read out of the DOM once at startup into a flat array - (`flat_item` plus a single character pool, ~5 KB for all 50 items), so the - handler walks that instead of 50 `std::map` trees; the walk it replaces cost - about 140 ns per item. That takes the serialization step for `/json/50?m=6` - from 31.0 to 23.4 us (9 interleaved rounds in one process, median of the - per-round ratios 0.751 / 0.769 / 0.793 over three repeats) and 10.8-11.7% of - server CPU per request end to end (11 paired rounds: 0.883 and 0.893 in two - runs). Nothing is pre-rendered — every request still walks the live items and - writes their bytes. The flattening is not trusted on faith: - `build_flat_dataset` writes each item both ways and enables the fast path only - if every one matches the reference byte for byte at three multipliers, so a - dataset of any other shape stays on the reference path and says which path it - is on at startup. Parsing is now the framework's `uvcpp_json_parse` rather - than nlohmann's `parse` directly, so every JSON call the entry makes is one - the library publishes. +- **JSON is serialized per request by the framework's JSON backend.** The + handler walks the live dataset, builds a `uvcpp_json_builder` tree — a public + and documented API in `doc/json-guide.md` — and writes it out through + `uvcpp_json_write`, which is yyjson's own serializer. The + backend is the library's yyjson one; the Dockerfile turns it on with + `-DUVCPP_ENABLE_YYJSON=ON`, which is the flag all six release legs pass in + their package builds, so this is the shipped library's configuration and not + an entry-only one. `total` is computed inside the loop, per request. Nothing + is pre-serialized, cached, or spliced out of a previously rendered buffer. + + This is deliberate, and it is the one place this entry gives up speed for the + rules. The profile's JSON tests exist to measure serialization, Standard mode + asks for "the framework standard JSON serialization" + (`test-profiles/h1/json-compressed/implementation.md`) and for the + ecosystem's production-grade library over a hand-rolled alternative + (`implementation-rules/frameworks/standard.md`: "Experimental or hand-rolled + alternatives solely for benchmark performance are not permitted"), and the + same file's exception is what settles which library: "If the framework itself + bundles or officially recommends a specific library, that library is + acceptable" — yyjson is bundled by libuvcpp itself and compiled into the same + `libuvcpp.so` the entry links. `implementation-rules/frameworks/tuned.md` + lists "Alternative JSON serializers" among the things *Tuned* allows, and + `uvcpp_json_writer` — the library's streaming writer, a public and documented + API that `doc/json-guide.md` describes — is such an alternative serializer, so + this entry does not answer these profiles with it. The same applies to + `/async-db`, which used to walk the result rows straight into the writer. + Reading is the same rule the other way: `uvcpp_json_parse` and the + `uvcpp_json_get_*` accessors rather than nlohmann's `parse` and `value()` + directly, so every JSON call the entry makes is one the library publishes — + and none of them can throw out of a libuv callback, which is what those + accessors exist for. +- **The response bytes did not change.** The handlers used to build an + `nlohmann::json` DOM and send it through `resp.json()`; both now build a + builder tree and write it with yyjson. Over 17 URLs — `/json/0` to `/json/50` + with and without `m`, a `count` past the end of the dataset, a negative + `count`, an `m` of a million, and five `/async-db` ranges against the seeded + Postgres — every body is byte-identical between the two, with the same status + lines and the same `Content-Type`. The order a `std::map` sorted for the DOM + path is now spelled out in the builder, because insertion order is what a + builder keeps, and the nested `rating` object is rebuilt key by key for the + same reason (the data file lists it `score, count`; the response has always + answered `count, score`). +- **The cost of choosing the backend over the writer, measured.** At the + 1.7.2-dev pin this repository was built on, `/json/50?m=6` through the writer + cost 63.2 us of server CPU per request against the DOM path's 176.0 us on the + same 6 pinned cores, and the writer also skipped the per-item DOM copy the DOM + path makes. Those two figures are the ones this entry reported in earlier + revisions; they are recorded here as the size of the gap the rules ask it to + accept, not as a pair it still collects. What it does collect is the backend + swap itself, same library and only `main.cc` different, arms alternating over + four rounds at the shape these profiles use (`pipeline = 1`, 1024 + connections): `/json/50?m=6` went from 228.60 to 125.31 us of server CPU per + request and from 60,350 to 89,605 req/s (the four-round ranges do not overlap: + 228.60-303.57 against 125.31-156.23 us), and `/async-db` at + `min=1&max=500&limit=20` from 258.62 to 158.95 us and from 30,794 to 41,120 + req/s. Both arms answered zero non-2xx. - **Compression is the framework's built-in response compression** (gzip), which answers `Accept-Encoding` per request and adds no `Content-Encoding` when the - client sends none. The library has no brotli compressor, so an `Accept-Encoding: - br, gzip` request is answered with gzip for anything the handler compresses - live. + client sends none, at the library's own default level. The library also + carries a brotli encoder; the build turns it off (`-DUVCPP_ENABLE_BROTLI=OFF` + in the Dockerfile). It was on for one run, and that run is why it is off. + `json-compressed` is the one profile in the suite scored on response bytes + (`effectiveRps = rps * (minBpr / bpr)^2`), so it is the only place a smaller + body buys anything, and there the smaller body has to be paid for out of + requests per second. The two runs' own reported `bpr` put brotli at 1288.4 B + per response against gzip's 1429.2, so brotli's whole win on that one term is + `(1429.2 / 1288.4)^2 = 1.23`. Its cost was above that: 5.06x gzip's + per-request CPU on the board (1238.2 us against the 244.5 us that leg reads + with gzip, both including the serializer change the other json legs pay too), + and 1.94x on the same request measured here with the server pinned to 8 cores + (508.0 us against 257.0 us). So it is a net loss on the only term it can move. + Turning it off costs nothing else: `Accept-Encoding` is still honoured per + request, so a client that asks for gzip alone gets gzip; every client these + profiles use that offers `br` also accepts gzip (`gzip, br` on json-comp, + `br;q=1, gzip;q=0.8` on the h2 and h3 generators); and the static legs' + pre-compressed twins are announced the same way whichever encoder the build + links -- see the next bullet. The profile's own guide asks for what it gets: its + `standard=` rule wants "the framework or engine's built-in response compression + (middleware, filter, or equivalent)", its `tuned=` rule only adds "as long as the + output is valid gzip or brotli", and the worked example under "Expected response" + answers a request that carried `Accept-Encoding: gzip, br` with + `Content-Encoding: gzip`. Brotli appears in the page's title, but no rule there + prefers it, and this entry is `mode: standard`. - **Where the dataset ships a pre-compressed twin, the handler serves it** (`precompressed`, on for every static leg). 15 of the dataset's 20 assets carry a `.br` and a `.gz` beside them, and the static profiles send `Accept-Encoding: br;q=1, gzip;q=0.8`, so those 15 go out as the shipped brotli file with `Content-Encoding: br` and `Vary: Accept-Encoding`. This is the same thing every other entry does — nginx `gzip_static`, Caddy - `precompressed br gzip` (which this entry's own edge Caddyfiles already use), - actix `try_compressed()` — and the entry neither produces nor caches those - files: it is the framework's option, pointed at the dataset as mounted. A + `precompressed br gzip`, actix `try_compressed()` — and the entry neither + produces nor caches those files: it is the framework's option, pointed at the + dataset as mounted. A request for a file with no twin, or one that sends no `Accept-Encoding`, or a `Range` request, gets the live path unchanged. - **The libuv thread pool is sized at the top of `main()`.** Two things ride @@ -204,13 +256,25 @@ the profile forbids the framework from doing the JWT verification itself. between dialects, on purpose — `?` is a legal operator in Postgres for jsonb and hstore, so a translation layer could not tell the two apart. This is the entry's only Postgres-specific line. -- **`tags` is re-parsed.** Postgres returns JSONB as text (OID 3802 is not one - of the driver's mapped types, and an unmapped OID degrades to the string libpq - returned), and that string *is* the JSON array — so it is parsed back into an - array rather than spliced in as a quoted string. +- **`tags` is parsed, not spliced.** Postgres returns JSONB as text (OID 3802 + is not one of the driver's mapped types, and an unmapped OID degrades to the + string libpq returned), and that string *is* the JSON array — so it has to + reach the response as an array rather than as a quoted string. `copy_tags` + runs `uvcpp_json_parse` over it and copies the array into the builder — an + array keeps its order in both serializers, so it can be copied whole where an + object could not — and it is written out with everything else. It used to be + walked and copied through instead, which was faster and is what the writer + path did; see the `/json` note above for why that is not this entry's call to + make. +- **One serializer, one path.** Every response body this profile serves — + including the ones carrying `tags` — is built with the framework's JSON + backend and written by the framework's serializer. There is no second, + hand-written path to prove against the first. - **Failure is not fatal and not silent-in-the-wrong-way.** With no `DATABASE_URL`, or a pool that will not open, `/async-db` answers the - documented `{"items":[],"count":0}` and the other profiles serve normally. + documented empty result set — `{"count":0,"items":[]}`, built by the same + serializer and in the same key order as a populated one — and the other + profiles serve normally. - **At the profile's own generator shape the pool saturates without failing.** `scripts/lib/tools/gcannon.sh` drives this endpoint with `-r 25`, so each socket serves 25 requests and is then retired and replaced; 1024 is how many @@ -257,20 +321,22 @@ Three things about 8443/udp are worth stating, because they are deliberate: ### The compose stacks `gateway-64`, `gateway-h3` and `production-stack` are orchestrated by -`compose..yml`, all of which run `network_mode: host` and put a stock -unmodified `caddy:2-alpine` at the edge. There is no hand-written proxy in this -entry: the gateway profiles' rules exclude one ("No custom proxy -implementations"), and a Caddyfile that says `file_server` and `reverse_proxy` -is the whole of it. +`compose..yml`, all of which run `network_mode: host` and put a stock, +unmodified reverse proxy at the edge: `nginx:1.27-alpine` for `gateway-64` and +`production-stack`, `caddy:2-alpine` for `gateway-h3`. There is no hand-written +proxy in this entry: the gateway profiles' rules exclude one ("No custom proxy +implementations"), and a `nginx.conf` or `Caddyfile` that says `file_server` and +`reverse_proxy` is the whole of it. -| Profile | Compose file | Services | -|---|---|---| -| `gateway-64` | `compose.gateway.yml` | proxy + server | -| `gateway-h3` | `compose.gateway-h3.yml` | proxy + server | -| `production-stack` | `compose.production-stack.yml` | edge + cache + authsvc + server | +| Profile | Compose file | Edge | Services | +|---|---|---|---| +| `gateway-64` | `compose.gateway.yml` | `nginx:1.27-alpine` | proxy + server | +| `gateway-h3` | `compose.gateway-h3.yml` | `caddy:2-alpine` | proxy + server | +| `production-stack` | `compose.production-stack.yml` | `nginx:1.27-alpine` | edge + cache + authsvc + server | -- **The edge serves `/static/*` off the mounted disk itself**, with - `file_server { precompressed br gzip }` — never forwarded to the backend. That +- **The edge serves `/static/*` off the mounted disk itself** — Caddy's + `file_server { precompressed br gzip }`, or for the nginx edges an `alias` + plus `gzip_static on` — never forwarded to the backend. That is what the gateway rules require and what the profile measures: a front door doing TLS, compression and file I/O in front of an application server that answers only the dynamic endpoints. @@ -279,16 +345,52 @@ is the whole of it. the server's per-core loops through a pool of h1 keepalive connections; a single h2 connection to the backend would re-serialize them behind one connection's flow control. -- **The same Caddyfile covers gateway-64 and gateway-h3.** `protocols h1 h2 h3` - binds 8443 on tcp and udp from one server block; only the load generator - differs (`h2load --alpn-list=h3`). +- **The gateway and production edges are nginx; only the h3 stack is Caddy.** + `gateway-64` drives 512 connections × 32 streams = 16,384 concurrent requests, + and over the profile's HTTP/1.1 upstream hop that multiplexing is what the + edge costs. Caddy charges roughly 3× nginx per request there — 372–404 µs/req + for the three entries whose `gateway-64-512` edge is Caddy against 100–110 for + the three whose edge is nginx — which put the proxy at 96% of its 32 CPUs + while the server sat at 23% of its own. The same holds on `production-stack`, + where the edge also runs `auth_request` per `/api/*` call: measured at the + profile's own 256 reads + 32 writes over a four-core stand-in, Caddy's edge + cost 440 µs/req at 8.4k req/s and 392 µs/req at 9.5k once its unconfigured + `forward_auth` upstream was given a connection pool, against 101 µs/req at + 39.2k for nginx in the same shape, with the edge pinned at its core budget in + all three arms. `proxy/nginx.conf` and `proxy-production/nginx.conf` carry the + measurements. The profile lists "proxy choice" among the things entries + compete on, its own example compose uses nginx, and `production-stack`'s + architecture diagram names nginx and `auth_request` — so both are nginx. The + h3 profile cannot follow: h3 needs QUIC, and nginx has no HTTP/3 without a + custom build against a QUIC library, so `gateway-h3` keeps Caddy from + `proxy-h3/` and lives in its own compose file rather than sharing one + Caddyfile with `gateway-64` as it used to. +- **`worker_processes` in both nginx configs is a literal, coupled to the + compose `cpuset`.** nginx's `worker_processes auto` counts the *host's* online + CPUs, ignoring a container cpuset, and `worker_cpu_affinity auto` is worse: it + re-pins each worker to a CPU of its own choosing and leaves the cpuset + entirely. The shipped values are 32 for `proxy/nginx.conf`, matching + `PROXY_CPUSET`'s default of `0-15,64-79`, and 30 for + `proxy-production/nginx.conf`, matching `EDGE_CPUSET`'s `0-14,64-78`; change a + cpuset and its number has to change with it. +- **The nginx edges serve the gzip twin of a static asset, not the brotli + one.** The framework's own static legs pick brotli, but the gateway and + production edges are a stock `nginx:1.27-alpine` and brotli needs + `nginx-mod-http-brotli`, which cannot be loaded into the official image's + nginx (nginx checks the build signature, so the module has to come from the + same build as the binary). Serving brotli from a distribution's nginx plus its + module package was measured on the rig at the gateway profile's concurrency — + 47,275 B of `app.js` against gzip's 54,813, medians of nine runs — and the two + came out within the rig's own run-to-run spread, so they serve the `.gz` + sibling like the profile's other nginx edges do. Both encodings are announced + in `Vary: Accept-Encoding`. - **The server is not given `/certs`.** `main.cc` opens its 8081 and 8443 listeners only when the certificate pair is readable, so with none mounted it comes up on its two cleartext ports and leaves every TLS port to the edge. Mount the certificates into both services and the two race for 8443. - **The backend's ports are 8085/8086, not 8080/8082.** Both ends of the hop are set together — `H1_PORT`/`H2C_PORT` in the compose `server`'s environment and - the upstream in the Caddyfile. The stack runs on the host network, and 8080 + the upstream in the edge's config. The stack runs on the host network, and 8080 and 8082 are exactly the two ports something else can already be holding there, so the backend is given ports of its own rather than racing for the entry's defaults. 8080 is still opened, but as the readiness probe's landing @@ -298,11 +400,15 @@ is the whole of it. one idle loop. - **`production-stack`'s JWT verification is the shared sidecar, unmodified** — `frameworks/_shared/authsvc/`, built from the same directory every entry uses. - The edge fires `forward_auth` at it for every `/api/*` request and copies back - the `X-User-Id` it resolves; the token cost is not cached, because that cost is - part of what the profile measures. The server never parses a token, never reads - `Authorization`, and separately returns 401 for any `/api/*` request that - arrives with no `X-User-Id` at all. + The edge fires an auth subrequest at it for every `/api/*` request (`auth_request` + for nginx, `forward_auth` for Caddy) and copies back the `X-User-Id` it + resolves; the token cost is not cached, because that cost is part of what the + profile measures. The server never parses a token and separately returns 401 + for any `/api/*` request that arrives with no `X-User-Id` at all. The nginx + edge additionally drops the `Authorization` header before forwarding, so the + token cannot be re-verified downstream; Caddy's `forward_auth` passes it + through — checked against a header-dumping backend rather than assumed — which + is not a violation either, just the looser reading. - **Cache-aside is in-process.** The profile's implementation guide lists "in-process only: no Redis for caching, just a local hashmap with TTL" beside the L1+L2 and Redis-only options, and this entry takes it: the profile runs one diff --git a/frameworks/libuvcpp/compose.gateway-h3.yml b/frameworks/libuvcpp/compose.gateway-h3.yml index 0a784696b..579f9cb73 100644 --- a/frameworks/libuvcpp/compose.gateway-h3.yml +++ b/frameworks/libuvcpp/compose.gateway-h3.yml @@ -1,11 +1,14 @@ # gateway-h3 — the same two-service stack as compose.gateway.yml, exercised # over HTTP/3 at the edge. # -# Nothing about the stack changes: stock Caddy binds 8443 on both tcp and udp -# from the same `protocols h1 h2 h3` server block, so the proxy config is -# byte-identical and only the load generator differs (h2load with -# `--alpn-list=h3`). The upstream hop stays plain h1 over loopback, which is -# what the server speaks. +# Same shape, same backend, same upstream hop (plain h1 over loopback), and a +# different edge *image*: this one is stock Caddy from ./proxy-h3, where +# gateway-64 is stock nginx from ./proxy. The split exists because h3 needs +# QUIC and nginx has no HTTP/3 without a custom build against a QUIC library, +# while over the profile's h1 upstream hop Caddy costs ~3x nginx per request at +# gateway-64's multiplexing -- so the two profiles want different edges. Caddy +# binds 8443 on both tcp and udp from its `protocols h1 h2 h3` server block, +# and the h3 load generator reaches the udp half (h2load with `--alpn-list=h3`). # # Kept as a separate file rather than a symlink because the harness resolves # compose files by name (`compose..yml`, with gateway-64 on the legacy @@ -21,7 +24,7 @@ services: proxy: build: - context: ./proxy + context: ./proxy-h3 network_mode: host cpuset: "${PROXY_CPUSET:-0-15,64-79}" ulimits: diff --git a/frameworks/libuvcpp/compose.gateway.yml b/frameworks/libuvcpp/compose.gateway.yml index 47292bcf0..6795d459a 100644 --- a/frameworks/libuvcpp/compose.gateway.yml +++ b/frameworks/libuvcpp/compose.gateway.yml @@ -1,10 +1,16 @@ -# gateway-64 — stock Caddy (TLS + h2 on 8443) in front of the libuvcpp server. +# gateway-64 — stock nginx (TLS + h2 on 8443) in front of the libuvcpp server. # # Two services, which is what the profile fixes: `proxy` terminates TLS, serves # /static/* off disk and forwards the dynamic endpoints over plain h1 to # 127.0.0.1:8085; `server` answers /baseline2, /json/{n} and /async-db and # nothing else. # +# The edge is nginx rather than the Caddy that gateway-h3 still uses, because at +# this profile's 512 connections x 32 streams Caddy costs ~3x nginx per request +# over an h1 upstream and the proxy was therefore the stack's bottleneck (96% of +# its 32 CPUs against the server's 23%). proxy/nginx.conf has the measurement. +# The h3 profile cannot follow: h3 needs QUIC, which nginx lacks. +# # The server is deliberately **not** given /certs. main.cc opens its 8081 and # 8443 listeners only when the certificate pair is readable, so with no certs # mounted it comes up on its two cleartext ports alone and leaves every TLS port diff --git a/frameworks/libuvcpp/compose.production-stack.yml b/frameworks/libuvcpp/compose.production-stack.yml index 1e43d2d99..fbb44c4a5 100644 --- a/frameworks/libuvcpp/compose.production-stack.yml +++ b/frameworks/libuvcpp/compose.production-stack.yml @@ -1,7 +1,7 @@ # production-stack — edge + cache + authsvc + server, four services on a 64 # logical CPU budget. # -# edge (caddy) 30 logical CPUs. TLS + h2 termination, /static served +# edge (nginx) 30 logical CPUs. TLS + h2 termination, /static served # off disk, one auth subrequest per /api/* call. # cache (redis) 2. Redis is single-threaded on the data path; a # second core would sit idle. @@ -14,6 +14,13 @@ # All four on host networking, so what passes between them is loopback: the # edge owns 8443, authsvc 9090, the server 8085, the cache 6379. # +# The edge is nginx rather than the Caddy this stack used before. The profile's +# own architecture diagram names nginx and `auth_request`, and on a 4-core +# stand-in for this shape Caddy's edge cost 392-440 us of CPU per request +# against nginx's 101, with the edge pinned at its core budget in every arm. +# proxy-production/nginx.conf carries the measurement. Nothing here is h3, so +# unlike the gateway pair this stack does not need a Caddy twin. +# # The server is not given /certs, for the same reason as the gateway stacks -- # main.cc opens its TLS listeners only when the certificate pair is readable, so # without it the server stays on its two cleartext ports and the edge has 8443 diff --git a/frameworks/libuvcpp/main.cc b/frameworks/libuvcpp/main.cc index f9a0ef81c..95ae0972a 100644 --- a/frameworks/libuvcpp/main.cc +++ b/frameworks/libuvcpp/main.cc @@ -47,7 +47,6 @@ #include #include #include -#include #include #include #include @@ -59,6 +58,7 @@ #include #include #include +#include #include @@ -183,9 +183,9 @@ const char* kStaticRoot = "/data/static"; /// /// It is also what every other entry in this arena does -- nginx `gzip_static`, /// Caddy `precompressed br gzip`, actix `try_compressed()` -- and what this -/// entry's own `proxy/Caddyfile` and `proxy-production/Caddyfile` already ask -/// their edges for. Left off, the direct legs would be the only place in the -/// whole entry serving a live deflate. +/// entry's own `proxy/nginx.conf`, `proxy-production/nginx.conf` and +/// `proxy-h3/Caddyfile` already ask their edges for. Left off, the direct legs +/// would be the only place in the whole entry serving a live deflate. /// /// Off by default in `uvcpp_web_static_options`, so it has to be turned on /// explicitly. `:8443/udp` (static-h3) is a `uvcpp_http_server` + @@ -213,8 +213,9 @@ uvcpp_web_static_options kStaticOpts = [] { }(); /// The 50-item dataset, read once at startup. Read-only afterwards, so every -/// worker loop can share it without a lock. -uvcpp_json g_dataset; +/// worker loop can share it without a lock: `uvcpp_json_value` reads touch +/// nothing in the document, and nothing here ever mutates it. +uvcpp_json_doc g_dataset; /// The Postgres pool behind `/async-db`. One pool for the process, not one per /// worker loop: the pool is the thing that owns the connections, and @@ -254,11 +255,6 @@ const char kAsyncDbSql[] = "rating_score, rating_count FROM items " "WHERE price BETWEEN $1 AND $2 LIMIT $3"; -/// The documented fallback, used when the pool is not up. Byte-for-byte the -/// response the profile asks for when Postgres is unreachable or matches -/// nothing, so there is one shape to produce rather than two. -const char kAsyncDbEmpty[] = "{\"items\":[],\"count\":0}"; - // --------------------------------------------------------------------------- // production-stack -- cache-aside, in process // --------------------------------------------------------------------------- @@ -394,19 +390,25 @@ void load_dataset() { const std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); // Through the framework's own documented parser rather than nlohmann's - // directly: same backend, but the entry then touches no JSON API the library - // does not publish. `uvcpp_json_parse` reports a `json_status` instead of - // throwing, and it applies the depth and size limits `nlohmann::json::parse` - // leaves to the caller. The 12 KB dataset is three orders of magnitude under - // its 8 MiB default. - uvcpp_json parsed; - const json_status st = uvcpp_json_parse(text, parsed); - if (st != json_status::OK) { + // directly: the entry then touches no JSON API the library does not publish. + // `uvcpp_json_parse` reports a `json_doc_status` instead of throwing, and it + // applies the depth and size limits (64 levels, 8 MiB) that + // `nlohmann::json::parse` leaves to the caller. The 12 KB dataset is three + // orders of magnitude under both. + // + // The parser here is the framework's yyjson backend, not its nlohmann one + // (`webapp/uvcpp_web_json.h`), because `/json` and `/async-db` build their + // responses with the same backend's construction side -- see `build_json`. + // A second parse of the same bytes into another DOM would only be a second + // copy of the dataset, and the two would have to be kept in step. + uvcpp_json_doc parsed; + const json_doc_status st = uvcpp_json_parse(text, parsed); + if (st != json_doc_status::OK) { std::fprintf(stderr, "libuvcpp: cannot parse `%s`: %s\n", path.c_str(), - json_status_name(st)); + json_doc_status_name(st)); return; } - if (!parsed.is_array()) { + if (!parsed.root().is_array()) { std::fprintf(stderr, "libuvcpp: `%s` is not a JSON array\n", path.c_str()); return; } @@ -479,362 +481,91 @@ long long baseline_sum(const uvcpp_web_request& req) { return sum; } -/// The reference path, kept for the shapes the fast path declines: one DOM per -/// request, exactly what this handler did for every request before. Expects an -/// already-clamped `n`. -std::string build_json_general(int n, long long m) { - nlohmann::json items = nlohmann::json::array(); - for (int i = 0; i < n; ++i) { - nlohmann::json item = g_dataset[i]; - const long long price = item.value("price", 0LL); - const long long quantity = item.value("quantity", 0LL); - item["total"] = price * quantity * m; - items.push_back(std::move(item)); - } - - nlohmann::json payload; - payload["items"] = std::move(items); - payload["count"] = n; - return payload.dump(); -} - -/// Writes one JSON value with the framework's own writer. Only the types whose -/// bytes that writer reproduces exactly are accepted: integers, strings, -/// booleans, arrays, objects. A float is declined -- `dump()` renders doubles -/// with a shortest-round-trip algorithm while the writer uses a different -/// format -- and so are binary and discarded values; the caller falls back to -/// the reference path for those. -bool put_json_value(const nlohmann::json& v, uvcpp_json_writer& w) { - switch (v.type()) { - case nlohmann::json::value_t::string: - return w.value(v.get_ref()) == json_write_status::OK; - case nlohmann::json::value_t::boolean: - return w.value(v.get()) == json_write_status::OK; - case nlohmann::json::value_t::number_integer: - case nlohmann::json::value_t::number_unsigned: - return w.value(static_cast(v.get())) == - json_write_status::OK; - case nlohmann::json::value_t::array: { - if (w.array_begin() != json_write_status::OK) return false; - for (auto e = v.begin(); e != v.end(); ++e) - if (!put_json_value(*e, w)) return false; - return w.array_end() == json_write_status::OK; - } - case nlohmann::json::value_t::object: { - if (w.object_begin() != json_write_status::OK) return false; - for (auto f = v.begin(); f != v.end(); ++f) { - if (w.key(f.key()) != json_write_status::OK) return false; - if (!put_json_value(f.value(), w)) return false; - } - return w.object_end() == json_write_status::OK; - } - default: - return false; - } -} - -// --------------------------------------------------------------------------- -// The flattened dataset -// --------------------------------------------------------------------------- - -/// A slice of `g_flat_pool`. -struct flat_str { - std::uint32_t off; - std::uint32_t len; -}; - -/// One dataset item, read out of the DOM once at startup. Every field is a -/// scalar or an offset into `g_flat_pool`, so all 50 items are one contiguous -/// block of a few KB: serializing a response walks that array instead of 50 -/// `std::map` trees (eight individually-allocated nodes each, plus two more -/// lookups for `price`/`quantity`). -struct flat_item { - long long id; - long long price; - long long quantity; - long long rating_count; - long long rating_score; - bool active; - flat_str name; - flat_str category; - std::uint32_t tag_first; // index into g_flat_tags - std::uint32_t tag_n; -}; - -std::vector g_flat_pool; // every string, one block; frozen after startup -std::vector g_flat_tags; // every item's tags, flattened -std::vector g_flat; // the items themselves - -/// False until build_flat_dataset() has built the flat form **and** proved it -/// reproduces the reference path byte for byte. `build_json` declines to the -/// slower paths while this is false, so a dataset of any other shape is still -/// answered correctly -- just at the reference path's cost. -bool g_flat_ok = false; - -/// Appends `s` to the pool and returns its slice. -flat_str flat_push(const std::string& s) { - flat_str r; - r.off = static_cast(g_flat_pool.size()); - r.len = static_cast(s.size()); - g_flat_pool.insert(g_flat_pool.end(), s.begin(), s.end()); - return r; -} - -/// An integer, or false. A float counts as "not an integer" on purpose: the -/// writer renders doubles with a different algorithm than `dump()` does, so -/// those items have to keep going through the reference path. -bool flat_int(const nlohmann::json& v, long long* out) { - if (v.is_number_unsigned()) { - const unsigned long long u = v.get(); - // Past `LLONG_MAX` the writer and `dump()` disagree on the digits, and - // truncating would be a lie either way -- decline. - if (u > static_cast(9223372036854775807LL)) return false; - *out = static_cast(u); - return true; - } - if (!v.is_number_integer()) return false; - *out = v.get(); - return true; -} - -/// A string, or false. Borrows from the DOM, which is not written before -/// startup ends; the callers copy into the pool immediately. -bool flat_str_of(const nlohmann::json& v, const std::string** out) { - if (!v.is_string()) return false; - *out = &v.get_ref(); - return true; +/// One key of a parsed dataset item, copied whole into the builder: value, +/// type and nesting included. A key the dataset does not have stays absent -- +/// `add_copy` on an invalid view would hang a `null` there, and the nlohmann +/// path this replaced never invented a key either. +void copy_item_key(uvcpp_json_builder& b, uvcpp_json_node obj, const char* key, + const uvcpp_json_value& item) { + const uvcpp_json_value v = item.get(key); + if (v.valid()) b.add_copy(obj, key, v); } -/// The keys below are compile-time literals and their order is the one -/// nlohmann's default `std::map` object sorts them into -- which is what -/// `dump()` and the reference path emit. -template -bool flat_key(uvcpp_json_writer& w, const char (&k)[N]) { - return w.key(k, N - 1) == json_write_status::OK; -} - -/// Writes one flattened item. `total` is separate because it depends on the -/// request's `m`. -bool flat_write_item(const flat_item& it, long long total, - uvcpp_json_writer& w) { - const char* pool = g_flat_pool.data(); - if (!flat_key(w, "active")) return false; - if (w.value(it.active) != json_write_status::OK) return false; - if (!flat_key(w, "category")) return false; - if (w.value(pool + it.category.off, it.category.len) != json_write_status::OK) - return false; - if (!flat_key(w, "id")) return false; - if (w.value(it.id) != json_write_status::OK) return false; - if (!flat_key(w, "name")) return false; - if (w.value(pool + it.name.off, it.name.len) != json_write_status::OK) - return false; - if (!flat_key(w, "price")) return false; - if (w.value(it.price) != json_write_status::OK) return false; - if (!flat_key(w, "quantity")) return false; - if (w.value(it.quantity) != json_write_status::OK) return false; - if (!flat_key(w, "rating")) return false; - if (w.object_begin() != json_write_status::OK) return false; - if (!flat_key(w, "count")) return false; - if (w.value(it.rating_count) != json_write_status::OK) return false; - if (!flat_key(w, "score")) return false; - if (w.value(it.rating_score) != json_write_status::OK) return false; - if (w.object_end() != json_write_status::OK) return false; - if (!flat_key(w, "tags")) return false; - if (w.array_begin() != json_write_status::OK) return false; - for (std::uint32_t t = 0; t < it.tag_n; ++t) { - const flat_str& s = g_flat_tags[it.tag_first + t]; - if (w.value(pool + s.off, s.len) != json_write_status::OK) return false; - } - if (w.array_end() != json_write_status::OK) return false; - if (!flat_key(w, "total")) return false; - return w.value(total) == json_write_status::OK; -} - -/// Reads the DOM into the flat form and **proves** the flat form reproduces the -/// reference path before the fast path is allowed on. The proof is the point: -/// `flat_write_item` hard-codes this dataset's key order and field types, so a -/// dataset that differs in any way -- an extra key, a float, a missing -/// `rating` -- has to fall back rather than answer with different bytes. -void build_flat_dataset() { - g_flat_ok = false; - g_flat.clear(); - g_flat_tags.clear(); - g_flat_pool.clear(); - if (!g_dataset.is_array()) return; - - for (std::size_t i = 0; i < g_dataset.size(); ++i) { - const nlohmann::json& item = g_dataset[i]; - if (!item.is_object()) return; - - flat_item f; - { - auto it = item.find("id"); - if (it == item.end() || !flat_int(*it, &f.id)) return; - } - { - auto it = item.find("price"); - if (it == item.end() || !flat_int(*it, &f.price)) return; - } - { - auto it = item.find("quantity"); - if (it == item.end() || !flat_int(*it, &f.quantity)) return; - } - { - auto it = item.find("active"); - if (it == item.end() || !it->is_boolean()) return; - f.active = it->get(); - } - { - const std::string* s = nullptr; - auto it = item.find("name"); - if (it == item.end() || !flat_str_of(*it, &s)) return; - f.name = flat_push(*s); - } - { - const std::string* s = nullptr; - auto it = item.find("category"); - if (it == item.end() || !flat_str_of(*it, &s)) return; - f.category = flat_push(*s); - } - { - auto it = item.find("rating"); - if (it == item.end() || !it->is_object()) return; - auto c = it->find("count"); - auto s = it->find("score"); - if (c == it->end() || s == it->end()) return; - if (!flat_int(*c, &f.rating_count)) return; - if (!flat_int(*s, &f.rating_score)) return; - } - { - auto it = item.find("tags"); - if (it == item.end() || !it->is_array()) return; - const std::uint32_t first = static_cast(g_flat_tags.size()); - for (auto e = it->begin(); e != it->end(); ++e) { - const std::string* s = nullptr; - if (!flat_str_of(*e, &s)) return; - g_flat_tags.push_back(flat_push(*s)); - } - f.tag_first = first; - f.tag_n = static_cast(g_flat_tags.size()) - first; - } - g_flat.push_back(f); - } - - // Self-check: every item, written the flat way, has to come out byte for byte - // the same as writing it the reference way. Three multipliers, so a `total` - // that only lines up for one of them is caught too. - static const long long kCheckM[] = {1LL, 7LL, 1000003LL}; - for (std::size_t i = 0; i < g_flat.size(); ++i) { - for (std::size_t k = 0; k < sizeof(kCheckM) / sizeof(kCheckM[0]); ++k) { - const long long m = kCheckM[k]; - std::string got; - uvcpp_json_writer w(got); - if (w.object_begin() != json_write_status::OK) return; - const flat_item& fi = g_flat[i]; - if (!flat_write_item(fi, fi.price * fi.quantity * m, w)) return; - if (w.object_end() != json_write_status::OK) return; - if (w.finish() != json_write_status::OK) return; - - nlohmann::json ref = g_dataset[i]; - ref["total"] = g_flat[i].price * g_flat[i].quantity * m; - if (ref.dump() != got) { - std::fprintf(stderr, - "libuvcpp: dataset item %zu does not reproduce the " - "reference bytes -- /json stays on the reference path\n", - i); - g_flat_ok = false; - return; - } - } - } - g_flat_ok = true; -} - -/// The fast path itself: the same bytes the writer path produces, out of the -/// flat array. Declines to `build_json_general`, not to the writer path, if -/// anything goes wrong -- the writer path's own declines are all decided before -/// this point, and re-entering it would only re-walk the trees. -std::string build_json_flat(int n, long long m) { - std::string out; - out.reserve(static_cast(n) * 224u + 64u); - uvcpp_json_writer w(out); - if (w.object_begin() != json_write_status::OK) return build_json_general(n, m); - if (!flat_key(w, "items")) return build_json_general(n, m); - if (w.array_begin() != json_write_status::OK) return build_json_general(n, m); - for (int i = 0; i < n; ++i) { - const flat_item& it = g_flat[static_cast(i)]; - if (w.object_begin() != json_write_status::OK) return build_json_general(n, m); - if (!flat_write_item(it, it.price * it.quantity * m, w)) - return build_json_general(n, m); - if (w.object_end() != json_write_status::OK) return build_json_general(n, m); - } - if (w.array_end() != json_write_status::OK) return build_json_general(n, m); - if (!flat_key(w, "count")) return build_json_general(n, m); - if (w.value(static_cast(n)) != json_write_status::OK) - return build_json_general(n, m); - if (w.object_end() != json_write_status::OK) return build_json_general(n, m); - if (w.finish() != json_write_status::OK) return build_json_general(n, m); - return out; -} - -/// `{"items": [...], "count": N}` for the first `count` dataset items, each -/// carrying `total = price * quantity * m`. +/// `{"count": N, "items": [...]}` for the first `count` dataset items, each +/// carrying `total = price * quantity * m`. `count` leads because that is the +/// order the response has always used: nlohmann's object is a `std::map` and +/// its keys come out sorted, so `count` before `items`. /// -/// The body is serialized here, per request, out of the live dataset, through -/// **the framework's own documented serializer** (`uvcpp_json_writer`, the -/// streaming writer `doc/json-guide.md` documents): nothing is rendered ahead -/// of time, no response is cached, and no field is copied verbatim out of a -/// pre-serialized buffer -- every request walks the live items and writes their -/// bytes. What the writer buys over dumping each item with nlohmann is that a -/// key's four or five separate `std::string::append` calls become one, which on -/// a 50-item response is 4073 appends down to 1536 (see the library's -/// `CHANGELOG.md`, "uvcpp_json_writer 把「一个键」从 4~5 次追加压成 1 次"). -/// A DOM is still walked to read the values; what is skipped relative to the -/// reference path is only the throwaway `dump()` string per item and the second -/// DOM holding them all. +/// Built per request, out of the live dataset, by the framework's yyjson +/// backend: a `uvcpp_json_builder` tree written out by `uvcpp_json_write`, +/// which is yyjson's own serializer (`src/yyjson/uvcpp_json_doc.h`). The bytes +/// come from a production-grade JSON library the framework bundles -- what the +/// JSON profiles ask for when they ask for "the framework standard JSON +/// serialization" and for the ecosystem's library over a hand-rolled +/// alternative (`implementation-rules/frameworks/standard.md`). Nothing is +/// rendered ahead of time and no response is cached; every request walks the +/// live items and writes their bytes. /// -/// The output is byte-for-byte what the previous implementation produced (the -/// two were compared over 70 `(count, m)` combinations, including the clamped -/// and empty ones), so this is a change of serializer, not of response. -std::string build_json(int count, long long m) { - const int available = static_cast(g_dataset.size()); +/// `uvcpp_json_writer`, the streaming writer `` +/// offers, is an alternative serializer -- `implementation-rules/frameworks/ +/// tuned.md` lists "Alternative JSON serializers" under Tuned -- and this entry +/// still does not route these responses through it. What the 1.7.2 pin added is +/// a construction side to the framework's own backend, so the standard path and +/// the fast path stopped being different paths. +/// +/// **The key order below is load-bearing.** A `uvcpp_json_builder` object keeps +/// insertion order while nlohmann's sorts, so the order has to be spelled out, +/// and it is spelled out as that sorted order: `active`, `category`, `id`, +/// `name`, `price`, `quantity`, `rating`, `tags`, `total`. Copying a whole item +/// with `add_copy` would carry the data file's order instead. For the same +/// reason the nested `rating` object is rebuilt key by key -- the dataset lists +/// it `score, count` -- while `tags`, an array, can be copied whole, because +/// arrays keep their order in both serializers. +bool build_json(std::string& out, int count, long long m) { + const uvcpp_json_value items = g_dataset.root(); + const int available = static_cast(items.size()); int n = count; if (n < 0) n = 0; if (n > available) n = available; - if (g_flat_ok) return build_json_flat(n, m); - - std::string out; - // Pure capacity hint: the largest item in this dataset serializes to 172 - // bytes and `,"total":N}` adds at most ~20, so 224 covers every item. An item - // longer than that is not an error, just one reallocation. - out.reserve(static_cast(n) * 224u + 64u); - uvcpp_json_writer w(out); - if (w.object_begin() != json_write_status::OK) return build_json_general(n, m); - if (w.key("items") != json_write_status::OK) return build_json_general(n, m); - if (w.array_begin() != json_write_status::OK) return build_json_general(n, m); + + uvcpp_json_builder b; + uvcpp_json_node root = b.make_object(); + b.set_root(root); + b.add_int(root, "count", n); + uvcpp_json_node arr = b.add_array(root, "items"); for (int i = 0; i < n; ++i) { - const nlohmann::json& item = g_dataset[static_cast(i)]; - if (!item.is_object()) return build_json_general(n, m); - if (w.object_begin() != json_write_status::OK) return build_json_general(n, m); - // nlohmann's default object is a `std::map`, so this walks the keys in the - // same sorted order `dump()` would have emitted them in. - for (auto f = item.begin(); f != item.end(); ++f) { - if (w.key(f.key()) != json_write_status::OK) return build_json_general(n, m); - if (!put_json_value(f.value(), w)) return build_json_general(n, m); + const uvcpp_json_value item = items.at(static_cast(i)); + // Read with the same reading the nlohmann path used: a missing or + // non-numeric value counts as zero. Only `total` is arithmetic; every other + // value goes through `copy_item_key` untouched. + const long long price = item.get("price").as_int(); + const long long quantity = item.get("quantity").as_int(); + + uvcpp_json_node o = b.push_object(arr); + copy_item_key(b, o, "active", item); + copy_item_key(b, o, "category", item); + copy_item_key(b, o, "id", item); + copy_item_key(b, o, "name", item); + copy_item_key(b, o, "price", item); + copy_item_key(b, o, "quantity", item); + const uvcpp_json_value rating = item.get("rating"); + if (rating.valid()) { + uvcpp_json_node r = b.add_object(o, "rating"); + copy_item_key(b, r, "count", rating); + copy_item_key(b, r, "score", rating); } - if (w.member("total", item.value("price", 0LL) * item.value("quantity", 0LL) * m) - != json_write_status::OK) { - return build_json_general(n, m); - } - if (w.object_end() != json_write_status::OK) return build_json_general(n, m); + copy_item_key(b, o, "tags", item); + b.add_int(o, "total", price * quantity * m); } - if (w.array_end() != json_write_status::OK) return build_json_general(n, m); - if (w.member("count", static_cast(n)) != json_write_status::OK) { - return build_json_general(n, m); + + const json_write_status st = uvcpp_json_write(root, out); + if (st != json_write_status::OK) { + // Either `NO_MEMORY` (a resize of `out` failed) or `MISUSE` (the tree is + // not there at all, i.e. the pools failed); `last_error()` says which. + std::fprintf(stderr, "libuvcpp: `/json` could not be written: %s\n", + b.last_error()); } - if (w.object_end() != json_write_status::OK) return build_json_general(n, m); - if (w.finish() != json_write_status::OK) return build_json_general(n, m); - return out; + return st == json_write_status::OK; } // --------------------------------------------------------------------------- @@ -864,9 +595,19 @@ void handle_json(uvcpp_web_request& req, uvcpp_web_response& resp, const std::string* count = req.param("count"); const std::string* multiplier = req.query("m"); const long long m = multiplier != nullptr ? to_ll(*multiplier) : 1; - resp.json_str(build_json(count != nullptr ? static_cast(to_ll(*count)) - : 0, - m)); + std::string body; + if (build_json(body, + count != nullptr ? static_cast(to_ll(*count)) : 0, m)) { + // `json_str(std::string&&)` takes the buffer over rather than copying it, + // exactly as `resp.json()` does with the string `dump()` returns. + resp.json_str(std::move(body)); + } else { + // Not answerable, so say so rather than answer a body that is missing + // items. The documented empty result set is `/async-db`'s fallback, not + // this profile's; a short answer that claims a `count` it does not have + // would be the one thing worse than a 500. + resp.status(500).json_str("{\"error\":\"json build failed\"}"); + } resp.end(); } @@ -892,51 +633,100 @@ int query_int(const uvcpp_web_request& req, const char* name, int fallback) { /// request: the profile has exactly one documented fallback shape and this is /// not it. nlohmann::json parse_tags(const std::string& text) { - if (!text.empty()) { - try { - nlohmann::json parsed = nlohmann::json::parse(text); - if (parsed.is_array()) return parsed; - } catch (const std::exception&) { - // falls through to the empty array + nlohmann::json parsed; + if (uvcpp_json_parse(text, parsed) == json_status::OK && parsed.is_array()) + return parsed; + return nlohmann::json::array(); +} + +/// The same column, into a builder instead of a DOM. Copying the parsed array +/// whole is safe here -- an array keeps its order in both serializers, and the +/// elements keep their types (strings, on this column) rather than being +/// re-typed on the way through. +/// +/// The `[]` fallback is the documented one from `parse_tags` above: the key is +/// always there, and a literal that will not parse becomes an empty array +/// rather than failing the request. +void copy_tags(uvcpp_json_builder& b, uvcpp_json_node obj, + const std::string& text) { + uvcpp_json_doc parsed; + if (uvcpp_json_parse(text, parsed) == json_doc_status::OK) { + // `root()` after the parse, never before: a value view borrows the + // document, and a successful `parse()` replaces the document underneath it. + const uvcpp_json_value v = parsed.root(); + if (v.is_array()) { + b.add_copy(obj, "tags", v); + return; } } - return nlohmann::json::array(); + b.add_array(obj, "tags"); } -/// `{"items": [...], "count": N}` for the rows Postgres returned. +/// `{"count": N, "items": [...]}` for the rows Postgres returned, built the same +/// way `/json` builds its body: a `uvcpp_json_builder` tree written out by +/// `uvcpp_json_write`, the framework's yyjson backend. +/// +/// `count` leads and the item keys follow the order below, which is the order +/// this response has always used -- nlohmann's `std::map` sort, and the same +/// order `/api/items` uses. So `rating` is rebuilt key by key: the response +/// says `count` before `score`, while the old code inserted `score` first and +/// let nlohmann sort it. /// /// Column names are the profile's, not an invention: `id`, `name`, `category`, /// `price`, `quantity`, `active`, `tags`, then `rating_score`/`rating_count` /// folded into one nested `rating` object. Every number stays an integer -- /// the profile reads `price` and `rating.score` as INTEGER and says so. /// `count` is the number of rows actually returned, never the requested limit. -std::string build_async_db_json(uvcpp_db_status status, - const uvcpp_db_table& table) { - if (!uvcpp_db_ok(status)) return kAsyncDbEmpty; - +/// +/// Unlike `/json` there is no `total`: the profile's `/async-db` shape does not +/// have one, and this handler has never added it. +bool build_async_db_json(std::string& out, const uvcpp_db_table& table) { const std::vector& rows = table.rows(); - nlohmann::json items = nlohmann::json::array(); + uvcpp_json_builder b; + uvcpp_json_node root = b.make_object(); + b.set_root(root); + b.add_int(root, "count", static_cast(rows.size())); + uvcpp_json_node arr = b.add_array(root, "items"); for (size_t i = 0; i < rows.size(); ++i) { const uvcpp_db_row& row = rows[i]; - nlohmann::json item; - item["id"] = row["id"].to_int64(); - item["name"] = row["name"].to_text(); - item["category"] = row["category"].to_text(); - item["price"] = row["price"].to_int64(); - item["quantity"] = row["quantity"].to_int64(); - item["active"] = row["active"].to_bool(); - item["tags"] = parse_tags(row["tags"].to_text()); - nlohmann::json rating; - rating["score"] = row["rating_score"].to_int64(); - rating["count"] = row["rating_count"].to_int64(); - item["rating"] = std::move(rating); - items.push_back(std::move(item)); + uvcpp_json_node o = b.push_object(arr); + b.add_bool(o, "active", row["active"].to_bool()); + b.add_string(o, "category", row["category"].to_text()); + b.add_int(o, "id", row["id"].to_int64()); + b.add_string(o, "name", row["name"].to_text()); + b.add_int(o, "price", row["price"].to_int64()); + b.add_int(o, "quantity", row["quantity"].to_int64()); + uvcpp_json_node r = b.add_object(o, "rating"); + b.add_int(r, "count", row["rating_count"].to_int64()); + b.add_int(r, "score", row["rating_score"].to_int64()); + copy_tags(b, o, row["tags"].to_text()); } - nlohmann::json payload; - payload["count"] = static_cast(rows.size()); - payload["items"] = std::move(items); - return payload.dump(); + const json_write_status st = uvcpp_json_write(root, out); + if (st != json_write_status::OK) { + std::fprintf(stderr, "libuvcpp: `/async-db` could not be written: %s\n", + b.last_error()); + } + return st == json_write_status::OK; +} + +/// The documented fallback shape, for when the pool is not up or the statement +/// did not run. Built here rather than spelled out as a literal, so the empty +/// answer and the populated one are the same object graph and cannot drift +/// apart; the same serializer writes it. +/// +/// The write status is deliberately not reported: these 26 bytes can only fail +/// on a `std::bad_alloc` from `out`'s first resize, and a fallback body for +/// that would have to allocate as well. `out` is then empty, which every +/// profile reads as a failed request -- the honest answer from a process that +/// has run out of memory. +void write_empty_table(std::string& out) { + uvcpp_json_builder b; + uvcpp_json_node root = b.make_object(); + b.set_root(root); + b.add_int(root, "count", 0); + b.add_array(root, "items"); + (void)uvcpp_json_write(root, out); } /// The retry half of init_database(): keep trying to open the pool, and give up @@ -1024,8 +814,8 @@ void init_database() { const char* url = std::getenv("DATABASE_URL"); if (url == nullptr || *url == '\0') { std::fprintf(stderr, - "libuvcpp: DATABASE_URL is not set -- /async-db answers %s\n", - kAsyncDbEmpty); + "libuvcpp: DATABASE_URL is not set -- /async-db answers the " + "empty result set\n"); return; } const char* env_max = std::getenv("DATABASE_MAX_CONN"); @@ -1077,7 +867,9 @@ void handle_async_db(uvcpp_web_app* app, uvcpp_web_request& req, if (limit > 50) limit = 50; if (!g_db_ready) { - resp.json_str(kAsyncDbEmpty); + std::string body; + write_empty_table(body); + resp.json_str(std::move(body)); resp.end(); return; } @@ -1094,7 +886,13 @@ void handle_async_db(uvcpp_web_app* app, uvcpp_web_request& req, const int rc = g_db.query( app->loop(), kAsyncDbSql, params, [rp, next](uvcpp_db_status status, const uvcpp_db_table& table) { - rp->json_str(build_async_db_json(status, table)); + std::string body; + if (uvcpp_db_ok(status)) build_async_db_json(body, table); + // A statement that did not run and a body that could not be written + // (the writer leaves `out` empty on failure) are the same answer here: + // the documented empty result set, not a body that is short by rows. + if (body.empty()) write_empty_table(body); + rp->json_str(std::move(body)); rp->end(); next(); }); @@ -1102,7 +900,9 @@ void handle_async_db(uvcpp_web_app* app, uvcpp_web_request& req, // Not dispatched: per the pool's contract the callback will not run, so // this is the only reply this request gets. `next` was never copied, so // the chain is not left suspended. - resp.json_str(kAsyncDbEmpty); + std::string body; + write_empty_table(body); + resp.json_str(std::move(body)); resp.end(); } } @@ -1121,7 +921,9 @@ void handle_async_db(uvcpp_web_app* app, uvcpp_web_request& req, /// A request that reached the framework without the edge's verified user id. /// 401 rather than a redirect: the profile's auth wall is checked as a status. void send_unauthorized(uvcpp_web_response& resp) { - resp.status(401).json_str("{\"error\":\"unauthorized\"}"); + nlohmann::json error; + error["error"] = "unauthorized"; + resp.status(401).json(error); resp.end(); } @@ -1194,7 +996,7 @@ void handle_api_item_get(uvcpp_web_app* app, uvcpp_web_request& req, return; } if (!g_db_ready) { - send_cached(resp, "{}", false); + send_cached(resp, nlohmann::json::object().dump(), false); return; } @@ -1206,13 +1008,14 @@ void handle_api_item_get(uvcpp_web_app* app, uvcpp_web_request& req, [rp, next, key](uvcpp_db_status status, const uvcpp_db_table& table) { const std::string json = item_body(status, table); if (!json.empty()) cache_store(key, json, kItemTtlMs); - send_cached(*rp, json.empty() ? std::string("{}") : json, false); + send_cached(*rp, json.empty() ? nlohmann::json::object().dump() : json, + false); next(); }); if (rc < 0) { // Not dispatched, so no callback is coming. `next` was never copied and // the chain is not left suspended. - send_cached(resp, "{}", false); + send_cached(resp, nlohmann::json::object().dump(), false); } } @@ -1240,13 +1043,14 @@ void handle_api_item_post(uvcpp_web_app* app, uvcpp_web_request& req, std::string name; long long price = 0; long long quantity = 0; - try { - const nlohmann::json body = nlohmann::json::parse(req.body_str()); - name = body.value("name", std::string()); - price = body.value("price", 0LL); - quantity = body.value("quantity", 0LL); - } catch (const std::exception&) { - // defaults above + nlohmann::json body; + if (uvcpp_json_parse(req.body_str(), body) == json_status::OK) { + // The library's getters, not `body["k"]` or `body.value()`: both of those + // throw out of a libuv callback on the wrong shape, and this handler runs + // inside one. These leave the defaults above in place instead. + name = uvcpp_json_get_string(body, "name", std::string()); + uvcpp_json_get_int(body, "price", price); + uvcpp_json_get_int(body, "quantity", quantity); } if (!g_db_ready) { @@ -1295,7 +1099,7 @@ void handle_api_me(uvcpp_web_app* app, uvcpp_web_request& req, return; } if (!g_db_ready) { - resp.status(503).json_str("{}"); + resp.status(503).json(nlohmann::json::object()); resp.end(); return; } @@ -1308,7 +1112,7 @@ void handle_api_me(uvcpp_web_app* app, uvcpp_web_request& req, [rp, next, key](uvcpp_db_status status, const uvcpp_db_table& table) { const std::string json = user_body(status, table); if (!json.empty()) cache_store(key, json, kUserTtlMs); - rp->json_str(json.empty() ? std::string("{}") : json); + rp->json_str(json.empty() ? nlohmann::json::object().dump() : json); rp->end(); next(); }); @@ -1316,7 +1120,7 @@ void handle_api_me(uvcpp_web_app* app, uvcpp_web_request& req, // Unlike `/api/items`, there is no cache header to get right here and no // documented empty shape -- an unavailable database is a 503, not a body // that claims a user. - resp.status(503).json_str("{}"); + resp.status(503).json(nlohmann::json::object()); resp.end(); } } @@ -1473,25 +1277,14 @@ int main() { uvcpp_logger::instance().set_level(log_level::OFF); load_dataset(); - if (g_dataset.empty()) { + // `valid()` is "the parse succeeded" and `size()` on the root is the item + // count, so this covers both a dataset that would not load and one that + // loaded as `[]`. + if (!g_dataset.valid() || g_dataset.root().size() == 0) { std::fprintf(stderr, "libuvcpp: dataset is empty -- /json will answer " "with an empty item list\n"); } - // Reads the dataset into the flat array and self-checks it against the - // reference path; a dataset it cannot reproduce leaves `/json` on the - // reference path (see build_flat_dataset). Which of the two it ended up on - // is printed, because "the two arms answered the same bytes" is worth - // nothing on its own -- it is also what a fast path that never ran looks - // like. - build_flat_dataset(); - std::fprintf(stderr, - "libuvcpp: /json fast path: %s (%zu items, %zu tag strings, " - "%zu B pool + %zu B items)\n", - g_flat_ok ? "flattened dataset" : "reference DOM path", - g_flat.size(), g_flat_tags.size(), g_flat_pool.size(), - g_flat.size() * sizeof(flat_item)); - // The one connection pool for the process, opened before any listener so a // bad DATABASE_URL is reported at startup rather than on the first request. // A pool per worker loop would be wrong here: the pool owns the connections, @@ -1773,6 +1566,36 @@ int main() { // response on the loop thread -- 933.15 rps against 82,451 with it, on the // rig described above kStaticOpts. h3_static.set_precompressed(true); + // The same cache-validation thread choice the h1/h2 legs get through + // kStaticOpts.revalidate, which this leg has no uvcpp_web_static_options to + // receive -- and did not get at all until now: uvcpp_static_server defaults + // to POOL, so every static-h3 request (a `.br` twin makes it two stats) + // queued to the libuv thread pool, woke a worker, stat()ed there and came + // back through after_work. LOOP_STAT does the stat where the request already + // is. The h1/h2 static legs have been on it since `7095e92`. + // + // Measured on this leg with the entry built twice, one line apart, same + // library, same ext4 corpus, the profile's own URIs and Accept-Encoding + // (--alpn-list=h3 -c 64 -m 64 -t 8 -D 5s, three reps a side, interleaved; + // server on 8 cores, generator on 8 disjoint ones): + // + // rps 29 434 -> 38 280 (rep range 29 129-30 454 vs 33 855-42 360; + // even the worst `loopstat` rep beats the + // best `pool` one) + // us/req 216.8 -> 193.3 + // + // The mechanism is in the library's doc/benchmark.md section 4.8, which ran + // the same A/B on the same `uvcpp_static_server`: `futex`/req 6.28 -> 0.66 + // with `statx`/req 2.24 -> 2.24 as the control -- the mtime/size check that + // decides whether the cache is still good still happens on every request, + // only the thread it happens on moved. Tail latency improved there too + // (p50 63,661 -> 56,896 us, p99 170,526 -> 140,458 us), which is the risk + // this option carries and the reason the library default stays POOL: on a + // FUSE or network mount one stat() is ~46 us and blocking a loop thread on + // it would be the wrong trade. The platform mounts the dataset into the + // container, so this leg is on the same local overlayfs the h1/h2 legs are + // already doing this on. + h3_static.set_revalidate(uvcpp_static_revalidate::LOOP_STAT); uvcpp_http_server h3; if (have_certs) { if (!quic_tls.load_certificate_file(kCertFile) || diff --git a/frameworks/libuvcpp/meta.json b/frameworks/libuvcpp/meta.json index 0bcd3c3d8..a7d9409a4 100644 --- a/frameworks/libuvcpp/meta.json +++ b/frameworks/libuvcpp/meta.json @@ -10,7 +10,7 @@ "response": true }, "engine": "libuv", - "description": "libuvcpp on the libuv event loop, one loop per available core. Routing, path and query params, static files and WebSocket through uvcpp_web_app, JSON written per request by the framework's own uvcpp_json_writer (values read out of the uvcpp_json DOM, no pre-rendered bytes), gzip through the built-in response compression, async Postgres through uvcpp_db_pool, and gRPC unary calls through the uvcpp_grpc module. One process serves HTTP/1.1 and prior-knowledge h2c on 8080, h2c on 8082, HTTP/2 over TLS on 8443, and HTTP/3 on 8443/udp, all from the same build; the gateway stacks put a stock Caddy edge in front of it.", + "description": "libuvcpp on the libuv event loop, one loop per available core. Routing, path and query params, static files and WebSocket through uvcpp_web_app, JSON serialized per request through the framework's bundled yyjson backend (uvcpp_json_builder, written by yyjson's own serializer, no pre-rendered bytes), gzip through the built-in response compression, async Postgres through uvcpp_db_pool, and gRPC unary calls through the uvcpp_grpc module. One process serves HTTP/1.1 and prior-knowledge h2c on 8080, h2c on 8082, HTTP/2 over TLS on 8443, and HTTP/3 on 8443/udp, all from the same build; the gateway-64 and production-stack profiles put a stock nginx edge in front of it and gateway-h3 a stock Caddy one.", "repo": "https://github.com/Antruly/libuvcpp", "enabled": true, "tests": [ diff --git a/frameworks/libuvcpp/proxy/Caddyfile b/frameworks/libuvcpp/proxy-h3/Caddyfile similarity index 100% rename from frameworks/libuvcpp/proxy/Caddyfile rename to frameworks/libuvcpp/proxy-h3/Caddyfile diff --git a/frameworks/libuvcpp/proxy-h3/Dockerfile b/frameworks/libuvcpp/proxy-h3/Dockerfile new file mode 100644 index 000000000..c9abcb782 --- /dev/null +++ b/frameworks/libuvcpp/proxy-h3/Dockerfile @@ -0,0 +1,13 @@ +# Stock Caddy, unmodified. This is the gateway-h3 edge only: the base image +# carries h1/h2/h3 (QUIC) and the Caddyfile binds 8443 on both tcp and udp, of +# which h3 uses the udp half. gateway-64 uses nginx instead, in ../proxy/, for +# the reason recorded in that nginx.conf -- h3 is the profile that cannot +# follow it, because h3 needs QUIC and nginx has no HTTP/3 without a custom +# build against a QUIC library. +FROM caddy:2-alpine + +COPY Caddyfile /etc/caddy/Caddyfile + +EXPOSE 8443/tcp 8443/udp + +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/frameworks/libuvcpp/proxy-production/Caddyfile b/frameworks/libuvcpp/proxy-production/Caddyfile deleted file mode 100644 index d9b09e933..000000000 --- a/frameworks/libuvcpp/proxy-production/Caddyfile +++ /dev/null @@ -1,66 +0,0 @@ -# libuvcpp production-stack edge. -# -# Same stock Caddy as the gateway edge, with the three-way path split the -# production-stack profile defines: -# -# /static/* served here from disk, never reaching the server -# /public/* forwarded unauthenticated -# /api/* forwarded only after authsvc has verified the bearer token -# -# forward_auth fires a subrequest to the shared authsvc on every /api/* call and -# nothing caches it: the HMAC-SHA256 that costs is part of what the profile -# measures. What the server receives is the X-User-Id authsvc resolved, never -# the Authorization header -- the profile's rules put JWT verification at the -# edge and forbid the framework from doing it, and the server separately -# refuses any /api/* request that arrives with no X-User-Id at all. -# -# Both upstreams name 127.0.0.1:8085 -- the compose `server` service's H1_PORT, -# not the 8080 the entry uses when it runs alone. The server binds its ports at -# startup and exits if a bind fails, and this stack is on the host network, so -# the backend is given ports of its own; see compose.gateway.yml. -{ - admin off - auto_https off - servers { - protocols h1 h2 - } -} - -https://localhost:8443 { - tls /certs/server.crt /certs/server.key - - handle /static/* { - root * /data - file_server { - precompressed br gzip - } - } - - handle /public/* { - reverse_proxy 127.0.0.1:8085 { - transport http { - versions 1.1 - keepalive 5m - keepalive_idle_conns 2048 - keepalive_idle_conns_per_host 2048 - } - } - } - - # authsvc answers 200 + X-User-Id or 401, and forward_auth returns its - # refusal verbatim, so an unauthenticated /api/* never reaches the server. - handle /api/* { - forward_auth 127.0.0.1:9090 { - uri /_auth - copy_headers X-User-Id - } - reverse_proxy 127.0.0.1:8085 { - transport http { - versions 1.1 - keepalive 5m - keepalive_idle_conns 2048 - keepalive_idle_conns_per_host 2048 - } - } - } -} diff --git a/frameworks/libuvcpp/proxy-production/Dockerfile b/frameworks/libuvcpp/proxy-production/Dockerfile index fd37caf66..3f24aa370 100644 --- a/frameworks/libuvcpp/proxy-production/Dockerfile +++ b/frameworks/libuvcpp/proxy-production/Dockerfile @@ -1,9 +1,22 @@ -# Stock Caddy, same base as the gateway edge. h2 over TCP only: the profile's -# load generator is h2load against 8443/tcp, so no QUIC listener is needed. -FROM caddy:2-alpine +# Stock nginx from the official image, unmodified: ssl, http_v2 and gzip_static +# are all in the default build. The production-stack edge -- TLS + h2, /static/* +# off disk, and one auth_request per /api/* call against the shared authsvc. +# gateway-h3 keeps Caddy, in ../proxy-h3/, because h3 needs QUIC. +FROM nginx:1.27-alpine -COPY Caddyfile /etc/caddy/Caddyfile +COPY nginx.conf /etc/nginx/nginx.conf + +# Unused on this profile -- the auth subrequest carries no body and the write +# path's bodies are small enough to stay in memory -- but nginx creates its +# scratch directories at startup, and left to its own defaults it asks for +# /var/lib/nginx/body, which this image does not create. It then refuses to boot +# with "mkdir() ... failed (2: No such file or directory)". Workers drop to the +# nginx user, so the directories have to be owned by it. +RUN mkdir -p /var/lib/nginx/tmp/body /var/lib/nginx/tmp/proxy \ + /var/lib/nginx/tmp/fastcgi /var/lib/nginx/tmp/uwsgi \ + /var/lib/nginx/tmp/scgi \ + && chown -R nginx:nginx /var/lib/nginx EXPOSE 8443/tcp -CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/frameworks/libuvcpp/proxy-production/nginx.conf b/frameworks/libuvcpp/proxy-production/nginx.conf new file mode 100644 index 000000000..b6495df10 --- /dev/null +++ b/frameworks/libuvcpp/proxy-production/nginx.conf @@ -0,0 +1,179 @@ +# libuvcpp production-stack edge: stock nginx terminating TLS + HTTP/2 on 8443, +# with the profile's three-way path split. +# +# /static/* served here from disk, never reaching the server +# /public/* forwarded unauthenticated +# /api/* forwarded only after authsvc has verified the bearer token +# +# Why nginx rather than Caddy, which this stack used before: the profile's own +# architecture diagram names nginx and `auth_request`, and Caddy's edge is far +# more expensive per request. On a 4-core stand-in for this shape, at the +# profile's own c=256 reads + c=32 writes: Caddy 8.4k req/s with its edge at +# 440 us/req, Caddy again with a connection pool added to the forward_auth +# upstream (which the Caddyfile never configured -- the shipped one pools only +# the backend) 9.5k at 392 us, and this file 39.2k at 101 us. The edge was +# pinned at its core budget in all three arms; the best Caddy arm is still 4.1x +# behind on throughput and 3.9x behind on edge CPU per request. ../proxy/ +# nginx.conf has the same finding at the gateway profile's multiplexing. +# The h3 stacks keep Caddy only because h3 needs QUIC. +# +# Every path this profile exercises was dumped from both edges and compared +# byte for byte: same status and same body throughout, headers differing only in +# the ways ../proxy/nginx.conf documents. The one behavioral difference is a +# request matching none of the three prefixes -- Caddy answered an empty 200, +# this answers nginx's 404 -- and nothing reads it: the load URIs cover only +# /static/, /public/ and /api/, and neither this service nor the server declares +# a healthcheck. +# +# The auth subrequest fires on every /api/* call and nothing caches it: the +# HMAC-SHA256 that costs is part of what the profile measures, and the rules +# forbid proxy_cache on /_auth. authsvc answers 200 with X-User-Id or 401, and +# a 401 from the subrequest becomes nginx's 401 -- the server is never reached. +# +# `Authorization ""` is a decision, not an accident of auth_request. Both edges +# were run against a header-dumping backend on the rig, and Caddy's forward_auth +# *does* pass the bearer token through along with the X-User-Id it copied; the +# comment this file replaced claimed otherwise and was simply wrong. Dropping it +# here is the tighter reading of the profile: JWT verification happens at the +# edge, the server is handed the identity authsvc resolved and nothing else, and +# the token cannot be re-verified downstream. It also keeps ~200 bytes of a +# per-request header off every /api/* hop. The server refuses any /api/* request +# that arrives with no X-User-Id at all, which is what makes the POST branch -- +# the one that writes rows -- safe. +# +# worker_processes is a literal, coupled to the edge service's `cpuset` in +# compose.production-stack.yml (0-14,64-78 = 30 logical CPUs). nginx's +# `worker_processes auto` counts the *host's* online CPUs and ignores a +# container cpuset, and `worker_cpu_affinity auto` re-pins workers outside it +# altogether. ../proxy/nginx.conf has the measurement. + +user nginx; +worker_processes 30; +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 65535; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # The load generator never repeats a request, so an access log is pure cost. + access_log off; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + keepalive_timeout 300s; + keepalive_requests 1000000; + server_tokens off; + + # Serve the shipped .gz twin of a static asset and announce both encodings. + # The gateway edge does the same; ../proxy/nginx.conf explains why the .gz + # twin and not the .br one. + gzip_static on; + gzip_vary on; + + # nginx wants scratch directories that the upstream connection pools spill + # into; the image is built with these created and owned by the nginx user. + client_body_temp_path /var/lib/nginx/tmp/body; + proxy_temp_path /var/lib/nginx/tmp/proxy; + fastcgi_temp_path /var/lib/nginx/tmp/fastcgi; + uwsgi_temp_path /var/lib/nginx/tmp/uwsgi; + scgi_temp_path /var/lib/nginx/tmp/scgi; + + # One pooled h1 connection per in-flight request, for both upstreams. The + # backend numbers are the Caddyfile's. The authsvc pool is not: that + # Caddyfile never configured a transport for its forward_auth upstream, and + # the rig counted the consequence -- 0.77 connections opened per auth + # subrequest against 0.024 with this pool, which is most of why its auth leg + # cost 96 us/req where this one costs 20. + upstream backend { + server 127.0.0.1:8085; + keepalive 2048; + keepalive_requests 1000000; + keepalive_timeout 300s; + } + + upstream authsvc { + server 127.0.0.1:9090; + keepalive 256; + keepalive_requests 1000000; + keepalive_timeout 300s; + } + + # The two loopback addresses rather than the wildcard `listen 8443`, and + # that is load-bearing. The harness validates a compose stack while the + # entry's own standalone container is still up (`--network host`, and + # /certs mounted because this profile subscribes to an h2 test), so that + # process holds 0.0.0.0:8443 as well -- one listening socket per loop with + # SO_REUSEPORT, 64 of them on the reference runner. A wildcard `listen` + # lands in the *same* reuseport group as those sockets and the kernel + # hashes localhost:8443 across all of them, so most of the checks reach + # the server directly, where /api/* is deliberately unregistered + # (main.cc:1924) and answers 404. Measured on the rig against a 64-socket + # wildcard holder: wildcard 3/24 requests to the edge, loopback 24/24. + # Caddy, which this file replaced, never had the problem because + # `https://localhost:8443` makes it bind these same two addresses, and a + # specific bind wins the listener lookup outright -- the wildcard socket is + # then never chosen at all. Every URL the harness uses is `localhost`. + server { + listen 127.0.0.1:8443 ssl http2 backlog=65535 reuseport; + listen [::1]:8443 ssl http2 backlog=65535 reuseport; + + ssl_certificate /certs/server.crt; + ssl_certificate_key /certs/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + + # The X-User-Id the auth subrequest answered with, handed to whichever + # /api/ location runs it. + auth_request_set $auth_user_id $upstream_http_x_user_id; + + # The auth subrequest itself: GET /_auth at authsvc, with the client's + # Authorization header and no body. `internal` keeps a client from + # reaching it directly. + location = /_auth { + internal; + proxy_pass http://authsvc/_auth; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + } + + # The 30% of the profile's URIs that never reach the server: a file open + # plus sendfile, with the .gz twin chosen when the client accepts it. + location /static/ { + alias /data/static/; + } + + # Unauthenticated, uncached: the server answers /public/baseline and + # /public/json/{n} straight from the dataset. + location /public/ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + } + + # Authenticated. auth_request runs first and its 401 short-circuits the + # location, so an unauthenticated /api/ never reaches the server -- not + # even for the POST that would write a row. + location /api/ { + auth_request /_auth; + + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-User-Id $auth_user_id; + proxy_set_header Authorization ""; + } + } +} diff --git a/frameworks/libuvcpp/proxy/Dockerfile b/frameworks/libuvcpp/proxy/Dockerfile index 11c970331..3214932fc 100644 --- a/frameworks/libuvcpp/proxy/Dockerfile +++ b/frameworks/libuvcpp/proxy/Dockerfile @@ -1,11 +1,20 @@ -# Stock Caddy, unmodified. The base image already carries h1/h2/h3 (QUIC), so -# one image serves both gateway profiles: the Caddyfile binds 8443 on tcp and -# udp, and gateway-64 only ever reaches the tcp half while gateway-h3 only ever -# reaches the udp one. -FROM caddy:2-alpine +# Stock nginx from the official image, unmodified: ssl, http_v2 and gzip_static +# are all in the default build. This edge serves gateway-64 only -- gateway-h3 +# keeps Caddy, in ../proxy-h3/, because h3 needs QUIC. +FROM nginx:1.27-alpine -COPY Caddyfile /etc/caddy/Caddyfile +COPY nginx.conf /etc/nginx/nginx.conf -EXPOSE 8443/tcp 8443/udp +# nginx.conf names its scratch directories explicitly. Left to its own defaults +# it asks for /var/lib/nginx/body, which this image does not create, and the +# master refuses to boot with "mkdir() ... failed (2: No such file or +# directory)". Workers drop to the nginx user, so the directories have to be +# owned by it. +RUN mkdir -p /var/lib/nginx/tmp/body /var/lib/nginx/tmp/proxy \ + /var/lib/nginx/tmp/fastcgi /var/lib/nginx/tmp/uwsgi \ + /var/lib/nginx/tmp/scgi \ + && chown -R nginx:nginx /var/lib/nginx -CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] +EXPOSE 8443/tcp + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frameworks/libuvcpp/proxy/nginx.conf b/frameworks/libuvcpp/proxy/nginx.conf new file mode 100644 index 000000000..9e2e0cfb0 --- /dev/null +++ b/frameworks/libuvcpp/proxy/nginx.conf @@ -0,0 +1,122 @@ +# libuvcpp gateway edge for gateway-64: stock nginx terminating TLS + HTTP/2 on +# 8443, serving /static/* from disk, and proxying everything else over h1 to the +# framework server on 8085. +# +# Why nginx rather than the Caddy the h3 stack still uses (see ../proxy-h3/): +# gateway-64 drives 512 connections x 32 streams = 16,384 concurrent requests, +# and over the profile's *HTTP/1.1* upstream hop that multiplexing is what the +# edge costs. On the reference machine Caddy edges land at 372-404 us/req there +# (libuvcpp, sark, tako -- three entries, same figure) while nginx edges land at +# 100-110 (vanilla-epoll, fulmine, morojs), and the same ~3x gap reproduces on a +# local two-service rig. That made the edge this stack's bottleneck: the proxy +# sat at 96% of its 32 logical CPUs while the server used 23% of its own. The +# profile lists "proxy choice" as one of the things entries compete on, and its +# own example compose uses nginx. The h3 profile cannot follow, because h3 needs +# QUIC and nginx has no HTTP/3 without a custom build against a QUIC library. +# +# worker_processes is a literal on purpose, and it is coupled to the proxy +# service's `cpuset` in compose.gateway.yml (0-15,64-79 = 32 logical CPUs). +# nginx's `worker_processes auto` counts the *host's* online CPUs and ignores a +# container cpuset, and `worker_cpu_affinity auto` is worse than that: it +# re-pins each worker to a CPU of its own choosing and escapes the cpuset +# entirely. Both were measured on this box under `taskset -c 8-11`, where +# `auto` + `worker_cpu_affinity auto` produced 18 workers pinned across CPUs +# 0-17. Change the cpuset and this number has to change with it. +# +# The static assets ship a `.gz` and a `.br` twin each; this edge serves the +# gzip one (`gzip_static`), which is what the official nginx image can do and +# what the profile's other nginx edges do. Serving the brotli twin instead needs +# nginx-mod-http-brotli, and a module built for one nginx build cannot be loaded +# into another, so it would mean taking both nginx and the module from a +# distribution rather than the official image. It was measured on the rig at +# this profile's concurrency and the two are a wash (brotli sends 47,275 B of +# app.js against gzip's 54,813, and the medians of nine runs came out within the +# rig's own run-to-run spread), so this keeps the stock image. + +user nginx; +worker_processes 32; +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 65535; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # The load generator never repeats a request, so an access log is pure cost. + access_log off; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + keepalive_timeout 300s; + keepalive_requests 1000000; + server_tokens off; + + # Serve the shipped .gz twin in place of the identity file when the client + # accepts gzip, and say so in Vary so the two encodings stay distinguishable + # to anything caching in front of this. + gzip_static on; + gzip_vary on; + + # nginx wants scratch directories that the upstream connection pool spills + # into; the image is built with these created and owned by the nginx user. + client_body_temp_path /var/lib/nginx/tmp/body; + proxy_temp_path /var/lib/nginx/tmp/proxy; + fastcgi_temp_path /var/lib/nginx/tmp/fastcgi; + uwsgi_temp_path /var/lib/nginx/tmp/uwsgi; + scgi_temp_path /var/lib/nginx/tmp/scgi; + + # One pooled h1 connection per in-flight request is what this profile asks + # for; `keepalive 2048` is the idle pool, and nginx opens beyond it under + # load rather than queueing. Raising it changed nothing measurable on the + # rig, so it stays at the value the Caddy config used. + upstream backend { + server 127.0.0.1:8085; + keepalive 2048; + keepalive_requests 1000000; + keepalive_timeout 300s; + } + + # Loopback addresses rather than the wildcard `listen 8443`, for the same + # reason ../proxy-production/nginx.conf spells out at length: the harness + # validates a compose stack while the entry's own standalone container is + # still up on `--network host` with /certs mounted, so a wildcard listen + # shares its reuseport group with that process's per-loop sockets and + # `localhost:8443` gets hashed across both servers. This stack's checks do + # not notice -- /static/*, /json, /async-db and /baseline2 are exactly the + # paths the standalone entry answers too, which is why the split went + # unseen here until the production-stack checks, whose /api/* is not on + # that entry's 8443 listener, caught it. Every URL the harness uses is + # `localhost`, so binding the loopbacks costs nothing. + server { + listen 127.0.0.1:8443 ssl http2 backlog=65535 reuseport; + listen [::1]:8443 ssl http2 backlog=65535 reuseport; + + ssl_certificate /certs/server.crt; + ssl_certificate_key /certs/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + + # The 30% of the profile's URIs that never reach the server: a file open + # plus sendfile, with the .gz twin chosen when the client sends + # `Accept-Encoding: br;q=1, gzip;q=0.8` -- which is what the load + # generator sends. A file with no twin, a request with no + # Accept-Encoding, and a Range request all fall through to the identity + # path unchanged. + location /static/ { + alias /data/static/; + } + + location / { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + } + } +} diff --git a/site/data/frameworks.json b/site/data/frameworks.json index 035a164d0..9197500a5 100644 --- a/site/data/frameworks.json +++ b/site/data/frameworks.json @@ -736,6 +736,7 @@ "type": "emerging", "engine": "ioxide", "mode": "standard", + "website": "https://genhttp.org/documentation/server/engines/ioxide/", "completeness": { "routing": true, "middleware": true, @@ -750,6 +751,7 @@ "type": "emerging", "engine": "kestrel", "mode": "standard", + "website": "https://genhttp.org/documentation/server/engines/kestrel/", "completeness": { "routing": true, "middleware": true, @@ -764,6 +766,7 @@ "type": "emerging", "engine": "genhttp", "mode": "standard", + "website": "https://genhttp.org/", "completeness": { "routing": true, "middleware": true, @@ -1167,7 +1170,7 @@ }, "libuvcpp": { "dir": "libuvcpp", - "description": "libuvcpp on the libuv event loop, one loop per available core. Routing, path and query params, static files and WebSocket through uvcpp_web_app, JSON written per request by the framework's own uvcpp_json_writer (values read out of the uvcpp_json DOM, no pre-rendered bytes), gzip through the built-in response compression, async Postgres through uvcpp_db_pool, and gRPC unary calls through the uvcpp_grpc module. One process serves HTTP/1.1 and prior-knowledge h2c on 8080, h2c on 8082, HTTP/2 over TLS on 8443, and HTTP/3 on 8443/udp, all from the same build; the gateway stacks put a stock Caddy edge in front of it.", + "description": "libuvcpp on the libuv event loop, one loop per available core. Routing, path and query params, static files and WebSocket through uvcpp_web_app, JSON serialized per request through the framework's bundled yyjson backend (uvcpp_json_builder, written by yyjson's own serializer, no pre-rendered bytes), gzip through the built-in response compression, async Postgres through uvcpp_db_pool, and gRPC unary calls through the uvcpp_grpc module. One process serves HTTP/1.1 and prior-knowledge h2c on 8080, h2c on 8082, HTTP/2 over TLS on 8443, and HTTP/3 on 8443/udp, all from the same build; the gateway-64 and production-stack profiles put a stock nginx edge in front of it and gateway-h3 a stock Caddy one.", "repo": "https://github.com/Antruly/libuvcpp", "type": "emerging", "engine": "libuv", diff --git a/site/data/results/libuvcpp.json b/site/data/results/libuvcpp.json index 3b50ff7dd..cdc60b37f 100644 --- a/site/data/results/libuvcpp.json +++ b/site/data/results/libuvcpp.json @@ -4,44 +4,44 @@ "8gbit-512": { "framework": "libuvcpp", "language": "C++", - "rps": 49175, - "avg_latency": "103.6us", - "p99_latency": "152.0us", - "cpu": "319.8%", - "memory": "128MiB", + "rps": 49340, + "avg_latency": "112.4us", + "p99_latency": "147.0us", + "cpu": "309.1%", + "memory": "209MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "511.32MB/s", - "input_bw": "480.22MB/s", + "bandwidth": "513.04MB/s", + "input_bw": "481.84MB/s", "reconnects": 0, - "status_2xx": 245942, + "status_2xx": 246773, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "cpu_usec": 17414305, - "cpu_per_req_us": 70.8066, + "cpu_usec": 16579179, + "cpu_per_req_us": 67.1839, "target_rate": 50000, - "rate_ratio": 0.9835, - "p99_9_latency": "1656.0us" + "rate_ratio": 0.9868, + "p99_9_latency": "6130.0us" }, "async-32000": { "framework": "libuvcpp", "language": "C++", - "rps": 1513512, - "avg_latency": "20.61ms", - "p99_latency": "23.10ms", - "cpu": "6394.1%", - "memory": "490MiB", + "rps": 1529805, + "avg_latency": "19.93ms", + "p99_latency": "24.60ms", + "cpu": "6125.0%", + "memory": "489MiB", "connections": 32000, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "226.57MB/s", - "input_bw": "69.28MB/s", + "bandwidth": "229.02MB/s", + "input_bw": "70.03MB/s", "reconnects": 0, - "status_2xx": 15135122, + "status_2xx": 15298051, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -49,19 +49,19 @@ "async-db-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 83890, - "avg_latency": "11.77ms", - "p99_latency": "41.70ms", - "cpu": "3415.7%", - "memory": "192MiB", + "rps": 95435, + "avg_latency": "10.21ms", + "p99_latency": "39.70ms", + "cpu": "2796.2%", + "memory": "185MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "324.51MB/s", - "input_bw": "5.60MB/s", - "reconnects": 33227, - "status_2xx": 838902, + "bandwidth": "369.07MB/s", + "input_bw": "6.37MB/s", + "reconnects": 37933, + "status_2xx": 954355, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -69,19 +69,19 @@ "baseline-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 1639189, - "avg_latency": "2.50ms", - "p99_latency": "3.27ms", - "cpu": "6419.0%", + "rps": 1802593, + "avg_latency": "2.27ms", + "p99_latency": "5.07ms", + "cpu": "6284.8%", "memory": "139MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "245.37MB/s", - "input_bw": "126.62MB/s", + "bandwidth": "269.83MB/s", + "input_bw": "139.25MB/s", "reconnects": 0, - "status_2xx": 8195949, + "status_2xx": 9012969, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -89,18 +89,18 @@ "baseline-h2-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 4688932, - "avg_latency": "20.62ms", - "p99_latency": "118.80ms", - "cpu": "6265.0%", - "memory": "178MiB", + "rps": 5430750, + "avg_latency": "17.83ms", + "p99_latency": "103.49ms", + "cpu": "6284.9%", + "memory": "172MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "122.22MB/s", + "bandwidth": "141.55MB/s", "reconnects": 0, - "status_2xx": 23726000, + "status_2xx": 27479600, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -108,18 +108,18 @@ "baseline-h2-256": { "framework": "libuvcpp", "language": "C++", - "rps": 4498827, - "avg_latency": "6.10ms", - "p99_latency": "37.07ms", - "cpu": "6152.2%", - "memory": "111MiB", + "rps": 5501093, + "avg_latency": "5.02ms", + "p99_latency": "28.45ms", + "cpu": "6319.2%", + "memory": "110MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "116.55MB/s", + "bandwidth": "142.51MB/s", "reconnects": 0, - "status_2xx": 22629100, + "status_2xx": 27670500, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -127,18 +127,18 @@ "baseline-h2c-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 4668853, - "avg_latency": "21.21ms", - "p99_latency": "566.60ms", - "cpu": "6423.9%", + "rps": 5709089, + "avg_latency": "17.54ms", + "p99_latency": "457.39ms", + "cpu": "6424.8%", "memory": "164MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "121.70MB/s", + "bandwidth": "148.51MB/s", "reconnects": 0, - "status_2xx": 23624400, + "status_2xx": 28830900, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -146,18 +146,18 @@ "baseline-h2c-256": { "framework": "libuvcpp", "language": "C++", - "rps": 4850874, - "avg_latency": "5.80ms", - "p99_latency": "52.59ms", - "cpu": "6055.3%", - "memory": "82MiB", + "rps": 5889601, + "avg_latency": "4.51ms", + "p99_latency": "41.60ms", + "cpu": "6197.9%", + "memory": "81MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "125.67MB/s", + "bandwidth": "152.27MB/s", "reconnects": 0, - "status_2xx": 24399900, + "status_2xx": 29565800, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -165,18 +165,18 @@ "baseline-h2c-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 4702573, - "avg_latency": "81.85ms", - "p99_latency": "682.36ms", - "cpu": "6417.1%", - "memory": "811MiB", + "rps": 5489431, + "avg_latency": "70.66ms", + "p99_latency": "779.10ms", + "cpu": "6391.8%", + "memory": "766MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "123.42MB/s", + "bandwidth": "144.33MB/s", "reconnects": 0, - "status_2xx": 23936100, + "status_2xx": 27996100, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -184,18 +184,18 @@ "baseline-h3-64": { "framework": "libuvcpp", "language": "C++", - "rps": 1167034, - "avg_latency": "4.46ms", - "p99_latency": "9.15ms", - "cpu": "4113.4%", - "memory": "238MiB", + "rps": 3239525, + "avg_latency": "1.18ms", + "p99_latency": "4.23ms", + "cpu": "3752.8%", + "memory": "103MiB", "connections": 64, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "15.56MB/s", + "bandwidth": "43.29MB/s", "reconnects": 0, - "status_2xx": 5846842, + "status_2xx": 16230024, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -203,18 +203,18 @@ "echo-ws-16384": { "framework": "libuvcpp", "language": "C++", - "rps": 2516942, - "avg_latency": "6.08ms", - "p99_latency": "7.46ms", - "cpu": "6118.9%", - "memory": "338MiB", + "rps": 2673301, + "avg_latency": "5.44ms", + "p99_latency": "7.04ms", + "cpu": "6159.0%", + "memory": "329MiB", "connections": 16384, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "17.20MB/s", + "bandwidth": "18.24MB/s", "reconnects": 0, - "status_2xx": 12584713, + "status_2xx": 13366508, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -222,18 +222,18 @@ "echo-ws-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 2844300, - "avg_latency": "1.44ms", - "p99_latency": "1.93ms", - "cpu": "6375.3%", - "memory": "130MiB", + "rps": 3007035, + "avg_latency": "1.36ms", + "p99_latency": "2.30ms", + "cpu": "6314.7%", + "memory": "131MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "19.04MB/s", + "bandwidth": "20.12MB/s", "reconnects": 0, - "status_2xx": 14221503, + "status_2xx": 15035178, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -241,18 +241,18 @@ "echo-ws-512": { "framework": "libuvcpp", "language": "C++", - "rps": 2751709, - "avg_latency": "185us", - "p99_latency": "458us", - "cpu": "6592.8%", + "rps": 2892280, + "avg_latency": "176us", + "p99_latency": "418us", + "cpu": "6512.2%", "memory": "79MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "18.37MB/s", + "bandwidth": "19.30MB/s", "reconnects": 0, - "status_2xx": 13758545, + "status_2xx": 14461400, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -260,18 +260,18 @@ "echo-ws-limited-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 1777709, - "avg_latency": "375us", - "p99_latency": "792us", - "cpu": "6250.1%", - "memory": "97MiB", + "rps": 1832895, + "avg_latency": "378us", + "p99_latency": "818us", + "cpu": "6204.7%", + "memory": "93MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "33.73MB/s", - "reconnects": 889076, - "status_2xx": 8888547, + "bandwidth": "34.78MB/s", + "reconnects": 916379, + "status_2xx": 9164476, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -279,18 +279,18 @@ "echo-ws-limited-512": { "framework": "libuvcpp", "language": "C++", - "rps": 1550796, - "avg_latency": "211us", - "p99_latency": "620us", - "cpu": "5899.1%", - "memory": "90MiB", + "rps": 1633110, + "avg_latency": "202us", + "p99_latency": "605us", + "cpu": "5850.3%", + "memory": "85MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "29.43MB/s", - "reconnects": 775390, - "status_2xx": 7753981, + "bandwidth": "30.99MB/s", + "reconnects": 816568, + "status_2xx": 8165553, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -298,18 +298,18 @@ "echo-ws-pipeline-16384": { "framework": "libuvcpp", "language": "C++", - "rps": 25179465, - "avg_latency": "9.83ms", - "p99_latency": "12.20ms", - "cpu": "6643.7%", - "memory": "357MiB", + "rps": 29129603, + "avg_latency": "7.85ms", + "p99_latency": "10.00ms", + "cpu": "6193.9%", + "memory": "348MiB", "connections": 16384, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "168.45MB/s", + "bandwidth": "194.82MB/s", "reconnects": 0, - "status_2xx": 125897329, + "status_2xx": 145648016, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -317,18 +317,18 @@ "echo-ws-pipeline-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 28938721, - "avg_latency": "2.26ms", - "p99_latency": "2.86ms", - "cpu": "6776.1%", - "memory": "138MiB", + "rps": 32736147, + "avg_latency": "1.99ms", + "p99_latency": "2.71ms", + "cpu": "6337.8%", + "memory": "135MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "193.18MB/s", + "bandwidth": "218.54MB/s", "reconnects": 0, - "status_2xx": 144693608, + "status_2xx": 163680736, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -336,18 +336,18 @@ "echo-ws-pipeline-512": { "framework": "libuvcpp", "language": "C++", - "rps": 30027149, - "avg_latency": "272us", - "p99_latency": "645us", - "cpu": "6641.8%", - "memory": "79MiB", + "rps": 33213203, + "avg_latency": "246us", + "p99_latency": "592us", + "cpu": "6810.3%", + "memory": "78MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "200.49MB/s", + "bandwidth": "221.58MB/s", "reconnects": 0, - "status_2xx": 150135747, + "status_2xx": 166066016, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -355,115 +355,115 @@ "gateway-64-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 68655, - "avg_latency": "420.44ms", - "p99_latency": "4.61s", - "cpu": "3722.8%", - "memory": "4.8GiB", + "rps": 233849, + "avg_latency": "107.33ms", + "p99_latency": "4.88s", + "cpu": "5998.6%", + "memory": "2.0GiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "780.52MB/s", + "bandwidth": "2.80GB/s", "reconnects": 0, - "status_2xx": 346709, + "status_2xx": 1183276, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_static": 104012, - "tpl_baseline": 69341, - "tpl_json": 121348, - "tpl_async_db": 52006, - "cpu_breakdown": "server: 785% 354MiB | proxy: 2938% 4.5GiB" + "tpl_static": 354982, + "tpl_baseline": 236655, + "tpl_json": 414146, + "tpl_async_db": 177491, + "cpu_breakdown": "server: 2774% 521MiB | proxy: 3225% 1.5GiB" }, "gateway-64-512": { "framework": "libuvcpp", "language": "C++", - "rps": 75887, - "avg_latency": "200.90ms", - "p99_latency": "2.15s", - "cpu": "4048.1%", - "memory": "2.5GiB", + "rps": 240386, + "avg_latency": "52.45ms", + "p99_latency": "4.83s", + "cpu": "5635.2%", + "memory": "1.5GiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "821.96MB/s", + "bandwidth": "2.82GB/s", "reconnects": 0, - "status_2xx": 383234, + "status_2xx": 1211549, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_static": 114970, - "tpl_baseline": 76646, - "tpl_json": 134131, - "tpl_async_db": 57485, - "cpu_breakdown": "server: 962% 210MiB | proxy: 3086% 2.3GiB" + "tpl_static": 363464, + "tpl_baseline": 242309, + "tpl_json": 424042, + "tpl_async_db": 181732, + "cpu_breakdown": "server: 2744% 309MiB | proxy: 2892% 1.2GiB" }, "gateway-h3-256": { "framework": "libuvcpp", "language": "C++", - "rps": 62547, - "avg_latency": "126.95ms", - "p99_latency": "783.31ms", - "cpu": "2859.1%", - "memory": "1.5GiB", + "rps": 62533, + "avg_latency": "127.00ms", + "p99_latency": "772.31ms", + "cpu": "2792.4%", + "memory": "1.4GiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "631.56MB/s", + "bandwidth": "631.92MB/s", "reconnects": 0, - "status_2xx": 314616, + "status_2xx": 314545, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_static": 94384, - "tpl_baseline": 62923, - "tpl_json": 110115, - "tpl_async_db": 47192, - "cpu_breakdown": "server: 755% 119MiB | proxy: 2104% 1.4GiB" + "tpl_static": 94363, + "tpl_baseline": 62909, + "tpl_json": 110090, + "tpl_async_db": 47181, + "cpu_breakdown": "server: 668% 107MiB | proxy: 2125% 1.3GiB" }, "gateway-h3-64": { "framework": "libuvcpp", "language": "C++", - "rps": 84167, - "avg_latency": "24.18ms", - "p99_latency": "85.33ms", - "cpu": "3685.2%", - "memory": "390MiB", + "rps": 84759, + "avg_latency": "24.01ms", + "p99_latency": "84.19ms", + "cpu": "3578.1%", + "memory": "415MiB", "connections": 64, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "871.37MB/s", + "bandwidth": "877.31MB/s", "reconnects": 0, - "status_2xx": 421679, + "status_2xx": 424644, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_static": 126503, - "tpl_baseline": 84335, - "tpl_json": 147587, - "tpl_async_db": 63251, - "cpu_breakdown": "server: 1018% 97MiB | proxy: 2667% 292MiB" + "tpl_static": 127393, + "tpl_baseline": 84928, + "tpl_json": 148625, + "tpl_async_db": 63696, + "cpu_breakdown": "server: 889% 81MiB | proxy: 2690% 335MiB" }, "json-comp-16384": { "framework": "libuvcpp", "language": "C++", - "rps": 321152, - "avg_latency": "30.31ms", - "p99_latency": "648.00ms", - "cpu": "6091.7%", - "memory": "162MiB", + "rps": 291220, + "avg_latency": "34.62ms", + "p99_latency": "724.90ms", + "cpu": "6503.1%", + "memory": "152MiB", "connections": 16384, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "459.04MB/s", - "input_bw": "23.89MB/s", - "reconnects": 63491, - "status_2xx": 1605764, + "bandwidth": "416.29MB/s", + "input_bw": "21.66MB/s", + "reconnects": 57518, + "status_2xx": 1456104, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -471,19 +471,19 @@ "json-comp-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 325476, - "avg_latency": "12.32ms", - "p99_latency": "283.50ms", - "cpu": "6476.7%", - "memory": "120MiB", + "rps": 285759, + "avg_latency": "14.01ms", + "p99_latency": "294.60ms", + "cpu": "6234.1%", + "memory": "111MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "465.12MB/s", - "input_bw": "24.21MB/s", - "reconnects": 64685, - "status_2xx": 1627384, + "bandwidth": "408.56MB/s", + "input_bw": "21.26MB/s", + "reconnects": 56722, + "status_2xx": 1428796, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -491,18 +491,18 @@ "json-h2c-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 1496312, - "avg_latency": "21.42ms", - "p99_latency": "509.96ms", - "cpu": "6387.4%", - "memory": "298MiB", + "rps": 1187769, + "avg_latency": "26.88ms", + "p99_latency": "506.39ms", + "cpu": "6357.1%", + "memory": "332MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "4.99GB/s", + "bandwidth": "3.97GB/s", "reconnects": 0, - "status_2xx": 7556377, + "status_2xx": 6010116, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -510,18 +510,18 @@ "json-h2c-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 1486566, - "avg_latency": "85.21ms", - "p99_latency": "538.31ms", - "cpu": "6273.2%", - "memory": "983MiB", + "rps": 1130987, + "avg_latency": "112.44ms", + "p99_latency": "888.05ms", + "cpu": "6187.0%", + "memory": "1.3GiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "4.97GB/s", + "bandwidth": "3.80GB/s", "reconnects": 0, - "status_2xx": 7551760, + "status_2xx": 5779348, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -529,18 +529,18 @@ "json-tls-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 672436, - "avg_latency": "6.03ms", - "p99_latency": "60.84ms", - "cpu": "6373.5%", - "memory": "396MiB", + "rps": 619671, + "avg_latency": "6.52ms", + "p99_latency": "58.00ms", + "cpu": "6361.6%", + "memory": "406MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "2.31GB", + "bandwidth": "2.13GB", "reconnects": 0, - "status_2xx": 3428958, + "status_2xx": 3160219, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -548,91 +548,91 @@ "latency-10k-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 9983, - "avg_latency": "78.9us", - "p99_latency": "121.0us", - "cpu": "38.5%", - "memory": "77MiB", + "rps": 9981, + "avg_latency": "83.3us", + "p99_latency": "120.0us", + "cpu": "36.7%", + "memory": "79MiB", "connections": 1024, "threads": 64, "duration": "20s", "pipeline": 1, "bandwidth": "1.56MB/s", "reconnects": 0, - "status_2xx": 199683, + "status_2xx": 199650, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "cpu_usec": 8090832, - "cpu_per_req_us": 40.5184, + "cpu_usec": 7641473, + "cpu_per_req_us": 38.2743, "target_rate": 10000, - "rate_ratio": 0.9983, - "p99_9_latency": "182.0us" + "rate_ratio": 0.9981, + "p99_9_latency": "433.0us" }, "latency-1m-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 997940, - "avg_latency": "168.6us", - "p99_latency": "462.0us", - "cpu": "4538.1%", - "memory": "81MiB", + "rps": 997805, + "avg_latency": "145.6us", + "p99_latency": "312.0us", + "cpu": "4179.8%", + "memory": "82MiB", "connections": 1024, "threads": 64, "duration": "20s", "pipeline": 1, - "bandwidth": "155.68MB/s", + "bandwidth": "155.66MB/s", "reconnects": 0, - "status_2xx": 19961754, + "status_2xx": 19959352, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "cpu_usec": 835214855, - "cpu_per_req_us": 41.8408, + "cpu_usec": 764408261, + "cpu_per_req_us": 38.2983, "target_rate": 1000000, - "rate_ratio": 0.9979, - "p99_9_latency": "970.0us" + "rate_ratio": 0.9978, + "p99_9_latency": "977.0us" }, "latency-500k-8cpu-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 245021, - "avg_latency": "5008517.3us", - "p99_latency": "10899456.0us", - "cpu": "775.7%", + "rps": 271901, + "avg_latency": "4536264.9us", + "p99_latency": "9293824.0us", + "cpu": "778.7%", "memory": "32MiB", "connections": 1024, "threads": 64, "duration": "20s", "pipeline": 1, - "bandwidth": "38.22MB/s", + "bandwidth": "42.42MB/s", "reconnects": 0, - "status_2xx": 4902305, + "status_2xx": 5439773, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "cpu_usec": 159611938, - "cpu_per_req_us": 32.5585, + "cpu_usec": 159574321, + "cpu_per_req_us": 29.3347, "target_rate": 500000, - "rate_ratio": 0.49, - "p99_9_latency": "11636736.0us" + "rate_ratio": 0.5438, + "p99_9_latency": "9654272.0us" }, "limited-conn-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 1309891, - "avg_latency": "3.09ms", - "p99_latency": "49.20ms", - "cpu": "6642.4%", - "memory": "101MiB", + "rps": 1480567, + "avg_latency": "2.74ms", + "p99_latency": "43.20ms", + "cpu": "6296.5%", + "memory": "86MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "196.08MB/s", - "input_bw": "101.19MB/s", - "reconnects": 655325, - "status_2xx": 6549456, + "bandwidth": "221.63MB/s", + "input_bw": "114.37MB/s", + "reconnects": 740631, + "status_2xx": 7402836, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -640,18 +640,18 @@ "pipelined-4096": { "framework": "libuvcpp", "language": "C++", - "rps": 6784375, - "avg_latency": "9.68ms", - "p99_latency": "12.30ms", - "cpu": "6360.9%", - "memory": "148MiB", + "rps": 7848400, + "avg_latency": "8.38ms", + "p99_latency": "10.60ms", + "cpu": "6474.9%", + "memory": "150MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "1015.54MB/s", + "bandwidth": "1.15GB/s", "reconnects": 0, - "status_2xx": 33921879, + "status_2xx": 39242004, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -659,66 +659,66 @@ "production-stack-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 46274, - "avg_latency": "346.24ms", - "p99_latency": "2.19s", - "cpu": "3301.1%", - "memory": "2.8GiB", + "rps": 208500, + "avg_latency": "73.82ms", + "p99_latency": "2.60s", + "cpu": "4284.8%", + "memory": "2.0GiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "475.49MB/s", + "bandwidth": "2.31GB/s", "reconnects": 0, - "status_2xx": 234147, + "status_2xx": 1059181, "status_3xx": 0, - "status_4xx": 5, + "status_4xx": 0, "status_5xx": 0, - "tpl_static": 70244, - "tpl_baseline": 23414, - "tpl_items": 117073, - "tpl_me": 23414, - "cpu_breakdown": "cache: 0% 5MiB | edge: 2788% 2.4GiB | authsvc: 277% 247MiB | server: 236% 172MiB" + "tpl_static": 317754, + "tpl_baseline": 105918, + "tpl_items": 529590, + "tpl_me": 105918, + "cpu_breakdown": "cache: 0% 4MiB | edge: 2803% 1.3GiB | authsvc: 728% 589MiB | server: 754% 122MiB" }, "production-stack-256": { "framework": "libuvcpp", "language": "C++", - "rps": 53685, - "avg_latency": "78.49ms", - "p99_latency": "436.48ms", - "cpu": "3165.2%", - "memory": "962MiB", + "rps": 256279, + "avg_latency": "17.06ms", + "p99_latency": "131.13ms", + "cpu": "4765.6%", + "memory": "1.2GiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "515.93MB/s", + "bandwidth": "2.78GB/s", "reconnects": 0, - "status_2xx": 269502, + "status_2xx": 1289088, "status_3xx": 0, - "status_4xx": 10, + "status_4xx": 0, "status_5xx": 0, - "tpl_static": 80850, - "tpl_baseline": 26950, - "tpl_items": 134751, - "tpl_me": 26950, - "cpu_breakdown": "cache: 0% 14MiB | edge: 2720% 812MiB | authsvc: 239% 60MiB | server: 206% 82MiB" + "tpl_static": 386726, + "tpl_baseline": 128908, + "tpl_items": 644544, + "tpl_me": 128908, + "cpu_breakdown": "cache: 0% 15MiB | edge: 3095% 994MiB | authsvc: 714% 107MiB | server: 956% 100MiB" }, "static-h2-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 242380, - "avg_latency": "131.42ms", - "p99_latency": "473.88ms", - "cpu": "2463.2%", - "memory": "793MiB", + "rps": 550039, + "avg_latency": "57.34ms", + "p99_latency": "243.84ms", + "cpu": "6240.6%", + "memory": "507MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "3.67GB/s", + "bandwidth": "8.42GB/s", "reconnects": 0, - "status_2xx": 1221596, + "status_2xx": 2783199, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -726,18 +726,18 @@ "static-h2-256": { "framework": "libuvcpp", "language": "C++", - "rps": 238807, - "avg_latency": "35.15ms", - "p99_latency": "171.99ms", - "cpu": "2420.0%", - "memory": "189MiB", + "rps": 806684, + "avg_latency": "10.69ms", + "p99_latency": "61.94ms", + "cpu": "6459.0%", + "memory": "240MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "3.65GB/s", + "bandwidth": "12.33GB/s", "reconnects": 0, - "status_2xx": 1201201, + "status_2xx": 4057623, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -745,18 +745,18 @@ "static-h3-64": { "framework": "libuvcpp", "language": "C++", - "rps": 114206, - "avg_latency": "32.76ms", - "p99_latency": "146.38ms", - "cpu": "3085.9%", - "memory": "472MiB", + "rps": 238175, + "avg_latency": "17.31ms", + "p99_latency": "82.81ms", + "cpu": "4207.0%", + "memory": "475MiB", "connections": 64, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "1.74GB/s", + "bandwidth": "3.62GB/s", "reconnects": 0, - "status_2xx": 572175, + "status_2xx": 1193260, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -764,18 +764,18 @@ "static-tls-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 190096, - "avg_latency": "5.36ms", - "p99_latency": "22.78ms", - "cpu": "3118.3%", - "memory": "278MiB", + "rps": 571215, + "avg_latency": "1.79ms", + "p99_latency": "14.38ms", + "cpu": "6554.2%", + "memory": "273MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "2.93GB", + "bandwidth": "8.81GB", "reconnects": 0, - "status_2xx": 969434, + "status_2xx": 2913314, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -783,18 +783,18 @@ "unary-grpc-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 3263606, - "avg_latency": "30.29ms", - "p99_latency": "193.63ms", - "cpu": "6435.7%", - "memory": "412MiB", + "rps": 3600218, + "avg_latency": "27.30ms", + "p99_latency": "236.44ms", + "cpu": "6352.8%", + "memory": "428MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "135.12MB/s", + "bandwidth": "149.35MB/s", "reconnects": 0, - "status_2xx": 16481213, + "status_2xx": 18217105, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -802,18 +802,18 @@ "unary-grpc-256": { "framework": "libuvcpp", "language": "C++", - "rps": 3391572, - "avg_latency": "7.84ms", - "p99_latency": "118.92ms", - "cpu": "6123.1%", - "memory": "145MiB", + "rps": 3837633, + "avg_latency": "6.78ms", + "p99_latency": "44.98ms", + "cpu": "6228.5%", + "memory": "173MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "139.92MB/s", + "bandwidth": "158.32MB/s", "reconnects": 0, - "status_2xx": 17059609, + "status_2xx": 19303294, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -821,18 +821,18 @@ "unary-grpc-tls-1024": { "framework": "libuvcpp", "language": "C++", - "rps": 3092506, - "avg_latency": "31.49ms", - "p99_latency": "154.43ms", - "cpu": "6191.1%", - "memory": "598MiB", + "rps": 3517793, + "avg_latency": "27.85ms", + "p99_latency": "181.47ms", + "cpu": "6142.2%", + "memory": "591MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "128.28MB/s", + "bandwidth": "145.95MB/s", "reconnects": 0, - "status_2xx": 15648081, + "status_2xx": 17800036, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -840,18 +840,18 @@ "unary-grpc-tls-256": { "framework": "libuvcpp", "language": "C++", - "rps": 3286145, - "avg_latency": "8.02ms", - "p99_latency": "77.81ms", - "cpu": "6041.9%", + "rps": 3664147, + "avg_latency": "7.22ms", + "p99_latency": "60.27ms", + "cpu": "6168.1%", "memory": "165MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "135.57MB/s", + "bandwidth": "151.17MB/s", "reconnects": 0, - "status_2xx": 16529310, + "status_2xx": 18430664, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/static/logs/8gbit/512/libuvcpp.log b/site/static/logs/8gbit/512/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/8gbit/512/libuvcpp.log +++ b/site/static/logs/8gbit/512/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/async-db/1024/libuvcpp.log b/site/static/logs/async-db/1024/libuvcpp.log index a31c1a85c..a679d3d0b 100644 --- a/site/static/logs/async-db/1024/libuvcpp.log +++ b/site/static/logs/async-db/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) libuvcpp: Postgres pool up (max=256) diff --git a/site/static/logs/async/32000/libuvcpp.log b/site/static/logs/async/32000/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/async/32000/libuvcpp.log +++ b/site/static/logs/async/32000/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/baseline-h2/1024/libuvcpp.log b/site/static/logs/baseline-h2/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/baseline-h2/1024/libuvcpp.log +++ b/site/static/logs/baseline-h2/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/baseline-h2/256/libuvcpp.log b/site/static/logs/baseline-h2/256/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/baseline-h2/256/libuvcpp.log +++ b/site/static/logs/baseline-h2/256/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/baseline-h2c/1024/libuvcpp.log b/site/static/logs/baseline-h2c/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/baseline-h2c/1024/libuvcpp.log +++ b/site/static/logs/baseline-h2c/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/baseline-h2c/256/libuvcpp.log b/site/static/logs/baseline-h2c/256/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/baseline-h2c/256/libuvcpp.log +++ b/site/static/logs/baseline-h2c/256/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/baseline-h2c/4096/libuvcpp.log b/site/static/logs/baseline-h2c/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/baseline-h2c/4096/libuvcpp.log +++ b/site/static/logs/baseline-h2c/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/baseline-h3/64/libuvcpp.log b/site/static/logs/baseline-h3/64/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/baseline-h3/64/libuvcpp.log +++ b/site/static/logs/baseline-h3/64/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/baseline/4096/libuvcpp.log b/site/static/logs/baseline/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/baseline/4096/libuvcpp.log +++ b/site/static/logs/baseline/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws-limited/4096/libuvcpp.log b/site/static/logs/echo-ws-limited/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws-limited/4096/libuvcpp.log +++ b/site/static/logs/echo-ws-limited/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws-limited/512/libuvcpp.log b/site/static/logs/echo-ws-limited/512/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws-limited/512/libuvcpp.log +++ b/site/static/logs/echo-ws-limited/512/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws-pipeline/16384/libuvcpp.log b/site/static/logs/echo-ws-pipeline/16384/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws-pipeline/16384/libuvcpp.log +++ b/site/static/logs/echo-ws-pipeline/16384/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws-pipeline/4096/libuvcpp.log b/site/static/logs/echo-ws-pipeline/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws-pipeline/4096/libuvcpp.log +++ b/site/static/logs/echo-ws-pipeline/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws-pipeline/512/libuvcpp.log b/site/static/logs/echo-ws-pipeline/512/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws-pipeline/512/libuvcpp.log +++ b/site/static/logs/echo-ws-pipeline/512/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws/16384/libuvcpp.log b/site/static/logs/echo-ws/16384/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws/16384/libuvcpp.log +++ b/site/static/logs/echo-ws/16384/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws/4096/libuvcpp.log b/site/static/logs/echo-ws/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws/4096/libuvcpp.log +++ b/site/static/logs/echo-ws/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/echo-ws/512/libuvcpp.log b/site/static/logs/echo-ws/512/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/echo-ws/512/libuvcpp.log +++ b/site/static/logs/echo-ws/512/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/json-comp/16384/libuvcpp.log b/site/static/logs/json-comp/16384/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/json-comp/16384/libuvcpp.log +++ b/site/static/logs/json-comp/16384/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/json-comp/4096/libuvcpp.log b/site/static/logs/json-comp/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/json-comp/4096/libuvcpp.log +++ b/site/static/logs/json-comp/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/json-h2c/1024/libuvcpp.log b/site/static/logs/json-h2c/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/json-h2c/1024/libuvcpp.log +++ b/site/static/logs/json-h2c/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/json-h2c/4096/libuvcpp.log b/site/static/logs/json-h2c/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/json-h2c/4096/libuvcpp.log +++ b/site/static/logs/json-h2c/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/json-tls/4096/libuvcpp.log b/site/static/logs/json-tls/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/json-tls/4096/libuvcpp.log +++ b/site/static/logs/json-tls/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/latency-10k/1024/libuvcpp.log b/site/static/logs/latency-10k/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/latency-10k/1024/libuvcpp.log +++ b/site/static/logs/latency-10k/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/latency-1m/1024/libuvcpp.log b/site/static/logs/latency-1m/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/latency-1m/1024/libuvcpp.log +++ b/site/static/logs/latency-1m/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/latency-500k-8cpu/1024/libuvcpp.log b/site/static/logs/latency-500k-8cpu/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/latency-500k-8cpu/1024/libuvcpp.log +++ b/site/static/logs/latency-500k-8cpu/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/limited-conn/4096/libuvcpp.log b/site/static/logs/limited-conn/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/limited-conn/4096/libuvcpp.log +++ b/site/static/logs/limited-conn/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/pipelined/4096/libuvcpp.log b/site/static/logs/pipelined/4096/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/pipelined/4096/libuvcpp.log +++ b/site/static/logs/pipelined/4096/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/static-h2/1024/libuvcpp.log b/site/static/logs/static-h2/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/static-h2/1024/libuvcpp.log +++ b/site/static/logs/static-h2/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/static-h2/256/libuvcpp.log b/site/static/logs/static-h2/256/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/static-h2/256/libuvcpp.log +++ b/site/static/logs/static-h2/256/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/static-h3/64/libuvcpp.log b/site/static/logs/static-h3/64/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/static-h3/64/libuvcpp.log +++ b/site/static/logs/static-h3/64/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/static-tls/1024/libuvcpp.log b/site/static/logs/static-tls/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/static-tls/1024/libuvcpp.log +++ b/site/static/logs/static-tls/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/unary-grpc-tls/1024/libuvcpp.log b/site/static/logs/unary-grpc-tls/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/unary-grpc-tls/1024/libuvcpp.log +++ b/site/static/logs/unary-grpc-tls/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/unary-grpc-tls/256/libuvcpp.log b/site/static/logs/unary-grpc-tls/256/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/unary-grpc-tls/256/libuvcpp.log +++ b/site/static/logs/unary-grpc-tls/256/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/unary-grpc/1024/libuvcpp.log b/site/static/logs/unary-grpc/1024/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/unary-grpc/1024/libuvcpp.log +++ b/site/static/logs/unary-grpc/1024/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set diff --git a/site/static/logs/unary-grpc/256/libuvcpp.log b/site/static/logs/unary-grpc/256/libuvcpp.log index c561222e5..365775ad6 100644 --- a/site/static/logs/unary-grpc/256/libuvcpp.log +++ b/site/static/logs/unary-grpc/256/libuvcpp.log @@ -1,2 +1 @@ -libuvcpp: /json fast path: flattened dataset (50 items, 115 tag strings, 1508 B pool + 3600 B items) -libuvcpp: DATABASE_URL is not set -- /async-db answers {"items":[],"count":0} +libuvcpp: DATABASE_URL is not set -- /async-db answers the empty result set