<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mahdi Molavi</title>
    <description>The latest articles on DEV Community by Mahdi Molavi (@mahdimolavi).</description>
    <link>https://dev.to/mahdimolavi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2284925%2F51f0ca37-76cd-4262-ab19-119da20b9690.jpg</url>
      <title>DEV Community: Mahdi Molavi</title>
      <link>https://dev.to/mahdimolavi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mahdimolavi"/>
    <language>en</language>
    <item>
      <title>Real Watch Time from Noisy Video Player Logs</title>
      <dc:creator>Mahdi Molavi</dc:creator>
      <pubDate>Sat, 24 Oct 2026 09:47:52 +0000</pubDate>
      <link>https://dev.to/mahdimolavi/real-watch-time-from-noisy-video-player-logs-jm8</link>
      <guid>https://dev.to/mahdimolavi/real-watch-time-from-noisy-video-player-logs-jm8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://mahdimolavi.ir/backend-notes/33-real-watch-time-from-noisy-video-player-logs.html" rel="noopener noreferrer"&gt;mahdimolavi.ir&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"How much of this video did the student actually watch?" sounds like a question with a one-line answer. The player knows where the playhead is, so add up the seconds. Then you open the raw events and the one-liner falls apart: pauses that were never logged, tabs closed in the middle of a lesson, phones whose clocks are minutes off, people watching at 2x, people dragging the slider back to see a proof for the third time.&lt;/p&gt;

&lt;p&gt;For a large Persian-language online learning platform, I rebuilt the pipeline that turns that telemetry into facts people can rely on. For every student and every video it answers: how many distinct seconds were watched, which parts were re-watched or skipped, whether the video was really finished, and how the watching was spread across days. The work started from a 2024 prototype written by two other developers; in 2026 I reworked the ingest service, the analysis engine, the storage model and the tests. This note walks through the decisions that turned out to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the player sends
&lt;/h2&gt;

&lt;p&gt;The web player posts a small JSON event whenever something happens: play, pause, seek, a change of speed or quality, and a periodic "still playing" heartbeat. Each event carries the position in the video, a client timestamp and identifiers for the learner and the video. The video file itself is never analysed. Everything below is inferred from these events.&lt;/p&gt;

&lt;p&gt;Two properties shape the whole design. Events are cheap to send and arrive in large volumes, so ingestion must never slow the player down. And events are unreliable in ways that are normal rather than exceptional, so the analysis has to reason about what probably happened between two events instead of trusting each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ingest: accept fast, write later
&lt;/h2&gt;

&lt;p&gt;The ingest service is an ASP.NET Core minimal API on .NET 10. Its request path does almost nothing: it puts the event on a bounded in-memory channel and returns &lt;code&gt;202 Accepted&lt;/code&gt;. When the channel is full it answers &lt;code&gt;503&lt;/code&gt; with a problem-details body, so clients back off instead of the server falling over. A simplified version of the endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PlayerEvent&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventBuffer&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryEnqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"The event buffer is full."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StatusCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status503ServiceUnavailable&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A background worker drains the channel and flushes when a batch is full or when a timer fires, whichever comes first. Each flush loads the batch into temporary tables and applies one set-based upsert inside a single transaction, so SQL Server performs a few large operations instead of thousands of small ones.&lt;/p&gt;

&lt;p&gt;The detail I would repeat in any similar system is how a failing batch is handled. If SQL Server rejects a batch with a data error (a constraint violation, a value that is too long, a failed conversion), the worker retries that batch row by row, writes the rows that still fail to an error table and moves on. One malformed event cannot block the thousands of good ones queued behind it. Infrastructure errors are treated differently: the batch stays in the buffer and is retried later, because the data was fine and the database was not.&lt;/p&gt;

&lt;p&gt;The trade-off is deliberate and written down: an in-memory buffer loses whatever it holds if the process crashes. For behavioural telemetry that is an acceptable price for a request path measured in milliseconds. For payments it would not be. A separate watchdog alerts the team when no events have arrived for longer than expected, which catches a broken player release faster than any dashboard.&lt;/p&gt;

&lt;p&gt;In a Locust benchmark on a 12-core workstation, replaying 10,000 events with 50 concurrent users, every request was accepted with no failures, at about 2,450 requests per second, a median latency of 13 ms and a 95th percentile of 21 ms. The service peaked at about 108 MB of memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  From events to watched seconds
&lt;/h2&gt;

