Skip to content

Experimental feature: Durable Object Facets #4123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open

Conversation

kentonv
Copy link
Member

@kentonv kentonv commented May 9, 2025

This is a new experimental Durable Objects feature. It requires the --experimental CLI flag and experimental compat flag to use. I fully expect that we may end up changing the design once we've played with it, and that's fine. This isn't intended to become generally available any time soon.

Feature Specification

Multifaceted Durable Objects are Durable Objects (DOs) which are composed of multiple "facets" implemented by different isolates. Each facet is written like a regular DO class, but all the facets run together on the same machine to implement the DO. Each facet has its own storage in the form of a SQLite database, but these databases are all stored together as one logical object.

A multifaceted DO has a "main" facet which implements its public interface. This works exactly like a regular Durable Object. The main facet's implementation can call out to other "facets" as needed, using ctx.facets:

// The main interface to manage facets is on `ctx`.
let facets = this.ctx.facets;

// Each facet has a name, which identifies its respective slice of storage. Names are hierarchical:
// if the main facet creates a facet called "foo", and that facet in turn creates a facet called
// "bar", the latter facet's true name is "foo/bar". A facet cannot directly access its siblings,
// unless the common parent facet chooses to pass references explicitly.
//
// Names are limited to 128 characters and cannot contain control characters nor `/`.
let name = "foo";

// To use a facet, pass the name to `this.ctx.facets.get()`.
//
// The second parameter specifies information needed to start up the facet. If the facet is already
// running, and matches the given parameters, the already-running instance is returned. If the
// parameters have changed, then the existing instance will shut down and will reload using the
// new parameters.
//
// Like with regular Durable Objects, there is no explicit "create" operation for a facet. It is
// implicitly created when first used, and is implicitly deleted if it shuts down with nothing left
// in storage.
let facet = this.ctx.facets.get(name, {
  // The class to use. This is a facet binding, which points to a class that may be defined
  // in a different Worker. As usual, ctx.exports can be used to refer to other classes
  // exported by the current worker. Dynamic Dispatch can also look up facets using
  // `getFacetClass`.
  class: this.env.FOO_FACET_CLASS,

  // The value that the facet sees in `ctx.id`. This can be a real Durable Object ID, a plain
  // string, or null. In this example, the parent chooses to pass its own ID down to the
  // facet, but this may or may not make sense depending on the use case. If not specified,
  // defaults to null.
  id: this.ctx.id
});

// `facet` acts like a DO stub, except all calls are local.
await facet.someRpcMethod();

// `facets.abort()` forcefully aborts the facet immediately. No further code will execute in the
// facet until it is started again. The next call to `facets.get()` is guaranteed to call the
// callback and start a new instance. `reason` is thrown by any outstanding or future RPC calls
// on existing stubs pointing into the facet.
//
// This also transitively aborts all children of the facet.
this.ctx.facets.abort(name, reason);

// Deleting a facet aborts the facet if it is running and then deletes its underlying storage. This
// applies transitively to all children as well.
this.ctx.facets.delete(name);

// Note that `storage.deleteAll()` deletes all facets in addition to regular storage.
this.ctx.storage.deleteAll();

Notes:

  • When any running facet becomes "broken", the entire actor breaks and will restart. There is currently no mechanism to catch errors in the parent, though one might be added in the future. As a special exception, if a facet becomes broken because a parent used facets.abort() or facets.delete() on it, this does not break the entire actor, only the specific facet.

Storage Details

In workerd, a DO's main database has always been stored in a file called <actor-id>.sqlite. Each non-root facet is stored in a separate file <actor-id>.<facet-id>.sqlite, where <facet-id> is a small integer assigned to each facet. An index of facet IDs is maintained in a separate file, <actor-id>.facets. This file contains a simple capnp-encoded representation of the facet tree, covering at least all facets for which files exist on disk. This index enables us to avoid encoding facet names into file names, and makes it possible to list and delete facets without performing a full directory listing (which may contain files for unrelated actors).

In production (not yet implemented), facets will only be supported when using SRS to store actor data (not the old storage backend). Each facet is stored as a separate SRS "lane". Lane names are prefixed by their facet path, which is the list of facet names leading from the root to the specific facet, separated by / characters.

kentonv added 12 commits May 9, 2025 16:02
…lass.

