Serverless and Cloudflare

Run fenecdb inside a Cloudflare Worker, with one small database for each Durable Object.

Two ways to use it

Either the engine runs inside the function, one database per Durable Object, or the function sends its queries to a fenec-server server. Inside is faster; a server holds more data and more writers.

 the engine in the functionthe function talks to a server
where the data isin the object's memory, kept in its storagein fenec-server's file
a queryno network in the object: 2.5 ms for a request's near over 10 000 × 128a round trip to the server
writersone an object; a tenant an objectone a file; a node holds many
sizeabout 60 000 rows of 128 dimensions, 12 000 of 768 (Limits)the machine's disk, the file mapped
fitsa database a user, a team or a document; RAG over a tenant's own corpusone large database, many writers, replicas
Durable Objects, and plain HTTP

The browser module runs wherever V8 runs a WebAssembly module, a Cloudflare Worker among them. A Durable Object gives it what a file gives a server: one writer, and storage that outlives the process. So one object holds one database, as a tenant is one file on a fenec-server node.

The second needs nothing new: Fenec.connect(url), the Python Client or fetch, and a function's connections are HTTP requests, so there is no pool to exhaust. Servers in several regions take one URL through a Worker in front of them, which sends each tenant's requests to its region: the global entry. The rest of this page is the first.

A database a Durable Object

The object opens the engine on its first request, restores its database from storage, and saves after every write, before it answers. Its alarm keeps it: rows past their time deleted, a new image when one is worth it.

import { DurableObject } from 'cloudflare:workers';
import { Fenec } from '@fenecdb/web';
import { maintain, persist, release, restore, schedule } from '@fenecdb/cloudflare';
import wasm from '@fenecdb/web/fenec.wasm';

interface Env {
  TENANT: DurableObjectNamespace<Tenant>;
}

export class Tenant extends DurableObject<Env> {
  #db: Promise<Fenec> | null = null;

