games: the clock that plays for whoever walked away, and the guard that stops it playing twice
Phase B runtime: the turn clock, the session reaper the plan noticed nobody had ever wired, and the game-agnostic seam the engines will plug into. - tableGame interface + a games() registry keyed on storage name, so the clock, the reaper and (soon) the handlers never know whether they drive poker or UNO. - The turn clock is the first goroutine in Pete to mutate game state. It obeys rule 1 (DueTables returns a plain slice — the rows are closed before any lock, or the scan would hold the one connection a locked write needs) and the version guard (act only if the table is still the version the scan saw). Tested against the exact double-move the plan warned of: a real move lands in the same tick the scan fired, bumps the version, and the clock steps aside instead of folding the next player who still had 25 seconds. - PushDeadlines on boot shoves every live clock out by a grace period, so the first tick after a deploy doesn't auto-fold the whole room at once. - ReapIdleSessions finally has a caller. A seated player is invisible to it — their chips are inside a table blob — so it only ever reaps loose idle chips. - publishTable fans a minimal version-carrying nudge through the hub; the frame is seat-blind, so a hole card never rides a broadcast that reaches the table. Clock wired into main.go behind gamesReady(). Still no engine implements tableGame, so the registry is empty and nothing a player can see has changed. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
208
internal/web/games_clock.go
Normal file
208
internal/web/games_clock.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The turn clock: the first goroutine in Pete that has ever mutated game state.
|
||||||
|
//
|
||||||
|
// Every other background loop here reads, refills or prunes. This one plays. When
|
||||||
|
// a human sits at a felt and walks away mid-hand, three other people are waiting
|
||||||
|
// on a decision that is never coming, and something has to make it for them. That
|
||||||
|
// something is this.
|
||||||
|
//
|
||||||
|
// It is built on one discipline and one guard.
|
||||||
|
//
|
||||||
|
// The discipline is rule 1: **collect the due tables and close the rows before
|
||||||
|
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
|
||||||
|
// taken while those rows are open would hold the connection the rows need, and the
|
||||||
|
// process would wedge. So DueTables returns a plain slice and the connection is
|
||||||
|
// free before the first table is touched.
|
||||||
|
//
|
||||||
|
// The guard is the version. The scan records each table's version; the clock acts
|
||||||
|
// on a table only if it is *still* that version by the time it holds the lock and
|
||||||
|
// has reloaded. Without that check the clock and a real move race and both land:
|
||||||
|
// Bob raises in the same second his clock expires, the action moves to Cara, and
|
||||||
|
// the clock — still believing the seat that ran out of time is to act — folds
|
||||||
|
// Cara, who had twenty-five seconds left. The version check turns that into a
|
||||||
|
// no-op: Bob's move bumped the version, the reload shows the new one, and the
|
||||||
|
// clock steps aside.
|
||||||
|
|
||||||
|
// clockInterval is how often the clock looks for expired turns. Sub-second
|
||||||
|
// precision buys nothing at a card table and would only spin the CPU.
|
||||||
|
const clockInterval = time.Second
|
||||||
|
|
||||||
|
// reaperInterval is how often idle sessions are cashed out. The reaper is a
|
||||||
|
// slow-moving safety net — a player who wandered off half an hour ago is not in a
|
||||||
|
// hurry — so it runs far less often than the clock.
|
||||||
|
const reaperInterval = time.Minute
|
||||||
|
|
||||||
|
// games returns the multiplayer engines by their storage key. It is the registry
|
||||||
|
// the clock and the handlers both dispatch through. Empty until an engine is
|
||||||
|
// wired; a clock over no games is a loop that finds nothing, which is correct.
|
||||||
|
func (s *Server) games() map[string]tableGame {
|
||||||
|
out := make(map[string]tableGame)
|
||||||
|
for _, g := range s.tableGames {
|
||||||
|
out[g.name()] = g
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTableClock launches the turn clock and the session reaper if the casino is
|
||||||
|
// on. Safe to call unconditionally.
|
||||||
|
func (s *Server) StartTableClock(ctx context.Context) {
|
||||||
|
if !s.gamesReady() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A deploy just took every table's clock down with it. Shove the live deadlines
|
||||||
|
// out so the first tick does not auto-act the whole room at once.
|
||||||
|
if err := storage.PushDeadlines(bootGrace); err != nil {
|
||||||
|
slog.Error("games: push deadlines on boot", "err", err)
|
||||||
|
}
|
||||||
|
go s.runTableClock(ctx)
|
||||||
|
go s.runSessionReaper(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) runTableClock(ctx context.Context) {
|
||||||
|
slog.Info("games: turn clock started", "interval", clockInterval)
|
||||||
|
ticker := time.NewTicker(clockInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.tickClock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickClock finds the tables whose turn has expired and acts on each. The scan is
|
||||||
|
// done and the connection released before any table is locked — rule 1.
|
||||||
|
func (s *Server) tickClock() {
|
||||||
|
due, err := storage.DueTables(time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: due tables", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ref := range due {
|
||||||
|
s.runClockTable(ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runClockTable acts for the walked-away player at one table, but only if the
|
||||||
|
// table is still at the version the scan saw.
|
||||||
|
//
|
||||||
|
// The whole read-modify-write is done under the table's stripe, which is what
|
||||||
|
// keeps the clock from racing a second copy of itself — but the stripe is only an
|
||||||
|
// optimisation. The version check inside CommitTable is the real thing: even
|
||||||
|
// across a redeploy, when two processes hold two different stripes over this row,
|
||||||
|
// the one whose write lands first bumps the version and the other's write finds
|
||||||
|
// zero rows and rolls back.
|
||||||
|
func (s *Server) runClockTable(ref storage.TableRef) {
|
||||||
|
err := s.tableLocks.withTable(ref.ID, func() error {
|
||||||
|
t, seats, err := storage.LoadTable(ref.ID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
return nil // closed out from under us; nothing to do
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Somebody moved between the scan and now. Their move set a fresh deadline (or
|
||||||
|
// cleared it), so this expiry is stale — step aside.
|
||||||
|
if t.Version != ref.Version {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// A deadline in the future means the scan is looking at an old view; leave it.
|
||||||
|
if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
game := s.games()[t.Game]
|
||||||
|
if game == nil {
|
||||||
|
slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
st, newSeats, err := game.timeout(t.State, seats)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: clock timeout", "table", t.ID, "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline
|
||||||
|
if err := storage.CommitTable(storage.TableCommit{Table: t, Seats: newSeats, Audit: st.Audit}); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrStaleTable) {
|
||||||
|
return nil // lost the race after all; the winner handled it
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishTable(ref.ID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: clock table", "table", ref.ID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishTable pushes the current table view to everyone watching it. It reads
|
||||||
|
// the table fresh (the authoritative state) and fans an opaque frame out through
|
||||||
|
// the hub. Called after every committed write, under no lock the hub cares about
|
||||||
|
// — the hub's sends are non-blocking, so this never stalls a caller.
|
||||||
|
//
|
||||||
|
// A view here is deliberately seat-blind: it carries only what every seat may see
|
||||||
|
// (the version and the public table shape), and each subscriber's own stream
|
||||||
|
// redacts and re-renders for the seat that is watching. That keeps a hole card
|
||||||
|
// from ever entering a frame that fans to the whole table.
|
||||||
|
func (s *Server) publishTable(tableID string) {
|
||||||
|
if s.hub.watchers(tableID) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, _, err := storage.LoadTable(tableID)
|
||||||
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: publish table load", "table", tableID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The frame is just a nudge carrying the version: a subscriber that sees a gap
|
||||||
|
// refetches the authoritative, per-seat table. So the payload can be minimal.
|
||||||
|
data, _ := json.Marshal(map[string]any{"version": t.Version, "phase": t.Phase})
|
||||||
|
s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSessionReaper cashes out players who wandered off, on a timer. This is the
|
||||||
|
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
|
||||||
|
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
|
||||||
|
//
|
||||||
|
// A seated player is invisible to it — their chips are inside a table blob, not on
|
||||||
|
// their game_chips stack — so it only ever reaps a player who is genuinely idle
|
||||||
|
// with loose chips. Getting up from a table returns them to the stack, and then
|
||||||
|
// this is what eventually sends them home.
|
||||||
|
func (s *Server) runSessionReaper(ctx context.Context) {
|
||||||
|
slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
|
||||||
|
ticker := time.NewTicker(reaperInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("games: reap idle sessions", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
slog.Info("games: reaped idle sessions", "count", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/web/games_clock_test.go
Normal file
140
internal/web/games_clock_test.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGame is a tableGame that records whether its clock ever fired. It lets the
|
||||||
|
// runtime tests exercise the lock discipline and the version guard without a real
|
||||||
|
// engine — the thing under test is the clock, not the cards.
|
||||||
|
type fakeGame struct {
|
||||||
|
fired int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *fakeGame) name() string { return "fake" }
|
||||||
|
|
||||||
|
func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
|
||||||
|
g.fired++
|
||||||
|
// Act: clear the deadline and bump the hand, as a real settle would.
|
||||||
|
return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clockTestServer stands up a Server with just the table machinery wired and a
|
||||||
|
// fresh DB. Enough to drive the clock, nothing else.
|
||||||
|
func clockTestServer(t *testing.T, g tableGame) *Server {
|
||||||
|
t.Helper()
|
||||||
|
// Reset the storage singleton onto a temp DB.
|
||||||
|
if err := storage.Init(filepath.Join(t.TempDir(), "clock.db")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { storage.Close() })
|
||||||
|
|
||||||
|
s := &Server{hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{g}}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func openClockTable(t *testing.T, id string, deadline int64) storage.Table {
|
||||||
|
t.Helper()
|
||||||
|
tbl := storage.Table{
|
||||||
|
ID: id, Game: "fake", Tier: "1-2", State: []byte(`{}`),
|
||||||
|
Seed1: 1, Seed2: 2, Phase: "betting", HandNo: 1, Deadline: deadline,
|
||||||
|
}
|
||||||
|
seats := []storage.Seat{{Seat: 0, MatrixUser: "@reala:parodia.dev", Name: "reala"}, {Seat: 1, Name: "bot"}}
|
||||||
|
if err := storage.OpenTable(tbl, seats); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tbl
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClock_ActsOnExpiredTable(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
openClockTable(t, "t1", time.Now().Unix()-5) // already expired
|
||||||
|
|
||||||
|
s.tickClock()
|
||||||
|
|
||||||
|
if g.fired != 1 {
|
||||||
|
t.Fatalf("clock should have fired once, got %d", g.fired)
|
||||||
|
}
|
||||||
|
after, _, err := storage.LoadTable("t1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.Phase != "handover" || after.Deadline != 0 {
|
||||||
|
t.Errorf("table should have advanced: %+v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClock_IgnoresFutureDeadlines(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
openClockTable(t, "t1", time.Now().Unix()+60)
|
||||||
|
|
||||||
|
s.tickClock()
|
||||||
|
|
||||||
|
if g.fired != 0 {
|
||||||
|
t.Fatalf("clock should not have fired on a future deadline, got %d", g.fired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClock_VersionGuardStopsTheDoubleMove is the scenario the whole design turns
|
||||||
|
// on. A move lands in the same tick the clock's scan found the table expired. The
|
||||||
|
// move bumps the version; the clock, acting on its stale scan, must see the new
|
||||||
|
// version and step aside rather than acting a second time.
|
||||||
|
func TestClock_VersionGuardStopsTheDoubleMove(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
tbl := openClockTable(t, "t1", time.Now().Unix()-5)
|
||||||
|
|
||||||
|
// The scan saw version 0.
|
||||||
|
due, err := storage.DueTables(time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Fatalf("want 1 due table, got %d", len(due))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real move lands first, bumping the version and setting a fresh deadline for
|
||||||
|
// the next player.
|
||||||
|
tbl.State = []byte(`{"moved":true}`)
|
||||||
|
tbl.Deadline = time.Now().Unix() + 30
|
||||||
|
if err := storage.CommitTable(storage.TableCommit{Table: tbl}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the clock acts on its stale scan (version 0). It must not fire.
|
||||||
|
s.runClockTable(due[0])
|
||||||
|
|
||||||
|
if g.fired != 0 {
|
||||||
|
t.Fatalf("the version guard should have stopped the clock, but it fired %d time(s)", g.fired)
|
||||||
|
}
|
||||||
|
after, _, _ := storage.LoadTable("t1")
|
||||||
|
if string(after.State) != `{"moved":true}` {
|
||||||
|
t.Errorf("the real move should stand, got %s", after.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClock_PublishesToWatchers(t *testing.T) {
|
||||||
|
g := &fakeGame{}
|
||||||
|
s := clockTestServer(t, g)
|
||||||
|
openClockTable(t, "t1", time.Now().Unix()-5)
|
||||||
|
|
||||||
|
ch, done := s.hub.subscribe("t1")
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
s.tickClock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case f := <-ch:
|
||||||
|
if f.Version == 0 {
|
||||||
|
t.Errorf("frame should carry the bumped version, got %d", f.Version)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("a watcher should have received a frame after the clock acted")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/web/games_runtime.go
Normal file
72
internal/web/games_runtime.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "pete/internal/storage"
|
||||||
|
|
||||||
|
// The table runtime: the game-agnostic half of a shared table.
|
||||||
|
//
|
||||||
|
// A shared table has two writers where a solo game had one — an HTTP move, and a
|
||||||
|
// turn clock acting for whoever walked away — and the whole job of this layer is
|
||||||
|
// to let those two coexist without either trusting the other. The rule that makes
|
||||||
|
// that work is the database's version column: every write is conditional on the
|
||||||
|
// version its writer read, so the two can race freely and exactly one wins.
|
||||||
|
//
|
||||||
|
// Everything specific to a game — how a move advances it, whose turn it is now,
|
||||||
|
// what a settled hand pays — lives behind the tableGame interface. The clock, the
|
||||||
|
// lock discipline and the SSE publish do not know whether they are driving poker
|
||||||
|
// or UNO, and that is what lets Phase B ship before any engine is multiway.
|
||||||
|
|
||||||
|
// turnSeconds is how long a human has to act before the clock acts for them. Long
|
||||||
|
// enough to read the table and think, short enough that a walked-away player does
|
||||||
|
// not hold three others hostage.
|
||||||
|
const turnSeconds = 30
|
||||||
|
|
||||||
|
// bootGrace is how far the turn clock shoves every live deadline out on boot. A
|
||||||
|
// deploy takes the in-memory clock with it, so without this the first tick after
|
||||||
|
// a restart would find every deadline in the casino already past and auto-act the
|
||||||
|
// whole room at once.
|
||||||
|
const bootGrace = 30
|
||||||
|
|
||||||
|
// step is what a game hands back after a move or a timeout: the new state, ready
|
||||||
|
// to persist, plus everything the runtime needs to settle and to schedule.
|
||||||
|
//
|
||||||
|
// The chips are inside State — a hand ending moves the pot within the blob and
|
||||||
|
// credits nobody — so there is no payout field. What comes out is the state, the
|
||||||
|
// audit of any hand that just ended, and the clock's next deadline.
|
||||||
|
type step struct {
|
||||||
|
// State is the engine's whole state, marshalled, ready for the table blob.
|
||||||
|
State []byte
|
||||||
|
// Phase is lifted out of the state so the lobby can read it without decoding.
|
||||||
|
Phase string
|
||||||
|
// HandNo is the hand this state is on. It advances when a new hand is dealt,
|
||||||
|
// and it is the audit key now that a seed no longer reproduces a shared hand.
|
||||||
|
HandNo int64
|
||||||
|
// Deadline is when the clock must next act, or 0 for none. It is nonzero only
|
||||||
|
// when the turn has landed on a *present* human: a bot resolves inside the move
|
||||||
|
// and an away human is auto-acted on sight, so neither is ever waited for.
|
||||||
|
Deadline int64
|
||||||
|
// Audit is the per-seat record of a hand that ended in this step. Empty if no
|
||||||
|
// hand settled.
|
||||||
|
Audit []storage.Hand
|
||||||
|
// Events is the script the felt plays back — the same shape every solo game
|
||||||
|
// already returns. It is what the SSE frame and the acting player both animate.
|
||||||
|
Events any
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableGame is everything the runtime needs from an engine to run it at a shared
|
||||||
|
// table. Each multiplayer game implements it; the clock and the handlers hold it
|
||||||
|
// as an interface so they stay game-agnostic.
|
||||||
|
type tableGame interface {
|
||||||
|
// name is the storage key and the lobby label: "holdem", "uno", "blackjack".
|
||||||
|
name() string
|
||||||
|
|
||||||
|
// timeout acts for the human whose clock has expired — check if the rules
|
||||||
|
// allow it, fold otherwise — and advances the table as far as the next
|
||||||
|
// decision, exactly as a real move would. seats is the current roster, so the
|
||||||
|
// engine can mark the timed-out player away.
|
||||||
|
//
|
||||||
|
// It returns ErrNotDue (via a nil step, see runClockTable) if, on decode, the
|
||||||
|
// seat to act is not in fact a waiting human — which happens when a real move
|
||||||
|
// landed in the same instant the clock fired and the version had not yet been
|
||||||
|
// bumped when the clock scanned.
|
||||||
|
timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error)
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ type Server struct {
|
|||||||
// until a table is opened.
|
// until a table is opened.
|
||||||
hub *gamesHub
|
hub *gamesHub
|
||||||
tableLocks *stripedLocks
|
tableLocks *stripedLocks
|
||||||
|
tableGames []tableGame // the multiplayer engines, dispatched through games()
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server. Templates are parsed once at startup. Each page gets
|
// New builds the server. Templates are parsed once at startup. Each page gets
|
||||||
|
|||||||
Reference in New Issue
Block a user