← Back
Home › Systems › Payments and commerce

promotions-engine

Decides which discounts apply to a basket and customer, and issues and redeems coupon codes within usage limits.

Shopify discounts

Open the promotions-engine pagepromotions-engine RCU (read-copy-update)concurrency: Readers access shared data with no locks or atomic writes; writers publish a new copy and free the old one after a grace period.›Sharded countersconcurrency: Split one hot counter into per-core or per-thread cells on separate cache lines; increments stay local and reads sum the cells.›Bloom filterdata-structures: Bit array answering "definitely not present" or "probably present" in about 10 bits per key for 1 % false positives; inserts are cheap, deletes impossible.›Interval treedata-structures: Index of ranges that answers "which intervals overlap this point or range?" in O(log n + k); static sorted-array form is the compact, cache-friendly choice.›Idempotency keysfinance: Client-chosen unique key per logical operation, stored with a request fingerprint and the final response, so retries replay the first result instead of acting twice.›Largest remainder roundingfinance: Splitting an amount into parts that sum exactly (largest remainder / Hamilton) for allocations, splits and prorationsMoney representationfinance: Store money as integer minor units with an explicit currency exponent, compute in wider integers, and round once with a named rounding mode.›Mixed-integer programming(branch and bound)optimization: Solves LPs with integer variables by solving the LP relaxation, branching on a fractional variable, and pruning subtrees whose LP bound cannot beat the incumbent.›Csprngsecurity: OS-backed cryptographically secure random numbers (getrandom), and when a fast PRNG is not enoughA/B testing (online controlledexperiments)statistics: Randomly split units into arms by a salted hash, log exposure, compare metrics with a confidence interval, and guard the result with sample-ratio, guardrail and peeking checks.›Optimistic concurrency controlstorage: Run transactions without locks, record what they read, and validate at commit that nothing changed; fastest when conflicts are rare.›Rete rules enginesystems: Match many rules against changing facts by caching partial matches in a shared network, so each change only does the work it actually affects.›Memory orderingconcurrency: What Relaxed, Acquire, Release and SeqCst atomics actually guarantee, what they cost on x86 and ARM, and which one each pattern needs.›False sharingconcurrency: Independent variables written by different cores on one cache line force the line to bounce between cores; pad or regroup hot data to fix it.›Leasesdistributed: A lock that expires on its own: bounded-time ownership that survives crashes and partitions, given bounded clock drift and fencing tokens on writes.›Cryptographic hashes (SHA-2,BLAKE3)security: Collision-resistant fingerprints of data; SHA-256 for interoperability, BLAKE3 for speed, HMAC/HKDF for keyed uses.›Exponential backoff with jittersystems: Retry after delays that grow geometrically and are randomized, so failed clients spread out instead of hammering a recovering service in synchronized waves.›d-ary heapdata-structures: Array heap with d children per node (d = 4 typical): half the binary heap's depth, siblings in one cache line, faster priority queues.›Cutting planesoptimization: Valid inequalities added to an LP relaxation to cut off its fractional optimum without removing any integer point; the engine of branch-and-cut MIP solvers.›Simplex methodoptimization: Solves linear programs by walking along the edges of the feasible polytope from vertex to vertex, one basis swap (pivot) at a time; the workhorse behind MIP solvers.›Presolve reductionsoptimization: Cheap, provably safe reductions run before the solver (fix variables, drop rows, tighten bounds and coefficients) so LP and MIP solves start from a smaller, tighter model.›Bootstrap confidence intervalsstatistics: Resampling-based confidence intervals for arbitrary statisticsCUPED (variance reduction withpre-experiment covariates)statistics: Subtract the part of each unit's outcome that a pre-experiment covariate predicts; the effect estimate stays unbiased and its variance shrinks by 1 - rho^2.›Experiment layering(overlapping experiments)statistics: Partition the tunable parameters into layers, hash each unit with a per-layer salt so layers are independent, and give experiments inside a layer disjoint bucket ranges.›+ 6 moreCPU caches and the memoryhierarchyhardware-os: Sizes, line widths and latencies of L1/L2/L3/DRAM on modern x86 and Apple M-series, and the data-layout rules that follow from them.›Fencing tokensdistributed: A number that grows with every lock grant and is checked by the protected resource, so a paused ex-holder's late write is rejected.›PRNGs (PCG, xoshiro)numerics: Small, fast, statistically strong non-cryptographic generators: PCG32 and xoshiro256**, with seeding, parallel streams and unbiased ranges.›Floating-point errornumerics: How IEEE 754 rounding error accumulates and how to contain it: compensated (Kahan/Neumaier) and pairwise summation, error-free transformations with FMA, ULP comparisons.›LU and Cholesky factorizationnumerics: Factor A = PLU (any square matrix, partial pivoting) or A = LLᵀ (SPD) once in O(n³), then solve each right-hand side in O(n²).›Sparse matrices (CSR)numerics: Compressed sparse row stores only nonzeros as row pointers, column indices and values; SpMV streams them once, so it is memory-bandwidth bound.›Fast hash functionsdata-structures: Non-cryptographic hashes built from 64×64→128-bit multiplies and xor-shifts; pick by key size, trust level and whether the hash is persisted.›Interval arithmeticnumerics: Rigorous bounds with outward rounding; root isolation and robust geometric tests+ 3 more+ 1 more

