jonnify·redis /redis-patterns/time-delay-priority/leaderboards/the-score-update-path

R4.05.3 · Leaderboards — dive 3

The score-update path.

A score changes in one of two ways: ZADD overwrites it, ZINCRBY accumulates onto it. Neither touches a rank, because no rank is stored — the rank is recomputed from the order on the next read, so it is always consistent.

Grounding: echo/apps/codemojex/lib/codemojex/board.ex · Codemojex.Board.record/3 — a best-of ZADD folded by new_base = max(old, base), idempotent on a re-delivered guess.

Overwrite vs accumulate, on one player. Start mango at 510, then pick an update — ZADD replaces the score, ZINCRBY adds a delta.
0—— 1—— 2—— 3—— 4—— 5—— 6—— 7——
readout · Pick an update for mango. ZADD overwrites to an absolute value; ZINCRBY adds a delta and is safe under concurrency. The rank is read from the re-ordered board.

An update writes one score; the rank is never written. ZADD 420 drops mango down the order; two ZINCRBY +50 calls sum to +100 with no lost update.

§1

ZADD — overwrite to an absolute value

leaderboards.md.txt · Core Operations

ZADD sets a member's score to the value given, whether or not the member is already on the board. This is the update to use when the new score is known outright — a final result, a recomputed total, a snapshot value. The member is re-placed in the order by the new value; lift one player above three and they move three places on the next read. ZADD carries the whole new state of the score in one write.

ZADD leaderboard 2000 "mango"    # mango's score is now 2000, replacing any prior score
§2

ZINCRBY — accumulate a delta

leaderboards.md.txt · Core Operations

ZINCRBY adds a delta to the current score and returns the new score. This is the update to use when points arrive incrementally — fifty more for a win, ten for a step completed. Because the increment happens inside Redis, two concurrent ZINCRBY calls on the same member both apply: the score rises by the sum of the deltas, with no lost update. A plain read-modify-write in the application would lose one of two concurrent increments; ZINCRBY does not. A member not yet on the board is treated as starting from 0.

ZINCRBY leaderboard 50 "mango"   # mango's score rises by 50; returns the new total

Notes on ValkeyZINCRBY key delta member increments a member's score atomically inside the server, so concurrent increments compose without a lost update — a member absent from the set is treated as a 0 score — valkey.io/commands/zincrby.

§3

The rank is recomputed on read, never stored

leaderboards.md.txt · Rank vs Reverse Rank

Neither update writes a rank. The rank of any member is its position in the score order, and the set computes that position when ZREVRANK or a range read asks for it — in O(log N), by walking the order. Two consequences follow. First, the rank is always exactly the current order: there is no stored rank that an update could leave stale, so a score change is reflected the instant the rank is next read. Second, updating one member's score can shift the ranks of many others — lift one player past three and those three each drop a place — but none of those neighbours is rewritten. Their scores are unchanged; only their computed position differs on the next read.

The work of a score update is O(log N) for the re-placement; the ripple through everyone else's rank costs nothing at write time, because it is computed at read time.

Update the score, not the rank. ZADD to overwrite, ZINCRBY to accumulate, and the rank is read from the order — so it is never stale.

§4

In codemojex — the best-of overwrite

echo/apps/codemojex · game.ex · board.ex

codemojex's score update is ZADD, with a best-of fold so a rank only ever climbs. The score worker is the authority: Codemojex.ScoreWorker.handle/1 scores a guess with the pure linear engine and records the result on the board.

echo/apps/codemojex/lib/codemojex/game.ex · ScoreWorker.handle/1, score then recordverbatim
s = Scoring.score(secret, emojis)          ← the linear total, out of 600
gid = EchoData.BrandedId.generate!("GES")
Store.put_guess(gid, %{game: game, player: player, emojis: emojis, points: s.total, …})
Cmd.incr("cm:" <> game <> ":attempts") |> Wire.run(conn)
eff = Board.record(game, player, s.total)  ← ZADD the player's best base to cm:<game>:board

Codemojex.Board.record/3 reads the player's current best from cm:<game>:base, takes new_base = max(old, base), and writes it to the board with Cmd.zadd("cm:<game>:board") |> Cmd.score(new_base, player). That is an absolute overwrite to the best-so-far — never ZINCRBY, because the rank is the best single result, not a running sum. A re-delivered guess re-scores identically (the engine is pure) and the max/2 fold makes the write idempotent: a lower or equal base leaves the rank where it was. No rank is stored; the next Board.top/2 read recomputes the whole order.

the pattern

A score is updated by ZADD (overwrite) or ZINCRBY (accumulate), and the rank is recomputed from the order on read, never stored.

its codemojex application

Codemojex.Board.record/3 writes each player's best linear total with Cmd.zadd("cm:<game>:board") |> Cmd.score(new_base, player) — a best-of overwrite, idempotent on re-delivery; the rank is read from the order, never written.

Update the score, not the rank — ZADD to overwrite, ZINCRBY to accumulate. The rank is read from the order, so it is never stale. codemojex's Board.record/3 is that ZADD, folded to the player's best base.

§5

References

sources · related

Sources

  • Redis — ZADD — set a member's score to an absolute value, overwriting any prior score.
  • Redis — ZINCRBY — add a delta to a member's score in place, with no lost update under concurrency.
  • Redis — ZREVRANK — read the rank from the order, recomputed on each read.
  • Redis — Sorted sets — the ordered structure an update re-places a member in.

Related in this course