&lt;p&gt;The analysis engine is plain C# with no dependencies, which made it easy to test thoroughly. For each (learner, video) pair it reads the full event history in time order and rebuilds the playback sessions. Four rules do most of the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Progress has to be physically possible
&lt;/h3&gt;

&lt;p&gt;Between two consecutive events the playhead moved some number of video seconds while some number of wall-clock seconds passed. If the video advanced further than the fastest allowed playback speed (2x) could explain, that segment is not counted as watched; something else happened, usually a seek that was logged late or not at all. Segments with no forward progress, or longer than the video itself, are rejected for the same reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;IsPlausible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;videoSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;wallClock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;videoLength&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;maxSpeed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;videoSeconds&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;videoSeconds&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;videoLength&lt;/span&gt;
    &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;videoSeconds&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;maxSpeed&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;wallClock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalSeconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Count tenths, not seconds
&lt;/h3&gt;

&lt;p&gt;Coverage is tracked at a resolution of 0.1 seconds, with half-open intervals so that two adjacent segments never count their shared boundary twice. A video second counts as watched only when at least half of its ten tenths were covered. This avoids two opposite errors: crediting a whole second to a seek that landed at 12.9, and losing a second that was watched in two short pieces.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Infer the pauses nobody logged
&lt;/h3&gt;

&lt;p&gt;While a video plays, the player sends a heartbeat at a regular interval. That makes silence informative. If much more time has passed since the last event than playback at the slowest allowed speed could explain, plus a tolerance, the learner almost certainly stopped watching without a pause event reaching the server: a closed tab, a lost connection, a closed laptop. Instead of crediting the whole gap, the engine inserts a virtual pause at the last position it can trust.&lt;/p&gt;

&lt;p&gt;The same reconstruction covers the other everyday cases. A seek resumes playback at its target position. A settings change cannot reopen a session that was already closed. A session still open at the end of the history is closed at its last known position.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A re-watch has to look like a re-watch
&lt;/h3&gt;

&lt;p&gt;Seconds watched more than once interest teachers, because they point at the hard parts of a lesson. But a learner who scrubs back two seconds to catch a word has not re-watched anything. The engine reports a repeated range only when every second in it was watched at least twice and the run lasts at least four seconds, tolerating a one-second hole inside the run. Skipped ranges come from the same timeline, read in the other direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time is harder than it looks
&lt;/h2&gt;

&lt;p&gt;Two smaller decisions removed whole classes of strange results. The first is about client clocks: an event's own timestamp is used only when it is within 30 minutes of the moment the server received it; otherwise the server time wins. A phone set to the wrong day can no longer push someone's studying into next month.&lt;/p&gt;

&lt;p&gt;The second is midnight. Per-day coverage matters for learning analytics ("did they study a bit every day?"), so a segment that crosses midnight is split. Progress is assumed to be uniform within the segment, and each video second is attributed to the day that contains its midpoint. The date dimension also carries Persian calendar fields, because the people reading the reports think in Persian months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing the results
&lt;/h2&gt;

&lt;p&gt;Results go into a small star schema in SQL Server: date and video dimensions, plus fact tables for per-pair watching, daily coverage, per-second actions, skipped ranges and repeated seconds. Batches are written with &lt;code&gt;SqlBulkCopy&lt;/code&gt;, on a connection that deliberately runs with Multiple Active Result Sets turned off; large asynchronous bulk copies and MARS on one connection are a combination best avoided.&lt;/p&gt;

&lt;p&gt;Recomputation is idempotent by design. When new events arrive for a pair, the engine recomputes that pair's entire history and writes a fresh snapshot tagged with the job that produced it, then deletes the older snapshots of the same pairs. Rerunning a job, or running two that overlap, converges to the same state instead of counting anything twice. Jobs themselves are rows in a queue table with a watermark over the event log, so new events are processed incrementally and an interrupted run can resume where it stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing an engine whose answers you do not know
&lt;/h2&gt;

&lt;p&gt;The hard part of testing this kind of engine is the oracle: for a messy history, what is the right answer? I settled on working out the expected values for each test history independently, before running the engine on it, and only then comparing. The suite grew to 282 passing tests: histories chosen to hit specific patterns (gaps, seeks, heartbeats, midnight, speed changes), randomly sampled histories, and small hand-built scenarios for each rule above.&lt;/p&gt;