How it is built

Architecture overview

A promotions engine decides, for a basket of items and a customer, which discounts apply, in what order, and how much each line is reduced; it also issues and redeems coupon codes and enforces limits such as "one per customer" or "first 1,000 redemptions". It sits in the checkout path of retailers, marketplaces, subscription businesses and booking sites, so it is read on every cart change and must answer in milliseconds, while a marketer can add a new rule at any time without a deployment.

The idea that makes it work is to separate three questions that are easy to mix up. Eligibility asks which promotions could apply to this basket; it is a rule-matching problem over changing facts. Selection and stacking asks which of the eligible ones to apply, given exclusivity and priority; it is a small optimisation problem. Redemption asks whether the shared limit still has room and records the use; it is a concurrency problem on a counter that many carts hit at once. Each has a different data shape and failure mode, and the engine stays understandable only if the contract between them is explicit: eligibility returns a set, selection returns an ordered plan with amounts, redemption commits the plan or refuses it.

Parts:

 marketing UI --> rule store (versioned) --publish--> RCU snapshot per node
                                                         |
 cart / checkout --basket, customer, code-->  +----------v-----------+
                                              | eligibility matcher  |  Rete / hash lookups
                                              +----------+-----------+
                                                         | eligible set
                                              +----------v-----------+
                                              | selection + stacking |  priority, exclusivity,
                                              | (greedy or MIP)      |  best-for-customer
                                              +----------+-----------+
                                                         | plan: per-line amounts (integer cents)
                                              +----------v-----------+
 order placed --idempotency key-------------->| redemption ledger    |  limits, per-customer,
                                              | (counters, OCC)      |  global budget
                                              +----------------------+

How the parts fit together

Rule store and distribution

A promotion is data: a condition on the basket and customer, an effect, a validity window, a priority, an exclusivity group, and limits. Marketers change it daily, the checkout path reads it thousands of times a second, so the pattern is read-mostly. RCU gives readers a pointer to an immutable snapshot with no lock and no atomic write; a publisher builds the new rule set, swaps the pointer with release semantics, and frees the old one after a grace period. The cost of this is that a cart in progress may finish on the old version, so the plan records the rule-set version it used, and a redemption refuses a plan older than the one stored for a promotion that was just paused. Lookups by promotion id and code hash use Fast hash functions on a table sized to stay inside the CPU caches and the memory hierarchy (the whole active rule set is small, tens of thousands of rules at most, order of).

Eligibility matcher

Naively, evaluate every promotion against every cart. With a few hundred active rules that is fine; with many thousands and rules like "buys 2 of category A and at least 50 in total, and is a member" it wastes time because most conditions are shared. A Rete rules engine compiles the conditions into a network and caches partial matches, so a cart change re-evaluates only the affected parts. Its internal joins are the Hash join pattern on the smaller side, with Swiss table for the memories and an Arena allocator so the per-cart working set is freed in one step. The graph lists event-condition-action rules as the alternative: for a small, flat rule set that just says "when this event, if this predicate, do that", the simple loop is easier to audit and often faster. Pick Rete when rules are many and share patterns; pick the loop when they are few.

Time windows ("Black Friday, 00:00 to 23:59 in the shop's timezone") sit in an Interval tree keyed by validity range so "which promotions are active now" is a stabbing query instead of a scan. The contract with selection is a set of match records: each carries the promotion id, the lines it matched (with quantities), and its declared effect. Eligibility does not compute amounts.

Code service

Single-use and personal codes must be unguessable, otherwise a bot can enumerate them. Codes come from a csprng (no page yet) with enough entropy and a checksum character to catch typos, and the system stores a hash (Cryptographic hashes (SHA-2, BLAKE3)) rather than the code itself, so a database leak does not reveal live codes. The most common request is an invalid code, either a typo or a guess; a Bloom filter over valid code hashes answers "definitely not a code" without a database read, which also blunts guessing traffic. The graph names cuckoo hashing and xor filters as alternatives; the Bloom filter wins when the set is updated in place and a small false-positive rate (which only costs a database lookup) is acceptable. Rate-limit code attempts per customer and per address, with Exponential backoff with jitter on the client side for retries.

