Jordi Villar

Building a Serverless ClickHouse

Splitting compute and storage with plain_rewritable

Building a managed ClickHouse offering has always been hard. On top of the day-to-day problems of running a complex technology at scale, the company behind it runs its own cloud, and some pieces only exist there.

One of the biggest changes I’ve seen in ClickHouse is SharedMergeTree, a table engine that stores data in S3 and keeps its metadata in ClickHouse Keeper, which gives the compute and storage separation many users were asking for. It’s only available in ClickHouse Cloud. In the open source version the closest thing was zero-copy replication, which never became production ready and was later deprecated and removed.

Everything seemed lost, but then a new kind of disk appeared in ClickHouse: plain_rewritable, which stores both metadata and data directly in S3. It came with a lot of caveats1, but it opened the door to compute and storage separation in the open source version.

At the same time, working at Tinybird, I always had the feeling that while we made ClickHouse much easier to use, people saw us as one more API tool and not as a real database.

So I tried to imagine the simplest possible way to put together a managed ClickHouse offering under these three premises:

  • Compute and storage separation using S3 on plain_rewritable disks
  • An interface that feels like a database in the cloud: one endpoint, the native protocol, and the client you already use
  • Trivial to start. No need to sign up for anything before trying it2

This might look like a pitch, and ClickHamlet is a real service you can try, but this is a technical post about how I built everything behind it.

The Idea

The central idea is a router that looks like a ClickHouse server from the outside. You connect with the client you already use, and behind it there are regular ClickHouse servers that only run while there is work to do. None of them own data: every table lives in S3 on a plain_rewritable disk, so any server can open it. That gives a few rules:

  • A service that speaks the native protocol, so any ClickHouse client works
  • Compute that starts on demand and stops when idle, so an unused database costs as little as its storage
  • A single writer per service, because plain_rewritable can’t handle more yet
  • Readers to scale reads horizontally (for now, one reader per service)

The first piece I built was the router, and the first thing to prove was compatibility: if clickhouse client doesn’t believe it’s talking to a real ClickHouse server, nothing else matters. The protocol looks complex, but you don’t have to write it: linking against ClickHouse’s own dbms library gives you the protocol handling and the SQL parser. So the first version was quite simple and did almost nothing: answer the handshake, accept any user and password, parse the query, and return a one-row table with its type.3

HANDSHAKEQUERYMORE QUERIES,THE SAME WAYclickhouse clientSERVERHelloNAME, VERSION, REVISION, DATABASE,USER, PASSWORDANY USER AND PASSWORDWILL DOHelloNAME, VERSION, REVISION, TIME ZONEQUOTA KEYQueryID, SETTINGS, COMPRESSION, SQL TEXTPARSES THE SQLData: ONE ROW, ITS TYPEtypeSelectEndOfStream… OR, IF THE SQL DOESN’T PARSE:ExceptionPingPongBETWEEN QUERIES,KEEPS IT ALIVEAND SO ON, UNTIL THE CLIENT CLOSES
The first server accepts any user and password, parses each query, and answers with its type, or with an exception if the SQL doesn’t parse.
❯ clickhouse --port 9001
ClickHouse client version 26.9.19.9 (official build).

hello :) SELECT 1

Query id: d92c18b7-fae3-410c-836f-e665d0095ae1

   ┌─type───┐
1. │ Select │
   └────────┘

1 row in set. Elapsed: 0.000 sec.

hello :) EXPLAIN SELECT 1

Query id: f37c15a5-a3d6-413e-b4a5-a0e8d8183cb6

   ┌─type────┐
1. │ Explain │
   └─────────┘

1 row in set. Elapsed: 0.000 sec.

hello :) Bye.

It’s simple, but that one column is the key to everything else. Once the router knows the query type, it knows whether a reader or the writer should run it.

Routing a Query

You could argue whether we need a module written in C++ sitting on top of ClickHouse’s codebase just to extract the query type and forward it to the right backend. In theory the job is easy and detecting if a query is a select or an insert shouldn’t be that difficult to do in any language. But there are a few reasons why I decided to implement it this way.

  • You don’t only need to parse the query and decide the type. You also have to speak the ClickHouse native protocol, with all its quirks
  • Parsing the entire query and extracting the full AST also enables smarter routing. For example, you can extract the affected tables and use this information to route to one backend or another
  • Some queries (like SELECT 1 or SELECT version()) can be answered without needing a backend
  • We have to keep some session state in the router when a query comes with USE or SET statements, since the next query may go to a different backend
  • Using ClickHouse’s own parser means new syntax works out of the box, with the exact same implementation
  • Parsing a query can get expensive. Once you have the AST, you could skip parsing it again on the backend4

So the router is in C++. The other piece is what I’ve called the controller, where the business logic lives. It can be written in any language as long as the hot paths are fast enough (more on this later). Its job:

  • Authenticate and authorize calls
  • Start and stop backends when needed
  • Return the backend, and the settings to mount the required tables

A basic block diagram:

CLIENTNATIVE PROTOCOLROUTER · ClickHamletAST TO KIND + TABLESRELAYS RESULTS BACKBACKENDSCONTROLLER (APP)SERVICES, TABLE REGISTRY,BACKENDSSELECTINSERT, DDLREADERclickhouse-serverIDLE STOPWRITERclickhouse-serverIDLE STOP + DRAINREADS ONLYWRITES PARTSS3: TABLE DATA AND METADATA (.sql FILES)
Clients only see the router. Reads go to a reader, writes to the writer, and both read the same tables from S3.

And this is the flow of a warm query, when the backend is already running:

CLIENTROUTERCONTROLLERBACKEND (WRITER)QUERY, TLS ON 9440PARSE, PICK WRITER OR READERPOST /backend, KEPT CONNECTIONPASSWORD: ONE SQLite READSERVICE LOCK: WRITER RUNSMARK IT USEDABOUT 0.2 ms IN ALLHOST, PORT, PASSWORDPOOLED CONNECTION, THE QUERYRESULT BLOCKSRELAYED AS THEY COMERETURNS IT TO THE POOL
A query to a running backend costs one call to the controller, about 0.2 ms.

The hard part is deciding where every query goes and making sure there’s a single writer per service at any time.

Readers and Writers

Every service gets a writer and, optionally, a reader. Both are regular ClickHouse servers started from the official image. The only difference between them is how they see the service’s bucket.

The writer owns the bucket. Its tables and its databases’ metadata live on a plain_rewritable disk that covers the whole bucket, so from the writer’s point of view it’s just a server with an S3 disk. Every INSERT, every DDL statement, and every SHOW or query on system tables goes there, since it’s the only backend that knows about every table.

The router doesn’t try to be clever on this path. It sends the original query text, not the AST it parsed5, together with the session’s settings, and relays everything back to the client as the backend writes it. An INSERT streaming a big file, a long query reporting progress, or a Cancel in the middle of either, all behave the same as against a real server. Something I didn’t expect is that the router has to decode every block it relays, because the client and the backend each negotiate their own protocol revision and compression. So it has to understand every type it might see, AggregateFunction columns included, which means linking ClickHouse’s aggregate functions too. One more reason to build on top of ClickHouse’s code.

Readers start empty. They have no storage configured and no idea which tables exist. The controller keeps a registry of the writer’s tables (name, UUID, location in the bucket and the CREATE statement), updated after every DDL and every time the writer starts. When a query goes to a reader, the controller returns the tables it touches that the reader hasn’t attached yet, and the router turns each CREATE into an ATTACH before running the query:

ATTACH TABLE IF NOT EXISTS default.events UUID '…'
(
    `id` UInt64,
    `at` DateTime
)
ENGINE = MergeTree
ORDER BY id
SETTINGS disk = disk(type = object_storage, object_storage_type = s3,
    endpoint = 'https://<service bucket>/', include = 'bucket_key',
    metadata_type = plain_rewritable, readonly = 1),
    refresh_parts_interval = 5

The table keeps the writer’s UUID, so the reader finds its data, and the disk is defined inline and read-only, so a reader can mount tables from anywhere without any configuration of its own. The bucket’s credentials never show up in SQL: include = 'bucket_key' reads them from the reader’s config file.

