Share state within instances, at any scale.
While Dart provides excellent HTTP functionality out-of-the-box, some of its features, namely sessions, are not scalable without further configuration. HTTP is defined as a stateless protocol, but sessions allow servers to manage state, with each visitor to the site having their own individual session. Dart’s HttpServer
class provides a session
member, of the type HttpSession
. HttpSession
implements Map
(akin to a dictionary in other languages), so we can add or remove values and have them persist for future connections by the same user. The problem, however, arises when we run multiple instances of our server, in separate isolates, which are isolated execution contexts in Dart, implemented as threads. Because each instance’s HttpServer
corresponds to a different in-memory store, users have a different session in each isolate. To synchronize user sessions, the solution is simple: have each isolate share a common session store.
To achieve this, we need:
- A way to identify a user between requests. The simplest solution is to use a cookie.
- A centralized store that exists outside of the server isolates. If a server crashes and its isolate is also managing sessions, all other servers will crash as a result of not being able to connect to the store.
- Mutual exclusion. Our session management will be brittle if multiple servers can write to the user’s session at the same time.
Identifying Users
Every instance of HttpSession
has an id
property; however, it is specific to the individual server instance it came from. Fetching its value in different isolates will always return a value local to that isolate, so we need to create our own cookie outside of that system. The name is completely arbitrary; for the purposes of this article, we’ll use sync_sess
.
Using Angel, it’s trivial to write a middleware that gets the value of, or creates a sync_sess
cookie. If not present, its value will default to the session ID assigned by the current server.
/// Simple middleware to get/set `sync_sess` cookieFuture<bool> getOrSetSessionId(RequestContext req, ResponseContext res) async { String sessionId; var cookie = req.cookies.firstWhere((c) => c.name == 'sync_sess', orElse() => null); if (cookie != null) sessionId = cookie.value; else { // The cookie doesn't exist, so make a new one, and add it to the **response**. // We can't add cookies to the request context; this is logical. res.cookies.add(new Cookie('sync_sess', sessionId = req.session.id)); } // Add `sessionId` to `req.properties`, so that we don't have to fetch the cookie again // for the remainder of the lifespan of the request. req.properties['session_id'] = sessionId; // Return `true`, to tell Angel that this is not the end of the response. return true;}
The Store
At large scale, consider using a caching system like Redis or Memcached. If you are only running server, though, the added network connection on each request may become something of a bottleneck. Fortunately, isolates in Dart can communicate by means of SendPort
and ReceivePort
(documentation).
We implement a dead-simple session store as follows:
var sessions = <String, Map>{};var sessionSync = new ReceivePort()..listen((List packet) { String sessionId = packet[0]; var arg = packet[1]; if (arg is SendPort) { arg.send(sessions.putIfAbsent(sessionId, () => {})); } else if (arg is Map) { sessions[sessionId] = arg; }});
We create a single ReceivePort
named sessionSync
. Session synchronization within the process follows a simple protocol. We will only be sending List
objects through this port, with two arguments each. The first will always be the session ID. The second may be either a SendPort
or a Map
. If it is a SendPort
, the value of the session is sent through it, effectively performing a "fetch". If it is a Map
, we overwrite the existing session data, essentially a "write" operation.
Each server instance should receive a reference to the SendPort
associated with sessionSync
. Just add it to your Isolate.spawn
call:
main() { // Session sync logic from above... for (int id = 0; id < Platform.numberOfProcessors; id++) { Isolate.spawn(serverMain, sessionSync.receivePort); }}/// Pseudo-code for an entry point in which a server isolate/// is provided a reference to our session store endpoint.void serverMain(SendPort sessionSync) { // ...}
Mutual Exclusion
There’s just one problem with our session store – it has no write-locking mechanism. If two instances tried to write to the same session simultaneously, it would create a race condition with unpredictable results.
To avoid this, let’s use package:pool
, provided by the Dart team, as a simple mutex. Add it to your pubspec.yaml
:
dependencies: pool: ^1.0.0
Create a Map
of session ID’s and Pool
instances. Each individual session will have a mutex, so while two instances cannot simultaneously write to the same session, they can write to different sessions.
Your code should now look like the following:
var sessions = <String, Map>{};var sessionPools = <String, Pool>{};var sessionSync = new ReceivePort()..listen((List packet) { String sessionId = packet[0]; var arg = packet[1]; if (arg is SendPort) { arg.send(sessions.putIfAbsent(sessionId, () => {})); } else if (arg is Map) { var pool = sessionPools.putIfAbsent(sessionId, () => new Pool(1)); pool.withResource(() { sessions[sessionId] = arg; }); }});
Putting it all Together
For each server instance to sync with the main server, we need a middleware that runs before every request that fetches the user’s session. We can simply add a call to app.use
before wiring in our actual application logic:
app.use((RequestContext req, ResponseContext res) { // Pull session changes var c = new Completer(); var recv = new ReceivePort(); recv.listen((Map session) { req.session.addAll(session); recv.close(); c.complete(true); }); // Send a "request" to the store. sessionSync.send([getOrSetSessionId(req, res), recv.sendPort]); // If the store doesn't respond within 10 seconds, continue as-is. // Ideally this will never happen. return c.future .timeout(const Duration(seconds: 10), onTimeout: () => true);});// The name `configureServer` is arbitrary; the gist is that// you add your actual logic AFTER the session-sync code.await app.configure(configureServer);
That’s not all, though. We still need to persist the changes to the store.
We use app.responseFinalizers
to add logic that runs after every request; this is typically used to dispose of resources allocated per request.
// Earlier session-fetching code...// And then, your application logic...await app.configure(configureServer);// Lastly, some logic to persist the session.app.responseFinalizers.add((req, res) async { // Push session changes sessionSync.send([ // We added the `session_id` to the request earlier. req.properties['session_id'], new Map.from(req.session), ]);});
Now each local instance of your application will share a common session store.
Going Further
Eventually, you might scale horizontally, in which case the process-local approach no longer results in synchronized servers. At this point you will have a few options:
- Use an established caching system, such as Redis or Memcached.
- Expand our isolate-based store to also support communication across TCP sockets, potentially using a simple RPC protocol. At this scale, you might consider running the session store on its own dedicated server. Also consider enforcing some type of security mechanism, i.e. a password, to prevent malicious users from injecting arbitrary session data into or fetching session data from our store.
- Forgo in-memory sessions entirely, and store state in JWT tokens. Although they prevent CSRF, JWT’s can be easily decoded, so be sure not to use them to store data that could potentially compromise your application’s security.
Conclusion
Though the functionality is not shipped out-of-the-box, it is trivial to synchronize sessions between multiple instances of the same server running within the same Dart process. When running on multiple machines, it only takes minimal tweaking to achieve the same synchronization.