Selection and stacking

Eligible promotions interact. "10 % off the order" and "free shipping over 50" may stack; "20 % off shoes" and "buy one get one half off shoes" are in one exclusivity group and the customer should get the better one. Most shops resolve this with an ordered pass: sort by priority, apply, mark the exclusivity group consumed, continue. That is a greedy algorithm over a heap (D-ary heap) and it is predictable, which merchants like.

Greedy is not optimal. When a coupon can attach to different lines, or the discount base changes with order (percent off after a fixed-amount off is different from before it), the best plan for the customer, or the plan with the lowest cost to the shop under a budget, is a combinatorial choice: binary variables for "apply promotion p to line l", linear constraints for quantities, exclusivity and caps. That is Mixed-integer programming, solved by branch and bound over Linear programming (simplex) relaxations that factor basis matrices with LU and Cholesky factorisation over Sparse matrices (CSR). Cutting planes and Presolve reductions shrink the search; baskets are small, so these models usually solve in a few milliseconds, but the worst case is exponential, so the call has a time limit and falls back to the greedy plan. In practice most systems keep greedy as the default and call the MIP only for cart shapes where the two can differ, or offline to audit how much greedy leaves on the table.

The output contract is a plan: promotion applications with, per line, an amount in integer minor units.

Pricing arithmetic

Amounts are integers in minor units with a stated rounding rule, per Money representation; never binary floats. An order-level discount (5 off the whole basket) has to be allocated to lines for tax and returns, and the line amounts must sum to the total exactly; largest-remainder-rounding (no page yet) distributes the leftover cents. Percentages are applied to the right base (before or after other discounts) and rounded once per line, a decision recorded as a rule, since Floating-point error shows how an intermediate float can flip a cent. Solvers compute in floating point, so the MIP's answer is turned into integers and verified by recomputing in exact arithmetic; interval-arithmetic (no page yet) is the tool when a solver's bound must be certified rather than trusted.

Redemption ledger

A limit such as "first 1,000 redemptions" or "one per customer" is a shared counter touched by many carts at once, and a launch-day promotion makes it a hot key. A single atomic counter on one cache line suffers False sharing and cache-line bouncing between cores; Sharded counters split it into cells and sum them for reading, with the budget divided across cells in advance (each cell holds a slice of the remaining allowance, and slices are rebalanced when one runs dry). The per-customer limit is cheap because it is keyed by customer and rarely contended.

Redemption is committed with Optimistic concurrency control: read the promotion's used count and version, check the limit, write with "if version unchanged"; on conflict re-read and retry with backoff. Each commit carries an Idempotency keys value (the cart or order id), so a client retry after a timeout does not redeem twice. Between "check at cart time" and "commit at payment time" the system may place a reservation, a Leases style hold that expires on its own if the shopper abandons, so abandoned carts do not eat the budget. A pause by the lease holder (a long GC) is the classic hazard, and Fencing tokens on the ledger reject a late commit from an expired holder. In-process counters need correct Memory ordering when they publish "budget exhausted" to other threads.

The contract: the ledger returns committed, refused (limit reached), or duplicate (already committed with this key); checkout turns "refused" into a visible message and removes the discount.

Experiments

A promotion costs margin; whether it pays back is an experiment question. A/B testing assigns customers by Deterministic bucketing, keeps promotion tests in their own Experiment layering layer, and compares incremental margin, not just redemptions (a coupon used by people who would have bought anyway is a cost). CUPED variance reduction and Hypothesis testing give the interval, a Sequential probability ratio test (with sequential-hypothesis-testing (no page yet)) allows early stops when a coupon is losing money fast, and bootstrap-confidence-intervals (no page yet) handle ratio metrics. Redemption rates on small segments use Bayesian rate estimation or the wilson-score-interval (no page yet) so a 2-out-of-2 does not look like 100 %. Randomised choices (which of two offers a visitor sees) use PCG and xoshiro generators seeded from the bucket.

Data and control flow