&lt;p&gt;On top of the unit tests sits an audit. A separate implementation of the same rules, written in Python, reads the raw events for a random sample of pairs, recomputes all five fact tables and compares them with what the C# pipeline stored. In the latest audit every sampled pair matched in every table. Two implementations in different languages agreeing on messy data is a much stronger signal than either one passing its own tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would change next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The analysis processes pairs one after another in a single loop. That is simple and correct, but the work is embarrassingly parallel, and partitioning pairs across workers is the obvious next step.&lt;/li&gt;
&lt;li&gt;Result objects are converted to &lt;code&gt;DataTable&lt;/code&gt;s for &lt;code&gt;SqlBulkCopy&lt;/code&gt; through reflection. A typed &lt;code&gt;IDataReader&lt;/code&gt; would cut allocations on large batches.&lt;/li&gt;
&lt;li&gt;The ingest buffer and the inactivity watchdog are single-instance by design. That is fine at the current scale and the first thing to revisit if the service ever runs on more than one node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are measuring engagement from player telemetry and want a second pair of eyes on the rules or the pipeline, this is the kind of backend and data work I take on: see &lt;a href="https://mahdimolavi.ir/services.html" rel="noopener noreferrer"&gt;Work with me&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>sqlserver</category>
      <category>analytics</category>
      <category>backend</category>
    </item>
    <item>
      <title>When a Low IRT Discrimination Score Is a Pipeline Problem</title>
      <dc:creator>Mahdi Molavi</dc:creator>
      <pubDate>Sat, 24 Oct 2026 09:45:17 +0000</pubDate>
      <link>https://dev.to/mahdimolavi/when-a-low-irt-discrimination-score-is-a-pipeline-problem-3bf1</link>
      <guid>https://dev.to/mahdimolavi/when-a-low-irt-discrimination-score-is-a-pipeline-problem-3bf1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://mahdimolavi.ir/backend-notes/71-irt-calibration-notes-1-low-discrimination-score-pipeline-problem.html" rel="noopener noreferrer"&gt;mahdimolavi.ir&lt;/a&gt;. This is part 1 of IRT Calibration Notes, a six-part case study.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three exams from the current year, one for each track, came back from calibration with a maximum estimated discrimination well below what the archive showed for the same tracks. The data belonged to an online exam platform whose analytics I was working on, and the question that reached me was short: are the new questions worse? Read at face value, the result table said yes. The new forms seemed to separate stronger and weaker students less sharply than the forms from two years earlier.&lt;/p&gt;

&lt;p&gt;The first thing I noticed was attendance. The archive exams I was comparing against had been taken by far more people:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Track&lt;/th&gt;
&lt;th&gt;Current exam (1405), official attendance&lt;/th&gt;
&lt;th&gt;Archive exam (1403), official attendance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mathematics&lt;/td&gt;
&lt;td&gt;434&lt;/td&gt;
&lt;td&gt;6,342&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Experimental Sciences&lt;/td&gt;
&lt;td&gt;521&lt;/td&gt;
&lt;td&gt;9,222&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Humanities&lt;/td&gt;
&lt;td&gt;1,134&lt;/td&gt;
&lt;td&gt;8,648&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A gap of roughly 8 to 18 times in attendance next to a drop in discrimination is a coincidence I did not want to accept or dismiss without looking. Several explanations fit the summary. The new questions might be weaker, the new cohorts might vary less, the samples might be too small, or the estimator might respond to sample size by itself. A familiar rule of thumb was also in the room: more than 200 students is enough for calibration. If that rule held, sample size was off the list for all three exams. Each explanation predicts something you can check, and checking them took longer than I expected and changed how I read the original metric.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this series covers
&lt;/h2&gt;

&lt;p&gt;This series is my record of that investigation. I wrote it for backend and data engineers, for people who run assessment systems, and for developers who are curious about psychometrics. Each part takes one layer of the problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1, this one: the puzzle, a short refresher on the 2PL model, two names in the code that pointed the wrong way, and what reading the code did and did not prove.&lt;/li&gt;
&lt;li&gt;Part 2: who entered the calibration. Attendance, usable responses and stored abilities are different counts, and the response coding decides what a zero means.&lt;/li&gt;
&lt;li&gt;Part 3: a prior labeled "vague" that is quite narrow, an experiment where the questions stayed fixed and only N changed, and a simulation that asks whether 434 students can support high discrimination at all.&lt;/li&gt;
&lt;li&gt;Part 4: two other estimators, a hierarchical prior and marginal maximum likelihood through the GIRTH library, with their own failure modes.&lt;/li&gt;
&lt;li&gt;Part 5: a repeated-sampling study over nine exams, twenty samples per size, and the engineering that made it restartable and reproducible.&lt;/li&gt;
&lt;li&gt;Part 6: equal-N comparisons across years, how the results were reported, and why equal N is not equating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Throughout, I keep observations, interpretations and proposals apart, and I say where a result stops applying. The numbers come from several phases of the work, with different extractions and different populations. I will not merge them, even when two of them look close enough to tell a neater story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model in a few lines
&lt;/h2&gt;