refresh_parts_interval is what keeps readers up to date: every 5 seconds the reader lists the table’s location and picks up new parts. This is the main trade-off. A reader can be up to 5 seconds behind the writer, even in the same session that just inserted the data. I started with 2 seconds, but every refresh is a LIST, and S3 bills a LIST like a PUT. It can get expensive.

Then there’s THE rule: a single writer. plain_rewritable has no coordination between servers, so two writers on the same bucket could corrupt all the data in a few seconds. The simplest way to enforce it is one writer per service, not per table.6

I never built a lease for it, because Kubernetes already has one: a pod’s name. Writer pods have a fixed name per service, Kubernetes only allows one pod with a given name, and a deleted pod keeps its name until the server is dead, unless you force the deletion, which the controller never does. So the controller refuses to start a writer while the previous pod still exists. The trade-off here is availability: if a node goes silent, its services can’t get a new writer until the node comes back or is removed from the cluster.7

Inside the controller, there’s also a lock per service. Every request for a backend takes it, so two queries arriving at the same time can’t both decide to start a writer.

Starting and Stopping

All of this assumes the backend is already running. When it isn’t, the first query pays the price for starting it. The controller creates the pod, waits for it to be ready and reachable, registers the required tables, and then returns its address. That takes a few seconds.

The whole start runs under the service’s lock. For the client, it’s just a slow first query: the router waits up to 300 seconds for the controller, as long as clickhouse client waits for a server.

CLIENTROUTERCONTROLLERAWSKUBERNETESBACKEND PODQUERYPOST /backendBUCKET, IAM KEYFIRST START ONLYROOM ON A NODE?SECRET, SERVICE, PODSTART ClickHouseNEXT QUERY WAITSWAIT: POD READY, THEN ITS NODE PORTREGISTRY, FLUSH LOGS, query_logITS ADDRESSTHE QUERYRESULTSRESULTS
A cold start holds the service’s lock (the teal bar) until the backend is ready, so other queries wait for it instead of starting a second one.

Backends stop on their own too. Every minute the controller checks in-flight queries: a backend running a query is never idle. Otherwise its idle time counts from the last query the router sent it. By default readers and writers stop after 5 minutes without activity8.

Readers can just be stopped: they can’t lose anything other than in-flight queries, and we already checked there are none. Writers need to drain first: the controller stops their merges and waits until there are no merges, mutations, moves or asynchronous inserts left, up to 5 minutes by default. If a query arrives in the meantime, merges get started again, the drain gets cancelled and the writer keeps serving.

READERS: IDLE TIMEOUTQUERY ARRIVESSTOPPEDNO SERVER RUNNINGQUERYSTARTINGWRITER WAITS FOR ITS OLD POD, STARTS SERVER,MOUNTS TABLESREADYSERVINGQUERIES ROUTED HERE, ROUTER TRACKS LAST QUERYIDLE TIMEOUTDRAINING (WRITER ONLY)SYSTEM STOP MERGES,WAITS FOR MERGES AND MUTATIONSDRAINEDSTOPPINGSHUTS DOWN THE SERVER, DELETES THE POD
Backends start on the first query that needs them and stop after 5 idle minutes by default. Only writers drain first.

Services and the Controller

A service is just a user. The first time someone connects with a new user name, the controller creates a service with that name and password. There’s no sign-up form or API key. One clickhouse client command and you have a database. The obvious downside is that a typo in the user name creates a new service instead of failing. I can live with that.

❯ clickhouse client --host cloud.clickhamlet.com --secure --user acme --password s3cret
Warnings:
 * Service created with name: acme
 * Open your dashboard at https://cloud.clickhamlet.com/dashboard/… and sign in before … to keep this service, or it will be deleted

That warning is how claiming works. Until someone owns the service, every login prints a claim link. The first person to open it and sign in becomes the owner, and from then on logins print the link to the service’s dashboard instead. Unclaimed services are deleted after two days.

The controller is a single Python process. It knows about services, their passwords and settings, the table registry, and which backends are running.