Pricing one cart (milliseconds, order of).

  1. The cart service sends basket lines, customer id and any entered codes to the engine.
  2. The engine reads the current rule snapshot (one pointer load) and resolves entered codes: Bloom filter, then hash lookup to the promotion.
  3. The matcher computes the eligible set: automatic promotions through the Rete network or hash lookups by category and customer segment, code promotions directly.
  4. Selection orders and filters the set; for risky shapes it runs the MIP with a time limit.
  5. Pricing computes per-line integer amounts and allocates order-level discounts.
  6. The response lists applied promotions with amounts and reasons ("rejected: exclusive with X"), plus the rule-set version; nothing is redeemed yet.

Placing the order.

  1. Checkout sends the plan with the order id as the idempotency key.
  2. The ledger checks the per-customer limit and takes from the global counter cell; on conflict it retries.
  3. If any promotion is refused, the engine re-prices the cart without it and returns the new total for the shopper to confirm; if all succeed, the redemptions are committed with the order in one transaction.
  4. If payment later fails, a compensating release returns the counts, again idempotent.

Launching a new promotion.

  1. The marketer drafts it; validation checks the rule compiles, the dates make sense and the budget is set.
  2. A dry-run replays recent carts and reports how often it fires and the discount volume.
  3. The rule set is published as a new snapshot, optionally behind an experiment layer.

Scaling

  1. Hot counters. The first real limit is the global redemption counter during a launch. Shard it, pre-allocate slices, and accept that the limit may be exceeded by at most the unspent slices unless the budget is a hard legal cap.
  2. Read volume. The cart path is pure reads on a small snapshot; replicate the snapshot to every service node and scale horizontally.
  3. Rule count. Grows with marketing activity. Index rules by the cheapest discriminating attribute (code, category, segment) before any rule network runs.
  4. MIP time. Cap baskets and models, warm-start from the greedy plan, and cache plans keyed by basket shape.
  5. Hotspots in the data store. Campaign-start spikes hit a few keys. Use per-key batching, and run the ledger on its own store so that checkout reads do not compete.
  6. Experiments. More concurrent promotion tests need more layers or more traffic; use variance reduction first.

Failure modes

FailureSymptomCauseFix
Over-redemptionA "first 1,000" promotion used 1,300 timesRead-check-write on the counter without atomicity, or slices not reclaimedOptimistic concurrency with version check; reserve/commit; slice rebalancing
Double redemptionCustomer charged discount twice, budget drops twiceClient retry after timeout without idempotencyIdempotency key per order; return the first result on replay
Stacking surpriseCustomer gets a discount larger than intendedTwo promotions both apply because exclusivity was not declaredExclusivity groups; maximum total discount per line and per order; dry-run on recent carts
Cent mismatchInvoice and cart disagree by a centFloat arithmetic, or rounding at different layersInteger minor units, one rounding rule, largest-remainder allocation
Stale rulesExpired promotion still applies on some nodesSnapshot rollout lag, long-lived cart on old versionVersion in the plan; refuse commit when the version is older than the pause
Code enumerationSpike of failed code lookups, then valid ones usedShort or sequential codesRandom codes with enough entropy, hashed storage, per-address rate limit
Abandoned-cart budget leakBudget consumed, few orders placedReservation without expiryLease with expiry; periodic release; fencing for late commits
Solver timeoutCheckout slow on large cartsMIP exponential worst caseTime limit and greedy fallback; cap basket size for the MIP
CannibalisationMargin falls, volume flatCoupons given to buyers who would have paid full priceIncrementality experiment against a holdout; target by predicted sensitivity

Real implementations

Retailers' promotion engines (the Examples line names Shopify discounts, and the impact estimate lists Talon.One and Voucherify) are proprietary and are not listed. The open-source items implement the rule engine, the commerce-platform promotion model and the solver parts.

namewhat it isrepolicencenotes
DroolsBusiness rules engine using the Phreak algorithm, an evolution of Reteapache/incubator-kie-droolsApache-2.0Eligibility matcher; docs
Clara RulesForward-chaining rules engine in Clojure(Script)cerner/clara-rulesApache-2.0Eligibility matcher
SaleorHeadless commerce platform with promotions and voucherssaleor/saleorBSD-3-ClausePromotion rules defined as predicates; docs
MedusaCommerce modules including a Promotion modulemedusajs/medusaMIT, enterprise materials excludeddocs
Spree CommerceOpen-source commerce platform with a promotions modelspree/spreeBSD-3-Clausedocs
OR-ToolsOptimisation suite with MIP and CP-SATgoogle/or-toolsApache-2.0Selection part
HiGHSLP and MIP solverERGO-Code/HiGHSMITSelection part
GrowthBookA/B testing and feature flagsgrowthbook/growthbookMIT (core); enterprise directories under a separate licenceExperiment part

Sources

Similar systems

Share the most building blocks with this one.