Flat RPC client and AtomRpc query/mutation: type-check cost grows with RPCs in the group × tags called
I maintainer di solito rispondono entro 1 giorno
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
What version of Effect is running?
[email protected] (also seen on 4.0.5); TypeScript 7 (@typescript/[email protected]). The types involved are unchanged on main.
What steps can reproduce the bug?
RpcClient.RpcClient.Flat, AtomRpc's query and mutation, and Rpc.ExtractTag type each call with a distributive conditional over the whole RPC union (Rpcs extends Rpc.Rpc<infer _Tag extends Tag, …>). For every distinct tag a program calls, TypeScript evaluates that conditional against every RPC in the group. So type-check time and memory grow with distinct tags called × RPCs in the group: roughly quadratic for an app that calls most of its RPCs. AtomRpc always builds its client with flatten: true, so AtomRpc users can't switch to the object-shaped client, which is a mapped type computed once and stays cheap.
The script below writes a group of N generated RPCs and 300 calls in each style:
flat:yield* client("RpcK", payload)through anAtomRpc.Servicequery:Client.query("RpcK", payload)mutation:Client.mutation("RpcK")methods: the object-shapedRpcClient.RpcClient, asclient.RpcK(payload)none: the group alone, with no calls
Reproduction (3 files, then npm install && bash bench.sh 600 300)
package.json
{
"private": true,
"type": "module",
"dependencies": { "effect": "4.1.2" },
"devDependencies": { "@typescript/native-preview": "7.0.0-dev.20260707.2" }
}
gen.mjs
// node gen.mjs <rpcs> <calls>: a group of <rpcs> RPCs, and <calls> calls in each style.
import { mkdirSync, writeFileSync } from "node:fs";
const [rpcs, calls] = process.argv.slice(2).map(Number);
const tags = Array.from({ length: rpcs }, (_, i) => `Rpc${i}`);
const config = (file) =>
JSON.stringify({
compilerOptions: { strict: true, target: "esnext", module: "nodenext", noEmit: true, allowImportingTsExtensions: true, skipLibCheck: true },
files: [file],
});
mkdirSync("src", { recursive: true });
writeFileSync("src/rpcs.ts", `import { type Layer, Schema } from "effect";
import { AtomRpc } from "effect/reactivity";
import { Rpc, type RpcClient, RpcGroup } from "effect/rpc";
class Failed extends Schema.TaggedError<Failed>()("Failed", { code: Schema.String }) {}
export const Rpcs = RpcGroup.make(
${tags.map((t) => ` Rpc.make("${t}", { payload: { id: Schema.String, n: Schema.Number }, success: Schema.Struct({ id: Schema.String, at: Schema.Number }), error: Failed }),`).join("\n")}
);
declare const protocol: Layer.Layer<RpcClient.Protocol>;
export class Client extends AtomRpc.Service<Client>()("Client", { group: Rpcs, protocol }) {}
`);
const styles = {
none: () => `0`,
flat: (t) => `Effect.gen(function* () { const client = yield* Client; return yield* client("${t}", { id: "a", n: 1 }); })`,
methods: (t) => `Effect.gen(function* () { return yield* methods.${t}({ id: "a", n: 1 }); })`,
query: (t) => `Client.query("${t}", { id: "a", n: 1 })`,
mutation: (t) => `Client.mutation("${t}")`,
};
for (const [style, call] of Object.entries(styles)) {
writeFileSync(`src/${style}.ts`, `import { Effect } from "effect";
import type { RpcClient, RpcClientError, RpcGroup } from "effect/rpc";
import { Client, type Rpcs } from "./rpcs.ts";
declare const methods: RpcClient.RpcClient<RpcGroup.Rpcs<typeof Rpcs>, RpcClientError.RpcClientError>;
export const used = [methods, Client] as const;
${Array.from({ length: calls }, (_, i) => `export const c${i} = ${call(tags[(i * 7) % rpcs])};`).join("\n")}
`);
writeFileSync(`tsconfig.${style}.json`, config(`src/${style}.ts`));
}
bench.sh
#!/usr/bin/env bash
# bash bench.sh <rpcs> <calls>
set -e
node gen.mjs "$1" "$2"
printf "%-9s %15s %8s %10s\n" style instantiations check memory
for style in none flat methods query mutation; do
out=$(npx tsgo -p "tsconfig.$style.json" --singleThreaded --extendedDiagnostics 2>&1) || { echo "$out" | head -5; exit 1; }
printf "%-9s %15s %8s %10s\n" "$style" \
"$(awk '/^Instantiations/{print $2}' <<<"$out")" \
"$(awk '/^Check time/{print $3}' <<<"$out")" \
"$(awk '/^Memory used/{print $3}' <<<"$out")"
done
What is the expected behavior?
A call should cost about the same whatever the size of its group, as the object-shaped client already does. Calling "Rpc5" shouldn't evaluate the other 599 RPCs.
What do you see instead?
With 600 RPCs and 300 calls (tsgo --singleThreaded --extendedDiagnostics, Effect 4.1.2):
| style | instantiations | check time | memory |
|---|---|---|---|
| none (group only) | 421,316 | 0.65 s | 194 MB |
methods (RpcClient.RpcClient) |
504,080 | 0.79 s | 203 MB |
flat (AtomRpc client) |
17,663,261 | 13.2 s | 999 MB |
AtomRpc.query |
11,843,988 | 11.5 s | 984 MB |
AtomRpc.mutation |
6,768,871 | 7.3 s | 971 MB |
Here is how the flat style scales. The calls cycle through (i * 7) % N, so the number of distinct tags called is min(calls, N):
| RPCs | calls (distinct tags) | flat instantiations |
|---|---|---|
| 150 | 300 (150) | 2,352,911 |
| 300 | 100 (100) | 3,244,961 |
| 300 | 300 (300) | 8,876,561 |
| 600 | 300 (300) | 17,663,261 |
That's about 100 instantiations per (distinct tag × RPC) pair. Repeat calls to the same tag are cached, so the count of call sites doesn't matter. What matters is how many different RPCs the program calls.
In our app (about 590 RPCs and 355 call sites, through AtomRpc), this was about 34M of the program's 48.7M instantiations. It became a real problem with type-aware linting: oxlint's tsgolint keeps a checker per thread, so the cost is paid once per thread. The full type-aware lint peaked at 14.8 GB on 8 threads, and our CI runner (7 GB) was killed at 9.4 GB. Moving the calls to object-shaped methods took the program from 48.7M to 14.3M instantiations, tsgo from 5.4 GB to 3.5 GB, and the 8-thread lint from 14.8 GB to 7.8 GB.
Additional information
A proposed fix. Look the RPC up through a tag-keyed mapped type (built once per group), and let the conditionals distribute only over the RPC that was picked. A default type parameter keeps them distributive, so union tags (#8578) still give the same union result:
--- a/packages/effect/src/rpc/Rpc.ts
+++ b/packages/effect/src/rpc/Rpc.ts
@@ -696,15 +696,17 @@
* @category utility types
* @since 4.0.0
*/
-export type ExtractTag<R extends Any, Tag extends string> = R extends Rpc<
- infer _Tag extends Tag,
- infer _Payload,
- infer _Success,
- infer _Error,
- infer _Middleware,
- infer _Requires
-> ? R :
- never
+export type ExtractTag<R extends Any, Tag extends string> = ByTag<R>[Tag & keyof ByTag<R>]
+
+/**
+ * Indexes RPCs by their tag. Computed once per group, so looking an RPC up by its
+ * tag doesn't test every RPC in the group.
+ *
+ * @stability unstable
+ * @category utility types
+ * @since 4.1.3
+ */
+export type ByTag<R extends Any> = { readonly [K in R as K["_tag"]]: K }
/**
* Extracts the services provided by middleware on the RPC with the specified
--- a/packages/effect/src/rpc/RpcClient.ts
+++ b/packages/effect/src/rpc/RpcClient.ts
@@ -170,7 +170,8 @@
export type Flat<Rpcs extends Rpc.Any, E = never> = <
const Tag extends Rpcs["_tag"],
const AsQueue extends boolean = false,
- const Discard = false
+ const Discard = false,
+ R = Rpc.ExtractTag<Rpcs, Tag>
>(
tag: Tag,
payload: Rpc.PayloadConstructor<Rpc.ExtractTag<Rpcs, Tag>>,
@@ -194,8 +195,8 @@
*/
readonly discard?: Discard | undefined
}
- ) => Rpcs extends Rpc.Rpc<
- infer _Tag extends Tag,
+ ) => R extends Rpc.Rpc<
+ infer _Tag,
infer _Payload,
infer _Success,
infer _Error,
--- a/packages/effect/src/reactivity/AtomRpc.ts
+++ b/packages/effect/src/reactivity/AtomRpc.ts
@@ -54,10 +54,10 @@
readonly runtime: Atom.AtomRuntime<Self>
- readonly mutation: <Tag extends Rpc.Tag<Rpcs>>(
+ readonly mutation: <Tag extends Rpc.Tag<Rpcs>, R = Rpc.ExtractTag<Rpcs, Tag>>(
arg: Tag
- ) => Rpcs extends Rpc.Rpc<
- infer _Tag extends Tag,
+ ) => R extends Rpc.Rpc<
+ infer _Tag,
infer _Payload,
infer _Success,
infer _Error,
@@ -66,7 +66,7 @@
> ? [_Success] extends [RpcSchema.Stream<infer _A, infer _E>] ? never
: Atom.AtomResultFn<
{
- readonly payload: Rpc.PayloadConstructor<Rpcs>
+ readonly payload: Rpc.PayloadConstructor<R>
readonly reactivityKeys?: Reactivity.KeysInput | undefined
readonly headers?: Headers.Input | undefined
},
@@ -75,7 +75,7 @@
>
: never
- readonly query: <Tag extends Rpc.Tag<Rpcs>>(
+ readonly query: <Tag extends Rpc.Tag<Rpcs>, R = Rpc.ExtractTag<Rpcs, Tag>>(
tag: Tag,
payload: Rpc.PayloadConstructor<Rpc.ExtractTag<Rpcs, Tag>>,
options?: {
@@ -84,8 +84,8 @@
readonly timeToLive?: Duration.Input | undefined
readonly serializationKey?: string | undefined
}
- ) => Rpcs extends Rpc.Rpc<
- infer _Tag extends Tag,
+ ) => R extends Rpc.Rpc<
+ infer _Tag,
infer _Payload,
infer _Success,
infer _Error,
The same reproduction, with that change applied to the published .d.ts files of 4.1.2:
| style (600 RPCs, 300 calls) | instantiations | check time | memory |
|---|---|---|---|
| flat | 17,663,261 → 8,302,979 | 13.2 s → 3.0 s | 999 → 213 MB |
AtomRpc.query |
11,843,988 → 2,842,320 | 11.5 s → 1.6 s | 984 → 197 MB |
AtomRpc.mutation |
6,768,871 → 1,357,827 | 7.3 s → 0.9 s | 971 → 195 MB |
What I checked on the patched 4.1.2 types:
- Against verbatim copies of the current types, with an exact type-equality check, the results are identical for:
- a plain call,
discard: true, and a stream RPC, with and withoutasQueue: true - a union of two tags
queryfor a plain RPC, a stream and a unionmutationfor a plain RPC, a stream and a union
- a plain call,
- A wrong payload and an unknown tag are still rejected.
- With
skipLibCheck: false, importingeffect/rpc,effect/reactivity,effect/clusterandeffect/workflowgives no errors, as before. A first version constrained the new parameter (R extends Rpc.Any = …) and failed there, hence the unconstrained default.
Caveats:
- The extra defaulted type parameter shows up in signatures and hover text. An internal helper alias may be nicer.
- The patched flat client is still about 16× the object client's instantiations at 600 RPCs. I didn't trace the rest;
Tag & keyof ByTag<R>is one remaining per-call cost. - Separately, it would help if
AtomRpccould hand out the object-shaped client in effects, the oneRpcClient.make(group)gives withoutflatten. That would makeclient.Method(payload)available toAtomRpcusers.
Our workaround in the meantime:
- We build that object once from the flat client: one function per RPC in
group.requests, each callingclient(tag, payload, options). - We give
queryandmutationsignatures that look the RPC up in a{ [R in Rpcs as R["_tag"]]: R }map. - A type test checks both stay equal to
AtomRpc's own types.
- Lingua principale
- TypeScript
- Stelle
- 16.7k
- Fork
- 808
- Merge medio
- 11h 40m
- PR unite (30g)
- 827
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di Effect-TS/effect
-
sql-pg: pgvector columns fail with "Invalid UTF-8 in text value"Forse già presa @effect-bot l’ha presa oggi. Apertabug
Effect-TS/effect#10420 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
cli: completions break for subcommand names with `:`Forse già presa @effect-bot l’ha presa oggi. Aperta
Effect-TS/effect#10416 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
McpServer.layerStdio: native EOF cancellation policy for active tool handlersForse già presa @effect-bot l’ha presa oggi. Aperta
Effect-TS/effect#10412 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
add OAuth authorization support to HTTP McpServerForse già presa @effect-bot l’ha presa oggi. Apertaenhancement
Effect-TS/effect#10402 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
effect/ai: support the MCP Apps extension (`io.modelcontextprotocol/ui`)Forse già presa @effect-bot l’ha presa 1 giorno fa. Aperta
Effect-TS/effect#10399 · 2 commenti · 1 reazione · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di Effect-TS/effect
Issue simili
-
Navbar avatar and name stay stale after saving profile changesForse già presa @alexechoi l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
marmelab/atomic-crm#416 ·
I maintainer di solito rispondono entro 1 giorno
-
bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
epam/ai-dial-react-file-manager#99 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
canonical/craft-actions#146 ·
I maintainer di solito rispondono entro 2 giorni
-
feature-request
Difficoltà 2/5 1-3 ore Idoneità per principianti 66/100
bluesky-social/social-app#12343 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
lucide-icons/lucide#5165 ·
I maintainer di solito rispondono entro 1 giorno