Every service is isolated from the others. Each one has its own writer and reader, its own S3 bucket, and its own IAM user that can only touch that bucket. The user is created under a permissions boundary, so not even a compromised controller could give it more access. The key never appears on a command line, in SQL or in the environment: backends get it in a config file for their disks. A backend has no credentials of its own, so a service’s s3(...) calls can’t sign requests with the bucket’s key. And backends only accept connections from the router, with a password generated per service.

The rest is enforced by ClickHouse itself. With table_engines_require_grant, a service can only create MergeTree-family tables, views and materialized views, so every table lives in the bucket and nothing ends up on a backend’s local disk. file, url and remote are revoked too, since url and remote could reach the host’s other processes or other services’ backends.

Making It Fast

Once everything worked end to end, I wanted to know how much it cost to route a query. As usual, the answer was too much. The first benchmark, 16 clickhouse client sessions running 300 SELECT count() each through the router, did 451 queries a second.

After a handful of changes, the same benchmark does 2713 queries a second, 6x the baseline, and the limit is now the test service’s writer, not the routing.

QUERIES A SECOND · 451 → 2713, 6.0× THE BASELINEBASELINE6ae0bb9Baseline (6ae0bb9): 451 queries a second; runs 437, 451, 453451 · 1.0×NO POD READ PER REQUEST34612deNo pod read per request (34612de): 944 queries a second; runs 935, 944, 951944 · 2.1×SERVICE ROW READ ONCE35e59b3Service row read once (35e59b3): 1055 queries a second; runs 1051, 1055, 10571055 · 2.3×TCP_NODELAY ON THE CONTROLLERd857c63Tcp_nodelay on the controller (d857c63): 1047 queries a second; runs 1045, 1047, 10561047 · 2.3×ROUTER KEEPS ITS CONNECTIONS04adf05Router keeps its connections (04adf05): 1111 queries a second; runs 1106, 1111, 11151111 · 2.5×LOCKS, PACKED PARTS, REFRESHefeb6c4 to 7484cbfLocks, packed parts, refresh (efeb6c4 to 7484cbf): 1201 queries a second; runs 1171, 1201, 12041201 · 2.7×PLAIN API ROUTES91427bbPlain api routes (91427bb): 2200 queries a second; runs 1898, 2200, 22142200 · 4.9×POOLED BACKEND CONNECTIONS1fb7b27, with f45ff23Pooled backend connections (1fb7b27, with f45ff23): 2713 queries a second; runs 2696, 2713, 27662713 · 6.0×BASELINE 451
Queries a second through the router after each change, median of 3 runs on one machine. The last bars are capped by the test service’s writer.

Throughput alone hides where the time went, because the last bars are capped by the writer. CPU per query by component is more useful: the controller went from 2.34 ms to 0.30 ms.