&lt;p&gt;The estimator behind these coefficients fits a two-parameter logistic model, the 2PL. For student s and question i:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P(correct response by student s to item i) = sigmoid(a_i * (theta_s - b_i))

sigmoid(x) = 1 / (1 + exp(-x))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;θ is the student's latent ability. Nobody observes it directly; the model estimates it along with the item parameters. b is the difficulty, and in this parameterization it is the ability at which the modeled chance of a correct answer is 0.5. a is the discrimination, the steepness of the curve around b. A question with a large positive a separates students whose abilities lie near its difficulty quite sharply, while a question with a small a gives similar chances to students who are far apart.&lt;/p&gt;

&lt;p&gt;Two cautions apply from the start. A difficult question is not automatically a bad one, and a high-discrimination question is not automatically right for every population or content objective. The metric under suspicion, the largest a in an exam, carries both cautions with it. I wrote about how the same parameters drive item selection in &lt;a href="https://mahdimolavi.ir/backend-notes/25-backend-notes-2-adaptive-testing-irt-item-selection-api.html" rel="noopener noreferrer"&gt;Backend Notes 2, on adaptive testing with IRT&lt;/a&gt;. This series is about where the parameters come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two names that pointed the wrong way
&lt;/h2&gt;

&lt;p&gt;The result tables carry a column called Lambda, and every row I looked at said 0.25. The service function that runs the estimation has "3pl" in its name. Together those two facts suggest a three-parameter model with an estimated guessing floor. When I read the function, it called the library's 2PL class. Lambda appears nowhere in the fitted response function; the output pipelines write it as a fixed value. A model that really estimated guessing would fit a lower asymptote c:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P = c + (1 - c) * sigmoid(a * (theta - b))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a different response function from the one fitted here. I put this first because it is the cheapest mistake in the whole story and the most embarrassing one to publish. A report that says the platform estimates a 3PL invites comparisons with 3PL guidance that do not apply. For the rest of the series every coefficient is a 2PL coefficient, and Lambda is a stored constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did the students vary less?
&lt;/h2&gt;

&lt;p&gt;One explanation on the table was that the new cohorts were more alike. Discrimination needs contrast between students, the argument went, so a homogeneous group produces flat curves. The official lesson statistics allowed a first check. In the main mathematics lesson of each track, raw percentage scores and the stored maximum discrimination looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Track&lt;/th&gt;
&lt;th&gt;Current mean / SD of raw percentage score&lt;/th&gt;
&lt;th&gt;Archive mean / SD&lt;/th&gt;
&lt;th&gt;Stored mathematics maximum a, current / archive&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mathematics&lt;/td&gt;
&lt;td&gt;22.10 / 16.62&lt;/td&gt;
&lt;td&gt;15.80 / 15.56&lt;/td&gt;
&lt;td&gt;about 0.90 / 1.54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Experimental Sciences&lt;/td&gt;
&lt;td&gt;8.60 / 14.47&lt;/td&gt;
&lt;td&gt;5.10 / 11.13&lt;/td&gt;
&lt;td&gt;about 0.96 / 1.73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Humanities&lt;/td&gt;
&lt;td&gt;9.40 / 14.02&lt;/td&gt;
&lt;td&gt;5.20 / 11.70&lt;/td&gt;
&lt;td&gt;about 1.22 / 1.72&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In all three tracks the new exam had the larger raw-score spread, and in all three its stored maximum discrimination was lower. The simple version of the homogeneity story predicts the opposite. The table has limits, though, and I want them on the record. These are lesson-specific stored statistics, so the maxima here are not maxima across every lesson and sublesson row. Raw-score SD describes observed scores, and the SD of latent ability is a different quantity. The old and new exams also had different questions. What the table does is remove one easy explanation. It leaves the cause unidentified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of variation
&lt;/h2&gt;

