shinyctl

How it works

One binary runs as three processes. A cycle in the middle one keeps every app's copies where they should be.

Three processes

  • The shinyctl command reads a manifest and asks shinyctl-api to act, over the socket ~/.shinyctl/api.sock. The first apply starts shinyctl-api if it isn't running.
  • shinyctl-api is a background process, one per user. It owns every app's state, starts the copies, restarts them, and tells the load balancer where they are.
  • The load balancer, a process of its own, listens on every app's port and sends each request to one of that app's copies. It keeps the list of copies in memory, and shinyctl-api replaces that list without a reload, so no connection is dropped when it changes.

The apps and the load balancer keep running if shinyctl-api stops. A new shinyctl-api adopts them, with the same process IDs.

How shinyctl is put together Visitors reach the load balancer on the app's port, which sends each request to one of the app's R copies. You talk to shinyctl-api over a socket; it records state in state.json, pushes the copies table to the load balancer, and starts and stops the copies. Visitors browsers, on :8765 requests Load balancer one public port per app sticky cookie lb_8765 · WebSockets R copies · 127.0.0.1:20000–29999 copy 3 active takes new visitors copy 4 warm ready spare, takes over at once copy 2 draining keeps its visitors, then stops shinyctl-api runs the cycle every 5 s and on every request or exit copies table, swapped in memory starts, stops, watches exits You apply · status · restart api.sock records state.json processes, start times, crash counts How shinyctl is put together Visitors reach the load balancer, which sends each request to one of the app's R copies. You talk to shinyctl-api, which records state in state.json, pushes the copies table to the load balancer, and starts and stops the copies. Visitors browsers, on :8765 requests Load balancer one public port per app sticky cookie lb_8765 · WebSockets to one copy R copies · 127.0.0.1:20000–29999 copy 3 active takes new visitors copy 4 warm ready spare copy 2 draining keeps its visitors, then stops You apply · status · restart api.sock shinyctl-api runs the cycle every 5 s and on every request or exit records state.json processes, start times, crash counts starts and stops copies table, swapped in memory
Solid line: new visitors. Dashed: visitors a copy already has, or the spare standing in when an active copy is down.

The cycle

shinyctl-api runs the same five steps for each app every 5 seconds, and at once whenever something happens: a command from you, or a copy that exits. An exit is noticed the moment it happens; the 5-second check catches anything else.

  1. ObserveWhich copies are alive and ready, and how many sessions each holds.
  2. DecideEach copy's role (active, warm or draining) and what to start or stop.
  3. RecordWrite the decision to the app's state.json.
  4. PushSend the copies table to the load balancer, which swaps it without a reload.
  5. ActStart and stop R processes, and watch them for exits.

What is stored is what happened: processes, start times, crash counts, and when a copy began to drain. What should happen (roles, what to start, what to stop) is worked out again on every cycle, so nothing about a role can go stale on disk.

Copies and roles

A copy is one R process running your app, numbered from 1. Of the copies that are running and ready:

  • active: the oldest replicas copies. They take new visitors.
  • warm: the rest. They are running and ready, and get no new visitors while every active copy is up. When an active copy fails, a warm copy takes its visitors at once.
  • draining: a copy being replaced. It keeps the visitors it has, gets no new ones, and stops when they are gone or drain-timeout passes.

A copy that is starting has no role yet; status shows - until it is ready.

Sticky sessions

Copies don't share memory, so a Shiny session has to stay with its R process. The load balancer sets a cookie named lb_<port> (for example lb_8080) that keeps each visitor on one copy for as long as it runs. This needs the browser to keep the cookie: it doesn't hold for an app embedded in another site's frame, or in Safari with third-party cookies blocked.

A visitor without a copy yet gets one of the active copies, picked by lb-policy:

lb-policyPicks
least-connThe copy with the fewest open connections. The default.
round-robinEach active copy in turn.
randomAny active copy.
firstAlways the oldest active copy.

Ports

The load balancer listens on the port in your manifest. Each copy runs on a port of its own between 20000 and 29999, on 127.0.0.1 only, which is why an app's port can't be in that range. Before sending visitors to a copy, shinyctl-api checks that the socket on its port belongs to that copy; if another program took the port, the copy moves to another one.

Your app sees requests coming from 127.0.0.1. The visitor's address is the first entry of the X-Forwarded-For header.

Files

Everything shinyctl keeps is under ~/.shinyctl/, except each app's state.json, which lives where its manifest says.

PathContents
api.logThe log of shinyctl-api and of the load balancer.
logs/<app-group>-<app-name>.logCopy 1's output; copy N's is in …@N.log. Logs rotate at 1 GiB.
registry.jsonEvery app shinyctl-api manages.
api.sockThe socket the command talks to.
proxy/The load balancer's settings, copies table, cookie key, lock and socket.

Words

WordMeaning
copyOne R process of an app, numbered from 1.
roleactive, warm or draining: how the load balancer treats a copy.
outdatedA copy running an older version than the manifest: another revision or other settings.
revisionA number in state.json that restart raises, so copies started before it are outdated.
switchThe moment a redeploy hands new visitors to the new copies; the old ones then drain.
settleThe 10 seconds a draining copy is kept even with no sessions, for a page that opens its WebSocket just after.
stand-inA warm copy that serves in place of an active one that is down, before the table changes.