C-DAC Noida · 2026 · Design owner · backend
Exactly-once rewards across three services
A watch-to-earn flow for a video platform: viewers earn coins for watching ads, coins land in a wallet, and wallets pay out to bank accounts. I own the design and the three backend services behind it.
- PostgreSQL
- Redis
- MongoDB
- Job queues
- Server-Sent Events
- S3-compatible storage
At a glance
- One idempotent, bounded call is the only way money moves between services
- Eight invariants, each enforced in exactly one place
- Concurrency bugs reproduced against real Postgres and Redis before they were fixed
Built at C-DAC. Client, product and infrastructure details are withheld; code is not public.
The problem
Money moves when a video finishes playing, which is a client event. Clients retry, networks drop, people open five tabs, and a modified client can simply lie. Every one of those cases still has to credit a watch at most once, never spend more than a brand budgeted, and never let a user earn past the daily cap.
Where the seams go
Three services, with responsibilities split so that each rule has exactly one owner:
- The wallet is not a referee. It credits and debits users within hard bounds, keeps the append-only ledger, and cannot name an ad.
- The ads service owns every policy decision: who earns, how much and how often. It also owns campaigns, brand budgets and media.
- One money-moving call joins them: synchronous, idempotent and bounded. No client-held watch token, no reservation limbo, no status polling.
- Payments sit downstream and turn wallet balances into payouts through a gateway.
The earn flow
- 1
Feed
The client loads ads. Each item says whether it can earn and, if not, why. Unearnable ads still play; only earning is suppressed.
- 2
Start
When an earnable ad starts, the client calls
watch/start. The ads service runs its policy checks and records a view with the server's start time. - 3
Complete
At the threshold the client calls
complete. The ads service checks elapsed time on its own clock, atomically claims the view (started → crediting) and debits the brand budget with a conditional update that can never go negative. - 4
Credit
Ads calls the wallet with a scoped service token and a deterministic idempotency key. The wallet checks the per-credit ceiling, reserves the daily cap, then writes the ledger row and the wallet balance in one Postgres transaction.
- 5
Notify
After commit the wallet publishes the new balance, and the client gets it over Server-Sent Events while the next ad plays.
Invariants
Every guarantee is enforced in exactly one place, so there is one place to read when it breaks:
| Invariant | Enforced by |
|---|---|
| One earning event credits at most once | Unique idempotency_key on the Postgres ledger |
| No single credit exceeds the ceiling | Wallet, on every request |
| No user earns past the daily cap (plus the one credit that crosses it) | Atomic Redis reservation before the credit, ledger as fallback |
| The ads token cannot touch payout money | Scoped service tokens on the wallet |
| A brand budget never goes negative | Conditional $inc in the ads store |
| Watch time is measured by the server | Server-side startedAt; client timing is telemetry only |
| Ledger, wallets and ads records agree | Reconciliation sweep and drift checks on both sides |
| Clients cannot write money | No public mutating wallet route |
Failure paths, all boring on purpose
- Daily cap reached: the ads service refunds the budget and marks the view rejected; the client stops starting earnable ads for the day.
- Wallet unreachable: the view stays in
crediting. A sweep every 60 seconds retries the identical call with the same key, so it can never pay twice. - Watched too little:
422with watched and required seconds. The row stays open and the user can retry after actually watching. - Budget exhausted at debit: the view is rejected and the ad disappears from earning feeds.
- Modified client ignores the earn flag: the policy check at
watch/startand the unique key in the wallet stop it anyway. The flag is cosmetic.
Two bugs worth telling
A burst beat the daily cap. The cap check read a counter, credited, then incremented. Every request in a concurrent burst saw the same stale value and passed. Reproduced locally: a user at 45 of 50 firing 50 parallel credits earned 295 coins. The fix reserves the amount with an atomic INCRBY before crediting, so exactly one request can cross the line, and releases the reservation on every path where the credit does not land, including idempotent replays. A cold key is seeded from the ledger with SET NX so a late seeder cannot overwrite reservations already made.
Replays came back as 500s. The data-access layer wraps the database error, so the 23505 unique-violation code sits one level down the cause chain, not on the thrown object. Six hand-written checks read the code from the outer wrapper, never matched, and a replayed money call escaped as a 500 instead of its contracted “already credited”. The fix is one helper that walks the cause chain with a depth cap, used at all six sites. Six duplicate-fire tests run against real Postgres and Redis, and before trusting them I reran them with the helper limited to depth 1: all six failed, which is what proves they test the fix.
The reconciler's race is reproduced the same way: a second connection holds a competing insert on the same key in an open transaction, so the reconciler blocks on the unique index and receives the violation at commit.
Ads side
- Campaign lifecycle with a recorded status history, from draft through upload, transcoding and approval to active, paused or exhausted.
- Publishing is gated by device-bound step-up authorization: a grant earned on one device does not follow a token replayed from another.
- Media goes straight to object storage on a presigned URL; transcoding is queued, and playback URLs are signed for five minutes.
- Earnability for a whole feed page is resolved in one aggregation, and daily limits follow the IST calendar day so the date in the idempotency key and the reset rule always agree.
- Per-ad analytics are bucketed hourly or daily in IST.
Money out
- Payment gateways sit behind one adapter interface, so the rest of the service never knows which gateway it is talking to.
- Every ledger entry carries a unique idempotency key, and the debit, credit and commission legs of one movement derive theirs from a shared prefix, so a replayed movement collides on every leg.
- Payout requests move through
pending → approved → processing → success | failed | reversed, and a failed payout returns its coins to the wallet. - Scheduled payout generation derives its key from the period and the user, so re-running a batch cannot create a second request.
Trade-offs I chose
- The credit that crosses the daily cap is paid in full. One bounded overshoot is far simpler to reason about than partial credits.
- Redis is the fast path for caps and balances; the ledger is the source of truth and the fallback for both.
- Server-measured watch time can under-count on a slow network. I accepted that: a client-reported number cannot be allowed to decide a payment.
How I'd build it in Spring Boot
Every decision above maps directly onto the Spring stack:
- One money transaction: a
@Transactionalservice method that inserts theLedgerEntryand updates theWallet, with the idempotency key backed by aUNIQUEconstraint in a Flyway migration. - Row lock per user: a Spring Data JPA finder annotated
@Lock(LockModeType.PESSIMISTIC_WRITE), which issuesSELECT … FOR UPDATEon the wallet. - Optimistic checks on paths that don't take the lock:
@Versionon the wallet entity, retrying a bounded number of times onObjectOptimisticLockingFailureException. - Recognising a replay: catching
DataIntegrityViolationExceptionis not enough, because a CHECK or foreign-key failure is one too. Walk the cause chain to theSQLException, require SQLState23505and the idempotency constraint's name, and only then answer “already credited”. - Daily cap reservation:
StringRedisTemplate.opsForValue().increment(key, coins)before the credit,decrementto release, andsetIfAbsent(key, seed, ttl)to seed a cold key from the ledger. - Reconciler and drift check:
@Scheduledjobs kept to one instance across replicas with ShedLock or a RedissetIfAbsentlock whose TTL is shorter than the schedule. - Balance push: an
SseEmitterper connected user, fed by a Redis pub/subMessageListenerafter commit (@TransactionalEventListener(phase = AFTER_COMMIT)). - Scoped service tokens: Spring Security with one authority per allowed transaction type, so the rewards caller's token cannot touch payouts.
- Payment gateways: a
PaymentGatewayinterface with one@Componentper gateway, injected as aMap<String, PaymentGateway>and chosen by configuration. - Payout state machine: an enum of allowed transitions, applied with a conditional
UPDATE … WHERE status = :expectedthrough@Modifying @Query, so two workers cannot move the same payout. - Queued transcoding: a Kafka or RabbitMQ listener with retries and a dead-letter topic (
@RetryableTopic). - Tests: JUnit 5 with Testcontainers Postgres and Redis; duplicate fires launched from an
ExecutorServicebehind aCountDownLatchso they really race.