&lt;p&gt;Before going further, it helps to separate three quantities that all get called "variation" in conversation. They answer different questions, and later parts measure them with different tools.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quantity&lt;/th&gt;
&lt;th&gt;What varies&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Between-item distribution&lt;/td&gt;
&lt;td&gt;Different questions in one lesson and year&lt;/td&gt;
&lt;td&gt;How different are the fitted coefficients of the questions?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sampling variability&lt;/td&gt;
&lt;td&gt;Students selected from the same frozen response pool&lt;/td&gt;
&lt;td&gt;How much does the answer change when the calibration sample changes?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training randomness&lt;/td&gt;
&lt;td&gt;The training seed, with exactly the same response subset&lt;/td&gt;
&lt;td&gt;How much does stochastic fitting change the answer?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A wide spread of coefficients across one exam's questions tells you nothing about how stable any single coefficient is, and a coefficient that barely moves under resampling may still move with the optimizer's seed. Part 5 measures the second and third quantities. The histograms and boxplots in part 6 show the first, and a narrow box there is not a narrow confidence interval for any question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the code
&lt;/h2&gt;

&lt;p&gt;The first practical question was whether two codebases on the platform calculated the same parameters with the same logic and settings. One of them served adaptive tests. In the feature I inspected, it read existing item parameters from its own item tables, and I found no equivalent end-to-end calibration entry point there. Nobody supplied another one for me to check. The second codebase was the one doing calibration.&lt;/p&gt;

&lt;p&gt;Both carried a vendored copy of py-irt, the open-source IRT library built on Pyro. I compared four of its files by SHA-256: the 2PL model, the dataset class, the training loop and the configuration. All four were byte-identical across the two repositories.&lt;/p&gt;

&lt;p&gt;Matching hashes prove exactly one thing, which is that those four files match. They say nothing about which responses each service selects, how it groups them, what runtime settings it uses, or whether it runs a calibration at all. Here, I found no calibration entry point in the inspected code of one of the two services, so the match was true and close to irrelevant. The hashes cannot carry the claim that both applications calibrate identically.&lt;/p&gt;

&lt;p&gt;Inside the calibrating codebase there were two code paths into the same estimator. The first read per-question response records from the analytics warehouse's response fact table, joined them through the active analysis request and the question dimensions, excluded responses with answer status 5 (unseen), grouped them by assessment lesson and called the estimator. The second was built around assessment-run results. It fetched responses and compact answer sheets through stored procedures, merged them by exam, user and content, gave the answer-sheet responses precedence, mapped each selected choice against a cached answer key, grouped by assessment lesson and lesson, called the same 2PL classes and wrote its own result models. In generic form, the estimator call looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trainer = TwoParamLog.train(data, device="cpu", epochs=2000)
params = trainer.best_params
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the model class and the training call were shared, and the inputs were prepared differently. The answer-sheet path can materialize a blank entry for a known question as an explicit response row, and such a row is not automatically equivalent to the unseen status in the fact table. One count in its output has a name that suggests "students who answered". It counts the users present in the merged input, and its name alone does not prove that each of them chose a substantive answer.&lt;/p&gt;

&lt;p&gt;The two paths fed different result tables. The coefficients people were worried about lived in the result table for assessment runs, while a separate content-bank table held another calibration. Treating those two branches as one was an early wrong turn, and I corrected it by following the run-level table and its run and lesson keys. When I later built research jobs, they imported the estimator classes directly and never called the write-capable HTTP endpoint.&lt;/p&gt;

&lt;p&gt;This comparison has a firm boundary. I compared current source code, which is a different thing from a historical deployment audit. The saved research results do not establish the stored-procedure implementations, the deployed versions, the exact production input snapshots or past configuration states. I therefore cannot say that every stored run used the input rules of the path I later used for research, and I do not say it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A coefficient is the output of a pipeline
&lt;/h2&gt;

&lt;p&gt;By this point the original question had changed shape. "Are the new questions worse?" treats a stored a value as a property of a question. In practice it is the end of a chain of decisions: who was selected, how their answers were coded, which model and prior were fitted, how the optimizer ran and when it stopped, and how the numbers were aggregated for a report. Until each link has been examined, a difference in the output cannot be attributed to question quality.&lt;/p&gt;

