fast failover

This commit is contained in:
2026-02-24 13:43:56 -07:00
parent 8bb086eac9
commit 58ee1db696
9 changed files with 375 additions and 92 deletions

20
client/dev.Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:20-alpine
RUN apk add --no-cache bash
WORKDIR /app
RUN npm install -g pnpm
ENV USER="node"
RUN addgroup -g 1000 $USER && \
adduser -D -u 1000 -G $USER $USER || true
RUN chown -R node:node /app
USER node
EXPOSE 5173
CMD ["pnpm", "dev", "--host"]

View File

@@ -1,12 +1,27 @@
import { useState } from "react";
import "./App.css";
import { UserInput } from "./game/UserInput";
import { BoardDisplay } from "./game/BoardDisplay";
import { ConnectionStatus } from "./game/ConnectionStatus";
import { NameInput } from "./game/NameInput";
const getPlayerNameFromUrl = () => {
const params = new URLSearchParams(window.location.search);
return params.get("name") || null;
};
function App() {
const [playerName, setPlayerName] = useState<string | null>(
getPlayerNameFromUrl,
);
if (!playerName) {
return <NameInput onNameSubmit={setPlayerName} />;
}
return (
<>
<UserInput />
<UserInput playerName={playerName} />
<div
style={{
width: "100vw",
@@ -17,7 +32,7 @@ function App() {
}}
>
<ConnectionStatus />
<BoardDisplay />
<BoardDisplay playerName={playerName} />
</div>
</>
);

View File

@@ -6,6 +6,7 @@ interface GameChannelContextValue {
channel: Channel | null;
channelStatus: string;
isJoined: boolean;
joinGame: (name: string) => void;
}
const GameChannelContext = createContext<GameChannelContextValue | undefined>(
@@ -40,18 +41,15 @@ export function GameChannelProvider({
.join()
.receive("ok", () => {
console.log(`✓ Joined channel: ${channelName}`);
setChannelStatus("joined");
setIsJoined(true);
setChannelStatus("connected");
})
.receive("error", (resp: unknown) => {
console.log(`✗ Failed to join ${channelName}:`, resp);
setChannelStatus("join failed");
setIsJoined(false);
})
.receive("timeout", () => {
console.log(`✗ Timeout joining ${channelName}`);
setChannelStatus("timeout");
setIsJoined(false);
});
// Set channel asynchronously to avoid cascading renders
@@ -62,15 +60,34 @@ export function GameChannelProvider({
newChannel.leave();
setChannel(null);
setIsJoined(false);
setChannelStatus("waiting");
};
}, [socket, isConnected, channelName, params]);
const joinGame = (name: string) => {
if (!channel) return;
channel
.push("join_game", { name })
.receive("ok", () => {
console.log(`✓ Joined game as: ${name}`);
setChannelStatus("joined");
setIsJoined(true);
})
.receive("error", (resp: unknown) => {
console.log(`✗ Failed to join game:`, resp);
setChannelStatus("join failed");
setIsJoined(false);
});
};
return (
<GameChannelContext.Provider
value={{
channel,
channelStatus,
isJoined,
joinGame,
}}
>
{children}

View File

@@ -7,34 +7,35 @@ interface Player {
}
interface GameState {
[playerId: string]: Player;
[playerName: string]: Player;
}
export const BoardDisplay = () => {
const { channel, isJoined } = useGameChannelContext();
export const BoardDisplay = ({ playerName }: {
playerName: string;
}) => {
const { channel, isJoined, joinGame } = useGameChannelContext();
const [players, setPlayers] = useState<GameState>({});
const [myPlayerId, setMyPlayerId] = useState<string | null>(null);
useEffect(() => {
if (!channel || !isJoined) return;
if (channel && !isJoined) {
// Send join_game message with player name
joinGame(playerName);
}
}, [channel, isJoined, playerName, joinGame]);
useEffect(() => {
if (!channel) return;
// Listen for game state updates
const ref = channel.on("game_state", (payload: { players: GameState }) => {
setPlayers(payload.players);
if (!myPlayerId && Object.keys(payload.players).length > 0) {
const playerIds = Object.keys(payload.players);
if (playerIds.length > 0) {
setMyPlayerId(playerIds[playerIds.length - 1]);
}
}
});
// Cleanup listener on unmount
return () => {
channel.off("game_state", ref);
};
}, [channel, isJoined, myPlayerId]);
}, [channel]);
return (
<div
@@ -48,9 +49,9 @@ export const BoardDisplay = () => {
overflow: "hidden",
}}
>
{Object.entries(players).map(([id, player]) => (
{Object.entries(players).map(([name, player]) => (
<div
key={id}
key={name}
style={{
position: "absolute",
left: player.x,
@@ -58,16 +59,15 @@ export const BoardDisplay = () => {
width: "20px",
height: "20px",
borderRadius: "50%",
background: id === myPlayerId ? "#e94560" : "#53a8b6",
background: name === playerName ? "#e94560" : "#53a8b6",
border:
id === myPlayerId ? "3px solid #ff6b6b" : "2px solid #48d6e0",
name === playerName ? "3px solid #ff6b6b" : "2px solid #48d6e0",
transition: "all 0.1s linear",
transform: "translate(-50%, -50%)",
boxShadow:
id === myPlayerId ? "0 0 10px #e94560" : "0 0 5px #53a8b6",
name === playerName ? "0 0 10px #e94560" : "0 0 5px #53a8b6",
}}
>
{id === myPlayerId && (
<div
style={{
position: "absolute",
@@ -79,9 +79,8 @@ export const BoardDisplay = () => {
whiteSpace: "nowrap",
}}
>
You
{name}
</div>
)}
</div>
))}
</div>

View File

@@ -0,0 +1,105 @@
import { useState } from "react";
interface NameInputProps {
onNameSubmit: (name: string) => void;
}
const getNameFromUrl = () => {
const params = new URLSearchParams(window.location.search);
return params.get("name") || "";
};
export const NameInput = ({ onNameSubmit }: NameInputProps) => {
const [name, setName] = useState(getNameFromUrl);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
// Update URL with name
const params = new URLSearchParams(window.location.search);
params.set("name", name.trim());
window.history.replaceState(
{},
"",
`${window.location.pathname}?${params}`,
);
onNameSubmit(name.trim());
}
};
return (
<div
style={{
width: "100vw",
height: "100vh",
background: "#1a1a2e",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
background: "#16213e",
padding: "40px",
borderRadius: "10px",
border: "2px solid #0f3460",
maxWidth: "400px",
width: "100%",
}}
>
<h1
style={{
color: "#e94560",
fontFamily: "monospace",
fontSize: "24px",
marginBottom: "20px",
textAlign: "center",
}}
>
Enter Your Name
</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name..."
autoFocus
style={{
width: "100%",
padding: "12px",
fontSize: "16px",
background: "#0f3460",
border: "2px solid #53a8b6",
borderRadius: "5px",
color: "white",
fontFamily: "monospace",
marginBottom: "20px",
boxSizing: "border-box",
}}
/>
<button
type="submit"
disabled={!name.trim()}
style={{
width: "100%",
padding: "12px",
fontSize: "16px",
background: name.trim() ? "#e94560" : "#666",
border: "none",
borderRadius: "5px",
color: "white",
fontFamily: "monospace",
cursor: name.trim() ? "pointer" : "not-allowed",
fontWeight: "bold",
}}
>
Join Game
</button>
</form>
</div>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { useGameChannelContext } from "../contexts/useGameChannelContext";
export const UserInput = () => {
export const UserInput = ({ playerName }: { playerName: string }) => {
const { channel } = useGameChannelContext();
const keysPressed = useRef<Set<string>>(new Set());
@@ -12,16 +12,7 @@ export const UserInput = () => {
const key = e.key.toLowerCase();
if (["w", "a", "s", "d"].includes(key)) {
e.preventDefault();
// Only send if not already pressed (prevent repeat)
if (!keysPressed.current.has(key)) {
keysPressed.current.add(key);
// Send to active channel
if (channel.state === "joined") {
channel.push("move", { direction: key });
}
}
}
};
@@ -32,14 +23,28 @@ export const UserInput = () => {
}
};
const handleBlur = () => {
keysPressed.current.clear();
};
const interval = setInterval(() => {
if (keysPressed.current.size > 0 && channel.state === "joined") {
const directions = Array.from(keysPressed.current);
channel.push("move", { directions, name: playerName });
}
}, 100);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
clearInterval(interval);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, [channel]);
}, [channel, playerName]);
return null;
};

109
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,109 @@
services:
phoenix1:
build:
context: ./backend
dockerfile: Dockerfile
container_name: phoenix1
hostname: phoenix1
environment:
- RELEASE_NODE=backend@phoenix1
- RELEASE_DISTRIBUTION=sname
- RELEASE_COOKIE=super_secret_cookie_change_in_production
- PHX_HOST=localhost
- PHX_SERVER=true
- PORT=4000
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
ports:
- "4001:4000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 10s
timeout: 5s
retries: 5
phoenix2:
build:
context: ./backend
dockerfile: Dockerfile
container_name: phoenix2
hostname: phoenix2
environment:
- RELEASE_NODE=backend@phoenix2
- RELEASE_DISTRIBUTION=sname
- RELEASE_COOKIE=super_secret_cookie_change_in_production
- PHX_HOST=localhost
- PHX_SERVER=true
- PORT=4000
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
ports:
- "4002:4000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 10s
timeout: 5s
retries: 5
phoenix3:
build:
context: ./backend
dockerfile: Dockerfile
container_name: phoenix3
hostname: phoenix3
environment:
- RELEASE_NODE=backend@phoenix3
- RELEASE_DISTRIBUTION=sname
- RELEASE_COOKIE=super_secret_cookie_change_in_production
- PHX_HOST=localhost
- PHX_SERVER=true
- PORT=4000
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
ports:
- "4003:4000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 10s
timeout: 5s
retries: 5
nginx-lb:
image: nginx:alpine
container_name: nginx-lb
volumes:
- ./nginx-lb.conf:/etc/nginx/conf.d/default.conf
ports:
- "4000:80"
depends_on:
- phoenix1
- phoenix2
- phoenix3
client:
build:
context: ./client
dockerfile: Dockerfile
container_name: client
ports:
- "5173:80"
depends_on:
- nginx-lb
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
container_name: otel-collector
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4318:4318" # OTLP HTTP receiver
- "4317:4317" # OTLP gRPC receiver
- "8888:8888" # Prometheus metrics
- "8889:8889" # Prometheus exporter metrics

View File

@@ -1,25 +1,26 @@
version: "3.8"
services:
phoenix1:
build:
context: ./backend
dockerfile: Dockerfile
dockerfile: dev.Dockerfile
container_name: phoenix1
hostname: phoenix1
environment:
- RELEASE_NODE=backend@phoenix1
- RELEASE_DISTRIBUTION=sname
- RELEASE_COOKIE=super_secret_cookie_change_in_production
- PHX_HOST=localhost
- PHX_SERVER=true
- PORT=4000
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
- MIX_ENV=dev
- NODE_NAME=backend@phoenix1
- COOKIE=dev_cookie
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
ports:
- "4001:4000"
user: root
command: |
sh -c '
chown -R elixir:elixir /app/_build
su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server"
'
volumes:
- ./backend:/app
- /app/_build
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 10s
@@ -29,22 +30,25 @@ services:
phoenix2:
build:
context: ./backend
dockerfile: Dockerfile
dockerfile: dev.Dockerfile
container_name: phoenix2
hostname: phoenix2
environment:
- RELEASE_NODE=backend@phoenix2
- RELEASE_DISTRIBUTION=sname
- RELEASE_COOKIE=super_secret_cookie_change_in_production
- PHX_HOST=localhost
- PHX_SERVER=true
- PORT=4000
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
- MIX_ENV=dev
- NODE_NAME=backend@phoenix2
- COOKIE=dev_cookie
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
ports:
- "4002:4000"
user: root
command: |
sh -c '
chown -R elixir:elixir /app/_build
su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server"
'
volumes:
- ./backend:/app
- /app/_build
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 10s
@@ -54,22 +58,25 @@ services:
phoenix3:
build:
context: ./backend
dockerfile: Dockerfile
dockerfile: dev.Dockerfile
container_name: phoenix3
hostname: phoenix3
environment:
- RELEASE_NODE=backend@phoenix3
- RELEASE_DISTRIBUTION=sname
- RELEASE_COOKIE=super_secret_cookie_change_in_production
- PHX_HOST=localhost
- PHX_SERVER=true
- PORT=4000
- DATABASE_URL=ecto://postgres:postgres@db/backend_dev
- SECRET_KEY_BASE=W8nGKNhNR8vKj6A4VnwN5h5h7RZvkKmZPqxqzLzYxXGQqC6HnKp2Wm8MNqKpQxZv
- MIX_ENV=dev
- NODE_NAME=backend@phoenix3
- COOKIE=dev_cookie
- CLUSTER_NODES=backend@phoenix1,backend@phoenix2,backend@phoenix3
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
ports:
- "4003:4000"
user: root
command: |
sh -c '
chown -R elixir:elixir /app/_build
su elixir -c "elixir --sname $${NODE_NAME} --cookie $${COOKIE} -S mix phx.server"
'
volumes:
- ./backend:/app
- /app/_build
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
interval: 10s
@@ -91,10 +98,12 @@ services:
client:
build:
context: ./client
dockerfile: Dockerfile
dockerfile: dev.Dockerfile
container_name: client
volumes:
- ./client:/app
ports:
- "5173:80"
- "5173:5173"
depends_on:
- nginx-lb
@@ -104,8 +113,4 @@ services:
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4318:4318" # OTLP HTTP receiver
- "4317:4317" # OTLP gRPC receiver
- "8888:8888" # Prometheus metrics
- "8889:8889" # Prometheus exporter metrics

View File

@@ -1,6 +1,4 @@
upstream phoenix_backend {
# Hash based on WebSocket handshake key for sticky sessions
# Note: Each new connection gets a new key, so reconnections may route differently
hash $http_sec_websocket_key consistent;
server phoenix1:4000 max_fails=1 fail_timeout=10s;
@@ -22,6 +20,11 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 1;
proxy_connect_timeout 1s;
proxy_read_timeout 60s;
}
location /api {
@@ -31,6 +34,11 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 3;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
location /health {