DEV Community

Chinyere Ukpong
Chinyere Ukpong

Posted on

One database query that fixed my race condition

Connuit

Connuit is an app I recently built. Pronounced in a french way, kind of the same way the french say "bonne nuit". Connuit automates repetitive tasks as workflows, and lets AI make decisions inside them. Said AI runs on a free tier with a daily limit. This seems safe; however, the underlying issue here is that 20 requests could hit the limit at the same time.

The AI used here is Gemini, and in order to implement an app-wide cap on the usage limit that won't exceed gemini's 500 hits per day limit, each user gets 50 AI calls a day, and the whole app is capped at 450 which keeps it under gemini's 500 requests per day.

The way this works in code is that the backend reads the database for today's count and checks that this count is less than 50 then proceeds to update it.

The version that looks right:

let usage = await Usage.findOne({ userId, day });
if (!usage) usage = new Usage({ userId, day, aiCalls: 0 });
if (usage.aiCalls >= 50) throw new LimitReachedError();
usage.aiCalls += 1;
await usage.save();

Enter fullscreen mode Exit fullscreen mode

Now picture a jar with a bunch of candy in it and a label across the jar stating that only 5 candies can be taken. Imagine two kids arrive at the same time and see 4 has been taken which is less than 5 so each of them register that info and reach for a piece of candy. This eventually means that 6 of the candies have been taken even though the set limit was 5.

A race condition is a bug where two things attempt to change the same value at the same moment. Now each request followed the right process however the error is in the gap between checking that today's count is less than the set limit and updating the count. The fix for this case is simply to make the process atomic. An atomic process happens as described as all-or-nothing,. This means the process cannot be interrupted simply because everything happens in one go.

await UsageModel.findOneAndUpdate(
  { userId, day, aiCalls: { $lt: limit } },   // the "find" part
  { $inc: { aiCalls: 1 } },                    // the "update" part
  { upsert: true }
);

Enter fullscreen mode Exit fullscreen mode

On a user’s first call of the day, there’s no counter to update yet. So we use an upsert, short for “update or insert”: if no counter exists, create one. That counter is then there for that same user’s later calls that day. The twist is what happens when the limit has been reached. The counter exists, but its count is not under the limit, so the filter doesn’t match it. MongoDB then thinks “no matching counter” and tries to create a new one. That’s where the unique index steps in.

A unique index on { userId, day } is a standing rule in the database: no two counters may share the same user and day. When the upsert tries to create a second counter for a full day, the database refuses and throws a duplicate key error (code 11000). We catch that error and treat it as “limit reached”.

The rule is enforced by the database at the moment of writing, so there’s no gap for another request to slip into.

The final takeaway from this is that if a check and an update are separate steps then another request can slip in between it and a much better way to go about this is to push the check into database the same time as an update. The database should be the one forcing your rules as trusting application code alone is often a bad way to go about it.

Here is the link to the repo below. Let me know how you would have solved this, perhaps with a lock or a redis counter?

GitHub logo deepracerchichi / connuit

AI workflow automation platform. Chain API calls and AI steps into pipelines that run on a schedule, a webhook, or a click, with durable retries, encrypted credentials, and live run history. React + TypeScript + Express + MongoDB + BullMQ.

Connuit

AI workflow automation where the AI makes the decisions. Chain steps on a canvas (fetch data, ask AI, post to Slack, Discord, Telegram or Gmail) and run them on a schedule, from a webhook, or when someone messages your Telegram bot. An If step lets the AI choose which path a workflow takes.

Live: connuit.vercel.app

Connuit landing page

What it does

A contact form posts to your workflow's private URL → the AI decides "is this a real sales lead?" → Yes: it writes a 3-line brief for #sales. No: it tells #support what the person needs.

The editor: a lead-triage workflow with an AI If step and two paths

  • Visual editor. A pan-and-zoom canvas on desktop and a vertical list on phones. Steps are friendly blocks (Hacker News, weather, any API, Ask AI, If, Slack, Discord, Telegram, Gmail) with an "Advanced" view for raw HTTP.
  • Branching. An If step asks the AI a yes/no question and takes the matching path. Any step can feed…




Top comments (0)