&lt;p&gt;Seeing it this way also gave me a work plan. Each stage could be checked on its own terms: the population against the database, the model against its source, and the estimator against controlled experiments in which I knew what ought to happen. Some of those checks removed explanations that had seemed obvious, and a couple uncovered data problems that a better model would not have fixed. The rest of the series follows that order.&lt;/p&gt;

&lt;p&gt;Part 2 starts with the population: six legitimate counts of "students" for a single lesson, and the coding rule that decides what an unanswered question means. The full series continues on &lt;a href="https://mahdimolavi.ir/backend-notes/" rel="noopener noreferrer"&gt;mahdimolavi.ir&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>statistics</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Redis Sentinel for Up to 70,000 Exam-Day Users</title>
      <dc:creator>Mahdi Molavi</dc:creator>
      <pubDate>Sat, 24 Oct 2026 09:42:22 +0000</pubDate>
      <link>https://dev.to/mahdimolavi/redis-sentinel-for-up-to-70000-exam-day-users-5bcn</link>
      <guid>https://dev.to/mahdimolavi/redis-sentinel-for-up-to-70000-exam-day-users-5bcn</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://mahdimolavi.ir/backend-notes/23-backend-notes-1-redis-sentinel-exam-day-traffic.html" rel="noopener noreferrer"&gt;mahdimolavi.ir&lt;/a&gt;. This is part 1 of Backend Notes, my series on .NET backend engineering.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most web traffic follows the clock. Exam traffic follows the calendar. For weeks the system runs at a fraction of its capacity, and then, at the start time printed on every admission card, tens of thousands of students open the same exam in the same minute. The monthly average says almost nothing about that minute, and that minute is the only one anybody remembers.&lt;/p&gt;

&lt;p&gt;At Gozine2 I designed and deployed a Redis Sentinel high-availability setup that carried 45,000 to 70,000 concurrent users at exam peaks. This note is about the reasoning behind that kind of setup: what Sentinel gives you, when Redis Cluster fits better, how to configure the .NET client, and the caching and testing habits that matter more than the topology.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;Two properties make exam platforms unusual. The spike is &lt;strong&gt;synchronized&lt;/strong&gt;: users arrive together because the exam has a start time. And it is &lt;strong&gt;predictable&lt;/strong&gt;: you know the date weeks ahead, so you can warm caches, run load tests and freeze risky deployments.&lt;/p&gt;

&lt;p&gt;Synchronized arrival is what hurts a cache. In the first minute everybody asks for the same few things: the exam definition, its sections, the question set, the timing rules. If those keys are cold, or expire together, every request falls through to SQL Server at once. And if the Redis primary dies inside that window, a replica has to take over within seconds, without anyone editing a connection string.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Sentinel does, and what it does not
&lt;/h2&gt;

&lt;p&gt;Redis Sentinel is a separate process that runs alongside your Redis servers. A typical deployment has one primary, one or more replicas, and at least three Sentinels on independent machines. Four of its jobs matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring.&lt;/strong&gt; Each Sentinel pings the primary and the replicas. If the primary stops answering for longer than &lt;code&gt;down-after-milliseconds&lt;/code&gt;, that Sentinel marks it as subjectively down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agreement.&lt;/strong&gt; When enough Sentinels agree (the quorum you configure), the primary is considered objectively down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic failover.&lt;/strong&gt; The Sentinels elect a leader, which promotes the most suitable replica and points the other replicas at it. The election needs a majority of all Sentinels, which is why three is the practical minimum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service discovery.&lt;/strong&gt; Clients never hard-code the primary's address. They ask the Sentinels who the primary is for a service name, and follow the answer when it changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What Sentinel does not do is make Redis durable. Replication is asynchronous, so a write acknowledged by the old primary may never reach the replica that gets promoted; the Redis documentation says so plainly. For a cache that is acceptable, as long as everything in Redis can be rebuilt from the source of truth. If losing a write would hurt, that write belongs in SQL Server first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sentinel or Cluster?
&lt;/h2&gt;

&lt;p&gt;Redis Cluster solves a different problem. It shards the keyspace across several primaries using 16,384 hash slots, each primary with its own replicas, so you get more memory and write throughput than one machine offers. The price is a set of constraints: multi-key commands, transactions and Lua scripts only work when all their keys map to the same slot, so keys get designed around hash tags such as &lt;code&gt;{exam:42}:sections&lt;/code&gt;. Only database 0 exists, and resharding becomes an operation your team has to understand.&lt;/p&gt;

