History

sdk(blobs): batch per-scope lookups into one IN query via BlobStore.getMany

pluginBlobStore.get used to walk the scope stack with one store.get per scope, short-circuiting on first hit. On a miss that's N round- trips against a persistent backend (sqlite, postgres) — real cost on the postgres deployment where round-trip latency dominates. Adds BlobStore.getMany(namespaces[], key) that returns a ReadonlyMap<namespace, value> of the hits. Backends implement it as WHERE namespace IN (...) AND key = ? — one round-trip instead of N. pluginBlobStore.get iterates its scope list in precedence order and picks the innermost match from the map. pluginBlobStore.has reuses getMany and reports size > 0; no callers of has() today so the full-value fetch isn't a concern. Adds blob.test.ts covering shadowing, fall-through, no-hit, and plugin-id namespacing.

Rhys Sullivan committed 6 months ago

Commit d875b181 parent c75d1044 files · +177 −12Browse files

Unified · Split

4 files
  1. packages/core/sdk/src/blob.test.ts +95 −0
  2. packages/core/sdk/src/blob.ts +30 −10
  3. packages/core/storage-file/src/blob-store.ts +28 −1
  4. packages/core/storage-postgres/src/blob-store.ts +24 −1
packages/core/sdk/src/blob.test.ts+95−0
@@ -0,0 +1,95 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect } from "effect";
3+
4+import { makeInMemoryBlobStore, pluginBlobStore } from "./blob";
5+
6+describe("pluginBlobStore", () => {
7+ it.effect("get returns innermost scope's value when both scopes have one", () =>
8+ Effect.gen(function* () {
9+ const store = makeInMemoryBlobStore();
10+ yield* store.put("inner/my-plugin", "k", "inner-value");
11+ yield* store.put("outer/my-plugin", "k", "outer-value");
12+
13+ const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin");
14+ const value = yield* plugin.get("k");
15+ expect(value).toBe("inner-value");
16+ }),
17+ );
18+
19+ it.effect("get falls through to outer scope when inner is empty", () =>
20+ Effect.gen(function* () {
21+ const store = makeInMemoryBlobStore();
22+ yield* store.put("outer/my-plugin", "k", "outer-value");
23+
24+ const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin");
25+ const value = yield* plugin.get("k");
26+ expect(value).toBe("outer-value");
27+ }),
28+ );
29+
30+ it.effect("get returns null when no scope has the key", () =>
31+ Effect.gen(function* () {
32+ const store = makeInMemoryBlobStore();
33+ const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin");
34+ const value = yield* plugin.get("k");
35+ expect(value).toBeNull();
36+ }),
37+ );
38+
39+ it.effect("has returns true when any scope has the key", () =>
40+ Effect.gen(function* () {
41+ const store = makeInMemoryBlobStore();
42+ yield* store.put("outer/my-plugin", "k", "v");
43+
44+ const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin");
45+ const found = yield* plugin.has("k");
46+ expect(found).toBe(true);
47+ }),
48+ );
49+
50+ it.effect("has returns false when no scope has the key", () =>
51+ Effect.gen(function* () {
52+ const store = makeInMemoryBlobStore();
53+ const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin");
54+ const found = yield* plugin.has("k");
55+ expect(found).toBe(false);
56+ }),
57+ );
58+
59+ it.effect("namespaces are keyed by scope/pluginId — different plugins don't collide", () =>
60+ Effect.gen(function* () {
61+ const store = makeInMemoryBlobStore();
62+ yield* store.put("inner/plugin-a", "k", "a-value");
63+ yield* store.put("inner/plugin-b", "k", "b-value");
64+
65+ const pluginA = pluginBlobStore(store, ["inner"], "plugin-a");
66+ const pluginB = pluginBlobStore(store, ["inner"], "plugin-b");
67+ expect(yield* pluginA.get("k")).toBe("a-value");
68+ expect(yield* pluginB.get("k")).toBe("b-value");
69+ }),
70+ );
71+});
72+
73+describe("BlobStore.getMany", () => {
74+ it.effect("returns hits keyed by namespace", () =>
75+ Effect.gen(function* () {
76+ const store = makeInMemoryBlobStore();
77+ yield* store.put("ns-a", "k", "a");
78+ yield* store.put("ns-c", "k", "c");
79+
80+ const hits = yield* store.getMany(["ns-a", "ns-b", "ns-c"], "k");
81+ expect(hits.size).toBe(2);
82+ expect(hits.get("ns-a")).toBe("a");
83+ expect(hits.get("ns-b")).toBeUndefined();
84+ expect(hits.get("ns-c")).toBe("c");
85+ }),
86+ );
87+
88+ it.effect("empty namespaces returns empty map", () =>
89+ Effect.gen(function* () {
90+ const store = makeInMemoryBlobStore();
91+ const hits = yield* store.getMany([], "k");
92+ expect(hits.size).toBe(0);
93+ }),
94+ );
95+});
packages/core/sdk/src/blob.ts+30−10
@@ -25,6 +25,15 @@ export interface BlobStore { · 24 unmodified lines
2525 namespace: string,
2626 key: string,
2727 ) => Effect.Effect<string | null, StorageError>;
28+ /** Multi-namespace lookup for a single key. Backends issue one query
29+ * (`WHERE namespace IN (...) AND key = ?`) and return the hits keyed
30+ * by namespace — the caller applies its own precedence. Lets
31+ * `pluginBlobStore` walk the scope stack in O(1) round-trips instead
32+ * of one per scope. */
33+ readonly getMany: (
34+ namespaces: readonly string[],
35+ key: string,
36+ ) => Effect.Effect<ReadonlyMap<string, string>, StorageError>;
2837 readonly put: (
2938 namespace: string,
3039 key: string,
@@ -90,9 +99,12 @@ export const pluginBlobStore = ( · 59 unmodified lines
9099 ): PluginBlobStore => ({
91100 get: (key) =>
92101 Effect.gen(function* () {
93− for (const scope of scopes) {
94− const value = yield* store.get(nsFor(scope, pluginId), key);
95− if (value !== null) return value;
102+ const namespaces = scopes.map((s) => nsFor(s, pluginId));
103+ const hits = yield* store.getMany(namespaces, key);
104+ if (hits.size === 0) return null;
105+ for (const ns of namespaces) {
106+ const v = hits.get(ns);
107+ if (v !== undefined) return v;
96108 }
97109 return null;
98110 }),
@@ -105,13 +117,12 @@ export const pluginBlobStore = ( · 6 unmodified lines
105117 store.delete(nsFor(options.scope, pluginId), key),
106118 ),
107119 has: (key) =>
108− Effect.gen(function* () {
109− for (const scope of scopes) {
110− const found = yield* store.has(nsFor(scope, pluginId), key);
111− if (found) return true;
112− }
113− return false;
114− }),
120+ store
121+ .getMany(
122+ scopes.map((s) => nsFor(s, pluginId)),
123+ key,
124+ )
125+ .pipe(Effect.map((hits) => hits.size > 0)),
115126 });
116127
117128 /**
@@ -128,6 +139,15 @@ export const makeInMemoryBlobStore = (): BlobStore => { · 10 unmodified lines
128139 const k = (ns: string, key: string) => `${ns}::${key}`;
129140 return {
130141 get: (ns, key) => Effect.sync(() => store.get(k(ns, key)) ?? null),
142+ getMany: (namespaces, key) =>
143+ Effect.sync(() => {
144+ const hits = new Map<string, string>();
145+ for (const ns of namespaces) {
146+ const v = store.get(k(ns, key));
147+ if (v !== undefined) hits.set(ns, v);
148+ }
149+ return hits;
150+ }),
131151 put: (ns, key, value) =>
132152 Effect.sync(() => {
133153 store.set(k(ns, key), value);
packages/core/storage-file/src/blob-store.ts+28−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
99 // ---------------------------------------------------------------------------
1010
1111 import { Effect } from "effect";
12−import { and, eq, sql as drizzleSql } from "drizzle-orm";
12+import { and, eq, inArray, sql as drizzleSql } from "drizzle-orm";
1313 import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
1414
1515 import type { BlobStore } from "@executor/sdk";
@@ -63,6 +63,33 @@ export const makeSqliteBlobStore = ( · 47 unmodified lines
6363 catch: wrapErr("get"),
6464 }).pipe(Effect.map((rows) => rows[0]?.value ?? null)),
6565
66+ getMany: (namespaces, key) =>
67+ namespaces.length === 0
68+ ? Effect.succeed(new Map<string, string>())
69+ : Effect.try({
70+ try: () =>
71+ options.db
72+ .select({
73+ namespace: blobTable.namespace,
74+ value: blobTable.value,
75+ })
76+ .from(blobTable)
77+ .where(
78+ and(
79+ inArray(blobTable.namespace, [...namespaces]),
80+ eq(blobTable.key, key),
81+ ),
82+ )
83+ .all() as ReadonlyArray<{ namespace: string; value: string }>,
84+ catch: wrapErr("getMany"),
85+ }).pipe(
86+ Effect.map((rows) => {
87+ const out = new Map<string, string>();
88+ for (const row of rows) out.set(row.namespace, row.value);
89+ return out;
90+ }),
91+ ),
92+
6693 put: (namespace, key, value) =>
6794 Effect.try({
6895 try: () =>
packages/core/storage-postgres/src/blob-store.ts+24−1
@@ -10,7 +10,7 @@ · 9 unmodified lines
1010 // ---------------------------------------------------------------------------
1111
1212 import { Effect } from "effect";
13−import { and, eq } from "drizzle-orm";
13+import { and, eq, inArray } from "drizzle-orm";
1414 import type { PgDatabase } from "drizzle-orm/pg-core";
1515 import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
1616
@@ -64,6 +64,29 @@ export const makePostgresBlobStore = ( · 47 unmodified lines
6464 },
6565 catch: wrapErr("get"),
6666 }),
67+ getMany: (namespaces, key) =>
68+ namespaces.length === 0
69+ ? Effect.succeed(new Map<string, string>())
70+ : Effect.tryPromise({
71+ try: async () => {
72+ const rows = await options.db
73+ .select({
74+ namespace: blobTable.namespace,
75+ value: blobTable.value,
76+ })
77+ .from(blobTable)
78+ .where(
79+ and(
80+ inArray(blobTable.namespace, [...namespaces]),
81+ eq(blobTable.key, key),
82+ ),
83+ );
84+ const out = new Map<string, string>();
85+ for (const row of rows) out.set(row.namespace, row.value);
86+ return out as ReadonlyMap<string, string>;
87+ },
88+ catch: wrapErr("getMany"),
89+ }),
6790 put: (namespace, key, value) =>
6891 Effect.tryPromise({
6992 try: async () => {