Repository navigation
Reshape a query's results with SQL, in process (Step 1 of #490) - #499
Merged
Merged
Conversation
1 task done
5 tasks done
zipdoki
force-pushed
the
feat/calcite-transform
branch
from
August 27, 2026 08:08
63dfbe6 to
ace0a50
Compare
zipdoki
force-pushed
the
feat/calcite-transform
branch
from
August 31, 2026 00:51
ace0a50 to
46f11f1
Compare
zipdoki
force-pushed
the
feat/calcite-transform
branch
from
August 31, 2026 01:03
46f11f1 to
6565850
Compare
Contributor
Author
|
Optimistic Merge |
`TransformSchedulerProbe` already wrote down what a transform does to a non-blocking thread: opening a session reads the driver and the generated class off disk, and waiting for a busy one parks the caller for up to a second. Both land on whoever subscribed, and nothing between the controller and here hops schedulers, so on a WebFlux request that was an event loop. `transforms.run` is the paying path — a statement the cache has not seen is parsed, planned and given its sessions inside the call, which the probe measured at 5 to 7 ms warm and far worse cold. Put it where blocking is allowed.
A transform's frame is added to the fetch context under the transform's name, and the response is keyed the same way. A transform named after a fetch step replaced that frame for every later transform and in what the caller got back, and a repeated transform name did the same to the earlier one. Both were silent — the response carried the right keys and the wrong rows. Refuse the query instead. The check names the collision, and it covers a fetch step with `include = false` too, since a transform reads the whole context and not only what is returned.
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.
Summary
A multi-hop query returns one frame per step and nothing more. Joining two hops, ranking their rows or projecting a subset all happens on the caller. This lets a query name SQL that runs over the frames it just fetched, in process, before the response goes out.
{ "fetch": [ { "type": "SCAN", "name": "follows", "database": "graph", "table": "follows", "index": "created_at", "source": { "type": "VALUE", "value": "user1" } }, { "type": "TOPK", "name": "ranked", "database": "graph", "table": "orders", "topk": "top_product_groups_1y", "entity": { "type": "REF", "ref": "follows", "field": "tgt" }, "limit": 100 } ], "transform": [ { "type": "SQL", "name": "top_items", "sql": "SELECT ranked.tgt, SUM(ranked.metric) AS metric FROM ranked GROUP BY ranked.tgt ORDER BY metric DESC LIMIT ?", "arguments": ["10"] } ] }Each step's frame is a table named after the step, so SQL addresses
ranked.metricdirectly. Transforms run in order and each one's output joins the context under itsname, so a later transform can read an earlier one. They see the whole context includinginclude = falsesteps — a step can exist only to be subtracted from a later one —and what the caller gets back is filtered afterwards.
Calcite is the engine, over a JDBC connection whose root schema holds one slot per frame. Most of the design here is about not paying its planning cost per request.
SqlTransformcaches aPreparedTransformper(sql, input columns). Calcite's driver keeps no plan between statements, so without this every request re-parses, re-plans and re-compiles the same SQL. Each prepared transform owns a pool of sessions rather than one shared session, because aPreparedStatementtakes a single thread.Sessions open at prepare time, not on the request path — opening one touches disk for the driver and the generated class.
preparepays that cost andpreparedanswers only if it was already paid, so a caller can keep it off a thread that must not block.The pool is capped per transform, so a burst of concurrent requests on the same SQL queues instead of scaling out. I'd rather queue than open connections on the request path. If that queue shows up in latency, the cap is the knob.
Two smaller decisions. The connection sets
lex=JAVAso identifiers stay as written andhop1.paidAtis not folded toPAIDAT, andfun=allso the dialect operator tables are available — the standard one has noIFNULL. Andcalcite.bindable.cache.maxSize=1000is set in three places: the Gradle test JVM, the Jib image, and as adefault in the pool. That cache is process-wide and its default is small enough that a handful of distinct transforms evict each other.
Nothing here bounds what a transform can ask for — no row cap on the result, no timeout, no check on the SQL beyond what Calcite refuses to plan. That needs to exist before this is reachable by an untrusted caller, and I'll do it in a follow-up.
The two probes are
@Disabled, and that is deliberate.TransformSchedulerProbeinstalls BlockHound for the whole JVM, andCalciteLatencyProbemeasures Calcite's planning cost rather than anything in this repo. They document how the numbers above were arrived at; run them by hand from the IDE when the numbers matter.Stacked on #498 — that needs to land first.
Part of #490.
Test plan
./gradlew :engine:test --tests '*SqlTransformTest*'— plan cache, session pool, prepared-vs-prepare, argument binding, closing./gradlew :engine:test --tests '*DataFrameSqlTransformTest*'— SQL over frames: projection, join across steps, aggregation, ordering./gradlew :engine:test --tests '*DataFrameShowTest*'— the frame formatter the tests read failures through./gradlew spotlessCheck build— formatting and full buildAI Assistance