CPU PER QUERY · 3.80 → 1.35 ms, THE CONTROLLER 2.34 → 0.30 msCONTROLLERROUTERWRITERBASELINE6ae0bb9Baseline (6ae0bb9), Controller: 2.34 ms of CPU per queryBaseline (6ae0bb9), Router: 0.53 ms of CPU per queryBaseline (6ae0bb9), Writer: 0.93 ms of CPU per query3.80 msNO POD READ PER REQUEST34612deNo pod read per request (34612de), Controller: 1.19 ms of CPU per queryNo pod read per request (34612de), Router: 0.48 ms of CPU per queryNo pod read per request (34612de), Writer: 0.76 ms of CPU per query2.43 msSERVICE ROW READ ONCE35e59b3Service row read once (35e59b3), Controller: 1.00 ms of CPU per queryService row read once (35e59b3), Router: 0.49 ms of CPU per queryService row read once (35e59b3), Writer: 0.79 ms of CPU per query2.28 msTCP_NODELAY ON THE CONTROLLERd857c63Tcp_nodelay on the controller (d857c63), Controller: 1.01 ms of CPU per queryTcp_nodelay on the controller (d857c63), Router: 0.49 ms of CPU per queryTcp_nodelay on the controller (d857c63), Writer: 0.78 ms of CPU per query2.28 msROUTER KEEPS ITS CONNECTIONS04adf05Router keeps its connections (04adf05), Controller: 0.97 ms of CPU per queryRouter keeps its connections (04adf05), Router: 0.44 ms of CPU per queryRouter keeps its connections (04adf05), Writer: 0.77 ms of CPU per query2.18 msLOCKS, PACKED PARTS, REFRESHefeb6c4 to 7484cbfLocks, packed parts, refresh (efeb6c4 to 7484cbf), Controller: 0.99 ms of CPU per queryLocks, packed parts, refresh (efeb6c4 to 7484cbf), Router: 0.46 ms of CPU per queryLocks, packed parts, refresh (efeb6c4 to 7484cbf), Writer: 0.84 ms of CPU per query2.29 msPLAIN API ROUTES91427bbPlain api routes (91427bb), Controller: 0.52 ms of CPU per queryPlain api routes (91427bb), Router: 0.47 ms of CPU per queryPlain api routes (91427bb), Writer: 0.86 ms of CPU per query1.85 msONE SHARED READ CONNECTIONf45ff23One shared read connection (f45ff23), Controller: 0.30 ms of CPU per queryOne shared read connection (f45ff23), Router: 0.48 ms of CPU per queryOne shared read connection (f45ff23), Writer: 0.87 ms of CPU per query1.65 msPOOLED BACKEND CONNECTIONS1fb7b27Pooled backend connections (1fb7b27), Controller: 0.30 ms of CPU per queryPooled backend connections (1fb7b27), Router: 0.36 ms of CPU per queryPooled backend connections (1fb7b27), Writer: 0.69 ms of CPU per query1.35 ms
CPU per query by component after each change. The controller went from 2.34 ms to 0.30 ms.

Most of the work was in the controller, and most of it was removing things it did on every call without needing to:

  • Each /backend call read every running backend’s pod from the Kubernetes API, to check it was alive. The read took about 1 ms, but turning the whole pod into Python objects to look at one field was 40% of the call, under the service’s lock. Now only the checks every minute read pods, and a crashed backend is noticed by the first query that fails.
  • The service row was read from SQLite several times per call. Now it’s read once and passed along.
  • The router opened a TCP connection to the controller for every query, and accepting and closing them was 13% of the controller’s time. Keeping them open only paid off after a TCP_NODELAY fix.
  • Under load, the controller saturated at one core of Python, and FastAPI’s overhead was most of each call. The four routes the router calls are now plain routes, while the dashboards stay on FastAPI. That change alone took the benchmark from 1201 to 2200 queries a second.
  • Every thread had its own SQLite connection, so reads would never wait for writes. But Python releases the GIL inside each SQLite call and takes it back after, so 16 threads reading at once spent their time handing it to each other. It looked like a SQLite problem, but it was the GIL. One shared read connection behind a lock fixed it.

The last change was in the router, which opened a new connection to the backend for every query. Besides the CPU, every closed connection kept a port in TIME_WAIT for a minute, so past about 470 queries a second to one backend the router ran out of ports and queries failed with “Cannot assign requested address”. That explained a few dropped connections I had seen and never understood. Now the router pools its backend connections, shared by every session.

This is where a single /backend call spends its time, before and after:

PER /backend CALL · 3.31 → 1.26 → 0.76 → 0.51 msBASELINE (6ae0bb9)04adf0591427bbf45ff23KUBERNETES POD READKubernetes pod read, baseline (6ae0bb9): 1.31 ms per call1.31 msKubernetes pod read, 04adf05: 0.004 ms per call0.004 msKubernetes pod read, 91427bb: 0 ms per call0 msKubernetes pod read, f45ff23: 0 ms per call0 msWEB FRAMEWORK AND HTTPWeb framework and http, baseline (6ae0bb9): 1.70 ms per call1.70 msWeb framework and http, 04adf05: 1.11 ms per call1.11 msWeb framework and http, 91427bb: 0.37 ms per call0.37 msWeb framework and http, f45ff23: 0.40 ms per call0.40 msSQLite (STORE)Sqlite (store), baseline (6ae0bb9): 0.17 ms per call0.17 msSqlite (store), 04adf05: 0.11 ms per call0.11 msSqlite (store), 91427bb: 0.34 ms per call0.34 msSqlite (store), f45ff23: 0.07 ms per call0.07 msCONTROLLER LOGICController logic, baseline (6ae0bb9): 0.13 ms per call0.13 msController logic, 04adf05: 0.04 ms per call0.04 msController logic, 91427bb: 0.05 ms per call0.05 msController logic, f45ff23: 0.04 ms per call0.04 ms
Where one /backend call spends its time, from py-spy, which roughly doubles each call (about 0.2 ms without it). The pod read and the SQLite contention are gone, and the framework is a quarter of what it was.

