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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3
4import { makeInMemoryBlobStore, pluginBlobStore } from "./blob";
5
6describe("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
73describe("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
25 namespace: string,25 namespace: string,
26 key: string,26 key: string,
27 ) => Effect.Effect<string | null, StorageError>;27 ) => 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>;
28 readonly put: (37 readonly put: (
29 namespace: string,38 namespace: string,
30 key: string,39 key: string,
@@ -90,9 +99,12 @@ export const pluginBlobStore = ( · 59 unmodified lines
90): PluginBlobStore => ({99): PluginBlobStore => ({
91 get: (key) =>100 get: (key) =>
92 Effect.gen(function* () {101 Effect.gen(function* () {
93 for (const scope of scopes) {102 const namespaces = scopes.map((s) => nsFor(s, pluginId));
94 const value = yield* store.get(nsFor(scope, pluginId), key);103 const hits = yield* store.getMany(namespaces, key);
95 if (value !== null) return value;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;
96 }108 }
97 return null;109 return null;
98 }),110 }),
@@ -105,13 +117,12 @@ export const pluginBlobStore = ( · 6 unmodified lines
105 store.delete(nsFor(options.scope, pluginId), key),117 store.delete(nsFor(options.scope, pluginId), key),
106 ),118 ),
107 has: (key) =>119 has: (key) =>
108 Effect.gen(function* () {120 store
109 for (const scope of scopes) {121 .getMany(
110 const found = yield* store.has(nsFor(scope, pluginId), key);122 scopes.map((s) => nsFor(s, pluginId)),
111 if (found) return true;123 key,
112 }124 )
113 return false;125 .pipe(Effect.map((hits) => hits.size > 0)),
114 }),
115});126});
116 127
117/**128/**
@@ -128,6 +139,15 @@ export const makeInMemoryBlobStore = (): BlobStore => { · 10 unmodified lines
128 const k = (ns: string, key: string) => `${ns}::${key}`;139 const k = (ns: string, key: string) => `${ns}::${key}`;
129 return {140 return {
130 get: (ns, key) => Effect.sync(() => store.get(k(ns, key)) ?? null),141 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 }),
131 put: (ns, key, value) =>151 put: (ns, key, value) =>
132 Effect.sync(() => {152 Effect.sync(() => {
133 store.set(k(ns, key), value);153 store.set(k(ns, key), value);
packages/core/storage-file/src/blob-store.ts+28−1
@@ -9,7 +9,7 @@ · 8 unmodified lines
9// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
10 10
11import { Effect } from "effect";11import { Effect } from "effect";
12import { and, eq, sql as drizzleSql } from "drizzle-orm";12import { and, eq, inArray, sql as drizzleSql } from "drizzle-orm";
13import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";13import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
14 14
15import type { BlobStore } from "@executor/sdk";15import type { BlobStore } from "@executor/sdk";
@@ -63,6 +63,33 @@ export const makeSqliteBlobStore = ( · 47 unmodified lines
63 catch: wrapErr("get"),63 catch: wrapErr("get"),
64 }).pipe(Effect.map((rows) => rows[0]?.value ?? null)),64 }).pipe(Effect.map((rows) => rows[0]?.value ?? null)),
65 65
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
66 put: (namespace, key, value) =>93 put: (namespace, key, value) =>
67 Effect.try({94 Effect.try({
68 try: () =>95 try: () =>
packages/core/storage-postgres/src/blob-store.ts+24−1
@@ -10,7 +10,7 @@ · 9 unmodified lines
10// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
11 11
12import { Effect } from "effect";12import { Effect } from "effect";
13import { and, eq } from "drizzle-orm";13import { and, eq, inArray } from "drizzle-orm";
14import type { PgDatabase } from "drizzle-orm/pg-core";14import type { PgDatabase } from "drizzle-orm/pg-core";
15import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";15import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
16 16
@@ -64,6 +64,29 @@ export const makePostgresBlobStore = ( · 47 unmodified lines
64 },64 },
65 catch: wrapErr("get"),65 catch: wrapErr("get"),
66 }),66 }),
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 }),
67 put: (namespace, key, value) =>90 put: (namespace, key, value) =>
68 Effect.tryPromise({91 Effect.tryPromise({
69 try: async () => {92 try: async () => {