Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

drogonR

R-hub R-hub check on the R Consortium cluster

High-performance HTTP server for R, powered by the Drogon C++ framework.

drogonR provides a plumber-style API for building REST services and APIs from R, with substantially higher throughput. The Drogon, Trantor and JsonCpp sources are bundled and built statically — no external installation of Drogon is required.

Status: 0.1.8, in development. Builds and passes R CMD check on Linux, Windows, and macOS (CRAN check farm, all green).

Architecture

[Drogon I/O threads]  →  [Lock-free queue]  →  [R main thread]
        ↑                                              ↓
  Accept HTTP                                  Execute R handler
  Parse request                                Build R response
  TLS / HTTP/2                                       ↓
        ←──────────── [Response callback] ←───────────
  • Drogon runs in its own C++ thread; R handlers are dispatched on the main R thread via a thread-safe queue.
  • C++-only routes can bypass the queue entirely.
  • Multi-process scaling is provided via forked workers (each worker has its own R session listening on the same port via SO_REUSEPORT).

Benchmarks

Same workload (GET /ping returning {"ok":true}, and GET /ping-text returning "ok"), four servers running side by side, one at a time, measured with wrk -t4 -c50 -d30s on AMD Ryzen 5 5600 (6 cores). drogonR runs with threads=4, single worker; plumber is single-threaded by design. The four columns are the three drogonR serving paths plus the plumber baseline:

  • cpp-shareddr_get_cpp(), handler is a C function in another R package, R is not in the request hot path.
  • nativedr_app() + dr_get(), handler is an R closure.
  • plumber-shimdrogonR::pr_run(plumber_obj), plumber router served via drogonR's dispatcher.
  • plumber — vanilla plumber::pr_run(), baseline.
drogonR cpp-shared drogonR native drogonR plumber-shim plumber
/ping requests/sec 239 428 116 159 94 400 1 078
/ping avg latency 200 µs 822 µs 591 µs 44.5 ms
/ping-text requests/sec 234 753 218 163 99 276 1 069
/ping-text avg latency 202 µs 252 µs 583 µs 44.9 ms

Two things to read out of this:

  • The cpp-shared path leaves R entirely — its throughput is bounded by Drogon and the kernel, ~240k rps for a trivial handler.
  • Even when an R closure runs per request (native, shim), drogonR is ~90–220× plumber, because the I/O loop is C++ and requests are marshaled onto the main R thread once per dispatch tick instead of per request.

The bench scripts live at tools/bench/run.sh (all four servers) and tools/bench/profile.sh (single-route perf record -g flame). Reproduce with bash tools/bench/run.sh and ROUTE=/ping bash tools/bench/profile.sh. For an in-depth look at the three drogonR variants see vignette("drogonR", package = "drogonR").

Installation

From source (development)

# Once published:
# install.packages("drogonR")

# From a local checkout:
install.packages("/path/to/drogonR", repos = NULL, type = "source")

Build requirements

  • C++17 compiler (GCC ≥ 7, Clang ≥ 5)
  • GNU make
  • (optional) OpenSSL development headers for HTTPS support

The configure script auto-detects OpenSSL via pkg-config and falls back to a plain-HTTP build if it is not found. To force the choice:

R CMD INSTALL --configure-args="--with-openssl"    drogonR
R CMD INSTALL --configure-args="--without-openssl" drogonR

Quick start

library(drogonR)

app <- dr_app() |>
  dr_get("/health", function(req) {
    dr_json(list(status = "ok"))
  }) |>
  dr_get("/users/:id", function(req) {
    # Path parameters: req$params is a named character vector.
    # `:id`, `<id>` and `{id}` are accepted interchangeably.
    dr_json(list(user_id = req$params[["id"]]))
  }) |>
  dr_post("/predict", function(req) {
    body <- dr_body(req, as = "json")
    dr_json(list(prediction = model_predict(body$data)))
  }) |>
  dr_get("/login", function(req) dr_redirect("/auth/sso")) |>
  dr_get("/report.csv", function(req) {
    dr_file("/srv/reports/latest.csv", download_as = "Q3-report.csv")
  })

# Single-process serve.
dr_serve(app, port = 8080L, threads = 4L)

# When done:
dr_stop()

Response helpers

  • dr_text(body)text/plain; charset=utf-8
  • dr_html(body)text/html; charset=utf-8
  • dr_json(x)application/json, with a fast C++ path for the common shapes
  • dr_redirect(location, status = 302L) — sets Location: and an empty body
  • dr_file(path, download_as = NULL) — reads a file, auto-detects the MIME from a built-in table, optionally adds Content-Disposition: attachment

Multi-process workers

For inference-bound APIs, fork N R worker processes that share the listening port via SO_REUSEPORT. Each worker has its own R session, so per-worker state (models, caches) can be loaded once in on_worker_start:

dr_serve(app, port = 8080L, workers = 8L,
         on_worker_start = function() {
           model <<- readRDS("model.rds")
         })

dr_status()   # data frame of worker pids and liveness

dr_stop() SIGTERMs every worker (with SIGKILL fallback after 2s) and reaps them.

