jonnify·codemojex /codemojex/guesses-on-fair-lanes/fair-lanes-and-the-worker

Codemojex · C3.3 · a dive of Guesses on Fair Lanes

Fair lanes and the worker.

The bus is the spine of the runtime. Codemojex.Application starts four EchoMQ.Consumer children, one per kind of work, and each lane is grouped so one heavy producer cannot starve the rest. The guess lane’s group is the player’s PLR — which is where procedural fairness lives.

Grounding — echo/…/codemojex/application.ex (the four consumers, their beat_ms/lease_ms) · game.ex (Codemojex.ScoreWorker.handle/1) · rate_limiter.ex · design §Messaging / §Privacy and fairness.

Four grouped lanes — select one
lane queue · group key consumer beat · lease guesses cm · PLR :cm_score 100 · 10s settle cm-settle · GAM :cm_settle 100 · 10s notify notify · chat :cm_notify 50 · 15s commands bot.commands · chat :cm_commands 100 · 10s
guesses → queue cm, keyed by the player’s PLR, drained by :cm_score — the single scoring authority. The bus rotates service across the per-player lanes, so paying for speed buys a turn, not the field.
§1

Four lanes, grouped

application.ex

The four consumers are supervised children with an explicit id each. The guess lane is keyed by the player’s PLR: a player who taps twice as fast gets their own lane serviced in turn, not twice the field’s throughput. The fairness is structural — the group key on the queue, not a throttle at the edge.

echo/…/codemojex/application.ex · the four consumers source
Supervisor.child_spec({EchoMQ.Consumer, score_opts(conn)},   id: :cm_score),    # guesses  → "cm"
Supervisor.child_spec({EchoMQ.Consumer, settle_opts(conn)},  id: :cm_settle),   # payout   → "cm-settle"
Supervisor.child_spec({EchoMQ.Consumer, notify_opts(conn)},  id: :cm_notify),   # telegram (rate-limited)
Supervisor.child_spec({EchoMQ.Consumer, command_opts(conn)}, id: :cm_commands), # inbound bot commands

defp score_opts(conn) do
  [queue: ScoreWorker.queue(), handler: &ScoreWorker.handle/1,
   connector: conn, beat_ms: 100, lease_ms: 10_000]
end

Two patterns recur. The move-then-settle split: the guess queue competes and a separate cm-settle queue pays, so contention and payout never share a path. The delayed re-enqueue: when the RateLimiter reports a notification arrived too soon, the worker re-enqueues the same job with EchoMQ.Jobs.enqueue_in/5 after a computed delay — a token bucket sized for Telegram’s ~30/s aggregate and ~1/s per-chat limits, so it defers rather than blocks.

§2

One scoring authority, leased

game.ex · ScoreWorker

:cm_score is the single authority. ScoreWorker.handle/1 receives the claimed job with the player id arriving as the lane group. It reads the secret through the near-cache (immutable for the game’s life), scores with the pure linear engine, writes the GES, counts the attempt, and records the board — then the two modes diverge.

echo/…/codemojex/game.ex · Codemojex.ScoreWorker.handle/1 source
def handle(%{id: job_id, payload: payload, group: player}) do
  {:guess, game, ^player, emojis} = :erlang.binary_to_term(payload)
  case Cache.fetch_game(game) do
    %{secret: secret} = g ->
      s = Scoring.score(secret, emojis)          # pure, linear (C4)
      gid = EchoData.BrandedId.generate!("GES")
      Store.put_guess(gid, %{game: game, player: player, points: s.total, ...})
      eff = Board.record(game, player, s.total)
      if Map.get(g, :feedback, "score") == "score" do
        Events.publish(conn, @queue, "scored", job_id, ...)              # the bus log
        Phoenix.PubSub.broadcast(PubSub, "game:" <> game, {:scored, ...}) # live
        if s.total == 600 and started?(game), do: Rooms.close_game(game)
      end
      :ok
    _ -> :ok                                       # unknown game → a drop, no retry loop
  end
end
After scoring — the mode decides what fans out
ScoreWorker group = PLR score · write GES record board publish scored broadcast :scored (live) 600 → close the game store guess · emit nothing sealed until the reveal at close
classic → the worker publishes a scored event on the bus log and broadcasts :scored over Phoenix PubSub on the game’s topic — both carrying a name, a percentage, and an effective score, never the secret or the guess. A perfect crack of 600 closes an open game there and then.

Because the worker leases each job, a crash mid-scoring is survivable: the lease expires and the job becomes visible again, so the paid-for attempt is scored by another consumer rather than lost. Delivery is at-least-once and the handler is idempotent — scoring is pure — so a re-delivery is harmless.

§3

References

sources · related

Sources

Related