DEV Community

Rowflare
Rowflare

Posted on Originally published at rowflare.com

What load tests changed in our open-source Airtable alternative on Durable Objects

Rowflare is an open-source (AGPL-3.0) Airtable alternative. The whole app is one Cloudflare Worker plus one Durable Object class: every base gets its own Durable Object, and that object owns a private SQLite database with all of the base's tables, fields, views and rows.

browser --HTTP--> Worker (auth, routing, REST API, assets) --RPC--> Base Durable Object (one per base)
   ^                         |                                         |  SQLite: tables, fields, views, rows
   +------ WebSocket --------+-----------------------------------------+  alarm: batch jobs
                             D1: users, workspaces, members, tokens      R2: attachments
Enter fullscreen mode Exit fullscreen mode

The Worker handles sessions, personal access tokens, workspaces and roles (in D1), the REST API, uploads to R2 and the static web app. The Durable Object applies every write, recomputes dependent fields, pushes changes to connected browsers over WebSocket Hibernation, and runs long jobs from its alarm.

Most of the interesting decisions were about how to lay out user data inside that one SQLite database. Several of them went the opposite way from what we first assumed, and only load tests settled them.

1. One physical table per user table

The obvious design is a single rows table with a table_id column. In load tests it made the SQLite query planner pick unrelated indexes, and filters got 2 to 4 times slower. So each user table is its own physical table (t_1, t_2, ...).

2. Rows are JSON documents keyed by field id

Spreadsheet users add, rename and delete columns all the time. Storing each row as a JSON document keyed by field id means those operations only touch metadata: no ALTER TABLE, no locking, no column limit. Deleted fields leave keys behind that are ignored on read.

3. No expression indexes on the row JSON

Expression indexes look like the natural way to make JSON fields filterable. They are rewritten on every write, though, and in our tests they tripled the rows written and made full-column recomputes 3 times slower. We dropped them, and the aggregation engine folds rows in 1,000-row batches inside the Durable Object instead of running GROUP BY over JSON.

4. Links get their own pair tables

A link field is stored twice: in a pair table per link field (l_1(a, b), indexed both ways) and mirrored into both cells as [{ id, t }], where t is the cached title the grid displays. Reverse lookups ("which tasks point at this project?") use the index instead of scanning JSON.

Keeping computed fields correct

Lookups, rollups and formulas are materialized into the row, so reads are free and writes only update what depends on the change. Every write goes through one transaction:

  1. Coerce incoming values to each field's stored form.
  2. For link cells, diff old and new ids, update the pair table and patch the inverse cell on the other side.
  3. Recompute local computed fields in dependency order, then follow incoming links to rows elsewhere whose lookups, rollups or link titles depend on what changed. Formula cycles are rejected when the formula is saved.
  4. Flush changed rows and broadcast them to clients (or a "reload" hint when more than 500 rows changed).

Not blocking edits behind big recomputes

Durable Objects are single-threaded. A new formula on a 100,000-row table can take seconds to compute, and nobody else can edit while it runs. Column-wide work therefore goes into a _jobs table. Tables up to 2,000 rows are processed inside the request; larger tables are processed 500 rows per step from the Durable Object alarm, 4 steps per alarm, so other users' edits slip in between batches. That batching is what keeps a 125,000-row base responsive.

Some numbers

Measured against the real engine with 50,000 rows, 15 fields, 2,000 linked projects, a lookup, two rollups and a formula (local wrangler dev, so indicative only):

Action ms
Open a view, first page 0.46
Edit a cell + rollup update 0.3
Filter by any field 58
Kanban group counts 81
One 500-row recompute batch 3
Paste 1,000 rows and delete them 24

The full table, including the numbers that are still unmeasured on production, is in the architecture docs.

Running it outside Cloudflare

The same Worker code also runs in a single Docker container: a small Node entry point starts it in workerd through Miniflare, with Durable Objects, D1 and R2 stored as local files in one volume. It needs no external database.

docker run -d -p 8787:8787 -v rowflare-data:/data rowflare/rowflare:0.1.0
Enter fullscreen mode Exit fullscreen mode

Links

Disclosure: I'm part of the Rowflare project. Questions about the storage layout or the Durable Object batching are welcome in the comments.

Top comments (1)

Collapse
 
vitayao profile image
Ziwei Yao •

How do you handle a formula being edited again halfway through a column recompute? The architecture doc describes sequence numbers for AI cells; is there a similar generation check for _jobs, or does the next batch pick up the current formula and restart from the beginning? Curious what happens to the rows already processed under the old formula.