Skip to content

Commit cad99dc

Browse files
teresalvestalves
and
talves
authoredMar 27, 2025··
feat: add bulk gets for kv in miniflare (#8623)
* feat: add bulk gets for kv in miniflare * update workerd version * fix: lint * test: add tests for metadata validation * update changeset * fix: address pr comments * refactor: small refactor when processing object --------- Co-authored-by: talves <talves@cloudflare.com>
1 parent f29f018 commit cad99dc

File tree

5 files changed

+201
-5
lines changed

5 files changed

+201
-5
lines changed
 

‎.changeset/funny-eggs-divide.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"miniflare": minor
3+
---
4+
5+
Add Miniflare Workers KV bulk get support

‎packages/miniflare/src/workers/kv/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const SiteBindings = {
3434
// This ensures edge caching of Workers Sites files is disabled, and the latest
3535
// local version is always served.
3636
export const SITES_NO_CACHE_PREFIX = "$__MINIFLARE_SITES__$/";
37+
export const MAX_BULK_GET_KEYS = 100;
3738

3839
export function encodeSitesKey(key: string): string {
3940
// `encodeURIComponent()` ensures `ETag`s used by `@cloudflare/kv-asset-handler`

‎packages/miniflare/src/workers/kv/namespace.worker.ts

+73-3
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
DELETE,
55
GET,
66
HttpError,
7+
KeyValueEntry,
78
KeyValueStorage,
89
maybeApply,
910
MiniflareDurableObject,
11+
POST,
1012
PUT,
1113
RouteHandler,
1214
} from "miniflare:shared";
13-
import { KVHeaders, KVLimits, KVParams } from "./constants";
15+
import { KVHeaders, KVLimits, KVParams, MAX_BULK_GET_KEYS } from "./constants";
1416
import {
1517
decodeKey,
1618
decodeListOptions,
@@ -73,6 +75,42 @@ function secondsToMillis(seconds: number): number {
7375
return seconds * 1000;
7476
}
7577

78+
async function processKeyValue(
79+
obj: KeyValueEntry<unknown> | null,
80+
type: "text" | "json" = "text",
81+
withMetadata = false
82+
) {
83+
const decoder = new TextDecoder();
84+
let decodedValue = "";
85+
if (obj?.value) {
86+
for await (const chunk of obj?.value) {
87+
decodedValue += decoder.decode(chunk, { stream: true });
88+
}
89+
decodedValue += decoder.decode();
90+
}
91+
92+
let val = null;
93+
try {
94+
val = !obj?.value
95+
? null
96+
: type === "json"
97+
? JSON.parse(decodedValue)
98+
: decodedValue;
99+
} catch (err: any) {
100+
throw new HttpError(
101+
400,
102+
"At least one of the requested keys corresponds to a non-JSON value"
103+
);
104+
}
105+
if (val && withMetadata) {
106+
return {
107+
value: val,
108+
metadata: obj?.metadata ?? null,
109+
};
110+
}
111+
return val;
112+
}
113+
76114
export class KVNamespaceObject extends MiniflareDurableObject {
77115
#storage?: KeyValueStorage;
78116
get storage() {
@@ -81,13 +119,46 @@ export class KVNamespaceObject extends MiniflareDurableObject {
81119
}
82120

83121
@GET("/:key")
122+
@POST("/bulk/get")
84123
get: RouteHandler<KVParams> = async (req, params, url) => {
124+
if (req.method === "POST" && req.body != null) {
125+
let decodedBody = "";
126+
const decoder = new TextDecoder();
127+
for await (const chunk of req.body) {
128+
decodedBody += decoder.decode(chunk, { stream: true });
129+
}
130+
decodedBody += decoder.decode();
131+
const parsedBody = JSON.parse(decodedBody);
132+
const keys: string[] = parsedBody.keys;
133+
const type = parsedBody?.type;
134+
if (type && type !== "text" && type !== "json") {
135+
return new Response(`Type ${type} is invalid`, { status: 400 });
136+
}
137+
const obj: { [key: string]: any } = {};
138+
if (keys.length > MAX_BULK_GET_KEYS) {
139+
return new Response(`Accepting a max of 100 keys, got ${keys.length}`, {
140+
status: 400,
141+
});
142+
}
143+
for (const key of keys) {
144+
validateGetOptions(key, { cacheTtl: parsedBody?.cacheTtl });
145+
const entry = await this.storage.get(key);
146+
const value = await processKeyValue(
147+
entry,
148+
parsedBody?.type,
149+
parsedBody?.withMetadata
150+
);
151+
obj[key] = value;
152+
}
153+
154+
return new Response(JSON.stringify(obj));
155+
}
156+
85157
// Decode URL parameters
86158
const key = decodeKey(params, url.searchParams);
87159
const cacheTtlParam = url.searchParams.get(KVParams.CACHE_TTL);
88160
const cacheTtl =
89161
cacheTtlParam === null ? undefined : parseInt(cacheTtlParam);
90-
91162
// Get value from storage
92163
validateGetOptions(key, { cacheTtl });
93164
const entry = await this.storage.get(key);
@@ -114,7 +185,6 @@ export class KVNamespaceObject extends MiniflareDurableObject {
114185
const rawExpiration = url.searchParams.get(KVParams.EXPIRATION);
115186
const rawExpirationTtl = url.searchParams.get(KVParams.EXPIRATION_TTL);
116187
const rawMetadata = req.headers.get(KVHeaders.METADATA);
117-
118188
// Validate key, expiration and metadata
119189
const now = millisToSeconds(this.timers.now());
120190
const { expiration, metadata } = validatePutOptions(key, {

‎packages/miniflare/test/plugins/kv/index.spec.ts

+102
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import consumers from "stream/consumers";
66
import { Macro, ThrowsExpectation } from "ava";
77
import {
88
KV_PLUGIN_NAME,
9+
MAX_BULK_GET_KEYS,
910
Miniflare,
1011
MiniflareOptions,
1112
ReplaceWorkersTypes,
@@ -122,6 +123,107 @@ test("get: returns value", async (t) => {
122123
const result = await kv.get("key");
123124
t.is(result, "value");
124125
});
126+
127+
test("bulk get: returns value", async (t) => {
128+
const { kv } = t.context;
129+
await kv.put("key1", "value1");
130+
const result: any = await kv.get(["key1", "key2"]);
131+
const expectedResult = new Map([
132+
["key1", "value1"],
133+
["key2", null],
134+
]);
135+
136+
t.deepEqual(result, expectedResult);
137+
});
138+
139+
test("bulk get: check max keys", async (t) => {
140+
const { kv } = t.context;
141+
await kv.put("key1", "value1");
142+
const keyArray = [];
143+
for (let i = 0; i <= MAX_BULK_GET_KEYS; i++) {
144+
keyArray.push(`key${i}`);
145+
}
146+
try {
147+
await kv.get(keyArray);
148+
} catch (error: any) {
149+
t.is(error.message, "KV GET_BULK failed: 400 Bad Request");
150+
}
151+
});
152+
153+
test("bulk get: request json type", async (t) => {
154+
const { kv } = t.context;
155+
await kv.put("key1", '{"example": "ex"}');
156+
await kv.put("key2", "example");
157+
let result: any = await kv.get(["key1"]);
158+
let expectedResult: any = new Map([["key1", '{"example": "ex"}']]);
159+
expectedResult = new Map([["key1", '{"example": "ex"}']]);
160+
t.deepEqual(result, expectedResult);
161+
162+
result = await kv.get(["key1"], "json");
163+
expectedResult = new Map([["key1", { example: "ex" }]]);
164+
t.deepEqual(result, expectedResult);
165+
166+
try {
167+
await kv.get(["key1", "key2"], "json");
168+
} catch (error: any) {
169+
t.is(
170+
error.message,
171+
"KV GET_BULK failed: 400 At least one of the requested keys corresponds to a non-JSON value"
172+
);
173+
}
174+
});
175+
176+
test("bulk get: check metadata", async (t) => {
177+
const { kv } = t.context;
178+
await kv.put("key1", "value1", {
179+
expiration: TIME_FUTURE,
180+
metadata: { testing: true },
181+
});
182+
183+
await kv.put("key2", "value2");
184+
const result: any = await kv.getWithMetadata(["key1", "key2"]);
185+
const expectedResult: any = new Map([
186+
["key1", { value: "value1", metadata: { testing: true } }],
187+
["key2", { value: "value2", metadata: null }],
188+
]);
189+
t.deepEqual(result, expectedResult);
190+
});
191+
192+
test("bulk get: check metadata with int", async (t) => {
193+
const { kv } = t.context;
194+
await kv.put("key1", "value1", {
195+
expiration: TIME_FUTURE,
196+
metadata: 123,
197+
});
198+
199+
const result: any = await kv.getWithMetadata(["key1"]);
200+
const expectedResult: any = new Map([
201+
["key1", { value: "value1", metadata: 123 }],
202+
]);
203+
t.deepEqual(result, expectedResult);
204+
});
205+
206+
test("bulk get: check metadata as string", async (t) => {
207+
const { kv } = t.context;
208+
await kv.put("key1", "value1", {
209+
expiration: TIME_FUTURE,
210+
metadata: "example",
211+
});
212+
const result: any = await kv.getWithMetadata(["key1"]);
213+
const expectedResult: any = new Map([
214+
["key1", { value: "value1", metadata: "example" }],
215+
]);
216+
t.deepEqual(result, expectedResult);
217+
});
218+
219+
test("bulk get: get with metadata for 404", async (t) => {
220+
const { kv } = t.context;
221+
222+
const result: any = await kv.getWithMetadata(["key1"]);
223+
const expectedResult: any = new Map([["key1", null]]);
224+
t.deepEqual(result, expectedResult);
225+
});
226+
125227
test("get: returns null for non-existent keys", async (t) => {
126228
const { kv } = t.context;
127229
t.is(await kv.get("key"), null);

‎packages/miniflare/test/test-shared/miniflare.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,25 @@ export function namespace<T>(ns: string, binding: T): Namespaced<T> {
3838
return (keys: unknown, ...args: unknown[]) => {
3939
if (typeof keys === "string") keys = ns + keys;
4040
if (Array.isArray(keys)) keys = keys.map((key) => ns + key);
41-
return (value as (...args: unknown[]) => unknown)(keys, ...args);
41+
const result = (value as (...args: unknown[]) => unknown)(
42+
keys,
43+
...args
44+
);
45+
if (result instanceof Promise) {
46+
return result.then((res) => {
47+
// KV.get([a,b,c]) would be prefixed with ns, so we strip this prefix from response.
48+
// Map keys => [{ns}{a}, {ns}{b}, {ns}{b}] -> [a,b,c]
49+
if (res instanceof Map) {
50+
const newResult = new Map<string, unknown>();
51+
for (const [key, value] of res) {
52+
newResult.set(key.slice(ns.length), value);
53+
}
54+
return newResult;
55+
}
56+
return res;
57+
});
58+
}
59+
return result;
4260
};
4361
}
4462
return value;
@@ -83,7 +101,7 @@ export function miniflareTest<
83101
status: 500,
84102
headers: { "MF-Experimental-Error-Stack": "true" },
85103
});
86-
}
104+
}
87105
}
88106
}
89107
`;

0 commit comments

Comments
 (0)
Please sign in to comment.