Skip to content

Commit adedc8a

Browse files
JPeer264claude
andcommitted
test(cloudflare): Check that Durable Object invocations end when the method returns
workerd keeps a Durable Object invocation open until its pending timers fire, so an SDK timer that outlives the method makes every invocation longer, and billed. The suite runs a tail worker in the same `wrangler dev` process and asserts that DO fetch, propagated RPC and alarm invocations end within 250 ms. They take a few ms locally, and about 500 ms with the segment-end flush timer of span streaming. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5391100 commit adedc8a

5 files changed

Lines changed: 161 additions & 0 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
TEST_DURABLE_OBJECT: DurableObjectNamespace<TestDurableObjectBase>;
7+
TAIL: Fetcher;
8+
}
9+
10+
class TestDurableObjectBase extends DurableObject<Env> {
11+
async fetch(): Promise<Response> {
12+
return new Response('OK');
13+
}
14+
15+
async ping(): Promise<string> {
16+
return 'pong';
17+
}
18+
19+
async setAlarm(): Promise<void> {
20+
await this.ctx.storage.setAlarm(Date.now());
21+
}
22+
23+
async alarm(): Promise<void> {}
24+
}
25+
26+
export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
27+
(env: Env) => ({
28+
dsn: env.SENTRY_DSN,
29+
tracesSampleRate: 1.0,
30+
}),
31+
TestDurableObjectBase,
32+
);
33+
34+
export default Sentry.withSentry(
35+
(env: Env) => ({
36+
dsn: env.SENTRY_DSN,
37+
tracesSampleRate: 1.0,
38+
rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'],
39+
}),
40+
{
41+
async fetch(request: Request, env: Env): Promise<Response> {
42+
const url = new URL(request.url);
43+
44+
if (url.pathname === '/invocations') {
45+
return env.TAIL.fetch(request);
46+
}
47+
48+
const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName(url.pathname));
49+
50+
if (url.pathname === '/rpc') {
51+
return new Response(await stub.ping());
52+
}
53+
54+
if (url.pathname === '/alarm') {
55+
await stub.setAlarm();
56+
return new Response('Alarm scheduled');
57+
}
58+
59+
return stub.fetch(request);
60+
},
61+
} satisfies ExportedHandler<Env>,
62+
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
interface Invocation {
2+
trigger: string;
3+
durationMs: number;
4+
}
5+
6+
const invocations: Invocation[] = [];
7+
8+
export default {
9+
async tail(events: TraceItem[]): Promise<void> {
10+
// `wallTime` is always 0 in local dev, so the duration ends when the tail event arrives.
11+
const receivedAt = Date.now();
12+
13+
for (const { event, eventTimestamp, executionModel } of events) {
14+
if (executionModel !== 'durableObject' || !event || eventTimestamp === null) {
15+
continue;
16+
}
17+
18+
const trigger = 'rpcMethod' in event ? event.rpcMethod : 'scheduledTime' in event ? 'alarm' : 'fetch';
19+
invocations.push({ trigger, durationMs: receivedAt - eventTimestamp });
20+
}
21+
},
22+
23+
async fetch(): Promise<Response> {
24+
return Response.json(invocations);
25+
},
26+
} satisfies ExportedHandler;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { join } from 'node:path';
2+
import { expect, it, vi } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
interface Invocation {
6+
trigger: string;
7+
durationMs: number;
8+
}
9+
10+
// Regression test for https://github.com/getsentry/sentry-javascript/issues/25489
11+
// workerd keeps a Durable Object invocation open until its pending timers fire, so an SDK timer
12+
// that is still pending when the method returns makes the invocation longer, and billed.
13+
it('does not keep Durable Object invocations open after the method returns', async ({ signal }) => {
14+
// The tail worker must run in the same `wrangler dev` process to receive the tail events.
15+
const runner = createRunner(__dirname)
16+
.withWranglerArgs('--config', join(__dirname, 'wrangler-tail.jsonc'))
17+
.start(signal);
18+
19+
await runner.makeRequest('get', '/fetch');
20+
await runner.makeRequest('get', '/rpc');
21+
await runner.makeRequest('get', '/alarm');
22+
23+
const { fetchInvocation, rpcInvocation, alarmInvocation } = await vi.waitFor(
24+
async () => {
25+
const invocations = (await runner.makeRequest<Invocation[]>('get', '/invocations')) ?? [];
26+
const result = {
27+
fetchInvocation: invocations.find(invocation => invocation.trigger === 'fetch'),
28+
rpcInvocation: invocations.find(invocation => invocation.trigger === 'ping'),
29+
alarmInvocation: invocations.find(invocation => invocation.trigger === 'alarm'),
30+
};
31+
32+
expect(result.fetchInvocation).toBeDefined();
33+
expect(result.rpcInvocation).toBeDefined();
34+
expect(result.alarmInvocation).toBeDefined();
35+
36+
return result;
37+
},
38+
{ timeout: 10_000, interval: 100 },
39+
);
40+
41+
// An invocation takes a few ms locally. The limit leaves room for slow CI machines and still
42+
// fails for a 500 ms timer.
43+
expect(fetchInvocation?.durationMs).toBeLessThan(250);
44+
expect(rpcInvocation?.durationMs).toBeLessThan(250);
45+
expect(alarmInvocation?.durationMs).toBeLessThan(250);
46+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "durableobject-invocation-duration-tail",
3+
"main": "tail.ts",
4+
"compatibility_date": "2025-06-17",
5+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "durableobject-invocation-duration",
3+
"main": "index.ts",
4+
"compatibility_date": "2025-06-17",
5+
"compatibility_flags": ["nodejs_compat"],
6+
"migrations": [
7+
{
8+
"new_sqlite_classes": ["TestDurableObject"],
9+
"tag": "v1",
10+
},
11+
],
12+
"durable_objects": {
13+
"bindings": [
14+
{
15+
"class_name": "TestDurableObject",
16+
"name": "TEST_DURABLE_OBJECT",
17+
},
18+
],
19+
},
20+
"services": [{ "binding": "TAIL", "service": "durableobject-invocation-duration-tail" }],
21+
"tail_consumers": [{ "service": "durableobject-invocation-duration-tail" }],
22+
}

0 commit comments

Comments
 (0)