Storage
Queues
Send and consume Cloudflare Queues messages with OpenOrange, backed by one Rivet Actor per queue.
Status: Supported. Runs on one Rivet Actor for each queue.
Configuration
OpenOrange reads the standard Wrangler Queues configuration. No extra setup is needed:
{
"queues": {
"producers": [{ "binding": "JOBS", "queue": "jobs", "delivery_delay": 0 }],
"consumers": [
{
"queue": "jobs",
"max_batch_size": 10,
"max_batch_timeout": 5,
"max_retries": 3,
"retry_delay": 0,
"dead_letter_queue": "jobs-dlq"
}
]
}
}
The Worker sends with env.JOBS and consumes with its queue() handler:
export default {
async fetch(request, env) {
await env.JOBS.send({ to: "user@example.com" });
return new Response("queued");
},
async queue(batch, env) {
for (const message of batch.messages) {
console.log(message.id, message.attempts, message.body);
message.ack();
}
},
};
Consumer settings use Cloudflare’s production defaults. Cloudflare’s maximums are not copied:
| Setting | Default | Range |
|---|---|---|
max_batch_size | 10 | 1 or more |
max_batch_timeout | 5 seconds | 0 or more |
max_retries | 3 | 0 or more |
retry_delay | 0 seconds | 0 or more |
wrangler dev uses smaller defaults (5 messages, 1 second, and 2 retries). Set
the values in the configuration to get the same batches in both.
A value out of range stops orange dev and names the queue.
How it works
Each queue is one Rivet Actor, openorange:queue, with the queue name as its
key. Producers in any Worker that name the same queue share it. Messages are
rows in the actor’s SQLite database, so they persist across orange dev
restarts in the project’s .open-orange directory. A message row is deleted
only after the consumer acks it, sends it to the dead-letter queue, or drops it.
send()andsendBatch()store messages with thetext,bytes,json, orv8content type.jsonis the default. A body ofundefinedand a body that does not match its content type throw aTypeError, like workerd.delaySecondsand the producer’sdelivery_delayset the time at which a message becomes available. The delay can be any whole number of seconds, 0 or more; Cloudflare’s 24-hour maximum is not copied. A Rivet schedule wakes the actor at the earliest pending time.- The consumer delivers one batch at a time to the Worker’s
queue()handler.ack(),retry(),ackAll(), andretryAll()follow Cloudflare’s rules: the first call wins, a call on one message wins over a batch call, and an explicitack()holds even when the handler throws. - A batch completes only after
queue()and itswaitUntil()promises settle. If the handler throws, every message that was not acked is retried. Iforange devstops or crashes duringqueue(), the batch is delivered again after the restart. Delivery is at least once. - A message gets
max_retries + 1deliveries. Then it goes to thedead_letter_queue, with its ID, timestamp, and body, or it is dropped with a warning.
Conformance
An end-to-end orange dev test covers retries up to max_retries and the
dead-letter queue, retry({ delaySeconds }), batches from concurrent
producers, a 128,000-byte message, send validation errors, a crash during
queue(), and a delayed message across a restart. The upstream workerd Queues
tests use a mock queue service that the conformance runner does not configure.
Limits and deviations
- OpenOrange does not copy Cloudflare’s limits of 128 KB per message, and 100
messages and 256 KB per
sendBatch(). The limit is Rivet’s: onesend()orsendBatch()is one message to the Rivet Engine, which takes at most 32 MiB. Messages over that in one call fail at once with aRangeErrorthat names the limit. A 31 MiB message was measured to work. - A
sendBatch()call is stored in one transaction, so a rejected message stores none of the batch. - Each queue runs one batch at a time.
max_concurrencyis ignored. - A queue is shared by name across the Workers of one
orange devsession. A consumer in another Worker of the session receives the batches. With no consumer in the session, messages wait in the queue, like a Cloudflare queue that has no consumer.wrangler devdrops them. - A batch that is delivered again after a crash keeps its
attemptsvalue. metrics()and themetadatathatsend()returns count the messages that are due, and they never includeoldestMessageTimestamp.
Throughput
Each queue is one actor, and one actor is the single writer for its storage.
Every send(), sendBatch(), and batch delivery on a queue goes through that
actor, so one queue’s throughput is bounded by what one actor can do.
Use sendBatch() to carry more messages per call, or more than one queue to
spread load. Sharding a queue across actors, or backing Queues with a Rivet
Service, may lift this limit later.
Registry message size
Orange raises the RivetKit registry option maxIncomingMessageSize from its
64 KiB default to 32 MiB, the most the Rivet Engine takes in one message from
a runner. RivetKit checks this limit on each action request, each queue send
over HTTP, and each message on an actor-connect WebSocket. send() and
sendBatch() reach the queue actor as one action request.
The option applies to every actor in the registry, including Durable Objects,
facets, and the Workflows and cron actors. Durable Object and facet fetch(),
RPC, and WebSocket traffic do not use RivetKit actions, so the limit does not
change them.
Compatibility
From the latest conformance run. Scores on the compatibility page; every binding on the status page.
Queues
queues.producersqueues.consumers Runs on Internal Rivet Actor per queue, delivered to the Worker's queue() handler.
Queues are shared by name across an orange dev session, so a consumer in another Worker receives the messages. One batch at a time per queue.
Scales by Queue. One actor per queue, delivering one batch at a time.
Recorded results
WorksQueuesAvailable.Delays, retries, dead letter queues, and batches work. One batch at a time per queue. 1/1 pass
- Results
- example: queues Pass
In the feature matrix
Alternativequeues.producers (queue)Sends messages to a Cloudflare Queue.Each queue is an Orange-internal Rivet Actor whose RivetKit queue stores the messages. send() and sendBatch() with the text, bytes, json, and v8 content types, delaySeconds, delivery_delay, and metrics() work, and messages persist across orange dev restarts. 0/1 pass · 5 files
- Notes
- Cloudflare's message and batch limits are not copied. One send() or sendBatch() is one message to the Rivet Engine, at most 32 MiB. The registry raises maxIncomingMessageSize to 32 MiB so that one sendBatch() fits in one action request. Upstream queue-test fails because the conformance runner drops its queue binding. See docs/modules/queues.mdx.
- Tests
-
- api-tests--queue-test › queue-test TypeError: Expected one of body or serializedBody for each message Fail · Behavior differs
- Source
- src/config/load-wrangler.ts:821-833packages/runtime-node/src/runner.ts:540-549packages/runtime-node/src/bindings/queues/producer.ts:88-175packages/runtime-node/src/internal-actors/queue.ts:301-344src/runtime/queues.test.ts:139-337
Partialqueue handlerQueue consumers in queues.consumers call queue(batch, env, ctx).The queue actor of each queue in queues.consumers delivers batches to queue(batch, env, ctx), one batch at a time. ack(), retry(), ackAll(), retryAll(), max_retries, retry delays, and dead_letter_queue follow Cloudflare's rules, and delivery is at least once. 1/1 pass · 6 files
- Notes
- max_concurrency is ignored. After an idle period, the first batch waits the whole max_batch_timeout. A queue is shared by name across the orange dev session, so a consumer in another Worker of the session receives the messages. orange dev fails at startup when two Workers declare a consumer for one queue. queue-metadata-test fails because the conformance runner drops its queue binding. See docs/modules/queues.mdx.
- Tests
-
- api-tests--queue-metadata-test › queue-metadata-test Pass
- Source
- packages/runtime-node/src/events/queue.ts:57-83packages/runtime-node/src/internal-actors/queue.ts:244-299packages/runtime-node/src/bindings/queues/batch.ts:112-163src/runtime/queues.test.ts:139-337packages/runtime-node/src/runner.ts:818-831src/runtime/multi-worker.test.ts:148-225