DEV Community

Deland
Deland

Posted on

Ranking by exponential decay without a cron job

I built a board of 1,000 tiles where anyone can pay to take a tile away from whoever currently holds it. Tiles are sorted by "heat" — a value that decays over time — so the board reorders itself continuously.

The obvious way to do that is a scheduled job that recomputes every row on a timer. I didn't want a scheduled job. Here's how you avoid one.

The problem

Heat is the sum of every payment a tile has received, each one decaying exponentially:

heat(t) = Σ Aᵢ · 2^(-(t - tᵢ) / H)
Enter fullscreen mode Exit fullscreen mode

Aᵢ is a payment, tᵢ is when it landed, H is the half-life (3 days, in my case).

Every term shrinks as t advances, so every tile's heat changes every second. Sorting by that looks like it requires recomputing all 1,000 rows on a timer. With a million rows, that timer stops being a detail and becomes the whole system.

Collapse the sum first

You never need to store individual payments. The entire sum can be represented by a single pair (P, t_P). When a new payment A arrives:

P'   = P · 2^(-(t_new - t_P)/H) + A
t_P' = t_new
Enter fullscreen mode Exit fullscreen mode

One row, two columns, no matter how many payments a tile has taken over its life.

Then take the log

Let k = ln2 / H:

ln(heat(t)) = ln(P) - k·(t - t_P)
            = [ln(P) + k·t_P] - k·t
Enter fullscreen mode Exit fullscreen mode

Look at the two halves separately. The -k·t term depends only on the current time, which means it is identical for every row in the table. A term that is the same for every row cannot affect their relative order. Drop it.

What's left is ln(P) + k·t_P. There is no t in it. It's a constant that changes only when someone pays.

Store it as rank_key, put an index on it, and:

SELECT * FROM tiles ORDER BY rank_key DESC
Enter fullscreen mode Exit fullscreen mode

is a permanently correct real-time ranking. Nothing is recomputed on a schedule. A row is rewritten only when money actually moves. The ordering is right one second after launch and still right three years later, with no job having ever run.

Log space is also where you add heat. Use a numerically stable log-add so a small payment on top of a large one doesn't vanish into floating point:

function logAddExp(a, b) {
  if (a === -Infinity) return b;
  if (b === -Infinity) return a;
  const hi = a > b ? a : b;
  const lo = a > b ? b : a;
  // lo - hi <= 0, so exp can't overflow; log1p is accurate near zero
  return hi + Math.log1p(Math.exp(lo - hi));
}
Enter fullscreen mode Exit fullscreen mode

Two things that will bite you

Choose the time unit deliberately. rank_key contains k · t_P. If t is unix seconds, then k = 2.674e-6 and k·t is roughly 4800 today — that burns four significant digits of a double before you've stored anything useful. Use days instead: k = 0.231, k·t ≈ 55, and you still have around 12 decimal digits of headroom, which is sub-microsecond resolution. Pick an epoch near your launch date and count days from it.

Never exponentiate rank_key directly. It grows linearly with time. Mine is ~55 today and will be ~1687 in twenty years, and Math.exp(1687) is Infinity. To get a real heat value for display, exponentiate in the domain shifted to now:

// rankKey - K·days(now) == ln(heat(now)), which stays in the 6..10 range
const heat = Math.exp(rankKey - K * toDays(nowUnixS));
Enter fullscreen mode Exit fullscreen mode

The rule: rank_key is for ordering. Never do arithmetic that leaves log space.

The bug this nearly gave me

All 1,000 tiles live in a single Cloudflare Durable Object. A DO gives you one JS execution stack — only one thing runs at a time — which sounds like it makes critical sections free. It doesn't.

Every await is a yield point. The input gate only blocks delivery during storage operations; it does nothing across a D1 query or an outbound fetch(). So this is broken:

const locked = await db.prepare('SELECT ... WHERE tile_id = ?').all()
if (locked.results.length) throw conflict()
// two requests both reach here, both see zero locks, both proceed
Enter fullscreen mode Exit fullscreen mode

The check and the claim have to happen in a purely synchronous block, before any await exists:

async reserve(input) {
  // ==== synchronous critical section: not one await in here ====
  const conflicts = [];
  for (const id of input.tileIds) {
    const t = this.tiles.get(id);
    if (t.status === 'frozen') conflicts.push(id);
    else if (this.inflight.has(id) || isBlocking(t.lock, now)) conflicts.push(id);
  }
  if (conflicts.length) throw new TileConflict(conflicts, retryAfter);

  for (const id of input.tileIds) this.inflight.add(id);
  // ==== end of critical section ====

  try {
    return await this.reserveSlow(input, now);   // D1 writes, payment intent, etc.
  } finally {
    for (const id of input.tileIds) this.inflight.delete(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

inflight is a plain in-memory Set. JavaScript's single thread guarantees that block can't be interrupted, so staking the claim there is atomic in the only sense that matters here. Everything slow happens afterwards, and finally releases the claim even if the slow part throws.

The division of labour is worth being precise about: the DO owns serialization, alarms, and snapshot generation. D1 owns every persistent fact — ownership, amounts, rank_key, the ledger. The DO's own storage holds nothing but the alarm, so crash recovery is a single hydrate() from D1 and there's no dual-write to reconcile.

The decision that mattered more than the math

When someone takes a tile, does the new owner inherit its heat or reset it?

Inheriting feels natural — the tile is hot, that's why it got taken. But the steal price is 1.5x current heat, so inheriting compounds: new heat = old + 1.5·old = 2.5·old. After four steals the tile sits at 39x its original heat and nobody can afford it again. An iron throne. The most interesting tile on the board becomes the one nobody can touch.

Resetting heat to the amount actually paid gives 1.5^n instead — 3.4x after four steals — and the 3-day half-life pulls that back down within a week. Contests stay winnable.

Identical decay math either way. One choice produces a living board, the other produces a museum. Worth remembering that the interesting part of this kind of system is usually not the formula.

Seeing it run

The board is at tilesquat.com. 1,000 tiles, $5 to take one, and the thing the Million Dollar Homepage never had: nothing you buy stays yours. Everything above is taken from its source.

Tile #0000 is mine at the moment. Feel free to evict me.

Top comments (0)