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
Details

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. 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.

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

interface Env {
  TENANT: DurableObjectNamespace<Tenant>;
}

export class Tenant extends DurableObject<Env> {
  #fenec?: Fenec;

  async #db(): Promise<Fenec> {
    if (!this.#fenec) {
      this.#fenec = await Fenec.open(wasm);       // the module as the Worker imports it
      await restore(this.#fenec, this.ctx.storage);
    }
    return this.#fenec;
  }

  async query(sql: string, params: unknown[]): Promise<unknown> {
    const db = await this.#db();
    const out = db.run(sql, params);
    await persist(db, this.ctx.storage);          // kept before the answer goes
    await this.ctx.storage.setAlarm(Date.now() + 10_000);
    return out;
  }

  async alarm() {
    await checkpoint(await this.#db(), this.ctx.storage);
  }
}

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>;
Details

A Worker imports a .wasm file compiled, and cannot compile bytes at run time; Fenec.open takes the module so. 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.

What the storage holds

persist saves a full image once, then only the new writes. If storage stops part way through, the last whole database is still there.

Details

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 a new image replaces them. Each is cut into pieces under a key-value backed object's 128 KiB a value; a SQLite-backed object takes 2 MB with the key, and {piece} can be set larger there. Every image goes under a generation of its own, and the record naming the whole one is written last, the generation before let go after it: storage that stops part way holds the last whole database. A Durable Object combines a call's puts into one atomic write besides; this does not lean on it, and a test stops the storage after every number of puts, an image's and an append's, and restores the last whole database each time.

A cold start

After an eviction or a deploy, the first request has to load the database again. Run a checkpoint from an alarm once writes stop, and that first query stays fast.

rowsfirst near, as writtenafter a checkpointevery near after
1 00022 ms23 ms2.5 ms
10 000792 ms36 ms2.5 ms
50 0001 455 ms84 ms2.4 ms
Details

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. So a checkpoint — a new image now, the writes since folded into it — once the writes stop, from an alarm, is what keeps a start short. 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) are in the table above.

A request's round trip through wrangler dev is in every figure. The rows went in at 3 000 to 5 700 a second, a request of 1 000 at a time with its vectors linked and kept before the answer.

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.
Details

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. 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.