May 26, 2022·6 min read·engineeringrqlitebatchingdurability

rqlite adds queued writes with an earlier acknowledgement

rqlite 7.5.0 batches separate write requests through an in-memory queue. The throughput opportunity comes with a precise change to what the initial successful response guarantees.

A client can reduce database overhead by sending several statements together, but independent clients may not have a convenient place to form a shared batch. rqlite 7.5.0, released on May 26, adds queued writes so the server can combine separate requests. The initial response acknowledges admission to an internal queue before the statements are committed to the Raft log.

That acknowledgement changes the application contract. The release documentation explicitly says HTTP 200 does not establish that the SQL statements will apply successfully. It establishes that the request was queued. A caller choosing this mode accepts an interval in which the server has acknowledged work that can still disappear if the receiving node crashes.

I value rqlite's proportionate approach to distributed storage, and an explicit queue is useful when its contract fits the data. The important decision is whether the application can treat queue acceptance as sufficient progress. Reusing the ordinary success status without understanding its narrower meaning would put a durability assumption in the client that the server has deliberately stopped promising at that point.

Separate requests become one batch

The API enables queuing through the queue query parameter on /db/execute. The documentation demonstrates two requests containing three inserts in total. rqlite combines the requests and executes them as a bulk request, saving each client from coordinating with other producers.

For example, this request queues two parameterized inserts into an existing foo table.

curl -XPOST 'localhost:4001/db/execute?queue' \
  -H 'Content-Type: application/json' \
  -d '[["INSERT INTO foo(name) VALUES(?)","fiona"],
       ["INSERT INTO foo(name) VALUES(?)","sinead"]]'

The response includes an empty results array and a monotonically increasing sequence number. It cannot return the normal per-statement execution result immediately because execution has not yet occurred. That shape makes the earlier acknowledgement visible in the response body as well as the documented behavior.

The queue implementation holds incoming statement groups and merges them into a request. It appends their statements and retains associated flush-notification channels. The combined request uses the largest sequence number among its constituent groups. This lets processing report progress through the admitted work.

Batch size and time govern when work leaves the queue

The queue has a maximum capacity, a batch size, and a timeout. Its run loop starts the timer when the first group enters an empty batch. Reaching the configured batch size sends the merged request onward. Expiry of the timer also sends the current contents, allowing low-volume input to proceed without waiting indefinitely for a full batch.

Those controls affect both batching and delay. A larger target batch can combine more independently submitted work when input is frequent. A timeout limits how long a partial batch waits under lower input. The documentation presents the configuration as a performance and durability tradeoff, with launch-time options controlling queue behavior.

An explicit flush path also sends accumulated work. The implementation stops the timer before emitting a batch and clears its accumulated groups. Its comments account for a timer event arriving after a size-triggered batch has already been emitted. Handling those races belongs to the server's queue implementation rather than each producer's application code.

This is the practical attraction of server-side batching. Separate clients can continue submitting logically independent requests while one component owns the rules for combining them. The resulting API still needs to communicate that the server now owns pending work whose persistence follows admission later.

A sequence number tracks queue progress

The queue assigns a sequence number while holding its sequence mutex. A request's number identifies its place in that queue's stream of admitted groups. The documentation says /status includes the latest request sequence successfully written to Raft, allowing a client to compare admission with processing progress.

That is useful information, but it should not be mistaken for the SQL result of each statement. A request can reach the database and contain an invalid statement. The initial empty result array did not promise otherwise, and the queued-write documentation directs operators to logs and diagnostics when investigating failures.

The HTTP service implementation shows the transition. The queue consumer constructs an execute request from the batch and sends it to the local store. If the store reports that it is not leader, the service obtains the leader address and forwards the operation through the cluster client. This lets the admission node differ from the execution leader.

After processing the queued request, the service updates its sequence progress and closes the request's notification channels. The sequence therefore connects the admission path to later processing. Its usefulness depends on interpreting it within that queue's diagnostics, rather than treating it as a universal application-level transaction identifier.

Waiting changes when the response returns

The API supports queue&wait to wait for queued work to persist. The documentation also permits a timeout and gives this form with a ten-second bound.

curl -XPOST 'localhost:4001/db/execute?queue&wait&timeout=10s' \
  -H 'Content-Type: application/json' \
  -d '[["INSERT INTO foo(name) VALUES(?)","bob"]]'

Without an explicit timeout, the documented default is thirty seconds. A timeout causes an error response if the queue has not completed the required progress in time. The caller has chosen to wait longer than simple admission, while retaining a bound on that wait.

The queue's flush channels provide an implementation mechanism for such waiting. Closing a processed request closes the channels associated with its constituent groups. Several admitted requests can therefore share one emitted batch while retain their own notification relationship to its completion.

Waiting does not restore every characteristic of receiving immediate per-statement results. The feature remains a batching queue with its documented response and diagnostics model. Applications needing the result of a particular SQL statement should distinguish that need from merely waiting for the queue to advance.

A batch is not automatically a transaction

The documentation states that queued writes do not execute inside a transaction by default. The launch-time -write-queue-tx option changes that policy. Combining several statements into one execution request and choosing their atomicity are separate decisions.

This matters because batches can contain statements from several incoming requests. A transport-level grouping created for efficiency should not silently establish an application transaction boundary that callers assumed was elsewhere. The explicit option makes transaction behavior an operator choice for the queue.

The service implements this by setting the execute request's transaction field from its configured default queue transaction setting. The queue component itself merges statement groups. It does not infer which statements constitute a business operation or choose atomicity from their SQL contents.

The separation makes the mechanism understandable. Admission records work, batching chooses when and how much to submit, and execution applies the configured transaction policy. The caller's success criteria need to align with the particular stage represented by its response.

The service also permits a queued request with no statements, describing it in the source as a checkpoint. This gives the notification path a purpose independent of inserting another row. A caller can ask the queue to advance through a position without inventing a data mutation solely to mark that position. The mechanism still concerns the queue's processing order. It does not turn the checkpoint into a substitute for inspecting the application state resulting from earlier statements. This distinction is useful when one component produces data and another needs to know when pending queue work has progressed.

Failure can occur between acknowledgement and persistence

The documented loss window exists because the queue is internal pending state rather than an already committed Raft entry. A receiving node can acknowledge admission and crash before that state reaches the replicated log. Tuning batch and timeout settings changes the duration of exposure alongside the amount of work combined.

This differs from an ordinary synchronous request whose caller waits for the distributed write path. The queued API is useful for data whose producer can accept or reconcile that earlier acknowledgement. Data requiring durable acceptance before the caller proceeds needs a response contract that reaches the required stage.

The release's changelog identifies queued writes as a new higher-performance option and adds support for them to the project's benchmarking tool. That establishes the intended optimization without supplying a universal throughput figure. The benefit depends on the request stream and chosen queue configuration.

I consider the explicit contract the feature's most useful part. Producers can ask rqlite to form batches while choose whether to wait for progress beyond admission. The server owns the queue mechanics, but applications still decide whether acknowledgement before persistence is acceptable for the operation they are submitting.