Some of the work was about cost, not speed. ClickHouse writes about a dozen objects for every small part, and deletes them one by one after a merge. The internal service that records usage, inserting about 400 rows an hour, was making 5500 PUT-class requests an hour. Packing small parts into a single object (min_bytes_for_full_part_storage, the same approach ClickHouse Cloud uses) cut that by 69%.

Running It

Everything started running on a single EC2 instance. The router, the controller and Caddy ran as systemd units, and the backends ran as pods in k3s on the same machine. The only thing that left the instance was data: each service’s bucket, and the controller’s SQLite database, streamed to S3 by Litestream.

Kubernetes on a single machine sounds like overkill, but the backend layer was already turning into a small Kubernetes: limits, admission, crash detection and addressing. Before it, the controller started clickhouse-server processes itself and was growing code for cgroups and free ports. Moving to pods deleted all of that, and it turned the next step into mostly a configuration change.

ONE EC2 INSTANCE · cloud.clickhamlet.comS3 · eu-west-3NATIVE CLIENTSclickhouse client ‑‑secureBROWSERSA SERVICE’S DASHBOARD LINKROUTER9440: TLS, PUBLIC9000: PLAIN, LOCALHOST ONLYCERTIFICATE FROM CADDYCONTROLLERAPI ON localhost:8180DASHBOARD ON localhost:8181STATE IN controller.dbCADDY443: THE DASHBOARD80: CERTIFICATE CHALLENGERENEWS THE CERTIFICATEk3s · A WRITER POD PER SERVICEacme · writerglobex · writerinitech · writerONE BUCKET PER SERVICE, OPENED ONLY WITH ITS OWN KEYScontroller.db backupQUERIESSTARTS, STOPSplain_rewritable DISKLITESTREAM
Phase 1 ran everything on one EC2 instance. Only data left it, to S3.

That next step was moving only the backends to a managed cluster (EKS) next to the instance. The router and the controller stay where they are and keep reaching backends through node ports. The controller just got a kubeconfig for a different cluster. What changes is that backends are no longer capped by one machine: the node group grows when a backend doesn’t fit, and can shrink to zero.

EKS CLUSTERBACKEND NODE GROUPGROWS AND SHRINKSEC2 INSTANCEUNCHANGED, systemd UNITSBROWSERSDASHBOARDS, 443CLIENTSclickhouse client, 9440CADDYDASHBOARD, HTTPSROUTERTLS ON 9440CONTROLLERSTARTS, STOPS AND CHECKS BACKENDSSTATE IN controller.dbLITESTREAMSTREAMS controller.db TO S3NODENODEacme · writeracme · readerglobex · writerONLY THE INSTANCE MAY CONNECTKUBERNETES APIEKS; THE CONTROLLER SIGNS IN AS ITS IAM USERSTATE BUCKETcontroller.dbSERVICE BUCKETSS3, ONE PER SERVICE, EACH WITH ITS OWN KEY
Only the backends moved to EKS. The router and controller stay on the instance and reach them through node ports.

Every backend runs on spot instances, writers included, with on-demand only as a fallback. A Graviton r7g.xlarge (4 vCPU, 32 GiB) costs $0.25 an hour on demand and around $0.10 on spot. Backends are a good fit: they hold no data, and a reclaimed node looks like a crash. AWS gives two minutes’ notice, the node is drained, running queries fail, and the next query starts the backend on another node. Acknowledged inserts and finished merges are already in S3. The single-writer rule still holds, because the replacement only starts once the old pod, and its name, are gone. That’s also why spot needs a proper drain: a node that just disappears keeps its pods, and their services can’t start a new writer until the node is removed from the cluster.