&lt;p&gt;My rule of thumb: if the working set fits comfortably in one machine's memory and one primary can handle the command rate, Sentinel is the simpler and safer choice. Exam caches often fit that profile. The hot data is the content of a limited number of exams, read by very many people, and the load is far more read-heavy than write-heavy. Move to Cluster when measurements show that a single primary is the limit. Replica reads with &lt;code&gt;CommandFlags.PreferReplica&lt;/code&gt; are a middle step, for data where slight staleness is harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring StackExchange.Redis for Sentinel
&lt;/h2&gt;

&lt;p&gt;In StackExchange.Redis you list the Sentinel endpoints, not the Redis servers, and pass the service name from &lt;code&gt;sentinel.conf&lt;/code&gt;. Setting &lt;code&gt;ServiceName&lt;/code&gt; switches the client into Sentinel mode: it asks the Sentinels for the current primary, connects to it, and follows a failover when one happens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;StackExchange.Redis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;ConfigurationOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ServiceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"exam-cache"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// matches "sentinel monitor exam-cache ..." in sentinel.conf&lt;/span&gt;
    &lt;span class="n"&gt;AbortOnConnectFail&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// keep retrying in the background instead of failing startup&lt;/span&gt;
    &lt;span class="n"&gt;ConnectTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SyncTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndPoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentinel-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;26379&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndPoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentinel-2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;26379&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndPoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sentinel-3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;26379&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConnectionMultiplexer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionFailed&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Redis connection failed: {EndPoint} {FailureType}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndPoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FailureType&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectionRestored&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Information&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Redis connection restored: {EndPoint}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EndPoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddSingleton&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IConnectionMultiplexer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The multiplexer is designed to be created once and shared, so it is a singleton; one per request is a classic way to run out of sockets. &lt;code&gt;AbortOnConnectFail = false&lt;/code&gt; keeps a Redis outage at startup from taking the whole service down (on the current 3.x client; with Sentinel on 2.x, the first connect can still throw if no Sentinel answers). Logging the connection events, with Serilog in my case, gives you a timeline when you later ask what happened during a failover. Authentication and TLS go on the same options object, with their values read from configuration.&lt;/p&gt;

&lt;p&gt;Then test failover on purpose. Stop the primary in staging while a load test runs and watch the next few seconds. Some requests will fail during the switch. Cache reads should treat a Redis error as a miss instead of returning a 500, but every miss is now a database query, at exactly the moment the database needs protection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache-aside without a stampede
&lt;/h2&gt;

&lt;p&gt;Cache-aside is the usual pattern: read from Redis, and on a miss load from the database and write the value back with a TTL. The weak spot is the miss. When a popular key expires, every request that sees the miss queries the database at once. Three defenses combine well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Locking.&lt;/strong&gt; One caller rebuilds the key; the rest wait briefly and read the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early refresh.&lt;/strong&gt; Hot keys are refreshed shortly before they expire, by a background job or probabilistically on read, so they never go cold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jitter.&lt;/strong&gt; A small random amount is added to each TTL, so keys written together do not expire together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a compact version of the first and third ideas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CacheAside&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IConnectionMultiplexer&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;IDatabase&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetDatabase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;GetOrLoadAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringGetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;())!;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"lock:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewGuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"N"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LockTakeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringGetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// another caller may have filled it&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;())!;&lt;/span&gt;

                &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringSetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;WithJitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;finally&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LockReleaseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;                          &lt;span class="c1"&gt;// someone else is loading: wait for it&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StringGetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;())!;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                &lt;span class="c1"&gt;// stop waiting and load directly&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="nf"&gt;WithJitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shared&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalMilliseconds&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lock only reduces load. If it fails in some edge case, two callers load the same data and nothing breaks; when it works, a thousand identical queries become one. If you can take a new dependency, &lt;code&gt;HybridCache&lt;/code&gt; (the Microsoft.Extensions.Caching.Hybrid package, announced with .NET 9 and stable since March 2025; it also runs on .NET 8) gives you per-process stampede protection and a local memory layer in front of Redis.&lt;/p&gt;

&lt;p&gt;For the hottest keys I prefer not to depend on expiry at all. If an exam starts tomorrow morning, a background job can load its definition and question set tonight and refresh them whenever the exam is republished. Expiry then only matters if that job fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  TTLs, and what not to cache
&lt;/h2&gt;

