Customer engagement · Full-time

Rewards to 300,000 people at once, exactly once

SNSoft runs marketing campaigns that pay real rewards to huge audiences. Paying someone twice costs money. Missing someone costs trust. I owned the system that makes sure neither happens.

Role
Senior backend developer
Timeline
Nov 2025 to Aug 2026
Scale
Up to 300K people per campaign
Stack
NestJS · Temporal · Pulsar
Illustration of SNSoft with demo data
300Krecipients per campaign
~1.5Mpayouts at peak
0double or missed payouts
0downtime during the engine switch
The problem

Huge campaigns, real money, servers that can fail

Several campaigns run at the same time, each reaching up to 300,000 people, processed by many servers in parallel.

  • Every reward is real moneyA duplicate payout is lost money. A missed one is an angry customer.
  • Servers can die mid-campaignA server can crash halfway through sending, leaving the campaign in an unknown state.
  • Messages arrive out of orderWith many servers working at once, the same work can be picked up twice.
  • No maintenance windowCampaigns run every day, so the engine had to be replaced while it was live.
What I did

From first conversation to running system

  1. Mapped every way it could go wrong

    Pod crashes, retries, reordering and two servers grabbing the same job. Each one needed a defined outcome.

  2. Made every payout claimable once

    Each payout is claimed with a version check, so only one server can ever send it.

  3. Added a reconciliation pass

    After a crash, a 4-pass reconciler marks what was sent and re-sends only what is missing.

  4. Swapped the engine while live

    Moved orchestration to Temporal in phases, keeping Pulsar for high-volume fan-out, without pausing a single campaign.

  5. Set architecture rules

    Introduced an L0 to L3 layering standard, checked automatically by dependency-cruiser.

See it work

What happens when a server dies mid-campaign

Simulation1 tile = 1,000 people
Under the hood

How it works

For technical readers: the moving parts, and the mechanism that makes it reliable.

Epoch gate + compare-and-swap

A payout row moves from pending to sent only if its version still matches. Two pods racing for the same payout cannot both win.

Mark first, then send

Work is marked before it is sent, so after a crash the reconciler knows exactly what is in flight and what is missing.

Hybrid engine

Temporal owns orchestration and recovery as explicit workflow code. Pulsar keeps doing what it is best at: high-throughput fan-out.

Enforced layering

An L0 to L3 dependency standard keeps business rules away from infrastructure, checked in CI by dependency-cruiser.

Engineering deep dive

How exactly-once works at this scale

For engineers: the stack, the dispatch path, the decisions behind it, and the code shape of the core mechanism.

Stack

Service
NestJSTypeScriptgRPC
Orchestration
Temporalparent / child workflows
Messaging
Apache PulsarShared subscription
State
MongoDBRedis
Quality
Jestdependency-cruiser

One reward, from campaign to wallet

Every step can be retried or run twice by another pod. Each one is written so a repeat is harmless.

  1. TemporalThe parent workflow runs targeting and creates one PENDING delivery row per recipient (idempotent).
  2. TemporalA child dispatcher workflow, keyed by campaign and epoch, pages through PENDING rows in chunks of 500.
  3. MongoDBEach row is claimed with a compare-and-swap PENDING to IN_PROGRESS. Only the winner continues (mark first, send second).
  4. RedisThe budget pool is debited atomically. If the daily cap would be exceeded, the row is held and operations are alerted.
  5. PulsarThe winner publishes {deliveryId, epoch}. A rate limiter releases messages gradually so downstream services are not flooded.
  6. ConsumerRe-reads the row (the row is the truth, not the message) and rejects anything stamped with an old epoch.
  7. MongoDBA second CAS IN_PROGRESS to SENDING. A pod that loses the race acknowledges and skips.
  8. ChannelThe reward is delivered and the row is marked SUCCESS or FAILED.
  9. ReconcilerA cron pass finds stuck rows after pod crashes, requeues only what is missing, and closes the campaign.

Delivery state machine

Pending→Claimed→Sending→Success/Failed

Every transition is a conditional update, so two pods can never both move the same row forward.

Key decisions

Epoch fencing instead of cancelling tasks

Problem
A restart or reschedule left up to 100K in-flight tasks from the old run. Cancelling them one by one was slow and expensive.
Choice
Each run gets an epoch number, stored in Redis and stamped on every message. Restarting bumps it. Old messages see a newer epoch and skip themselves.
Why
Restarts became instant. Old and new runs can briefly overlap, and correctness still holds.

Temporal + Pulsar hybrid

Problem
All orchestration lived in Pulsar consumers, so recovery depended on hidden consumer state.
Choice
Temporal owns orchestration, retries and recovery as explicit workflow code. Pulsar keeps the high-throughput fan-out.
Why
Each tool does what it is best at. Migrated in phases with zero downtime.

A daily budget pool above every campaign

Problem
5 to 10 campaigns run at once, each targeting different players. Together they could overspend.
Choice
One Redis pool per day (for example RM 1M) debited with atomic INCRBYFLOAT, guarded by a single-flight lock and TTL, in UTC+8. Near the cap, operations are notified to top up or pause.
Why
Overspend is impossible even across 1.5M deductions running in parallel.

The core check, simplified

delivery.consumer.tssimplified sketch
async handle(msg: { deliveryId; epoch }) {
  // the row is the source of truth, not the message
  const row = await deliveries.findById(msg.deliveryId);
  if (row?.status !== 'IN_PROGRESS') return;          // ack and skip

  // epoch fence: messages from an older run never touch the row
  if (msg.epoch < await campaigns.currentEpoch(row.campaignId)) return;

  // CAS: only one pod can move IN_PROGRESS -> SENDING
  const won = await deliveries.updateOne(
    { _id: row._id, status: 'IN_PROGRESS' },
    { $set: { status: 'SENDING' } },
  );
  if (won.modifiedCount !== 1) return;               // lost the race

  const ok = await channel.deliver(row);
  await deliveries.markTerminal(row._id, ok ? 'SUCCESS' : 'FAILED');
}

An open demo with the same architecture (business logic removed): nestjs-temporal-pulsar-demo ↗

Scale

1.5Mdispatches a day at peak
5-10campaigns running at once
6worker pods
1K-5Kmessages per second, per pod
0duplicate payouts

Reliability and operations

  • Rate-limited releaseLarge campaigns are drained gradually instead of all at once.
  • Single-flight schedulerLocks, claim-based CAS and unique indexes stop a job running twice across pods.
  • Never drop silentlyUnknown errors are not acknowledged, so Pulsar redelivers them.
  • Layering in CIAn L0 to L3 dependency standard is checked on every change by dependency-cruiser.
Illustration for SNSoft
The hard part

The engine had to be replaced while campaigns were running every day.

There was no quiet hour to switch over. Moving in phases, with Temporal taking over orchestration piece by piece, meant every campaign kept paying out while the foundation underneath changed.

The result

What changed for the business

  • Exactly-once delivery at full campaign scale
  • Survives pod crashes mid-campaign
  • Engine migrated with zero downtime and no delivery incidents
  • Recovery paths are explicit code, not hidden state
Next project

Dear Kyra

Read the story →