diff --git a/.changeset/spotty-lions-repeat.md b/.changeset/spotty-lions-repeat.md new file mode 100644 index 00000000..e8b944dd --- /dev/null +++ b/.changeset/spotty-lions-repeat.md @@ -0,0 +1,14 @@ +--- +"partyserver": minor +--- + +Stop persisting the `__ps_name` self-heal fallback record on named-access initialization; deprecate the `setName()` name-bootstrap role and the `x-partykit-room` header fallback; document the native `ctx.id.name` availability matrix + +`ctx.id.name` has been populated natively inside Durable Objects addressed via `idFromName()`/`getByName()` since 2026-03-15 (https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/), and production verification on a worker pinned to `compatibility_date` 2024-06-01 (i.e. the behavior is not compat-date gated) confirmed the name is present in the constructor, on hibernating-WebSocket message wakeups, and in alarm handlers firing on cold instances after eviction. PartyServer therefore no longer writes a `__ps_name` fallback copy of the native name during initialization. + +What stays: + +- the legacy `__ps_name` **read** — records written by older partyserver versions (covering e.g. pre-2026-03-15 alarm records) or by the `setName()` bootstrap are still honored when `ctx.id.name` is undefined; +- the `setName()` bootstrap **write** for raw-ID DOs (`idFromString()`/`newUniqueId()`) — now deprecated, to become an error in a future major version; +- the `x-partykit-room` header fallback read — now deprecated; +- `setName()` itself — it remains the onStart-synchronization + `props`-delivery channel used by `getServerByName()` on every resolution. diff --git a/fixtures/alarm-restart-e2e/README.md b/fixtures/alarm-restart-e2e/README.md index 6a98479d..92febfbc 100644 --- a/fixtures/alarm-restart-e2e/README.md +++ b/fixtures/alarm-restart-e2e/README.md @@ -1,10 +1,23 @@ # `alarm-restart-e2e` -Reproducer for the runtime contract that motivates partyserver's +Reproducer for the runtime contract that motivated partyserver's `__ps_name` fallback record. Pins down behavior reported in [cloudflare/partykit#390](https://github.com/cloudflare/partykit/issues/390) across three Durable Objects in the same Worker: +> **Status (historical):** the workspace partyserver no longer WRITES +> the `__ps_name` record during named-access initialization — the +> local-dev alarm-name gap this fixture reproduces was fixed in workerd +> (the alarm scheduler persists the actor name since +> `workerd@1.20260703.1` / wrangler 4.108.0, see +> [cloudflare/workerd#6850](https://github.com/cloudflare/workerd/issues/6850)), +> and production behavior was verified on a worker pinned to +> `compatibility_date: "2024-06-01"`. The legacy `__ps_name` READ +> remains, so the `FixedAlarm` recovery described below now requires a +> pre-existing record (seeded manually, or written by an earlier +> partyserver release that still had the self-heal write). On current +> tooling all three DOs see `ctx.id.name` in `alarm()` natively. + | DO | Class | Extends | | ------------ | --------------------------------- | -------------------------------------------------------------------------- | | `RawAlarm` | `RawAlarm` | `DurableObject` (no PartyServer) | diff --git a/packages/partyserver/README.md b/packages/partyserver/README.md index 4209a9f3..7476735b 100644 --- a/packages/partyserver/README.md +++ b/packages/partyserver/README.md @@ -129,9 +129,9 @@ These methods can be optionally `async`: ## Properties -- `.name` - (readonly) the server's name. Resolves from the underlying Durable Object's `ctx.id.name`, populated whenever the DO is addressed via `idFromName()` / `getByName()` — which is the supported way to address PartyServer DOs. Available inside every entry point including the constructor, `onStart()`, `onAlarm()`, and hibernating websocket handlers. PartyServer also persists this name to a small `__ps_name` fallback record during initialization so that alarm handlers firing on stale on-disk alarm records (scheduled by older workerd versions that didn't persist `name`) can still recover the name. Legacy DOs bootstrapped via `setName()` use the same fallback. +- `.name` - (readonly) the server's name. Resolves from the underlying Durable Object's `ctx.id.name`, which the Cloudflare runtime populates natively (since 2026-03-15, see the [changelog](https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/) and the [DO id docs](https://developers.cloudflare.com/durable-objects/api/id/#name)) whenever the DO is addressed via `idFromName()` / `getByName()` — which is the supported way to address PartyServer DOs. Available inside the constructor, class field initializers, `onStart()`, and `onAlarm()` (subject to the alarm-scheduling caveats below). Constructor availability isn't spelled out in the docs — it's pinned by this package's test suite and workerd's own tests ([cloudflare/workerd#6421](https://github.com/cloudflare/workerd/pull/6421)). Availability on hibernating-websocket wakeups isn't documented either — it's pinned by production verification: a hibernated instance woken by a WebSocket message sees `ctx.id.name` in both the constructor and `webSocketMessage`, on a worker pinned to `compatibility_date` 2024-06-01 (i.e. the behavior isn't compat-date gated). PartyServer no longer persists a `__ps_name` fallback copy of the native name during initialization; the legacy `__ps_name` record is still read whenever `ctx.id.name` is undefined, covering records written by older partyserver versions and DOs bootstrapped via `setName()`. - DOs addressed via `idFromString()` or `newUniqueId()` without a `setName()` bootstrap are not supported and `.name` will throw. + `ctx.id.name` is undefined — by design, per the [DO id docs](https://developers.cloudflare.com/durable-objects/api/id/#name) — for DOs addressed via `idFromString()` (even if the ID was originally created with `idFromName()`) or `newUniqueId()`, for names longer than 1,024 bytes, and inside alarm handlers for alarms scheduled before 2026-03-15. Alarms only carry the name when they were scheduled from a context where `ctx.id.name` was itself available — an alarm rescheduled from a nameless handler also fires without a name (a legacy `__ps_name` record — written by an older partyserver version or a `setName()` bootstrap — covers these alarm cases where one exists; otherwise reschedule the alarm from a fetch/RPC handler where the name is available). DOs addressed via `idFromString()` / `newUniqueId()` without a `setName()` bootstrap are not supported and `.name` will throw. - `.ctx` - the context object for the Durable Object, containing references to [`storage`](https://developers.cloudflare.com/durable-objects/api/transactional-storage-api/) diff --git a/packages/partyserver/src/index.ts b/packages/partyserver/src/index.ts index bec3dd0c..d7cae0c0 100644 --- a/packages/partyserver/src/index.ts +++ b/packages/partyserver/src/index.ts @@ -656,6 +656,15 @@ export class Server< // regardless of how it was supplied. `#ensureInitialized()` will // fall back to reading storage when neither ctx.id.name nor the // header has provided one. + // + // DEPRECATED: the `x-partykit-room` header fallback is a legacy + // name source for raw `stub.fetch()` callers and old clients, and + // will be removed in a future major version. Address servers via + // `idFromName()`/`getByName()` (routePartykitRequest and + // getServerByName already do) — the runtime populates + // `ctx.id.name` natively inside the DO since 2026-03-15: + // https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/ + // https://developers.cloudflare.com/durable-objects/api/id/#name if (!this.ctx.id.name && !this.#_name) { const room = request.headers.get("x-partykit-room"); if (room) this.#_name = room; @@ -667,7 +676,7 @@ export class Server< throw new Error(`Cannot determine the name for ${this.#ParentClass.name}: this.ctx.id.name is undefined, no legacy __ps_name storage record is present, and no x-partykit-room header was supplied. Likely causes: 1. The stub was built via idFromString()/newUniqueId(). PartyServer requires name-based addressing (idFromName/getByName). 2. The workerd/wrangler runtime is too old to expose ctx.id.name — update to a recent wrangler release. - 3. You called stub.fetch() directly without going through routePartykitRequest()/getServerByName(). Prefer those, or set the x-partykit-room header.`); + 3. You called stub.fetch() directly without going through routePartykitRequest()/getServerByName(). Prefer those; the x-partykit-room header fallback is deprecated.`); } const url = new URL(request.url); @@ -841,6 +850,14 @@ export class Server< * `__ps_name` directly (or call `setName()`) before triggering * `__unsafe_ensureInitialized()` — typically DOs addressed via * `idFromString()` / `newUniqueId()` plus a name override. + * + * PartyServer no longer WRITES this record during named-access + * initialization: `ctx.id.name` is populated natively across cold + * starts, hibernating-WebSocket wakeups, and alarm wakeups after + * eviction (verified in production on a worker pinned to + * `compatibility_date` 2024-06-01, so the behavior is not compat-date + * gated). Existing records — written by older partyserver versions or + * by the `setName()` bootstrap — are honored on read. */ async #hydrateNameFromLegacyStorage(): Promise { if (this.#_name) return; @@ -850,17 +867,6 @@ export class Server< } } - async #persistNameFallbackFromCtxId(): Promise { - const ctxName = this.ctx.id.name; - if (ctxName === undefined || this.#_name) return; - - const stored = await this.ctx.storage.get(NAME_STORAGE_KEY); - if (stored !== ctxName) { - await this.ctx.storage.put(NAME_STORAGE_KEY, ctxName); - } - this.#_name = ctxName; - } - /** * @internal — Do not use directly. This is an escape hatch for frameworks * (like Agents) that receive calls via native DO RPC, bypassing the @@ -875,15 +881,18 @@ export class Server< async #ensureInitialized(): Promise { if (this.#status === "started") return; - // Persist a fallback record for name-based DOs before user startup - // code can schedule alarms. Current workerd populates `ctx.id.name` - // in alarm handlers, but stale on-disk alarm records scheduled by - // older workerd versions do not, and we want recovery from those - // without requiring users to wipe `.wrangler/state` or to reschedule - // alarms from a fetch handler. See cloudflare/partykit#390. - if (this.ctx.id.name !== undefined) { - await this.#persistNameFallbackFromCtxId(); - } else if (!this.#_name) { + // When the runtime provides no native name (raw-ID addressing, or + // an alarm record scheduled before 2026-03-15), fall back to the + // legacy `__ps_name` storage record. When `ctx.id.name` IS defined + // there is nothing to do: PartyServer no longer persists a + // `__ps_name` fallback copy of the native name — production + // verification on a worker pinned to `compatibility_date` + // 2024-06-01 (i.e. ungated by compat date) confirmed `ctx.id.name` + // is populated in the constructor, on hibernating-WebSocket + // wakeups, and in alarm handlers firing on cold instances after + // eviction. Records written by older partyserver versions (or by + // the `setName()` bootstrap) are still honored on read. + if (this.ctx.id.name === undefined && !this.#_name) { await this.#hydrateNameFromLegacyStorage(); } @@ -973,46 +982,73 @@ export class Server< /** * The name for this server. * - * Resolves from `this.ctx.id.name` — the native DO id name, populated - * whenever the stub was created via `idFromName()` or `getByName()`. - * This is available inside every entry point (including the constructor, - * alarms, and hibernating websocket handlers). + * Resolves from `this.ctx.id.name` — the native DO id name, which the + * runtime populates since 2026-03-15 + * (https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/). + * Availability matrix: * - * For alarm handlers firing on stale on-disk alarm records from - * older workerd versions that didn't persist `name` into the alarm - * record, the name is recovered from a storage fallback record. + * - `idFromName()` / `getByName()`: POPULATED. Availability inside + * the constructor and class field initializers isn't spelled out + * in the docs — it is pinned by workerd's own tests + * (https://github.com/cloudflare/workerd/pull/6421) and this + * repo's `NameInConstructorServer` / "Raw runtime contract" + * tests. Availability on hibernating-WebSocket wakeups is not + * documented either — it is pinned by production verification + * (a hibernated instance woken by a WebSocket message sees + * `ctx.id.name` in both the constructor and `webSocketMessage`, + * on a worker pinned to `compatibility_date` 2024-06-01). + * - `idFromString()`: UNDEFINED, permanently, by design (per + * https://developers.cloudflare.com/durable-objects/api/id/#name) + * — even if the ID was originally created with `idFromName()`. + * - `newUniqueId()`: UNDEFINED. + * - Names longer than 1,024 bytes: UNDEFINED (not passed through + * to `ctx.id`). + * - Alarms: POPULATED only for alarms scheduled on or after + * 2026-03-15 from a context where `ctx.id.name` was itself + * available. Alarms scheduled before 2026-03-15 fire with it + * UNDEFINED (the on-disk alarm record carries no name), and per + * the DO id docs an alarm rescheduled from such a nameless + * handler also fires without a name. Where a legacy `__ps_name` + * record exists (written by an older partyserver version or a + * `setName()` bootstrap), PartyServer recovers the name from it + * in both cases; otherwise, per the DO id docs, reschedule the + * alarm from a fetch/RPC handler where the name is available. * - * Throws if neither source is available — typically this means the DO - * was addressed via `idFromString()` or `newUniqueId()`, which is not - * supported by PartyServer. + * When `ctx.id.name` is undefined, falls back to the in-memory / + * stored name (`setName()` bootstrap or `__ps_name` record). Throws + * if neither source is available — typically this means the DO was + * addressed via `idFromString()` or `newUniqueId()` without a + * bootstrap, which is not supported by PartyServer. */ get name(): string { const ctxName = this.ctx.id.name; if (ctxName !== undefined) return ctxName; if (this.#_name) return this.#_name; throw new Error( - `Attempting to read .name on ${this.#ParentClass.name}, but this.ctx.id.name is not set and no ${NAME_STORAGE_KEY} fallback record is available. PartyServer requires DOs to be addressed via idFromName()/getByName(), or explicitly bootstrapped with setName() when using idFromString()/newUniqueId(). If this happens in an alarm handler firing on a stale alarm record, initialize the DO from a fetch/RPC entry point first so PartyServer can persist the fallback name.` + `Attempting to read .name on ${this.#ParentClass.name}, but this.ctx.id.name is not set and no ${NAME_STORAGE_KEY} fallback record is available. PartyServer requires DOs to be addressed via idFromName()/getByName(), or explicitly bootstrapped with setName() when using idFromString()/newUniqueId(). If this happens in an alarm handler firing on a stale (pre-2026-03-15) alarm record, reschedule the alarm from a fetch or RPC handler where ctx.id.name is available.` ); } /** * Establish this server's name and trigger `onStart()`. * - * Use cases: + * Two roles: * - * 1. **Framework-level bootstrap of DOs where `ctx.id.name` is - * undefined** — e.g. DOs addressed via `idFromString()` / - * `newUniqueId()`. `setName()` stashes the name in memory and - * persists it under `__ps_name` so cold-wake invocations - * recover it via `#ensureInitialized()`'s legacy fallback. - * 2. **Delivering initial `props` to `onStart()`** via the - * optional second argument. + * 1. **onStart synchronization + `props` delivery** — `getServerByName()` + * calls this on every resolution so that `onStart()` has completed + * (and received `props`) before user-defined RPC methods run. + * This role is NOT deprecated; the method stays. + * 2. **Legacy name establishment for raw-ID DOs** — DOs addressed + * via `idFromString()` / `newUniqueId()` have no `ctx.id.name`, + * so `setName()` stashes the name in memory and persists it + * under `__ps_name` so cold-wake invocations recover it via + * `#ensureInitialized()`'s legacy fallback. This role is + * DEPRECATED — see the `@deprecated` note below. * - * For DOs addressed via `idFromName()` / `getByName()`, calling - * `setName()` is redundant — `this.name` is available automatically - * from `ctx.id.name`. The normal initialization path also persists - * a fallback record so old-compat alarm handlers can recover the name. - * Throws if `name` does not match `ctx.id.name`. + * For DOs addressed via `idFromName()` / `getByName()`, `this.name` + * is available automatically from `ctx.id.name`, so the name argument + * is purely a consistency check — nothing is persisted. Throws if + * `name` does not match `ctx.id.name`. * * **Not appropriate for facets.** Cloudflare Agents and any other * framework using `ctx.facets.get(...)` should pass an explicit @@ -1033,10 +1069,20 @@ export class Server< * https://developers.cloudflare.com/dynamic-workers/usage/durable-object-facets/ * for the `FacetStartupOptions.id` semantics. * - * @deprecated for callers that address DOs via `idFromName()` / - * `getByName()`. Still the supported API for framework-level - * bootstrap of header/`newUniqueId`-addressed DOs and for - * delivering initial `props` to `onStart()`. + * @deprecated Using `setName()` to ESTABLISH a name — the bootstrap + * for DOs addressed via `idFromString()` / `newUniqueId()` — is + * deprecated and will become an error in a future major version. + * Address servers via `idFromName()` / `getByName()` instead: the + * runtime populates `ctx.id.name` natively inside the DO since + * 2026-03-15, so no name needs to be passed through arguments or + * persisted to storage. See + * https://developers.cloudflare.com/changelog/post/2026-03-15-durable-object-id-name/ + * and + * https://developers.cloudflare.com/durable-objects/api/id/#name. + * + * The onStart-synchronization + `props`-delivery role (used by + * `getServerByName()` on every resolution) is NOT deprecated; the + * method itself stays. */ async setName(name: string, props?: Props) { if (!name) { diff --git a/packages/partyserver/src/tests/index.test.ts b/packages/partyserver/src/tests/index.test.ts index b4a9159c..df43051b 100644 --- a/packages/partyserver/src/tests/index.test.ts +++ b/packages/partyserver/src/tests/index.test.ts @@ -755,6 +755,23 @@ describe("Name resolution", () => { expect(data.name).toBe("ctx-id-name-test"); }); + it("named-access cold init does not write a __ps_name fallback record", async () => { + // The self-heal write was removed after production verification (on a + // worker pinned to compatibility_date 2024-06-01) that ctx.id.name is + // populated in the constructor, on hibernating-WebSocket wakeups, and + // in alarm handlers on cold instances after eviction. Storage stays + // clean of __ps_name for named-access DOs. + const id = env.AlarmNameServer.idFromName("no-selfheal-write"); + const stub = env.AlarmNameServer.get(id); + + const res = await stub.fetch(new Request("http://example.com/")); + expect(res.status).toBe(200); + const data = (await res.json()) as { name: string }; + expect(data.name).toBe("no-selfheal-write"); + + await expect(stub.readStoredName()).resolves.toBeUndefined(); + }); + it("this.name is available inside onAlarm after normal setup", async () => { const id = env.AlarmNameServer.idFromName("alarm-name-normal"); const stub = env.AlarmNameServer.get(id); @@ -765,7 +782,9 @@ describe("Name resolution", () => { ) ); expect(await setupRes.text()).toBe("alarm set"); - await expect(stub.readStoredName()).resolves.toBe("alarm-name-normal"); + // Named-access init no longer persists a __ps_name fallback record — + // ctx.id.name carries the name natively, including into alarm(). + await expect(stub.readStoredName()).resolves.toBeUndefined(); const ran = await runDurableObjectAlarm(stub); expect(ran).toBe(true); @@ -872,18 +891,17 @@ describe("Name resolution", () => { // name. Consumers reading this.name from inside an implicit-id // facet will see the wrong value. expect(result.facet.name).toBe(parentName); - // PartyServer persists the native ctx.id.name as an alarm fallback. - // For implicit-id facets that is the parent's name, which is another - // reason this flow is not recommended. - expect(result.facet.storedName).toBe(parentName); + // PartyServer no longer persists a __ps_name fallback copy of the + // native ctx.id.name, so the facet's storage stays clean of it. + expect(result.facet.storedName).toBeUndefined(); }); - it("facet WITH explicit id survives cold wake with its own persisted fallback", async () => { + it("facet WITH explicit id survives cold wake via the deterministic factory id", async () => { // Variant of the explicit-id path that exercises cold wake. // The factory passed to ctx.facets.get() runs again on resume, // and idFromName(facetName) is deterministic, so the resumed - // facet gets the same ctx.id.name. PartyServer also persists it - // so old-compat alarm handlers have a fallback. + // facet gets the same ctx.id.name — no persisted fallback needed + // (and none is written). const parentName = "facet-parent-" + Math.random().toString(36).slice(2); const facetName = "facet-child-" + Math.random().toString(36).slice(2); @@ -891,11 +909,10 @@ describe("Name resolution", () => { const stub = env.FacetParent.get(id); await stub.spawnWithExplicitId(facetName, "env-namespace"); - // The fallback record is populated from the facet's own explicit id. const result = await stub.spawnWithExplicitId(facetName, "env-namespace"); expect(result.facet.name).toBe(facetName); expect(result.facet.ctxIdName).toBe(facetName); - expect(result.facet.storedName).toBe(facetName); + expect(result.facet.storedName).toBeUndefined(); }); it("facet WITH explicit id (FacetStartupOptions.id) gets its own ctx.id.name — no setName needed", async () => { @@ -943,10 +960,11 @@ describe("Name resolution", () => { ) ).facet; - // env-namespace: works. + // env-namespace: works. No __ps_name fallback is written — the + // native ctx.id.name is the only name source. expect(fromEnvNs.name).toBe(`${facetName}-env-namespace`); expect(fromEnvNs.ctxIdName).toBe(`${facetName}-env-namespace`); - expect(fromEnvNs.storedName).toBe(`${facetName}-env-namespace`); + expect(fromEnvNs.storedName).toBeUndefined(); expect(fromEnvNs.onStartName).toBe(`${facetName}-env-namespace`); // ctx-exports-namespace: also works, no env knowledge needed. @@ -955,9 +973,7 @@ describe("Name resolution", () => { expect(fromCtxExportsNs.ctxIdName).toBe( `${facetName}-ctx-exports-namespace` ); - expect(fromCtxExportsNs.storedName).toBe( - `${facetName}-ctx-exports-namespace` - ); + expect(fromCtxExportsNs.storedName).toBeUndefined(); expect(fromCtxExportsNs.onStartName).toBe( `${facetName}-ctx-exports-namespace` ); @@ -1077,19 +1093,19 @@ describe("setName() as bootstrap API for non-idFromName DOs", () => { expect(data.name).toBe("setname-coldwake-test"); }); - it("persists a fallback when the DO was addressed via idFromName", async () => { - // For normal idFromName DOs, ctx.id.name carries the name. PartyServer - // also writes a fallback before onStart so old-compat alarm handlers - // can recover the name after a cold wake. + it("does not persist a fallback when the DO was addressed via idFromName", async () => { + // For normal idFromName DOs, ctx.id.name carries the name natively — + // there is nothing to persist. Calling setName with the matching name + // is redundant but still triggers initialization; it must NOT write a + // __ps_name record (the bootstrap write is only for raw-ID DOs where + // ctx.id.name is undefined). const id = env.SetNameBootstrapServer.idFromName("setname-fallback-write"); const stub = env.SetNameBootstrapServer.get(id); - // Calling setName with the matching name is redundant, but still - // triggers initialization and the fallback persistence path. await stub.setName("setname-fallback-write"); const stored = await stub.readStoredName(); - expect(stored).toBe("setname-fallback-write"); + expect(stored).toBeUndefined(); }); });