Phase 2 is only a plan, for when one instance isn’t enough for the router and the controller themselves: a load balancer in front of several routers that send queries straight to backends, and a controller that places backends on hosts and grants each writer a real lease. None of it exists yet.

NATIVE CLIENTSclickhouse client ‑‑secureBROWSERSA SERVICE’S DASHBOARD LINKNETWORK LOAD BALANCERTCP PASS-THROUGH ON 9440ROUTER INSTANCESTLS ON 9440, ENDPOINT CACHErouter 1router 2CONTROLLER INSTANCECONTROLLERPLACES BACKENDS ON HOSTSGRANTS WRITER LEASESSCALES READERS ON LOADcontroller.db, LITESTREAMCADDY443: THE DASHBOARDRENEWS THE CERTIFICATEBACKEND HOSTS · A POOL, MANY BACKENDS ON EACHHOST 1AGENT: STARTS, STOPS, REPORTSacme · writer, leasedglobex · readerHOST 2AGENT: STARTS, STOPS, REPORTSglobex · writer, leasedacme · readerHOST 3AGENT: STARTS, STOPS, REPORTSinitech · writer, leasedinitech · readerS3 · eu-west-3ONE BUCKET PER SERVICE, OPENED ONLY WITH ITS OWN KEYSQUERIES, STRAIGHT TO BACKENDSSTART, STOP, LEASESplain_rewritable DISKS
Phase 2, still a plan: several routers behind a load balancer, and a controller that places backends on hosts and grants each writer a lease.

Pricing is simple. Free services have a 10 GiB storage cap and share resources. Paid accounts pay for what they use: $0.2181 an hour per 8 GiB of running server, drain included, and about $0.025 per GiB stored a month. The controller records usage every minute in an internal service (dogfooding baby!), and an hourly job reports it to Stripe.

Where It Is Now

It works! You can point clickhouse client at it, create tables, insert and query, and pay nothing while you’re not using it. It’s still small on purpose: about 1400 lines of C++ for the router and 2800 of Python for the controller.

Building on ClickHouse instead of around it gives you most of a database service for free. The protocol, the parser, settings, sessions, access control and the storage itself are all ClickHouse’s code. ClickHamlet mostly decides where a query goes and when a server should be running.

plain_rewritable still has important limits. One writer per bucket is the big one, and these disks also reject schema changes like ALTER ... ADD COLUMN and mutations. ClickHouse 26.10 can lift the second with enable_hard_links, off by default and not used by ClickHamlet yet, and the first has work in progress upstream.

What’s still open:

  • More than one writer, once ClickHouse supports it, or at least one writer per table instead of per service.
  • Schema changes and mutations, with readers re-attaching tables whose definition changed.
  • Autoscaling.
  • Faster cold starts, now that backends run on an autoscaled cluster, where a start may wait for a new node.
  • A real writer lease, once there’s more than one controller.

If you want to try it, all you need is clickhouse client:

clickhouse client --host cloud.clickhamlet.com --secure --user <service> --password <password>

Footnotes

  1. The main blocker was (and still is) not being able to have multiple writers into a single table. There is a work in progress PR to solve this. ↩

  2. Let’s make bots (and humans) happy: an agent can create a database without anyone signing up. ↩

  3. There’s a working example you can build and run in this repo. ↩

  4. They even have a dialect for this: the 26.8 release post introduces a JSON AST dialect. ↩

  5. See footnote 4. There are plans to improve this, but the current version is as simple as it can be. ↩

  6. We can later make things more complex and increase granularity to table level. ↩

  7. The controller pings every backend every minute and replaces the ones that stop answering, but a node that goes silent is still a failure point in the setup. ↩

  8. On paid plans this is configurable: anything from 5 minutes to a day, or keep your writer always running. ↩