jonnify·codemojex /codemojex/guesses-on-fair-lanes/charged-then-enqueued

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

Charged, then enqueued.

A validated, lock-merged guess is not yet work. Two things happen, in this order, and the order is the point: the wallet charges the room’s currency, and only a charged guess is enqueued as a branded JOB. Charge-before-enqueue over an idempotent bus is what makes an at-least-once system safe with real money on the line.

Grounding — echo/…/codemojex/game.ex (the submit/3 charge branch) · wallet.ex (charge_guess/3, the paired TXN) · design §Messaging (the idempotent enqueue) / §Core flows. Every guess pays its fee — the all-pay structure.

The charge decides — only a charged guess becomes a job
validated + merged Wallet charge_guess {:ok, _balance} mint JOB Lanes.enqueue cm · PLR lane → answer: on its way {:error, reason} returned to caller no job · no work · no scoring
charged → the charge commits — a row-locked, all-or-nothing transaction with a paired TXN. Only then is a fresh branded JOB minted and Lanes.enqueued on the player’s cm lane. The surface answers on its way; the score arrives later.
§1

Charge first

game.ex · wallet.ex

Only after a successful charge does the guess become a JOB: a fresh branded id, the guess tupled into an opaque payload, dropped on the player’s lane with EchoMQ.Lanes.enqueue/5. If the player cannot afford the guess, charge_guess returns {:error, reason} and submit/3 returns it unchanged.

echo/…/codemojex/game.ex · the charge branch of submit/3 source
case Wallet.charge_guess(player, r, game) do
  {:ok, _balance} ->
    job = EchoData.BrandedId.generate!("JOB")
    payload = :erlang.term_to_binary({:guess, game, player, guess})
    Lanes.enqueue(Bus.conn(), @queue, player, job, payload)   # @queue "cm", lane = PLR

  {:error, reason} ->
    {:error, reason}
end

Wallet.charge_guess/3 picks the currency by the game’s kind: clips for a free game, keys for a paid classic game, and a two-sided charge for a Golden Room — the keys fee debited and the diamond pool funded in one transaction, so every guess funds the pool it competes for (cm.5; the economy is C5). Each branch locks the player row, checks the non-negative invariant, writes the balance, and inserts the paired TXN ledger row — all or nothing.

echo/…/codemojex/wallet.ex · Codemojex.Wallet.charge_guess/3 source
def charge_guess(player, game_map, ref) do
  cond do
    Map.get(game_map, :free, false) ->
      debit(player, :clips, Map.get(game_map, :clip_cost, 1), "guess", ref)

    Map.get(game_map, :golden, false) ->
      charge_guess_golden(player, Map.get(game_map, :guess_fee, 1), ref)  # keys + fund pool

    true ->
      debit(player, :keys, Map.get(game_map, :guess_fee, 1), "guess", ref)
  end
end
§2

Two windows, both closed

design §Messaging · §Fault tolerance

At-least-once delivery means a message can arrive more than once, and a process can crash mid-flight. Both windows are closed by construction — the enqueue dedupes (a re-enqueued JOB answers :duplicate), scoring is a pure function of the secret and the guess, and each consumer leases the job it claims.

The two at-least-once windows — select one
a re-delivered guess same JOB re-sent enqueue → :duplicate pure score → same GES harmless a crash mid-flight consumer dies lease expires visible again → scored once not lost
a re-delivered guess → the charge already committed on the first pass; the bus dedupes the re-sent JOB as :duplicate; and even if the same guess reached the scorer twice, scoring is pure — the second pass writes the identical GES and board total. Re-delivery is harmless.

The paired TXN row is the audit trail that ties the money to the guess: every charge is one ledger entry, so a charge and its guess reconcile exactly. This is Helland’s idempotent activity — the write is safe to retry because its effect is defined once.

§3

References

sources · related

Sources

Related