Backpressure

Under overload, the request queue between Drogon and R is bounded by max_queue (default 1024). Once full, incoming requests are rejected with 503 Service Unavailable directly from a Drogon I/O thread — no R-side cost — instead of growing memory unboundedly:

dr_serve(app, port = 8080L, max_queue = 256L)

Streaming responses

For Server-Sent-Events feeds, LLM token streams, or any endpoint where the client cares about first-byte time more than last-byte time, return a dr_stream() (or the SSE convenience wrapper dr_stream_sse()) instead of a normal response. The dispatcher opens a chunked response and pumps the generator on the main R thread one chunk at a time. On client disconnect the generator is called once with cancelled = TRUE so it can release per-stream state.

app <- dr_app() |>
  dr_get("/sse", function(req) {
    dr_stream_sse(
      state = list(i = 0L, n = 5L),
      generator = function(state, cancelled) {
        if (cancelled || state$i >= state$n) {
          return(list(data = "", state = state, done = TRUE))
        }
        state$i <- state$i + 1L
        list(data  = sprintf("tick %d", state$i),
             state = state, done = FALSE)
      })
  })

See vignette("streaming", package = "drogonR") for the full API, threading caveats, and cancellation contract.

Rate limiting

Cap how many requests are allowed in a rolling window. The check runs on the I/O thread before R is invoked; over-budget requests get HTTP 429 with a Retry-After header.

app <- dr_app() |>
  dr_get("/health",    function(req) "ok") |>
  dr_get("/api/users", function(req) dr_json(list(...))) |>
  # 100 req / 60 s, per-route, applied to anything under /api/
  dr_rate_limit(capacity = 100L, window = 60, routes = "/api/")

Algorithms: "sliding_window" (default), "fixed_window", "token_bucket". Scope: "per_route" (default — each matched route gets its own bucket) or "global" (one bucket shared across the match set). Per-IP throttling is intentionally out of scope — do that in a reverse proxy. See vignette("rate-limiting", package = "drogonR").

Bandwidth shaping

Where rate limiting caps requests, bandwidth caps bytes. Each connection gets its own token bucket, enforced on the I/O thread as the response is written — it covers every response body, including static files, chunked streams and WebSocket frames.

# 1 MiB/s per connection, allowing a 2 MiB burst from idle.
dr_serve(app, port = 8080,
         bandwidth       = 1024 * 1024,
         bandwidth_burst = 2 * 1024 * 1024)

The limit is per connection, not a server-wide total; bandwidth = 0 (the default) disables shaping entirely and costs nothing. Under TLS it applies to the plaintext, so bytes on the wire exceed it slightly by the size of the TLS record framing.

WebSocket

Register a full-duplex WebSocket endpoint with dr_ws(). Unlike the request/response routes, a connection is long-lived: the server can push to the client at any time with dr_ws_send(), and every frame from the client arrives at on_message. The hooks run on the main R thread, so they may touch R state freely.

app <- dr_app() |>
  dr_ws("/ws/echo",
        on_connect = function(conn) dr_ws_send(conn, "welcome"),
        on_message = function(conn, msg, binary) dr_ws_send(conn, msg),
        on_close   = function(conn) message("gone"))

Broadcast rooms fan a message out to many connections at once, so you don't have to track handles yourself:

app <- dr_app() |>
  dr_ws("/chat",
        on_connect = function(conn) dr_ws_join(conn, "lobby"),
        on_message = function(conn, msg, binary)
          dr_ws_broadcast("lobby", msg))

For a backend that owns the socket and streams from C/C++ (e.g. an LLM emitting tokens), dr_ws_cpp(app, path, package, callable) serves every frame with a compiled handler on Drogon's I/O thread, bypassing R entirely. The handler signature is drogonr_ws_handler_t in inst/include/drogonR.h; its send callback is thread-safe and may be called from a detached backend thread.

dr_ws_cpp() also accepts three optional guards for backends that batch many connections into one decode loop (the way llama-server uses -np N and its slot timeouts):

app <- dr_app() |>
  dr_ws_cpp("/ws/llm", "myllm", "handler",
            max_conns    = 32,   # refuse the 33rd with WS close 1013
            idle_timeout = 60,   # close after 60s with no outgoing frame
            max_lifetime = 600)  # hard ceiling regardless of activity

They are the transport's safety net — a stalled decode thread can't leak connections, and a scheduler can't oversubscribe the KV-cache — so a scheduler layered on this ABI doesn't have to re-implement them itself. All three default to 0 (off).

License

drogonR itself is released under the MIT license.

The package bundles the following third-party libraries, all under the MIT license, with their original copyright notices preserved:

  • Drogon © an-tao and contributors
  • Trantor © an-tao and contributors
  • JsonCpp © Baptiste Lepilleur and contributors

See LICENSE.note for details.

About

❗ This is a read-only mirror of the CRAN R package repository. drogonR — High-Performance HTTP Server for R via 'Drogon'. Homepage: https://github.com/Zabis13/drogonR Report bugs for this package: https://github.com/Zabis13/drogonR/issues

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages