Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 384763b

Browse files
authoredMay 15, 2023
Add unsafe options for configuring Durable Objects (#573)
Wrangler supports multi-worker development using a dev registry. This system allows you to reference Durable Objects defined in one process in another. If we had a Durable Object in worker `a` we wanted to reference in worker `b`, we could with Miniflare 3: 1. Define a proxy Durable Object in the same process as `b` that forwarded all requests to `a`'s process, and bind this to `b` 2. In user code, the user could obtain a Durable Object ID and stub for the proxy object in `b` 3. The user would send a request to this stub, which would proxy to a worker defined in `a`'s process. 4. This worker would have all `a`'s Durable Objects bound, and we could obtain a Durable Object ID and stub for the actual object. 5. Finally, we could forward the request to the real stub. Ideally, the IDs we obtain in steps 2 and 4 would be the same. This ensures the stub in `b` and instance in `a` share the same ID. Unfortunately, Durable Object IDs for one object (e.g. the proxy object in `b`) cannot be used with another (e.g. the actual object in `a`), _unless_ the objects share the same *unique key*. In this case, `workerd` effectively treats them as the same object. Normally, this would be really bad, but these objects are defined in separate processes, so I think it's ok. The `unsafeUniqueKey` option on Durable Object bindings allows the unique key to be customised. An issue with this is that the proxy objects will use this unique key when persisting their data. The proxy objects don't write any data, but they still create the databases. This can be confusing, as it'll look like data for `a` is stored with `a` and `b` (even though `b`'s copies will be empty). To alleviate this issue, a `unsafeEphemeralDurableObjects` option has been added which forces in-memory `workerd` storage for Durable Objects. This gets wiped between reloads.

File tree

6 files changed

+129
-21
lines changed

6 files changed

+129
-21
lines changed
 

‎packages/tre/src/index.ts

+24-5
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,34 @@ function getDurableObjectClassNames(
146146
for (const designator of Object.values(
147147
workerOpts.do.durableObjects ?? {}
148148
)) {
149-
// Fallback to current worker service if name not defined
150-
const [className, serviceName = workerServiceName] =
151-
normaliseDurableObject(designator);
149+
const {
150+
className,
151+
// Fallback to current worker service if name not defined
152+
serviceName = workerServiceName,
153+
unsafeUniqueKey,
154+
} = normaliseDurableObject(designator);
155+
// Get or create `Map` mapping class name to optional unsafe unique key
152156
let classNames = serviceClassNames.get(serviceName);
153157
if (classNames === undefined) {
154-
classNames = new Set();
158+
classNames = new Map();
155159
serviceClassNames.set(serviceName, classNames);
156160
}
157-
classNames.add(className);
161+
if (classNames.has(className)) {
162+
// If we've already seen this class in this service, make sure the
163+
// unsafe unique keys match
164+
const existingUnsafeUniqueKey = classNames.get(className);
165+
if (existingUnsafeUniqueKey !== unsafeUniqueKey) {
166+
throw new MiniflareCoreError(
167+
"ERR_DIFFERENT_UNIQUE_KEYS",
168+
`Multiple unsafe unique keys defined for Durable Object "${className}" in "${serviceName}": ${JSON.stringify(
169+
unsafeUniqueKey
170+
)} and ${JSON.stringify(existingUnsafeUniqueKey)}`
171+
);
172+
}
173+
} else {
174+
// Otherwise, just add it
175+
classNames.set(className, unsafeUniqueKey);
176+
}
158177
}
159178
}
160179
return serviceClassNames;

‎packages/tre/src/plugins/core/index.ts

+21-11
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Service,
1010
Worker_Binding,
1111
Worker_Module,
12+
kVoid,
1213
supportedCompatibilityDate,
1314
} from "../../runtime";
1415
import {
@@ -56,6 +57,8 @@ export const CoreOptionsSchema = z.intersection(
5657
textBlobBindings: z.record(z.string()).optional(),
5758
dataBlobBindings: z.record(z.string()).optional(),
5859
serviceBindings: z.record(ServiceDesignatorSchema).optional(),
60+
61+
unsafeEphemeralDurableObjects: z.boolean().optional(),
5962
})
6063
);
6164

@@ -239,9 +242,9 @@ export const CORE_PLUGIN: Plugin<
239242
}
240243

241244
const name = getUserServiceName(options.name);
242-
const classNames = Array.from(
243-
durableObjectClassNames.get(name) ?? new Set<string>()
244-
);
245+
const classNames = durableObjectClassNames.get(name);
246+
const classNamesEntries = Array.from(classNames ?? []);
247+
245248
const compatibilityDate = validateCompatibilityDate(
246249
log,
247250
options.compatibilityDate ?? FALLBACK_COMPATIBILITY_DATE
@@ -255,16 +258,23 @@ export const CORE_PLUGIN: Plugin<
255258
compatibilityDate,
256259
compatibilityFlags: options.compatibilityFlags,
257260
bindings: workerBindings,
258-
durableObjectNamespaces: classNames.map((className) => ({
259-
className,
260-
// This `uniqueKey` will (among other things) be used as part of the
261-
// path when persisting to the file-system. `-` is invalid in
262-
// JavaScript class names, but safe on filesystems (incl. Windows).
263-
uniqueKey: `${options.name ?? ""}-${className}`,
264-
})),
261+
durableObjectNamespaces: classNamesEntries.map(
262+
([className, unsafeUniqueKey]) => {
263+
return {
264+
className,
265+
// This `uniqueKey` will (among other things) be used as part of the
266+
// path when persisting to the file-system. `-` is invalid in
267+
// JavaScript class names, but safe on filesystems (incl. Windows).
268+
uniqueKey:
269+
unsafeUniqueKey ?? `${options.name ?? ""}-${className}`,
270+
};
271+
}
272+
),
265273
durableObjectStorage:
266-
classNames.length === 0
274+
classNamesEntries.length === 0
267275
? undefined
276+
: options.unsafeEphemeralDurableObjects
277+
? { inMemory: kVoid }
268278
: { localDisk: DURABLE_OBJECTS_STORAGE_SERVICE_NAME },
269279
cacheApiOutbound: { name: getCacheServiceName(workerIndex) },
270280
},

‎packages/tre/src/plugins/do/index.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const DurableObjectsOptionsSchema = z.object({
2121
z.object({
2222
className: z.string(),
2323
scriptName: z.string().optional(),
24+
// Allow `uniqueKey` to be customised. We use in Wrangler when setting
25+
// up stub Durable Objects that proxy requests to Durable Objects in
26+
// another `workerd` process, to ensure the IDs created by the stub
27+
// object can be used by the real object too.
28+
unsafeUniqueKey: z.string().optional(),
2429
}),
2530
])
2631
)
@@ -34,14 +39,15 @@ export function normaliseDurableObject(
3439
designator: NonNullable<
3540
z.infer<typeof DurableObjectsOptionsSchema>["durableObjects"]
3641
>[string]
37-
): [className: string, serviceName: string | undefined] {
42+
): { className: string; serviceName?: string; unsafeUniqueKey?: string } {
3843
const isObject = typeof designator === "object";
3944
const className = isObject ? designator.className : designator;
4045
const serviceName =
4146
isObject && designator.scriptName !== undefined
4247
? getUserServiceName(designator.scriptName)
4348
: undefined;
44-
return [className, serviceName];
49+
const unsafeUniqueKey = isObject ? designator.unsafeUniqueKey : undefined;
50+
return { className, serviceName, unsafeUniqueKey };
4551
}
4652

4753
export const DURABLE_OBJECTS_PLUGIN_NAME = "do";
@@ -97,7 +103,7 @@ export const DURABLE_OBJECTS_PLUGIN: Plugin<
97103
getBindings(options) {
98104
return Object.entries(options.durableObjects ?? {}).map<Worker_Binding>(
99105
([name, klass]) => {
100-
const [className, serviceName] = normaliseDurableObject(klass);
106+
const { className, serviceName } = normaliseDurableObject(klass);
101107
return {
102108
name,
103109
durableObjectNamespace: { className, serviceName },

‎packages/tre/src/plugins/shared/index.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { GatewayConstructor, RemoteStorageConstructor } from "./gateway";
55
import { RouterConstructor } from "./router";
66

77
// Maps **service** names to the Durable Object class names exported by them
8-
export type DurableObjectClassNames = Map<string, Set<string>>;
8+
export type DurableObjectClassNames = Map<
9+
string,
10+
Map</* className */ string, /* unsafeUniqueKey */ string | undefined>
11+
>;
912

1013
export const QueueConsumerOptionsSchema = z.object({
1114
// https://developers.cloudflare.com/queues/platform/configuration/#consumer

‎packages/tre/src/shared/error.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export type MiniflareCoreErrorCode =
2424
| "ERR_PERSIST_REMOTE_UNSUPPORTED" // Remote storage is not supported for this database
2525
| "ERR_FUTURE_COMPATIBILITY_DATE" // Compatibility date in the future
2626
| "ERR_NO_WORKERS" // No workers defined
27-
| "ERR_DUPLICATE_NAME"; // Multiple workers defined with same name
27+
| "ERR_DUPLICATE_NAME" // Multiple workers defined with same name
28+
| "ERR_DIFFERENT_UNIQUE_KEYS"; // Multiple Durable Object bindings declared for same class with different unsafe unique keys
2829
export class MiniflareCoreError extends MiniflareError<MiniflareCoreErrorCode> {}
2930

3031
export class HttpError extends MiniflareError<number> {

‎packages/tre/test/plugins/do/index.spec.ts

+69
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ test("persists Durable Object data in-memory between options reloads", async (t)
6060
mf = new Miniflare(opts);
6161
res = await mf.dispatchFetch("http://localhost");
6262
t.is(await res.text(), "Options #5: 1");
63+
64+
// Check doesn't persist with `unsafeEphemeralDurableObjects` enabled
65+
opts.script = COUNTER_SCRIPT("Options #6: ");
66+
opts.unsafeEphemeralDurableObjects = true;
67+
await mf.setOptions(opts);
68+
res = await mf.dispatchFetch("http://localhost");
69+
t.is(await res.text(), "Options #6: 1");
70+
res = await mf.dispatchFetch("http://localhost");
71+
t.is(await res.text(), "Options #6: 2");
72+
await mf.setOptions(opts);
73+
res = await mf.dispatchFetch("http://localhost");
74+
t.is(await res.text(), "Options #6: 1");
6375
});
6476

6577
test("persists Durable Object data on file-system", async (t) => {
@@ -172,3 +184,60 @@ test("multiple Workers access same Durable Object data", async (t) => {
172184
});
173185
t.is(await res.text(), "via B: b: 2");
174186
});
187+
188+
test("can use Durable Object ID from one object in another", async (t) => {
189+
const opts: MiniflareOptions = {
190+
port: await getPort(),
191+
workers: [
192+
{
193+
name: "a",
194+
routes: ["*/id"],
195+
unsafeEphemeralDurableObjects: true,
196+
durableObjects: {
197+
OBJECT_B: { className: "b_B", unsafeUniqueKey: "b-B" },
198+
},
199+
modules: true,
200+
script: `
201+
export class b_B {}
202+
export default {
203+
fetch(request, env) {
204+
const id = env.OBJECT_B.newUniqueId();
205+
return new Response(id);
206+
}
207+
}
208+
`,
209+
},
210+
{
211+
name: "b",
212+
routes: ["*/*"],
213+
durableObjects: { OBJECT_B: "B" },
214+
modules: true,
215+
script: `
216+
export class B {
217+
constructor(state) {
218+
this.state = state;
219+
}
220+
fetch() {
221+
return new Response("id:" + this.state.id);
222+
}
223+
}
224+
export default {
225+
fetch(request, env) {
226+
const url = new URL(request.url);
227+
const id = env.OBJECT_B.idFromString(url.pathname.substring(1));
228+
const stub = env.OBJECT_B.get(id);
229+
return stub.fetch(request);
230+
}
231+
}
232+
`,
233+
},
234+
],
235+
};
236+
const mf = new Miniflare(opts);
237+
t.teardown(() => mf.dispose());
238+
239+
let res = await mf.dispatchFetch("http://localhost/id");
240+
const id = await res.text();
241+
res = await mf.dispatchFetch(`http://localhost/${id}`);
242+
t.is(await res.text(), `id:${id}`);
243+
});

0 commit comments

Comments
 (0)
This repository has been archived.