History

Merge pull request #276 from RhysSullivan/rs/secrets-parallel-fanout

perf(secrets): parallelize provider fan-out and header resolution

Rhys Sullivan committed 6 months ago

Commit 36ef8382 parents 370df07 165e7703 files · +100 −51Browse files

Unified · Split

3 files
  1. packages/core/sdk/src/executor.ts +45 −25
  2. packages/plugins/graphql/src/sdk/invoke.ts +28 −11
  3. packages/plugins/openapi/src/sdk/invoke.ts +27 −15
packages/core/sdk/src/executor.ts+45−25
@@ -519,17 +519,20 @@ export const createExecutor = < · 518 unmodified lines
519519 return yield* provider.get(id);
520520 }
521521
522− // Fallback: ask enumerating providers in registration order.
523− // First non-null wins. Providers that throw are treated as
524− // "don't have it" and skipped so one flaky provider doesn't
522+ // Fallback: ask every enumerating provider in parallel. First
523+ // non-null in registration order wins. Providers that throw
524+ // are treated as "don't have it" so one flaky provider can't
525525 // block resolution via others.
526− for (const provider of secretProviders.values()) {
527− if (!provider.list) continue;
528− const value = yield* provider
529− .get(id)
530− .pipe(Effect.catchAll(() => Effect.succeed(null)));
531− if (value !== null) return value;
532− }
526+ const candidates = [...secretProviders.values()].filter(
527+ (p) => p.list,
528+ );
529+ const values = yield* Effect.all(
530+ candidates.map((p) =>
531+ p.get(id).pipe(Effect.catchAll(() => Effect.succeed(null))),
532+ ),
533+ { concurrency: "unbounded" },
534+ );
535+ for (const value of values) if (value !== null) return value;
533536 return null;
534537 });
535538
@@ -595,11 +598,17 @@ export const createExecutor = < · 59 unmodified lines
595598
596599 const secretsRemove = (id: string): Effect.Effect<void, Error> =>
597600 Effect.gen(function* () {
598− for (const provider of secretProviders.values()) {
599− if (provider.writable && provider.delete) {
600− yield* provider.delete(id);
601− }
602− }
601+ // Providers don't coordinate on which of them own the id — they
602+ // each get asked. Most calls are no-ops; fan them out so one
603+ // slow provider doesn't serialize the rest.
604+ const deleters = [...secretProviders.values()].filter(
605+ (p): p is typeof p & { delete: NonNullable<typeof p.delete> } =>
606+ !!(p.writable && p.delete),
607+ );
608+ yield* Effect.all(
609+ deleters.map((p) => p.delete(id)),
610+ { concurrency: "unbounded" },
611+ );
603612 yield* core.delete({
604613 model: "secret",
605614 where: [{ field: "id", value: id }],
@@ -643,15 +652,26 @@ export const createExecutor = < · 37 unmodified lines
643652 );
644653 }
645654
646− // Then every provider that can enumerate itself. If a provider
647− // fails to list (unlocked vault, network error), swallow the
648− // failure and continue — one flaky provider shouldn't block
649− // the whole list.
650− for (const [providerKey, provider] of secretProviders.entries()) {
651− if (!provider.list) continue;
652− const entries = yield* provider
653− .list()
654− .pipe(Effect.catchAll(() => Effect.succeed([] as const)));
655+ // Then every provider that can enumerate itself, in parallel.
656+ // If a provider fails to list (unlocked vault, network error),
657+ // swallow the failure so one flaky provider can't block the
658+ // whole list. Merge in registration order afterwards so the
659+ // "first provider wins" precedence stays deterministic.
660+ const listers = [...secretProviders.entries()].filter(
661+ ([, p]) => p.list,
662+ );
663+ const lists = yield* Effect.all(
664+ listers.map(([key, p]) =>
665+ p
666+ .list!()
667+ .pipe(
668+ Effect.catchAll(() => Effect.succeed([] as const)),
669+ Effect.map((entries) => ({ key, entries })),
670+ ),
671+ ),
672+ { concurrency: "unbounded" },
673+ );
674+ for (const { key, entries } of lists) {
655675 for (const entry of entries) {
656676 if (byId.has(entry.id)) continue; // core row wins
657677 byId.set(
@@ -660,7 +680,7 @@ export const createExecutor = < · 2 unmodified lines
660680 id: SecretId.make(entry.id),
661681 scopeId: scope.id,
662682 name: entry.name,
663− provider: providerKey,
683+ provider: key,
664684 createdAt: new Date(),
665685 }),
666686 );
packages/plugins/graphql/src/sdk/invoke.ts+28−11
@@ -17,18 +17,35 @@ export const resolveHeaders = ( · 16 unmodified lines
1717 secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> },
1818 ): Effect.Effect<Record<string, string>, Error> =>
1919 Effect.gen(function* () {
20+ const entries = Object.entries(headers);
21+ // Resolve secret-backed headers in parallel. Missing / failing
22+ // lookups drop the header rather than fail the invocation, same
23+ // as the serial version.
24+ const values = yield* Effect.all(
25+ entries.map(([name, value]) =>
26+ typeof value === "string"
27+ ? Effect.succeed<{ readonly name: string; readonly value: string | null }>({
28+ name,
29+ value,
30+ })
31+ : secrets.get(value.secretId).pipe(
32+ Effect.catchAll(() => Effect.succeed<string | null>(null)),
33+ Effect.map((secret) => ({
34+ name,
35+ value:
36+ secret === null
37+ ? null
38+ : value.prefix
39+ ? `${value.prefix}${secret}`
40+ : secret,
41+ })),
42+ ),
43+ ),
44+ { concurrency: "unbounded" },
45+ );
2046 const resolved: Record<string, string> = {};
21− for (const [name, value] of Object.entries(headers)) {
22− if (typeof value === "string") {
23− resolved[name] = value;
24− } else {
25− const secret = yield* secrets.get(value.secretId).pipe(
26− Effect.catchAll(() => Effect.succeed<string | null>(null)),
27− );
28− if (secret !== null) {
29− resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
30− }
31− }
47+ for (const { name, value } of values) {
48+ if (value !== null) resolved[name] = value;
3249 }
3350 return resolved;
3451 });
packages/plugins/openapi/src/sdk/invoke.ts+27−15
@@ -95,22 +95,34 @@ export const resolveHeaders = ( · 94 unmodified lines
9595 secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> },
9696 ): Effect.Effect<Record<string, string>, Error> =>
9797 Effect.gen(function* () {
98− const resolved: Record<string, string> = {};
99− for (const [name, value] of Object.entries(headers)) {
100− if (typeof value === "string") {
101− resolved[name] = value;
102− } else {
103− const secret = yield* secrets.get(value.secretId);
104− if (secret === null) {
105− return yield* Effect.fail(
106− new Error(
107− `Failed to resolve secret "${value.secretId}" for header "${name}"`,
98+ const entries = Object.entries(headers);
99+ // Fan out secret lookups: on every invocation, one or two headers
100+ // typically each hit the secret store. Resolving them in parallel
101+ // is a free wall-clock win — preserved order is only needed for
102+ // the final assembly, not the fetches.
103+ const values = yield* Effect.all(
104+ entries.map(([name, value]) =>
105+ typeof value === "string"
106+ ? Effect.succeed({ name, value })
107+ : secrets.get(value.secretId).pipe(
108+ Effect.flatMap((secret) =>
109+ secret === null
110+ ? Effect.fail(
111+ new Error(
112+ `Failed to resolve secret "${value.secretId}" for header "${name}"`,
113+ ),
114+ )
115+ : Effect.succeed({
116+ name,
117+ value: value.prefix ? `${value.prefix}${secret}` : secret,
118+ }),
119+ ),
108120 ),
109− );
110− }
111− resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
112− }
113− }
121+ ),
122+ { concurrency: "unbounded" },
123+ );
124+ const resolved: Record<string, string> = {};
125+ for (const { name, value } of values) resolved[name] = value;
114126 return resolved;
115127 });
116128