Exactly-once rewards on an at-least-once event bus by Alikhan UrumovExactly-once rewards on an at-least-once event bus by Alikhan Urumov

Exactly-once rewards on an at-least-once event bus

Alikhan Urumov

Alikhan Urumov

What shipped

The rewards and notifications layer of Funcy Network. The network publishes facts — a round settled, a player's first login of the day, a balance change — and one engine turns them into XP, levels, achievements and token bonuses, while a second service tells the player. Operators set campaign budgets, a daily emission cap and a global kill switch from the admin panel, with no redeploy.
Two Go services: 40 migrations and 592 test functions between them.

The constraint

Kafka delivers at least once, games retry, and pods restart mid-transaction. A reward paid twice is a mint bug. A reward lost is a broken promise, and the player notices first.

Paying exactly once

Each event is de-duplicated inside the same transaction that moves the player's metrics and streaks, so the dedup cannot drift from the effect it guards
A grant is recorded before any token is credited. The wallet call carries the same idempotency key end to end; if anything fails midway the grant stays pending and a reconciler finishes it with that key. Every grant ends applied or failed, never in between
Campaign budgets are checked and spent under a row lock, so concurrent issuance cannot overshoot the cap, and a concurrency test proves it
Transient failures retry the same message with the offset held; malformed ones go to a dead-letter topic before the commit
Bonuses still count against the wallet's hard daily mint cap. The engine never bypasses it
The consumer watches for new topic partitions, so events on a freshly added partition are read at once rather than after the next restart

Achievements that tell the truth

75 achievements, each a metric crossing a threshold, declared in one registry that states what every number means: a running total, a personal record, or a live value that can fall
"Not started", "not tracked" and zero are three different states, and the API says which, so a page never shows a fake zero
An unlock is granted once, forever, credits XP and leaves through an outbox

Notifications

De-duplication in two layers, in a fixed order: check, write the inbox row, then mark. Marking first would open a window where a crash loses the notification; a unique key in the database closes the remaining race
If the dedup store is down the message is retried rather than assumed unseen: a duplicate is worse than a delay
Round results go to each player's inbox and never to push or email: a message that reaches thousands of players every week is exactly how people learn to switch a channel off. Turning it on is an operator's decision, not a side effect of a deploy
Like this project

Posted Sep 28, 2026

XP, levels, 75 achievements and token bonuses for a network of games, driven by Kafka events and built so nothing is paid twice and nothing is lost.