Storage
Durable Objects
Run Cloudflare Durable Objects on OpenOrange, where each object is one Rivet Actor with its own SQLite database.
Status: Partial. Each object runs as one Rivet Actor with its own SQLite database. WebSocket hibernation is emulated, and a few APIs throw. See What works.
To move existing objects from Cloudflare, see Migrate from Cloudflare. For how Orange builds each part, see the implementation notes.
Configuration
Use the standard Wrangler binding and migration:
{
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] }
]
}
Export the class from the Worker entrypoint, and import the base class from
cloudflare:workers:
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {
async increment() {
const value = (this.ctx.storage.kv.get("count") ?? 0) + 1;
this.ctx.storage.kv.put("count", value);
return value;
}
}
Orange reads the configuration like wrangler dev:
- Each configured class needs one
new_sqlite_classesornew_classesmigration. Orange stores both kinds in SQLite. - More than one binding can name one class.
- A migrated class with no binding is still registered. Reach it through
ctx.exportsor theexportsimport fromcloudflare:workers. - A renamed class keeps its objects. A deleted class’s objects stay in storage.
- A class from
transferred_classesstarts empty. Orange does not move the objects that the other Worker stored. - Other migration fields stop
orange devwith an error. - A binding with
script_nameworks when that Worker runs in the sameorange devsession. See Multiple Workers. - A binding with
environmentornamespace_idloads as a stub that throws when you use it. - Migration tags do not run SQL. Create your tables in the constructor, for
example with
CREATE TABLE IF NOT EXISTS.
A plain class that does not extend DurableObject gets fetch() and alarms.
RPC to a plain class fails, also with the js_rpc flag, because Orange does not
read compatibility flags.
What works
| API | Status | Notes |
|---|---|---|
idFromName(), newUniqueId(), idFromString(), get(), getByName(), jurisdiction() | Supported | Location hints and jurisdictions are checked, but they do not change where an object runs. |
| Method RPC on a stub | Supported | Includes streams, RpcTarget, callbacks, and pipelined calls. |
stub.fetch() | Supported | Request and response bodies stream. |
stub.connect() | Not supported | Throws. Service binding connect() works. |
ctx.storage.sql | Supported | Cursors are eager. See Storage. |
ctx.storage.kv and the async storage API | Supported | |
transactionSync() and transaction() | Supported | |
| Alarms | Supported | See Alarms. |
getCurrentBookmark(), waitForBookmark() | Supported | Bookmarks use the local workerd format. |
getBookmarkForTime(), onNextSessionRestoreBookmark() | Not supported | Throw DurableObjectStorage.<name> is not implemented. |
blockConcurrencyWhile(), waitUntil(), abort() | Supported | abort() restarts the complete Rivet Actor. |
ctx.exports | Supported | |
| Standard WebSockets | Supported | |
| WebSocket hibernation API | Partial | Emulated. See WebSocket hibernation. |
ctx.facets | Partial | The part that the agents SDK uses. See Facets. |
ctx.container | Partial | Runs only Cloudflare Sandbox images. See Sandbox. |
ctx.props, ctx.restore, ctx.configureReadReplication, ctx.primaryStub, ctx.version | Not supported | Reading one throws a NotSupportedError. |
How an object runs
Each object is one Rivet Actor. The JavaScript instance of your class lives inside that actor. The instance and the actor stop at different times:
- The instance is dropped after 10 seconds without an event while an accepted WebSocket is open. The actor keeps running, and the socket stays open. The next event builds a new instance.
- The actor sleeps after 10 seconds without an event when no accepted WebSocket is open. The next request, RPC call, or alarm wakes it and builds a new instance.
- Instance fields are lost in both cases. Keep state in
ctx.storage.
These keep the instance running past the idle timeout:
- A standard WebSocket.
- A WebSocket that the object opened, while it is open.
- A
fetch()until its response headers arrive, also when nothing awaits it. - An open RPC session, or an RPC result that a caller still holds.
- A
waitUntil()orctx.keepAwake()task. - A pending
setTimeout()orsetInterval()callback, until it runs or you clear it.unref()does not change that. The timers ofnode:timersandnode:timers/promisesare the global timers, so they count too. - An upgrade that a client has not claimed yet.
This matches wrangler dev. For the measurements, see the
implementation notes.
- A pending timer, an outbound WebSocket, or a
fetch()also keeps an object that has no WebSocket. Orange keeps its actor awake until the work ends, so the timer runs in the same instance. - Only work that your Worker code starts counts. The timers and sockets of Orange and its packages do not keep an object awake.
- When
ctx.abort()resets an object, or its actor stops, Orange cancels the timers of the old instance, as workerd does. Their callbacks never run against an instance that is gone.
Clear each setInterval() when the object has no more work for it. An
interval that is never cleared is always pending. It keeps the instance and its
Rivet Actor awake forever, with or without a socket. The actor keeps its memory
and counts toward the runner’s
limit of awake objects. To find
such an interval, see
Find what keeps an object awake.
When the runtime stops, it closes each accepted WebSocket with
1012 Service Restart, at random times in a window of 10 ms per socket, up to
10 seconds. Clients do not all reconnect at once. Standard WebSockets close at
once.
Find what keeps an object awake
Orange prints one line for each hibernation event of an object when RivetKit’s
info logs are on. Run orange dev --log-level info, or set
RIVET_LOG_LEVEL=info for orange serve.
[durable-object] soft hibernation actor_id=<id> event=hibernated
[durable-object] soft hibernation actor_id=<id> event=woke
[durable-object] soft hibernation actor_id=<id> event=held class=Room idle_ms=10004 held_by=timer:1 timers=setInterval(3000ms):1
The held line means that no event of the object started or ended for the
idle timeout, and the object still runs.
idle_msis the time since the last event started or ended. For a handler that still runs, it is how long the handler has run.held_bynames each cause with its count.
| Cause | What holds the object |
|---|---|
timer | A pending setTimeout() or setInterval() callback. timers lists each kind with its delay and count. |
standard-websocket | An open WebSocket that the object accepted with accept(). |
outbound-websocket | An open WebSocket that the object opened, with new WebSocket() or through a Durable Object stub. |
fetch | A fetch() whose response has not arrived. |
wait-until | A ctx.waitUntil() promise that has not settled. |
event | A request, message, or alarm handler that still runs. |
rpc-session | An open RPC session with a caller. |
rpc-result | An RPC result that a caller has not collected. It expires after 30 seconds. |
unclaimed-upgrade | A WebSocket upgrade whose client has not connected. It expires after 30 seconds. |
keep-awake | Work that Orange runs for the object, such as a Workflow step. |
When Orange prints the line:
- First when the idle time reaches 10 seconds, then at 20, 40, and so on. The interval doubles up to 10 minutes.
- An event that starts or ends starts the sequence again, so the next line comes 10 seconds after it.
- A
setInterval()that is never cleared shows in everyheldline, for as long as the object runs, asheld_by=timer:1 timers=setInterval(3000ms):1. - Only for an object that has a WebSocket. An object with no WebSocket prints
no
heldline, and with info logs, its actor’s sleep never appears.
Without info logs, Orange counts the causes and starts no report timer.
Storage
SQL
ctx.storage.sql.exec()runs synchronously on the actor’s SQLite database. Cursors supportnext(),toArray(),one(), raw rows, and duplicate column names.- SQL errors carry SQLite’s message and result code, as in workerd, for example
no such table: x: SQLITE_ERROR. databaseSizeuses workerd’s formula: allocated pages minus free pages, times the page size.- The cursor reads all rows before it returns.
rowsReadcounts returned rows, not scanned rows.rowsWrittencounts the changes of oneINSERT,UPDATE,DELETE, orREPLACE, and is 0 otherwise. - A script with more than one statement runs every statement and returns the rows of each. Workerd returns the last statement’s rows. Bindings on such a script are rejected.
BEGIN,COMMIT,ROLLBACK,SAVEPOINT, andRELEASEthroughexec()fail. UsetransactionSync()ortransaction().
Key-value storage
ctx.storage.kvhas synchronousget(),put(),delete(), andlist()on the same database and transaction as SQL.- The async API has
get(),put(),delete(),list(), anddeleteAll(). Multi-key calls have no key-count limit. - Values use V8 serialization, so cycles, maps, sets, and typed arrays keep their shape. Each read returns a new copy.
- Keys sort by their UTF-8 bytes.
list()supportsstart,startAfter,end,prefix,reverse, andlimit. deleteAll()removes every user table, view, and trigger, and the stored alarm.noCacheandallowConcurrencyhave no effect.allowUnconfirmed: truekeeps a write from holding the output gate.
Transactions and gates
- Writes in one JavaScript turn commit together, as in workerd.
transactionSync()runs its closure in a savepoint. A thrown error rolls back the closure’s writes and is thrown again.transaction()keeps one transaction open acrossawait. New events wait until it ends.- Input gate: while
blockConcurrencyWhile()ortransaction()runs, new fetch, RPC, alarm, and WebSocket events wait. - Output gate: a response, a
fetch(), an RPC call, or a WebSocketsend()waits until the writes made before it are durable. If a commit fails, the held output is never sent, and the object resets. blockConcurrencyWhile()times out after 30 seconds. A rejection or a timeout resets the object.ctx.storage.sync()resolves when Rivet confirms every earlier write.
Reserved tables
Orange keeps its own data in the object’s SQLite database:
_cf_KV,_cf_ALARM, and_cf_METADATA, as workerd does._rivet_tables for RivetKit and Orange.
These tables appear in sqlite_master. SQL that reads or changes them fails
with not authorized. Do not name your own tables with these prefixes.
Alarms
alarm()getsscheduledTime,retryCount, andisRetry.- Only one
alarm()runs at a time for an object. - When
alarm()throws, Orange retries up to 6 times, after 2, 4, 8, 16, 32, and 64 seconds, plus up to 25% jitter. AsetAlarm()ordeleteAlarm()during the handler replaces the retry. getAlarm()returnsnullwhile the alarm runs, unless the handler set a new one.- After construction,
setAlarm()rejects a class with noalarm()method. - An alarm that comes due while
orange devis stopped does not run.
RPC and fetch
- Calls from one Worker to one object arrive in the order you make them.
- An error that your method or
fetch()throws rejects only that call. The stub stays usable. - When the object resets, for example after
ctx.abort(), the stub breaks. Its pending and later calls reject, and the error hasdurableObjectReset: true. Callget()orgetByName()again for a new stub. - A response with
Content-Encoding: gziporbris compressed unless it setsencodeBody: "manual". - The object sees the request that the Worker sent, including
OPTIONSrequests andrequest.cf. - A pipelined call waits for its parent result, which adds round trips.
- A Durable Object stub cannot be sent over RPC. Send the ID string.
WebSockets
new WebSocketPair(),accept(), and a status101response work as on Cloudflare.ctx.acceptWebSocket(), tags,getWebSockets(),getTags(),serializeAttachment(), anddeserializeAttachment()work.webSocketMessage(),webSocketClose(), andwebSocketError()get events for accepted sockets. Binary messages areArrayBuffervalues.setWebSocketAutoResponse()answers a matching text frame without building an instance or resetting the idle timer.setHibernatableWebSocketEventTimeout()accepts up to 2,147,483,647 ms (about 24.8 days), the longest Node.js timer. It cannot stop synchronous JavaScript.- Orange does not copy Cloudflare’s limits on tags, sockets per object, attachment size, or automatic-response size. Rivet’s transport limit applies to a message.
WebSocket hibernation
Orange emulates hibernation inside a running Rivet Actor, because Rivet 3 has paused Rivet WebSocket hibernation:
- A socket host in the actor owns each accepted socket and outlives the instance.
- After 10 seconds with no event, Orange drops the instance. The sockets stay open.
- The next message, close, or error builds a new instance. Before the constructor runs, Orange restores each socket with its tags, attachment, and automatic-response timestamp. Events that arrive during construction wait and keep their order.
These differences from Cloudflare come from running hibernation without Rivet WebSocket hibernation. Orange cannot remove them.
A connected object is always a live actor
A hibernated object frees its instance, but its Rivet Actor, its sockets, and their state stay in memory. Cost grows with the number of objects that have an open socket, not with the number of objects that run code.
Measured on the packaged CLI, with one socket for each object and every object hibernated:
| Connected objects | JavaScript heap for each | Runtime process memory for each | Engine process memory for each |
|---|---|---|---|
| 1,000 | 45 KB | 420 KB | 610 KB |
| 10,000 | 44 KB | 299 KB | 431 KB |
For where the runtime’s memory goes, see the implementation notes.
One runner holds at most 4,000 awake objects
The Rivet Engine setting pegboard.sqlite_sessions limits the actors with an
open SQLite database on one runner connection. Each connected object is one.
Its default is 4,000. The next object does not start: its first request waits
and then fails.
- For
orange dev, setRIVET__PEGBOARD__SQLITE_SESSIONSin the environment. Orange passes it to the Engine that it starts. With 20,000, oneorange devheld 10,001 connected objects. - For a self-hosted Engine, set
pegboard.sqlite_sessionsin the Engine’s configuration.
Runner churn closes the sockets
Rivet cannot move an actor to another runner without closing its sockets, so a connected actor cannot move.
On Cloudflare, a code update keeps the sockets of a hibernated object connected by default since deferred code updates. A forced update or a runtime update disconnects them. On Orange, each of these events closes sockets.
Measured with two orange serve runners on one Engine and 40 objects:
| Event | The client’s close | When the object answers again |
|---|---|---|
| A runner stops for a deploy, a scale-down, or a drain | 1012 Service Restart in 0.2 seconds. With many sockets, the closes spread over up to 10 seconds. | A new connection through another runner got its reply in 0.03 to 0.33 seconds. |
| A runner crashes, and the client connected through it | 1006 at once | At once when the object runs on another runner. |
| A runner crashes, and the object ran on it | 1012 Service Restart in 0.1 seconds | Only after the Engine judges the runner lost. Until then, a request to the object waits. |
| The Engine stops | 1012 Service Restart at once | After the Engine is back. |
Orange gives 1012 only when Rivet’s error says that the runner or the Engine
went away, such as ups.consumer_closed. Any other Rivet error, such as
actor.not_found or guard.rate_limit, reaches the client as Rivet sent it:
1011 and <group>.<code>#<ray id>. Find the ray ID in the Engine’s logs.
The object’s storage survived each event. A client must connect again after any close that it did not ask for. It must wait a random time before each attempt, so that the clients of a stopped runner do not all return at once.
Facets
ctx.facets runs child objects that have their own SQLite storage. Orange
supports the part that the agents SDK uses.
| API | Status |
|---|---|
get(name, callback) | Supported. class takes a class from ctx.exports or a namespace. id takes a DurableObjectId. |
RPC and fetch() on the facet stub | Supported |
abort(name, reason) | Supported |
delete(name) | Supported |
clone(src, dst) | Supported. Copies rows in the background. See Clone. |
- Each facet is a child Rivet Actor. Any exported class works. It needs no binding and no migration.
get()starts nothing. The first call through the stub runs the callback and starts the facet.- After
abort()ordelete(), the nextget()runs its callback again, so a new class oridtakes effect. Afterabort(), the new instance sees the old storage. delete()fails calls withFacet was deleted.The nextget()gets empty storage.- A facet without an
idgets its parent’sctx.id. - Facets nest to a depth of 4, including the root.
setAlarm()in a facet fails withFacets currently cannot set alarms.
Clone
clone(src, dst) replaces the storage of dst and its children with a copy of
src. Cloudflare copies the SQLite files in the call. Orange cannot reach
another actor’s files, so it copies rows:
clone()returns at once. Calls todstfail withFacet was cloned-over.- The parent stores a clone record before its next output.
srcreads all its rows in one synchronous call and sends them in chunks.dstapplies the chunks in one transaction.- Calls to
srcanddstwait until the copy ends.
What you can rely on:
dstholds the data ofsrcas of theclone()call. Calls todstalways see the copy.- The copy is whole or absent. It survives a restart, a crash, and the
parent’s
ctx.abort(). - The copy is exact SQL: rowids,
AUTOINCREMENTcounters, generated columns, triggers, views, FTS5 tables, and value types. clone(name, name)aborts the facet and keeps its storage. Asrcthat never ran emptiesdst. At the maximum depth,clone()does nothing.
| Case | Cloudflare | Orange |
|---|---|---|
src is inside storage.transaction() | Copies the state before the transaction | Waits for the transaction, then copies |
| Copy cost | A file copy | src reads every row in one synchronous call, which blocks the runtime process |
| Facets created before clone support | Copied | Their own rows are copied. Their children are not. |
| Infrastructure failure during the copy | Not possible | Calls to dst fail until the parent restarts and runs the copy again |
Measured cost of one clone() in orange dev on an Apple M-series Mac, for a
facet of 256 KiB blobs. “Longest block” is the longest time the process
answered no other request. With no clone, it is about 6 ms.
| Facet size | Clone time | Longest block | Extra process memory |
|---|---|---|---|
| 1 MB | 143 ms | 39 ms | 121 MB |
| 10 MB | 537 ms | 37 ms | 252 MB |
| 100 MB | 5.9 s | 1.5 s | 703 MB |
Limits
Orange does not copy Cloudflare’s quotas. Rivet’s limits apply:
| What | Limit | What happens over it |
|---|---|---|
| One storage write | 128 MiB of changed SQLite pages in one commit | The write throws a RangeError that names the limit. |
| One RPC call | 32 MiB on the wire. Binary data counts 4/3 of its size. | The call throws a RangeError. Send large data with stub.fetch(), which streams. |
blockConcurrencyWhile() | 30 seconds | The object resets. |
| Idle time before the instance or actor stops | 10 seconds | See How an object runs. |
| Alarm retries | 6 | The alarm is dropped. |
| Facet depth | 4, including the root | clone() does nothing at the maximum depth. |
| Awake objects on one runner | 4,000 by default. Set RIVET__PEGBOARD__SQLITE_SESSIONS to change it. | The next object does not start. See One runner holds at most 4,000 awake objects. |
| Storage per object | No limit set by Orange |
All synchronous SQL runs on the one Node.js runtime thread, so a long query delays other Workers and objects in that process.
Who can call an object
Every Orange actor is a Rivet Actor in your project’s namespace: your Durable Objects, facets, and the actors of KV, D1, R2, Cache, Rate Limiting, Queues, Cron Triggers, and Workflows.
- The Rivet gateway checks every call with the caller’s Rivet token and its grants. Orange adds no check of its own.
- So your other Rivet Actors and clients in the same project can call Orange
actors directly with a token that has an
actor_gatewaygrant. On Cloudflare, only the Worker can reach a Durable Object. - A caller with the grant can also set the request that an object’s
fetch()sees, includingrequest.cf. - To keep a client away from your objects, give it a token without an
actor_gatewaygrant, or scope its grant to specific actor IDs. orange devandorange servedrop everyx-open-orange-internal-*header that a client sends to the Worker.- The Rivet token never reaches your object’s
fetch(), WebSocket, or RPC code. - Local development is not a security boundary. The local Engine’s admin
token is
default, and any process on the machine can use it.
Debug an object
- Run
orange dev. The binding table shows each Durable Object binding asRivet Actor · <ClassName>. - To see RivetKit’s own logs, run
orange dev --log-level debug. By default,orange devshows only RivetKit errors. - Find the actor of an object. Its name is the class in kebab case and a short
hash, for example
greeter-5c8cd02c. Its key is the object’s ID.
| Symptom | Cause | Fix |
|---|---|---|
| A field of your class is empty | The instance was dropped or the actor slept | Keep the value in ctx.storage. |
| An object never hibernates or sleeps | A timer, an outbound WebSocket, a fetch(), or other work still holds it | See Find what keeps an object awake. |
| A request to a new object waits, then fails | The runner already holds 4,000 awake objects | Raise the limit. See One runner holds at most 4,000 awake objects. |
A client’s WebSocket closes with 1012 | A runner or the Engine went away | Connect again after a random wait. See Runner churn closes the sockets. |
A client’s WebSocket closes with 1011 and <group>.<code>#<ray id> | Rivet returned an error | Find the ray ID in the Engine’s logs. |
Calls reject with durableObjectReset: true | The object reset, for example after ctx.abort() or a failed commit | Call get() or getByName() again. |
RangeError that names 128 MiB | One write changed too many pages | Split the write. |
RangeError that names 32 MiB | One RPC call is too large | Send the data with stub.fetch(). |
SQL fails with not authorized | The SQL reads or changes a _cf_ or _rivet_ table | Rename your table, and skip these tables in a dump. |
Synchronous storage cannot run while another event's storage.transaction() is open | An event that was already running used sync storage during another event’s transaction() | Use the async storage API in that code path. |
A fetch() response body is cut short | The response reuses the unread request body | Read the request body before you build the response. |
Under orange dev, local Engine state is in .open-orange/rivetkit/ in your
project. The Engine’s log gets half of the free disk there, from 256 MiB to 4 GiB, and
orange dev stops with an error when less than 512 MiB is free. Set
RIVET__STORAGE__LOG_SHARD__MAX_DISK_BYTES in the shell to pick the size yourself.
orange doctor reports the free space in its disk check, with the same rule: it counts
the free disk and the log of the Worker’s own storage directory.
Deviations
- RivetKit’s
_rivet_tables share the object’s database. Code that dumps every table insqlite_master, such as starbasedb’s/export/dump, fails. - Orange blocks
_cf_and_rivet_names with a text check, not a SQLite authorizer. There is no SQL function or PRAGMA allow-list.DELETEandUPDATEwithLIMITfail. ctx.storage.kv.list()reads the matching rows when you call it, so later writes do not appear in the iterator.- The input gate holds only new events. An event that was already running can continue during another event’s critical section.
- The output gate does not hold body chunks streamed after the headers,
outbound
new WebSocket()setup, or output from callbacks that run outside the object’s async context. - The alarm row commits before Orange updates the Rivet schedule. Startup repairs a schedule that a crash left missing or stale.
ctx.abort()and broken storage restart the complete Rivet actor. About 1 call in 600 that overlapsctx.abort()fails with503.- Every idle object stops after 10 seconds without events, so instance fields are lost sooner than on Cloudflare.
- An object that closes a standard WebSocket itself stays awake.
- A stub has no request owner, so a stub kept in a global works in later requests.
- Classes from
new_classessupportctx.storage.sql, where Cloudflare throws. - In a new parent instance, the first
get()of a facet applies its callback’s class even when the facet actor is still awake. - A facet whose stored class is no longer exported cannot start. Call
delete(). - A parent’s
ctx.abort()andstorage.deleteAll()do not reach its facets.ctx.facets.abort()restarts only the named facet. - Each facet call goes through the Rivet gateway. A class change restarts the facet actor.
delete()destroys the old facet actor in the background. If that fails, the actor stays. Facets nested in a deleted facet are not destroyed.- A facet stub cannot be sent over RPC.
Not supported
- Container images other than Cloudflare Sandbox images.
- Per-request
ctx.propsvalues. - Point-in-time recovery with
getBookmarkForTime()andonNextSessionRestoreBookmark(). ctx.restore()andctx.configureReadReplication().stub.connect()and theconnect()handler.- Bindings to another Worker with
environmentornamespace_id. - Moving existing objects with a
transferred_classesmigration. - Durable Object
exportsconfiguration.
Their types stay in the generated contract, and each placeholder throws an error that says it is not supported.
Conformance
src/runtime/durable-object-facets.test.tsandsrc/runtime/durable-object-facet-clone.test.tsrun facets and workerd’s facet clone test on a realorange dev.tests/accepted-websockets.test.tsruns hibernation, the work that keeps an object awake, and theheldline onorange devwith a real Rivet Engine.- The conformance package runs workerd’s upstream tests against
orange devand ports workerd’s C++ Durable Object tests for alarms, IDs, the input gate, SQL, storage, and WebSockets. - Generated type checks compare Orange’s Durable Object types with the pinned Wrangler declarations.
The implementation notes list every test suite.
Compatibility
From the latest conformance run. Scores on the compatibility page; every binding on the status page.
Durable Objects
durable_objectsmigrations Runs on Rivet Actors with SQLite storage and alarms.
WebSocket hibernation is emulated. After 10 idle seconds, Orange drops the object's instance and keeps the actor and its accepted WebSockets open. The next event builds a new instance, and an auto-response frame is answered without one. The actor sleeps only when it has no open accepted WebSocket. A class from another Worker (script_name) runs when that Worker is in the same orange dev session. Rivet's _rivet_ tables are reserved in the object's SQLite.
Scales by Object ID. One actor per object id. Add ids to add capacity.
Recorded results
WorksStorage API: get, put, list, delete, transaction, sync KVAvailable.Same, stored in the object's SQLite. 4/4 pass
- Results
- synthetic--do-storage-test Pass 24/24
- api-tests--actor-kv-test Pass 1/1
- api-tests--sync-kv-test Pass 1/1
- lifecycle: storage: a value survives the object's sleep Pass workerd pass
WorksKV-backed classes (new_classes)A separate storage backend with no SQL.Accepted, and stored in SQLite like every other class, so the class can also use SQL. There is no import of data from a Cloudflare KV-backed object. 2/2 pass
- What to do
- New code runs unchanged. To move a live object off Cloudflare, read its data out through its own API.
- Results
- lifecycle: KV-backed class: the storage API keeps a value Pass workerd pass
- lifecycle: KV-backed class: SQL is available Pass workerd fail
WorksObject ids and input gatesAvailable.Same. 2/2 pass · 1 not run
- Results
- synthetic--do-id-test Pass 2/2
- synthetic--do-input-gate-test Pass 5/5
- api-tests--actor-block-concurrency-depth-test Not run
WorksAlarmsAn alarm fires on time, wakes a sleeping object, and survives a restart.Same, when the process is up at the alarm's time. 4/4 pass
- Results
- synthetic--do-alarm-test Pass 17/17
- lifecycle: alarm: fires on time Pass workerd pass
- lifecycle: alarm: wakes a sleeping object with no request Pass workerd pass
- lifecycle: alarm: a future alarm survives a restart Pass workerd pass
WorksAn alarm due while the process is downFires when the runtime comes back.Same. It fires after a restart, with no request. 1/1 pass
- Results
- lifecycle: alarm: an alarm due while the process is down fires after a restart, with no request Pass workerd pass
PartialRPC and entrypointsAvailable.Stubs, WorkerEntrypoint, RpcTarget, and streams work. Calls from one process to one object keep the order they were made in, whichever stub made them, as in workerd. Other failing tests are in the report. 2/3 pass
- Results
- workerd tests: RPC + entrypoints Partial 154/179
- api-tests--request-signal-enabled > abortIfClientAbandonsRequest Pass
- api-tests--request-signal-enabled > abortedRequestDoesNotAbortSubrequest Pass
WorksWebSockets through an objectAvailable.Same, while the object stays awake. 2/2 pass
- Results
- synthetic--do-websocket-test Pass 11/11
- workers-chat-demo: two clients chat in a room, and history is kept Pass
PartialWebSocket hibernationA socket stays open while the object sleeps, and a message wakes the object.Orange emulates hibernation, and real hibernation is coming soon. When an object with an open accepted WebSocket is idle, Orange drops the object and its in-memory state and keeps the socket open. The next message builds a new instance of the object. The Rivet actor stays running the whole time, and it sleeps once the last socket closes. A client stays connected, but the actor does not sleep while it does. 16/19 pass
- Why
- Rivet 3 has paused WebSocket hibernation, so an actor cannot sleep with a socket open. Orange hibernates the object inside the running actor instead.
- What to do
- Have clients reconnect on any close, and don't depend on one close code. Keep anything that must survive hibernation or a restart in storage. Close sockets you no longer need, so the actor can sleep.
- What to plan for
-
- An object with an open socket is always a running actor and does not scale to zero: the actor, its sockets, and their state stay in memory. Before the emulation, an idle awake object cost about 1.1 MB in total, in Orange and the Engine, and each idle socket on one object cost about 0.2 MB. About half of an object's cost stayed after it slept. Measured through
orange serveon one machine, with 1,000 idle objects or 1,000 idle sockets on one object, and not measured again with the emulation. It is not a limit. - A deploy closes open sockets. On one cluster, each client reconnected on the first try:
1012on 4 sockets and1011on 1. The object is a new instance, and storage survives. In-memory state and the old sockets do not. Whether the object getswebSocketCloseduring a rollout is not measured. - Old and new runners overlap for about 100 s during a rollout, and the Engine can place new objects on the old runner then. Those clients get cut off soon after opening.
- Under
orange dev, a restart closes open sockets with1012and a crash with1006. In both cases a reconnect works and storage survives. - Orange pings every network socket every 30 s, because Cloudflare closes idle sockets on pool URLs at 125 s with
1006. With pings, idle sockets stayed open until a rollout closed them at 695 s. A Worker and its objects never see the pings.wrangler devkept idle sockets open for 360 s with no pings. Real Cloudflare is not measured: its docs say idle sockets get closed and recommend a heartbeat. - A client whose connection is cut gives the object
webSocketClosewith1006within about 1 s. A client that stays connected but stops answering pings is closed with1006at about 90 s. The object sleeps after both. webSocketClosegets1001 "gateway disconnected"where a client closed cleanly with1000.wrangler devpasses the client's code.- The object hibernates, but its actor does not sleep with a socket open. Real projects' steps that wait for every actor to sleep fail with
Some actor was still awake. The real-project results show each of those steps again under a sleep check that also accepts the emulation.
- An object with an open socket is always a running actor and does not scale to zero: the actor, its sockets, and their state stay in memory. Before the emulation, an idle awake object cost about 1.1 MB in total, in Orange and the Engine, and each idle socket on one object cost about 0.2 MB. About half of an object's cost stayed after it slept. Measured through
- Results
- synthetic--do-websocket-hibernation-test AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | + actual - expected | Partial 2/3
- lifecycle: hibernation: a socket stays open through the object's sleep, and a message wakes it Some actor was still awake 45000ms after the last client activity Fail workerd pass
- lifecycle: open socket: an idle socket stays open past the sleep timeout Pass workerd pass
- lifecycle: open socket: an idle object drops its in-memory state while its socket stays open Pass workerd pass
- lifecycle: open socket: the object sleeps after its last socket closes Pass workerd pass
- lifecycle: open socket: webSocketClose gets the client's close code /closed?room=k4 did not reach the expected state: {"code":1001} Fail workerd pass
- lifecycle: open socket: a restart closes it with 1012, and a reconnect finds storage Pass workerd fail
- lifecycle: open socket: a crash closes it with 1006, and a reconnect finds storage Pass workerd pass
- cluster-websocket: an idle socket on a pool URL, with no pings, closes at 125 s with 1006 Pass
- cluster-websocket: with pings, idle sockets stay open until a rollout closes them at 695 s Pass
- cluster-websocket: a rollout closes sockets with 1012, and each client reconnects on the first try Pass
- cluster-websocket: a socket can also close with 1011 when its object's generation ends Pass
- cluster-websocket: after a rollout the object is a new instance, and an accepted socket's storage is kept Pass
- cluster-websocket: a cut client gives the object webSocketClose 1006 within about 1 s, and the object sleeps Pass
- cluster-websocket: a client that stops answering pings is closed with 1006 at about 90 s, and the object sleeps Pass
- cluster-websocket: wrangler dev keeps idle sockets open for 360 s with no pings Pass
- keep-awake-memory: 1,000 objects with one idle socket each cost about 1.1 MB per object Pass
- keep-awake-memory: 1,000 idle sockets on one object cost about 0.2 MB per socket after a long hold Pass
- keep-awake-memory: about half of an object's cost stays after it sleeps Pass
In the feature matrix
Partialdurable_objects (class in this Worker)Binds a Durable Object class from this Worker. The class can use the SQLite or the KV storage backend.Binds the class when a new_sqlite_classes migration creates it. The namespace runs on Rivet Actors. 4 files
- Notes
- Classes from new_classes (KV backend) fail config load with Unsupported Durable Object migration fields. See the Durable Object entries for API details.
- Source
- src/config/load-wrangler.ts:1036-1043src/config/load-wrangler.ts:921-944packages/runtime-node/src/runner.ts:681-693src/config/load-wrangler.test.ts:92-134
Supporteddurable_objects (class in another Worker)Binds a Durable Object class that another Worker exports, with script_name.The class runs in the Worker named by script_name, in the same orange dev session and on the same Rivet Engine. Stubs from both Workers reach one object. 3 files
- Notes
- A script_name outside the session gives a namespace whose stubs answer fetch() with 503 and throw on RPC, like Wrangler's dev registry. A binding with only environment or namespace_id still loads a stub.
- Source
- packages/runtime-node/src/session/client.tspackages/runtime-node/src/session/server.tssrc/runtime/multi-worker.test.ts
SupportedDurable Object fetch()stub.fetch() calls the Durable Object's fetch(request) through the input gate.stub.fetch() runs the instance fetch(request) as an ordered event inside the input gate. 1/1 pass · 2 files
- Notes
- A class without fetch() fails with Error Handler does not export a fetch() function. A non-Response result fails with TypeError Incorrect type for Promise: the Promise did not resolve to 'Response'. Both match workerd.
- Tests
-
- api-tests--actor-stub-test › main Pass
- Source
- packages/runtime-node/src/bindings/durable-objects/fetch.ts:128-207src/runtime/durable-object-rpc-fetch.test.ts
SupportedDurable Object alarm()alarm(alarmInfo) runs at the time from setAlarm(), with retries on failure.Runs alarm({ retryCount, isRetry, scheduledTime }) at the stored time, with up to 6 retries. 2/2 pass · 2 files
- Notes
- setAlarm() without an alarm() handler fails with Your Durable Object class must have an alarm() handler in order to call setAlarm(). All 17 synthetic do-alarm tests pass.
- Tests
-
- synthetic--do-alarm-test › throwingHandlerIsRetried Pass
- api-tests--actor-alarms-test › test Pass
- Source
- packages/runtime-node/src/bindings/durable-objects/alarm-storage.ts:305-341packages/runtime-node/src/bindings/durable-objects/alarm-storage.ts:232-240
SupportedDurable Object webSocketMessage()webSocketMessage(ws, message) receives frames on WebSockets accepted with ctx.acceptWebSocket().Calls webSocketMessage(ws, message) for each frame that is not an auto-response, as an input-gated event. 2/2 pass · 1 file
- Notes
- A missing method silently drops the event, like workerd. The handler runs on the shared Node thread, so a synchronous loop cannot be stopped by the event timeout.
- Tests
-
- synthetic--do-websocket-test › clientStringDispatchedToHandler Pass
- synthetic--do-websocket-test › clientBinaryDispatchedToHandler Pass
- Source
- packages/runtime-node/src/bindings/durable-objects/hibernatable-websocket-events.ts:168-181
PartialDurable Object webSocketClose()webSocketClose(ws, code, reason, wasClean) runs when a hibernatable WebSocket closes.Calls webSocketClose(ws, code, reason, wasClean) once. The socket stays listed until the handler settles. 2/3 pass · 4 files
- Notes
- A client's close code can be lost in the Rivet gateway: in the recorded lifecycle probe, a clean client close reached webSocketClose as 1001, not 1000. A missing method silently drops the event.
- Tests
-
- synthetic--do-websocket-hibernation-test › closeWhileHibernatedKeepsCodeAndReason AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | + actual - expected | Fail · Behavior differs
- synthetic--do-websocket-test › clientCloseDispatchedToHandler Pass
- api-tests--websocket-hibernation › test Pass
- Source
- packages/runtime-node/src/bindings/durable-objects/hibernatable-websocket-events.ts:138-153packages/runtime-node/src/bindings/durable-objects/websocket.ts:276-288packages/cloudflare-conformance/probes/lifecycle/scenario.ts:90-94packages/cloudflare-conformance/probes/lifecycle/result.json:87-91
SupportedDurable Object webSocketError()webSocketError(ws, error) runs when a hibernatable WebSocket fails.Calls webSocketError(ws, error) once and releases the socket. 1 file
- Notes
- No conformance test triggers a WebSocket error.
- Not verified by a test
- No test drives the error path. The claim is read from code.
- Source
- packages/runtime-node/src/bindings/durable-objects/hibernatable-websocket-events.ts:154-166
StubDurable Object connect()stub.connect() opens a TCP-style socket to the Durable Object's connect() handler.stub.connect() throws DurableObjectStub.connect is not implemented by OpenOrange. 1 file
- Notes
- A connect() method on a Durable Object class never runs.
- Not verified by a test
- Whether Cloudflare production exposes Durable Object connect() is not checked.
- Source
- packages/runtime-node/src/bindings/durable-objects/namespace.ts:362-369
Durable Object Facets
Runs on A child Rivet Actor per facet.
get, abort, delete, clone, and string IDs work, which covers the agents SDK.
Recorded results
WorksDurable Object facetsAvailable.A child Rivet Actor per facet, with abort, class swap, and delete. 1/1 pass
- Results
- example: facets-loader Pass
In the feature matrix
Partialctx.facetsStarts and manages child objects (facets) that share the parent's storage.ctx.facets.get(), abort(), delete(), and clone() work. Each facet is a child Rivet Actor with its own SQLite storage, hosted by one generic actor definition, so any exported class works without a binding or migration. A string id becomes the facet's ctx.id, as in workerd. clone() returns at once and copies one synchronous row snapshot of the source facet and its children in the background; later calls to the source and destination wait for the copy. 0/1 pass · 5 files
- Notes
- clone() matches workerd's facet cloning test, including restarts, and survives ctx.abort() and a crash during the copy. It copies a source inside storage.transaction() after the commit, where workerd copies the state before it, and its synchronous read blocks the runtime process (1.6 s for 100 MB). Facets nest to a depth of 4 and cannot set alarms. ctx.facets is still excluded from the generated type checks. See docs/modules/durable-objects.mdx.
- Tests
-
- api-tests--persistent-stubs-test › persistentFacetStubs NotSupportedError: DurableObjectState.restore is not implemented Fail · Not-implemented API
- Source
- packages/runtime-node/src/bindings/durable-objects/state.ts:408-411packages/runtime-node/src/bindings/durable-objects/facets.ts:577-651packages/runtime-node/src/bindings/durable-objects/facet-clone.ts:87-288src/runtime/durable-object-facet-clone.test.ts:19-161src/runtime/durable-object-facets.test.ts:86-163