&lt;p&gt;A TTL is a statement about how wrong you are willing to be, and for how long. Content that changes only on publish, like exam definitions and question text, can take a long TTL plus explicit invalidation. Periodically recomputed aggregates and rankings should expire on the recompute interval. Small reference data can live long, ideally with an in-process memory cache in front of Redis, because a network round trip is still a round trip.&lt;/p&gt;

&lt;p&gt;Some things I keep out of the cache, or never treat as the only copy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Submitted answers, scores, anything financial.&lt;/strong&gt; A failover can lose the last writes. The database is the record; the cache is a copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-critical decisions.&lt;/strong&gt; Whether an exam window is open, or how much time a student has left, should come from server time and durable state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-reuse data.&lt;/strong&gt; Values read once per user mostly cost memory and drag the hit ratio down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large values.&lt;/strong&gt; Redis executes commands one at a time, so a big value makes every other client wait, and it eats bandwidth when you have none to spare.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rehearse the peak
&lt;/h2&gt;

&lt;p&gt;Because the peak is predictable, it can be rehearsed. At Gozine2 I used Locust load tests to find bottlenecks before the peaks arrived. Locust scenarios are plain Python, easy to write and review, and they can produce the "everyone arrives at once" shape that matters for exams. A minimal scenario against a staging API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;locust&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HttpUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;between&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ExamTaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HttpUser&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;wait_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# students read before they answer
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/exams/demo/sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sessionId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nd"&gt;@task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;exam_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/exams/demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exam info&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;next_question&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/sessions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/next&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/sessions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/answers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submit answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;locust &lt;span class="nt"&gt;-f&lt;/span&gt; exam_day.py &lt;span class="nt"&gt;--headless&lt;/span&gt; &lt;span class="nt"&gt;--host&lt;/span&gt; https://staging.example.com &lt;span class="se"&gt;\&lt;/span&gt;
       &lt;span class="nt"&gt;-u&lt;/span&gt; 5000 &lt;span class="nt"&gt;-r&lt;/span&gt; 500 &lt;span class="nt"&gt;--run-time&lt;/span&gt; 20m &lt;span class="nt"&gt;--csv&lt;/span&gt; exam_day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoints and numbers are placeholders; derive real ones from the registration count of the exam you are preparing for, and run Locust distributed (&lt;code&gt;--master&lt;/code&gt;, &lt;code&gt;--worker&lt;/code&gt;) once one machine cannot generate enough load. The spawn rate matters as much as the user count, because the ramp is the dangerous moment, when caches are cold and connection pools are still growing. Read percentiles, not averages, and watch Redis and SQL Server during the test. An average hides exactly the slow requests that make students hit refresh, and every refresh is more load.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQL Server is still on the hot path
&lt;/h2&gt;

&lt;p&gt;Redis takes reads off SQL Server, but writes still land there, misses still go there, and right after a failover the database briefly carries the read load alone. Alongside the Redis work at Gozine2, I optimized SQL Server queries, caching paths and hot APIs. The method barely changes: find the queries behind the hot endpoints (Query Store is the easiest place to start), give each one an index that covers it, and remove work the request does not need.&lt;/p&gt;

&lt;p&gt;On the EF Core side, project hot reads into small DTOs instead of loading full entities, and add &lt;code&gt;AsNoTracking&lt;/code&gt; when you do load entities for read-only work. For queries that run on every request, a compiled query helps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExamDbContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ExamSummary&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;GetExamSummary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;EF&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompileAsyncQuery&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ExamDbContext&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;examId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exams&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;examId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ExamSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StartsAtUtc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DurationMinutes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstOrDefault&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EF Core already caches query translation, but it still inspects the expression tree on every call to find the cached plan; a compiled query skips that step. The saving per request is small, and on an endpoint every student hits in the same minute small savings add up. Measure before and after. If the profile never showed query preparation as a cost, keep the code simple.&lt;/p&gt;

&lt;p&gt;If your system has a calendar-shaped peak like this and you want a second pair of eyes on it, that is the kind of &lt;a href="https://mahdimolavi.ir/services.html" rel="noopener noreferrer"&gt;backend and performance work&lt;/a&gt; I take on. The next note moves from the cache to what students are actually doing: adaptive tests, where the server chooses every next question.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>redis</category>
      <category>performance</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
