The Async Pipeline, part 5
Rate Limiting Without a Database Lock
How Lumcast enforces weekly quota limits using DynamoDB conditional expressions instead of locks: a two-tier atomic update pattern that handles week rollovers without a scheduler.
AWS / DynamoDB / Rate Limiting / Serverless / Concurrency
Part 4 showed how audio is assembled from synthesised chunks. Before any of that happens, the system needs to decide whether this user is allowed to generate a podcast at all. This post is about the quota system — specifically, how to enforce a weekly usage limit atomically in DynamoDB without a distributed lock, a scheduler, or a separate cache layer.
The Problem With Naive Quota Checks
The obvious implementation of a rate limit is read-then-write:
# Pseudocode — the naive approach (broken under concurrency)
user = db.get_item(user_id)
if user["quota_used"] >= user["quota_limit"]:
return reject()
db.update_item(user_id, quota_used=user["quota_used"] + 1)
This is a classic read-modify-write race condition. Two concurrent requests both read quota_used = 199, both check 199 >= 200 (false), both increment to 200. The quota has been exceeded by one generation, consistently, for every pair of concurrent requests near the limit.
In a relational database you’d wrap this in a transaction with SELECT FOR UPDATE. DynamoDB has transactions (TransactWriteItems) but they add latency and cost. For quota enforcement, there’s a cleaner solution: conditional update expressions with atomic ADD.
The Two-Tier Update Pattern
Lumcast’s quota state in DynamoDB is two fields:
quotaWeek: the ISO calendar week string when the user last generated ("2026-W22")quotaCount: the number of quota slots consumed this week
No timestamp, no cron reset job. The week key IS the state. Here is how the atomic deduction works:
# Pseudocode — two-tier conditional quota deduction
def deduct_quota(user_id, cost, week_key, weekly_limit):
# Tier 1: try to add to an existing, non-exhausted week
try:
table.update_item(
Key={"userId": user_id},
UpdateExpression="ADD quotaCount :cost",
ConditionExpression=(
"quotaWeek = :week "
"AND quotaCount <= :headroom"
),
ExpressionAttributeValues={
":cost": cost,
":week": week_key,
":headroom": weekly_limit - cost,
},
)
return True # deducted successfully
except ConditionalCheckFailedException:
pass # either wrong week, or over limit
# Tier 2: try to start a fresh week
try:
table.update_item(
Key={"userId": user_id},
UpdateExpression="SET quotaWeek = :week, quotaCount = :cost",
ConditionExpression=(
"attribute_not_exists(quotaWeek) "
"OR quotaWeek <> :week"
),
ExpressionAttributeValues={
":week": week_key,
":cost": cost,
},
)
return True # new week started, cost recorded
except ConditionalCheckFailedException:
pass # same week — first tier failed because over limit, not wrong week
return False # over limit
Tier 1 succeeds when: the current week matches AND quotaCount + cost <= limit. It atomically adds cost to quotaCount in one operation — no read, no separate write, no window for a race.
Tier 2 runs when Tier 1 fails. It succeeds when: the stored week is either absent or different from the current week (i.e., the week has rolled over). It resets both fields atomically. The condition quotaWeek <> :week ensures that if two requests race at the week boundary, only one wins — the second finds quotaWeek = current_week and falls through to return False correctly.
The Week Key
# Pseudocode — ISO calendar week key generation
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
year, week, _ = now.isocalendar()
week_key = f"{year}-W{week:02d}" # e.g., "2026-W22"
ISO calendar weeks start on Monday and end on Sunday. Week 1 is the first week containing a Thursday. Using a string like "2026-W22" has several advantages over a Unix timestamp:
- It is human-readable in the DynamoDB console
- Comparison is correct lexicographically (same year; larger string = later week)
- Week rollover is handled by the two-tier logic — no cron job resets the counter
One edge case: week 53. The ISO calendar allows weeks 1–53. Year boundaries fall awkwardly. A week starting in December might be "2025-W01" of 2026. Python’s isocalendar() handles this correctly. If you format the week key yourself, use %G-W%V strftime codes, not %Y-W%W.
Preflight vs Deduction: Why Two Locations?
The quota logic appears in two places with different semantics:
SQS consumer — non-atomic preflight:
Before starting a Step Functions execution, the consumer reads quotaCount and rejects the request if the user is clearly over quota. This is a best-effort check. It can lose a race — two concurrent requests could both pass a preflight for a user with one slot remaining. That’s acceptable. The preflight exists to avoid starting expensive SFN executions for obviously-over-quota users, not to provide airtight enforcement.
SFN callback — atomic deduction:
After the pipeline succeeds, sfn-callback runs the two-tier atomic update. This is the authoritative enforcement point. If the preflight missed a race, the callback catches it. The user ends up with a podcast they generated, and their quota is deducted correctly.
The deduction happens after synthesis, not before. This is a deliberate product decision: you don’t charge a user quota for a failed generation. If the Bedrock call fails, the callback writes status: failed and the quota is untouched. The user retries without losing a slot.
Quota Cost by Voice Quality
Not all podcasts cost the same number of slots:
| Voice quality | Slots per minute |
|---|---|
| standard | 1 |
| neural | 2 |
| generative | 4 |
The deduction is: cost = SLOTS_PER_QUALITY[voice_quality] × ceil(duration_seconds / 60).
For a 15-minute podcast with a neural voice: 2 × 15 = 30 slots. The free plan baseline is 200 slots per week — enough for roughly 6 such podcasts. This tiered cost model lets the system offer generative quality without giving it away for free: a 15-minute generative podcast costs 60 slots, leaving room for only 3 per week on the free plan.
Why Not DynamoDB Transactions?
TransactWriteItems would let you read quotaCount, check it against the limit, and increment — all in one atomic operation with full read isolation. This would simplify the code. There are two reasons not to use it here.
Latency. Transactions add overhead. A conditional update is a single round trip. A transaction with a ConditionCheck + Update is also a single round trip but at higher cost, and with a larger response payload.
Unnecessary. The two-tier conditional pattern achieves the same correctness guarantee for this use case. The conditional expression on the ADD prevents going over quota; the second conditional prevents a stale week reset. Transactions are the right tool when you need to atomically read one record and write another. Here you’re only writing one record.
Apply This
1. Use ADD with a ConditionExpression for atomic increments.
UpdateExpression: "ADD counter :n" combined with a condition that bounds the counter is the DynamoDB way to enforce rate limits. No transaction needed, no lock needed, no read needed. The condition is evaluated server-side against the current value atomically.
2. Encode rolling windows as key strings, not timestamps.
A week key like "2026-W22" embeds the window boundary in the data. Rollover detection is a string comparison. No cron job. No scheduled Lambda. No clock drift. The data self-describes when the window resets.
3. Separate preflight from enforcement, and be explicit about which is which. A preflight check reduces wasted work. An atomic enforcement check provides correctness guarantees. Never collapse these two roles into one. The preflight can be non-atomic and optimistic; the enforcement must be atomic and conservative.
4. Deduct quota on confirmed success, not on request. Charging before work is done means failed operations cost quota. Charging after means you occasionally under-charge for work that partially succeeded — a much better failure mode from a user trust perspective. Design your quota deduction to be the last write of the success path, never the first write of the request path.
5. Test the week boundary explicitly.
The ISO week boundary is the hardest case to get right. Add a unit test that passes a now value of Sunday 23:59:59 UTC, runs a deduction, then passes Monday 00:00:01 UTC and verifies the counter resets. This test will catch the %Y-W%W vs %G-W%V mistake before production does. Post 6 covers the final piece of the pipeline: how series episodes build on each other using a mechanism that’s almost the opposite of a rate limit — injecting remembered context rather than enforcing forgetting.