  #open(): Promise<Fenec> {
    // Once, with every other event held back while it restores.
    return (this.#db ??= this.ctx.blockConcurrencyWhile(async () => {
      const db = await Fenec.open(wasm);         // the module as the Worker imports it
      await restore(db, this.ctx.storage);
      return db;
    }));
  }

  async query(sql: string, params: unknown[]): Promise<unknown> {
    const opened = this.#open();
    const db = await opened;
    try {
      return db.run(sql, params);
    } finally {
      let wrote = 0;
      try {
        wrote = await persist(db, this.ctx.storage); // durable before the answer goes
      } catch (e) {
        if (this.#db === opened) this.#db = null;   // the next request restores
        throw e;
      }
      // The alarm, once something is due: a row that will expire, an image
      // owed. Nothing else sets it -- an alarm is a row written too. The
      // write is stored either way, and the next one sets an alarm not set.
      if (wrote > 0) await schedule(db, this.ctx.storage).catch((e) => console.error(e));
    }
  }

  async alarm() {
    const opened = this.#open();
    const db = await opened;
    // Expired rows deleted, an image if one is worth it, the alarm set
    // again for what comes due next, or none.
    const done = await maintain(db, this.ctx.storage).catch(async (e) => {
      await release(db);                             // nothing more of it is stored
      if (this.#db === opened) this.#db = null;     // the retry restores
      throw e;
    });
    if (done.error) throw done.error;               // stored, the alarm not set: tried again
  }
}

export default {
  async fetch(req, env) {
    const tenant = env.TENANT.get(env.TENANT.idFromName('acme'));
    const { sql, params } = await req.json<{ sql: string; params: unknown[] }>();
    return Response.json(await tenant.query(sql, params));
  },
} satisfies ExportedHandler<Env>;
Why open and restore run in blockConcurrencyWhile

A Worker imports a .wasm file compiled, and cannot compile bytes at run time; Fenec.open takes the module so. The open and the restore run inside blockConcurrencyWhile, once: opened by whichever request found the object cold, two requests could each restore an engine of their own and append to the storage as if alone. persist runs in a finally, since a statement that failed may follow ones in the same text that wrote. A persist that fails leaves the database in memory holding writes its storage never took, so the object lets it go and the next request restores what storage holds, rather than answer from writes nobody kept. Its refusal, and a failed maintain's once release has let the database go, come only once nothing of that database is on its way to storage, and nothing more of it will be: the database restored next reads all the first stored and is the only one writing. An error setting the alarm fails neither a write, which is stored, nor maintain, which answers it as error for the alarm to be tried again. integrations/cloudflare/example is this Worker with a tenant from the path, POST /t/<tenant>/query, and its test runs it under wrangler dev — workerd, as Cloudflare runs it — then stops and starts it over the same storage and reads the rows, a near's answer and each tenant's own database back, and sends a cold object 32 writes at once.

What the storage holds

persist saves a full image once, then only the new writes: one row each in a SQLite-backed object's storage, which bills by the row written. If storage stops part way through, the last whole database is still there.

 rows written beforenow
a single-row write, 1 writer3.41
a single-row write, 8 writers at once3.40.52
a single-row write, 32 writers at once3.40.15
a checkpoint, 10 000 × 128 (a 5.7 MB image)1015
a checkpoint, 50 000 × 128 (a 27.6 MB image)47916
How persist lays out the image and the writes

persist keeps what a file would hold: an image the first time, then only the writes since the last call — the engine's journal, as persist in the browser takes it for IndexedDB — until they outgrow half the image and 64 KiB, and a new image replaces them.

A SQLite-backed object — the only kind a new account makes — has ctx.storage.sql, and the database is a table a generation, fenec:1, fenec:2 and on (the key, then the generation): the image's rows, cut under the 2 MB a row takes, then a row each time writes are stored. Nothing names where the database stands beside the rows: the table there is says it, so a write is the one row. A new image is written, and the table before it dropped, in one transactionSync: workerd counts a table made as two rows written and a table dropped as none, where a DELETE of the rows before counts each, and storage that stops part way holds the generation before, whole. The rows are an INTEGER PRIMARY KEY table with no other index, since an index is a row written more.

A write goes out at once, unless the one before it is still on its way to being durable (storage.sync()): then it waits for that, and every request whose writes land meanwhile — a Durable Object takes the next request in while one waits — goes out with it, in one row. Each persist resolves only once its own writes are durable, and a read that may have seen writes on their way waits for them, so no answer leaves before what it shows is kept, and a lone write answers as soon as before: 2.0 to 2.3 ms p50 against 2.4 to 3.1. Waiting a turn of the event loop instead, for requests to land, cost a lone write 1.7 to 2.4 ms more. The figures above are make cloudflare-bench's, under wrangler dev with every storage call counted: a write before was a value, a record naming it and an alarm set again, and a checkpoint a value each 128 KiB of the image and a delete each of the values before.

A key-value backed object, made before SQLite with new_classes, has no sql; there the image and the writes are cut into values under its 128 KiB, every image goes under a generation of its own, and the record naming the whole one is written last. A test stops either kind of storage after every number of operations, an image's, an append's and a compact's, and restores the last whole database each time; and a write that fails fails every write that went out with it.

A database kept by an earlier version on a SQLite-backed object is in values, which restore no longer reads there. To move one, restore it through the key-value methods alone — an object holding get, put, delete and list of ctx.storage and no sql — and persist through ctx.storage.

A cold start

After an eviction or a deploy, the first request has to load the database again: tens of milliseconds for 10 000 rows, under 200 for 50 000. The alarm's maintain folds the writes into a new image once they reach half of the last one, for fewer rows to read and no vectors to link.

rowsfirst near, as writtenafter a checkpointevery near after
1 00020 ms21 ms2.1 ms
10 00039 ms47 ms2.1 ms
50 000175 ms157 ms2.1 ms
What a cold start costs, measured

An object evicted, or a deploy, starts the next request with no database in memory: the module instantiated, the image and the writes after it read out of storage and loaded. The graphs the image holds are restored as they were; the writes after it are applied as the write path would, their vectors linked into the graph one after another — the browser module has one thread. The first near after a restart, against the ones after it, under wrangler dev on an M1 (make cloudflare-bench, median of five restarts; rows of a text, an int and a 128-dim vector, 1 000 a request, each kept before it was answered, no alarm) are in the table above; a second run was within 2 ms of each.

Linking the writes after the image is no longer what a start waits for: when this page was first written it took the first near over 10 000 rows to 792 ms and over 50 000 to 1 455, and the same measurement on today's module gives 39 and 175, so a checkpoint takes 50 000 rows from 175 ms to 157, 10 000 none quicker, and is mostly worth the rows it folds. The rows are read into the module a row at a time rather than joined first: 50 000 rows as written started in 175 ms against 186 to 196, after a checkpoint 157 against 168 to 171. A request's round trip through wrangler dev is in every figure. The rows went in at 5 200 to 15 000 a second, a request of 1 000 at a time with its vectors linked and kept before the answer.

The figures under “after a checkpoint” are a new image taken by hand (checkpoint); an object that keeps its database from its alarm takes one by the rule below.

Maintenance

A server deletes rows past their @ttl, compacts its file and keeps its graphs on threads of its own. An object has one alarm, which is a row written each time it is set and wakes the object each time it rings: schedule sets it after a write only once something is due, and maintain, its work, sets it again only for what comes next. An idle object sets none.

rows written, 10 000 × 128an alarm after every write, a checkpoint past 64 KiBmaintenance
30 bursts of 20 single-row writes, a second's quiet after each651 to 652600
12 bursts of 500 rows10019
20 000 rows past their time, deletednever2 and 2 alarms
How the alarm sweeps expired rows

maintain deletes the rows a minute (grace) past their time, as a server's sweep does, so a reader of expired() as frequent sees each before it goes; 10 000 a call (sweep) in statements of 1 000 ordinary deletes, stored as any write is, the alarm set again at once past the bound. The object answers nothing while it runs: 1 000 rows of 100 000 took 0.46 ms to delete in the module, so 10 000 hold it about 5 ms, far inside an alarm's 30 s of CPU and 15 minutes. Then it sets the alarm for the next row's time and the grace, no sooner than a minute (every) after this sweep, or sets none; each sweep is two rows at most, its deletes and the alarm, so rows expiring a few a second cost two rows a minute rather than two a row. No export of the module was added for it: a del naming expired() and walking the field's index in order says what the server's sweep does natively.

It writes a new image when the writes since the last reach half of it and 64 KiB (min), where persist folds them in a request, or when half of what storage holds is dead and a mebibyte — what a sweep or deletes left, which the writes' size alone does not show. Woken to sweep and nothing more, it stores the deletes as one row and writes no image. Once schedule is called, a request folds the writes only past the whole image: the alarm takes the image at half, and no request waits for it. A threshold of 64 KiB, as the alarm used, wrote a 27.6 MB image as 16 rows for writes that were one or two; at a quarter of the image, rewrites of 1 000 rows of 128 dimensions a request, a row each, brought an 11 MB image, 8 rows, every 6.

The module never gives memory back, and holds every version of a record written since it was opened. Once the dead versions reach half of what it holds — at least 4 MiB, at most 16 — maintain makes sure storage holds an image without them and answers reload: the example lets the database go and restores a new module, as it does on any failure — calls arriving wait while the ones holding it finish, a write on its way to storage among them, then it is released and closed and another restored. Let go of at once, a database an alarm dropped while a request's write waited behind another's stored it after the next request had restored a second database and written, under the same id: an answered write lost. In Node, 50 000 rows of 128 dimensions with three in five past their time held 42.7 MB of module through two sweeps of 10 000; the third left half of storage dead, its image took the module to 51.6 MB, and the module restored from it held 19.2. A compact in the module would have rebuilt every graph holding tombstones and built its image whole, and given nothing back.

Limits

An object holds about 60 000 rows of 128 dimensions, or 12 000 of 768. For more, use a fenec-server server or spread a tenant over more objects.

  • One writer an object. Writes to one tenant go one after another, as on a server; tenants write side by side.
  • Bundle collation data. Collation data for scripts the module does not carry is fetched from a URL by default; in a Worker, pass {collation: name => ...} to Fenec.open and hand the chunks from your bundle.
  • Install it from npm. npm install @fenecdb/cloudflare @fenecdb/web; both are published with each release.
Memory in a 128 MB isolate, measured

A Worker's isolate has 128 MB, the WebAssembly memory in it, and wrangler dev does not hold a Worker to it: the figures above reach past it. What the module holds, measured in Node on the same module (rows of a text, an int and a vector under HNSW):

rowsrestoredwritten, 1 000 a requestthen a checkpointthe image
10 000 × 12816 MB24 MB28 MB6 MB
20 000 × 12829 MB37 MB42 MB12 MB
30 000 × 12841 MB46 MB53 MB18 MB
50 000 × 12867 MB73 MB83 MB30 MB
60 000 × 12880 MB85 MB97 MB36 MB
3 000 × 76821 MB40 MB40 MB9 MB
5 000 × 76835 MB58 MB58 MB16 MB
10 000 × 76866 MB84 MB84 MB32 MB
12 000 × 76880 MB102 MB102 MB38 MB

A WebAssembly memory grows and never gives back, so an object holds the most it ever held. The module keeps an index's vectors in chunks of about a mebibyte and lets a segment of documents grow no further than the record that seals it: grown by doubling, old and new side by side, 5 000 rows of 768 written 1 000 a request held 100 MB, 58 now, and 10 000 of them 150, 84 now. A restore keeps the image it reads and the documents in it, rather than copy each out: 10 000 rows of 768 restored held 97 MB, 66 now; and it copies the rows into that image as it reads them, one at a time (loadParts): read whole, joined, then copied in, a 27.6 MB image — 50 000 rows of 128 — held 60 MB of JavaScript beside the module's 42 at its peak, and holds 4, the process 105 MB more resident against 49 to 55 (make cloudflare-bench, in Node). A checkpoint takes the image a mebibyte at a time (snapshotChunks), each chunk made as it is taken out of the documents the stores hold, not a copy of them, and let go once it is in storage: made all at once, the image stood beside the rows, and 50 000 rows of 128 went from 73 to 109 MB at it, 83 now. So an object holds about 60 000 rows of 128 dimensions and 12 000 of 768, however many a request brings; past that, a fenec-server node and Fenec.connect, or a tenant's data spread over more objects.