James Carl Sitsit

Reserve quota before the model call, then check what keeps it safe

AlgoLens moved quota reservation ahead of its model call to close a check-then-act race. What keeps the reservation from racing, and what the test shows.

By James Carl Sitsit · From the AlgoLens case study

A per-user usage quota has a classic race condition: two requests check the limit at the same time, both see one slot left, and both go through. In AlgoLens every uncached analysis is a paid model call, so that race spends real money past the limit.

The first version of the analyze route had exactly that order. A middleware checked the limit, the route called the model, and only then did it record the usage.

That order was the bug. Two requests from the same signed-in user could both pass the check before either one recorded its call. Both would spend on the model, and a limit with one slot left would allow two analyses.

Move the claim before the cost

The fix, a commit titled "atomic reserve/refund quota for analyze (matches optimize)", reserves a slot before the model call. For a signed-in user, the route now does this (excerpt from routes/analyze.js):

let usageId = null;
if (req.user) {
    const reservation = reserveFeatureUsage(req, 'analyze');
    if (!reservation.allowed) {
        return res.status(429).json({ error: reservation.error, reason: reservation.reason, message: reservation.message });
    }
    usageId = reservation.usageId;
}

If the model call throws, the catch block calls refundUsage(usageId), so a failed call doesn't use up the user's quota. The optimize route already worked this way. Moving analyze onto the same pattern fixed an inconsistency between two routes that both spend on model calls.

Guests don't take this path. They go through separate IP-level limits instead, so everything below is about signed-in users.

Ordering is necessary, not sufficient

Reserve-first only closes the race if the reservation itself can't interleave with another reservation against the same quota. If the helper read the count, awaited something, and then wrote, or if a second process could write in between, the race would just have moved inside the helper.

So the helper is the part worth showing. Here it is from services/usage.js:

function reserveFeatureUsage(req, type) {
    const userId = req.user.id;
    const ip = getIpAddress(req);
    const txn = db.transaction(() => {
        const check = checkFeatureLimits(userId, type);
        if (!check.allowed) return check;
        const info = db.prepare(
            'INSERT INTO usage_logs (user_id, ip_address, type) VALUES (?, ?, ?)'
        ).run(userId, ip, type);
        return { allowed: true, usageId: info.lastInsertRowid };
    });
    return txn();
}

What actually keeps this safe today is simpler than the transaction. The better-sqlite3 driver is synchronous, so the check and the insert run back to back with no await between them, and no other JavaScript runs until both finish. The backend also runs as a single process, so there's no second event loop writing to the same database. With both of those true, two reservations can't interleave, transaction or not.

The transaction is what would start to matter if that changed. With several API processes on one database, the check and the insert would need to be one unit in the database itself, and I'd want an immediate (write-locking) transaction rather than the default deferred one, so a second writer waits up front instead of failing with a busy error when its read upgrades to a write. Until then, the guarantee rests on one process and a synchronous driver, and I'd rather say that than credit the transaction for it.

What the test shows, and what it can't

A follow-up commit put the route's own rate limiter back and rewrote analyze-concurrency.test.js as a service-level test. It fires 20 reservations for one user against a daily limit smaller than 20, then asserts that exactly the limit's worth are allowed, the rest are denied with daily_limit, and the usage table holds exactly that many rows.

That checks the counting under a burst of calls. It can't check atomicity. Each call runs to completion before the next one starts, so the same test would pass with the transaction removed. It also calls reserveFeatureUsage directly rather than going through HTTP, because the rate limiter in front of the route would throttle 20 same-IP requests before the quota ever mattered. A test that could catch a missing transaction would need two processes writing to one database.

Refund is a second operation

The model call sits between the reservation and the refund. That makes the refund a compensating operation, not part of the same transaction. refundUsage deletes the reserved row by id. If that delete fails, it logs the error and moves on, so a rare failed refund costs the user a slot rather than crashing the request.

A cache hit keeps its reservation on purpose: a cached answer still counts as an analysis.

So reserving first was the easy half. The harder half was knowing what actually makes the reservation safe in this setup, and that the test in front of it couldn't tell me.


This came out of building AlgoLens, an AI pre-post reviewer for X that calls a model on every uncached analysis.