My customer was recovering from an outage due to an overloaded server. During the recovery, we re-ran load tests (using VS) with one script at 100%, simulating > 200 users. The script checks that the user can progress from page to page of an application. The state of the data and progress accumulated from each page was stored using the session state feature of ASP.NET. The session state provider was the local in-process session state storage. The customer had elected not to have multiple servers in place; this previously had not been an issue.
The application was originally written in ASP.NET MVC (with .NET Framework 4.x). However, this version had been ported to ASP.NET Core backed by the .NET Framework (so, still on Windows, still using IIS, etc.).
In previous versions (pre-ASP.NET Core), load tests had been conducted without error.
This time, load tests indicated intermittent failures. The errors were all related to an unexpected redirect response, forcing the user to see an error page or restart the workflow back at the home page.
Investigation revealed that the app was attempting to gracefully recover when it was loading session state for some of the pages in the application. It seemed that the session had been abandoned, even though it clearly did not have time to timeout, and we put logging around any code that touched the session state to make sure some errant code didn’t clear the session inadvertently. It did not.
We even went as far as storing a separate session cookie with a unique user ID to uniquely identify a thread of requests from the test engine for a single user. We set up the logging mechanism to capture that user id, the ASP.NET session cookie value, and the server’s Session ID. What we saw was that the session cookie remained the same, the user ID value stayed the same, but the session ID changed at some point during the sequence of requests.
In other words, it kind of looked like this (timespan is about 3-4 minutes from start to finish, because the test server injects “think time” between requests):
User agent ▶ GET home, User ID (null), ASP.NET Session (null), Session ID (null) 200 home (html), SET User ID=5, SET ASP.NET Session=qwaszx, SET Session ID=be0110ef ◀ Server User agent ▶ POST home, User ID 5, ASP.NET Session qwaszx, Session ID be0110ef 302 page2, Session ID be0110ef ◀ Server User agent ▶ GET page2, User ID 5, ASP.NET Session qwaszx, Session ID be0110ef 200 page2 (html), Session ID be0110ef ◀ Server User agent ▶ POST page2, User ID 5, ASP.NET Session qwaszx, Session ID be0110ef 302 page3, Session ID be0110ef ◀ Server User agent ▶ GET page3, User ID 5, ASP.NET Session qwaszx, Session ID be0110ef 200 page3 (html), Session ID be0110ef ◀ Server User agent ▶ POST page3, User ID 5, ASP.NET Session qwaszx, Session ID be0110ef (or so we thought! This is about where the session state had been discarded.) 302 error, SET Session ID=de1001ad ◀ Server (this is the step that the load test caught as an error – it should have been a 302 to page4) User agent ▶ GET error, User ID 5, ASP.NET Session qwaszx, Session ID de1001ad 200 error (html), Session ID de1001ad ◀ Server
The “User ID” is the number uniquely assigned to the user’s incoming request and stored in a session cookie. We know that nothing would change this value once we set it for the user.
The “ASP.NET Session” is the encrypted opaque cookie that ASP.NET sets. It can change until the app actually stores data in the session state, and then it will remain constant. When we performed this test, we were concerned the value might change, which is why we used the User ID cookie. It’s normally a much longer value, and looks a lot like a base-64-encoded string (it’s not, exactly).
The “Session ID” is a GUID that is assigned only at the server side. The ASP.NET Session cookie value is used to locate previous session data, and the ID is a program-friendly identifier. It is not set in the ASP.NET Session cookie, but I displayed it in the sequence above to better illustrate the point where it changes in the flow of requests and responses.
I have a lot of history with ASP.NET on the framework, so this exhibited behavior was surprising to me.
It’s surprising because the way session state is stored has changed in ASP.NET Core from the way it worked in ASP.NET on the .NET Framework (v1.x to 4.x)
With ASP.NET (Framework), local (in-proc) session state was stored in a simple dictionary (or something equivalent to it). Any session data created in the session state was kept alive so long as the appdomain was alive (i.e. the WAS apppool host wasn’t restarted), the session wasn’t abandoned (with Session.Abandon), and the session didn’t timeout due to inactivity. None of those conditions were present, so I expected the session state to still be present for the duration of the test script execution.
With ASP.NET Core, however, local (in-proc) session state is stored in a cache. Session data created in the session state is kept alive under the same three conditions as before, but an additional condition was added: because it’s a cache, and not a data structure, it could remove any session data for any reason at any time it deemed appropriate.
This behavior is documented, albeit somewhat lightly. The only note to this effect I found is shown in the first paragraph of the documentation about ASP.NET Core Session State (see below). It states, "The session data is backed by a cache and considered ephemeral data—the site should continue to function without the session data." I have not yet found an event or some mechanism that might be used to detect or prevent the discarding of session state, nor any way to configure the size of the cache used.
Furthermore, there are potential legal and compliance issues regarding the storing of session data that the browser must honor (GDPR is a primary driver), which affects the way session cookies are managed. Read the docs on ASP.NET Core Session State (link below) for more info.
Consequently, for any ASP.NET Core applications, local (inproc) session data must be treated as only cached/cacheable data. Any data which must be logically preserved across requests should never be stored in the session state, but instead should be stored in durable storage elsewhere.
Alternately, session data can be backed by an external (out-of-proc) session data store. Currently, I’m only aware of Redis and SQL Server session store providers. Redis is technically also a cache, but I have greater faith in it not discarding session data without warning. SQL Server would be a more reliable store, but it’s going to add some significant overhead that will affect performance and throughput.
The best way to store session data with ASP.NET Core would be with an architectural design that doesn’t actually emphasize “web server-managed user session data” as much as it simply provides a durable store for user activity, and relies purely on stateless web servers. Or, by passing session state back and forth manually with each request (don't do this!).
Documentation for reference:
- ASP.NET Core Session State: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/app-state?view=aspnetcore-2.2#session-state
- ASP.NET (.NET Framework) Session State: https://docs.microsoft.com/en-us/dotnet/api/system.web.sessionstate?view=netframework-4.7.2