The goal is to make `ActorNamespace` independent of `WorkerService`, although this doesn't get all the way there yet.

The reason we want `ActorNamespace` to be independent is that multifaceted actors may contain instances of classes from multiple services.
…d scheduler.

The actor storage should actually originate from the namespace's parent service and NOT from the service that hosts the actor class.

The alarm scheduler is a server-wide singleton so it doesn't matter where we get it from. Might as well come with the storage.

This eliminates dependencies on the ActorClass's service for any purpose other than constructing the Actor object.
This is a binding that points to a DO class _without_ a storage namespace attached. It's more like a service binding than a DO namespace binding.

DO class bindings will be used with facets.
With facets, each facet could have a different class, so we can't get this from the parent namespace anymore.
There's not yet any API to instantiate them. This is just setting up the scaffolding.
From the design doc:

```js
// The main interface to manage facets is on `ctx`.
let facets = this.ctx.facets;

// Each facet has a name, which identifies its respective slice of storage. Names are hierarchical:
// if the main facet creates a facet called "foo", and that facet in turn creates a facet called
// "bar", the latter facet's true name is "foo/bar". A facet cannot directly access its siblings,
// unless the common parent facet chooses to pass references explicitly.
//
// Names are limited to 128 characters and cannot contain control characters nor `/`.
let name = "foo";

// To use a facet, pass the name to `this.ctx.facets.get()`.
//
// The second parameter specifies information needed to start up the facet. If the facet is already
// running, and matches the given parameters, the already-running instance is returned. If the
// parameters have changed, then the existing instance will shut down and will reload using the
// new parameters.
//
// Like with regular Durable Objects, there is no explicit "create" operation for a facet. It is
// implicitly created when first used, and is implicitly deleted if it shuts down with nothing left
// in storage.
let facet = this.ctx.facets.get(name, {
  // The class to use. This is a facet binding, which points to a class that may be defined
  // in a different Worker. As usual, ctx.exports can be used to refer to other classes
  // exported by the current worker. Dynamic Dispatch can also look up facets using
  // `getFacetClass`.
  class: this.env.FOO_FACET_CLASS,

  // The value that the facet sees in `ctx.id`. This can be a real Durable Object ID, a plain
  // string, or null. In this example, the parent chooses to pass its own ID down to the
  // facet, but this may or may not make sense depending on the use case. If not specified,
  // defaults to null.
  id: this.ctx.id
});

// `facet` acts like a DO stub, except all calls are local.
await facet.someRpcMethod();

// `facets.abort()` forcefully aborts the facet immediately. No further code will execute in the
// facet until it is started again. The next call to `facets.get()` is guaranteed to call the
// callback and start a new instance. `reason` is thrown by any outstanding or future RPC calls
// on existing stubs pointing into the facet.
//
// This also transitively aborts all children of the facet.
this.ctx.facets.abort(name, reason);

// Deleting a facet aborts the facet if it is running and then deletes its underlying storage. This
// applies transitively to all children as well.
this.ctx.facets.delete(name);

// Note that `storage.deleteAll()` deletes all facets in addition to regular storage.
this.ctx.storage.deleteAll();
```
This commit was assisted by Claude Code. I wrote the header file, then prompted it to fill in the implementation and test. There was a lot of iteration on the details, culminating in me heavily editing the implementation by hand. I'm not sure if it saved time.
Each actor namespace has always had its own directory on disk, but previously it did not create a kj::Directory for it. Instead, a single SqliteDatabase::Vfs was created for the parent of all the actor storage directories, and each sqlite file was opened by path that included the actor namespace directory.

That had two problems:
* It's ugly that an ActorNamespace could open files outside its own storage, and just has to promise to always use the correct path to its storage.
* We actually didn't give the ActorNamespace a kj::Directory at all, just to the single SqliteDatabase::Vfs. But we now want to open other kinds of files in this directory, so we need a kj::Directory. Let's just actually open the directory specific to the namespace, and create a separate Vfs per-namespace.
@kentonv kentonv requested a review from MellowYarker May 9, 2025 21:53
@kentonv kentonv requested review from a team as code owners May 9, 2025 21:53
Copy link

github-actions bot commented May 9, 2025

The generated output of @cloudflare/workers-types matches the snapshot in types/generated-snapshot 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant