Parse block payloads with hyperpb instead of dynamicpb - #876
Merged
Conversation
The sink only ever has the module descriptor at runtime, so every payload goes through a dynamic parser. hyperpb compiles the descriptor once and parses into an arena: 18x on the parse, one allocation per block instead of thousands, and the walk reads through protoreflect either way so the rows are identical. The parser tests and benchmarks live in the benchmarks package, which the local buffer PR introduces; they land with it rather than duplicating its harness here.
Runs have failed with every container in the package going unreachable part-way through: six tests pass, then `connection refused` on both the Postgres and the ClickHouse mapped port for the remaining eighteen. It predates this branch and reproduces on the base branch. Disable Ryuk in CI. Several packages start their own containers and `go test ./...` runs them in parallel, so more than one binary is connected to the shared reaper; the failures line up with another package's binary exiting. The reaper buys nothing on a runner that is destroyed with the job. Wait for Postgres by connecting rather than by reading its log, as the ClickHouse container already did. The line it watched for is printed twice — once by the temporary server initdb runs over a Unix socket — so the second occurrence is a proxy for listening on TCP, not proof of it, and the 5s budget was inside what a cold runner takes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite needs a container runtime anyway, and a variable that skips half of it quietly only hides a broken environment behind a green run. TestMain no longer checks SF_SINK_SQL_INTEGRATION_TESTS, and the workflow no longer sets it: nothing reads it any more.
The parse needs only a descriptor and the walk only a dialect and an in-memory inserter, so the comparison runs anywhere: 17.7x and 18.2x on the parse, 2.2x and 2.6x once the walk is included, one allocation per block instead of thousands. Also pins that both parsers produce the same message and that hyperpb messages are read-only and single-parse-per-arena, which is what shapes the decoder.
Contributor
Author
|
@maoueh yeah I'm tweaking test runs and benchmarks to get this clean. |
Removing the environment gate means the integration tests always run, and the macOS runner has no Docker to run them with: it panicked on the first container. macOS keeps the build step, so a compile break still shows up on both.
Contributor
Author
The arena is per block slot and never shared; the compiled type is what every worker touches at once. It is immutable — hyperpb's PGO path returns a new type from Recompile rather than mutating — and this fails under -race if that changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces #873, which GitHub closed when this branch was rebased off the local-buffer PR
and onto
develop— it can now merge on its own, ahead of #869.The from-proto sink only ever has the module's descriptor at runtime, never a generated Go type, so every block payload goes through a dynamic parser. It used protobuf-go's
dynamicpb. This switches it to hyperpb, which compiles the descriptor into a parser once and then parses into an arena.The descriptor walk only ever read the message —
Descriptor,Get,IsValidand nothing else — so it now takes aprotoreflect.Messageand the decoder chooses the parser. That is the whole structural change; the walk's behaviour is untouched.Numbers
M4 Max, 200 entities per payload.
BenchmarkUnmarshal, parse in isolation:TestClientDecodeScaling, the real per-block work (parse + walk + buffer), wide entity, 16 cores:18x on the parse becomes 1.9x on the full decode path and 2.7x across the pool — the parse was about half the wide-entity cost and is now about a twentieth, so the walk is what is left (~90%).
Two conclusions in
benchmarks/README.mdchanged and have been rewritten rather than appended to:Constraints, and how they are handled
hyperpb is not a drop-in in every respect, and each of these is a panic rather than an error, so each is pinned by a test in
benchmarks/parser_test.go:TestHyperpbRejectsMutationfails loudly if a write is ever added to that path.attempted to parse message using in-use Context. This is why the decoder keeps one arena per block slot, not the one-per-worker arrangement that looks natural.Freeon an arena nothing was allocated in panics on a negative slice bound. A block whose module produced no output never parses, so a slot really can reach its first free untouched —decoder.arena.usedcovers it.BufferedInserteruntilapplyreplays them. So an arena is freed at the top of the nextdecodeAll, by which point the previous round has been applied and flushed.govalidatoroverflows onlinux/386ondeveloptoday).Breaking
sql.Database.WalkMessageDescriptorAndInsert,WalkMessageDescriptorAndInsertInto,BaseDatabase.WalkMessageDescriptorAndInsertWithDialectandsql.Dialect.AppendInlineFieldValuestake aprotoreflect.Messagewhere they took a*dynamicpb.Message.tui/print.goandpb/sf/substreams/v1/package.gostill usedynamicpband should: they arejsonpb.AnyResolverimplementations that hand an empty message to a JSON decoder to unmarshal into, which is mutation. hyperpb cannot serve that.Measured on this branch alone
sink/sql/db_proto/parser_test.gocompares the two parsers with no database and nocontainer: the parse needs only a descriptor, and the walk only a dialect and an in-memory
inserter.
go test ./sink/sql/db_proto/ -bench . -benchmemreproduces it.BenchmarkUnmarshal, the parse in isolation, 200 entities per payload, M4 Max:BenchmarkDecodeBlock, the per-block work the decoder parallelises — parse and walk,which is the ratio an operator actually sees:
The parse stops being the expensive half of decoding: the walk is what is left.
Testing
sink/sqlsuite green, Postgres and ClickHouse. The integration tests no longer need an environment variable to run — see below.Also on this branch
Two commits that are not about the parser:
Stop the sink/sql integration containers dying mid-suite in CI—go test ./...builds several packages that each start their own containers and runs them in parallel, so more than one test binary was connected to the shared Ryuk reaper at a time. Runs failed with every container in one package going unreachable part-way through the suite — tests that had already passed, thenconnection refusedon both the Postgres and the ClickHouse mapped port — right as a different package's binary exited.TESTCONTAINERS_RYUK_DISABLEDis now set in CI: Ryuk buys nothing there, since the runner is destroyed when the job ends.Always run the sql integration tests—TestMainno longer checksSF_SINK_SQL_INTEGRATION_TESTSandos.Exit(0)s when it is unset. The suite needs a container runtime anyway, and a variable that quietly skips half of it only hides a broken environment behind a green run. Same reasoning as the container correctness test in Sink from-proto through a disk spool, and load without constraints #869. The variable now has no readers left in the repo and its export is gone frombin/test.sh; the workflow runs the tests on ubuntu only, since the macOS runner has no container runtime and keeps the build step.🤖 Generated with Claude Code