MongoDB logo A quick look at why and how to hold your HTTP Session data in a database.

HTTP Sessions are an integral part of any web application. To quickly get you up and running most web servers, including Fantom's core wisp server, store session data in-memory.

Wisp has a pluggable session storage mechanism, meaning the default in-memory storage may be swapped out for a different storage strategy. Benefits of storing session data in a database, over memory, are:

1. Salability

If your data is held in memory, then the user has to hit that very same server to access their data. While this is fine in dev or for smaller production applications, if you ever need to scale horizontally and add more web servers then things get tricky.

Lets say you now have 5 web servers. You have to make sure each hits the same server every time. Some load balancers can implement sticky sessions and perform other tricks, but it's messy and takes a lot of fiddly configuration. Plus you may still find yourself in a position where one server holds the lion's share of session data, while the other servers idle away!

If your session data is held in a database, then no sticky sessions are required and any server can serve any user.

2. Resilience

If you're following any sort of agile / extreme programming methodology (and what's your excuse if you're not!?) then you'll be re-releasing your web application to production often.

While this is a good thing, it also (usually) necessitates an application restart. Which if your session is held in-memory, means all your users automatically get logged out and loose their session data.

But if your session data is held in a database then after an application upgrade, all your logged in users remain logged in and their session data safe! You can re-start your app as many times as you like with minimal disturbance to your users!

This also holds true for failover strategies and other instances of server migration.

3. Visibility

You can see how many logged in users are currently using your site simply by querying the database!

How?

As mentioned, Wisp has a pluggable session storage mechanism. Lets see how this works with BedSheet, which is based on Wisp.

Normally you would start a BedSheet application with this:

BedSheetBuilder("myPodName").startWisp(8069)

But we're going to add an option to the builder that tells BedSheet to swap in our MongoDB session store:

BedSheetBuilder("myPodName") {
    it.options["wisp.sessionStore"] = MongoSessionStore#
}.startWisp(8069)

This would create an instance of MongoSessionStore using its default constructor; which is fine if the class is self-contained.

But what if we want our MongoSessionStore to be built by the IoC in our application to make use of shared services and config?

That gets tricky because the Wisp session store needs to be created before the Wisp server is created and before our IoC application is created. So instead we set a different option:

BedSheetBuilder("myPodName") {
    it.options["wisp.sessionStoreProxy"] = MongoSessionStore#
}.startWisp(8069)

This tells BedSheet to create a session store proxy and to register an onRegistryStatup callback. The callback then uses IoC to either lookup MongoSessionStore by type or to autobuild it. All session calls are then routed to our instance!

Our actual MongoSessionStore is then pretty straightforward:

select all
using afIoc::Inject
using wisp::WispSessionStore
using afMongo::Collection
using afMongo::Database
using afMongo::Index
using afMorphia::Converters

const class MongoSessionStore : WispSessionStore {
    @Inject
    private const Converters    converters
    private const Collection    sessions
    private const Duration      expireSessionsAfter  := 60min
    private const Str:Obj?      emptyMap             := Str:Obj?[:].toImmutable

    private new make(Database database, |This| f) {
        f(this)
        sessions = database.collection("Session")
        if (!sessions.exists) sessions.create
        sessions.index("_timeToLive_")
            .ensure(["lastAccessed" : Index.DESC], false, ["expireAfterSeconds":expireSessionsAfter.toSec])
    }

    override Str:Obj? load(Str id) {
        converters.toFantom([Str:Obj?]#, sessions.get(id, false)?.get("data")) ?: emptyMap
    }

    override Void save(Str id, Str:Obj? map) {
        sessions.update(["_id":id], [
            "_id"           : id,
            "data"          : converters.toMongo([Str:Obj?]#, map),
            "lastAccessed"  : DateTime.now(1sec)
        ], false, true)
    }

    override Void delete(Str id) {
        sessions.delete(["_id":id])
    }
}

The ctor creates a Mongo Collection for our sessions so we can subsequently create our Index. Note how our Index has an expireAfterSeconds option; this tells MongoDB to automatically delete documents that go out of date, based on the collection's lastAccessed property. Conveniently, this handles the session time out for us!

Note that Mongo's document deletion background task only runs every minute, so if you set the timeout to less than this (for example, to 10 seconds during testing) then your sessions will still survive for 60 seconds or so. But you could always manually check the lastAccessed date if you absolutly need a smaller time-out resolution.

Session data is serialised via Morphia's Converters service. This is overcome inherent problems with dotted property keys and to enable a strategy for persisting Fantom data types.

The collection is upserted during a save so the document is automatically inserted if it doesn't exist.

load(), save(), and delete() are called by Wisp as and when it feels like it.

And that's it!

Have fun!

Dependencies

This article has been written and tested with the following dependencies:

select all
depends = [
    "sys        1.0.70 - 1.0",
    "wisp       1.0.70 - 1.0",

    "afBedSheet 1.5.10 - 1.5",

    "afIoc      3.0.6  - 3.0",
    "afMongo    1.1.6  - 1.1",
    "afMorphia  1.2.2  - 1.2",
]

Edits

  • 22 January 2018 - Original article.

Discuss