Skip to content

Commit 36ef838

Browse files
authored
Merge pull request #276 from RhysSullivan/rs/secrets-parallel-fanout
perf(secrets): parallelize provider fan-out and header resolution
2 parents 370df07 + 165e770 commit 36ef838

3 files changed

Lines changed: 100 additions & 51 deletions

File tree

‎packages/core/sdk/src/executor.ts‎

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -519,17 +519,20 @@ export const createExecutor = <
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 = <
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 = <
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 = <
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‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,35 @@ export const resolveHeaders = (
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‎

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -95,22 +95,34 @@ export const resolveHeaders = (
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

0 commit comments

Comments
 (0)