Skip to content

feat: add --standalone mode (single node, no Raft)#2

Open
alnr wants to merge 2 commits into
v31from
alnr/standalone
Open

feat: add --standalone mode (single node, no Raft)#2
alnr wants to merge 2 commits into
v31from
alnr/standalone

Conversation

@alnr

@alnr alnr commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

In --standalone, writes bypass Raft entirely: ReplicationState::write
applies the request directly on a thread pool, serialized per collection
by a stripe mutex (different collections run in parallel), instead of
node->apply -> single on_apply thread -> BatchedIndexer. This removes the
single-threaded Raft commit/apply pipeline that caps write throughput,
and also sidesteps the BatchedIndexer RocksDB request round-trip.

  • config: --standalone / TYPESENSE_STANDALONE / ini server.standalone
  • durability: with no Raft log to act as the write-ahead log, the
    document store opens with the RocksDB WAL enabled. Writes are recovered
    on the next DB open and replayed into the in-memory index by the
    existing, Raft-independent CollectionManager::load() path, so a process
    restart rebuilds the index from disk. Note: durability only covers
    persisted fields; a store:false field is not written to disk and will
    not survive a restart.
  • start_standalone(): load collections from the durable store and mark
    the node ready/caught-up/leader; no brpc peering server is started
  • is_leader()/node_state() report a healthy leader so reads, /health and
    the leader-only paths (analytics, conversations) work unchanged;
    on-demand snapshots/vote/peer operations are rejected (node is null)
  • chunked (async_req) write bodies aggregate in request->body and the
    handler runs once with the complete body; non-final chunks send a
    request-proceed message to the owning event loop so streamed uploads
    keep flowing, and gzip bodies are inflated in one call once complete
  • writes are striped by BatchedIndexer::get_collection_name, so a
    collection's creation and subsequent writes to it serialize in order
    while writes to different collections run in parallel; unlike the Raft
    path there is no cross-request ordering for referenced collections
  • harden the boot path: _populate_referenced_ins parsed each
    collection-meta row with the throwing nlohmann::json::parse, so a
    single unparseable row terminated the server on boot (the
    is_discarded() guard below it was dead code). Use the non-throwing
    overload so a bad row is logged and skipped, matching the main load
    loop's fail-closed behavior.
  • single node only: no replication or consensus in this mode

alnr added 2 commits July 15, 2026 19:25
Run min(thread-pool-size, cores) h2o event loops instead of one. Each loop
has its own SO_REUSEPORT listener (the kernel load-balances accepts), h2o
context, message dispatcher and SSL refresh; loop 0 runs on the run()
thread. A connection is owned by exactly one loop: it is accepted, read
and written only there.

Every request is tagged with its owning loop's dispatcher
(http_req::res_dispatcher), and all response/request-proceed/deferral
messages are delivered through it via the new helpers
HttpServer::deliver_stream_response / deliver_request_proceed /
deliver_defer_processing. For requests with no owning loop (writes
replayed from the log, tests) the helpers mirror the dead-request paths
of the corresponding message handlers.

The global dispatcher access paths are deleted -- HttpServer::
get_message_dispatcher(), the unused HttpServer::send_message() wrapper
and ReplicationState's stashed message_dispatcher -- so no delivery site
can bypass the owning loop. That makes multiple loops safe in Raft mode
too: the BatchedIndexer apply path, follower->leader forwarding
(HttpClient), the SSE proxy and all write-rejection paths now deliver on
the request's own loop.

Deferred-processing timers are linked on the owning loop (the dispatcher
carries its h2o loop), so streamed responses (e.g. /documents/export) no
longer stall under backpressure when the request belongs to a loop other
than loop 0.

Removes the single-event-loop ceiling on HTTP intake and response I/O.

Three adjustments follow from requests now being minted on N threads:
http_req::start_ts, which doubles as the unique write-request ID in the
BatchedIndexer (request map and write-log chunk keys), comes from a global
atomic monotonic counter since wall-clock microseconds are no longer unique
across accept threads; deliver_defer_processing drives dispatcher-less
(replayed) write continuations directly on the worker pool, so chunked
handlers such as batched deletes still apply fully on followers and during
log replay; and the access log's ofstream is guarded by a mutex now that
requests are logged from multiple loops.
In --standalone, writes bypass Raft entirely: ReplicationState::write
applies the request directly on a thread pool, serialized per collection
by a stripe mutex (different collections run in parallel), instead of
node->apply -> single on_apply thread -> BatchedIndexer. This removes the
single-threaded Raft commit/apply pipeline that caps write throughput,
and also sidesteps the BatchedIndexer RocksDB request round-trip.

- config: --standalone / TYPESENSE_STANDALONE / ini server.standalone
- durability: with no Raft log to act as the write-ahead log, the
  document store opens with the RocksDB WAL enabled. Writes are recovered
  on the next DB open and replayed into the in-memory index by the
  existing, Raft-independent CollectionManager::load() path, so a process
  restart rebuilds the index from disk. Note: durability only covers
  persisted fields; a store:false field is not written to disk and will
  not survive a restart.
- start_standalone(): load collections from the durable store and mark
  the node ready/caught-up/leader; no brpc peering server is started
- is_leader()/node_state() report a healthy leader so reads, /health and
  the leader-only paths (analytics, conversations) work unchanged;
  on-demand snapshots/vote/peer operations are rejected (node is null)
- chunked (async_req) write bodies aggregate in request->body and the
  handler runs once with the complete body; non-final chunks send a
  request-proceed message to the owning event loop so streamed uploads
  keep flowing, and gzip bodies are inflated in one call once complete
- writes are striped by BatchedIndexer::get_collection_name, so a
  collection's creation and subsequent writes to it serialize in order
  while writes to different collections run in parallel; unlike the Raft
  path there is no cross-request ordering for referenced collections
- harden the boot path: _populate_referenced_ins parsed each
  collection-meta row with the throwing nlohmann::json::parse, so a
  single unparseable row terminated the server on boot (the
  is_discarded() guard below it was dead code). Use the non-throwing
  overload so a bad row is logged and skipped, matching the main load
  loop's fail-closed behavior.
- single node only: no replication or consensus in this mode
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant